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 @@ + + +
+
+
+
🔮
+
약품을 선택하면 소진 예측을 표시합니다
+
+
+
@@ -676,6 +818,8 @@ // 재고 변화 차트 로드 loadStockLevel(); + // 재고 소진 예측 로드 + loadForecast(); // 일별 추이도 해당 약품으로 갱신 loadDailyTrend(); } @@ -692,6 +836,13 @@
약품을 검색하여 선택하세요
`; + // 예측 카드 초기화 + document.getElementById('forecastContainer').innerHTML = ` +
+
🔮
+
약품을 선택하면 소진 예측을 표시합니다
+
`; + if (stockLevelChart) { stockLevelChart.destroy(); stockLevelChart = null; @@ -783,6 +934,126 @@ } } + // ══════════════════ 재고 소진 예측 ══════════════════ + async function loadForecast() { + if (!selectedDrugCode) return; + + const container = document.getElementById('forecastContainer'); + container.innerHTML = ` +
+
+
예측 데이터 로딩 중...
+
`; + + try { + const response = await fetch(`/api/stock-analytics/forecast?drug_code=${selectedDrugCode}`); + const data = await response.json(); + + if (data.success) { + renderForecastCard(data); + } else { + container.innerHTML = ` +
+
⚠️
+
${data.error || '예측 데이터를 불러올 수 없습니다'}
+
`; + } + } catch (err) { + console.error('예측 로드 실패:', err); + container.innerHTML = ` +
+
+
예측 로드 실패
+
`; + } + } + + function renderForecastCard(data) { + const container = document.getElementById('forecastContainer'); + + // 추세 표시 + const trendMap = { + 'increasing': { label: '증가 추세', icon: '📈', class: 'trend-increasing' }, + 'stable': { label: '안정적', icon: '➡️', class: 'trend-stable' }, + 'decreasing': { label: '감소 추세', icon: '📉', class: 'trend-decreasing' }, + 'unknown': { label: '분석 불가', icon: '❓', class: 'trend-unknown' } + }; + const trend = trendMap[data.trend] || trendMap['unknown']; + + // 신뢰도 표시 + const confidenceMap = { + 'high': { label: '높음', icon: '✅', class: 'confidence-high' }, + 'medium': { label: '보통', icon: '⚠️', class: 'confidence-medium' }, + 'low': { label: '낮음', icon: '❗', class: 'confidence-low' }, + 'none': { label: '없음', icon: '❌', class: 'confidence-none' } + }; + const confidence = confidenceMap[data.confidence] || confidenceMap['none']; + + // 데이터 없는 경우 + if (data.confidence === 'none' || !data.days_until_empty) { + container.innerHTML = ` +
+
+ 🔮 + 재고 소진 예측 +
+
+ 현재 재고 + ${data.current_stock.toLocaleString()}개 +
+
+
+
📊
+
${data.message || '출고 데이터가 없어 예측할 수 없습니다'}
+
+
`; + return; + } + + // 소진 예상일까지 남은 일수 색상 + let daysColor = 'var(--accent-emerald)'; + if (data.days_until_empty <= 7) { + daysColor = 'var(--accent-rose)'; + } else if (data.days_until_empty <= 14) { + daysColor = 'var(--accent-amber)'; + } + + container.innerHTML = ` +
+
+ 🔮 + 재고 소진 예측 +
+ +
+ 현재 재고 + ${data.current_stock.toLocaleString()}개 +
+
+ 평균 일일 출고 + ${data.avg_daily_outbound}개 +
+ +
+
+ 🗓️ + 예상 소진일 + ${data.estimated_empty_date}(${data.days_until_empty}일 후) +
+
+ 🛒 + 발주 권장일 + ${data.recommended_order_date} +
+
+ +
+ ${trend.icon} ${trend.label} + ${confidence.icon} 신뢰도: ${confidence.label} +
+
`; + } + // ══════════════════ 차트 렌더링 ══════════════════ function renderDailyTrendChart(items) { const ctx = document.getElementById('dailyTrendChart').getContext('2d');