diff --git a/backend/app.py b/backend/app.py index 1c6d76a..ad19d4d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9973,6 +9973,192 @@ def api_stock_level(): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/stock-analytics/stock-with-usage') +def api_stock_with_usage(): + """ + 재고 변화 + 처방 기반 사용량 통합 API (이중 Y축 그래프용) + GET /api/stock-analytics/stock-with-usage?drug_code=A123456789&start_date=2026-01-01&end_date=2026-03-13&period=daily|weekly|monthly + + 반환: + - items: [{ date, stock, inbound, outbound, rx_usage }] + - rx_usage: PS_sub_pharm 기반 처방 사용량 + """ + conn = None + conn_pres = None + try: + drug_code = request.args.get('drug_code', '').strip() + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + period = request.args.get('period', 'daily') # daily, weekly, monthly + + if not drug_code: + return jsonify({'success': False, 'error': 'drug_code 파라미터 필요'}), 400 + + # 날짜 형식 변환 + start_date_fmt = start_date.replace('-', '') if start_date else (datetime.now() - timedelta(days=90)).strftime('%Y%m%d') + end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d') + + import pyodbc + conn_str_drug = ( + 'DRIVER={ODBC Driver 17 for SQL Server};' + 'SERVER=192.168.0.4\\PM2014;' + 'DATABASE=PM_DRUG;' + 'UID=sa;' + 'PWD=tmddls214!%(;' + 'TrustServerCertificate=yes' + ) + conn_str_pres = ( + 'DRIVER={ODBC Driver 17 for SQL Server};' + 'SERVER=192.168.0.4\\PM2014;' + 'DATABASE=PM_PRES;' + 'UID=sa;' + 'PWD=tmddls214!%(;' + 'TrustServerCertificate=yes' + ) + + conn = pyodbc.connect(conn_str_drug, timeout=10) + conn_pres = pyodbc.connect(conn_str_pres, timeout=10) + cursor = conn.cursor() + cursor_pres = conn_pres.cursor() + + # 1. 현재 재고 및 약품 정보 + cursor.execute(""" + SELECT + IT.IM_QT_sale_debit as current_stock, + G.GoodsName as product_name, + G.SplName as supplier + FROM IM_total IT + LEFT JOIN CD_GOODS G ON IT.DrugCode = G.DrugCode + WHERE IT.DrugCode = ? + """, (drug_code,)) + + stock_row = cursor.fetchone() + current_stock = int(stock_row.current_stock or 0) if stock_row else 0 + product_name = stock_row.product_name if stock_row else drug_code + supplier = stock_row.supplier if stock_row else '' + + # 2. 그룹핑 기준 설정 + if period == 'monthly': + date_group_expr = "LEFT(IM_DT_appl, 6)" # YYYYMM + rx_date_group_expr = "LEFT(INSUDATETIME, 6)" + elif period == 'weekly': + # 주차 계산: YYYYWW + date_group_expr = "CAST(YEAR(CONVERT(DATE, IM_DT_appl, 112)) AS VARCHAR) + RIGHT('0' + CAST(DATEPART(WEEK, CONVERT(DATE, IM_DT_appl, 112)) AS VARCHAR), 2)" + rx_date_group_expr = "CAST(YEAR(CONVERT(DATE, INSUDATETIME, 112)) AS VARCHAR) + RIGHT('0' + CAST(DATEPART(WEEK, CONVERT(DATE, INSUDATETIME, 112)) AS VARCHAR), 2)" + else: # daily + date_group_expr = "IM_DT_appl" + rx_date_group_expr = "LEFT(INSUDATETIME, 8)" + + # 3. 재고 입출고 데이터 (IM_date_total) + cursor.execute(f""" + SELECT + {date_group_expr} as date_group, + SUM(ISNULL(IM_QT_sale_debit, 0)) as inbound, + SUM(ISNULL(IM_QT_sale_credit, 0)) as outbound + FROM IM_date_total + WHERE DrugCode = ? + AND IM_DT_appl >= ? + AND IM_DT_appl <= ? + GROUP BY {date_group_expr} + ORDER BY {date_group_expr} DESC + """, (drug_code, start_date_fmt, end_date_fmt)) + + stock_rows = cursor.fetchall() + + # 4. 처방 기반 사용량 (PS_sub_pharm) + # 컬럼명: PreSerial, Indate, DrugCode, QUAN, Days + if period == 'monthly': + rx_date_group_expr = "LEFT(CONVERT(VARCHAR, PSP.Indate, 112), 6)" + elif period == 'weekly': + rx_date_group_expr = "CAST(YEAR(PSP.Indate) AS VARCHAR) + RIGHT('0' + CAST(DATEPART(WEEK, PSP.Indate) AS VARCHAR), 2)" + else: # daily + rx_date_group_expr = "CONVERT(VARCHAR, PSP.Indate, 112)" + + cursor_pres.execute(f""" + SELECT + {rx_date_group_expr} as date_group, + SUM(ISNULL(PSP.QUAN * PSP.QUAN_TIME * PSP.Days, 0)) as rx_usage, + COUNT(DISTINCT PSP.PreSerial) as rx_count + FROM PS_sub_pharm PSP + WHERE PSP.DrugCode = ? + AND PSP.Indate >= ? + AND PSP.Indate <= ? + GROUP BY {rx_date_group_expr} + ORDER BY {rx_date_group_expr} + """, (drug_code, start_date_fmt, end_date_fmt)) + + rx_rows = cursor_pres.fetchall() + rx_data = {str(row.date_group): {'rx_usage': int(row.rx_usage or 0), 'rx_count': int(row.rx_count or 0)} for row in rx_rows} + + # 5. 역순 누적 재고 계산 + 처방 사용량 병합 + items = [] + running_stock = current_stock + + for row in stock_rows: + date_group = str(row.date_group) if row.date_group else '' + + # 날짜 포맷팅 + if period == 'monthly' and len(date_group) == 6: + formatted_date = f"{date_group[:4]}-{date_group[4:6]}" + elif period == 'weekly': + formatted_date = date_group # YYYYWW + else: # daily + formatted_date = f"{date_group[:4]}-{date_group[4:6]}-{date_group[6:8]}" if len(date_group) == 8 else date_group + + inbound = int(row.inbound or 0) + outbound = int(row.outbound or 0) + + rx_info = rx_data.get(date_group, {'rx_usage': 0, 'rx_count': 0}) + + items.append({ + 'date': formatted_date, + 'date_group': date_group, + 'stock': running_stock, + 'inbound': inbound, + 'outbound': outbound, + 'rx_usage': rx_info['rx_usage'], + 'rx_count': rx_info['rx_count'] + }) + + running_stock = running_stock - inbound + outbound + + # 시간순 정렬 + items.reverse() + + # 6. 통계 계산 + total_rx_usage = sum(item['rx_usage'] for item in items) + total_rx_count = sum(item['rx_count'] for item in items) + avg_rx_usage = total_rx_usage / len(items) if items else 0 + + conn.close() + conn_pres.close() + + return jsonify({ + 'success': True, + 'drug_code': drug_code, + 'product_name': product_name, + 'supplier': supplier, + 'current_stock': current_stock, + 'period': period, + 'items': items, + 'stats': { + 'total_rx_usage': total_rx_usage, + 'total_rx_count': total_rx_count, + 'avg_rx_usage': round(avg_rx_usage, 1) + } + }) + + except Exception as e: + if conn: + conn.close() + if conn_pres: + conn_pres.close() + logging.error(f"stock-with-usage API 오류: {e}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/stock-analytics/search-drugs') def api_search_drugs(): """ diff --git a/backend/templates/admin_stock_analytics.html b/backend/templates/admin_stock_analytics.html index d41e75d..cb3de52 100644 --- a/backend/templates/admin_stock_analytics.html +++ b/backend/templates/admin_stock_analytics.html @@ -608,6 +608,14 @@
+
+ + +
- -
+ +
📊 - 특정 약품 재고 변화 + 재고량 vs 처방 사용량 비교 +
+
-
+
💊
약품을 검색하여 선택하세요
+
재고 추이와 처방 사용량을 함께 분석합니다
@@ -695,6 +708,16 @@
+ + +
+
+
+
📈
+
약품을 선택하면 추세 분석을 표시합니다
+
+
+
@@ -827,13 +850,15 @@ function clearSelectedDrug() { selectedDrugCode = null; document.getElementById('selectedDrugWrap').style.display = 'none'; - document.getElementById('stockLevelTitle').textContent = '특정 약품 재고 변화'; + document.getElementById('stockLevelTitle').textContent = '재고량 vs 처방 사용량 비교'; + document.getElementById('chartLegendCustom').style.display = 'none'; // 재고 변화 차트 초기화 document.getElementById('stockLevelContainer').innerHTML = `
💊
약품을 검색하여 선택하세요
+
재고 추이와 처방 사용량을 함께 분석합니다
`; // 예측 카드 초기화 @@ -843,6 +868,13 @@
약품을 선택하면 소진 예측을 표시합니다
`; + // 추세 분석 카드 초기화 + document.getElementById('trendAnalysisContainer').innerHTML = ` +
+
📈
+
약품을 선택하면 추세 분석을 표시합니다
+
`; + if (stockLevelChart) { stockLevelChart.destroy(); stockLevelChart = null; @@ -917,20 +949,24 @@ const startDate = document.getElementById('startDate').value; const endDate = document.getElementById('endDate').value; + const period = document.getElementById('periodSelect').value; // 캔버스 준비 document.getElementById('stockLevelContainer').innerHTML = ''; + document.getElementById('chartLegendCustom').style.display = 'flex'; try { - const response = await fetch(`/api/stock-analytics/stock-level?drug_code=${selectedDrugCode}&start_date=${startDate}&end_date=${endDate}`); + const response = await fetch(`/api/stock-analytics/stock-with-usage?drug_code=${selectedDrugCode}&start_date=${startDate}&end_date=${endDate}&period=${period}`); const data = await response.json(); if (data.success) { - document.getElementById('stockLevelTitle').textContent = `${data.product_name} 재고 변화`; - renderStockLevelChart(data.items, data.product_name); + const periodLabel = {daily: '일별', weekly: '주별', monthly: '월별'}[period] || ''; + document.getElementById('stockLevelTitle').textContent = `${data.product_name} - ${periodLabel} 재고량 vs 사용량`; + renderStockLevelChart(data.items, data.product_name, data.stats); } } catch (err) { console.error('재고 변화 로드 실패:', err); + showToast('재고 데이터 로드 실패', 'error'); } } @@ -1189,7 +1225,7 @@ }); } - function renderStockLevelChart(items, productName) { + function renderStockLevelChart(items, productName, stats) { const ctx = document.getElementById('stockLevelChart').getContext('2d'); if (stockLevelChart) { @@ -1198,34 +1234,69 @@ const labels = items.map(item => item.date); const stockData = items.map(item => item.stock); + const rxUsageData = items.map(item => item.rx_usage); + + // 재고가 늘어나는데 사용량은 줄어드는지 등 해석용 데이터 + const hasUsageData = rxUsageData.some(v => v > 0); stockLevelChart = new Chart(ctx, { - type: 'line', + type: 'bar', data: { labels: labels, - datasets: [{ - label: '재고량', - data: stockData, - borderColor: '#a855f7', - backgroundColor: 'rgba(168, 85, 247, 0.2)', - fill: true, - tension: 0.3, - pointRadius: 4, - pointHoverRadius: 7, - pointBackgroundColor: '#a855f7' - }] + datasets: [ + { + type: 'line', + label: '재고량', + data: stockData, + borderColor: '#a855f7', + backgroundColor: 'rgba(168, 85, 247, 0.15)', + fill: true, + tension: 0.3, + pointRadius: 4, + pointHoverRadius: 7, + pointBackgroundColor: '#a855f7', + borderWidth: 3, + yAxisID: 'y', + order: 1 + }, + { + type: 'bar', + label: '처방 사용량', + data: rxUsageData, + backgroundColor: 'rgba(59, 130, 246, 0.7)', + borderColor: '#3b82f6', + borderWidth: 1, + borderRadius: 4, + yAxisID: 'y1', + order: 2 + } + ] }, options: { responsive: true, maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, plugins: { legend: { display: false }, tooltip: { backgroundColor: '#1e293b', + titleColor: '#f1f5f9', + bodyColor: '#94a3b8', + borderColor: '#334155', + borderWidth: 1, callbacks: { - afterLabel: function(context) { - const item = items[context.dataIndex]; - return `입고: ${item.inbound} / 출고: ${item.outbound}`; + afterBody: function(context) { + const idx = context[0].dataIndex; + const item = items[idx]; + const lines = []; + lines.push(`입고: ${item.inbound.toLocaleString()} / 출고: ${item.outbound.toLocaleString()}`); + if (item.rx_count > 0) { + lines.push(`처방 건수: ${item.rx_count}건`); + } + return lines; } } } @@ -1233,16 +1304,183 @@ scales: { x: { grid: { color: 'rgba(255,255,255,0.05)' }, - ticks: { color: '#64748b' } + ticks: { + color: '#64748b', + maxRotation: 45, + minRotation: 0 + } }, y: { + type: 'linear', + position: 'left', grid: { color: 'rgba(255,255,255,0.05)' }, - ticks: { color: '#64748b' }, + ticks: { + color: '#a855f7', + callback: function(value) { + return value.toLocaleString(); + } + }, + title: { + display: true, + text: '재고량', + color: '#a855f7', + font: { weight: 600 } + }, + beginAtZero: false + }, + y1: { + type: 'linear', + position: 'right', + grid: { drawOnChartArea: false }, + ticks: { + color: '#3b82f6', + callback: function(value) { + return value.toLocaleString(); + } + }, + title: { + display: true, + text: '처방 사용량', + color: '#3b82f6', + font: { weight: 600 } + }, beginAtZero: true } } } }); + + // 데이터 해석 힌트 표시 + if (stats && items.length >= 2) { + displayTrendAnalysis(items, stats); + } + } + + function displayTrendAnalysis(items, stats) { + const container = document.getElementById('trendAnalysisContainer'); + + // 재고 추세 계산 (단순 선형) + const firstStock = items[0].stock; + const lastStock = items[items.length - 1].stock; + const stockChange = lastStock - firstStock; + const stockChangePercent = firstStock > 0 ? Math.round((stockChange / firstStock) * 100) : 0; + const stockTrend = stockChange > 0 ? 'increasing' : (stockChange < 0 ? 'decreasing' : 'stable'); + + // 사용량 추세 계산 (전반부 vs 후반부) + const half = Math.floor(items.length / 2); + const firstHalfUsage = items.slice(0, half).reduce((sum, i) => sum + i.rx_usage, 0); + const secondHalfUsage = items.slice(half).reduce((sum, i) => sum + i.rx_usage, 0); + const usageChange = secondHalfUsage - firstHalfUsage; + const usageChangePercent = firstHalfUsage > 0 ? Math.round((usageChange / firstHalfUsage) * 100) : 0; + const usageTrend = usageChange > firstHalfUsage * 0.1 ? 'increasing' : (usageChange < -firstHalfUsage * 0.1 ? 'decreasing' : 'stable'); + + // 해석 메시지 및 상태 결정 + let interpretation = ''; + let statusClass = ''; + let statusIcon = ''; + + if (stockTrend === 'increasing' && usageTrend === 'stable') { + interpretation = '재고 증가 중, 사용량 유지 → 과잉재고 주의'; + statusClass = 'warning'; + statusIcon = '⚠️'; + } else if (stockTrend === 'increasing' && usageTrend === 'increasing') { + interpretation = '재고·사용량 동반 증가 → 건강한 성장'; + statusClass = 'success'; + statusIcon = '✅'; + } else if (stockTrend === 'increasing' && usageTrend === 'decreasing') { + interpretation = '재고 증가, 사용량 감소 → 재고 과잉 위험!'; + statusClass = 'danger'; + statusIcon = '🚨'; + } else if (stockTrend === 'decreasing' && usageTrend === 'increasing') { + interpretation = '재고 감소, 사용량 증가 → 재고 부족 위험!'; + statusClass = 'danger'; + statusIcon = '🚨'; + } else if (stockTrend === 'decreasing' && usageTrend === 'stable') { + interpretation = '재고 감소 추세, 발주 검토 필요'; + statusClass = 'warning'; + statusIcon = '📉'; + } else if (stockTrend === 'decreasing' && usageTrend === 'decreasing') { + interpretation = '재고·사용량 동반 감소 → 수요 감소 추세'; + statusClass = 'info'; + statusIcon = '📉'; + } else { + interpretation = '안정적인 재고 운영'; + statusClass = 'success'; + statusIcon = '➡️'; + } + + // 추세 아이콘 + const trendIcon = { + 'increasing': '📈', + 'decreasing': '📉', + 'stable': '➡️' + }; + + // 추세 색상 + const trendColor = { + 'increasing': 'var(--accent-emerald)', + 'decreasing': 'var(--accent-rose)', + 'stable': 'var(--accent-blue)' + }; + + // 상태 배경색 + const statusBg = { + 'success': 'rgba(16, 185, 129, 0.15)', + 'warning': 'rgba(245, 158, 11, 0.15)', + 'danger': 'rgba(239, 68, 68, 0.15)', + 'info': 'rgba(59, 130, 246, 0.15)' + }; + + const statusBorder = { + 'success': 'var(--accent-emerald)', + 'warning': 'var(--accent-amber)', + 'danger': 'var(--accent-rose)', + 'info': 'var(--accent-blue)' + }; + + container.innerHTML = ` +
+
+ 📈 + 추세 분석 +
+ +
+ 재고 추세 + + ${trendIcon[stockTrend]} ${stockChange >= 0 ? '+' : ''}${stockChange.toLocaleString()} (${stockChangePercent >= 0 ? '+' : ''}${stockChangePercent}%) + +
+
+ 사용량 추세 + + ${trendIcon[usageTrend]} ${usageChange >= 0 ? '+' : ''}${usageChange.toLocaleString()} (${usageChangePercent >= 0 ? '+' : ''}${usageChangePercent}%) + +
+ +
+ +
+ 총 처방 사용량 + ${stats.total_rx_usage.toLocaleString()}개 +
+
+ 처방 건수 + ${stats.total_rx_count.toLocaleString()}건 +
+
+ 기간 평균 사용량 + ${stats.avg_rx_usage}개/기간 +
+ +
+
+ ${statusIcon} + ${interpretation} +
+
+
+ `; } function renderTopUsageTable(items) {