feat(animal-chat): APC 코드 2024년 체계 지원 및 피부약 2단계 추천

## APC 코드 체계 확장
- 기존: 023%만 검색 (~2023년 제품만)
- 변경: 02% OR 92% + 13자리 검증
  - 02%: 2023년 이전 item_seq (9자리) 기반 APC
  - 92%: 2024년 이후 item_seq (10자리) 기반 APC
- 999% 등 청구프로그램 임의코드는 제외

## 동물약 챗봇 피부약 추천 개선
- 피부약 2단계 추천 구조 추가
  - 1차(치료): 의약품 (개시딘겔, 테르비덤 등)
  - 2차(보조케어): 의약외품 (스킨카솔 - 회복기 피부보호)
- 스킨카솔은 의약외품임을 명시하여 치료제로 오인 방지

## 기타
- RAG 테스트 스크립트 추가
- 수인약품 API 문서화
This commit is contained in:
thug0bin
2026-03-11 14:17:04 +09:00
parent e470deaefc
commit 83ecf88bd4
21 changed files with 724 additions and 76 deletions

View File

@@ -48,6 +48,87 @@
50% { opacity: 1; }
}
/* ══════════════════ 주문량 툴팁 ══════════════════ */
.order-qty-cell {
position: relative;
cursor: pointer;
}
.order-qty-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 100;
opacity: 0;
visibility: hidden;
transition: all 0.2s;
pointer-events: none;
}
.order-qty-cell:hover .order-qty-tooltip {
opacity: 1;
visibility: visible;
bottom: calc(100% + 8px);
}
.order-qty-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border);
}
.order-qty-tooltip-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.order-qty-tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 12px;
}
.order-qty-tooltip-row:not(:last-child) {
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.order-qty-vendor {
display: flex;
align-items: center;
gap: 6px;
}
.order-qty-vendor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.order-qty-vendor-dot.geoyoung { background: #06b6d4; }
.order-qty-vendor-dot.sooin { background: #a855f7; }
.order-qty-vendor-dot.baekje { background: #f59e0b; }
.order-qty-vendor-dot.dongwon { background: #22c55e; }
.order-qty-value {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: var(--text-primary);
}
.order-qty-total {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-weight: 700;
color: var(--accent-cyan);
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
@@ -958,60 +1039,45 @@
let totalOrders = 0;
// 지오영 데이터 합산
if (geoRes.success && geoRes.by_kd_code) {
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('지오영');
}
totalOrders += geoRes.order_count || 0;
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
}
// 도매상 정보 (확장 가능)
const vendorConfig = {
geoyoung: { name: '지오영', icon: '🏭', res: geoRes },
sooin: { name: '수인', icon: '💜', res: sooinRes },
baekje: { name: '백제', icon: '💉', res: baekjeRes },
dongwon: { name: '동원', icon: '🟠', res: dongwonRes }
};
// 수인 데이터 합산
if (sooinRes.success && sooinRes.by_kd_code) {
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
// 각 도매상 데이터 합산 (상세 정보 포함)
for (const [vendorId, config] of Object.entries(vendorConfig)) {
const res = config.res;
if (res.success && res.by_kd_code) {
for (const [kd, data] of Object.entries(res.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = {
product_name: data.product_name,
spec: data.spec,
boxes: 0,
units: 0,
details: [] // 도매상별 상세 배열
};
}
const boxes = data.boxes || 0;
const units = data.units || 0;
orderDataByKd[kd].boxes += boxes;
orderDataByKd[kd].units += units;
// 상세 정보 추가 (수량이 있는 경우만)
if (units > 0 || boxes > 0) {
orderDataByKd[kd].details.push({
vendor: vendorId,
name: config.name,
boxes: boxes,
units: units
});
}
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('수인');
totalOrders += res.order_count || 0;
console.log(`${config.icon} ${config.name} 주문량:`, Object.keys(res.by_kd_code).length, '품목,', res.order_count, '건');
}
totalOrders += sooinRes.order_count || 0;
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
}
// 백제 데이터 합산
if (baekjeRes.success && baekjeRes.by_kd_code) {
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('백제');
}
totalOrders += baekjeRes.order_count || 0;
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
}
// 동원 데이터 합산
if (dongwonRes.success && dongwonRes.by_kd_code) {
for (const [kd, data] of Object.entries(dongwonRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('동원');
}
totalOrders += dongwonRes.order_count || 0;
console.log('🟠 동원 주문량:', Object.keys(dongwonRes.by_kd_code).length, '품목,', dongwonRes.order_count, '건');
}
console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
@@ -1025,14 +1091,46 @@
}
}
// KD코드로 주문량 조회
// KD코드로 주문량 조회 (툴팁 포함)
let orderDataLoading = true; // 로딩 상태
function getOrderedQty(kdCode) {
if (orderDataLoading) return '<span class="order-loading">···</span>';
const order = orderDataByKd[kdCode];
if (!order) return '-';
return order.units.toLocaleString();
if (!order || order.units === 0) return '-';
// 상세 정보가 없거나 1개만 있으면 단순 표시
if (!order.details || order.details.length <= 1) {
const vendorName = order.details && order.details[0] ? order.details[0].name : '';
return `<span title="${vendorName}">${order.units.toLocaleString()}</span>`;
}
// 2개 이상 도매상이면 툴팁 표시
let tooltipHtml = `<div class="order-qty-tooltip">
<div class="order-qty-tooltip-title">도매상별 주문</div>`;
for (const detail of order.details) {
tooltipHtml += `
<div class="order-qty-tooltip-row">
<span class="order-qty-vendor">
<span class="order-qty-vendor-dot ${detail.vendor}"></span>
${detail.name}
</span>
<span class="order-qty-value">${detail.units.toLocaleString()}</span>
</div>`;
}
tooltipHtml += `
<div class="order-qty-tooltip-row order-qty-total">
<span>합계</span>
<span>${order.units.toLocaleString()}</span>
</div>
</div>`;
return `<div class="order-qty-cell">
${order.units.toLocaleString()}
${tooltipHtml}
</div>`;
}
// ──────────────── 데이터 로드 ────────────────

View File

@@ -878,6 +878,13 @@
vertical-align: middle;
}
.med-table tr:hover { background: #f8fafc; }
.med-num {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
background: #7c3aed; color: #fff;
font-size: 0.7rem; font-weight: 700;
margin-right: 6px; flex-shrink: 0;
}
.med-name { font-weight: 600; color: #1e293b; }
.med-code { font-size: 0.75rem; color: #94a3b8; }
.med-dosage {
@@ -1555,12 +1562,12 @@
</tr>
</thead>
<tbody>
${data.medications.map(m => `
${data.medications.map((m, i) => `
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
<td>
<div class="med-name">
${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
<span class="med-num">${i+1}</span>${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
@@ -1666,10 +1673,10 @@
</tr>
</thead>
<tbody>
${h.medications.map(m => `
${h.medications.map((m, i) => `
<tr>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.7rem;color:#94a3b8;">${escapeHtml(m.add_info)}</div>` : ''}
</td>
<td>${m.dosage || '-'}</td>
@@ -1790,7 +1797,7 @@
</tr>
</thead>
<tbody>
${compared.map(m => {
${compared.map((m, i) => {
const rowClass = 'row-' + m.status;
const statusLabel = {
added: '<span class="med-status status-added">🆕 추가</span>',
@@ -1817,7 +1824,7 @@
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}">
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
</td>
@@ -1851,11 +1858,11 @@
</tr>
</thead>
<tbody>
${currentMedications.map(m => `
${currentMedications.map((m, i) => `
<tr data-add-info="${escapeHtml(m.add_info || '')}">
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
</td>