Compare commits
3 Commits
3871154509
...
e7daadb316
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7daadb316 | ||
|
|
8bcea3040f | ||
|
|
21e1c3adfa |
471
backend/app.py
471
backend/app.py
@@ -54,6 +54,56 @@ except ImportError as e:
|
||||
OTC_LABEL_AVAILABLE = False
|
||||
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.secret_key = 'pharmacy-qr-mileage-secret-key-2026'
|
||||
|
||||
@@ -89,6 +139,9 @@ app.register_blueprint(wholesaler_config_bp)
|
||||
from order_api import order_bp
|
||||
app.register_blueprint(order_bp)
|
||||
|
||||
from order_recommendation import order_recommendation_bp
|
||||
app.register_blueprint(order_recommendation_bp)
|
||||
|
||||
# 데이터베이스 매니저
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
@@ -2791,23 +2844,24 @@ def api_kiosk_trigger():
|
||||
from utils.qr_token_generator import QR_BASE_URL
|
||||
qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}"
|
||||
else:
|
||||
# 새 토큰 생성
|
||||
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||||
token_info = generate_claim_token(transaction_id, float(amount))
|
||||
success, error = save_token_to_db(
|
||||
transaction_id,
|
||||
token_info['token_hash'],
|
||||
float(amount),
|
||||
token_info['claimable_points'],
|
||||
token_info['expires_at'],
|
||||
token_info['pharmacy_id']
|
||||
)
|
||||
if not success:
|
||||
return jsonify({'success': False, 'message': error}), 500
|
||||
# 새 토큰 생성 + 서버 동기화 (v2)
|
||||
from utils.qr_token_generator import generate_and_sync_token
|
||||
# 품목 조회
|
||||
sale_items = get_sale_items(transaction_id)
|
||||
token_info = generate_and_sync_token(transaction_id, float(amount), get_pharmacy_code(), items=sale_items)
|
||||
|
||||
if not token_info.get('local_saved'):
|
||||
return jsonify({'success': False, 'message': token_info.get('local_error', 'DB 저장 실패')}), 500
|
||||
|
||||
claimable_points = token_info['claimable_points']
|
||||
qr_url = token_info['qr_url']
|
||||
|
||||
# 서버 동기화 로그 (실패해도 계속 진행)
|
||||
if token_info.get('synced'):
|
||||
print(f"[QR] 서버 동기화 완료: {transaction_id} → ID:{token_info.get('server_token_id')}")
|
||||
else:
|
||||
print(f"[QR] 서버 동기화 실패 (오프라인?): {transaction_id}")
|
||||
|
||||
# MSSQL에서 구매 품목 조회
|
||||
sale_items = []
|
||||
try:
|
||||
@@ -4846,6 +4900,191 @@ def admin_rx_usage():
|
||||
return render_template('admin_rx_usage.html')
|
||||
|
||||
|
||||
@app.route('/admin/price-trend')
|
||||
def admin_price_trend():
|
||||
"""가격 변동 추이 분석 페이지"""
|
||||
return render_template('admin_price_trend.html')
|
||||
|
||||
|
||||
@app.route('/api/price-trend/search')
|
||||
def api_price_trend_search():
|
||||
"""
|
||||
가격 추이 분석용 약품 검색 API (자동완성)
|
||||
GET /api/price-trend/search?q=검색어&limit=15
|
||||
"""
|
||||
try:
|
||||
query = request.args.get('q', '').strip()
|
||||
limit = min(int(request.args.get('limit', 15)), 30) # 최대 30개
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({'success': True, 'items': []})
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# SALE_sub에서 판매 기록이 있는 약품 검색 (TOP 값 직접 삽입)
|
||||
search_query = text(f"""
|
||||
SELECT TOP {limit}
|
||||
S.BARCODE as barcode,
|
||||
S.DrugCode as drug_code,
|
||||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||
COUNT(*) as sale_count
|
||||
FROM SALE_sub S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.BARCODE IS NOT NULL AND S.BARCODE != ''
|
||||
AND (S.BARCODE LIKE :query OR G.GoodsName LIKE :query_like)
|
||||
GROUP BY S.BARCODE, S.DrugCode, G.GoodsName
|
||||
ORDER BY COUNT(*) DESC
|
||||
""")
|
||||
|
||||
results = mssql_session.execute(search_query, {
|
||||
'query': query,
|
||||
'query_like': f'%{query}%'
|
||||
}).fetchall()
|
||||
|
||||
items = []
|
||||
for row in results:
|
||||
items.append({
|
||||
'barcode': row[0],
|
||||
'drug_code': row[1],
|
||||
'product_name': row[2],
|
||||
'sale_count': int(row[3])
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'items': items})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"가격 추이 검색 실패: {e}")
|
||||
return jsonify({'success': False, 'error': str(e), 'items': []})
|
||||
|
||||
|
||||
@app.route('/api/price-trend')
|
||||
def api_price_trend():
|
||||
"""
|
||||
제품별 가격 변동 추이 조회 API
|
||||
GET /api/price-trend?query=바코드또는약품명&period=365
|
||||
"""
|
||||
try:
|
||||
query = request.args.get('query', '').strip()
|
||||
period = int(request.args.get('period', 365)) # 기간 (일)
|
||||
|
||||
if not query:
|
||||
return jsonify({'success': False, 'error': '검색어를 입력하세요'})
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
|
||||
# 기간 계산
|
||||
if period > 0:
|
||||
from datetime import datetime, timedelta
|
||||
start_date = (datetime.now() - timedelta(days=period)).strftime('%Y%m%d')
|
||||
date_condition = f"AND S.SL_DT_appl >= '{start_date}'"
|
||||
else:
|
||||
date_condition = ""
|
||||
|
||||
# 바코드로 먼저 검색, 없으면 약품명으로 검색
|
||||
barcode_query = text(f"""
|
||||
SELECT TOP 1 BARCODE, DrugCode
|
||||
FROM SALE_sub
|
||||
WHERE BARCODE = :query OR DrugCode = :query
|
||||
""")
|
||||
result = mssql_session.execute(barcode_query, {'query': query}).fetchone()
|
||||
|
||||
if result:
|
||||
barcode = result[0]
|
||||
drug_code = result[1]
|
||||
else:
|
||||
# 약품명으로 검색
|
||||
name_query = text("""
|
||||
SELECT TOP 1 S.BARCODE, S.DrugCode
|
||||
FROM SALE_sub S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE G.GoodsName LIKE :query
|
||||
AND S.BARCODE IS NOT NULL AND S.BARCODE != ''
|
||||
""")
|
||||
result = mssql_session.execute(name_query, {'query': f'%{query}%'}).fetchone()
|
||||
if not result:
|
||||
return jsonify({'success': False, 'error': f'"{query}"에 대한 판매 기록이 없습니다'})
|
||||
barcode = result[0]
|
||||
drug_code = result[1]
|
||||
|
||||
# 약품명 조회
|
||||
name_query = text("""
|
||||
SELECT GoodsName FROM PM_DRUG.dbo.CD_GOODS WHERE DrugCode = :drug_code
|
||||
""")
|
||||
name_result = mssql_session.execute(name_query, {'drug_code': drug_code}).fetchone()
|
||||
product_name = name_result[0] if name_result else '알 수 없음'
|
||||
|
||||
# 일별 가격/마진 데이터 조회
|
||||
trend_query = text(f"""
|
||||
SELECT
|
||||
SL_DT_appl as date,
|
||||
AVG(SL_NM_cost_a) as avg_price,
|
||||
AVG(INPRICE) as avg_cost,
|
||||
AVG(SL_NM_cost_a - INPRICE) as avg_margin,
|
||||
CASE
|
||||
WHEN AVG(SL_NM_cost_a) > 0
|
||||
THEN ROUND((AVG(SL_NM_cost_a) - AVG(INPRICE)) / AVG(SL_NM_cost_a) * 100, 1)
|
||||
ELSE 0
|
||||
END as margin_rate,
|
||||
COUNT(*) as count
|
||||
FROM SALE_sub S
|
||||
WHERE S.BARCODE = :barcode
|
||||
{date_condition}
|
||||
GROUP BY SL_DT_appl
|
||||
ORDER BY SL_DT_appl
|
||||
""")
|
||||
|
||||
results = mssql_session.execute(trend_query, {'barcode': barcode}).fetchall()
|
||||
|
||||
if not results:
|
||||
return jsonify({'success': False, 'error': '해당 기간에 판매 기록이 없습니다'})
|
||||
|
||||
# 데이터 변환
|
||||
data = []
|
||||
for row in results:
|
||||
data.append({
|
||||
'date': row[0],
|
||||
'avg_price': float(row[1] or 0),
|
||||
'avg_cost': float(row[2] or 0),
|
||||
'avg_margin': float(row[3] or 0),
|
||||
'margin_rate': float(row[4] or 0),
|
||||
'count': int(row[5] or 0)
|
||||
})
|
||||
|
||||
# 통계 계산
|
||||
prices = [d['avg_price'] for d in data]
|
||||
costs = [d['avg_cost'] for d in data]
|
||||
margins = [d['margin_rate'] for d in data]
|
||||
|
||||
stats = {
|
||||
'current_price': data[-1]['avg_price'] if data else 0,
|
||||
'current_cost': data[-1]['avg_cost'] if data else 0,
|
||||
'current_margin': data[-1]['margin_rate'] if data else 0,
|
||||
'min_price': min(prices) if prices else 0,
|
||||
'max_price': max(prices) if prices else 0,
|
||||
'min_cost': min(costs) if costs else 0,
|
||||
'max_cost': max(costs) if costs else 0,
|
||||
'min_margin': min(margins) if margins else 0,
|
||||
'max_margin': max(margins) if margins else 0,
|
||||
'total_count': sum(d['count'] for d in data),
|
||||
'first_date': data[0]['date'] if data else '',
|
||||
'last_date': data[-1]['date'] if data else ''
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'barcode': barcode,
|
||||
'product_name': product_name,
|
||||
'data': data,
|
||||
'stats': stats
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"가격 변동 추이 조회 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@app.route('/api/usage')
|
||||
def api_usage():
|
||||
"""
|
||||
@@ -7160,15 +7399,23 @@ def api_admin_pos_live_detail(order_no):
|
||||
mssql_conn.close()
|
||||
|
||||
|
||||
@app.route('/api/customers/<cus_code>/mileage')
|
||||
def api_customer_mileage(cus_code):
|
||||
# ==============================================================================
|
||||
# MSSQL 회원번호(CUSCODE) ↔ SQLite user 맵핑 공통 함수
|
||||
# ==============================================================================
|
||||
|
||||
def get_sqlite_user_by_cuscode(cus_code):
|
||||
"""
|
||||
고객 마일리지 조회 API (비동기)
|
||||
- CD_PERSON에서 이름+전화번호 조회
|
||||
- SQLite users와 이름+전화뒤4자리로 매칭
|
||||
MSSQL 회원번호(CUSCODE)로 SQLite user 조회
|
||||
|
||||
맵핑 방식:
|
||||
1. CD_PERSON에서 CUSCODE로 이름+전화번호 조회
|
||||
2. SQLite users에서 이름+전화뒤4자리로 매칭
|
||||
|
||||
Returns:
|
||||
dict: {'id', 'nickname', 'phone', 'mileage_balance'} 또는 None
|
||||
"""
|
||||
if not cus_code or cus_code == '0000000000':
|
||||
return jsonify({'success': False, 'mileage': None})
|
||||
return None
|
||||
|
||||
mssql_conn = None
|
||||
try:
|
||||
@@ -7185,7 +7432,7 @@ def api_customer_mileage(cus_code):
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({'success': False, 'mileage': None})
|
||||
return None
|
||||
|
||||
name, phone1, phone2, phone3 = row
|
||||
phone = phone1 or phone2 or phone3 or ''
|
||||
@@ -7193,14 +7440,14 @@ def api_customer_mileage(cus_code):
|
||||
last4 = phone_digits[-4:] if len(phone_digits) >= 4 else ''
|
||||
|
||||
if not name or not last4:
|
||||
return jsonify({'success': False, 'mileage': None})
|
||||
return None
|
||||
|
||||
# 2. SQLite에서 이름+전화뒤4자리로 매칭
|
||||
sqlite_conn = db_manager.get_sqlite_connection()
|
||||
sqlite_cursor = sqlite_conn.cursor()
|
||||
|
||||
sqlite_cursor.execute("""
|
||||
SELECT nickname, phone, mileage_balance
|
||||
SELECT id, nickname, phone, mileage_balance
|
||||
FROM users
|
||||
""")
|
||||
|
||||
@@ -7209,22 +7456,108 @@ def api_customer_mileage(cus_code):
|
||||
user_last4 = user_phone[-4:] if len(user_phone) >= 4 else ''
|
||||
|
||||
if user['nickname'] == name and user_last4 == last4:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'mileage': user['mileage_balance'] or 0,
|
||||
'name': name
|
||||
})
|
||||
return dict(user)
|
||||
|
||||
return jsonify({'success': False, 'mileage': None})
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"마일리지 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
logging.error(f"CUSCODE→SQLite 맵핑 오류: {e}")
|
||||
return None
|
||||
finally:
|
||||
if mssql_conn:
|
||||
mssql_conn.close()
|
||||
|
||||
|
||||
@app.route('/api/customers/<cus_code>/mileage')
|
||||
def api_customer_mileage(cus_code):
|
||||
"""
|
||||
고객 마일리지 조회 API (비동기)
|
||||
- CD_PERSON에서 이름+전화번호 조회
|
||||
- SQLite users와 이름+전화뒤4자리로 매칭
|
||||
"""
|
||||
user = get_sqlite_user_by_cuscode(cus_code)
|
||||
|
||||
if user:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'mileage': user['mileage_balance'] or 0,
|
||||
'name': user['nickname']
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'mileage': None})
|
||||
|
||||
|
||||
@app.route('/api/customers/<cus_code>/pets')
|
||||
def api_customer_pets(cus_code):
|
||||
"""
|
||||
고객 반려동물 조회 API
|
||||
- MSSQL 회원번호(CUSCODE)로 SQLite user 맵핑
|
||||
- 해당 user의 반려동물 목록 반환
|
||||
"""
|
||||
user = get_sqlite_user_by_cuscode(cus_code)
|
||||
|
||||
if not user:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '매칭된 회원이 없습니다',
|
||||
'pets': []
|
||||
})
|
||||
|
||||
try:
|
||||
sqlite_conn = db_manager.get_sqlite_connection()
|
||||
cursor = sqlite_conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, name, species, breed, gender, birth_date,
|
||||
age_months, weight, photo_url, notes, created_at
|
||||
FROM pets
|
||||
WHERE user_id = ? AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
""", (user['id'],))
|
||||
|
||||
# 이미지 URL 베이스 (외부 접근용, https 강제)
|
||||
base_url = request.host_url.rstrip('/').replace('http://', 'https://')
|
||||
|
||||
pets = []
|
||||
for row in cursor.fetchall():
|
||||
species = row['species']
|
||||
species_label = '강아지 🐕' if species == 'dog' else ('고양이 🐈' if species == 'cat' else '기타')
|
||||
|
||||
# photo_url을 절대 URL로 변환
|
||||
photo_url = row['photo_url']
|
||||
if photo_url and photo_url.startswith('/'):
|
||||
photo_url = base_url + photo_url
|
||||
|
||||
pets.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'species': species,
|
||||
'species_label': species_label,
|
||||
'breed': row['breed'],
|
||||
'gender': row['gender'],
|
||||
'birth_date': row['birth_date'],
|
||||
'age_months': row['age_months'],
|
||||
'weight': float(row['weight']) if row['weight'] else None,
|
||||
'photo_url': photo_url,
|
||||
'notes': row['notes']
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user_id': user['id'],
|
||||
'user_name': user['nickname'],
|
||||
'pets': pets
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"반려동물 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'pets': []
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/customers/search')
|
||||
def api_customers_search():
|
||||
"""
|
||||
@@ -7443,8 +7776,8 @@ def api_admin_qr_generate():
|
||||
if not order_no:
|
||||
return jsonify({'success': False, 'error': '주문번호가 필요합니다'}), 400
|
||||
|
||||
# 기존 모듈 import
|
||||
from utils.qr_token_generator import generate_claim_token, save_token_to_db
|
||||
# 기존 모듈 import (v2: 서버 동기화 포함)
|
||||
from utils.qr_token_generator import generate_and_sync_token
|
||||
from utils.qr_label_printer import print_qr_label
|
||||
|
||||
# 거래 시간 조회 (MSSQL)
|
||||
@@ -7465,21 +7798,19 @@ def api_admin_qr_generate():
|
||||
if amount <= 0:
|
||||
amount = float(row[1]) if row[1] else 0
|
||||
|
||||
# 1. 토큰 생성
|
||||
token_info = generate_claim_token(order_no, amount)
|
||||
# 1. 토큰 생성 + 로컬 저장 + 서버 동기화 (v2)
|
||||
# 품목 조회 (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 저장
|
||||
success, error = save_token_to_db(
|
||||
order_no,
|
||||
token_info['token_hash'],
|
||||
amount,
|
||||
token_info['claimable_points'],
|
||||
token_info['expires_at'],
|
||||
token_info['pharmacy_id']
|
||||
)
|
||||
if not token_info.get('local_saved'):
|
||||
return jsonify({'success': False, 'error': token_info.get('local_error', 'DB 저장 실패')}), 400
|
||||
|
||||
if not success:
|
||||
return jsonify({'success': False, 'error': error}), 400
|
||||
# 서버 동기화 로그
|
||||
if token_info.get('synced'):
|
||||
print(f"[QR] 서버 동기화 완료: {order_no} → ID:{token_info.get('server_token_id')}")
|
||||
else:
|
||||
print(f"[QR] 서버 동기화 실패 (오프라인?): {order_no}")
|
||||
|
||||
# 3. 미리보기 이미지 생성
|
||||
image_url = None
|
||||
@@ -9339,6 +9670,43 @@ def admin_paai():
|
||||
return render_template('admin_paai.html')
|
||||
|
||||
|
||||
@app.route('/api/paai/reprint', methods=['POST'])
|
||||
def api_paai_reprint():
|
||||
"""PAAI 결과 재인쇄"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
pre_serial = data.get('pre_serial')
|
||||
|
||||
if not pre_serial:
|
||||
return jsonify({'success': False, 'error': '처방번호가 없습니다'}), 400
|
||||
|
||||
# 캐시에서 PAAI 결과 조회
|
||||
from db.paai_logger import get_cached_result
|
||||
cached = get_cached_result(pre_serial)
|
||||
|
||||
if not cached:
|
||||
return jsonify({'success': False, 'error': '저장된 분석 결과가 없습니다. 재분석 후 인쇄해주세요.'}), 404
|
||||
|
||||
# 환자 정보 조회
|
||||
patient_name = cached.get('patient_name', '환자')
|
||||
analysis = cached.get('analysis', {})
|
||||
kims_summary = cached.get('kims_summary', {})
|
||||
|
||||
# 인쇄
|
||||
from paai_printer import print_paai_result
|
||||
result = print_paai_result(pre_serial, patient_name, analysis, kims_summary)
|
||||
|
||||
if result.get('success'):
|
||||
return jsonify({'success': True, 'message': '인쇄 완료'})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': result.get('error', '인쇄 실패')}), 500
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/paai/logs')
|
||||
def api_paai_logs():
|
||||
"""PAAI 로그 목록 조회"""
|
||||
@@ -11013,6 +11381,19 @@ def api_product_image_info(barcode):
|
||||
return response, 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 동물약 강의 콘텐츠 라우트
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
from flask import send_from_directory
|
||||
|
||||
@app.route('/lecture/<int:lecture_id>')
|
||||
def serve_lecture(lecture_id):
|
||||
"""동물약 강의 콘텐츠 서빙 (카카오톡 og 태그 포함)"""
|
||||
filename = f'lecture_{lecture_id:02d}.html'
|
||||
lectures_dir = os.path.join(app.static_folder, 'lectures')
|
||||
return send_from_directory(lectures_dir, filename)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
11
backend/config.json
Normal file
11
backend/config.json
Normal 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"
|
||||
}
|
||||
@@ -77,7 +77,7 @@ def get_available_odbc_driver():
|
||||
class DatabaseConfig:
|
||||
"""PIT3000 데이터베이스 연결 설정"""
|
||||
|
||||
SERVER = "192.168.0.4\\PM2014"
|
||||
SERVER = "192.168.0.69\\PM2014"
|
||||
USERNAME = "sa"
|
||||
PASSWORD = "tmddls214!%(" # 원본 비밀번호
|
||||
|
||||
|
||||
@@ -289,6 +289,43 @@ def get_log_detail(log_id: int) -> dict:
|
||||
return log
|
||||
|
||||
|
||||
def get_cached_result(pre_serial: str) -> dict:
|
||||
"""처방번호로 캐시된 PAAI 결과 조회 (재인쇄용)"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 가장 최근 성공한 분석 결과 조회
|
||||
cursor.execute('''
|
||||
SELECT * FROM paai_logs
|
||||
WHERE pre_serial = ? AND status = 'success'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
''', (pre_serial,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
result = dict(row)
|
||||
|
||||
# JSON 파싱
|
||||
import json
|
||||
for field in ['analysis', 'kims_summary', 'raw_response']:
|
||||
if result.get(field):
|
||||
try:
|
||||
result[field] = json.loads(result[field])
|
||||
except:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""통계 조회"""
|
||||
if not DB_PATH.exists():
|
||||
|
||||
291
backend/order_recommendation.py
Normal file
291
backend/order_recommendation.py
Normal 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
|
||||
@@ -1555,7 +1555,7 @@ def paai_analyze():
|
||||
|
||||
# 5. Clawdbot AI 호출 (WebSocket)
|
||||
ai_start = time_module.time()
|
||||
ai_response = call_clawdbot_ai(ai_prompt)
|
||||
ai_response = call_clawdbot_ai(ai_prompt, cus_code=cus_code)
|
||||
ai_time = int((time_module.time() - ai_start) * 1000)
|
||||
|
||||
# AI 결과 로그 업데이트
|
||||
@@ -1744,21 +1744,32 @@ def build_paai_prompt(
|
||||
return prompt
|
||||
|
||||
|
||||
def call_clawdbot_ai(prompt: str) -> dict:
|
||||
"""Clawdbot AI 호출 (WebSocket Gateway)"""
|
||||
def call_clawdbot_ai(prompt: str, cus_code: str = None) -> dict:
|
||||
"""Clawdbot AI 호출 (WebSocket Gateway)
|
||||
|
||||
Args:
|
||||
prompt: AI에게 보낼 프롬프트
|
||||
cus_code: 환자 코드 (세션 분리용, 같은 날 같은 환자는 세션 공유)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from services.clawdbot_client import ask_clawdbot
|
||||
|
||||
PAAI_SYSTEM_PROMPT = """당신은 경험 많은 약사입니다.
|
||||
처방 데이터를 분석하여 약사에게 유용한 정보를 제공합니다.
|
||||
이전 대화와 관계없이, 아래 제공된 처방 정보만 보고 독립적으로 분석하세요.
|
||||
반드시 요청된 JSON 형식으로만 응답하세요."""
|
||||
|
||||
# 세션 ID: 날짜별 단일 세션 (하루 1개)
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
session_id = f'paai-{today}'
|
||||
|
||||
try:
|
||||
# Clawdbot Gateway WebSocket API 호출
|
||||
ai_text = ask_clawdbot(
|
||||
message=prompt,
|
||||
session_id='paai-analysis',
|
||||
session_id=session_id,
|
||||
system_prompt=PAAI_SYSTEM_PROMPT,
|
||||
timeout=60,
|
||||
model='anthropic/claude-sonnet-4-5' # 빠른 Sonnet 사용
|
||||
|
||||
914
backend/static/lectures/lecture_01.html
Normal file
914
backend/static/lectures/lecture_01.html
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/static/uploads/pets/pet_9_f5bf91bb.jpeg
Normal file
BIN
backend/static/uploads/pets/pet_9_f5bf91bb.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
789
backend/templates/admin_price_trend.html
Normal file
789
backend/templates/admin_price_trend.html
Normal 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>
|
||||
@@ -486,6 +486,34 @@
|
||||
.paai-feedback button:hover { border-color: #10b981; }
|
||||
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
|
||||
.paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; }
|
||||
.paai-reanalyze-btn {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.paai-reanalyze-btn:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.paai-reanalyze-btn:disabled {
|
||||
background: #9ca3af !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.paai-reprint-btn {
|
||||
background: linear-gradient(135deg, #10b981, #059669) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.paai-reprint-btn:hover {
|
||||
background: linear-gradient(135deg, #059669, #047857) !important;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.paai-reprint-btn:disabled {
|
||||
background: #9ca3af !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.paai-timing {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
@@ -1456,6 +1484,8 @@
|
||||
<span>도움이 되셨나요?</span>
|
||||
<button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button>
|
||||
<button onclick="sendPaaiFeedback(false)" id="paaiNotUseful">👎 아니요</button>
|
||||
<button onclick="reanalyzePaai()" id="paaiReanalyze" class="paai-reanalyze-btn">🔄 재분석</button>
|
||||
<button onclick="reprintPaai()" id="paaiReprint" class="paai-reprint-btn">🖨️ 재인쇄</button>
|
||||
</div>
|
||||
<div class="paai-timing" id="paaiTiming"></div>
|
||||
</div>
|
||||
@@ -2674,6 +2704,146 @@
|
||||
triggerPaaiAnalysis();
|
||||
}
|
||||
|
||||
// 🖨️ 재인쇄 함수
|
||||
async function reprintPaai() {
|
||||
if (!currentPrescriptionData) return;
|
||||
|
||||
const btn = document.getElementById('paaiReprint');
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '🖨️ 인쇄 중...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/paai/reprint', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pre_serial: currentPrescriptionData.pre_serial
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
btn.textContent = '✅ 인쇄 완료!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
btn.textContent = '❌ 실패';
|
||||
alert('인쇄 실패: ' + (result.error || '알 수 없는 오류'));
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
btn.textContent = '❌ 오류';
|
||||
alert('인쇄 오류: ' + error.message);
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 재분석 함수 - 캐시 무시하고 새로 분석
|
||||
async function reanalyzePaai() {
|
||||
if (!currentPrescriptionData) return;
|
||||
|
||||
const btn = document.getElementById('paaiReanalyze');
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
|
||||
// 버튼 비활성화
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 분석 중...';
|
||||
|
||||
// 로딩 표시
|
||||
body.innerHTML = `
|
||||
<div class="paai-loading">
|
||||
<div class="spinner"></div>
|
||||
<div>AI 재분석 중...</div>
|
||||
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">캐시 무시하고 새로 분석합니다</div>
|
||||
</div>
|
||||
`;
|
||||
footer.style.display = 'none';
|
||||
|
||||
const preSerial = currentPrescriptionData.pre_serial;
|
||||
|
||||
// 캐시 삭제
|
||||
delete paaiResultCache[preSerial];
|
||||
|
||||
try {
|
||||
// triggerPaaiAnalysis와 동일한 형식으로 데이터 구성
|
||||
const requestData = {
|
||||
pre_serial: preSerial,
|
||||
cus_code: currentPrescriptionData.cus_code,
|
||||
patient_name: currentPrescriptionData.name || '환자',
|
||||
patient_note: currentPrescriptionData.cusetc || '',
|
||||
disease_info: {
|
||||
code_1: currentPrescriptionData.st1 || '',
|
||||
name_1: currentPrescriptionData.st1_name || '',
|
||||
code_2: currentPrescriptionData.st2 || '',
|
||||
name_2: currentPrescriptionData.st2_name || ''
|
||||
},
|
||||
current_medications: (currentPrescriptionData.medications || []).map(med => ({
|
||||
code: med.medication_code,
|
||||
name: med.med_name,
|
||||
dosage: med.dosage,
|
||||
frequency: med.frequency,
|
||||
days: med.duration
|
||||
})),
|
||||
previous_medications: (currentPrescriptionData.previous_medications || []).map(med => ({
|
||||
code: med.medication_code,
|
||||
name: med.med_name,
|
||||
dosage: med.dosage,
|
||||
frequency: med.frequency,
|
||||
days: med.duration
|
||||
})),
|
||||
otc_history: otcData ? {
|
||||
visit_count: otcData.summary?.total_visits || 0,
|
||||
frequent_items: otcData.summary?.frequent_items || [],
|
||||
purchases: otcData.purchases || []
|
||||
} : {}
|
||||
};
|
||||
|
||||
const response = await fetch('/pmr/api/paai/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 새 결과 캐시
|
||||
paaiResultCache[preSerial] = { result, cached: false };
|
||||
currentPaaiLogId = result.log_id;
|
||||
currentPaaiResponse = JSON.stringify(result.analysis || {});
|
||||
displayPaaiResult(result);
|
||||
|
||||
// 성공 표시
|
||||
btn.textContent = '✅ 완료!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = '🔄 재분석';
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
body.innerHTML = `<div style="color:#ef4444;padding:20px;">❌ 재분석 실패: ${result.error}</div>`;
|
||||
btn.textContent = '🔄 재분석';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
body.innerHTML = `<div style="color:#ef4444;padding:20px;">❌ 오류: ${error.message}</div>`;
|
||||
btn.textContent = '🔄 재분석';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
footer.style.display = 'flex';
|
||||
}
|
||||
|
||||
function displayPaaiResult(result) {
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""
|
||||
QR Claim Token 생성 모듈
|
||||
후향적 적립을 위한 1회성 토큰 생성
|
||||
|
||||
v2 (2026-03-29): 서버 즉시 전송 추가 (pos.pharmq.kr)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
import os
|
||||
@@ -16,7 +20,23 @@ from db.dbsetup import DatabaseManager
|
||||
# 설정값
|
||||
MILEAGE_RATE = 0.03 # 3% 적립
|
||||
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"):
|
||||
@@ -77,7 +97,8 @@ def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"):
|
||||
'expires_at': expires_at,
|
||||
'pharmacy_id': pharmacy_id,
|
||||
'transaction_id': transaction_id,
|
||||
'total_amount': total_amount
|
||||
'total_amount': total_amount,
|
||||
'nonce': nonce, # 서버 전송용
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +171,97 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
|
||||
return (False, f"DB 저장 실패: {str(e)}")
|
||||
|
||||
|
||||
def sync_token_to_server(transaction_id, total_amount, pharmacy_code=None, items=None):
|
||||
"""
|
||||
토큰을 서버(pos.pharmq.kr)에 즉시 전송 (품목 상세 포함)
|
||||
|
||||
Args:
|
||||
transaction_id: 거래 ID
|
||||
total_amount: 판매 금액
|
||||
pharmacy_code: 약국 코드 (기본값: P0001)
|
||||
items: 품목 리스트 [{'item_code': ..., 'item_name': ..., 'quantity': ..., 'unit_price': ..., 'total_price': ...}]
|
||||
|
||||
Returns:
|
||||
tuple: (성공 여부, 서버 응답 or 에러 메시지)
|
||||
"""
|
||||
pharmacy_code = pharmacy_code or PHARMACY_CODE
|
||||
|
||||
payload = {
|
||||
'pharmacy_code': pharmacy_code,
|
||||
'transaction_id': str(transaction_id),
|
||||
'total_amount': int(total_amount),
|
||||
}
|
||||
|
||||
# 품목 상세 추가
|
||||
if items:
|
||||
payload['items'] = items
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{CLOUD_API_URL}/api/v1/tokens/create",
|
||||
json=payload,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.ok:
|
||||
result = response.json()
|
||||
logger.info(f"[QR] 서버 전송 성공: {transaction_id} → {result.get('points', 0)}P")
|
||||
return (True, result)
|
||||
else:
|
||||
logger.warning(f"[QR] 서버 응답 오류: {response.status_code} - {response.text[:100]}")
|
||||
return (False, f"서버 오류: {response.status_code}")
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"[QR] 서버 타임아웃 (오프라인?): {transaction_id}")
|
||||
return (False, "타임아웃")
|
||||
except Exception as e:
|
||||
logger.warning(f"[QR] 서버 전송 실패: {e}")
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def generate_and_sync_token(transaction_id, total_amount, pharmacy_id="P0001", items=None):
|
||||
"""
|
||||
토큰 생성 + 로컬 저장 + 서버 즉시 전송 (통합 함수)
|
||||
|
||||
Args:
|
||||
transaction_id: 거래 ID
|
||||
total_amount: 판매 금액
|
||||
pharmacy_id: 약국 코드
|
||||
items: 품목 리스트 [{'item_code': ..., 'item_name': ..., 'quantity': ..., 'unit_price': ..., 'total_price': ...}]
|
||||
|
||||
Returns:
|
||||
dict: 토큰 정보 + synced 플래그
|
||||
"""
|
||||
# 1. 토큰 생성
|
||||
token_info = generate_claim_token(transaction_id, total_amount, pharmacy_id)
|
||||
|
||||
# 2. 로컬 DB 저장
|
||||
local_success, local_error = save_token_to_db(
|
||||
transaction_id,
|
||||
token_info['token_hash'],
|
||||
total_amount,
|
||||
token_info['claimable_points'],
|
||||
token_info['expires_at'],
|
||||
pharmacy_id
|
||||
)
|
||||
|
||||
token_info['local_saved'] = local_success
|
||||
if not local_success:
|
||||
token_info['local_error'] = local_error
|
||||
|
||||
# 3. ⚡ 서버 즉시 전송 (품목 포함)
|
||||
sync_success, sync_result = sync_token_to_server(
|
||||
transaction_id, total_amount, pharmacy_id, items=items
|
||||
)
|
||||
|
||||
token_info['synced'] = sync_success
|
||||
if sync_success and isinstance(sync_result, dict):
|
||||
token_info['server_token_id'] = sync_result.get('token_id')
|
||||
token_info['server_points'] = sync_result.get('points')
|
||||
|
||||
return token_info
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
|
||||
564
docs/LABEL_PRINTING_GUIDE.md
Normal file
564
docs/LABEL_PRINTING_GUIDE.md
Normal 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
434
docs/OTC_LABEL_SYSTEM.md
Normal 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
263
docs/환산계수.md
Normal 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개 건조시럽 환산계수 등록됨*
|
||||
Reference in New Issue
Block a user