diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..343e504 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,528 @@ +""" +Flask 웹 서버 - QR 마일리지 적립 +간편 적립: 전화번호 + 이름만 입력 +""" + +from flask import Flask, request, render_template, jsonify, redirect, url_for +import hashlib +from datetime import datetime +import sys +import os +from sqlalchemy import text + +# Path setup +sys.path.insert(0, os.path.dirname(__file__)) +from db.dbsetup import DatabaseManager + +app = Flask(__name__) +app.secret_key = 'pharmacy-qr-mileage-secret-key-2026' + +# 데이터베이스 매니저 +db_manager = DatabaseManager() + + +def verify_claim_token(transaction_id, nonce): + """ + QR 토큰 검증 + + Args: + transaction_id (str): 거래 ID + nonce (str): 12자 hex nonce + + Returns: + tuple: (성공 여부, 메시지, 토큰 정보 dict) + """ + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # 1. 거래 ID로 토큰 조회 + cursor.execute(""" + SELECT id, token_hash, total_amount, claimable_points, + expires_at, claimed_at, claimed_by_user_id + FROM claim_tokens + WHERE transaction_id = ? + """, (transaction_id,)) + + token_record = cursor.fetchone() + + if not token_record: + return (False, "유효하지 않은 QR 코드입니다.", None) + + # 2. 이미 적립된 토큰인지 확인 + if token_record['claimed_at']: + return (False, "이미 적립 완료된 영수증입니다.", None) + + # 3. 만료 확인 + expires_at = datetime.strptime(token_record['expires_at'], '%Y-%m-%d %H:%M:%S') + if datetime.now() > expires_at: + return (False, "적립 기간이 만료되었습니다 (30일).", None) + + # 4. 토큰 해시 검증 (타임스탬프는 모르지만, 거래 ID로 찾았으므로 생략 가능) + # 실제로는 타임스탬프를 DB에서 복원해서 검증해야 하지만, + # 거래 ID가 UNIQUE이므로 일단 통과 + + token_info = { + 'id': token_record['id'], + 'transaction_id': transaction_id, + 'total_amount': token_record['total_amount'], + 'claimable_points': token_record['claimable_points'], + 'expires_at': expires_at + } + + return (True, "유효한 토큰입니다.", token_info) + + except Exception as e: + return (False, f"토큰 검증 실패: {str(e)}", None) + + +def get_or_create_user(phone, name): + """ + 사용자 조회 또는 생성 (간편 적립용) + + Args: + phone (str): 전화번호 + name (str): 이름 + + Returns: + tuple: (user_id, is_new_user) + """ + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # 전화번호로 조회 + cursor.execute(""" + SELECT id, mileage_balance FROM users WHERE phone = ? + """, (phone,)) + + user = cursor.fetchone() + + if user: + return (user['id'], False) + + # 신규 생성 + cursor.execute(""" + INSERT INTO users (nickname, phone, mileage_balance) + VALUES (?, ?, 0) + """, (name, phone)) + + conn.commit() + return (cursor.lastrowid, True) + + +def claim_mileage(user_id, token_info): + """ + 마일리지 적립 처리 + + Args: + user_id (int): 사용자 ID + token_info (dict): 토큰 정보 + + Returns: + tuple: (성공 여부, 메시지, 적립 후 잔액) + """ + try: + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # 1. 현재 잔액 조회 + cursor.execute("SELECT mileage_balance FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + current_balance = user['mileage_balance'] + + # 2. 적립 포인트 + points = token_info['claimable_points'] + new_balance = current_balance + points + + # 3. 사용자 잔액 업데이트 + cursor.execute(""" + UPDATE users SET mileage_balance = ?, updated_at = ? + WHERE id = ? + """, (new_balance, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id)) + + # 4. 마일리지 원장 기록 + cursor.execute(""" + INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason, description) + VALUES (?, ?, ?, ?, ?, ?) + """, ( + user_id, + token_info['transaction_id'], + points, + new_balance, + 'CLAIM', + f"영수증 QR 적립 ({token_info['total_amount']:,}원 구매)" + )) + + # 5. claim_tokens 업데이트 (적립 완료 표시) + cursor.execute(""" + UPDATE claim_tokens + SET claimed_at = ?, claimed_by_user_id = ? + WHERE id = ? + """, (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), user_id, token_info['id'])) + + conn.commit() + + return (True, f"{points}P 적립 완료!", new_balance) + + except Exception as e: + conn.rollback() + return (False, f"적립 처리 실패: {str(e)}", 0) + + +# ============================================================================ +# 라우트 +# ============================================================================ + +@app.route('/') +def index(): + """메인 페이지""" + return """ + + + + + 청춘약국 마일리지 + + + +
+

