pharmacy-pos-qr-system/backend/app.py
시골약사 d715b630fe feat: 구매이력 정렬 기능 및 상품검색 시간 표시 개선
- 관리자 페이지 사용자 상세 모달에 날짜별/금액별 정렬 버튼 추가
- 구매 이력 정렬 기능 구현 (날짜순/금액순)
- 상품 검색 결과에 구매일시/적립일시 모두 표시

주요 변경사항:

1. 구매 이력 정렬 기능 (admin.html)
   - 날짜별 정렬: 최신순 정렬
   - 금액별 정렬: 구매금액 높은 순 정렬
   - 정렬 버튼 UI: 우측 정렬, 토글 방식
   - 탭 전환 시 정렬 버튼 자동 표시/숨김

2. 상품 검색 시간 표시 개선 (app.py, admin.html)
   - 구매일시: MSSQL InsertTime (실제 거래 시간)
   - 적립일시: SQLite claimed_at (QR 적립 시간)
   - 두 시간 모두 테이블에 표시 (구분 명확화)

3. UI/UX 개선
   - 정렬 버튼 스타일: search-type-btn과 동일한 패턴
   - 적립일시: 회색(#868e96)으로 구매일시와 시각적 구분
   - 정렬 상태 유지: 버튼 클릭 시 active 클래스 토글

기술 구현:
- renderPurchaseHistory() 함수로 구매 이력 동적 렌더링
- sortPurchases(type) 함수로 정렬 로직 처리
- 전역 변수로 현재 사용자 데이터 및 정렬 타입 관리
- JavaScript 배열 복사 후 정렬하여 원본 데이터 보존

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 22:06:47 +09:00

1017 lines
32 KiB
Python

"""
Flask 웹 서버 - QR 마일리지 적립
간편 적립: 전화번호 + 이름만 입력
"""
from flask import Flask, request, render_template, jsonify, redirect, url_for
import hashlib
from datetime import datetime, timezone, timedelta
import sys
import os
import logging
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()
# KST 타임존 (UTC+9)
KST = timezone(timedelta(hours=9))
def utc_to_kst_str(utc_time_str):
"""
UTC 시간 문자열을 KST 시간 문자열로 변환
Args:
utc_time_str (str): UTC 시간 문자열 (ISO 8601 형식)
Returns:
str: KST 시간 문자열 (YYYY-MM-DD HH:MM:SS)
"""
if not utc_time_str:
return None
try:
# ISO 8601 형식 파싱 ('2026-01-23T12:28:36' 또는 '2026-01-23 12:28:36')
utc_time_str = utc_time_str.replace(' ', 'T') # 공백을 T로 변환
# datetime 객체로 변환
if 'T' in utc_time_str:
utc_time = datetime.fromisoformat(utc_time_str)
else:
utc_time = datetime.fromisoformat(utc_time_str)
# UTC 타임존 설정 (naive datetime인 경우)
if utc_time.tzinfo is None:
utc_time = utc_time.replace(tzinfo=timezone.utc)
# KST로 변환
kst_time = utc_time.astimezone(KST)
# 문자열로 반환 (초 단위까지만)
return kst_time.strftime('%Y-%m-%d %H:%M:%S')
except Exception as e:
logging.error(f"시간 변환 실패: {utc_time_str}, 오류: {e}")
return utc_time_str # 변환 실패 시 원본 반환
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_raw = cursor.fetchone()
if not user_raw:
return render_template('error.html', message='등록되지 않은 전화번호입니다.')
# 사용자 정보에 KST 시간 변환 적용
user = dict(user_raw)
user['created_at'] = utc_to_kst_str(user_raw['created_at'])
# 적립 내역 조회
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_raw = cursor.fetchall()
# 거래 내역에 KST 시간 변환 적용
transactions = []
for tx in transactions_raw:
tx_dict = dict(tx)
tx_dict['created_at'] = utc_to_kst_str(tx['created_at'])
transactions.append(tx_dict)
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/user/<int:user_id>')
def admin_user_detail(user_id):
"""사용자 상세 이력 조회 - 구매 이력, 적립 이력, 구매 품목"""
try:
# 1. SQLite 연결
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# 2. 사용자 기본 정보 조회
cursor.execute("""
SELECT id, nickname, phone, mileage_balance, created_at
FROM users WHERE id = ?
""", (user_id,))
user = cursor.fetchone()
if not user:
return jsonify({
'success': False,
'message': '사용자를 찾을 수 없습니다.'
}), 404
# 3. 마일리지 이력 조회 (최근 50건)
cursor.execute("""
SELECT transaction_id, points, balance_after, reason, description, created_at
FROM mileage_ledger
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50
""", (user_id,))
mileage_history = cursor.fetchall()
# 4. 구매 이력 조회 (적립된 거래만, 최근 20건)
cursor.execute("""
SELECT transaction_id, total_amount, claimable_points, claimed_at
FROM claim_tokens
WHERE claimed_by_user_id = ?
ORDER BY claimed_at DESC
LIMIT 20
""", (user_id,))
claimed_tokens = cursor.fetchall()
# 5. 각 거래의 상품 상세 조회 (MSSQL)
purchases = []
try:
session = db_manager.get_session('PM_PRES')
for token in claimed_tokens:
transaction_id = token['transaction_id']
# SALE_MAIN에서 거래 시간 조회
sale_main_query = text("""
SELECT InsertTime
FROM SALE_MAIN
WHERE SL_NO_order = :transaction_id
""")
sale_main = session.execute(
sale_main_query,
{'transaction_id': transaction_id}
).fetchone()
# 거래 시간 추출 (MSSQL의 실제 거래 시간)
if sale_main and sale_main.InsertTime:
transaction_date = str(sale_main.InsertTime)[:16].replace('T', ' ')
else:
transaction_date = '-'
# SALE_SUB + CD_GOODS JOIN
sale_items_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
""")
items_raw = session.execute(
sale_items_query,
{'transaction_id': transaction_id}
).fetchall()
# 상품 리스트 변환
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 items_raw
]
# 상품 요약 생성 ("첫번째상품명 외 N개")
if items:
first_item_name = items[0]['name']
items_count = len(items)
if items_count == 1:
items_summary = first_item_name
else:
items_summary = f"{first_item_name}{items_count - 1}"
else:
items_summary = "상품 정보 없음"
items_count = 0
purchases.append({
'transaction_id': transaction_id,
'date': transaction_date, # MSSQL의 실제 거래 시간 사용
'amount': int(token['total_amount']),
'points': int(token['claimable_points']),
'items_summary': items_summary,
'items_count': items_count,
'items': items
})
except Exception as mssql_error:
# MSSQL 연결 실패 시 빈 배열 반환
print(f"[WARNING] MSSQL 조회 실패 (user {user_id}): {mssql_error}")
purchases = []
# 6. 응답 생성
return jsonify({
'success': True,
'user': {
'id': user['id'],
'name': user['nickname'],
'phone': user['phone'],
'balance': user['mileage_balance'],
'created_at': utc_to_kst_str(user['created_at'])
},
'mileage_history': [
{
'points': ml['points'],
'balance_after': ml['balance_after'],
'reason': ml['reason'],
'description': ml['description'],
'created_at': utc_to_kst_str(ml['created_at']),
'transaction_id': ml['transaction_id']
}
for ml in mileage_history
],
'purchases': purchases
})
except Exception as e:
return jsonify({
'success': False,
'message': f'조회 실패: {str(e)}'
}), 500
@app.route('/admin/search/user')
def admin_search_user():
"""사용자 검색 (이름/전화번호/전화번호 뒷자리)"""
query = request.args.get('q', '').strip()
search_type = request.args.get('type', 'name') # 'name', 'phone', 'phone_last'
if not query:
return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
try:
if search_type == 'phone_last':
# 전화번호 뒷자리 검색
cursor.execute("""
SELECT id, nickname, phone, mileage_balance
FROM users
WHERE phone LIKE ?
ORDER BY created_at DESC
""", (f'%{query}',))
elif search_type == 'phone':
# 전체 전화번호 검색
cursor.execute("""
SELECT id, nickname, phone, mileage_balance
FROM users
WHERE phone = ?
""", (query,))
else:
# 이름 검색
cursor.execute("""
SELECT id, nickname, phone, mileage_balance
FROM users
WHERE nickname LIKE ?
ORDER BY created_at DESC
""", (f'%{query}%',))
results = cursor.fetchall()
if not results:
return jsonify({'success': False, 'message': '검색 결과가 없습니다'}), 404
if len(results) == 1:
# 단일 매칭 - user_id만 반환
return jsonify({
'success': True,
'multiple': False,
'user_id': results[0]['id']
})
else:
# 여러 명 매칭 - 선택 모달용 데이터 반환
users = [{
'id': row['id'],
'name': row['nickname'],
'phone': row['phone'],
'balance': row['mileage_balance']
} for row in results]
return jsonify({
'success': True,
'multiple': True,
'users': users
})
except Exception as e:
return jsonify({
'success': False,
'message': f'검색 실패: {str(e)}'
}), 500
@app.route('/admin/search/product')
def admin_search_product():
"""제품 검색 - 적립자 목록 반환 (SQLite 적립자 기준)"""
query = request.args.get('q', '').strip()
if not query:
return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
try:
# 1. MSSQL에서 제품명으로 거래번호 찾기
session = db_manager.get_session('PM_PRES')
sale_items_query = text("""
SELECT DISTINCT
S.SL_NO_order,
S.SL_NM_item,
M.InsertTime
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
LEFT JOIN SALE_MAIN M ON S.SL_NO_order = M.SL_NO_order
WHERE G.GoodsName LIKE :product_name
ORDER BY M.InsertTime DESC
""")
sale_results = session.execute(sale_items_query, {
'product_name': f'%{query}%'
}).fetchall()
if not sale_results:
return jsonify({
'success': True,
'results': []
})
# 2. SQLite에서 적립된 거래만 필터링 (claimed_by_user_id IS NOT NULL)
transaction_ids = [row.SL_NO_order for row in sale_results]
placeholders = ','.join('?' * len(transaction_ids))
cursor.execute(f"""
SELECT
ct.transaction_id,
ct.total_amount,
ct.claimed_at,
ct.claimed_by_user_id,
u.nickname,
u.phone
FROM claim_tokens ct
JOIN users u ON ct.claimed_by_user_id = u.id
WHERE ct.transaction_id IN ({placeholders})
AND ct.claimed_by_user_id IS NOT NULL
ORDER BY ct.claimed_at DESC
LIMIT 50
""", transaction_ids)
claimed_results = cursor.fetchall()
# 3. 결과 조합
results = []
for claim_row in claimed_results:
# 해당 거래의 MSSQL 정보 찾기
mssql_row = next((r for r in sale_results if r.SL_NO_order == claim_row['transaction_id']), None)
if mssql_row:
results.append({
'user_id': claim_row['claimed_by_user_id'],
'user_name': claim_row['nickname'],
'user_phone': claim_row['phone'],
'purchase_date': str(mssql_row.InsertTime)[:16].replace('T', ' ') if mssql_row.InsertTime else '-', # MSSQL 실제 거래 시간
'claimed_date': str(claim_row['claimed_at'])[:16].replace('T', ' ') if claim_row['claimed_at'] else '-', # 적립 시간
'quantity': float(mssql_row.SL_NM_item or 0),
'total_amount': int(claim_row['total_amount'])
})
return jsonify({
'success': True,
'results': results
})
except Exception as e:
return jsonify({
'success': False,
'message': f'검색 실패: {str(e)}'
}), 500
@app.route('/admin/use-points', methods=['POST'])
def admin_use_points():
"""관리자 페이지에서 포인트 사용 (차감)"""
try:
data = request.get_json()
user_id = data.get('user_id')
points_to_use = data.get('points')
if not user_id or not points_to_use:
return jsonify({
'success': False,
'message': '사용자 ID와 포인트를 입력하세요.'
}), 400
if points_to_use <= 0:
return jsonify({
'success': False,
'message': '1 이상의 포인트를 입력하세요.'
}), 400
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# 1. 현재 사용자 잔액 확인
cursor.execute("""
SELECT id, nickname, mileage_balance
FROM users
WHERE id = ?
""", (user_id,))
user = cursor.fetchone()
if not user:
return jsonify({
'success': False,
'message': '사용자를 찾을 수 없습니다.'
}), 404
current_balance = user['mileage_balance']
# 2. 잔액 확인
if points_to_use > current_balance:
return jsonify({
'success': False,
'message': f'잔액({current_balance:,}P)이 부족합니다.'
}), 400
# 3. 포인트 차감
new_balance = current_balance - points_to_use
cursor.execute("""
UPDATE users
SET mileage_balance = ?
WHERE id = ?
""", (new_balance, user_id))
# 4. 마일리지 원장 기록
cursor.execute("""
INSERT INTO mileage_ledger
(user_id, transaction_id, points, balance_after, reason, description)
VALUES (?, NULL, ?, ?, 'USE', ?)
""", (
user_id,
-points_to_use, # 음수로 저장
new_balance,
f'포인트 사용 (관리자) - {points_to_use:,}P 차감'
))
conn.commit()
logging.info(f"포인트 사용 완료: 사용자 ID {user_id}, 차감 {points_to_use}P, 잔액 {new_balance}P")
return jsonify({
'success': True,
'message': f'{points_to_use:,}P 사용 완료',
'new_balance': new_balance,
'used_points': points_to_use
})
except Exception as e:
logging.error(f"포인트 사용 실패: {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_raw = cursor.fetchall()
# 시간을 KST로 변환
recent_users = []
for user in recent_users_raw:
user_dict = dict(user)
user_dict['created_at'] = utc_to_kst_str(user['created_at'])
recent_users.append(user_dict)
# 최근 적립 내역 (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_raw = cursor.fetchall()
# 시간을 KST로 변환
recent_transactions = []
for trans in recent_transactions_raw:
trans_dict = dict(trans)
trans_dict['created_at'] = utc_to_kst_str(trans['created_at'])
recent_transactions.append(trans_dict)
# 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_raw = cursor.fetchall()
# Convert only created_at (발행일), leave claimed_at (적립일) unconverted
recent_tokens = []
for token in recent_tokens_raw:
token_dict = dict(token)
token_dict['created_at'] = utc_to_kst_str(token['created_at']) # Convert 발행일
# claimed_at stays as-is (or remains None if not claimed)
recent_tokens.append(token_dict)
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)