# -*- 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