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

+ 애니팜 로고 +

+

동물약의 전문가는 약사입니다

+
+ +

시골약사의 동물약 이야기

+

#1 복합 개시딘 겔

+

🐕 세균성 피부염·농피증 치료제 | 퓨시드산 + 베타메타손 | 비처방대상 동물용의약품

+ +
+

"사람 후시딘 개한테 발라도 돼요?"

+
+

약국에서 한 번쯤 들어보셨죠? 저도 처음엔 "뭐... 되지 않을까?" 했는데, 알고 보니 꽤 복잡한 이야기가 숨어 있더라고요. 오늘은 이 질문에 제대로 답할 수 있는 약사가 되어봅시다! 🎯

+
+

💊 약사와 동물약 - 우리가 할 수 있는 것

+

법적으로, 이미 가능합니다

+

혹시 "동물약은 수의사만 줄 수 있는 거 아니야?"라고 생각하셨다면, 그건 오해예요.

+ + + + + + + + + + + + + + + + + +
구분약국 판매
처방대상 동물용의약품 (항생제 일부, 호르몬제 등)✅ 가능 (수의사 처방 필요)
비처방대상 동물용의약품 (구충제, 외용제, 소화제 등)✅ 가능 (처방 없이)
+

👉 동물약국 개설허가를 받은 약국은 동물용의약품을 취급할 수 있습니다. 복합 개시딘 겔처럼 비처방대상 동물용의약품은 수의사 처방 없이 판매 가능해요.

+

수의사와 약사, 다른 전문성

+

수의사를 깎아내리려는 게 아닙니다. 교육 구조 자체가 다르고, 그래서 강점이 다릅니다.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
영역수의대 (6년)약대 (6년)
교육 목표진단 → 처방 → 수술까지 종합 임상약 중심 전문가
약물학종별 차이 + 임상 적용 중심Receptor, signaling pathway 수준 심화
약동학기본 PK + 종간 차이Compartment model, Clearance, TDM, Modeling
제제학·제형사용자 입장 (tablet, injection 이해)설계자 입장 (서방형, 나노, 생체이용률)
약물 상호작용임상 빈용 조합 중심CYP, Transporter, Mechanism 분석
+

종간 차이 — 특히 대동물(소, 말)과 소동물(개, 고양이)의 용량 체계, 종별 독성 프로파일, 그리고 진단에서 수술까지 이어지는 임상 판단은 수의사의 전문 영역이에요. 6년간 그걸 집중적으로 훈련받은 분들이니까요.

+

물론 우리도 고양이 acetaminophen 독성 같은 건 알고 있죠. 하지만 "말에게는 이 용량, 소에게는 저 용량" 같은 대동물 임상 디테일까지는 솔직히 깊지 않아요.

+

반면, "이 약이 왜 이런 제형인지", "왜 이 농도인지", "왜 이 기간만 써야 하는지" — 이건 우리가 평소 인체약으로 매일 하고 있는 설명이잖아요? 동물약도 마찬가지예요. 약의 원리를 깊이 있게 전달하는 건 약사가 잘할 수 있는 영역입니다.

+

반려인이 약국에 오는 이유

+

반려인들은 수의사 가기 전, 또는 처방받고 나서도 약국에 와요.

+
    +
  • 💬 "이거 개한테 발라도 돼요?"
  • +
  • 💬 "수의사 선생님이 준 약인데, 어떻게 먹이는 거예요?"
  • +
  • 💬 "항생제 언제까지 먹여야 해요?"
  • +
+

이때 정확한 정보 + 전문가다운 설명을 해주면?
+→ 신뢰가 쌓이고, "약 관련은 약국에 물어봐야지"가 됩니다.

+

우리는 법적 테두리 안에서 충분히 전문가로서 역할할 수 있어요. 항생제 남용 문제, 제형 선택 이유, 투약 기간의 근거 — 이런 건 약사가 더 잘 전달할 수 있는 영역입니다.

+
+

💡 이 연재의 목표: 이미 알고 있는 동물약, 더 깊이 알고 더 잘 설명하자.

+
+
+

🔬 개시딘 vs 사람 후시딘 - 뭐가 다른데?

+

+ 복합 개시딘 겔 +
+ 복합 개시딘 겔 (퓨시드산 0.5% + 베타메타손 0.1%) +

+ +

"어차피 퓨시드산 아니야?" 맞아요. 근데 결정적 차이가 3가지 있어요.

+

1️⃣ 제형: 겔 vs 크림

