Compare commits

..

7 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
thug0bin
3871154509 fix: PAAI OpenClaw 호출 방식 변경 (WebSocket -> CLI)
- OpenClaw 업데이트로 device identity 필수화됨
- WebSocket 대신 Node.js 직접 호출로 변경
- 특수문자/줄바꿈 문제 해결 (shell=True 제거)
- subprocess array 방식으로 안전한 인자 전달
2026-03-28 12:42:01 +09:00
thug0bin
f855fc5916 feat: OTC 라벨 프리셋 확인 API + 인쇄 조건 강화
- GET /api/otc-label-check - 바코드 배열로 프리셋 존재 여부 일괄 확인
- 인쇄 API: 프리셋 없으면 인쇄 안 함 (404 반환)
- POS 장바구니에서 인쇄 버튼 활성화 판단용
2026-03-14 00:36:57 +09:00
thug0bin
cb7450f654 feat: OTC 라벨 바로 인쇄 API 추가 (CORS 지원)
- GET /api/otc-label-print/{barcode} - 바로 인쇄
- 프리셋 있으면 해당 데이터로, 없으면 약품명만으로 인쇄
- 인쇄 횟수 자동 카운트
2026-03-14 00:18:58 +09:00
thug0bin
67fb7bf937 feat: 동물약 안내서/제품 이미지 외부 API 추가 (CORS 지원)
- GET /api/animal-drug-print/{apc|barcode} - 바로 인쇄
- GET /api/product-image/{barcode} - 제품 이미지 반환
- GET /api/product-image-info/{barcode} - 이미지 메타데이터
- 바코드→APC 자동 변환 지원
2026-03-14 00:18:04 +09:00
15 changed files with 4729 additions and 81 deletions

File diff suppressed because it is too large Load Diff

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 사용

View File

