diff --git a/backend/app.py b/backend/app.py index ab26c9a..1c6d76a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10028,6 +10028,178 @@ def api_search_drugs(): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/stock-analytics/forecast') +def api_stock_forecast(): + """ + 재고 소진 예측 API + GET /api/stock-analytics/forecast?drug_code=A123456789 + + 계산 로직: + 1. 최근 30일 출고량 조회 (IM_date_total에서 credit 합계) + 2. 평균 일일 출고량 = 총 출고량 / 실제 데이터 일수 + 3. 예상 소진일 = 현재재고 / 평균출고량 + 4. 소진 예상일 = 오늘 + 예상소진일 + 5. 발주 권장일 = 소진예상일 - 3일 (리드타임) + 6. confidence: 데이터 많으면 high, 적으면 low + 7. trend: 최근 7일 vs 이전 23일 비교 → increasing/stable/decreasing + """ + conn = None + try: + drug_code = request.args.get('drug_code', '').strip() + + if not drug_code: + return jsonify({'success': False, 'error': '약품코드가 필요합니다'}), 400 + + # pyodbc 직접 연결 (기존 daily-trend API와 동일 방식) + import pyodbc + conn_str = ( + 'DRIVER={ODBC Driver 17 for SQL Server};' + 'SERVER=192.168.0.4\\PM2014;' + 'DATABASE=PM_DRUG;' + 'UID=sa;' + 'PWD=tmddls214!%(;' + 'TrustServerCertificate=yes' + ) + conn = pyodbc.connect(conn_str, timeout=10) + cursor = conn.cursor() + + # 1. 약품 정보 및 현재 재고 조회 + cursor.execute(""" + SELECT + G.GoodsName as product_name, + ISNULL(IT.IM_QT_sale_debit, 0) as current_stock + FROM CD_GOODS G + LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode + WHERE G.DrugCode = ? + """, (drug_code,)) + + info_row = cursor.fetchone() + + if not info_row: + conn.close() + return jsonify({'success': False, 'error': '해당 약품을 찾을 수 없습니다'}), 404 + + product_name = info_row.product_name or '' + current_stock = int(info_row.current_stock or 0) + + # 2. 최근 30일 출고 데이터 조회 + today = datetime.now(KST).date() + thirty_days_ago = today - timedelta(days=30) + + cursor.execute(""" + SELECT + IM_DT_appl as date, + ISNULL(IM_QT_sale_credit, 0) as outbound_qty + FROM IM_date_total + WHERE DrugCode = ? + AND IM_DT_appl >= ? + AND IM_DT_appl <= ? + ORDER BY IM_DT_appl + """, (drug_code, thirty_days_ago.strftime('%Y%m%d'), today.strftime('%Y%m%d'))) + + rows = cursor.fetchall() + conn.close() + + # 3. 데이터 처리 + if not rows: + # 출고 데이터 없음 + return jsonify({ + 'success': True, + 'drug_code': drug_code, + 'product_name': product_name, + 'current_stock': current_stock, + 'avg_daily_outbound': 0, + 'days_until_empty': None, + 'estimated_empty_date': None, + 'recommended_order_date': None, + 'confidence': 'none', + 'trend': 'unknown', + 'message': '최근 30일 출고 데이터가 없습니다' + }) + + # 일별 출고량 집계 + daily_outbound = {} + for row in rows: + date_str = str(row.date) + daily_outbound[date_str] = int(row.outbound_qty or 0) + + # 총 출고량 및 평균 계산 + total_outbound = sum(daily_outbound.values()) + data_days = len(daily_outbound) + avg_daily_outbound = total_outbound / data_days if data_days > 0 else 0 + + # 4. 예상 소진일 계산 + if avg_daily_outbound > 0: + days_until_empty = int(current_stock / avg_daily_outbound) + estimated_empty_date = today + timedelta(days=days_until_empty) + # 발주 권장일 (소진 3일 전, 리드타임 고려) + recommended_order_date = estimated_empty_date - timedelta(days=3) + if recommended_order_date < today: + recommended_order_date = today # 이미 지난 경우 오늘로 + else: + days_until_empty = None + estimated_empty_date = None + recommended_order_date = None + + # 5. 신뢰도 계산 (데이터 일수 기준) + if data_days >= 20: + confidence = 'high' + elif data_days >= 10: + confidence = 'medium' + else: + confidence = 'low' + + # 6. 추세 분석 (최근 7일 vs 이전 23일) + recent_7_days = [] + earlier_days = [] + + sorted_dates = sorted(daily_outbound.keys(), reverse=True) + for i, date_key in enumerate(sorted_dates): + if i < 7: + recent_7_days.append(daily_outbound[date_key]) + else: + earlier_days.append(daily_outbound[date_key]) + + if len(recent_7_days) >= 3 and len(earlier_days) >= 5: + recent_avg = sum(recent_7_days) / len(recent_7_days) + earlier_avg = sum(earlier_days) / len(earlier_days) + + if earlier_avg > 0: + change_ratio = (recent_avg - earlier_avg) / earlier_avg + if change_ratio > 0.15: # 15% 이상 증가 + trend = 'increasing' + elif change_ratio < -0.15: # 15% 이상 감소 + trend = 'decreasing' + else: + trend = 'stable' + else: + trend = 'stable' if recent_avg == 0 else 'increasing' + else: + trend = 'unknown' + + return jsonify({ + 'success': True, + 'drug_code': drug_code, + 'product_name': product_name, + 'current_stock': current_stock, + 'avg_daily_outbound': round(avg_daily_outbound, 2), + 'days_until_empty': days_until_empty, + 'estimated_empty_date': estimated_empty_date.strftime('%Y-%m-%d') if estimated_empty_date else None, + 'recommended_order_date': recommended_order_date.strftime('%Y-%m-%d') if recommended_order_date else None, + 'confidence': confidence, + 'trend': trend, + 'data_days': data_days + }) + + except Exception as e: + if conn: + conn.close() + logging.error(f"stock-forecast API 오류: {e}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + if __name__ == '__main__': import os diff --git a/backend/templates/admin_stock_analytics.html b/backend/templates/admin_stock_analytics.html index 4da479f..d41e75d 100644 --- a/backend/templates/admin_stock_analytics.html +++ b/backend/templates/admin_stock_analytics.html @@ -430,6 +430,138 @@ .toast.success { border-color: var(--accent-emerald); } .toast.error { border-color: var(--accent-rose); } + /* ══════════════════ 예측 카드 ══════════════════ */ + .forecast-card { + background: var(--bg-card); + border-radius: 14px; + padding: 20px; + border: 1px solid var(--border); + height: 100%; + } + .forecast-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + } + .forecast-title { + font-size: 15px; + font-weight: 600; + } + .forecast-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + } + .forecast-label { + font-size: 13px; + color: var(--text-secondary); + } + .forecast-value { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + } + .forecast-divider { + border: none; + border-top: 1px dashed var(--border); + margin: 12px 0; + } + .forecast-highlight { + background: linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(124, 58, 237, 0.1)); + border-radius: 10px; + padding: 14px; + margin: 12px 0; + } + .forecast-highlight-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + .forecast-highlight-row:last-child { + margin-bottom: 0; + } + .forecast-highlight-icon { + font-size: 16px; + } + .forecast-highlight-label { + font-size: 12px; + color: var(--text-secondary); + flex: 1; + } + .forecast-highlight-value { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 700; + color: var(--accent-purple); + } + .forecast-highlight-sub { + font-size: 11px; + color: var(--text-muted); + margin-left: 4px; + font-weight: 400; + } + .forecast-meta { + display: flex; + justify-content: space-between; + padding-top: 12px; + border-top: 1px solid var(--border); + } + .forecast-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + } + .forecast-tag.trend-increasing { + background: rgba(239, 68, 68, 0.15); + color: #f87171; + } + .forecast-tag.trend-stable { + background: rgba(16, 185, 129, 0.15); + color: #34d399; + } + .forecast-tag.trend-decreasing { + background: rgba(59, 130, 246, 0.15); + color: #60a5fa; + } + .forecast-tag.trend-unknown { + background: rgba(100, 116, 139, 0.15); + color: #94a3b8; + } + .forecast-tag.confidence-high { + background: rgba(16, 185, 129, 0.15); + color: #34d399; + } + .forecast-tag.confidence-medium { + background: rgba(245, 158, 11, 0.15); + color: #fbbf24; + } + .forecast-tag.confidence-low { + background: rgba(239, 68, 68, 0.15); + color: #f87171; + } + .forecast-tag.confidence-none { + background: rgba(100, 116, 139, 0.15); + color: #94a3b8; + } + .forecast-empty { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); + } + .forecast-empty-icon { + font-size: 36px; + margin-bottom: 12px; + } + /* ══════════════════ 반응형 ══════════════════ */ @media (max-width: 1200px) { .chart-grid { grid-template-columns: 1fr; } @@ -553,6 +685,16 @@ + + +