- 부정확한 '수금' 레이블을 '공급가액'으로 수정 - 부가세 (SL_MY_rec_vat) 필드 추가 조회 및 표시 - 공급가액 + 부가세 = 판매 금액 구조로 명확화 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
539 lines
16 KiB
Python
539 lines
16 KiB
Python
"""
|
|
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 """
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>청춘약국 마일리지</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Malgun Gothic', sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
margin: 0;
|
|
padding: 20px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
}
|
|
.container {
|
|
background: white;
|
|
border-radius: 20px;
|
|
padding: 40px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
max-width: 400px;
|
|
width: 100%;
|
|
text-align: center;
|
|
}
|
|
h1 {
|
|
color: #667eea;
|
|
margin-bottom: 30px;
|
|
font-size: 28px;
|
|
}
|
|
.info {
|
|
color: #666;
|
|
line-height: 1.8;
|
|
margin-bottom: 30px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🏥 청춘약국 마일리지</h1>
|
|
<div class="info">
|
|
영수증 QR 코드를 스캔하여<br>
|
|
마일리지를 적립해보세요!<br><br>
|
|
<strong>구매금액의 3%</strong>를<br>
|
|
포인트로 돌려드립니다.
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
@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()
|
|
privacy_consent = data.get('privacy_consent', False)
|
|
|
|
# 입력 검증
|
|
if not phone or not name:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': '전화번호와 이름을 모두 입력해주세요.'
|
|
}), 400
|
|
|
|
# 개인정보 동의 검증
|
|
if not privacy_consent:
|
|
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/<transaction_id>')
|
|
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,
|
|
SL_MY_rec_vat,
|
|
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_NM_cost_a 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),
|
|
'supply_value': int(sale_main.SL_MY_recive or 0),
|
|
'vat': int(sale_main.SL_MY_rec_vat 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)
|