@@ -25,23 +25,29 @@ import websockets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Gateway 설정 (clawdbot.json에서 읽기) # Gateway 설정 (openclaw.json 또는 clawdbot.json에서 읽기)
OPENCLAW_CONFIG_PATH = Path.home() / '.openclaw' / 'openclaw.json'
CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json' CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json'
def _load_gateway_config(): def _load_gateway_config():
"""clawdbot.json에서 Gateway 설정 로드""" """OpenClaw/Clawdbot Gateway 설정 로드"""
try: for config_path in [OPENCLAW_CONFIG_PATH, CLAWDBOT_CONFIG_PATH]:
with open(CLAWDBOT_CONFIG_PATH, 'r', encoding='utf-8') as f: try:
config = json.load(f) with open(config_path, 'r', encoding='utf-8') as f:
gw = config.get('gateway', {}) config = json.load(f)
return { gw = config.get('gateway', {})
'port': gw.get('port', 18789), token = gw.get('auth', {}).get('token', '')
'token': gw.get('auth', {}).get('token', ''), if token:
} logger.info(f"[Gateway] 설정 로드: {config_path.name}")
except Exception as e: return {
logger.warning(f"[Clawdbot] 설정 파일 로드 실패: {e}") 'port': gw.get('port', 18789),
return {'port': 18789, 'token': ''} 'token': token,
}
except Exception:
continue
logger.warning("[Gateway] 설정 파일 로드 실패")
return {'port': 18789, 'token': ''}
async def _ask_gateway(message, session_id='pharmacy-upsell', async def _ask_gateway(message, session_id='pharmacy-upsell',
@@ -85,10 +91,10 @@ async def _ask_gateway(message, session_id='pharmacy-upsell',
'maxProtocol': 3, 'maxProtocol': 3,
'client': { 'client': {
'id': 'gateway-client', 'id': 'gateway-client',
'displayName': 'Pharmacy Upsell', 'displayName': 'Pharmacy PAAI',
'version': '1.0.0', 'version': '1.0.0',
'platform': 'win32', 'platform': 'win32',
'mode': 'backend', 'mode': 'cli',
'instanceId': str(uuid.uuid4()), 'instanceId': str(uuid.uuid4()),
}, },
'caps': [], 'caps': [],
@@ -96,7 +102,7 @@ async def _ask_gateway(message, session_id='pharmacy-upsell',
'token': token, 'token': token,
}, },
'role': 'operator', 'role': 'operator',
'scopes': ['operator.admin'], 'scopes': ['operator.admin', 'operator.write', 'operator.read'],
} }
} }
await ws.send(json.dumps(connect_frame)) await ws.send(json.dumps(connect_frame))
@@ -198,27 +204,65 @@ async def _ask_gateway(message, session_id='pharmacy-upsell',
def ask_clawdbot(message, session_id='pharmacy-upsell', def ask_clawdbot(message, session_id='pharmacy-upsell',
system_prompt=None, timeout=60, model=None): system_prompt=None, timeout=60, model=None):
""" """
동기 래퍼: Flask에서 직접 호출 가능 OpenClaw CLI를 통한 AI 호출 (WebSocket 대신)
Args: Args:
message: 사용자 메시지 message: 사용자 메시지
session_id: 세션 ID (대화 구분용) session_id: 세션 ID (대화 구분용)
system_prompt: 추가 시스템 프롬프트 system_prompt: 추가 시스템 프롬프트 (현재 미사용)
timeout: 타임아웃 (초) timeout: 타임아웃 (초)
model: 모델 오버라이드 (예: 'anthropic/claude-sonnet-4-5') model: 모델 오버라이드 (현재 미사용 - CLI가 기본 모델 사용)
Returns: Returns:
str: AI 응답 텍스트 (실패 시 None) str: AI 응답 텍스트 (실패 시 None)
""" """
import subprocess
import os
from pathlib import Path
try: try:
loop = asyncio.new_event_loop() # Node.js로 OpenClaw 직접 호출 (shell 없이, 특수문자 안전)
result = loop.run_until_complete( node_path = r'C:\Program Files\nodejs\node.exe'
_ask_gateway(message, session_id, system_prompt, timeout, model=model) openclaw_path = str(Path.home() / 'AppData/Roaming/npm/node_modules/openclaw/openclaw.mjs')
cmd = [node_path, openclaw_path, 'agent', '-m', message, '--session-id', session_id, '--json']
logger.info(f"[OpenClaw] session={session_id}, msg_len={len(message)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout + 30,
encoding='utf-8',
env={**os.environ, 'PYTHONIOENCODING': 'utf-8'}
) )
loop.close()
return result if result.returncode != 0:
logger.warning(f"[OpenClaw] CLI 에러: {result.stderr}")
# 에러가 있어도 stdout에 결과가 있을 수 있음
if not result.stdout:
return None
# JSON 파싱
data = json.loads(result.stdout)
if data.get('status') == 'ok':
payloads = data.get('result', {}).get('payloads', [])
if payloads:
text = payloads[0].get('text', '')
logger.info(f"[OpenClaw] 응답 수신: {len(text)}")
return text
logger.warning(f"[OpenClaw] 응답 없음: {data}")
return None
except subprocess.TimeoutExpired:
logger.warning(f"[OpenClaw] 타임아웃 ({timeout}초)")
return None
except json.JSONDecodeError as e:
logger.warning(f"[OpenClaw] JSON 파싱 실패: {e}")
return None
except Exception as e: except Exception as e:
logger.warning(f"[Clawdbot] 호출 실패: {e}") logger.warning(f"[OpenClaw] 호출 실패: {e}")
return None return None
@@ -356,13 +400,13 @@ async def _get_gateway_status():
'displayName': 'Pharmacy Status', 'displayName': 'Pharmacy Status',
'version': '1.0.0', 'version': '1.0.0',
'platform': 'win32', 'platform': 'win32',
'mode': 'backend', 'mode': 'cli',
'instanceId': str(uuid.uuid4()), 'instanceId': str(uuid.uuid4()),
}, },
'caps': [], 'caps': [],
'auth': {'token': token}, 'auth': {'token': token},
'role': 'operator', 'role': 'operator',
'scopes': ['operator.read'], 'scopes': ['operator.admin', 'operator.write', 'operator.read'],
} }
} }
await ws.send(json.dumps(connect_frame)) await ws.send(json.dumps(connect_frame))

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개 건조시럽 환산계수 등록됨*