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

View File

@@ -77,7 +77,7 @@ def get_available_odbc_driver():
class DatabaseConfig:
"""PIT3000 데이터베이스 연결 설정"""
SERVER = "192.168.0.4\\PM2014"
SERVER = "192.168.0.69\\PM2014"
USERNAME = "sa"
PASSWORD = "tmddls214!%(" # 원본 비밀번호

View File

@@ -289,6 +289,43 @@ def get_log_detail(log_id: int) -> dict:
return log
def get_cached_result(pre_serial: str) -> dict:
"""처방번호로 캐시된 PAAI 결과 조회 (재인쇄용)"""
if not DB_PATH.exists():
return None
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 가장 최근 성공한 분석 결과 조회
cursor.execute('''
SELECT * FROM paai_logs
WHERE pre_serial = ? AND status = 'success'
ORDER BY created_at DESC
LIMIT 1
''', (pre_serial,))
row = cursor.fetchone()
conn.close()
if not row:
return None
result = dict(row)
# JSON 파싱
import json
for field in ['analysis', 'kims_summary', 'raw_response']:
if result.get(field):
try:
result[field] = json.loads(result[field])
except:
pass
return result
def get_stats() -> dict:
"""통계 조회"""
if not DB_PATH.exists():

View File

@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
"""
주문 추천 API v2
- 의약품 도메인 지식 반영
- 처방 빈도 기반 차등 추천
- 저빈도 약품: 나간 만큼만 보충
- 고빈도 약품: 일평균 기반 주문
"""
import pyodbc
import logging
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
order_recommendation_bp = Blueprint('order_recommendation', __name__)
def get_mssql_connection(db_name='PM_DRUG'):
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
f'SERVER=192.168.0.4\\PM2014;'
f'DATABASE={db_name};'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes'
)
return pyodbc.connect(conn_str, timeout=10)
@order_recommendation_bp.route('/api/order-recommendation')
def api_order_recommendation():
"""
주문 추천 목록 API v2
의약품 도메인 지식 반영:
1. 고빈도 약품 (7일 이상 데이터, 3건 이상 처방): 일평균 × N일분
2. 저빈도 약품 (가끔 사용): 나간 만큼만 보충
3. 유통기한/폐기 위험 고려하여 과잉 주문 방지
GET /api/order-recommendation?days_threshold=7&order_days=14&limit=50
"""
try:
days_threshold = int(request.args.get('days_threshold', 7)) # N일 이내 소진
order_days = int(request.args.get('order_days', 14)) # 고빈도 약품 주문 기준 일수
limit = int(request.args.get('limit', 50))
min_data_days = int(request.args.get('min_data_days', 3)) # 최소 데이터 일수
conn = get_mssql_connection('PM_DRUG')
cursor = conn.cursor()
today = datetime.now().date()
thirty_days_ago = today - timedelta(days=30)
# 1단계: 재고 있는 품목 + 최근 30일 출고/입고 + 처방 건수 조회
cursor.execute("""
WITH StockItems AS (
SELECT
G.DrugCode,
G.GoodsName,
G.BARCODE,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
FROM CD_GOODS G
INNER JOIN IM_total IT ON G.DrugCode = IT.DrugCode
WHERE ISNULL(IT.IM_QT_sale_debit, 0) > 0
),
Outbound AS (
SELECT
DrugCode,
SUM(ISNULL(IM_QT_sale_credit, 0)) as total_outbound,
SUM(ISNULL(IM_QT_sale_debit, 0)) as total_inbound,
COUNT(DISTINCT IM_DT_appl) as data_days,
MAX(IM_DT_appl) as last_outbound_date
FROM IM_date_total
WHERE IM_DT_appl >= ?
AND IM_DT_appl <= ?
GROUP BY DrugCode
)
SELECT
S.DrugCode,
S.GoodsName,
S.BARCODE,
S.current_stock,
ISNULL(O.total_outbound, 0) as total_outbound,
ISNULL(O.total_inbound, 0) as total_inbound,
ISNULL(O.data_days, 0) as data_days,
O.last_outbound_date
FROM StockItems S
LEFT JOIN Outbound O ON S.DrugCode = O.DrugCode
WHERE ISNULL(O.total_outbound, 0) > 0
""", (thirty_days_ago.strftime('%Y%m%d'), today.strftime('%Y%m%d')))
rows = cursor.fetchall()
# 2단계: 처방 건수 조회 (PM_PRES)
drug_codes = [row.DrugCode for row in rows]
rx_counts = {}
if drug_codes:
conn_pres = get_mssql_connection('PM_PRES')
cursor_pres = conn_pres.cursor()
# 최근 30일 처방 건수
placeholders = ','.join(['?' for _ in drug_codes])
cursor_pres.execute(f"""
SELECT DrugCode, COUNT(DISTINCT PreSerial) as rx_count
FROM PS_sub_pharm
WHERE DrugCode IN ({placeholders})
AND PreSerial >= ?
GROUP BY DrugCode
""", drug_codes + [thirty_days_ago.strftime('%Y%m%d')])
for row in cursor_pres.fetchall():
rx_counts[row.DrugCode] = row.rx_count
conn_pres.close()
conn.close()
# 3단계: 추천 로직 (도메인 지식 반영)
recommendations = []
for row in rows:
drug_code = row.DrugCode
goods_name = row.GoodsName
barcode = row.BARCODE or ''
current_stock = int(row.current_stock)
total_outbound = int(row.total_outbound)
total_inbound = int(row.total_inbound)
data_days = int(row.data_days)
rx_count = rx_counts.get(drug_code, 0)
# === 약품 분류 ===
# 고빈도: 7일 이상 데이터 AND 3건 이상 처방
# 저빈도: 그 외
is_high_frequency = data_days >= 7 and rx_count >= 3
if is_high_frequency:
# === 고빈도 약품: 나간 만큼 + 약간 버퍼 ===
avg_daily = total_outbound / data_days
days_until_empty = current_stock / avg_daily if avg_daily > 0 else 999
if days_until_empty > days_threshold:
continue # 아직 여유 있음
# 기본: 나간 만큼 주문 + 10% 버퍼
recommended_qty = int(total_outbound * 1.1)
# 현재 재고 고려 (이미 있는 건 빼기)
recommended_qty = max(0, recommended_qty - current_stock)
# 최소 주문량 (나간 양의 50% 이상)
min_qty = int(total_outbound * 0.5)
if recommended_qty < min_qty:
recommended_qty = min_qty
calc_method = 'high_freq'
else:
# === 저빈도 약품: 나간 만큼만 보충 ===
# 원래 재고 수준으로 복구
original_stock = current_stock + total_outbound - total_inbound
# 나간 만큼만 주문 (과잉 주문 방지)
recommended_qty = int(total_outbound)
# 현재 재고가 이미 충분하면 스킵
if current_stock >= original_stock * 0.5:
continue
# 일평균 개념 없음, 대략적인 소진일
if total_outbound > 0 and data_days > 0:
# 한 달에 total_outbound 나갔으니, 하루 평균
rough_daily = total_outbound / 30
days_until_empty = current_stock / rough_daily if rough_daily > 0 else 999
else:
days_until_empty = 999
if days_until_empty > days_threshold * 2: # 저빈도는 기준 완화
continue
avg_daily = total_outbound / 30 # 대략적
calc_method = 'low_freq'
# 재고가 0 이하면 긴급
if current_stock <= 0:
days_until_empty = 0
# 소진 예상일
empty_date = today + timedelta(days=int(min(days_until_empty, 365)))
# 신뢰도
if data_days >= 20 and rx_count >= 10:
confidence = 'high'
elif data_days >= 7 and rx_count >= 3:
confidence = 'medium'
else:
confidence = 'low'
# 긴급도
if days_until_empty <= 3:
urgency = 'critical'
elif days_until_empty <= 5:
urgency = 'high'
elif days_until_empty <= days_threshold:
urgency = 'normal'
else:
urgency = 'low'
recommendations.append({
'drug_code': drug_code,
'goods_name': goods_name,
'barcode': barcode,
'current_stock': current_stock,
'total_outbound_30d': total_outbound,
'avg_daily_usage': round(avg_daily, 2),
'days_until_empty': round(days_until_empty, 1),
'empty_date': empty_date.strftime('%Y-%m-%d'),
'recommended_qty': recommended_qty,
'rx_count_30d': rx_count,
'data_days': data_days,
'confidence': confidence,
'urgency': urgency,
'calc_method': calc_method, # 계산 방식
'is_high_frequency': is_high_frequency
})
# 4단계: 정렬 (긴급도 → 소진일)
urgency_order = {'critical': 0, 'high': 1, 'normal': 2, 'low': 3}
recommendations.sort(key=lambda x: (urgency_order.get(x['urgency'], 9), x['days_until_empty']))
recommendations = recommendations[:limit]
# 5단계: 요약
critical_count = sum(1 for r in recommendations if r['urgency'] == 'critical')
high_count = sum(1 for r in recommendations if r['urgency'] == 'high')
high_freq_count = sum(1 for r in recommendations if r['is_high_frequency'])
low_freq_count = sum(1 for r in recommendations if not r['is_high_frequency'])
total_order_qty = sum(r['recommended_qty'] for r in recommendations)
return jsonify({
'success': True,
'version': '2.0',
'generated_at': datetime.now().isoformat(),
'params': {
'days_threshold': days_threshold,
'order_days': order_days,
'min_data_days': min_data_days
},
'summary': {
'total_items': len(recommendations),
'critical_count': critical_count,
'high_count': high_count,
'high_frequency_items': high_freq_count,
'low_frequency_items': low_freq_count,
'total_recommended_qty': total_order_qty
},
'recommendations': recommendations
})
except Exception as e:
logging.error(f"order-recommendation API error: {e}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
@order_recommendation_bp.route('/api/order-recommendation/execute', methods=['POST'])
def api_execute_order():
"""주문 실행 API (POST) - TODO"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data'}), 400
wholesaler = data.get('wholesaler', 'sooin')
items = data.get('items', [])
dry_run = data.get('dry_run', True)
if not items:
return jsonify({'success': False, 'error': 'No items'}), 400
return jsonify({
'success': True,
'wholesaler': wholesaler,
'dry_run': dry_run,
'items_count': len(items),
'message': 'Simulation complete' if dry_run else 'Order submitted'
})
except Exception as e:
logging.error(f"execute-order API error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1555,7 +1555,7 @@ def paai_analyze():
# 5. Clawdbot AI 호출 (WebSocket)
ai_start = time_module.time()
ai_response = call_clawdbot_ai(ai_prompt)
ai_response = call_clawdbot_ai(ai_prompt, cus_code=cus_code)
ai_time = int((time_module.time() - ai_start) * 1000)
# AI 결과 로그 업데이트
@@ -1744,21 +1744,32 @@ def build_paai_prompt(
return prompt
def call_clawdbot_ai(prompt: str) -> dict:
"""Clawdbot AI 호출 (WebSocket Gateway)"""
def call_clawdbot_ai(prompt: str, cus_code: str = None) -> dict:
"""Clawdbot AI 호출 (WebSocket Gateway)
Args:
prompt: AI에게 보낼 프롬프트
cus_code: 환자 코드 (세션 분리용, 같은 날 같은 환자는 세션 공유)
"""
import json
import re
from datetime import datetime
from services.clawdbot_client import ask_clawdbot
PAAI_SYSTEM_PROMPT = """당신은 경험 많은 약사입니다.
처방 데이터를 분석하여 약사에게 유용한 정보를 제공합니다.
이전 대화와 관계없이, 아래 제공된 처방 정보만 보고 독립적으로 분석하세요.
반드시 요청된 JSON 형식으로만 응답하세요."""
# 세션 ID: 날짜별 단일 세션 (하루 1개)
today = datetime.now().strftime('%Y%m%d')
session_id = f'paai-{today}'
try:
# Clawdbot Gateway WebSocket API 호출
ai_text = ask_clawdbot(
message=prompt,
session_id='paai-analysis',
session_id=session_id,
system_prompt=PAAI_SYSTEM_PROMPT,
timeout=60,
model='anthropic/claude-sonnet-4-5' # 빠른 Sonnet 사용

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -0,0 +1,789 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>가격 변동 추이 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--border: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-teal: #14b8a6;
--accent-blue: #3b82f6;
--accent-purple: #a855f7;
--accent-amber: #f59e0b;
--accent-emerald: #10b981;
--accent-rose: #f43f5e;
--accent-orange: #f97316;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
padding: 20px 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.header-left p {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.header-nav {
display: flex;
gap: 8px;
}
.header-nav a {
color: rgba(255,255,255,0.85);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
background: rgba(255,255,255,0.1);
transition: all 0.2s;
}
.header-nav a:hover {
background: rgba(255,255,255,0.2);
color: #fff;
}
/* ══════════════════ 컨텐츠 ══════════════════ */
.content {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* ══════════════════ 검색 영역 ══════════════════ */
.search-section {
background: var(--bg-card);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.search-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.search-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.search-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.search-group input, .search-group select {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
background: var(--bg-primary);
color: var(--text-primary);
min-width: 200px;
}
.search-group input:focus, .search-group select:focus {
outline: none;
border-color: var(--accent-teal);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
}
/* ══════════════════ 약품 검색 자동완성 ══════════════════ */
.drug-search-wrap {
position: relative;
flex: 1;
min-width: 280px;
}
.drug-search-wrap input {
width: 100%;
}
.drug-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
max-height: 320px;
overflow-y: auto;
z-index: 50;
display: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
margin-top: 4px;
}
.drug-search-results.show {
display: block;
}
.drug-item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.05);
transition: background 0.2s;
}
.drug-item:hover {
background: var(--bg-card-hover);
}
.drug-item:last-child {
border-bottom: none;
}
.drug-item-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.drug-item-info {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.drug-item-barcode {
color: var(--accent-teal);
}
.search-btn {
background: var(--accent-teal);
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:hover {
background: #0d9488;
transform: translateY(-1px);
}
.search-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
transform: none;
}
/* ══════════════════ 통계 카드 ══════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.stat-card .label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-card .value {
font-size: 24px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.stat-card .sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.stat-card.teal .value { color: var(--accent-teal); }
.stat-card.blue .value { color: var(--accent-blue); }
.stat-card.amber .value { color: var(--accent-amber); }
.stat-card.emerald .value { color: var(--accent-emerald); }
.stat-card.rose .value { color: var(--accent-rose); }
/* ══════════════════ 차트 영역 ══════════════════ */
.chart-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
@media (max-width: 1000px) {
.chart-section { grid-template-columns: 1fr; }
}
.chart-card {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.chart-card h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.chart-container {
position: relative;
height: 280px;
}
/* ══════════════════ 데이터 테이블 ══════════════════ */
.table-section {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.table-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
font-size: 14px;
font-family: 'JetBrains Mono', monospace;
}
tr:hover {
background: var(--bg-card-hover);
}
.price-up { color: var(--accent-rose); }
.price-down { color: var(--accent-emerald); }
.price-same { color: var(--text-muted); }
/* ══════════════════ 빈 상태 ══════════════════ */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-state p {
font-size: 14px;
}
/* ══════════════════ 로딩 ══════════════════ */
.loading {
display: none;
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.loading.active { display: block; }
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent-teal);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ══════════════════ 제품 정보 ══════════════════ */
.product-info {
background: var(--bg-card);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
display: none;
}
.product-info.active { display: block; }
.product-info h2 {
font-size: 20px;
font-weight: 700;
color: var(--accent-teal);
margin-bottom: 8px;
}
.product-info .barcode {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--text-muted);
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<div class="header-left">
<h1>📈 가격 변동 추이</h1>
<p>제품별 판매가/마진 변화 분석</p>
</div>
<nav class="header-nav">
<a href="/admin">관리자</a>
<a href="/admin/sales/pos">POS 매출</a>
<a href="/admin/stock-analytics">재고 분석</a>
</nav>
</div>
</header>
<main class="content">
<!-- 검색 -->
<section class="search-section">
<div class="search-row">
<div class="search-group drug-search-wrap">
<label>바코드 또는 약품명</label>
<input type="text" id="searchQuery" placeholder="약품명 또는 바코드 입력..." autocomplete="off">
<div class="drug-search-results" id="drugSearchResults"></div>
</div>
<div class="search-group">
<label>기간</label>
<select id="periodSelect">
<option value="90">최근 3개월</option>
<option value="180">최근 6개월</option>
<option value="365" selected>최근 1년</option>
<option value="730">최근 2년</option>
<option value="0">전체 기간</option>
</select>
</div>
<button class="search-btn" id="searchBtn" onclick="searchProduct()">
🔍 조회
</button>
</div>
</section>
<!-- 제품 정보 -->
<section class="product-info" id="productInfo">
<h2 id="productName">-</h2>
<p class="barcode">바코드: <span id="productBarcode">-</span></p>
</section>
<!-- 통계 카드 -->
<section class="stats-grid" id="statsGrid" style="display: none;">
<div class="stat-card teal">
<div class="label">현재 판매가</div>
<div class="value" id="currentPrice">-</div>
<div class="sub" id="priceChange">-</div>
</div>
<div class="stat-card blue">
<div class="label">현재 입고가</div>
<div class="value" id="currentCost">-</div>
<div class="sub" id="costChange">-</div>
</div>
<div class="stat-card emerald">
<div class="label">현재 마진율</div>
<div class="value" id="currentMargin">-</div>
<div class="sub" id="marginRange">-</div>
</div>
<div class="stat-card amber">
<div class="label">총 판매건수</div>
<div class="value" id="totalSales">-</div>
<div class="sub" id="salesPeriod">-</div>
</div>
</section>
<!-- 차트 -->
<section class="chart-section" id="chartSection" style="display: none;">
<div class="chart-card">
<h3>💰 판매가 변동 추이</h3>
<div class="chart-container">
<canvas id="priceChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>📊 마진율 변동 추이</h3>
<div class="chart-container">
<canvas id="marginChart"></canvas>
</div>
</div>
</section>
<!-- 상세 테이블 -->
<section class="table-section" id="tableSection" style="display: none;">
<div class="table-header">
<h3>📋 일별 상세 내역</h3>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>날짜</th>
<th>판매가</th>
<th>입고가</th>
<th>마진</th>
<th>마진율</th>
<th>판매건수</th>
<th>변동</th>
</tr>
</thead>
<tbody id="dataTable">
</tbody>
</table>
</div>
</section>
<!-- 로딩 -->
<div class="loading" id="loading">
<div class="spinner"></div>
<p>데이터 조회 중...</p>
</div>
<!-- 빈 상태 -->
<div class="empty-state" id="emptyState">
<div class="icon">📊</div>
<p>바코드 또는 약품명을 검색하여<br>가격 변동 추이를 확인하세요</p>
</div>
</main>
<script>
let priceChart = null;
let marginChart = null;
let searchTimeout = null;
// 초기화
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchQuery');
// 입력 시 자동완성
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => searchDrugs(this.value), 300);
});
// 포커스 시 결과 표시
searchInput.addEventListener('focus', function() {
if (this.value.length >= 2) {
searchDrugs(this.value);
}
});
// 외부 클릭 시 드롭다운 숨기기
document.addEventListener('click', function(e) {
if (!e.target.closest('.drug-search-wrap')) {
document.getElementById('drugSearchResults').classList.remove('show');
}
});
// 엔터키 검색
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
document.getElementById('drugSearchResults').classList.remove('show');
searchProduct();
}
});
});
// 약품 자동완성 검색
async function searchDrugs(query) {
const resultsDiv = document.getElementById('drugSearchResults');
if (!query || query.length < 2) {
resultsDiv.classList.remove('show');
return;
}
try {
const response = await fetch(`/api/price-trend/search?q=${encodeURIComponent(query)}&limit=15`);
const data = await response.json();
if (data.success && data.items.length > 0) {
resultsDiv.innerHTML = data.items.map(item => `
<div class="drug-item" onclick="selectDrug('${escapeHtml(item.barcode)}', '${escapeHtml(item.product_name)}')">
<div class="drug-item-name">${escapeHtml(item.product_name)}</div>
<div class="drug-item-info">
바코드: <span class="drug-item-barcode">${item.barcode}</span>
· 판매건수: ${item.sale_count.toLocaleString()}
</div>
</div>
`).join('');
resultsDiv.classList.add('show');
} else {
resultsDiv.innerHTML = '<div class="drug-item"><div class="drug-item-name" style="color:var(--text-muted)">검색 결과 없음</div></div>';
resultsDiv.classList.add('show');
}
} catch (err) {
console.error('약품 검색 실패:', err);
}
}
function selectDrug(barcode, productName) {
document.getElementById('searchQuery').value = barcode;
document.getElementById('drugSearchResults').classList.remove('show');
searchProduct();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
async function searchProduct() {
const query = document.getElementById('searchQuery').value.trim();
const period = document.getElementById('periodSelect').value;
if (!query) {
alert('바코드 또는 약품명을 입력하세요');
return;
}
// UI 초기화
document.getElementById('emptyState').style.display = 'none';
document.getElementById('loading').classList.add('active');
document.getElementById('productInfo').classList.remove('active');
document.getElementById('statsGrid').style.display = 'none';
document.getElementById('chartSection').style.display = 'none';
document.getElementById('tableSection').style.display = 'none';
document.getElementById('searchBtn').disabled = true;
try {
const response = await fetch(`/api/price-trend?query=${encodeURIComponent(query)}&period=${period}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || '조회 실패');
}
if (!data.data || data.data.length === 0) {
document.getElementById('emptyState').innerHTML = `
<div class="icon">🔍</div>
<p>"${query}"에 대한 판매 기록이 없습니다</p>
`;
document.getElementById('emptyState').style.display = 'block';
return;
}
// 데이터 표시
displayData(data);
} catch (error) {
console.error('Error:', error);
document.getElementById('emptyState').innerHTML = `
<div class="icon">⚠️</div>
<p>오류: ${error.message}</p>
`;
document.getElementById('emptyState').style.display = 'block';
} finally {
document.getElementById('loading').classList.remove('active');
document.getElementById('searchBtn').disabled = false;
}
}
function displayData(result) {
const data = result.data;
const stats = result.stats;
const productName = result.product_name || '알 수 없음';
const barcode = result.barcode;
// 제품 정보
document.getElementById('productName').textContent = productName;
document.getElementById('productBarcode').textContent = barcode;
document.getElementById('productInfo').classList.add('active');
// 통계
document.getElementById('currentPrice').textContent = formatNumber(stats.current_price) + '원';
document.getElementById('currentCost').textContent = formatNumber(stats.current_cost) + '원';
document.getElementById('currentMargin').textContent = stats.current_margin.toFixed(1) + '%';
document.getElementById('totalSales').textContent = formatNumber(stats.total_count) + '건';
document.getElementById('priceChange').textContent =
`범위: ${formatNumber(stats.min_price)}원 ~ ${formatNumber(stats.max_price)}`;
document.getElementById('costChange').textContent =
`범위: ${formatNumber(stats.min_cost)}원 ~ ${formatNumber(stats.max_cost)}`;
document.getElementById('marginRange').textContent =
`범위: ${stats.min_margin.toFixed(1)}% ~ ${stats.max_margin.toFixed(1)}%`;
document.getElementById('salesPeriod').textContent =
`${stats.first_date} ~ ${stats.last_date}`;
document.getElementById('statsGrid').style.display = 'grid';
// 차트 데이터 준비
const labels = data.map(d => d.date.substring(0, 10));
const prices = data.map(d => d.avg_price);
const margins = data.map(d => d.margin_rate);
// 판매가 차트
if (priceChart) priceChart.destroy();
const priceCtx = document.getElementById('priceChart').getContext('2d');
priceChart = new Chart(priceCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '판매가',
data: prices,
borderColor: '#14b8a6',
backgroundColor: 'rgba(20, 184, 166, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#64748b', maxTicksLimit: 10 },
grid: { color: '#334155' }
},
y: {
ticks: {
color: '#64748b',
callback: v => formatNumber(v) + '원'
},
grid: { color: '#334155' }
}
}
}
});
// 마진율 차트
if (marginChart) marginChart.destroy();
const marginCtx = document.getElementById('marginChart').getContext('2d');
marginChart = new Chart(marginCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '마진율',
data: margins,
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#64748b', maxTicksLimit: 10 },
grid: { color: '#334155' }
},
y: {
ticks: {
color: '#64748b',
callback: v => v.toFixed(1) + '%'
},
grid: { color: '#334155' }
}
}
}
});
document.getElementById('chartSection').style.display = 'grid';
// 테이블
const tbody = document.getElementById('dataTable');
tbody.innerHTML = '';
let prevPrice = null;
data.forEach((row, idx) => {
let changeClass = 'price-same';
let changeText = '-';
if (idx > 0 && prevPrice !== null) {
if (row.avg_price > prevPrice) {
changeClass = 'price-up';
changeText = '↑ ' + formatNumber(row.avg_price - prevPrice);
} else if (row.avg_price < prevPrice) {
changeClass = 'price-down';
changeText = '↓ ' + formatNumber(prevPrice - row.avg_price);
}
}
prevPrice = row.avg_price;
tbody.innerHTML += `
<tr>
<td>${row.date}</td>
<td>${formatNumber(row.avg_price)}원</td>
<td>${formatNumber(row.avg_cost)}원</td>
<td>${formatNumber(row.avg_margin)}원</td>
<td>${row.margin_rate.toFixed(1)}%</td>
<td>${row.count}건</td>
<td class="${changeClass}">${changeText}</td>
</tr>
`;
});
document.getElementById('tableSection').style.display = 'block';
}
function formatNumber(num) {
return Math.round(num).toLocaleString('ko-KR');
}
</script>
</body>
</html>

View File

@@ -486,6 +486,34 @@
.paai-feedback button:hover { border-color: #10b981; }
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
.paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; }
.paai-reanalyze-btn {
background: linear-gradient(135deg, #3b82f6, #2563eb) !important;
color: #fff !important;
border: none !important;
margin-left: 10px;
}
.paai-reanalyze-btn:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
transform: scale(1.02);
}
.paai-reanalyze-btn:disabled {
background: #9ca3af !important;
cursor: not-allowed;
}
.paai-reprint-btn {
background: linear-gradient(135deg, #10b981, #059669) !important;
color: #fff !important;
border: none !important;
margin-left: 10px;
}
.paai-reprint-btn:hover {
background: linear-gradient(135deg, #059669, #047857) !important;
transform: scale(1.02);
}
.paai-reprint-btn:disabled {
background: #9ca3af !important;
cursor: not-allowed;
}
.paai-timing {
font-size: 0.8rem;
color: #9ca3af;
@@ -1456,6 +1484,8 @@
<span>도움이 되셨나요?</span>
<button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button>
<button onclick="sendPaaiFeedback(false)" id="paaiNotUseful">👎 아니요</button>
<button onclick="reanalyzePaai()" id="paaiReanalyze" class="paai-reanalyze-btn">🔄 재분석</button>
<button onclick="reprintPaai()" id="paaiReprint" class="paai-reprint-btn">🖨️ 재인쇄</button>
</div>
<div class="paai-timing" id="paaiTiming"></div>
</div>
@@ -2674,6 +2704,146 @@
triggerPaaiAnalysis();
}
// 🖨️ 재인쇄 함수
async function reprintPaai() {
if (!currentPrescriptionData) return;
const btn = document.getElementById('paaiReprint');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '🖨️ 인쇄 중...';
try {
const response = await fetch('/api/paai/reprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pre_serial: currentPrescriptionData.pre_serial
})
});
const result = await response.json();
if (result.success) {
btn.textContent = '✅ 인쇄 완료!';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
} else {
btn.textContent = '❌ 실패';
alert('인쇄 실패: ' + (result.error || '알 수 없는 오류'));
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
}
} catch (error) {
btn.textContent = '❌ 오류';
alert('인쇄 오류: ' + error.message);
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
}
}
// 🔄 재분석 함수 - 캐시 무시하고 새로 분석
async function reanalyzePaai() {
if (!currentPrescriptionData) return;
const btn = document.getElementById('paaiReanalyze');
const body = document.getElementById('paaiBody');
const footer = document.getElementById('paaiFooter');
// 버튼 비활성화
btn.disabled = true;
btn.textContent = '⏳ 분석 중...';
// 로딩 표시
body.innerHTML = `
<div class="paai-loading">
<div class="spinner"></div>
<div>AI 재분석 중...</div>
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">캐시 무시하고 새로 분석합니다</div>
</div>
`;
footer.style.display = 'none';
const preSerial = currentPrescriptionData.pre_serial;
// 캐시 삭제
delete paaiResultCache[preSerial];
try {
// triggerPaaiAnalysis와 동일한 형식으로 데이터 구성
const requestData = {
pre_serial: preSerial,
cus_code: currentPrescriptionData.cus_code,
patient_name: currentPrescriptionData.name || '환자',
patient_note: currentPrescriptionData.cusetc || '',
disease_info: {
code_1: currentPrescriptionData.st1 || '',
name_1: currentPrescriptionData.st1_name || '',
code_2: currentPrescriptionData.st2 || '',
name_2: currentPrescriptionData.st2_name || ''
},
current_medications: (currentPrescriptionData.medications || []).map(med => ({
code: med.medication_code,
name: med.med_name,
dosage: med.dosage,
frequency: med.frequency,
days: med.duration
})),
previous_medications: (currentPrescriptionData.previous_medications || []).map(med => ({
code: med.medication_code,
name: med.med_name,
dosage: med.dosage,
frequency: med.frequency,
days: med.duration
})),
otc_history: otcData ? {
visit_count: otcData.summary?.total_visits || 0,
frequent_items: otcData.summary?.frequent_items || [],
purchases: otcData.purchases || []
} : {}
};
const response = await fetch('/pmr/api/paai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
const result = await response.json();
if (result.success) {
// 새 결과 캐시
paaiResultCache[preSerial] = { result, cached: false };
currentPaaiLogId = result.log_id;
currentPaaiResponse = JSON.stringify(result.analysis || {});
displayPaaiResult(result);
// 성공 표시
btn.textContent = '✅ 완료!';
setTimeout(() => {
btn.textContent = '🔄 재분석';
btn.disabled = false;
}, 2000);
} else {
body.innerHTML = `<div style="color:#ef4444;padding:20px;">❌ 재분석 실패: ${result.error}</div>`;
btn.textContent = '🔄 재분석';
btn.disabled = false;
}
} catch (error) {
body.innerHTML = `<div style="color:#ef4444;padding:20px;">❌ 오류: ${error.message}</div>`;
btn.textContent = '🔄 재분석';
btn.disabled = false;
}
footer.style.display = 'flex';
}
function displayPaaiResult(result) {
const body = document.getElementById('paaiBody');
const footer = document.getElementById('paaiFooter');

View File

@@ -1,10 +1,14 @@
"""
QR Claim Token 생성 모듈
후향적 적립을 위한 1회성 토큰 생성
v2 (2026-03-29): 서버 즉시 전송 추가 (pos.pharmq.kr)
"""
import hashlib
import secrets
import logging
import requests
from datetime import datetime, timedelta
import sys
import os
@@ -16,7 +20,14 @@ from db.dbsetup import DatabaseManager
# 설정값
MILEAGE_RATE = 0.03 # 3% 적립
TOKEN_EXPIRY_DAYS = 30 # 30일 유효기간
QR_BASE_URL = "https://mile.0bin.in/claim"
# 서버 설정 (v2)
CLOUD_API_URL = "https://pos.pharmq.kr"
PHARMACY_CODE = "P0001"
QR_BASE_URL = f"{CLOUD_API_URL}/{PHARMACY_CODE}/claim"
# 로거
logger = logging.getLogger(__name__)
def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"):
@@ -77,7 +88,8 @@ def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"):
'expires_at': expires_at,
'pharmacy_id': pharmacy_id,
'transaction_id': transaction_id,
'total_amount': total_amount
'total_amount': total_amount,
'nonce': nonce, # 서버 전송용
}
@@ -150,6 +162,97 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
return (False, f"DB 저장 실패: {str(e)}")
def sync_token_to_server(transaction_id, total_amount, pharmacy_code=None, items=None):
"""
토큰을 서버(pos.pharmq.kr)에 즉시 전송 (품목 상세 포함)
Args:
transaction_id: 거래 ID
total_amount: 판매 금액
pharmacy_code: 약국 코드 (기본값: P0001)
items: 품목 리스트 [{'item_code': ..., 'item_name': ..., 'quantity': ..., 'unit_price': ..., 'total_price': ...}]
Returns:
tuple: (성공 여부, 서버 응답 or 에러 메시지)
"""
pharmacy_code = pharmacy_code or PHARMACY_CODE
payload = {
'pharmacy_code': pharmacy_code,
'transaction_id': str(transaction_id),
'total_amount': int(total_amount),
}
# 품목 상세 추가
if items:
payload['items'] = items
try:
response = requests.post(
f"{CLOUD_API_URL}/api/v1/tokens/create",
json=payload,
timeout=5
)
if response.ok:
result = response.json()
logger.info(f"[QR] 서버 전송 성공: {transaction_id}{result.get('points', 0)}P")
return (True, result)
else:
logger.warning(f"[QR] 서버 응답 오류: {response.status_code} - {response.text[:100]}")
return (False, f"서버 오류: {response.status_code}")
except requests.Timeout:
logger.warning(f"[QR] 서버 타임아웃 (오프라인?): {transaction_id}")
return (False, "타임아웃")
except Exception as e:
logger.warning(f"[QR] 서버 전송 실패: {e}")
return (False, str(e))
def generate_and_sync_token(transaction_id, total_amount, pharmacy_id="P0001", items=None):
"""
토큰 생성 + 로컬 저장 + 서버 즉시 전송 (통합 함수)
Args:
transaction_id: 거래 ID
total_amount: 판매 금액
pharmacy_id: 약국 코드
items: 품목 리스트 [{'item_code': ..., 'item_name': ..., 'quantity': ..., 'unit_price': ..., 'total_price': ...}]
Returns:
dict: 토큰 정보 + synced 플래그
"""
# 1. 토큰 생성
token_info = generate_claim_token(transaction_id, total_amount, pharmacy_id)
# 2. 로컬 DB 저장
local_success, local_error = save_token_to_db(
transaction_id,
token_info['token_hash'],
total_amount,
token_info['claimable_points'],
token_info['expires_at'],
pharmacy_id
)
token_info['local_saved'] = local_success
if not local_success:
token_info['local_error'] = local_error
# 3. ⚡ 서버 즉시 전송 (품목 포함)
sync_success, sync_result = sync_token_to_server(
transaction_id, total_amount, pharmacy_id, items=items
)
token_info['synced'] = sync_success
if sync_success and isinstance(sync_result, dict):
token_info['server_token_id'] = sync_result.get('token_id')
token_info['server_points'] = sync_result.get('points')
return token_info
# 테스트 코드
if __name__ == "__main__":
# 테스트

View File

@@ -0,0 +1,564 @@
# 라벨 인쇄 시스템 가이드
> pharmacy-pos-qr-system의 라벨 인쇄/미리보기 기능 문서
>
> 작성일: 2026-03-18
---
## 📁 파일 구조
```
backend/
├── pmr_api.py # 처방전(PMR) 라벨 인쇄 API
├── qr_printer.py # 약품 QR 라벨 (바코드/가격)
├── utils/
│ ├── otc_label_printer.py # OTC 용법 라벨 (가로형 와이드)
│ └── qr_label_printer.py # QR 영수증 라벨 (마일리지용)
└── samples/
└── print_label.py # 처방전 라벨 핵심 함수 (참조용)
```
---
## 🖨️ 프린터 설정
| 용도 | 모델 | IP | 포트 | 용지 |
|------|------|-----|------|------|
| QR 라벨 (121) | Brother QL-710W | 192.168.0.121 | 9100 | 29mm 연속 |
| OTC 라벨 (168) | Brother QL-810W | 192.168.0.168 | 9100 | 29mm 연속 |
---
## 1⃣ 처방전 라벨 (PMR)
### 파일 위치
- **API**: `backend/pmr_api.py`
- **엔드포인트**: `/pmr/api/label/preview`, `/pmr/api/label/print`
### 미리보기 API
```
POST /pmr/api/label/preview
Content-Type: application/json
```
**Request Body:**
```json
{
"patient_name": "홍길동",
"med_name": "아모잘탄정5/50mg",
"add_info": "고혈압치료제",
"dosage": 1.0,
"frequency": 2,
"duration": 30,
"unit": "정",
"sung_code": "123456TB"
}
```
**Response:**
```json
{
"success": true,
"image": "data:image/png;base64,iVBORw0KGgo...",
"conversion_factor": null,
"storage_conditions": "실온보관"
}
```
### 인쇄 API
```
POST /pmr/api/label/print
Content-Type: application/json
```
**Request Body:**
```json
{
"patient_name": "홍길동",
"med_name": "아모잘탄정5/50mg",
"add_info": "고혈압치료제",
"dosage": 1.0,
"frequency": 2,
"duration": 30,
"unit": "정",
"sung_code": "123456TB",
"printer": "168",
"orientation": "portrait"
}
```
**Parameters:**
| 파라미터 | 타입 | 필수 | 설명 |
|----------|------|------|------|
| patient_name | string | ✅ | 환자명 |
| med_name | string | ✅ | 약품명 |
| add_info | string | ❌ | 효능/분류 (PRINT_TYPE) |
| dosage | float | ✅ | 1회 복용량 |
| frequency | int | ✅ | 1일 복용 횟수 (1,2,3...) |
| duration | int | ✅ | 복용 일수 |
| unit | string | ✅ | 단위 (정, 캡슐, mL, 포, g) |
| sung_code | string | ❌ | 성분코드 (환산계수 조회용) |
| printer | string | ❌ | "121" 또는 "168" (기본: 168) |
| orientation | string | ❌ | "portrait" 또는 "landscape" (기본: portrait) |
### 핵심 함수 (`pmr_api.py`)
```python
def create_label_image(patient_name, med_name, add_info='', dosage=0,
frequency=0, duration=0, unit='',
conversion_factor=None, storage_conditions='실온보관'):
"""
라벨 이미지 생성 (29mm 용지 기준, 306x380px)
Returns:
PIL.Image: RGB 이미지
"""
```
```python
def normalize_medication_name(med_name):
"""
약품명 정제
- 밀리그램 → mg
- 마이크로그램 → μg
- 밀리리터 → mL
- 언더스코어 뒤 내용 제거
"""
```
```python
def get_drug_unit(goods_name, sung_code):
"""
SUNG_CODE 마지막 2자리로 단위 판별
- TB, TA, TC... → ""
- CA, CH, CS... → "캡슐"
- SS, SY, LQ... → "mL"
- GA, GB, PD... → ""
"""
```
---
## 2⃣ OTC 용법 라벨 (가로형 와이드)
### 파일 위치
- **모듈**: `backend/utils/otc_label_printer.py`
- **API**: `backend/app.py`
### 미리보기 API
```
POST /api/admin/otc-labels/preview
Content-Type: application/json
```
**Request Body:**
```json
{
"drug_name": "타이레놀정500mg",
"effect": "해열·진통",
"dosage_instruction": "1일 3회, 1회 1~2정 [식후 30분]",
"usage_tip": "공복 복용 시 위장장애 주의"
}
```
**Response:**
```json
{
"success": true,
"preview_url": "data:image/png;base64,iVBORw0KGgo..."
}
```
### 인쇄 API
```
POST /api/admin/otc-labels/print
Content-Type: application/json
```
**Request Body:**
```json
{
"drug_name": "타이레놀정500mg",
"effect": "해열·진통",
"dosage_instruction": "1일 3회, 1회 1~2정 [식후 30분]",
"usage_tip": "공복 복용 시 위장장애 주의",
"barcode": "8806436044814"
}
```
### 바코드로 인쇄 (간편)
```
GET /api/otc-label-print/<barcode>
```
예: `GET /api/otc-label-print/8806436044814`
> DB의 `otc_label_presets` 테이블에서 미리 저장된 라벨 정보 사용
### 핵심 함수 (`utils/otc_label_printer.py`)
```python
def create_otc_label_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
OTC 용법 라벨 이미지 생성 (800 x 306px 가로형)
레이아웃:
- 효능: 중앙 상단 72pt (매우 크게!)
- 약품명: 오른쪽 중간 36pt
- 용법: 왼쪽 하단 40pt (체크박스 포함)
- 약국명: 오른쪽 하단 테두리 박스
Returns:
PIL.Image: 1-bit 이미지 (흑백)
"""
```
```python
def generate_preview_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
미리보기용 Base64 PNG 이미지 반환
Returns:
str: "data:image/png;base64,..." 형태
"""
```
```python
def print_otc_label(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
Brother QL-810W (192.168.0.168)로 인쇄
- 이미지 90도 회전 후 전송
Returns:
bool: 성공 여부
"""
```
---
## 3⃣ 약품 QR 라벨 (바코드/가격)
### 파일 위치
- **모듈**: `backend/qr_printer.py`
- **API**: `backend/app.py`
### 미리보기 API
```
POST /api/qr-preview
Content-Type: application/json
```
**Request Body:**
```json
{
"drug_name": "벤포파워Z",
"barcode": "8806418067510",
"sale_price": 3000,
"drug_code": "A12345678"
}
```
**Response:**
```json
{
"success": true,
"preview_url": "data:image/png;base64,iVBORw0KGgo..."
}
```
### 인쇄 API
```
POST /api/qr-print
Content-Type: application/json
```
**Request Body:**
```json
{
"drug_name": "벤포파워Z",
"barcode": "8806418067510",
"sale_price": 3000,
"drug_code": "A12345678"
}
```
### 핵심 함수 (`qr_printer.py`)
```python
def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None,
pharmacy_name='청춘약국'):
"""
약품 QR 라벨 이미지 생성 (306 x 380px)
구조:
┌─────────────────┐
│ [QR CODE] │ ← 바코드 기반 QR (130x130px)
├─────────────────┤
│ 약품명 │ ← 중앙 정렬 (최대 2줄)
├─────────────────┤
│ ₩12,000 │ ← 판매가격
├─────────────────┤
│ 청 춘 약 국 │ ← 테두리 박스
└─────────────────┘
Returns:
PIL.Image: 1-bit 이미지
"""
```
```python
def print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None,
pharmacy_name='청춘약국'):
"""
Brother QL-710W (192.168.0.121)로 인쇄
Returns:
dict: {"success": True/False, "message": "...", "error": "..."}
"""
```
```python
def preview_qr_label(drug_name, barcode, sale_price, drug_code=None,
pharmacy_name='청춘약국'):
"""
미리보기용 Base64 PNG 반환
Returns:
str: "data:image/png;base64,..."
"""
```
---
## 4⃣ QR 영수증 라벨 (마일리지용)
### 파일 위치
- **모듈**: `backend/utils/qr_label_printer.py`
- **API**: `backend/app.py`
### 인쇄 API
```
POST /api/admin/qr/print
Content-Type: application/json
```
**Request Body:**
```json
{
"transaction_id": "20251024000042",
"total_amount": 50000,
"claimable_points": 1500,
"transaction_time": "2025-10-24T14:30:00",
"token_raw": "abc123",
"printer": "brother"
}
```
**Parameters:**
| 파라미터 | 타입 | 설명 |
|----------|------|------|
| transaction_id | string | 거래 번호 |
| total_amount | float | 결제 금액 |
| claimable_points | int | 적립 예정 포인트 |
| transaction_time | string | 거래 시간 (ISO 8601) |
| token_raw | string | QR URL 생성용 토큰 |
| printer | string | "brother" 또는 "pos" |
### 핵심 함수 (`utils/qr_label_printer.py`)
```python
def create_qr_receipt_label(qr_url, transaction_id, total_amount,
claimable_points, transaction_time):
"""
QR 영수증 라벨 이미지 생성 (800 x 306px 가로형)
레이아웃:
┌─────────────────────────────────────────────────────────────┐
│ [청춘약국] [QR CODE] │
│ 2025-10-24 14:30 200x200px │
│ 거래: 20251024000042 │
│ │
│ 결제금액: 50,000원 │
│ 적립예정: 1,500P │
│ │
│ QR 촬영하고 포인트 받으세요! │
└─────────────────────────────────────────────────────────────┘
Returns:
PIL.Image: 1-bit 이미지
"""
```
```python
def print_qr_label(qr_url, transaction_id, total_amount, claimable_points,
transaction_time, preview_mode=False):
"""
QR 라벨 출력 또는 미리보기
Args:
preview_mode: True = 미리보기 (파일 저장), False = 인쇄
Returns:
preview_mode=True: (성공 여부, 이미지 파일 경로)
preview_mode=False: 성공 여부 (bool)
"""
```
---
## 🔧 공통 유틸리티
### 지그재그 테두리 (절취선)
```python
def draw_scissor_border(draw, width, height, edge_size=10, steps=20):
"""
라벨 테두리에 톱니 모양 절취선 그리기
Args:
draw: ImageDraw 객체
width: 라벨 너비
height: 라벨 높이
edge_size: 톱니 크기 (px)
steps: 톱니 개수
"""
```
### 중앙 정렬 텍스트
```python
def draw_centered_text(draw, text, y, font, max_width=None):
"""
중앙 정렬된 텍스트 출력 (줄바꿈 지원)
Returns:
int: 다음 Y 위치
"""
```
---
## 📦 의존성
```
pillow>=10.0.0 # 이미지 처리
brother-ql>=0.9.4 # Brother QL 프린터 제어
qrcode[pil]>=7.0 # QR 코드 생성
```
### Brother QL 라이브러리 사용법
```python
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
# 1. Raster 객체 생성
qlr = BrotherQLRaster("QL-810W")
# 2. 이미지 변환 (29mm 라벨)
instructions = convert(
qlr=qlr,
images=[pil_image],
label='29',
rotate='0',
threshold=70.0,
dither=False,
compress=False,
red=False,
dpi_600=False,
hq=True, # 고화질
cut=True # 자동 절단
)
# 3. 프린터 전송
send(instructions, printer_identifier="tcp://192.168.0.168:9100")
```
---
## ⚠️ 주의사항
### 가로형 라벨 (800x306px)
Brother QL은 세로 방향이 기준이므로, 가로형 라벨은 **90도 회전 후 전송**해야 함:
```python
# 가로형 이미지 생성 (800 x 306)
label_img = create_wide_label(...)
# 90도 회전 (시계 반대방향)
label_img_rotated = label_img.rotate(90, expand=True)
# 전송
send(convert(..., images=[label_img_rotated], ...))
```
### 폰트 경로
Windows: `C:/Windows/Fonts/malgunbd.ttf`
Linux: `/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf`
폴백 처리 권장:
```python
font_paths = [
"C:/Windows/Fonts/malgunbd.ttf",
"/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf",
]
for path in font_paths:
if os.path.exists(path):
font = ImageFont.truetype(path, 32)
break
else:
font = ImageFont.load_default()
```
### 이미지 모드
Brother QL은 **1-bit (흑백)** 이미지 권장:
```python
image = Image.new("1", (width, height), 1) # 1 = 흰색
# 또는
image = image.convert('1')
```
---
## 📋 테이블 스키마 (SQLite)
### otc_label_presets
OTC 라벨 프리셋 저장용:
```sql
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode TEXT UNIQUE NOT NULL,
drug_name TEXT NOT NULL,
effect TEXT,
dosage_instruction TEXT,
usage_tip TEXT,
print_count INTEGER DEFAULT 0,
last_printed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## 🔗 관련 문서
- `docs/PHARMACY_DB_GUIDE.md` - 약국 DB 조회 가이드
- `docs/ENCODING_GUIDE.md` - 인코딩 문제 해결

