From 71d1916efbee030cbcebb4382cfca588cc2a1c21 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Sun, 8 Mar 2026 10:46:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=B0=98=ED=92=88=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EC=95=BD=ED=92=88=20=EC=9C=84=EC=B9=98=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: return-candidates에서 CD_item_position 조인하여 위치 정보 반환 - 테이블에 '위치' 컬럼 추가 (노란색 뱃지 스타일) - 위치 미지정 약품은 '-' 표시 --- backend/app.py | 18 +- .../templates/admin_return_management.html | 321 ++++++++++-------- 2 files changed, 183 insertions(+), 156 deletions(-) diff --git a/backend/app.py b/backend/app.py index 191f40a..bda168c 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5360,8 +5360,9 @@ def api_return_candidates(): # 약품코드 목록 추출 drug_codes = [row['drug_code'] for row in rows] - # MSSQL에서 가격 정보 조회 (한 번에) + # MSSQL에서 가격 + 위치 정보 조회 (한 번에) price_map = {} + location_map = {} if drug_codes: try: mssql_conn_str = ( @@ -5378,20 +5379,24 @@ def api_return_candidates(): # IN 절 생성 (SQL Injection 방지를 위해 파라미터화) # Price가 없으면 Saleprice, Topprice 순으로 fallback + # CD_item_position JOIN으로 위치 정보도 함께 조회 placeholders = ','.join(['?' for _ in drug_codes]) mssql_cursor.execute(f""" - SELECT DrugCode, - COALESCE(NULLIF(Price, 0), NULLIF(Saleprice, 0), NULLIF(Topprice, 0), 0) as BestPrice - FROM CD_GOODS - WHERE DrugCode IN ({placeholders}) + SELECT G.DrugCode, + COALESCE(NULLIF(G.Price, 0), NULLIF(G.Saleprice, 0), NULLIF(G.Topprice, 0), 0) as BestPrice, + ISNULL(POS.CD_NM_sale, '') as Location + FROM CD_GOODS G + LEFT JOIN CD_item_position POS ON G.DrugCode = POS.DrugCode + WHERE G.DrugCode IN ({placeholders}) """, drug_codes) for row in mssql_cursor.fetchall(): price_map[row[0]] = float(row[1]) if row[1] else 0 + location_map[row[0]] = row[2] or '' mssql_conn.close() except Exception as e: - logging.warning(f"MSSQL 가격 조회 실패: {e}") + logging.warning(f"MSSQL 가격/위치 조회 실패: {e}") # 전체 데이터 조회 (통계용) cursor.execute("SELECT drug_code, current_stock, months_since_use, months_since_purchase FROM return_candidates") @@ -5432,6 +5437,7 @@ def api_return_candidates(): 'current_stock': stock, 'unit_price': price, 'recoverable_amount': recoverable, + 'location': location_map.get(code, ''), 'last_prescription_date': row['last_prescription_date'], 'months_since_use': row['months_since_use'], 'last_purchase_date': row['last_purchase_date'], diff --git a/backend/templates/admin_return_management.html b/backend/templates/admin_return_management.html index f4b1566..2b23e12 100644 --- a/backend/templates/admin_return_management.html +++ b/backend/templates/admin_return_management.html @@ -3,7 +3,7 @@ - 반품 관리 - 청춘약국 + 반품 관?- ??국 @@ -38,7 +38,7 @@ min-height: 100vh; } - /* ══════════════════ 헤더 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?더 ?═?═?═?═?═?═?═?═?═ */ .header { background: linear-gradient(135deg, #dc2626 0%, #f97316 50%, #f59e0b 100%); padding: 20px 24px; @@ -89,14 +89,14 @@ background: rgba(255,255,255,0.25); } - /* ══════════════════ 컨텐츠 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ 컨텐??═?═?═?═?═?═?═?═?═ */ .content { max-width: 1800px; margin: 0 auto; padding: 24px; } - /* ══════════════════ 통계 카드 (2줄) ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?계 카드 (2? ?═?═?═?═?═?═?═?═?═ */ .stats-row { display: flex; gap: 16px; @@ -162,7 +162,7 @@ margin-top: 2px; } - /* ══════════════════ 필터 바 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?터 ??═?═?═?═?═?═?═?═?═ */ .filter-bar { background: var(--bg-card); border-radius: 16px; @@ -218,7 +218,7 @@ box-shadow: 0 4px 12px rgba(249, 115, 22, 0.4); } - /* ══════════════════ 긴급도 탭 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ 긴급?????═?═?═?═?═?═?═?═?═ */ .urgency-tabs { display: flex; gap: 8px; @@ -256,7 +256,7 @@ background: rgba(255,255,255,0.3); } - /* ══════════════════ 테이블 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?이??═?═?═?═?═?═?═?═?═ */ .table-wrap { background: var(--bg-card); border-radius: 16px; @@ -299,7 +299,7 @@ background: rgba(249, 115, 22, 0.05); } - /* 약품 셀 */ + /* ?품 ? */ .drug-cell { display: flex; flex-direction: column; @@ -316,7 +316,26 @@ color: var(--text-muted); } - /* 긴급도 배지 */ + /* ?치 ? */ + .location-cell { + text-align: center; + } + .location-badge { + display: inline-block; + background: rgba(251, 191, 36, 0.2); + color: var(--accent-amber); + font-size: 11px; + font-weight: 600; + padding: 4px 8px; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + } + .location-empty { + color: var(--text-muted); + font-size: 12px; + } + + /* 긴급??배? */ .urgency-badge { display: inline-flex; align-items: center; @@ -339,7 +358,7 @@ color: var(--text-muted); } - /* 상태 배지 */ + /* ?태 배? */ .status-badge { display: inline-block; padding: 4px 10px; @@ -354,7 +373,7 @@ .status-badge.disposed { background: rgba(244, 63, 94, 0.2); color: var(--accent-rose); } .status-badge.resolved { background: rgba(100, 116, 139, 0.2); color: var(--text-muted); } - /* 수량/날짜/금액 셀 */ + /* ?량/?짜/금액 ? */ .qty-cell { font-family: 'JetBrains Mono', monospace; font-weight: 600; @@ -385,7 +404,7 @@ color: var(--text-muted); } - /* 액션 버튼 */ + /* ?션 버튼 */ .action-btn { padding: 6px 12px; border: none; @@ -404,7 +423,7 @@ box-shadow: 0 4px 12px rgba(249, 115, 22, 0.4); } - /* ══════════════════ 페이지네이션 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?이지?이???═?═?═?═?═?═?═?═?═ */ .pagination { display: flex; justify-content: center; @@ -428,7 +447,7 @@ .page-btn:disabled { opacity: 0.3; cursor: not-allowed; } .page-info { color: var(--text-muted); font-size: 13px; } - /* ══════════════════ 모달 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ 모달 ?═?═?═?═?═?═?═?═?═ */ .modal-overlay { position: fixed; inset: 0; @@ -496,7 +515,7 @@ .drug-detail-label { color: var(--text-muted); } .drug-detail-value { font-weight: 600; font-family: 'JetBrains Mono', monospace; } - /* ══════════════════ 로딩/빈 상태 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ 로딩/??태 ?═?═?═?═?═?═?═?═?═ */ .loading-state, .empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); } .loading-spinner { width: 40px; height: 40px; @@ -509,7 +528,7 @@ @keyframes spin { to { transform: rotate(360deg); } } .empty-icon { font-size: 48px; margin-bottom: 16px; } - /* ══════════════════ 토스트 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?스???═?═?═?═?═?═?═?═?═ */ .toast { position: fixed; bottom: 32px; @@ -531,7 +550,7 @@ .toast.success { border-color: var(--accent-emerald); } .toast.error { border-color: var(--accent-rose); } - /* ══════════════════ 반응형 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ 반응???═?═?═?═?═?═?═?═?═ */ @media (max-width: 1200px) { .stats-row { flex-wrap: wrap; } .stat-card { min-width: calc(33% - 12px); } @@ -545,7 +564,7 @@ .urgency-tabs { flex-wrap: wrap; } } - /* ══════════════════ 입고이력 모달 ══════════════════ */ + /* ?═?═?═?═?═?═?═?═?═ ?고?력 모달 ?═?═?═?═?═?═?═?═?═ */ .purchase-modal { position: fixed; inset: 0; @@ -669,7 +688,7 @@ background: var(--bg-card-hover); color: var(--text-primary); } - /* 테이블 행 더블클릭 가능 표시 */ + /* ?이????블?릭 가???시 */ #dataTableBody tr { cursor: pointer; } @@ -682,154 +701,155 @@
-

