feat: QR 토큰 품목 상세 전송 지원 (items 파라미터)

This commit is contained in:
thug0bin
2026-03-29 12:37:36 +09:00
parent 3871154509
commit 21e1c3adfa
13 changed files with 3955 additions and 52 deletions

View File

@@ -89,6 +89,9 @@ app.register_blueprint(wholesaler_config_bp)
from order_api import order_bp
app.register_blueprint(order_bp)
from order_recommendation import order_recommendation_bp
app.register_blueprint(order_recommendation_bp)
# 데이터베이스 매니저
db_manager = DatabaseManager()
@@ -2791,22 +2794,21 @@ def api_kiosk_trigger():
from utils.qr_token_generator import QR_BASE_URL
qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}"
else:
# 새 토큰 생성
from utils.qr_token_generator import generate_claim_token, save_token_to_db
token_info = generate_claim_token(transaction_id, float(amount))
success, error = save_token_to_db(
transaction_id,
token_info['token_hash'],
float(amount),
token_info['claimable_points'],
token_info['expires_at'],
token_info['pharmacy_id']
)
if not success:
return jsonify({'success': False, 'message': error}), 500
# 새 토큰 생성 + 서버 동기화 (v2)
from utils.qr_token_generator import generate_and_sync_token
token_info = generate_and_sync_token(transaction_id, float(amount), "P0001")
if not token_info.get('local_saved'):
return jsonify({'success': False, 'message': token_info.get('local_error', 'DB 저장 실패')}), 500
claimable_points = token_info['claimable_points']
qr_url = token_info['qr_url']
# 서버 동기화 로그 (실패해도 계속 진행)
if token_info.get('synced'):
print(f"[QR] 서버 동기화 완료: {transaction_id} → ID:{token_info.get('server_token_id')}")
else:
print(f"[QR] 서버 동기화 실패 (오프라인?): {transaction_id}")
# MSSQL에서 구매 품목 조회
sale_items = []
@@ -4846,6 +4848,191 @@ def admin_rx_usage():
return render_template('admin_rx_usage.html')
@app.route('/admin/price-trend')
def admin_price_trend():
"""가격 변동 추이 분석 페이지"""
return render_template('admin_price_trend.html')
@app.route('/api/price-trend/search')
def api_price_trend_search():
"""
가격 추이 분석용 약품 검색 API (자동완성)
GET /api/price-trend/search?q=검색어&limit=15
"""
try:
query = request.args.get('q', '').strip()
limit = min(int(request.args.get('limit', 15)), 30) # 최대 30개
if not query or len(query) < 2:
return jsonify({'success': True, 'items': []})
mssql_session = db_manager.get_session('PM_PRES')
# SALE_sub에서 판매 기록이 있는 약품 검색 (TOP 값 직접 삽입)
search_query = text(f"""
SELECT TOP {limit}
S.BARCODE as barcode,
S.DrugCode as drug_code,
ISNULL(G.GoodsName, '알 수 없음') as product_name,
COUNT(*) as sale_count
FROM SALE_sub S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.BARCODE IS NOT NULL AND S.BARCODE != ''
AND (S.BARCODE LIKE :query OR G.GoodsName LIKE :query_like)
GROUP BY S.BARCODE, S.DrugCode, G.GoodsName
ORDER BY COUNT(*) DESC
""")
results = mssql_session.execute(search_query, {
'query': query,
'query_like': f'%{query}%'
}).fetchall()
items = []
for row in results:
items.append({
'barcode': row[0],
'drug_code': row[1],
'product_name': row[2],
'sale_count': int(row[3])
})
return jsonify({'success': True, 'items': items})
except Exception as e:
logging.error(f"가격 추이 검색 실패: {e}")
return jsonify({'success': False, 'error': str(e), 'items': []})
@app.route('/api/price-trend')
def api_price_trend():
"""
제품별 가격 변동 추이 조회 API
GET /api/price-trend?query=바코드또는약품명&period=365
"""
try:
query = request.args.get('query', '').strip()
period = int(request.args.get('period', 365)) # 기간 (일)
if not query:
return jsonify({'success': False, 'error': '검색어를 입력하세요'})
mssql_session = db_manager.get_session('PM_PRES')
# 기간 계산
if period > 0:
from datetime import datetime, timedelta
start_date = (datetime.now() - timedelta(days=period)).strftime('%Y%m%d')
date_condition = f"AND S.SL_DT_appl >= '{start_date}'"
else:
date_condition = ""
# 바코드로 먼저 검색, 없으면 약품명으로 검색
barcode_query = text(f"""
SELECT TOP 1 BARCODE, DrugCode
FROM SALE_sub
WHERE BARCODE = :query OR DrugCode = :query
""")
result = mssql_session.execute(barcode_query, {'query': query}).fetchone()
if result:
barcode = result[0]
drug_code = result[1]
else:
# 약품명으로 검색
name_query = text("""
SELECT TOP 1 S.BARCODE, S.DrugCode
FROM SALE_sub S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE G.GoodsName LIKE :query
AND S.BARCODE IS NOT NULL AND S.BARCODE != ''
""")
result = mssql_session.execute(name_query, {'query': f'%{query}%'}).fetchone()
if not result:
return jsonify({'success': False, 'error': f'"{query}"에 대한 판매 기록이 없습니다'})
barcode = result[0]
drug_code = result[1]
# 약품명 조회
name_query = text("""
SELECT GoodsName FROM PM_DRUG.dbo.CD_GOODS WHERE DrugCode = :drug_code
""")
name_result = mssql_session.execute(name_query, {'drug_code': drug_code}).fetchone()
product_name = name_result[0] if name_result else '알 수 없음'
# 일별 가격/마진 데이터 조회
trend_query = text(f"""
SELECT
SL_DT_appl as date,
AVG(SL_NM_cost_a) as avg_price,
AVG(INPRICE) as avg_cost,
AVG(SL_NM_cost_a - INPRICE) as avg_margin,
CASE
WHEN AVG(SL_NM_cost_a) > 0
THEN ROUND((AVG(SL_NM_cost_a) - AVG(INPRICE)) / AVG(SL_NM_cost_a) * 100, 1)
ELSE 0
END as margin_rate,
COUNT(*) as count
FROM SALE_sub S
WHERE S.BARCODE = :barcode
{date_condition}
GROUP BY SL_DT_appl
ORDER BY SL_DT_appl
""")
results = mssql_session.execute(trend_query, {'barcode': barcode}).fetchall()
if not results:
return jsonify({'success': False, 'error': '해당 기간에 판매 기록이 없습니다'})
# 데이터 변환
data = []
for row in results:
data.append({
'date': row[0],
'avg_price': float(row[1] or 0),
'avg_cost': float(row[2] or 0),
'avg_margin': float(row[3] or 0),
'margin_rate': float(row[4] or 0),
'count': int(row[5] or 0)
})
# 통계 계산
prices = [d['avg_price'] for d in data]
costs = [d['avg_cost'] for d in data]
margins = [d['margin_rate'] for d in data]
stats = {
'current_price': data[-1]['avg_price'] if data else 0,
'current_cost': data[-1]['avg_cost'] if data else 0,
'current_margin': data[-1]['margin_rate'] if data else 0,
'min_price': min(prices) if prices else 0,
'max_price': max(prices) if prices else 0,
'min_cost': min(costs) if costs else 0,
'max_cost': max(costs) if costs else 0,
'min_margin': min(margins) if margins else 0,
'max_margin': max(margins) if margins else 0,
'total_count': sum(d['count'] for d in data),
'first_date': data[0]['date'] if data else '',
'last_date': data[-1]['date'] if data else ''
}
return jsonify({
'success': True,
'barcode': barcode,
'product_name': product_name,
'data': data,
'stats': stats
})
except Exception as e:
logging.error(f"가격 변동 추이 조회 실패: {e}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/usage')
def api_usage():
"""
@@ -7160,15 +7347,23 @@ def api_admin_pos_live_detail(order_no):
mssql_conn.close()
@app.route('/api/customers/<cus_code>/mileage')
def api_customer_mileage(cus_code):
# ==============================================================================
# MSSQL 회원번호(CUSCODE) ↔ SQLite user 맵핑 공통 함수
# ==============================================================================
def get_sqlite_user_by_cuscode(cus_code):
"""
고객 마일리지 조회 API (비동기)
- CD_PERSON에서 이름+전화번호 조회
- SQLite users와 이름+전화뒤4자리로 매칭
MSSQL 회원번호(CUSCODE)로 SQLite user 조회
맵핑 방식:
1. CD_PERSON에서 CUSCODE로 이름+전화번호 조회
2. SQLite users에서 이름+전화뒤4자리로 매칭
Returns:
dict: {'id', 'nickname', 'phone', 'mileage_balance'} 또는 None
"""
if not cus_code or cus_code == '0000000000':
return jsonify({'success': False, 'mileage': None})
return None
mssql_conn = None
try:
@@ -7185,7 +7380,7 @@ def api_customer_mileage(cus_code):
row = cursor.fetchone()
if not row:
return jsonify({'success': False, 'mileage': None})
return None
name, phone1, phone2, phone3 = row
phone = phone1 or phone2 or phone3 or ''
@@ -7193,14 +7388,14 @@ def api_customer_mileage(cus_code):
last4 = phone_digits[-4:] if len(phone_digits) >= 4 else ''
if not name or not last4:
return jsonify({'success': False, 'mileage': None})
return None
# 2. SQLite에서 이름+전화뒤4자리로 매칭
sqlite_conn = db_manager.get_sqlite_connection()
sqlite_cursor = sqlite_conn.cursor()
sqlite_cursor.execute("""
SELECT nickname, phone, mileage_balance
SELECT id, nickname, phone, mileage_balance
FROM users
""")
@@ -7209,22 +7404,108 @@ def api_customer_mileage(cus_code):
user_last4 = user_phone[-4:] if len(user_phone) >= 4 else ''
if user['nickname'] == name and user_last4 == last4:
return jsonify({
'success': True,
'mileage': user['mileage_balance'] or 0,
'name': name
})
return dict(user)
return jsonify({'success': False, 'mileage': None})
return None
except Exception as e:
logging.error(f"마일리지 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
logging.error(f"CUSCODE→SQLite 맵핑 오류: {e}")
return None
finally:
if mssql_conn:
mssql_conn.close()
@app.route('/api/customers/<cus_code>/mileage')
def api_customer_mileage(cus_code):
"""
고객 마일리지 조회 API (비동기)
- CD_PERSON에서 이름+전화번호 조회
- SQLite users와 이름+전화뒤4자리로 매칭
"""
user = get_sqlite_user_by_cuscode(cus_code)
if user:
return jsonify({
'success': True,
'mileage': user['mileage_balance'] or 0,
'name': user['nickname']
})
else:
return jsonify({'success': False, 'mileage': None})
@app.route('/api/customers/<cus_code>/pets')
def api_customer_pets(cus_code):
"""
고객 반려동물 조회 API
- MSSQL 회원번호(CUSCODE)로 SQLite user 맵핑
- 해당 user의 반려동물 목록 반환
"""
user = get_sqlite_user_by_cuscode(cus_code)
if not user:
return jsonify({
'success': False,
'message': '매칭된 회원이 없습니다',
'pets': []
})
try:
sqlite_conn = db_manager.get_sqlite_connection()
cursor = sqlite_conn.cursor()
cursor.execute("""
SELECT id, name, species, breed, gender, birth_date,
age_months, weight, photo_url, notes, created_at
FROM pets
WHERE user_id = ? AND is_active = TRUE
ORDER BY created_at DESC
""", (user['id'],))
# 이미지 URL 베이스 (외부 접근용, https 강제)
base_url = request.host_url.rstrip('/').replace('http://', 'https://')
pets = []
for row in cursor.fetchall():
species = row['species']
species_label = '강아지 🐕' if species == 'dog' else ('고양이 🐈' if species == 'cat' else '기타')
# photo_url을 절대 URL로 변환
photo_url = row['photo_url']
if photo_url and photo_url.startswith('/'):
photo_url = base_url + photo_url
pets.append({
'id': row['id'],
'name': row['name'],
'species': species,
'species_label': species_label,
'breed': row['breed'],
'gender': row['gender'],
'birth_date': row['birth_date'],
'age_months': row['age_months'],
'weight': float(row['weight']) if row['weight'] else None,
'photo_url': photo_url,
'notes': row['notes']
})
return jsonify({
'success': True,
'user_id': user['id'],
'user_name': user['nickname'],
'pets': pets
})
except Exception as e:
logging.error(f"반려동물 조회 오류: {e}")
return jsonify({
'success': False,
'error': str(e),
'pets': []
}), 500
@app.route('/api/customers/search')
def api_customers_search():
"""
@@ -7443,8 +7724,8 @@ def api_admin_qr_generate():
if not order_no:
return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400
# 기존 모듈 import
from utils.qr_token_generator import generate_claim_token, save_token_to_db
# 기존 모듈 import (v2: 서버 동기화 포함)
from utils.qr_token_generator import generate_and_sync_token
from utils.qr_label_printer import print_qr_label
# 거래 시간 조회 (MSSQL)
@@ -7465,21 +7746,17 @@ def api_admin_qr_generate():
if amount <= 0:
amount = float(row[1]) if row[1] else 0
# 1. 토큰 생성
token_info = generate_claim_token(order_no, amount)
# 1. 토큰 생성 + 로컬 저장 + 서버 동기화 (v2)
token_info = generate_and_sync_token(order_no, amount, "P0001")
# 2. DB 저장
success, error = save_token_to_db(
order_no,
token_info['token_hash'],
amount,
token_info['claimable_points'],
token_info['expires_at'],
token_info['pharmacy_id']
)
if not token_info.get('local_saved'):
return jsonify({'success': False, 'error': token_info.get('local_error', 'DB 저장 실패')}), 400
if not success:
return jsonify({'success': False, 'error': error}), 400
# 서버 동기화 로그
if token_info.get('synced'):
print(f"[QR] 서버 동기화 완료: {order_no} → ID:{token_info.get('server_token_id')}")
else:
print(f"[QR] 서버 동기화 실패 (오프라인?): {order_no}")
# 3. 미리보기 이미지 생성
image_url = None
@@ -9339,6 +9616,43 @@ def admin_paai():
return render_template('admin_paai.html')
@app.route('/api/paai/reprint', methods=['POST'])
def api_paai_reprint():
"""PAAI 결과 재인쇄"""
try:
data = request.get_json()
pre_serial = data.get('pre_serial')
if not pre_serial:
return jsonify({'success': False, 'error': '처방번호가 없습니다'}), 400
# 캐시에서 PAAI 결과 조회
from db.paai_logger import get_cached_result
cached = get_cached_result(pre_serial)
if not cached:
return jsonify({'success': False, 'error': '저장된 분석 결과가 없습니다. 재분석 후 인쇄해주세요.'}), 404
# 환자 정보 조회
patient_name = cached.get('patient_name', '환자')
analysis = cached.get('analysis', {})
kims_summary = cached.get('kims_summary', {})
# 인쇄
from paai_printer import print_paai_result
result = print_paai_result(pre_serial, patient_name, analysis, kims_summary)
if result.get('success'):
return jsonify({'success': True, 'message': '인쇄 완료'})
else:
return jsonify({'success': False, 'error': result.get('error', '인쇄 실패')}), 500
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/paai/logs')
def api_paai_logs():
"""PAAI 로그 목록 조회"""
@@ -11013,6 +11327,19 @@ def api_product_image_info(barcode):
return response, 500
# ─────────────────────────────────────────────────────────────
# 동물약 강의 콘텐츠 라우트
# ─────────────────────────────────────────────────────────────
from flask import send_from_directory
@app.route('/lecture/<int:lecture_id>')
def serve_lecture(lecture_id):
"""동물약 강의 콘텐츠 서빙 (카카오톡 og 태그 포함)"""
filename = f'lecture_{lecture_id:02d}.html'
lectures_dir = os.path.join(app.static_folder, 'lectures')
return send_from_directory(lectures_dir, filename)
if __name__ == '__main__':
import os