+ + + + + + + + + + + + + + + + + + + + + + + + + +
구분개시딘사람 후시딘
제형크림
털 침투✅ 우수❌ 불량
잔여감깔끔번들거림
+

개는 털이 있어요. (너무 당연한가요? ㅋㅋ) 크림은 털 위에서 뭉치는데, 겔은 털 사이로 쭉쭉 스며들어요. 이게 생각보다 큰 차이예요.

+

2️⃣ pH 최적화

+
    +
  • 개 피부 pH: 4.4~8.2 (넓은 범위)
  • +
  • 사람 피부 pH: 4.5~5.5 (좁은 범위)
  • +
+

개시딘은 개 피부 pH 범위에 맞게 제형화되어 있어요. 사람용은 사람 피부에 최적화되어 있고요.

+

3️⃣ 농도 & 스테로이드 차이

+

+ 인체용 후시딘 +     + 후시딘H +
+ 후시딘 연고 (퓨시드산 2%)  |  후시딘H 크림 (퓨시드산 2% + 하이드로코르티손 1%) +

+ +

후시딘 제품별 성분 비교

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
제품퓨시드산스테로이드스테로이드 강도
개시딘 겔 (동물용)0.5%베타메타손발레레이트 0.1%Class IV (중등도)
후시딘 연고/크림 (인체용)2%❌ 없음-
후시딘H 크림 (인체용)2%하이드로코르티손아세테이트 1%Class VI (약함)
+

스테로이드 강도 분류 기준 (Stoughton-Cornell Classification)

+

국소 스테로이드 강도는 혈관수축 분석(Vasoconstriction Assay)을 기반으로 Class I(초강력)~VII(가장 약함)로 분류됩니다.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Class강도대표 약물
I초강력 (Superpotent)Clobetasol propionate 0.05%
II강력 (Potent)Betamethasone dipropionate 0.05%
III상위 중강도Fluticasone propionate 0.005%
IV중등도 (Mid-strength)Betamethasone valerate 0.1% ← 개시딘
V하위 중등도Fluticasone propionate 0.05% cream
VI약함 (Mild)Hydrocortisone 1% ← 후시딘H
VII가장 약함Hydrocortisone 0.5%
+
+

📚 근거: Stoughton RB. Vasoconstrictor assay—specific applications. In: Maibach HI, Surber C, eds. Topical Corticosteroids. Basel: Karger; 1992:42-53. 및 PMID 3881106 - Jacob SE, Steele T. Corticosteroid classes: a quick reference guide including patch test substances and cross-reactivity. J Am Acad Dermatol. 2006.

+
+

베타메타손 "염(Ester)"에 따른 차이

+

같은 베타메타손이라도 어떤 염(ester)으로 결합되어 있느냐에 따라 효력이 다릅니다:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
베타메타손 염스테로이드 등급특징
Betamethasone valerate (개시딘)Class IV중등도, 표피 침투 적당
Betamethasone dipropionateClass I~II초강력, 깊은 침투
Betamethasone sodium phosphate주사용수용성, 전신 투여
+
+

💡 약사 포인트: 개시딘의 베타메타손발레레이트(Class IV)는 후시딘H의 하이드로코르티손(Class VI)보다 약 10~15배 강력합니다. 그래서 개시딘은 최대 7일로 사용 기간이 제한되어 있어요.

+
+

왜 개시딘이 낮은 농도인데 괜찮을까?

+

"어? 퓨시드산이 0.5%밖에 안 돼?" 맞아요. 근데 여기서 약사의 시선이 필요해요.

+

겔 제형은 피부 침투가 좋아서 낮은 농도로도 피부 내 MIC의 67,000배 농도를 달성해요! (PMID 29162115)

+

높은 농도가 항상 좋은 게 아닙니다:
+- 크림 2% → 털에 흡착되어 실제 피부 도달량 ↓
+- 겔 0.5% → 털 통과 + 피부 직접 도달 → 유효 농도 충분

+
+

🧪 약제학 깊이 보기: 왜 겔이어야 하는가?

+

대학 시절 약제학 시간에 배웠던 에멀전, Henderson-Hasselbalch 방정식... 기억나시죠? 개시딘이 왜 인지, 그 과학적 이유를 파헤쳐봅시다.

+

에멀전의 기초: O/W vs W/O

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구분O/W (수중유형)W/O (유중수형)Gel (수성 겔)
연속상오일
분산상오일(없음)
촉감산뜻함유성/무거움가장 산뜻
털 침투보통나쁨우수
증발 후약간 잔여기름막깔끔
+

