feat: 포인트 사용 기능 및 시간 표시 개선

- UTC to KST 시간 변환 로직 추가 (SQLite 저장 시간 표시용)
- 관리자 페이지에 포인트 사용(차감) 기능 추가
  - 사용자 상세 모달에 "포인트 사용" 버튼 추가
  - 포인트 입력 및 차감 처리
  - 마일리지 원장에 USE 타입으로 기록
- 구매 이력 시간을 MSSQL의 실제 거래 시간(InsertTime)으로 수정
- 선택적 시간 변환 적용
  - 변환: users.created_at, mileage_ledger.created_at, claim_tokens.created_at
  - 미변환: claim_tokens.claimed_at, MSSQL 거래 시간
- 관리자 페이지에 검색 기능 추가 (사이드바)
  - 사용자 검색 (이름, 전화번호, 뒷자리)
  - 제품 검색 (약품명으로 구매자 조회)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-01-23 21:54:12 +09:00
parent 7627efbdfb
commit 59a33cc249
3 changed files with 328 additions and 20 deletions

View File

@ -5,9 +5,10 @@ Flask 웹 서버 - QR 마일리지 적립
from flask import Flask, request, render_template, jsonify, redirect, url_for from flask import Flask, request, render_template, jsonify, redirect, url_for
import hashlib import hashlib
from datetime import datetime from datetime import datetime, timezone, timedelta
import sys import sys
import os import os
import logging
from sqlalchemy import text from sqlalchemy import text
# Path setup # Path setup
@ -20,6 +21,47 @@ app.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
# 데이터베이스 매니저 # 데이터베이스 매니저
db_manager = DatabaseManager() 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): def verify_claim_token(transaction_id, nonce):
""" """
@ -355,11 +397,15 @@ def my_page():
FROM users WHERE phone = ? FROM users WHERE phone = ?
""", (phone,)) """, (phone,))
user = cursor.fetchone() user_raw = cursor.fetchone()
if not user: if not user_raw:
return render_template('error.html', message='등록되지 않은 전화번호입니다.') return render_template('error.html', message='등록되지 않은 전화번호입니다.')
# 사용자 정보에 KST 시간 변환 적용
user = dict(user_raw)
user['created_at'] = utc_to_kst_str(user_raw['created_at'])
# 적립 내역 조회 # 적립 내역 조회
cursor.execute(""" cursor.execute("""
SELECT points, balance_after, reason, description, created_at SELECT points, balance_after, reason, description, created_at
@ -369,7 +415,14 @@ def my_page():
LIMIT 20 LIMIT 20
""", (user['id'],)) """, (user['id'],))
transactions = cursor.fetchall() 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) return render_template('my_page.html', user=user, transactions=transactions)
@ -506,6 +559,24 @@ def admin_user_detail(user_id):
for token in claimed_tokens: for token in claimed_tokens:
transaction_id = token['transaction_id'] 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_SUB + CD_GOODS JOIN
sale_items_query = text(""" sale_items_query = text("""
SELECT SELECT
@ -551,7 +622,7 @@ def admin_user_detail(user_id):
purchases.append({ purchases.append({
'transaction_id': transaction_id, 'transaction_id': transaction_id,
'date': str(token['claimed_at'])[:16].replace('T', ' '), 'date': transaction_date, # MSSQL의 실제 거래 시간 사용
'amount': int(token['total_amount']), 'amount': int(token['total_amount']),
'points': int(token['claimable_points']), 'points': int(token['claimable_points']),
'items_summary': items_summary, 'items_summary': items_summary,
@ -572,7 +643,7 @@ def admin_user_detail(user_id):
'name': user['nickname'], 'name': user['nickname'],
'phone': user['phone'], 'phone': user['phone'],
'balance': user['mileage_balance'], 'balance': user['mileage_balance'],
'created_at': str(user['created_at'])[:16].replace('T', ' ') 'created_at': utc_to_kst_str(user['created_at'])
}, },
'mileage_history': [ 'mileage_history': [
{ {
@ -580,7 +651,7 @@ def admin_user_detail(user_id):
'balance_after': ml['balance_after'], 'balance_after': ml['balance_after'],
'reason': ml['reason'], 'reason': ml['reason'],
'description': ml['description'], 'description': ml['description'],
'created_at': str(ml['created_at'])[:16].replace('T', ' '), 'created_at': utc_to_kst_str(ml['created_at']),
'transaction_id': ml['transaction_id'] 'transaction_id': ml['transaction_id']
} }
for ml in mileage_history for ml in mileage_history
@ -753,6 +824,93 @@ def admin_search_product():
}), 500 }), 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') @app.route('/admin')
def admin(): def admin():
"""관리자 페이지 - 전체 사용자 및 적립 현황""" """관리자 페이지 - 전체 사용자 및 적립 현황"""
@ -775,7 +933,14 @@ def admin():
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20 LIMIT 20
""") """)
recent_users = cursor.fetchall() 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건) # 최근 적립 내역 (50건)
cursor.execute(""" cursor.execute("""
@ -793,7 +958,14 @@ def admin():
ORDER BY ml.created_at DESC ORDER BY ml.created_at DESC
LIMIT 50 LIMIT 50
""") """)
recent_transactions = cursor.fetchall() 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 토큰 통계 # QR 토큰 통계
cursor.execute(""" cursor.execute("""
@ -820,7 +992,15 @@ def admin():
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20 LIMIT 20
""") """)
recent_tokens = cursor.fetchall() 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', return render_template('admin.html',
stats=stats, stats=stats,

