feat: PMR OTC 구매 이력 기능

- /pmr/api/patient/<cus_code>/otc: OTC 구매 이력 API
- SALE_MAIN + SALE_SUB (PRESERIAL='V' = OTC)
- 💊 OTC 뱃지 클릭 → 모달로 구매 이력 표시
- 자주 구매하는 품목 요약
- 방문/금액 통계
This commit is contained in:
thug0bin 2026-03-04 23:55:54 +09:00
parent 41428646ab
commit ebf2e8a016
2 changed files with 350 additions and 0 deletions

View File

@ -650,3 +650,134 @@ def get_patient_history(cus_code):
except Exception as e: except Exception as e:
logging.error(f"환자 이전 처방 조회 오류: {e}") logging.error(f"환자 이전 처방 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# ─────────────────────────────────────────────────────────────
# API: 환자 OTC 구매 이력
# ─────────────────────────────────────────────────────────────
@pmr_bp.route('/api/patient/<cus_code>/otc', methods=['GET'])
def get_patient_otc_history(cus_code):
"""
환자 OTC (일반의약품) 구매 이력 조회
Args:
cus_code: 환자 고유코드 (CusCode = SL_CD_custom)
Query Params:
- limit: 최대 조회 건수 (기본 20, 최대 100)
"""
try:
limit = min(int(request.args.get('limit', 20)), 100)
conn = get_mssql_connection('PM_PRES')
cursor = conn.cursor()
# OTC 거래 목록 조회 (PRESERIAL = 'V' = OTC 판매)
cursor.execute("""
SELECT
m.SL_NO_order,
m.SL_DT_appl,
m.InsertTime,
m.SL_MY_sale,
m.SL_NM_custom
FROM SALE_MAIN m
WHERE m.SL_CD_custom = ?
AND m.PRESERIAL = 'V'
ORDER BY m.InsertTime DESC
""", (cus_code,))
# 먼저 거래 목록 수집
orders = []
for row in cursor.fetchall():
orders.append({
'order_no': row.SL_NO_order,
'date': row.SL_DT_appl,
'datetime': row.InsertTime.strftime('%Y-%m-%d %H:%M') if row.InsertTime else '',
'amount': int(row.SL_MY_sale or 0),
'customer_name': row.SL_NM_custom or ''
})
# 최근 limit개만
orders = orders[:limit]
if not orders:
conn.close()
return jsonify({
'success': True,
'cus_code': cus_code,
'count': 0,
'purchases': []
})
# 각 거래의 품목 조회
purchases = []
for order in orders:
cursor.execute("""
SELECT
s.DrugCode,
g.GoodsName,
s.SL_NM_item,
s.SL_TOTAL_PRICE,
mc.PRINT_TYPE
FROM SALE_SUB s
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
LEFT JOIN PM_DRUG.dbo.CD_MC mc ON s.DrugCode = mc.DRUGCODE
WHERE s.SL_NO_order = ?
ORDER BY s.DrugCode
""", (order['order_no'],))
items = []
for item_row in cursor.fetchall():
items.append({
'drug_code': item_row.DrugCode or '',
'name': item_row.GoodsName or item_row.DrugCode or '',
'quantity': int(item_row.SL_NM_item or 0),
'price': int(item_row.SL_TOTAL_PRICE or 0),
'category': item_row.PRINT_TYPE or ''
})
purchases.append({
**order,
'items': items,
'item_count': len(items)
})
conn.close()
# 통계 계산
total_amount = sum(p['amount'] for p in purchases)
total_visits = len(purchases)
# 자주 구매하는 품목 집계
item_freq = {}
for p in purchases:
for item in p['items']:
key = item['drug_code']
if key not in item_freq:
item_freq[key] = {
'name': item['name'],
'category': item['category'],
'count': 0,
'total_qty': 0
}
item_freq[key]['count'] += 1
item_freq[key]['total_qty'] += item['quantity']
# 빈도순 정렬
frequent_items = sorted(item_freq.values(), key=lambda x: x['count'], reverse=True)[:10]
return jsonify({
'success': True,
'cus_code': cus_code,
'count': len(purchases),
'summary': {
'total_visits': total_visits,
'total_amount': total_amount,
'frequent_items': frequent_items
},
'purchases': purchases
})
except Exception as e:
logging.error(f"환자 OTC 구매 이력 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@ -150,6 +150,125 @@
color: #92400e !important; color: #92400e !important;
margin-left: 5px; margin-left: 5px;
} }
.detail-header .rx-info .otc-badge {
background: #dbeafe !important;
color: #1e40af !important;
cursor: pointer;
transition: all 0.2s;
}
.detail-header .rx-info .otc-badge:hover {
background: #bfdbfe !important;
transform: scale(1.05);
}
/* OTC 모달 */
.otc-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
overflow-y: auto;
}
.otc-modal-content {
max-width: 600px;
margin: 40px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.otc-modal-header {
background: linear-gradient(135deg, #3b82f6, #60a5fa);
color: #fff;
padding: 20px 25px;
display: flex;
justify-content: space-between;
align-items: center;
}
.otc-modal-header h3 { margin: 0; font-size: 1.2rem; }
.otc-modal-close {
background: none;
border: none;
color: #fff;
font-size: 1.5rem;
cursor: pointer;
opacity: 0.8;
}
.otc-modal-close:hover { opacity: 1; }
.otc-summary {
display: flex;
gap: 20px;
padding: 15px 25px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.otc-summary-item {
text-align: center;
}
.otc-summary-item .num {
font-size: 1.5rem;
font-weight: 700;
color: #1e40af;
}
.otc-summary-item .label {
font-size: 0.75rem;
color: #64748b;
}
.otc-frequent {
padding: 15px 25px;
border-bottom: 1px solid #e2e8f0;
}
.otc-frequent h4 {
margin: 0 0 10px 0;
font-size: 0.9rem;
color: #475569;
}
.otc-frequent-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.otc-frequent-item {
background: #eff6ff;
color: #1e40af;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
}
.otc-purchases {
max-height: 300px;
overflow-y: auto;
padding: 15px 25px;
}
.otc-purchase {
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 12px;
overflow: hidden;
}
.otc-purchase-header {
background: #f1f5f9;
padding: 10px 15px;
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.otc-purchase-header .date { color: #475569; font-weight: 600; }
.otc-purchase-header .amount { color: #1e40af; font-weight: 600; }
.otc-purchase-items {
padding: 10px 15px;
}
.otc-purchase-item {
display: flex;
justify-content: space-between;
padding: 5px 0;
font-size: 0.85rem;
border-bottom: 1px solid #f1f5f9;
}
.otc-purchase-item:last-child { border-bottom: none; }
.otc-purchase-item .name { color: #1e293b; }
.otc-purchase-item .qty { color: #64748b; }
/* 약품 목록 */ /* 약품 목록 */
.medication-list { .medication-list {
@ -443,6 +562,19 @@
<button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button> <button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button>
</div> </div>
<!-- OTC 구매 이력 모달 -->
<div class="otc-modal" id="otcModal">
<div class="otc-modal-content">
<div class="otc-modal-header">
<h3>💊 OTC 구매 이력</h3>
<button class="otc-modal-close" onclick="closeOtcModal()">×</button>
</div>
<div class="otc-summary" id="otcSummary"></div>
<div class="otc-frequent" id="otcFrequent"></div>
<div class="otc-purchases" id="otcPurchases"></div>
</div>
</div>
<!-- 미리보기 모달 --> <!-- 미리보기 모달 -->
<div id="previewModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:1000;overflow-y:auto;"> <div id="previewModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:1000;overflow-y:auto;">
<div style="max-width:400px;margin:50px auto;background:#fff;border-radius:12px;padding:20px;"> <div style="max-width:400px;margin:50px auto;background:#fff;border-radius:12px;padding:20px;">
@ -480,6 +612,7 @@
let historyData = []; let historyData = [];
let historyIndex = 0; let historyIndex = 0;
let compareMode = false; let compareMode = false;
let otcData = null;
// HTML 이스케이프 // HTML 이스케이프
function escapeHtml(text) { function escapeHtml(text) {
@ -644,6 +777,7 @@
currentPatientCode = data.patient.code; currentPatientCode = data.patient.code;
if (currentPatientCode) { if (currentPatientCode) {
loadPatientHistory(currentPatientCode, prescriptionId); loadPatientHistory(currentPatientCode, prescriptionId);
checkOtcHistory(currentPatientCode);
} }
} }
} catch (err) { } catch (err) {
@ -917,6 +1051,89 @@
`; `;
} }
// OTC 구매 이력 체크
async function checkOtcHistory(cusCode) {
try {
const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`);
const data = await res.json();
if (data.success && data.count > 0) {
otcData = data;
// OTC 뱃지 추가 (질병 뱃지 앞에)
const rxInfo = document.getElementById('rxInfo');
const otcBadge = `<span class="otc-badge" onclick="showOtcModal()">💊 OTC ${data.count}건</span>`;
rxInfo.innerHTML = otcBadge + rxInfo.innerHTML;
} else {
otcData = null;
}
} catch (err) {
console.error('OTC check error:', err);
otcData = null;
}
}
// OTC 모달 표시
function showOtcModal() {
if (!otcData) return;
const modal = document.getElementById('otcModal');
const summary = document.getElementById('otcSummary');
const frequent = document.getElementById('otcFrequent');
const purchases = document.getElementById('otcPurchases');
// 요약
summary.innerHTML = `
<div class="otc-summary-item">
<div class="num">${otcData.summary.total_visits}</div>
<div class="label">방문</div>
</div>
<div class="otc-summary-item">
<div class="num">${(otcData.summary.total_amount / 10000).toFixed(1)}만</div>
<div class="label">총 구매액</div>
</div>
`;
// 자주 구매하는 품목
if (otcData.summary.frequent_items && otcData.summary.frequent_items.length > 0) {
frequent.innerHTML = `
<h4>🔥 자주 구매하는 품목</h4>
<div class="otc-frequent-list">
${otcData.summary.frequent_items.map(item =>
`<span class="otc-frequent-item">${item.name} (${item.count}회)</span>`
).join('')}
</div>
`;
frequent.style.display = 'block';
} else {
frequent.style.display = 'none';
}
// 구매 이력
purchases.innerHTML = otcData.purchases.map(p => `
<div class="otc-purchase">
<div class="otc-purchase-header">
<span class="date">📅 ${p.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3') || p.datetime}</span>
<span class="amount">${p.amount.toLocaleString()}원</span>
</div>
<div class="otc-purchase-items">
${p.items.map(item => `
<div class="otc-purchase-item">
<span class="name">${item.name}</span>
<span class="qty">${item.quantity}개 / ${item.price.toLocaleString()}원</span>
</div>
`).join('')}
</div>
</div>
`).join('');
modal.style.display = 'block';
}
// OTC 모달 닫기
function closeOtcModal() {
document.getElementById('otcModal').style.display = 'none';
}
// 상세 초기화 // 상세 초기화
function clearDetail() { function clearDetail() {
document.getElementById('detailHeader').style.display = 'none'; document.getElementById('detailHeader').style.display = 'none';
@ -925,6 +1142,7 @@
document.getElementById('compareToggle').style.display = 'none'; document.getElementById('compareToggle').style.display = 'none';
document.getElementById('compareMode').checked = false; document.getElementById('compareMode').checked = false;
document.getElementById('compareLegend').style.display = 'none'; document.getElementById('compareLegend').style.display = 'none';
document.getElementById('otcModal').style.display = 'none';
document.getElementById('medicationList').innerHTML = ` document.getElementById('medicationList').innerHTML = `
<div class="empty-state"> <div class="empty-state">
<div class="icon">👈</div> <div class="icon">👈</div>
@ -937,6 +1155,7 @@
historyData = []; historyData = [];
historyIndex = 0; historyIndex = 0;
compareMode = false; compareMode = false;
otcData = null;
} }
// 전체 선택 토글 // 전체 선택 토글