Compare commits

...

3 Commits

Author SHA1 Message Date
thug0bin
e7daadb316 fix: QR 품목은 MSSQL 수납완료 데이터에서만 조회 2026-03-29 12:58:45 +09:00
thug0bin
8bcea3040f feat: QR 토큰 API에서 클라이언트 items 우선 처리
- /api/admin/qr/generate에서 client_items 파라미터 추가
- 클라이언트 전달 items 우선, 없으면 MSSQL 조회
- get_sale_items 쿼리 컬럼명 수정 (DrugCode, GoodsName 등)
2026-03-29 12:54:20 +09:00
thug0bin
21e1c3adfa feat: QR 토큰 품목 상세 전송 지원 (items 파라미터) 2026-03-29 12:37:36 +09:00
14 changed files with 4029 additions and 52 deletions

View File

@@ -54,6 +54,56 @@ except ImportError as e:
OTC_LABEL_AVAILABLE = False OTC_LABEL_AVAILABLE = False
logging.warning(f"OTC 라벨 프린터 모듈 로드 실패: {e}") logging.warning(f"OTC 라벨 프린터 모듈 로드 실패: {e}")
# 약국 코드 가져오기 (config.json)
def get_pharmacy_code():
import json
config_path = os.path.join(os.path.dirname(__file__), 'config.json')
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get('pharmacy_code', 'P0001')
except:
return 'P0001'
# 판매 품목 조회 (MSSQL)
def get_sale_items(transaction_id):
"""MSSQL에서 판매 품목 조회"""
try:
from db.dbsetup import db_manager
from sqlalchemy import text
mssql_engine = db_manager.get_engine('PM_PRES')
with mssql_engine.connect() as conn:
result = conn.execute(text("""
SELECT
s.DrugCode as item_code,
g.GoodsName as item_name,
s.QUAN as quantity,
s.SL_INPUT_PRICE as unit_price,
s.SL_TOTAL_PRICE as total_price
FROM SALE_SUB s
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON g.DrugCode = s.DrugCode
WHERE s.SL_NO_order = :tx_id
"""), {'tx_id': transaction_id})
items = []
for row in result:
items.append({
'item_code': row.item_code or '',
'item_name': row.item_name or '상품',
'quantity': int(row.quantity) if row.quantity else 1,
'unit_price': int(row.unit_price) if row.unit_price else 0,
'total_price': int(row.total_price) if row.total_price else 0
})
print(f"[QR] 품목 조회 성공: {transaction_id}{len(items)}")
return items
except Exception as e:
print(f"[QR] 품목 조회 실패: {e}")
return []
app = Flask(__name__) app = Flask(__name__)
app.secret_key = 'pharmacy-qr-mileage-secret-key-2026' app.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
@@ -89,6 +139,9 @@ app.register_blueprint(wholesaler_config_bp)
from order_api import order_bp from order_api import order_bp
app.register_blueprint(order_bp) app.register_blueprint(order_bp)
from order_recommendation import order_recommendation_bp
app.register_blueprint(order_recommendation_bp)
# 데이터베이스 매니저 # 데이터베이스 매니저
db_manager = DatabaseManager() db_manager = DatabaseManager()
@@ -2791,23 +2844,24 @@ def api_kiosk_trigger():
from utils.qr_token_generator import QR_BASE_URL from utils.qr_token_generator import QR_BASE_URL
qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}" qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}"
else: else:
# 새 토큰 생성 # 새 토큰 생성 + 서버 동기화 (v2)
from utils.qr_token_generator import generate_claim_token, save_token_to_db from utils.qr_token_generator import generate_and_sync_token
token_info = generate_claim_token(transaction_id, float(amount)) # 품목 조회
success, error = save_token_to_db( sale_items = get_sale_items(transaction_id)
transaction_id, token_info = generate_and_sync_token(transaction_id, float(amount), get_pharmacy_code(), items=sale_items)
token_info['token_hash'],
float(amount), if not token_info.get('local_saved'):
token_info['claimable_points'], return jsonify({'success': False, 'message': token_info.get('local_error', 'DB 저장 실패')}), 500
token_info['expires_at'],
token_info['pharmacy_id']
)
if not success:
return jsonify({'success': False, 'message': error}), 500
claimable_points = token_info['claimable_points'] claimable_points = token_info['claimable_points']
qr_url = token_info['qr_url'] 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에서 구매 품목 조회 # MSSQL에서 구매 품목 조회
sale_items = [] sale_items = []
try: try:
@@ -4846,6 +4900,191 @@ def admin_rx_usage():
return render_template('admin_rx_usage.html') 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') @app.route('/api/usage')
def api_usage(): def api_usage():
""" """
@@ -7160,15 +7399,23 @@ def api_admin_pos_live_detail(order_no):
mssql_conn.close() 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 (비동기) MSSQL 회원번호(CUSCODE)로 SQLite user 조회
- CD_PERSON에서 이름+전화번호 조회
- SQLite users와 이름+전화뒤4자리로 매칭 맵핑 방식:
1. CD_PERSON에서 CUSCODE로 이름+전화번호 조회
2. SQLite users에서 이름+전화뒤4자리로 매칭
Returns:
dict: {'id', 'nickname', 'phone', 'mileage_balance'} 또는 None
""" """
if not cus_code or cus_code == '0000000000': if not cus_code or cus_code == '0000000000':
return jsonify({'success': False, 'mileage': None}) return None
mssql_conn = None mssql_conn = None
try: try:
@@ -7185,7 +7432,7 @@ def api_customer_mileage(cus_code):
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
return jsonify({'success': False, 'mileage': None}) return None
name, phone1, phone2, phone3 = row name, phone1, phone2, phone3 = row
phone = phone1 or phone2 or phone3 or '' phone = phone1 or phone2 or phone3 or ''
@@ -7193,14 +7440,14 @@ def api_customer_mileage(cus_code):
last4 = phone_digits[-4:] if len(phone_digits) >= 4 else '' last4 = phone_digits[-4:] if len(phone_digits) >= 4 else ''
if not name or not last4: if not name or not last4:
return jsonify({'success': False, 'mileage': None}) return None
# 2. SQLite에서 이름+전화뒤4자리로 매칭 # 2. SQLite에서 이름+전화뒤4자리로 매칭
sqlite_conn = db_manager.get_sqlite_connection() sqlite_conn = db_manager.get_sqlite_connection()
sqlite_cursor = sqlite_conn.cursor() sqlite_cursor = sqlite_conn.cursor()
sqlite_cursor.execute(""" sqlite_cursor.execute("""
SELECT nickname, phone, mileage_balance SELECT id, nickname, phone, mileage_balance
FROM users FROM users
""") """)
@@ -7209,22 +7456,108 @@ def api_customer_mileage(cus_code):
user_last4 = user_phone[-4:] if len(user_phone) >= 4 else '' user_last4 = user_phone[-4:] if len(user_phone) >= 4 else ''
if user['nickname'] == name and user_last4 == last4: if user['nickname'] == name and user_last4 == last4:
return jsonify({ return dict(user)
'success': True,
'mileage': user['mileage_balance'] or 0,
'name': name
})
return jsonify({'success': False, 'mileage': None}) return None
except Exception as e: except Exception as e:
logging.error(f"마일리지 조회 오류: {e}") logging.error(f"CUSCODE→SQLite 맵핑 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 return None
finally: finally:
if mssql_conn: if mssql_conn:
mssql_conn.close() 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') @app.route('/api/customers/search')
def api_customers_search(): def api_customers_search():
""" """
@@ -7443,8 +7776,8 @@ def api_admin_qr_generate():
if not order_no: if not order_no:
return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400 return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400
# 기존 모듈 import # 기존 모듈 import (v2: 서버 동기화 포함)
from utils.qr_token_generator import generate_claim_token, save_token_to_db from utils.qr_token_generator import generate_and_sync_token
from utils.qr_label_printer import print_qr_label from utils.qr_label_printer import print_qr_label
# 거래 시간 조회 (MSSQL) # 거래 시간 조회 (MSSQL)
@@ -7465,21 +7798,19 @@ def api_admin_qr_generate():
if amount <= 0: if amount <= 0:
amount = float(row[1]) if row[1] else 0 amount = float(row[1]) if row[1] else 0
# 1. 토큰 생성 # 1. 토큰 생성 + 로컬 저장 + 서버 동기화 (v2)
token_info = generate_claim_token(order_no, amount) # 품목 조회 (MSSQL SALE_SUB - 수납완료 데이터)
sale_items = get_sale_items(order_no)
token_info = generate_and_sync_token(order_no, amount, get_pharmacy_code(), items=sale_items)
# 2. DB 저장 if not token_info.get('local_saved'):
success, error = save_token_to_db( return jsonify({'success': False, 'error': token_info.get('local_error', 'DB 저장 실패')}), 400
order_no,
token_info['token_hash'],
amount,
token_info['claimable_points'],
token_info['expires_at'],
token_info['pharmacy_id']
)
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. 미리보기 이미지 생성 # 3. 미리보기 이미지 생성
image_url = None image_url = None
@@ -9339,6 +9670,43 @@ def admin_paai():
return render_template('admin_paai.html') 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') @app.route('/api/paai/logs')
def api_paai_logs(): def api_paai_logs():
"""PAAI 로그 목록 조회""" """PAAI 로그 목록 조회"""
@@ -11013,6 +11381,19 @@ def api_product_image_info(barcode):
return response, 500 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__': if __name__ == '__main__':
import os import os

11
backend/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"pharmacy_code": "P0001",
"pharmacy_name": "청춘약국",
"cloud_api_url": "https://pos.pharmq.kr",
"pos_printer": {
"ip": "192.168.0.174",
"port": 9100
},
"pharmacist_name": "김영빈",
"license_number": "72672"
}