크림(O/W)이나 연고(W/O)는 지용성 기제가 털 위에서 뭉쳐요. 반면 겔은 수성 기반이라 털 사이로 쭉쭉 퍼지고, 증발 후 약물만 피부에 남깁니다.

+

pH와 약물 흡수: Henderson-Hasselbalch로 이해하기

+

퓨시드산은 약산(pKa ≈ 6.0)입니다. 약산의 이온화는 pH에 따라 달라지죠:

+
Henderson-Hasselbalch 방정식:
+pH = pKa + log([A⁻]/[HA])
+
+비이온화율(%) = 100 / (1 + 10^(pH-pKa))
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
피부 환경pH비이온화(HA)이온화(A⁻)지용성
사람 피부5.091%9%높음
개 피부(귀/발)6.050%50%중간
개 피부(체간)7.09%91%낮음
+

핵심 포인트:
+- 사람 피부(pH 5): 비이온화↑ → 지용성 크림이 각질층 확산에 유리
+- 개 피부(pH 7): 이온화↑ → 수성 겔 기제가 이온화된 약물을 피부 표면에 효율적으로 전달 후, 피부 pH에서 약물 방출

+

겔 제형의 과학: 털 있는 피부에서의 강점

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
특성크림/연고
Spreading coefficient낮음높음
모낭 침투털 위에 머묾털 사이 침투
핥았을 때기름 맛 = 더 핥음무미건조
약물 잔류기제와 함께 제거피부 흡착 유지
+

PMID 29162115 연구에서 퓨시드산 겔 적용 시, 표피 상층 240μm에서 2,000 μg/g 농도 달성 - 이는 MIC(0.03 mg/L)의 약 67,000배입니다!

+
+

💡 약사 포인트: 제형 선택은 약물의 물리화학적 특성 + 적용 부위의 생리적 조건을 모두 고려해야 해요. 개시딘이 겔인 건 마케팅이 아니라, 약제학적 근거가 있는 거죠!

+
+
+

🐱 잠깐! 고양이는 왜 안 되나요?

+

개시딘이 "개 전용"인 이유는 제품명에 '개'가 들어가서가 아닙니다. 베타메타손 0.1%가 포함되어 있기 때문이에요.

+

고양이와 스테로이드: 민감한 관계

+

고양이도 스테로이드를 "잘 견딘다"는 오래된 인식이 있지만, 이는 급성 부작용이 적다는 의미일 뿐입니다. 진짜 문제는 대사적 취약성이에요.

+

고양이의 HPA axis(시상하부-뇌하수체-부신 축)는 외인성 글루코코르티코이드에 의해 억제될 수 있으며, 특히 고양이의 당뇨 발생 기전이 개와 근본적으로 다릅니다. 고양이는 사람의 제2형 당뇨와 유사한 췌도 아밀로이드 침착(islet amyloidosis) 기전을 가지고 있어, 스테로이드에 의한 인슐린 저항성이 비가역적 당뇨로 진행할 위험이 높아요 (PMID 11106586).

+

약동학으로 보는 차이

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목고양이
Glucuronidation활발미숙 (UGT 효소 결핍)
스테로이드 대사상대적 빠름지연
당뇨 발생 기전Type 1 유사Type 2 유사 (amyloid)
인슐린 저항성가역적 경향비가역적 위험↑
+

고양이는 glucuronidation 경로가 미숙하여 여러 약물의 대사가 느립니다. 베타메타손 같은 강력한 fluorocorticoid(C-16 치환, mineralocorticoid 활성 無)는 외용제라도 피부를 통해 전신 흡수되어 HPA axis 억제를 유발할 수 있습니다.

+

임상적 위험: 숫자로 보면 무섭습니다

+

2021년 J Feline Med Surg 연구(PMID 32716236):
+- Prednisolone ≥1.9mg/kg/day 투여 고양이 143마리 중 14마리(9.7%)에서 당뇨 발생
+- 85.7%가 투약 3개월 이내 발생

+

2022년 브라질 연구(PMID 35202848):
+- MPA 단회 주사만으로도 혈당·인슐린 상승, HOMA-IR 지표 악화
+- 메트포르민이나 당뇨식이로도 예방 실패

+ + + + + + + + + + + + + + + + + + + + +
고양이 스테로이드 부작용
당뇨병 유발 (특히 비만묘, 버미즈 품종)
의인성 쿠싱 증후군
피부 위축, 상처 치유 지연
면역억제 → 감염 취약
+

