feat: 재고량 vs 처방 사용량 이중 Y축 비교 그래프 추가

- /api/stock-analytics/stock-with-usage API 추가
- 일별/주별/월별 분석 기간 선택
- 재고 추세 + 사용량 추세 동시 분석
- 추세 해석 카드 (과잉재고/부족 위험 등 자동 진단)
This commit is contained in:
thug0bin
2026-03-13 08:07:12 +09:00
parent 2ca35cdc82
commit 0b81999cb4
2 changed files with 450 additions and 26 deletions

View File

@@ -608,6 +608,14 @@
<input type="text" id="drugSearch" placeholder="약품명 또는 코드 입력..." autocomplete="off">
<div class="drug-search-results" id="drugSearchResults"></div>
</div>
<div class="search-group">
<label>분석 기간</label>
<select id="periodSelect">
<option value="daily">일별</option>
<option value="weekly">주별</option>
<option value="monthly" selected>월별</option>
</select>
</div>
<button class="search-btn" onclick="loadAllData()">📊 분석</button>
<div id="selectedDrugWrap" style="display:none;">
@@ -670,18 +678,23 @@
</div>
</div>
<!-- 특정 약품 재고 변화 -->
<div class="chart-card">
<!-- 재고량 vs 사용량 비교 차트 (이중 Y축) -->
<div class="chart-card full-width">
<div class="chart-header">
<div class="chart-title">
<span class="chart-badge">📊</span>
<span id="stockLevelTitle">특정 약품 재고 변화</span>
<span id="stockLevelTitle">재고량 vs 처방 사용량 비교</span>
</div>
<div class="chart-legend-custom" id="chartLegendCustom" style="display:none;">
<span style="color:#a855f7;font-size:12px;margin-right:16px;">━ 재고량 (좌)</span>
<span style="color:#3b82f6;font-size:12px;">▮ 처방 사용량 (우)</span>
</div>
</div>
<div class="chart-container" id="stockLevelContainer">
<div class="chart-container tall" id="stockLevelContainer">
<div class="empty-state">
<div class="empty-icon">💊</div>
<div>약품을 검색하여 선택하세요</div>
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">재고 추이와 처방 사용량을 함께 분석합니다</div>
</div>
</div>
</div>
@@ -695,6 +708,16 @@
</div>
</div>
</div>
<!-- 추세 해석 카드 -->
<div class="chart-card">
<div id="trendAnalysisContainer">
<div class="forecast-empty">
<div class="forecast-empty-icon">📈</div>
<div>약품을 선택하면 추세 분석을 표시합니다</div>
</div>
</div>
</div>
</div>
<!-- TOP 10 테이블 -->
@@ -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 = `
<div class="empty-state">
<div class="empty-icon">💊</div>
<div>약품을 검색하여 선택하세요</div>
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">재고 추이와 처방 사용량을 함께 분석합니다</div>
</div>`;
// 예측 카드 초기화
@@ -843,6 +868,13 @@
<div>약품을 선택하면 소진 예측을 표시합니다</div>
</div>`;
// 추세 분석 카드 초기화
document.getElementById('trendAnalysisContainer').innerHTML = `
<div class="forecast-empty">
<div class="forecast-empty-icon">📈</div>
<div>약품을 선택하면 추세 분석을 표시합니다</div>
</div>`;
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 = '<canvas id="stockLevelChart"></canvas>';
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 = `
<div class="forecast-card">
<div class="forecast-header">
<span>📈</span>
<span class="forecast-title">추세 분석</span>
</div>
<div class="forecast-row">
<span class="forecast-label">재고 추세</span>
<span class="forecast-value" style="color: ${trendColor[stockTrend]};">
${trendIcon[stockTrend]} ${stockChange >= 0 ? '+' : ''}${stockChange.toLocaleString()} (${stockChangePercent >= 0 ? '+' : ''}${stockChangePercent}%)
</span>
</div>
<div class="forecast-row">
<span class="forecast-label">사용량 추세</span>
<span class="forecast-value" style="color: ${trendColor[usageTrend]};">
${trendIcon[usageTrend]} ${usageChange >= 0 ? '+' : ''}${usageChange.toLocaleString()} (${usageChangePercent >= 0 ? '+' : ''}${usageChangePercent}%)
</span>
</div>
<hr class="forecast-divider">
<div class="forecast-row">
<span class="forecast-label">총 처방 사용량</span>
<span class="forecast-value" style="color: var(--accent-blue);">${stats.total_rx_usage.toLocaleString()}개</span>
</div>
<div class="forecast-row">
<span class="forecast-label">처방 건수</span>
<span class="forecast-value">${stats.total_rx_count.toLocaleString()}건</span>
</div>
<div class="forecast-row">
<span class="forecast-label">기간 평균 사용량</span>
<span class="forecast-value">${stats.avg_rx_usage}개/기간</span>
</div>
<div class="forecast-highlight" style="background: ${statusBg[statusClass]}; border-left: 3px solid ${statusBorder[statusClass]}; margin-top: 16px;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:20px;">${statusIcon}</span>
<span style="font-size:13px; font-weight:500;">${interpretation}</span>
</div>
</div>
</div>
`;
}
function renderTopUsageTable(items) {