View File

@ -550,6 +550,35 @@
</div> </div>
</div> </div>
<!-- 포인트 사용 모달 -->
<div id="usePointsModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; padding: 20px; overflow-y: auto;">
<div style="max-width: 500px; margin: 100px auto; background: #fff; border-radius: 20px; padding: 32px; position: relative;">
<button onclick="closeUsePointsModal()" style="position: absolute; top: 20px; right: 20px; background: #f1f3f5; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; font-size: 20px; color: #495057;">×</button>
<h2 style="font-size: 24px; font-weight: 700; color: #212529; margin-bottom: 24px; letter-spacing: -0.5px;">💳 포인트 사용</h2>
<div style="background: #f8f9fa; border-radius: 12px; padding: 16px; margin-bottom: 24px;">
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">현재 포인트 잔액</div>
<div id="current-balance-display" style="color: #6366f1; font-size: 24px; font-weight: 700;">0P</div>
</div>
<div style="margin-bottom: 24px;">
<label style="display: block; color: #495057; font-size: 14px; font-weight: 600; margin-bottom: 8px;">사용할 포인트</label>
<input type="number" id="points-to-use" placeholder="포인트 입력" min="1" style="width: 100%; padding: 14px; border: 2px solid #e9ecef; border-radius: 10px; font-size: 16px; font-family: 'Noto Sans KR', sans-serif; box-sizing: border-box;" onkeypress="if(event.key==='Enter') confirmUsePoints()">
<div id="use-points-error" style="color: #f03e3e; font-size: 13px; margin-top: 8px; display: none;"></div>
</div>
<div style="display: flex; gap: 12px;">
<button onclick="closeUsePointsModal()" style="flex: 1; padding: 14px; background: #f1f3f5; color: #495057; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer;">
취소
</button>
<button onclick="confirmUsePoints()" style="flex: 1; padding: 14px; background: #f03e3e; color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer;">
사용하기
</button>
</div>
</div>
</div>
<script> <script>
function showTransactionDetail(transactionId) { function showTransactionDetail(transactionId) {
document.getElementById('transactionModal').style.display = 'block'; document.getElementById('transactionModal').style.display = 'block';
@ -720,7 +749,7 @@
let html = ` let html = `
<!-- 사용자 기본 정보 --> <!-- 사용자 기본 정보 -->
<div style="background: #f8f9fa; border-radius: 12px; padding: 20px; margin-bottom: 24px;"> <div style="background: #f8f9fa; border-radius: 12px; padding: 20px; margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;"> <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
<div> <div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div> <div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div> <div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div>
@ -731,13 +760,18 @@
</div> </div>
<div> <div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">포인트 잔액</div> <div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">포인트 잔액</div>
<div style="color: #6366f1; font-size: 18px; font-weight: 700;">${user.balance.toLocaleString()}P</div> <div id="user-balance-display" style="color: #6366f1; font-size: 18px; font-weight: 700;">${user.balance.toLocaleString()}P</div>
</div> </div>
<div> <div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div> <div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div> <div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
</div> </div>
</div> </div>
<div style="text-align: right;">
<button onclick="showUsePointsModal(${user.id}, ${user.balance})" style="padding: 10px 24px; background: #f03e3e; color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
💳 포인트 사용
</button>
</div>
</div> </div>
<!-- 탭 메뉴 --> <!-- 탭 메뉴 -->
@ -899,11 +933,98 @@
document.getElementById('tab-content-' + tabName).style.display = 'block'; document.getElementById('tab-content-' + tabName).style.display = 'block';
} }
// ESC 키로 사용자 모달 닫기 // ===== 포인트 사용 기능 =====
let currentUserId = null;
let currentUserBalance = 0;
function showUsePointsModal(userId, balance) {
currentUserId = userId;
currentUserBalance = balance;
// 현재 잔액 표시
document.getElementById('current-balance-display').innerText = balance.toLocaleString() + 'P';
// 입력 필드 초기화
document.getElementById('points-to-use').value = '';
document.getElementById('use-points-error').style.display = 'none';
// 모달 표시
document.getElementById('usePointsModal').style.display = 'block';
// 입력 필드에 포커스
setTimeout(() => {
document.getElementById('points-to-use').focus();
}, 100);
}
function closeUsePointsModal() {
document.getElementById('usePointsModal').style.display = 'none';
currentUserId = null;
currentUserBalance = 0;
}
function confirmUsePoints() {
const pointsInput = document.getElementById('points-to-use');
const points = parseInt(pointsInput.value);
const errorDiv = document.getElementById('use-points-error');
// 유효성 검사
if (!points || points <= 0) {
errorDiv.innerText = '1 이상의 포인트를 입력하세요.';
errorDiv.style.display = 'block';
return;
}
if (points > currentUserBalance) {
errorDiv.innerText = `잔액(${currentUserBalance.toLocaleString()}P)보다 많이 사용할 수 없습니다.`;
errorDiv.style.display = 'block';
return;
}
// API 호출
fetch('/admin/use-points', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: currentUserId,
points: points
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 성공 - userId 저장 후 모달 닫고 사용자 상세 정보 새로고침
const userId = currentUserId; // closeUsePointsModal() 전에 저장
closeUsePointsModal();
showUserDetail(userId);
// 성공 메시지 표시 (선택사항)
alert(`${points.toLocaleString()}P 사용 완료!\n남은 잔액: ${data.new_balance.toLocaleString()}P`);
} else {
// 실패 - 에러 메시지 표시
errorDiv.innerText = data.message || '포인트 사용에 실패했습니다.';
errorDiv.style.display = 'block';
}
})
.catch(error => {
errorDiv.innerText = '네트워크 오류가 발생했습니다.';
errorDiv.style.display = 'block';
});
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
// 포인트 사용 모달이 열려있으면 먼저 닫기
if (document.getElementById('usePointsModal').style.display === 'block') {
closeUsePointsModal();
} else {
closeUserModal(); closeUserModal();
} }
}
}); });
// 사용자 모달 배경 클릭 시 닫기 // 사용자 모달 배경 클릭 시 닫기
@ -913,6 +1034,13 @@
} }
}); });
// 포인트 사용 모달 배경 클릭 시 닫기
document.getElementById('usePointsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeUsePointsModal();
}
});
// ===== 검색 기능 ===== // ===== 검색 기능 =====
let currentSearchType = 'user'; // 'user' 또는 'product' let currentSearchType = 'user'; // 'user' 또는 'product'

View File

@ -246,7 +246,7 @@
{% if tx.description %} {% if tx.description %}
<div class="transaction-desc">{{ tx.description }}</div> <div class="transaction-desc">{{ tx.description }}</div>
{% endif %} {% endif %}
<div class="transaction-date">{{ tx.created_at[:16].replace('T', ' ') }}</div> <div class="transaction-date">{{ tx.created_at }}</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>