434
docs/OTC_LABEL_SYSTEM.md Normal file
View File

@@ -0,0 +1,434 @@
# OTC 용법 라벨 시스템
## 1. 시스템 개요
### OTC 라벨이란?
OTC(Over-The-Counter) 약품 판매 시 부착하는 **용법·용량 안내 라벨**입니다.
약사가 직접 설명하는 것 외에 시각적 보조 자료로, 복용 방법과 효능을 명확히 전달합니다.
### 전체 흐름
```
바코드 스캔 → POS 연동 → 웹 관리 페이지 → 미리보기 → Brother 프린터 출력
```
1. **POS에서 바코드 스캔** → URL 호출 (`?barcode=...&name=...`)
2. **관리 페이지 자동 로드** → 기존 프리셋이 있으면 채움
3. **효능/용법 입력** → 실시간 미리보기
4. **인쇄** → Brother QL-810W로 29mm 라벨 출력
5. **프리셋 저장** → 다음 번엔 바코드만 스캔하면 바로 인쇄
---
## 2. 아키텍처
### 2.1 시스템 구성도
```
┌─────────────────┐ ┌──────────────────────────────┐
│ POS (PIT3000) │────▶│ Flask 서버 (port 7001) │
│ 바코드 스캔 │ │ /admin/otc-labels │
└─────────────────┘ └───────────┬──────────────────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌────────────┐ ┌──────────────┐
│ SQLite │ │ MSSQL │ │ Brother │
│ (프리셋 저장) │ │ (약품 정보) │ │ QL-810W │
│ mileage.db │ │ PM_DRUG │ │ 192.168.0.168│
└──────────────┘ └────────────┘ └──────────────┘
```
### 2.2 Flask 라우트 구조
```
/admin/otc-labels ← 관리 페이지 (HTML)
/api/admin/otc-labels ← 프리셋 목록 조회 / 등록·수정
/api/admin/otc-labels/<barcode> ← 단건 조회 / 삭제
/api/admin/otc-labels/preview ← 미리보기 이미지 생성
/api/admin/otc-labels/print ← 라벨 인쇄
/api/admin/otc-labels/search-mssql ← MSSQL 약품 검색
/api/otc-label-print/<barcode> ← 외부 GET 인쇄 (CORS 지원)
/api/otc-label-check ← 프리셋 존재 여부 일괄 확인
```
### 2.3 DB 연결
| DB | 용도 | 연결 방식 |
|---|---|---|
| **SQLite** | 라벨 프리셋 저장 | `db_manager.get_sqlite_connection()` |
| **MSSQL** | 약품 마스터 (CD_GOODS) | `db_manager.get_session('PM_DRUG')` |
- SQLite DB 경로: `backend/db/mileage.db`
- MSSQL 인스턴스: `192.168.0.4\PM2014`
### 2.4 프린터 연동
| 항목 | 값 |
|---|---|
| 프린터 | Brother QL-810W |
| IP | 192.168.0.168 |
| 포트 | 9100 (TCP) |
| 용지 | 29mm 연속 라벨 |
| 라이브러리 | `brother_ql` |
---
## 3. API 엔드포인트
### 3.1 관리 페이지
```
GET /admin/otc-labels
GET /admin/otc-labels?barcode=8806436003118&name=노바손크림
```
- URL 파라미터로 바코드/이름 전달 시 자동 로드
---
### 3.2 프리셋 목록 조회
```http
GET /api/admin/otc-labels
```
**응답 예시:**
```json
{
"success": true,
"count": 5,
"labels": [
{
"id": 1,
"barcode": "8806436003118",
"drug_code": "DR001",
"display_name": "노바손크림",
"effect": "습진, 피부염",
"dosage_instruction": "1일 2회, 환부에 얇게 도포",
"usage_tip": "눈 주위 사용 금지",
"use_wide_format": true,
"print_count": 12,
"last_printed_at": "2026-03-19 15:30:00",
"created_at": "...",
"updated_at": "..."
}
]
}
```
---
### 3.3 프리셋 단건 조회
```http
GET /api/admin/otc-labels/{barcode}
```
**응답 예시:**
```json
{
"success": true,
"exists": true,
"label": { /* */ }
}
```
**프리셋 없는 경우 (404):**
```json
{
"success": false,
"error": "등록된 프리셋이 없습니다.",
"exists": false
}
```
---
### 3.4 프리셋 등록/수정 (Upsert)
```http
POST /api/admin/otc-labels
Content-Type: application/json
{
"barcode": "8806436003118",
"drug_code": "DR001",
"display_name": "",
"effect": ", ",
"dosage_instruction": "1 2, ",
"usage_tip": " ",
"use_wide_format": true
}
```
**필수 필드:** `barcode`
**동작:** 바코드가 이미 존재하면 UPDATE, 없으면 INSERT
---
### 3.5 프리셋 삭제
```http
DELETE /api/admin/otc-labels/{barcode}
```
---
### 3.6 미리보기 이미지 생성
```http
POST /api/admin/otc-labels/preview
Content-Type: application/json
{
"drug_name": "",
"effect": ", ",
"dosage_instruction": "1 2, ",
"usage_tip": " "
}
```
**응답:**
```json
{
"success": true,
"preview_url": "data:image/png;base64,iVBORw0KGgo..."
}
```
---
### 3.7 라벨 인쇄
```http
POST /api/admin/otc-labels/print
Content-Type: application/json
{
"barcode": "8806436003118",
"drug_name": "",
"effect": ", ",
"dosage_instruction": "1 2",
"usage_tip": ""
}
```
**동작:** 인쇄 후 `print_count` 증가, `last_printed_at` 갱신
---
### 3.8 외부 GET 인쇄 (CORS 지원)
```http
GET /api/otc-label-print/{barcode}
```
- **프리셋 있음** → 해당 데이터로 즉시 인쇄
- **프리셋 없음** → 404 반환 (인쇄 안 함)
- POS 등 외부 시스템에서 URL 호출로 바로 인쇄 가능
---
### 3.9 MSSQL 약품 검색
```http
GET /api/admin/otc-labels/search-mssql?q=
```
**응답:**
```json
{
"success": true,
"count": 3,
"drugs": [
{
"drug_code": "DR001",
"barcode": "8806436003118",
"goods_name": "노바손크림30g",
"sale_price": 8500.0
}
]
}
```
**쿼리 대상:**
- `CD_GOODS.GoodsName` (약품명)
- `CD_GOODS.Barcode` (바코드)
- `CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE` (포장 단위 바코드)
---
### 3.10 프리셋 존재 여부 일괄 확인
```http
GET /api/otc-label-check?barcodes=8806436003118,8806436058613
#
POST /api/otc-label-check
Content-Type: application/json
{
"barcodes": ["8806436003118", "8806436058613"]
}
```
**응답:**
```json
{
"success": true,
"total": 2,
"found": 1,
"results": {
"8806436003118": true,
"8806436058613": false
}
}
```
---
## 4. DB 스키마
### 4.1 테이블: `otc_label_presets` (SQLite)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| `id` | INTEGER | PK, 자동 증가 |
| `barcode` | VARCHAR(20) | **UNIQUE**, 바코드 (실질적 PK) |
| `drug_code` | VARCHAR(20) | MSSQL DrugCode (참조용) |
| `display_name` | VARCHAR(100) | 표시 이름 (오버라이드) |
| `effect` | VARCHAR(100) | 효능 (예: "치통, 두통") |
| `dosage_instruction` | TEXT | 용법 (예: "1일 3회, 1회 1정") |
| `usage_tip` | TEXT | 부가 설명 |
| `use_wide_format` | BOOLEAN | 와이드 포맷 사용 여부 |
| `print_count` | INTEGER | 인쇄 횟수 (통계) |
| `last_printed_at` | DATETIME | 마지막 인쇄 시간 |
| `created_at` | DATETIME | 생성 시간 |
| `updated_at` | DATETIME | 수정 시간 |
**인덱스:**
- `idx_otc_label_barcode` (barcode)
- `idx_otc_label_drug_code` (drug_code)
---
### 4.2 MSSQL 테이블: `CD_GOODS` (약품 마스터)
검색 시 조회하는 테이블:
```sql
SELECT TOP 20
G.DrugCode,
COALESCE(NULLIF(G.Barcode, ''),
(SELECT TOP 1 CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER WHERE DrugCode = G.DrugCode)
) AS Barcode,
G.GoodsName,
G.Saleprice
FROM CD_GOODS G
WHERE G.GoodsName LIKE '%검색어%'
OR G.Barcode LIKE '%검색어%'
OR G.DrugCode IN (SELECT DrugCode FROM CD_ITEM_UNIT_MEMBER WHERE CD_CD_BARCODE LIKE '%검색어%')
```
---
## 5. 라벨 이미지 생성
### 5.1 이미지 사양
| 항목 | 값 |
|---|---|
| 크기 | 800 × 306 px |
| 색상 | 1-bit (흑백) |
| 폰트 | 맑은 고딕 Bold (`malgunbd.ttf`) |
### 5.2 레이아웃
```
┌────────────────────────────────────────────┐
│ [효능 - 72pt, 굵게, 중앙 상단] │
│ │
│ □ 용법 - 40pt, 왼쪽 정렬 │
│ [약품명 36pt] │
│ □ 부가 설명 - 26pt ┌──────────┐ │
│ │ 청춘약국 │ │
│ └──────────┘ │
└────────────────────────────────────────────┘
```
### 5.3 인쇄 과정
1. PIL로 이미지 생성 (가로 800 × 세로 306)
2. 90도 회전 (Brother QL은 세로 기준)
3. `brother_ql` 라이브러리로 래스터 변환
4. TCP 9100 포트로 전송
---
## 6. 관련 파일 목록
### 6.1 핵심 파일
| 파일 | 역할 |
|---|---|
| `backend/app.py` | Flask 라우트 (7730~8200줄) |
| `backend/utils/otc_label_printer.py` | 이미지 생성 & 프린터 출력 |
| `backend/templates/admin_otc_labels.html` | 관리 페이지 UI |
| `backend/db/mileage_schema.sql` | 테이블 스키마 |
| `backend/db/mileage.db` | SQLite DB |
### 6.2 app.py 내 주요 함수
| 함수명 | 라인 | 설명 |
|---|---|---|
| `admin_otc_labels()` | 7735 | 관리 페이지 렌더링 |
| `api_get_otc_labels()` | 7741 | 목록 조회 |
| `api_get_otc_label()` | 7770 | 단건 조회 |
| `api_upsert_otc_label()` | 7801 | 등록/수정 |
| `api_delete_otc_label()` | 7848 | 삭제 |
| `api_preview_otc_label()` | 7868 | 미리보기 |
| `api_print_otc_label()` | 7900 | 인쇄 |
| `api_otc_label_print_by_barcode()` | 7948 | GET 인쇄 |
| `api_otc_label_check()` | 8039 | 일괄 확인 |
| `api_search_mssql_drug()` | 8117 | MSSQL 검색 |
### 6.3 otc_label_printer.py 함수
| 함수명 | 설명 |
|---|---|
| `create_otc_label_image()` | 라벨 이미지 생성 (PIL) |
| `print_otc_label()` | Brother QL로 인쇄 |
| `generate_preview_image()` | Base64 미리보기 생성 |
---
## 7. 트러블슈팅
### 프린터 연결 테스트
```python
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
result = sock.connect_ex(("192.168.0.168", 9100))
print("OK" if result == 0 else f"FAIL: {result}")
sock.close()
```
### 모듈 로드 실패
`OTC_LABEL_AVAILABLE = False` 로그 발생 시:
- `brother_ql` 설치 확인: `pip install brother_ql`
- Pillow 설치 확인: `pip install Pillow`
### 폰트 깨짐
- Windows: `C:/Windows/Fonts/malgunbd.ttf` 존재 확인
- 대체 폰트: NanumGothicBold 등
---
## 8. 사용 예시
### POS에서 라벨 페이지 열기
```
https://mile.0bin.in/admin/otc-labels?barcode=8806436003118&name=노바손크림
```
### 외부 시스템에서 바로 인쇄
```bash
curl https://mile.0bin.in/api/otc-label-print/8806436003118
```
### 프리셋 일괄 등록 (스크립트)
```python
import requests
labels = [
{"barcode": "8806436003118", "display_name": "노바손크림", "effect": "습진", "dosage_instruction": "1일 2회"},
{"barcode": "8806436058613", "display_name": "게보린", "effect": "두통", "dosage_instruction": "1회 1정"},
]
for label in labels:
requests.post("https://mile.0bin.in/api/admin/otc-labels", json=label)
```
---
*문서 작성: 2026-03-19*