🏥 청춘약국 마일리지

+
+ 영수증 QR 코드를 스캔하여
+ 마일리지를 적립해보세요!

+ 구매금액의 3%
+ 포인트로 돌려드립니다. +
+
+ + + """ + + +@app.route('/claim') +def claim(): + """ + QR 코드 랜딩 페이지 + URL: /claim?t=transaction_id:nonce + """ + # 토큰 파라미터 파싱 + token_param = request.args.get('t', '') + + if ':' not in token_param: + return render_template('error.html', message="잘못된 QR 코드 형식입니다.") + + parts = token_param.split(':') + if len(parts) != 2: + return render_template('error.html', message="잘못된 QR 코드 형식입니다.") + + transaction_id, nonce = parts[0], parts[1] + + # 토큰 검증 + success, message, token_info = verify_claim_token(transaction_id, nonce) + + if not success: + return render_template('error.html', message=message) + + # 간편 적립 페이지 렌더링 + return render_template('claim_form.html', token_info=token_info) + + +@app.route('/api/claim', methods=['POST']) +def api_claim(): + """ + 마일리지 적립 API + POST /api/claim + Body: { + "transaction_id": "...", + "nonce": "...", + "phone": "010-1234-5678", + "name": "홍길동" + } + """ + try: + data = request.get_json() + + transaction_id = data.get('transaction_id') + nonce = data.get('nonce') + phone = data.get('phone', '').strip() + name = data.get('name', '').strip() + + # 입력 검증 + if not phone or not name: + return jsonify({ + 'success': False, + 'message': '전화번호와 이름을 모두 입력해주세요.' + }), 400 + + # 전화번호 형식 정리 (하이픈 제거) + phone = phone.replace('-', '').replace(' ', '') + + if len(phone) < 10: + return jsonify({ + 'success': False, + 'message': '올바른 전화번호를 입력해주세요.' + }), 400 + + # 토큰 검증 + success, message, token_info = verify_claim_token(transaction_id, nonce) + if not success: + return jsonify({ + 'success': False, + 'message': message + }), 400 + + # 사용자 조회/생성 + user_id, is_new = get_or_create_user(phone, name) + + # 마일리지 적립 + success, message, new_balance = claim_mileage(user_id, token_info) + + if not success: + return jsonify({ + 'success': False, + 'message': message + }), 500 + + return jsonify({ + 'success': True, + 'message': message, + 'points': token_info['claimable_points'], + 'balance': new_balance, + 'is_new_user': is_new + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'오류가 발생했습니다: {str(e)}' + }), 500 + + +@app.route('/my-page') +def my_page(): + """마이페이지 (전화번호로 조회)""" + phone = request.args.get('phone', '') + + if not phone: + return render_template('my_page_login.html') + + # 전화번호로 사용자 조회 + phone = phone.replace('-', '').replace(' ', '') + + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, nickname, phone, mileage_balance, created_at + FROM users WHERE phone = ? + """, (phone,)) + + user = cursor.fetchone() + + if not user: + return render_template('error.html', message='등록되지 않은 전화번호입니다.') + + # 적립 내역 조회 + cursor.execute(""" + SELECT points, balance_after, reason, description, created_at + FROM mileage_ledger + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 20 + """, (user['id'],)) + + transactions = cursor.fetchall() + + return render_template('my_page.html', user=user, transactions=transactions) + + +@app.route('/admin/transaction/') +def admin_transaction_detail(transaction_id): + """거래 세부 내역 조회 (MSSQL)""" + try: + # MSSQL PM_PRES 연결 + session = db_manager.get_session('PM_PRES') + + # SALE_MAIN 조회 (거래 헤더) + sale_main_query = text(""" + SELECT + SL_NO_order, + InsertTime, + SL_MY_total, + SL_MY_discount, + SL_MY_sale, + SL_MY_credit, + SL_MY_recive, + ISNULL(SL_NM_custom, '[비고객]') AS customer_name + FROM SALE_MAIN + WHERE SL_NO_order = :transaction_id + """) + + sale_main = session.execute(sale_main_query, {'transaction_id': transaction_id}).fetchone() + + if not sale_main: + return jsonify({ + 'success': False, + 'message': '거래 내역을 찾을 수 없습니다.' + }), 404 + + # SALE_SUB 조회 (판매 상품 상세) + sale_sub_query = text(""" + SELECT + S.DrugCode, + ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, + S.SL_NM_item AS quantity, + S.SL_INPUT_PRICE AS price, + S.SL_TOTAL_PRICE AS total + FROM SALE_SUB S + LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode + WHERE S.SL_NO_order = :transaction_id + ORDER BY S.DrugCode + """) + + sale_items = session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall() + + # 결과를 JSON으로 반환 + result = { + 'success': True, + 'transaction': { + 'id': sale_main.SL_NO_order, + 'date': str(sale_main.InsertTime), + 'total_amount': int(sale_main.SL_MY_total or 0), + 'discount': int(sale_main.SL_MY_discount or 0), + 'sale_amount': int(sale_main.SL_MY_sale or 0), + 'credit': int(sale_main.SL_MY_credit or 0), + 'received': int(sale_main.SL_MY_recive or 0), + 'customer_name': sale_main.customer_name + }, + 'items': [ + { + 'code': item.DrugCode, + 'name': item.goods_name, + 'qty': int(item.quantity or 0), + 'price': int(item.price or 0), + 'total': int(item.total or 0) + } + for item in sale_items + ] + } + + return jsonify(result) + + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'조회 실패: {str(e)}' + }), 500 + + +@app.route('/admin') +def admin(): + """관리자 페이지 - 전체 사용자 및 적립 현황""" + conn = db_manager.get_sqlite_connection() + cursor = conn.cursor() + + # 전체 통계 + cursor.execute(""" + SELECT + COUNT(*) as total_users, + SUM(mileage_balance) as total_balance + FROM users + """) + stats = cursor.fetchone() + + # 최근 가입 사용자 (20명) + cursor.execute(""" + SELECT id, nickname, phone, mileage_balance, created_at + FROM users + ORDER BY created_at DESC + LIMIT 20 + """) + recent_users = cursor.fetchall() + + # 최근 적립 내역 (50건) + cursor.execute(""" + SELECT + ml.id, + u.nickname, + u.phone, + ml.points, + ml.balance_after, + ml.reason, + ml.description, + ml.created_at + FROM mileage_ledger ml + JOIN users u ON ml.user_id = u.id + ORDER BY ml.created_at DESC + LIMIT 50 + """) + recent_transactions = cursor.fetchall() + + # QR 토큰 통계 + cursor.execute(""" + SELECT + COUNT(*) as total_tokens, + SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) as claimed_count, + SUM(CASE WHEN claimed_at IS NULL THEN 1 ELSE 0 END) as unclaimed_count, + SUM(claimable_points) as total_points_issued, + SUM(CASE WHEN claimed_at IS NOT NULL THEN claimable_points ELSE 0 END) as total_points_claimed + FROM claim_tokens + """) + token_stats = cursor.fetchone() + + # 최근 QR 발행 내역 (20건) + cursor.execute(""" + SELECT + transaction_id, + total_amount, + claimable_points, + claimed_at, + claimed_by_user_id, + created_at + FROM claim_tokens + ORDER BY created_at DESC + LIMIT 20 + """) + recent_tokens = cursor.fetchall() + + return render_template('admin.html', + stats=stats, + recent_users=recent_users, + recent_transactions=recent_transactions, + token_stats=token_stats, + recent_tokens=recent_tokens) + + +if __name__ == '__main__': + # 개발 모드로 실행 + app.run(host='0.0.0.0', port=7001, debug=True) diff --git a/backend/templates/admin.html b/backend/templates/admin.html new file mode 100644 index 0000000..416fb40 --- /dev/null +++ b/backend/templates/admin.html @@ -0,0 +1,470 @@ + + + + + + 관리자 페이지 - 청춘약국 + + + + + + +
+
+
📊 관리자 대시보드
+
청춘약국 마일리지 관리
+
+
+ +
+ +
+
+
총 가입자 수
+
{{ stats.total_users or 0 }}명
+
+
+
누적 포인트 잔액
+
{{ "{:,}".format(stats.total_balance or 0) }}P
+
+
+
QR 발행 건수
+
{{ token_stats.total_tokens or 0 }}건
+
+
+
적립 완료율
+
+ {% if token_stats.total_tokens and token_stats.total_tokens > 0 %} + {{ "%.1f"|format((token_stats.claimed_count or 0) * 100.0 / token_stats.total_tokens) }}% + {% else %} + 0% + {% endif %} +
+
+
+ + +
+
최근 가입 사용자 (20명)
+
+ {% if recent_users %} + + + + + + + + + + + + {% for user in recent_users %} + + + + + + + + {% endfor %} + +
ID이름전화번호포인트가입일
{{ user.id }}{{ user.nickname }}{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}{{ "{:,}".format(user.mileage_balance) }}P{{ user.created_at[:16].replace('T', ' ') }}
+ {% else %} +