그럼 고양이는 뭘 써요?

+

고양이 피부 세균 감염 시:
+1. 퓨시드산 단독 제품 (스테로이드 無) - 사람용 후시딘 연고 off-label
+2. 클로르헥시딘 세정제 - 1차 관리
+3. 전신 항생제 - 세팔렉신, 아목시실린-클라불란산 등
+4. 수의사 처방 - 고양이 허가 제품 또는 off-label 사용 판단

+
+

⚠️ 복약상담 포인트: "고양이 키우시는 분들 많으시죠? 개시딘은 스테로이드 성분 때문에 고양이에게 당뇨를 유발할 수 있어요. 퓨시드산만 필요하시면 스테로이드 없는 제품을 찾거나, 수의사 선생님과 상의해 보세요."

+
+
+

📋 약사의 꿀팁: 이렇게 설명하세요!

+

🎯 3-5-7 법칙

+
┌─────────────────────────────────────────┐
+│  3일: 효과 체크 (안 나으면 수의사!)     │
+│  5일: 최소 사용 기간                    │
+│  7일: 최대 사용 기간 (이상 사용 금지)   │
+└─────────────────────────────────────────┘
+
+

반려인에게 이렇게 말해주세요:

+
+

"3일 발랐는데 전혀 안 나아지면 수의사 선생님께 가세요. 효과 있으면 5~7일 꾸준히! 근데 7일 넘기면 안 돼요~"

+
+

🐶 핥기 방지 필수!

+

이거 꼭 강조하세요:

+
+

"바르고 나서 10~15분은 핥지 못하게 해주세요. 엘리자베스 칼라 있으면 씌우시고, 없으면 안고 계시거나 간식으로 관심 돌리세요!"

+
+

🍄 곰팡이(진균) 감별 포인트

+

퓨시드산은 세균성 피부염에 써요. 진균이면 안 들어요!

+ + + + + + + + + + + + + + + + + + + + + +
세균성 (개시딘 OK)진균성 (터비덤 or 수의사)
부분적, 붉은 병변동그란 링 모양
고름/삼출물털 빠짐 + 비듬
급성 발생서서히 퍼짐
+
+

💡 진균이 의심된다면? 약국에서 바로 추천할 수 있는 제품이 있어요!
+터비덤 스프레이 큐어 — 테르비나핀(항진균) + 클로르헥시딘(항균) 복합 스프레이
+- Malassezia 피부염, 윤선(Microsporum), 표재성 세균 감염까지 커버
+- 1일 1~2회 분무, 증상 소실 후 7일 추가 사용
+- 개 전용 비처방대상 동물용의약품

+

확실하지 않거나, 윤선(링웜) 의심 시에는 수의사 진료도 함께 권유하세요. 윤선은 인수공통이라 가족에게도 전파될 수 있거든요!

+
+
+

⚠️ 항생제 스튜어드십 - 이건 진지하게!

+

한국 퓨시드산 내성률: 27% (PMID 32115810)

+

4마리 중 1마리 이상에서 내성균이 나온다는 거예요. 이게 왜 중요하냐면:

+

항생제 스튜어드십이란?

+
+

항생제를 꼭 필요할 때, 적절한 용량으로, 적절한 기간만 쓰자는 원칙

+
+

국소 항생제라고 막 쓰면 안 되는 이유:
+- 피부 상재균에 내성 유발
+- 나중에 전신 감염 시 치료 옵션 감소
+- 사람에게도 내성균 전파 가능 (One Health 개념)

+

그래서 우리가 할 일

+
    +
  1. 7일 넘게 쓰지 말라고 안내 (정말 중요!)
  2. +
  3. 호전 안 되면 수의사 가라고 (배양검사 필요할 수 있음)
  4. +
  5. 예방 목적 사용 금지 (상처 없는데 미리 바르기 ❌)
  6. +
+
+

🌟 마무리: 약사의 자부심

+

우리는 약의 전문가예요. 수의사가 진단하면, 우리는 그 약이 어떻게 작용하고, 어떻게 써야 효과적인지 설명할 수 있어요.

+

동물약도 결국 약리학, 약동학, 제제학의 연장선이에요. 사람약 공부하며 쌓은 내공이 여기서도 빛을 발하는 거죠! ✨

+

다음에 "사람 후시딘 발라도 돼요?" 질문 받으면, 이렇게 답해보세요:

+
+