263
docs/환산계수.md Normal file
View File

@@ -0,0 +1,263 @@
# 건조시럽 환산계수 시스템
> 작성일: 2026-03-19
> 작성자: 용림 🐉
---
## 1. 개요
건조시럽(dry syrup)은 물로 희석하여 복용하는 시럽 형태의 의약품입니다.
**환산계수(conversion_factor)**를 사용하여 복용량(mL)을 실제 분말량(g)으로 변환합니다.
### 계산 예시
```
오구멘틴듀오시럽 228mg/5ml
├─ 환산계수: 0.11
├─ 총량: 120mL
└─ 필요 분말량: 120 × 0.11 = 13.2g
```
---
## 2. 데이터베이스 정보
### PostgreSQL 연결
| 항목 | 값 |
|------|-----|
| **Host** | 192.168.0.39 |
| **Port** | 5432 |
| **Database** | label10 |
| **User** | admin |
| **Password** | trajet6640 |
### Connection String
```
postgresql://admin:trajet6640@192.168.0.39:5432/label10
```
### Python 연결 코드
```python
import psycopg2
conn = psycopg2.connect(
host='192.168.0.39',
port=5432,
database='label10',
user='admin',
password='trajet6640'
)
```
---
## 3. 테이블 스키마
### drysyrup 테이블
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| `idx` | INTEGER | PK, 자동증가 |
| `ingredient_code` | VARCHAR | 성분코드 (MSSQL SUNG_CODE와 매칭) |
| `ingredient_name` | VARCHAR | 성분명 |
| `product_name` | VARCHAR | 대표 제품명 |
| `post_prep_amount` | VARCHAR | 조제 후 농도 (예: 25mg/ml) |
| `main_ingredient_amt` | VARCHAR | 주성분량 (예: 0.75g/16.7g) |
| `conversion_factor` | DOUBLE PRECISION | **환산계수** (mL → g) |
| `storage_conditions` | VARCHAR | 보관조건 (냉장, 상온 등) |
| `expiration_date` | VARCHAR | 조제 후 유효기간 |
### 매핑 관계
```
MSSQL (PIT3000) PostgreSQL (label10)
───────────────── ────────────────────
PM_DRUG.CD_GOODS drysyrup
└─ SUNG_CODE ──────▶ └─ ingredient_code
```
---
## 4. 데이터 샘플 (23건)
| idx | ingredient_code | 성분명 | 제품명 | 환산계수 | 보관 | 유효기간 |
|-----|-----------------|--------|--------|----------|------|----------|
| 18 | 125333ASY | 세파드록실수화물 | 보령듀리세프 125mg/5ml | 0.557 | 냉장 | 14일 |
| 19 | 125332ASY | 세파드록실수화물 | 보령듀리세프 250mg/5ml | 0.557 | 냉장 | 14일 |
| 20 | 125237ASY | 세파클러수화물 | 크로세프 | 0.667 | 냉장 | 14일 |
| 21 | 128931ASY | 세푸록심악세틸 | 올세프 | 1.0 | 25℃이하 | 10일 |
| 22 | 127931ASY | 세프포독심프록세틸 | 포독스 | 0.2 | 냉장 | 14일 |
| 23 | 128030ASY | 세프프로질수화물 | 세프질시럽 | 0.5 | 냉장 | 14일 |
| 24 | 108130ASY | 아목시실린수화물 | 파목신시럽 | 0.775 | 냉장 | 14일 |
| 25 | 535000ASY | 아목시실린+클라불란산 | 오구멘틴듀오 228mg/5ml | **0.11** | 냉장 | 7일 |
| 26 | 536300ASY | 아목시실린+클라불란산 | 아목클란네오시럽 | 0.22 | 냉장 | 7일 |
| 27 | 112732ASY | 아지트로마이신수화물 | 지스로맥스 | 0.867 | 상온 | 5일 |
---
## 5. API 엔드포인트
### 환산계수 조회
```
GET /api/drug-info/conversion-factor/<sung_code>
```
#### 요청 예시
```bash
curl https://mile.0bin.in/api/drug-info/conversion-factor/535000ASY
```
#### 응답 (성공)
```json
{
"success": true,
"sung_code": "535000ASY",
"conversion_factor": 0.11,
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
}
```
#### 응답 (데이터 없음)
```json
{
"success": true,
"sung_code": "NOTEXIST",
"conversion_factor": null,
"ingredient_name": null,
"product_name": null
}
```
---
## 6. 쿼리 예시
### 환산계수 조회
```sql
SELECT conversion_factor, ingredient_name, product_name,
storage_conditions, expiration_date
FROM drysyrup
WHERE ingredient_code = '535000ASY';
```
### 전체 목록 조회
```sql
SELECT * FROM drysyrup ORDER BY idx;
```
### 특정 성분 검색
```sql
SELECT * FROM drysyrup
WHERE ingredient_name LIKE '%아목시실린%';
```
---
## 7. Python 사용 예시
### 환산계수 조회 함수
```python
import psycopg2
def get_conversion_factor(sung_code):
"""성분코드로 환산계수 조회"""
conn = psycopg2.connect(
host='192.168.0.39',
port=5432,
database='label10',
user='admin',
password='trajet6640'
)
cursor = conn.cursor()
cursor.execute("""
SELECT conversion_factor, ingredient_name, product_name,
storage_conditions, expiration_date
FROM drysyrup
WHERE ingredient_code = %s
""", (sung_code,))
row = cursor.fetchone()
conn.close()
if row:
return {
'conversion_factor': row[0],
'ingredient_name': row[1],
'product_name': row[2],
'storage_conditions': row[3],
'expiration_date': row[4]
}
return None
# 사용 예시
result = get_conversion_factor('535000ASY')
print(result)
# {'conversion_factor': 0.11, 'ingredient_name': '아목시실린...', ...}
```
### 분말량 계산 함수
```python
def calculate_powder_amount(sung_code, total_ml):
"""총 mL로 필요한 분말량(g) 계산"""
data = get_conversion_factor(sung_code)
if data and data['conversion_factor']:
return round(total_ml * data['conversion_factor'], 2)
return None
# 사용 예시
powder = calculate_powder_amount('535000ASY', 120)
print(f"필요 분말량: {powder}g") # 필요 분말량: 13.2g
```
---
## 8. 관련 파일
| 파일 | 위치 | 설명 |
|------|------|------|
| app.py | `backend/app.py` | Flask API 라우트 |
| DRYSYRUP_CONVERSION.md | `docs/` | 기존 문서 |
### Flask 라우트 위치
```python
# backend/app.py
@app.route('/api/drug-info/conversion-factor/<sung_code>')
def get_drug_conversion_factor(sung_code):
...
```
---
## 9. 아키텍처
```
┌─────────────────────────────────────────────────────────┐
│ 클라이언트 │
│ (POS, 라벨 프린터, 웹 UI) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Flask Backend (7001) │
│ GET /api/drug-info/conversion-factor/<sung_code> │
└─────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ MSSQL (PIT3000) │ │ PostgreSQL │
│ 192.168.0.4 │ │ 192.168.0.39:5432 │
├─────────────────────┤ ├─────────────────────┤
│ PM_DRUG.CD_GOODS │ │ label10.drysyrup │
│ └─ SUNG_CODE ─────┼──────▶│ └─ ingredient_code│
│ └─ GoodsName │ │ └─ conversion_factor
│ └─ DrugCode │ │ └─ storage_conditions
└─────────────────────┘ └─────────────────────┘
```
---
*총 23개 건조시럽 환산계수 등록됨*