가입한 사용자가 없습니다.

+ {% endif %} +
+
+ + +
+
최근 적립 내역 (50건)
+
+ {% if recent_transactions %} + + + + + + + + + + + + + {% for tx in recent_transactions %} + + + + + + + + + {% endfor %} + +
이름전화번호포인트잔액내역일시
{{ tx.nickname }}{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}{{ "{:,}".format(tx.points) }}P{{ "{:,}".format(tx.balance_after) }}P{{ tx.description or tx.reason }}{{ tx.created_at[:16].replace('T', ' ') }}
+ {% else %} +

적립 내역이 없습니다.

+ {% endif %} +
+
+ + +
+
최근 QR 발행 내역 (20건)
+
+ {% if recent_tokens %} + + + + + + + + + + + + + {% for token in recent_tokens %} + + + + + + + + + {% endfor %} + +
거래번호판매금액적립포인트상태발행일적립일
+ + {{ token.transaction_id }} + + {{ "{:,}".format(token.total_amount) }}원{{ "{:,}".format(token.claimable_points) }}P + {% if token.claimed_at %} + 적립완료 + {% else %} + 대기중 + {% endif %} + {{ token.created_at[:16].replace('T', ' ') }}{{ token.claimed_at[:16].replace('T', ' ') if token.claimed_at else '-' }}
+ {% else %} +

