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 %}
+
+
+
+ | ID |
+ 이름 |
+ 전화번호 |
+ 포인트 |
+ 가입일 |
+
+
+
+ {% for user in recent_users %}
+
+ | {{ 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', ' ') }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
가입한 사용자가 없습니다.
+ {% endif %}
+
+
+
+
+
+
최근 적립 내역 (50건)
+
+ {% if recent_transactions %}
+
+
+
+ | 이름 |
+ 전화번호 |
+ 포인트 |
+ 잔액 |
+ 내역 |
+ 일시 |
+
+
+
+ {% for tx in recent_transactions %}
+
+ | {{ 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', ' ') }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
적립 내역이 없습니다.
+ {% endif %}
+
+
+
+
+
+
최근 QR 발행 내역 (20건)
+
+ {% if recent_tokens %}
+
+
+
+ | 거래번호 |
+ 판매금액 |
+ 적립포인트 |
+ 상태 |
+ 발행일 |
+ 적립일 |
+
+
+
+ {% for token in recent_tokens %}
+
+ |
+
+ {{ 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 '-' }} |
+
+ {% endfor %}
+
+
+ {% 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 @@
+
+
+
+
+
+ 포인트 적립 - 청춘약국
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
적립 완료!
+
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 %}
+
+ {% 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 @@
+
+
+
+
+
+ 마이페이지 - 청춘약국
+
+
+
+
+
+
+
+
+
+
+