View File

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

View File

@@ -289,6 +289,43 @@ def get_log_detail(log_id: int) -> dict:
return log 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: def get_stats() -> dict:
"""통계 조회""" """통계 조회"""
if not DB_PATH.exists(): 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) # 5. Clawdbot AI 호출 (WebSocket)
ai_start = time_module.time() 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_time = int((time_module.time() - ai_start) * 1000)
# AI 결과 로그 업데이트 # AI 결과 로그 업데이트
@@ -1744,21 +1744,32 @@ def build_paai_prompt(
return prompt return prompt
def call_clawdbot_ai(prompt: str) -> dict: def call_clawdbot_ai(prompt: str, cus_code: str = None) -> dict:
"""Clawdbot AI 호출 (WebSocket Gateway)""" """Clawdbot AI 호출 (WebSocket Gateway)
Args:
prompt: AI에게 보낼 프롬프트
cus_code: 환자 코드 (세션 분리용, 같은 날 같은 환자는 세션 공유)
"""
import json import json
import re import re
from datetime import datetime
from services.clawdbot_client import ask_clawdbot from services.clawdbot_client import ask_clawdbot
PAAI_SYSTEM_PROMPT = """당신은 경험 많은 약사입니다. PAAI_SYSTEM_PROMPT = """당신은 경험 많은 약사입니다.
처방 데이터를 분석하여 약사에게 유용한 정보를 제공합니다. 처방 데이터를 분석하여 약사에게 유용한 정보를 제공합니다.
이전 대화와 관계없이, 아래 제공된 처방 정보만 보고 독립적으로 분석하세요.
반드시 요청된 JSON 형식으로만 응답하세요.""" 반드시 요청된 JSON 형식으로만 응답하세요."""
# 세션 ID: 날짜별 단일 세션 (하루 1개)
today = datetime.now().strftime('%Y%m%d')
session_id = f'paai-{today}'
try: try:
# Clawdbot Gateway WebSocket API 호출 # Clawdbot Gateway WebSocket API 호출
ai_text = ask_clawdbot( ai_text = ask_clawdbot(
message=prompt, message=prompt,
session_id='paai-analysis', session_id=session_id,
system_prompt=PAAI_SYSTEM_PROMPT, system_prompt=PAAI_SYSTEM_PROMPT,
timeout=60, timeout=60,
model='anthropic/claude-sonnet-4-5' # 빠른 Sonnet 사용 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:hover { border-color: #10b981; }
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; } .paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
.paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; } .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 { .paai-timing {
font-size: 0.8rem; font-size: 0.8rem;
color: #9ca3af; color: #9ca3af;
@@ -1456,6 +1484,8 @@
<span>도움이 되셨나요?</span> <span>도움이 되셨나요?</span>
<button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button> <button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button>
<button onclick="sendPaaiFeedback(false)" id="paaiNotUseful">👎 아니요</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>
<div class="paai-timing" id="paaiTiming"></div> <div class="paai-timing" id="paaiTiming"></div>
</div> </div>
@@ -2674,6 +2704,146 @@
triggerPaaiAnalysis(); 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) { function displayPaaiResult(result) {
const body = document.getElementById('paaiBody'); const body = document.getElementById('paaiBody');
const footer = document.getElementById('paaiFooter'); const footer = document.getElementById('paaiFooter');

View File

@@ -1,10 +1,14 @@
""" """
QR Claim Token 생성 모듈 QR Claim Token 생성 모듈
후향적 적립을 위한 1회성 토큰 생성 후향적 적립을 위한 1회성 토큰 생성
v2 (2026-03-29): 서버 즉시 전송 추가 (pos.pharmq.kr)
""" """
import hashlib import hashlib
import secrets import secrets
import logging
import requests
from datetime import datetime, timedelta from datetime import datetime, timedelta
import sys import sys
import os import os
@@ -16,7 +20,23 @@ from db.dbsetup import DatabaseManager
# 설정값 # 설정값
MILEAGE_RATE = 0.03 # 3% 적립 MILEAGE_RATE = 0.03 # 3% 적립
TOKEN_EXPIRY_DAYS = 30 # 30일 유효기간 TOKEN_EXPIRY_DAYS = 30 # 30일 유효기간
QR_BASE_URL = "https://mile.0bin.in/claim"
# 서버 설정 (v2) - config.json에서 읽기
import json
_config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
try:
with open(_config_path, 'r', encoding='utf-8') as f:
_config = json.load(f)
CLOUD_API_URL = _config.get('cloud_api_url', 'https://pos.pharmq.kr')
PHARMACY_CODE = _config.get('pharmacy_code', 'P0001')
except:
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"): def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"):
@@ -77,7 +97,8 @@ def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"):
'expires_at': expires_at, 'expires_at': expires_at,
'pharmacy_id': pharmacy_id, 'pharmacy_id': pharmacy_id,
'transaction_id': transaction_id, 'transaction_id': transaction_id,
'total_amount': total_amount 'total_amount': total_amount,
'nonce': nonce, # 서버 전송용
} }
@@ -150,6 +171,97 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
return (False, f"DB 저장 실패: {str(e)}") 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__": 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개 건조시럽 환산계수 등록됨*