feat: 재고량 vs 처방 사용량 이중 Y축 비교 그래프 추가
- /api/stock-analytics/stock-with-usage API 추가 - 일별/주별/월별 분석 기간 선택 - 재고 추세 + 사용량 추세 동시 분석 - 추세 해석 카드 (과잉재고/부족 위험 등 자동 진단)
This commit is contained in:
parent
2ca35cdc82
commit
0b81999cb4
186
backend/app.py
186
backend/app.py
@ -9973,6 +9973,192 @@ def api_stock_level():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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')
|
@app.route('/api/stock-analytics/search-drugs')
|
||||||
def api_search_drugs():
|
def api_search_drugs():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -608,6 +608,14 @@
|
|||||||
<input type="text" id="drugSearch" placeholder="약품명 또는 코드 입력..." autocomplete="off">
|
<input type="text" id="drugSearch" placeholder="약품명 또는 코드 입력..." autocomplete="off">
|
||||||
<div class="drug-search-results" id="drugSearchResults"></div>
|
<div class="drug-search-results" id="drugSearchResults"></div>
|
||||||
</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>
|
<button class="search-btn" onclick="loadAllData()">📊 분석</button>
|
||||||
|
|
||||||
<div id="selectedDrugWrap" style="display:none;">
|
<div id="selectedDrugWrap" style="display:none;">
|
||||||
@ -670,18 +678,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 특정 약품 재고 변화 -->
|
<!-- 재고량 vs 사용량 비교 차트 (이중 Y축) -->
|
||||||
<div class="chart-card">
|
<div class="chart-card full-width">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<span class="chart-badge">📊</span>
|
<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>
|
</div>
|
||||||
<div class="chart-container" id="stockLevelContainer">
|
<div class="chart-container tall" id="stockLevelContainer">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">💊</div>
|
<div class="empty-icon">💊</div>
|
||||||
<div>약품을 검색하여 선택하세요</div>
|
<div>약품을 검색하여 선택하세요</div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">재고 추이와 처방 사용량을 함께 분석합니다</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -695,6 +708,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- TOP 10 테이블 -->
|
<!-- TOP 10 테이블 -->
|
||||||
@ -827,13 +850,15 @@
|
|||||||
function clearSelectedDrug() {
|
function clearSelectedDrug() {
|
||||||
selectedDrugCode = null;
|
selectedDrugCode = null;
|
||||||
document.getElementById('selectedDrugWrap').style.display = 'none';
|
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 = `
|
document.getElementById('stockLevelContainer').innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">💊</div>
|
<div class="empty-icon">💊</div>
|
||||||
<div>약품을 검색하여 선택하세요</div>
|
<div>약품을 검색하여 선택하세요</div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">재고 추이와 처방 사용량을 함께 분석합니다</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// 예측 카드 초기화
|
// 예측 카드 초기화
|
||||||
@ -843,6 +868,13 @@
|
|||||||
<div>약품을 선택하면 소진 예측을 표시합니다</div>
|
<div>약품을 선택하면 소진 예측을 표시합니다</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// 추세 분석 카드 초기화
|
||||||
|
document.getElementById('trendAnalysisContainer').innerHTML = `
|
||||||
|
<div class="forecast-empty">
|
||||||
|
<div class="forecast-empty-icon">📈</div>
|
||||||
|
<div>약품을 선택하면 추세 분석을 표시합니다</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
if (stockLevelChart) {
|
if (stockLevelChart) {
|
||||||
stockLevelChart.destroy();
|
stockLevelChart.destroy();
|
||||||
stockLevelChart = null;
|
stockLevelChart = null;
|
||||||
@ -917,20 +949,24 @@
|
|||||||
|
|
||||||
const startDate = document.getElementById('startDate').value;
|
const startDate = document.getElementById('startDate').value;
|
||||||
const endDate = document.getElementById('endDate').value;
|
const endDate = document.getElementById('endDate').value;
|
||||||
|
const period = document.getElementById('periodSelect').value;
|
||||||
|
|
||||||
// 캔버스 준비
|
// 캔버스 준비
|
||||||
document.getElementById('stockLevelContainer').innerHTML = '<canvas id="stockLevelChart"></canvas>';
|
document.getElementById('stockLevelContainer').innerHTML = '<canvas id="stockLevelChart"></canvas>';
|
||||||
|
document.getElementById('chartLegendCustom').style.display = 'flex';
|
||||||
|
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('stockLevelTitle').textContent = `${data.product_name} 재고 변화`;
|
const periodLabel = {daily: '일별', weekly: '주별', monthly: '월별'}[period] || '';
|
||||||
renderStockLevelChart(data.items, data.product_name);
|
document.getElementById('stockLevelTitle').textContent = `${data.product_name} - ${periodLabel} 재고량 vs 사용량`;
|
||||||
|
renderStockLevelChart(data.items, data.product_name, data.stats);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('재고 변화 로드 실패:', 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');
|
const ctx = document.getElementById('stockLevelChart').getContext('2d');
|
||||||
|
|
||||||
if (stockLevelChart) {
|
if (stockLevelChart) {
|
||||||
@ -1198,34 +1234,69 @@
|
|||||||
|
|
||||||
const labels = items.map(item => item.date);
|
const labels = items.map(item => item.date);
|
||||||
const stockData = items.map(item => item.stock);
|
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, {
|
stockLevelChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: labels,
|
labels: labels,
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: '재고량',
|
{
|
||||||
data: stockData,
|
type: 'line',
|
||||||
borderColor: '#a855f7',
|
label: '재고량',
|
||||||
backgroundColor: 'rgba(168, 85, 247, 0.2)',
|
data: stockData,
|
||||||
fill: true,
|
borderColor: '#a855f7',
|
||||||
tension: 0.3,
|
backgroundColor: 'rgba(168, 85, 247, 0.15)',
|
||||||
pointRadius: 4,
|
fill: true,
|
||||||
pointHoverRadius: 7,
|
tension: 0.3,
|
||||||
pointBackgroundColor: '#a855f7'
|
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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index'
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: '#1e293b',
|
||||||
|
titleColor: '#f1f5f9',
|
||||||
|
bodyColor: '#94a3b8',
|
||||||
|
borderColor: '#334155',
|
||||||
|
borderWidth: 1,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
afterLabel: function(context) {
|
afterBody: function(context) {
|
||||||
const item = items[context.dataIndex];
|
const idx = context[0].dataIndex;
|
||||||
return `입고: ${item.inbound} / 출고: ${item.outbound}`;
|
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: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||||
ticks: { color: '#64748b' }
|
ticks: {
|
||||||
|
color: '#64748b',
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'left',
|
||||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
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
|
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) {
|
function renderTopUsageTable(items) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user