"쓸 수는 있는데, 개 전용 개시딘이 더 좋아요. 겔이라 털 사이로 잘 스며들고, 개 피부에 맞게 만들어졌거든요. 3일 발라서 효과 없으면 수의사 선생님 가시고, 효과 있으면 5~7일 꾸준히 바르세요. 7일 넘기면 안 돼요!"

+
+

전문가다운 답변, 멋지지 않나요? 😎

+
+

다음 편 예고: 귀 세정제의 과학 - 왜 클로르헥시딘은 귀에 안 될까?

+
+ +

+

📚 참고문헌
+- Fusidic acid resistance in S. pseudintermedius (Korea): PMID 32115810
+- Topical fusidic acid skin concentration: PMID 29162115
+- Feline islet amyloidosis model: PMID 11106586
+- Prednisolone-induced diabetes in cats: PMID 32716236
+- MPA insulin resistance in cats: PMID 35202848
+- 복합 개시딘 겔 제품 설명서

+ + \ No newline at end of file diff --git a/backend/static/uploads/pets/pet_9_f5bf91bb.jpeg b/backend/static/uploads/pets/pet_9_f5bf91bb.jpeg new file mode 100644 index 0000000..3a7b86f Binary files /dev/null and b/backend/static/uploads/pets/pet_9_f5bf91bb.jpeg differ diff --git a/backend/templates/admin_price_trend.html b/backend/templates/admin_price_trend.html new file mode 100644 index 0000000..5893473 --- /dev/null +++ b/backend/templates/admin_price_trend.html @@ -0,0 +1,789 @@ + + + + + + 가격 변동 추이 - 청춘약국 + + + + + + + +
+
+
+

📈 가격 변동 추이

+

제품별 판매가/마진 변화 분석

+
+ +
+
+ +
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+

-

+

바코드: -

+
+ + + + + + + + + + + +
+
+

데이터 조회 중...

+
+ + +
+
📊
+

바코드 또는 약품명을 검색하여
가격 변동 추이를 확인하세요

