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 @@
+
+
+
+
@@ -670,18 +678,23 @@
-
-
+
+
-
+
💊
약품을 검색하여 선택하세요
+
재고 추이와 처방 사용량을 함께 분석합니다
@@ -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) {