feat: 도매상 API 통합 및 스키마 업데이트

- wholesale 패키지 연동 (SooinSession, GeoYoungSession)
- Flask Blueprint 분리 (sooin_api.py, geoyoung_api.py)
- order_context 스키마 확장 (wholesaler_id, internal_code 등)
- 수인약품 개별 취소 기능 (cancel_item, restore_item)
- 문서 추가: WHOLESALE_API_INTEGRATION.md
- 테스트 스크립트들
This commit is contained in:
thug0bin
2026-03-06 11:50:46 +09:00
parent e84eda928a
commit c1596a6d35
53 changed files with 8789 additions and 3 deletions

View File

@@ -1054,8 +1054,172 @@
// ──────────────── 주문 제출 ────────────────
function submitOrder() {
if (cart.length === 0) return;
// 지오영 품목만 필터
const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
if (geoItems.length === 0) {
// 지오영 품목 없으면 기존 방식 (클립보드)
submitOrderClipboard();
return;
}
// 지오영 주문 모달 열기
openOrderConfirmModal(geoItems);
}
function openOrderConfirmModal(items) {
const modal = document.getElementById('orderConfirmModal');
const tbody = document.getElementById('orderConfirmBody');
let html = '';
items.forEach((item, idx) => {
html += `
<tr>
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.specification || '-'}</td>
<td class="mono">${item.qty}</td>
</tr>`;
});
tbody.innerHTML = html;
document.getElementById('orderConfirmCount').textContent = items.length;
modal.classList.add('show');
}
function closeOrderConfirmModal() {
document.getElementById('orderConfirmModal').classList.remove('show');
}
async function executeOrder(dryRun = true) {
const geoItems = cart.filter(c => c.supplier === '지오영' || c.geoyoung_code);
if (geoItems.length === 0) {
showToast('지오영 품목이 없습니다', 'error');
return;
}
// 버튼 비활성화
const btnTest = document.getElementById('btnOrderTest');
const btnReal = document.getElementById('btnOrderReal');
btnTest.disabled = true;
btnReal.disabled = true;
btnTest.textContent = dryRun ? '처리 중...' : '🧪 테스트';
btnReal.textContent = !dryRun ? '처리 중...' : '🚀 실제 주문';
try {
const payload = {
wholesaler_id: 'geoyoung',
items: geoItems.map(item => ({
drug_code: item.drug_code,
kd_code: item.geoyoung_code || item.drug_code,
product_name: item.product_name,
manufacturer: item.supplier,
specification: item.specification || '',
order_qty: item.qty,
usage_qty: item.usage_qty || 0,
current_stock: item.current_stock || 0
})),
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: dryRun
};
// 실제 주문은 시간이 오래 걸림 (Playwright 사용)
const timeoutMs = dryRun ? 60000 : 180000; // 테스트 1분, 실제 3분
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch('/api/order/quick-submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
const result = await response.json();
closeOrderConfirmModal();
if (result.success) {
showOrderResultModal(result);
} else {
showToast(`❌ 주문 실패: ${result.error}`, 'error');
}
} catch (err) {
showToast(`❌ 오류: ${err.message}`, 'error');
} finally {
btnTest.disabled = false;
btnReal.disabled = false;
btnTest.textContent = '🧪 테스트';
btnReal.textContent = '🚀 실제 주문';
}
}
function showOrderResultModal(result) {
const modal = document.getElementById('orderResultModal');
const content = document.getElementById('orderResultContent');
const isDryRun = result.dry_run;
const statusEmoji = result.failed_count === 0 ? '✅' : result.success_count === 0 ? '❌' : '⚠️';
let html = `
<div class="result-header ${result.failed_count === 0 ? 'success' : 'partial'}">
<span class="result-emoji">${statusEmoji}</span>
<span class="result-title">${isDryRun ? '[테스트]' : ''} 주문 ${result.failed_count === 0 ? '완료' : '처리됨'}</span>
</div>
<div class="result-summary">
<div class="result-stat">
<span class="stat-label">주문번호</span>
<span class="stat-value mono">${result.order_no}</span>
</div>
<div class="result-stat">
<span class="stat-label">성공</span>
<span class="stat-value success">${result.success_count}개</span>
</div>
<div class="result-stat">
<span class="stat-label">실패</span>
<span class="stat-value ${result.failed_count > 0 ? 'failed' : ''}">${result.failed_count}개</span>
</div>
</div>
<table class="result-table">
<thead><tr><th>품목</th><th>수량</th><th>결과</th></tr></thead>
<tbody>`;
(result.results || []).forEach(item => {
const isSuccess = item.status === 'success';
html += `
<tr class="${isSuccess ? '' : 'failed-row'}">
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.order_qty}</td>
<td class="${isSuccess ? 'result-ok' : 'result-fail'}">
${isSuccess ? '✓' : '✗'} ${item.result_code}
${item.result_message ? `<br><small>${escapeHtml(item.result_message)}</small>` : ''}
</td>
</tr>`;
});
html += '</tbody></table>';
if (isDryRun && result.success_count > 0) {
html += `<div class="result-note">💡 테스트 모드입니다. 실제 주문은 "실제 주문" 버튼을 누르세요.</div>`;
}
content.innerHTML = html;
modal.classList.add('show');
}
function closeOrderResultModal() {
document.getElementById('orderResultModal').classList.remove('show');
}
// 기존 클립보드 방식 (지오영 아닌 품목용)
function submitOrderClipboard() {
if (cart.length === 0) return;
// 제조사별 그룹화
const bySupplier = {};
cart.forEach(item => {
const sup = item.supplier || '미지정';
@@ -1063,7 +1227,6 @@
bySupplier[sup].push(item);
});
// 주문서 텍스트 생성
let orderText = `💊 청춘약국 전문의약품 발주서\n`;
orderText += `━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `📅 작성일: ${new Date().toLocaleDateString('ko-KR')}\n`;
@@ -1081,7 +1244,6 @@
orderText += `\n━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `${cart.length}개 품목\n`;
// 클립보드 복사
navigator.clipboard.writeText(orderText).then(() => {
showToast('📋 주문서가 클립보드에 복사되었습니다!', 'success');
}).catch(() => {
@@ -1116,6 +1278,573 @@
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') loadUsageData();
});
// ──────────────── 지오영 재고 조회 ────────────────
let currentGeoyoungItem = null;
function openGeoyoungModal(idx) {
const item = usageData[idx];
if (!item) return;
currentGeoyoungItem = item;
// 모달 열기
document.getElementById('geoModalProductName').textContent = item.product_name;
document.getElementById('geoModalDrugCode').textContent = item.drug_code;
document.getElementById('geoModalUsage').textContent = item.total_dose.toLocaleString() + '개';
document.getElementById('geoModalStock').textContent = item.current_stock.toLocaleString() + '개';
document.getElementById('geoyoungModal').classList.add('show');
// 로딩 표시
document.getElementById('geoResultBody').innerHTML = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>지오영 재고 조회 중...</div>
</div>`;
// API 호출 (보험코드로 먼저 시도)
searchGeoyoung(item.drug_code, item.product_name);
}
function closeGeoyoungModal() {
document.getElementById('geoyoungModal').classList.remove('show');
currentGeoyoungItem = null;
}
async function searchGeoyoung(kdCode, productName) {
const resultBody = document.getElementById('geoResultBody');
try {
// 1차: 보험코드(KD코드)로 검색
let response = await fetch(`/api/geoyoung/stock?kd_code=${encodeURIComponent(kdCode)}`);
let data = await response.json();
// 결과 없으면 성분명으로 재검색
if (data.success && data.count === 0) {
document.getElementById('geoResultBody').innerHTML = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>성분명으로 재검색 중...</div>
</div>`;
response = await fetch(`/api/geoyoung/stock-by-name?product_name=${encodeURIComponent(productName)}`);
data = await response.json();
}
if (!data.success) {
resultBody.innerHTML = `
<div class="geo-error">
<div>❌ ${data.message || '조회 실패'}</div>
</div>`;
return;
}
if (data.count === 0) {
resultBody.innerHTML = `
<div class="geo-empty">
<div>📭 지오영에 해당 제품이 없습니다</div>
</div>`;
return;
}
// 검색어 표시
if (data.extracted_ingredient) {
document.getElementById('geoSearchKeyword').textContent = `검색: "${data.extracted_ingredient}"`;
document.getElementById('geoSearchKeyword').style.display = 'block';
}
// 결과 렌더링
renderGeoyoungResults(data.items);
} catch (err) {
resultBody.innerHTML = `
<div class="geo-error">
<div>❌ 네트워크 오류: ${err.message}</div>
</div>`;
}
}
function renderGeoyoungResults(items) {
const resultBody = document.getElementById('geoResultBody');
// 재고 있는 것 먼저 정렬
items.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
let html = `<table class="geo-table">
<thead>
<tr>
<th>제품명</th>
<th>규격</th>
<th>재고</th>
<th></th>
</tr>
</thead>
<tbody>`;
items.forEach((item, idx) => {
const hasStock = item.stock > 0;
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.product_name)}</span>
<span class="geo-code">${item.insurance_code}</span>
</div>
</td>
<td class="geo-spec">${item.specification}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>
${hasStock ? `<button class="geo-add-btn" onclick="addGeoyoungToCart(${idx})">담기</button>` : ''}
</td>
</tr>`;
});
html += '</tbody></table>';
// 전역에 저장 (담기용)
window.geoyoungItems = items;
resultBody.innerHTML = html;
}
function addGeoyoungToCart(idx) {
const item = window.geoyoungItems[idx];
if (!item || !currentGeoyoungItem) return;
// 수량 계산 (규격에서 숫자 추출)
const specMatch = item.specification.match(/(\d+)/);
const specQty = specMatch ? parseInt(specMatch[1]) : 1;
// 필요 수량 계산
const needed = currentGeoyoungItem.total_dose;
const suggestedQty = Math.ceil(needed / specQty);
const qty = prompt(`주문 수량 (${item.specification} 기준)\n\n필요량: ${needed}\n규격: ${specQty}개/단위\n추천: ${suggestedQty}단위 (${suggestedQty * specQty}개)`, suggestedQty);
if (!qty || isNaN(qty)) return;
// 장바구니에 추가 (지오영 정보 포함)
const cartItem = {
drug_code: currentGeoyoungItem.drug_code,
product_name: item.product_name,
supplier: '지오영',
qty: parseInt(qty),
specification: item.specification,
geoyoung_code: item.insurance_code
};
// 기존 항목 체크
const existing = cart.find(c => c.drug_code === currentGeoyoungItem.drug_code && c.specification === item.specification);
if (existing) {
existing.qty = parseInt(qty);
} else {
cart.push(cartItem);
}
updateCartUI();
closeGeoyoungModal();
showToast(`${item.product_name} (${item.specification}) ${qty}개 추가`, 'success');
}
// 테이블 행 더블클릭으로 지오영 모달 열기
document.addEventListener('dblclick', function(e) {
const row = e.target.closest('tr[data-idx]');
if (row) {
const idx = parseInt(row.dataset.idx);
openGeoyoungModal(idx);
}
});
</script>
<!-- 지오영 재고 조회 모달 -->
<div class="geo-modal" id="geoyoungModal">
<div class="geo-modal-content">
<div class="geo-modal-header">
<h3>🏭 지오영 재고 조회</h3>
<button class="geo-close" onclick="closeGeoyoungModal()"></button>
</div>
<div class="geo-modal-info">
<div class="geo-info-row">
<span class="geo-label">약품명</span>
<span class="geo-value" id="geoModalProductName">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">보험코드</span>
<span class="geo-value mono" id="geoModalDrugCode">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">사용량</span>
<span class="geo-value highlight" id="geoModalUsage">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">현재고</span>
<span class="geo-value" id="geoModalStock">-</span>
</div>
</div>
<div class="geo-search-info" id="geoSearchKeyword" style="display:none;"></div>
<div class="geo-result" id="geoResultBody">
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>지오영 재고 조회 중...</div>
</div>
</div>
</div>
</div>
<style>
/* 지오영 모달 스타일 */
.geo-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 500;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.geo-modal.show { display: flex; }
.geo-modal-content {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-cyan);
box-shadow: 0 8px 32px rgba(6, 182, 212, 0.3);
}
.geo-modal-header {
padding: 16px 20px;
background: linear-gradient(135deg, #0891b2, var(--accent-cyan));
display: flex;
justify-content: space-between;
align-items: center;
}
.geo-modal-header h3 {
font-size: 16px;
font-weight: 700;
}
.geo-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px;
height: 28px;
border-radius: 6px;
color: #fff;
cursor: pointer;
}
.geo-modal-info {
padding: 16px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.geo-info-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
}
.geo-label {
color: var(--text-muted);
font-size: 12px;
}
.geo-value {
font-weight: 600;
font-size: 13px;
}
.geo-value.mono {
font-family: 'JetBrains Mono', monospace;
}
.geo-value.highlight {
color: var(--accent-cyan);
}
.geo-search-info {
padding: 8px 20px;
background: rgba(6, 182, 212, 0.1);
font-size: 12px;
color: var(--accent-cyan);
}
.geo-result {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.geo-loading, .geo-error, .geo-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.geo-error { color: var(--accent-rose); }
.geo-table {
width: 100%;
border-collapse: collapse;
}
.geo-table th {
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.geo-table td {
padding: 12px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.geo-table tr:hover {
background: rgba(255,255,255,0.02);
}
.geo-table tr.no-stock {
opacity: 0.5;
}
.geo-product {
display: flex;
flex-direction: column;
gap: 2px;
}
.geo-name {
font-weight: 500;
}
.geo-code {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
}
.geo-spec {
font-weight: 600;
color: var(--accent-amber);
}
.geo-stock {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
text-align: center;
}
.geo-stock.in-stock { color: var(--accent-emerald); }
.geo-stock.out-stock { color: var(--text-muted); }
.geo-add-btn {
padding: 6px 12px;
background: var(--accent-cyan);
border: none;
border-radius: 6px;
color: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.geo-add-btn:hover {
background: #0891b2;
transform: scale(1.05);
}
/* 주문 확인 모달 */
.order-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 600;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.order-modal.show { display: flex; }
.order-modal-content {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-violet);
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
}
.order-modal-header {
padding: 16px 20px;
background: linear-gradient(135deg, #7c3aed, var(--accent-violet));
display: flex;
justify-content: space-between;
align-items: center;
}
.order-modal-header h3 { font-size: 16px; font-weight: 700; }
.order-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px; height: 28px;
border-radius: 6px;
color: #fff;
cursor: pointer;
}
.order-modal-body {
padding: 20px;
overflow-y: auto;
}
.order-confirm-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.order-confirm-table th {
padding: 8px;
font-size: 11px;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.order-confirm-table td {
padding: 10px 8px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.order-confirm-table .mono {
font-family: 'JetBrains Mono', monospace;
}
.order-modal-footer {
padding: 16px 20px;
background: var(--bg-card);
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn-order-test {
padding: 10px 20px;
background: var(--accent-amber);
border: none;
border-radius: 8px;
color: #000;
font-weight: 600;
cursor: pointer;
}
.btn-order-real {
padding: 10px 20px;
background: var(--accent-emerald);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn-order-test:disabled, .btn-order-real:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 주문 결과 모달 */
.result-header {
text-align: center;
padding: 20px;
border-radius: 12px;
margin-bottom: 16px;
}
.result-header.success { background: rgba(16, 185, 129, 0.1); }
.result-header.partial { background: rgba(245, 158, 11, 0.1); }
.result-emoji { font-size: 32px; display: block; margin-bottom: 8px; }
.result-title { font-size: 16px; font-weight: 600; }
.result-summary {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.result-stat {
flex: 1;
text-align: center;
padding: 12px;
background: var(--bg-card);
border-radius: 8px;
}
.stat-label { display: block; font-size: 11px; color: var(--text-muted); }
.stat-value { display: block; font-size: 16px; font-weight: 700; margin-top: 4px; }
.stat-value.success { color: var(--accent-emerald); }
.stat-value.failed { color: var(--accent-rose); }
.result-table {
width: 100%;
border-collapse: collapse;
}
.result-table th {
padding: 8px;
font-size: 11px;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.result-table td {
padding: 10px 8px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.result-table .failed-row { background: rgba(244, 63, 94, 0.1); }
.result-ok { color: var(--accent-emerald); }
.result-fail { color: var(--accent-rose); }
.result-note {
margin-top: 16px;
padding: 12px;
background: rgba(6, 182, 212, 0.1);
border-radius: 8px;
font-size: 12px;
color: var(--accent-cyan);
}
</style>
<!-- 주문 확인 모달 -->
<div class="order-modal" id="orderConfirmModal">
<div class="order-modal-content">
<div class="order-modal-header">
<h3>🏭 지오영 주문 확인</h3>
<button class="order-close" onclick="closeOrderConfirmModal()"></button>
</div>
<div class="order-modal-body">
<p style="margin-bottom:12px;color:var(--text-secondary);">
<span id="orderConfirmCount">0</span>개 품목을 지오영에 주문합니다.
</p>
<table class="order-confirm-table">
<thead><tr><th>품목명</th><th>규격</th><th>수량</th></tr></thead>
<tbody id="orderConfirmBody"></tbody>
</table>
</div>
<div class="order-modal-footer">
<button class="btn-order-test" id="btnOrderTest" onclick="executeOrder(true)">🧪 테스트</button>
<button class="btn-order-real" id="btnOrderReal" onclick="executeOrder(false)">🚀 실제 주문</button>
</div>
</div>
</div>
<!-- 주문 결과 모달 -->
<div class="order-modal" id="orderResultModal">
<div class="order-modal-content" style="max-width:600px;">
<div class="order-modal-header" style="background:linear-gradient(135deg, #059669, var(--accent-emerald));">
<h3>📋 주문 결과</h3>
<button class="order-close" onclick="closeOrderResultModal()"></button>
</div>
<div class="order-modal-body" id="orderResultContent">
</div>
<div class="order-modal-footer">
<button class="btn-order-test" onclick="closeOrderResultModal()">닫기</button>
</div>
</div>
</div>
</body>
</html>