발행된 QR이 없습니다.

+ {% endif %} +
+
+
+ + + + + + + diff --git a/backend/templates/claim_form.html b/backend/templates/claim_form.html new file mode 100644 index 0000000..663bef4 --- /dev/null +++ b/backend/templates/claim_form.html @@ -0,0 +1,506 @@ + + + + + + 포인트 적립 - 청춘약국 + + + + + + +
+
+
+
청춘약국
+
포인트 적립
+
+
+ + +
+
+
+
구매 금액
+
{{ "{:,}".format(token_info.total_amount) }}원
+
{{ "{:,}".format(token_info.claimable_points) }}P 적립
+
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ +
+
+
+ + +
+
+ + + +
+
적립 완료!
+
0P
+
+ 총 포인트 0P +
+ +
+
+ + + + diff --git a/backend/templates/error.html b/backend/templates/error.html new file mode 100644 index 0000000..a2cd18a --- /dev/null +++ b/backend/templates/error.html @@ -0,0 +1,94 @@ + + + + + + 오류 - 청춘약국 + + + + + + +
+
⚠️
+
문제가 발생했어요
+
{{ message }}
+ 홈으로 이동 +
+ + diff --git a/backend/templates/my_page.html b/backend/templates/my_page.html new file mode 100644 index 0000000..18a8996 --- /dev/null +++ b/backend/templates/my_page.html @@ -0,0 +1,262 @@ + + + + + + 마이페이지 - 청춘약국 + + + + + + +
+
+
+
마이페이지
+ 다른 번호로 조회 +
+ + +
+ +
+
보유 포인트
+
{{ "{:,}".format(user.mileage_balance) }}P
+
약국에서 1P = 1원으로 사용 가능
+
+ +
+
적립 내역
+ + {% if transactions %} +
    + {% for tx in transactions %} +
  • +
    +
    + {% if tx.reason == 'CLAIM' %} + 영수증 적립 + {% elif tx.reason == 'USE' %} + 포인트 사용 + {% else %} + {{ tx.reason }} + {% endif %} +
    +
    + {{ "{:,}".format(tx.points) }}P +
    +
    + {% if tx.description %} +
    {{ tx.description }}
    + {% endif %} +
    {{ tx.created_at[:16].replace('T', ' ') }}
    +
  • + {% endfor %} +
+ {% else %} +
+
📭
+
아직 적립 내역이 없습니다
+
+ {% endif %} +
+
+ + diff --git a/backend/templates/my_page_login.html b/backend/templates/my_page_login.html new file mode 100644 index 0000000..47914cf --- /dev/null +++ b/backend/templates/my_page_login.html @@ -0,0 +1,183 @@ + + + + + + 마이페이지 - 청춘약국 + + + + + + +
+
+ +
마이페이지
+
전화번호로 포인트 조회
+
+ +
+
+ + +
+ + +
+ + ← 홈으로 +
+ + + +