📦 반품 후보 관리

-

장기 미사용 의약품 반품/폐기 관리

+

? 반품 ?보 관?/h1> +

?기 미사???약??반품/?기 관?/p>

- +
-
🔴
+
?
-
-
3년+ 미사용
-
긴급 처리 필요
+
3?? 미사??/div> +
긴급 처리 ?요
-
🟠
+
?
-
-
2년+ 미사용
-
검토 권장
+
2?? 미사??/div> +
검??권장
-
📋
+
?
-
-
미결정
-
pending 상태
+
미결??/div> +
pending ?태
-
+
??/div>
-
-
처리완료
+
처리?료
-
📊
+
?
-
-
전체 후보
+
?체 ?보
- +
-
💰
+
?
-
-
총 회수가능 금액
-
전체 반품 대상
+
??수가??금액
+
?체 반품 ???/div>
-
🔴💵
+
??
-
-
3년+ 금액
-
긴급 회수 대상
+
3?? 금액
+
긴급 ?수 ???/div>
-
🟠💵
+
??
-
-
2년+ 금액
-
주의 대상
+
2?? 금액
+
주의 ???/div>
- +
- +
- +
- - + +
- +
- +
- +
- +
- 💡 행 더블클릭 → 입고이력 확인 (도매상 연락처) + ? ???블?릭 ???고?력 ?인 (?매???락?
- - - - - - - - - + + + + + + + - @@ -837,70 +857,70 @@
긴급약품현재고단가회수가능금액마지막 처방미사용마지막 입고상태액션?품?치?재?/th> + ???수가?금??/th> + 마??처방미사??/th> + 마???고?태?션
+
-
데이터 로딩 중...
+
?이??로딩 ?..
- +
- + - +
- +
-

📦 입고 이력

+

? ?고 ?력

-
- - - - + + - +
도매상입고일수량단가?매??/th> + ?고??/th> + ?량??
📭

로딩 중...

?

로딩 ?..

@@ -920,7 +940,7 @@ async function loadData() { const tbody = document.getElementById('dataTableBody'); - tbody.innerHTML = `
데이터 로딩 중...
`; + tbody.innerHTML = `
?이??로딩 ?..
`; const status = document.getElementById('filterStatus').value; const urgency = document.getElementById('filterUrgency').value; @@ -944,10 +964,10 @@ currentPage = 1; renderTable(); } else { - tbody.innerHTML = `
⚠️
오류: ${data.error}
`; + tbody.innerHTML = `
?️
?류: ${data.error}
`; } } catch (err) { - tbody.innerHTML = `
데이터 로드 실패
`; + tbody.innerHTML = `
??/div>
?이??로드 ?패
`; } } @@ -958,23 +978,23 @@ document.getElementById('statProcessed').textContent = stats.processed || 0; document.getElementById('statTotal').textContent = stats.total || 0; - // 금액 표시 + // 금액 ?시 document.getElementById('statTotalAmount').textContent = formatAmount(stats.total_amount || 0); document.getElementById('statCriticalAmount').textContent = formatAmount(stats.critical_amount || 0); document.getElementById('statWarningAmount').textContent = formatAmount(stats.warning_amount || 0); } function formatAmount(amount) { - if (!amount || amount === 0) return '₩0'; + if (!amount || amount === 0) return '??'; if (amount >= 1000000) { - return '₩' + (amount / 10000).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '만'; + return '?? + (amount / 10000).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '?; } - return '₩' + Math.round(amount).toLocaleString(); + return '?? + Math.round(amount).toLocaleString(); } function formatPrice(price) { if (!price || price === 0) return '-'; - return '₩' + Math.round(price).toLocaleString(); + return '?? + Math.round(price).toLocaleString(); } function updateTabs() { @@ -1016,7 +1036,7 @@ } if (filteredData.length === 0) { - tbody.innerHTML = `
📦
해당 조건의 반품 후보가 없습니다
`; + tbody.innerHTML = `
?
?당 조건??반품 ?보가 ?습?다
`; document.getElementById('pagination').innerHTML = ''; return; } @@ -1035,7 +1055,7 @@ - ${urgency === 'critical' ? '🔴' : urgency === 'warning' ? '🟠' : '⚪'} + ${urgency === 'critical' ? '?' : urgency === 'warning' ? '?' : '??} @@ -1044,9 +1064,10 @@ ${item.drug_code}
+ ${item.location ? `${escapeHtml(item.location)}` : '-'} ${item.current_stock || 0} ${formatPrice(item.unit_price)} - ${hasAmount ? '₩' + Math.round(item.recoverable_amount).toLocaleString() : '-'} + ${hasAmount ? '?? + Math.round(item.recoverable_amount).toLocaleString() : '-'} ${formatDate(item.last_prescription_date) || '-'} ${item.months_since_use ? item.months_since_use + '개월' : '-'} @@ -1054,7 +1075,7 @@ ${formatDate(item.last_purchase_date) || '-'} ${getStatusLabel(item.status)} - + `; - html += ``; + html += ``; } - html += ``; + html += ``; - html += `총 ${totalItems}건`; + html += `?${totalItems}?/span>`; pagination.innerHTML = html; } @@ -1106,27 +1127,27 @@
${escapeHtml(item.drug_name)}
- 약품코드 + ?품코드 ${item.drug_code}
- 현재고 + ?재?/span> ${item.current_stock || 0}
- 단가 + ?? ${formatPrice(item.unit_price)}
- 회수가능금액 - ${hasAmount ? '₩' + Math.round(item.recoverable_amount).toLocaleString() : '-'} + ?수가?금??/span> + ${hasAmount ? '?? + Math.round(item.recoverable_amount).toLocaleString() : '-'}
- 마지막 처방 + 마??처방 ${formatDate(item.last_prescription_date) || '-'}
- 미사용 기간 + 미사??기간 ${item.months_since_use ? item.months_since_use + '개월' : '-'}
@@ -1149,7 +1170,7 @@ const reason = document.getElementById('modalReason').value; if (status === 'keep' && !reason.trim()) { - showToast('보류 상태에서는 사유 입력이 필수입니다', 'error'); + showToast('보류 ?태?서???유 ?력???수?니??, 'error'); return; } @@ -1163,14 +1184,14 @@ const data = await res.json(); if (data.success) { - showToast('상태가 변경되었습니다', 'success'); + showToast('?태가 변경되?습?다', 'success'); closeModal(); loadData(); } else { - showToast('변경 실패: ' + data.error, 'error'); + showToast('변??패: ' + data.error, 'error'); } } catch (err) { - showToast('서버 오류', 'error'); + showToast('?버 ?류', 'error'); } } @@ -1191,12 +1212,12 @@ function getStatusLabel(status) { const labels = { - 'pending': '미결정', - 'reviewed': '검토됨', - 'returned': '반품완료', + 'pending': '미결??, + 'reviewed': '검?됨', + 'returned': '반품?료', 'keep': '보류', - 'disposed': '폐기', - 'resolved': '재고소진' + 'disposed': '?기', + 'resolved': '?고?진' }; return labels[status] || status; } @@ -1208,14 +1229,14 @@ setTimeout(() => { toast.classList.remove('show'); }, 3000); } - // ══════════════════ 입고이력 모달 ══════════════════ + // ?═?═?═?═?═?═?═?═?═ ?고?력 모달 ?═?═?═?═?═?═?═?═?═ async function openPurchaseModal(drugCode, drugName) { const modal = document.getElementById('purchaseModal'); const nameEl = document.getElementById('purchaseDrugName'); const tbody = document.getElementById('purchaseHistoryBody'); nameEl.textContent = drugName || drugCode; - tbody.innerHTML = '