+
+
+ + + + diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html index 4071c15..90a1a00 100644 --- a/backend/templates/pmr.html +++ b/backend/templates/pmr.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 @@ 도움이 되셨나요? + +
@@ -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 = ` +
+
+
AI 재분석 중...
+
캐시 무시하고 새로 분석합니다
+
+ `; + 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 = `
❌ 재분석 실패: ${result.error}
`; + btn.textContent = '🔄 재분석'; + btn.disabled = false; + } + } catch (error) { + body.innerHTML = `
❌ 오류: ${error.message}
`; + btn.textContent = '🔄 재분석'; + btn.disabled = false; + } + + footer.style.display = 'flex'; + } + function displayPaaiResult(result) { const body = document.getElementById('paaiBody'); const footer = document.getElementById('paaiFooter'); diff --git a/backend/utils/qr_token_generator.py b/backend/utils/qr_token_generator.py index 8d8423b..a303031 100644 --- a/backend/utils/qr_token_generator.py +++ b/backend/utils/qr_token_generator.py @@ -1,10 +1,14 @@ """ QR Claim Token 생성 모듈 후향적 적립을 위한 1회성 토큰 생성 + +v2 (2026-03-29): 서버 즉시 전송 추가 (pos.pharmq.kr) """ import hashlib import secrets +import logging +import requests from datetime import datetime, timedelta import sys import os @@ -16,7 +20,14 @@ from db.dbsetup import DatabaseManager # 설정값 MILEAGE_RATE = 0.03 # 3% 적립 TOKEN_EXPIRY_DAYS = 30 # 30일 유효기간 -QR_BASE_URL = "https://mile.0bin.in/claim" + +# 서버 설정 (v2) +CLOUD_API_URL = "https://pos.pharmq.kr" +PHARMACY_CODE = "P0001" +QR_BASE_URL = f"{CLOUD_API_URL}/{PHARMACY_CODE}/claim" + +# 로거 +logger = logging.getLogger(__name__) def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"): @@ -77,7 +88,8 @@ def generate_claim_token(transaction_id, total_amount, pharmacy_id="YANGGU001"): 'expires_at': expires_at, 'pharmacy_id': pharmacy_id, 'transaction_id': transaction_id, - 'total_amount': total_amount + 'total_amount': total_amount, + 'nonce': nonce, # 서버 전송용 } @@ -150,6 +162,97 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points, return (False, f"DB 저장 실패: {str(e)}") +def sync_token_to_server(transaction_id, total_amount, pharmacy_code=None, items=None): + """ + 토큰을 서버(pos.pharmq.kr)에 즉시 전송 (품목 상세 포함) + + Args: + transaction_id: 거래 ID + total_amount: 판매 금액 + pharmacy_code: 약국 코드 (기본값: P0001) + items: 품목 리스트 [{'item_code': ..., 'item_name': ..., 'quantity': ..., 'unit_price': ..., 'total_price': ...}] + + Returns: + tuple: (성공 여부, 서버 응답 or 에러 메시지) + """ + pharmacy_code = pharmacy_code or PHARMACY_CODE + + payload = { + 'pharmacy_code': pharmacy_code, + 'transaction_id': str(transaction_id), + 'total_amount': int(total_amount), + } + + # 품목 상세 추가 + if items: + payload['items'] = items + + try: + response = requests.post( + f"{CLOUD_API_URL}/api/v1/tokens/create", + json=payload, + timeout=5 + ) + + if response.ok: + result = response.json() + logger.info(f"[QR] 서버 전송 성공: {transaction_id} → {result.get('points', 0)}P") + return (True, result) + else: + logger.warning(f"[QR] 서버 응답 오류: {response.status_code} - {response.text[:100]}") + return (False, f"서버 오류: {response.status_code}") + + except requests.Timeout: + logger.warning(f"[QR] 서버 타임아웃 (오프라인?): {transaction_id}") + return (False, "타임아웃") + except Exception as e: + logger.warning(f"[QR] 서버 전송 실패: {e}") + return (False, str(e)) + + +def generate_and_sync_token(transaction_id, total_amount, pharmacy_id="P0001", items=None): + """ + 토큰 생성 + 로컬 저장 + 서버 즉시 전송 (통합 함수) + + Args: + transaction_id: 거래 ID + total_amount: 판매 금액 + pharmacy_id: 약국 코드 + items: 품목 리스트 [{'item_code': ..., 'item_name': ..., 'quantity': ..., 'unit_price': ..., 'total_price': ...}] + + Returns: + dict: 토큰 정보 + synced 플래그 + """ + # 1. 토큰 생성 + token_info = generate_claim_token(transaction_id, total_amount, pharmacy_id) + + # 2. 로컬 DB 저장 + local_success, local_error = save_token_to_db( + transaction_id, + token_info['token_hash'], + total_amount, + token_info['claimable_points'], + token_info['expires_at'], + pharmacy_id + ) + + token_info['local_saved'] = local_success + if not local_success: + token_info['local_error'] = local_error + + # 3. ⚡ 서버 즉시 전송 (품목 포함) + sync_success, sync_result = sync_token_to_server( + transaction_id, total_amount, pharmacy_id, items=items + ) + + token_info['synced'] = sync_success + if sync_success and isinstance(sync_result, dict): + token_info['server_token_id'] = sync_result.get('token_id') + token_info['server_points'] = sync_result.get('points') + + return token_info + + # 테스트 코드 if __name__ == "__main__": # 테스트 diff --git a/docs/LABEL_PRINTING_GUIDE.md b/docs/LABEL_PRINTING_GUIDE.md new file mode 100644 index 0000000..0ab531b --- /dev/null +++ b/docs/LABEL_PRINTING_GUIDE.md @@ -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/ +``` + +예: `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` - 인코딩 문제 해결 diff --git a/docs/OTC_LABEL_SYSTEM.md b/docs/OTC_LABEL_SYSTEM.md new file mode 100644 index 0000000..32224fa --- /dev/null +++ b/docs/OTC_LABEL_SYSTEM.md @@ -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/ ← 단건 조회 / 삭제 +/api/admin/otc-labels/preview ← 미리보기 이미지 생성 +/api/admin/otc-labels/print ← 라벨 인쇄 +/api/admin/otc-labels/search-mssql ← MSSQL 약품 검색 +/api/otc-label-print/ ← 외부 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* diff --git a/docs/환산계수.md b/docs/환산계수.md new file mode 100644 index 0000000..e53cb41 --- /dev/null +++ b/docs/환산계수.md @@ -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/ +``` + +#### 요청 예시 +```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/') +def get_drug_conversion_factor(sung_code): + ... +``` + +--- + +## 9. 아키텍처 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 클라이언트 │ +│ (POS, 라벨 프린터, 웹 UI) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Flask Backend (7001) │ +│ GET /api/drug-info/conversion-factor/ │ +└─────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────┐ +│ 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개 건조시럽 환산계수 등록됨*