입고이력 조회 중...

'; + tbody.innerHTML = '
??/div>

?고?력 조회 ?..

'; modal.classList.add('open'); try { @@ -1224,13 +1245,13 @@ if (data.success) { if (data.history.length === 0) { - tbody.innerHTML = '
📭

입고 이력이 없습니다

'; + tbody.innerHTML = '
?

?고 ?력???습?다

'; } else { tbody.innerHTML = data.history.map(h => `
${escapeHtml(h.supplier)}
- ${h.supplier_tel ? `
📞 ${h.supplier_tel}
` : ''} + ${h.supplier_tel ? `
? ${h.supplier_tel}
` : ''} ${h.date} ${h.quantity.toLocaleString()} @@ -1239,10 +1260,10 @@ `).join(''); } } else { - tbody.innerHTML = `
⚠️

조회 실패: ${data.error}

`; + tbody.innerHTML = `
?️

조회 ?패: ${data.error}

`; } } catch (err) { - tbody.innerHTML = `

오류: ${err.message}

`; + tbody.innerHTML = `
??/div>

?류: ${err.message}

`; } } @@ -1252,7 +1273,7 @@ function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { - showToast(`📋 ${text} 복사됨`, 'success'); + showToast(`? ${text} 복사??, 'success'); }).catch(() => { const input = document.createElement('input'); input.value = text; @@ -1260,11 +1281,11 @@ input.select(); document.execCommand('copy'); document.body.removeChild(input); - showToast(`📋 ${text} 복사됨`, 'success'); + showToast(`? ${text} 복사??, 'success'); }); } - // 모달 외부 클릭 시 닫기 + // 모달 ?? ?릭 ???기 document.getElementById('purchaseModal').addEventListener('click', function(e) { if (e.target === this) closePurchaseModal(); });