feat: PMR OTC 구매 이력 기능
- /pmr/api/patient/<cus_code>/otc: OTC 구매 이력 API
- SALE_MAIN + SALE_SUB (PRESERIAL='V' = OTC)
- 💊 OTC 뱃지 클릭 → 모달로 구매 이력 표시
- 자주 구매하는 품목 요약
- 방문/금액 통계
This commit is contained in:
parent
41428646ab
commit
ebf2e8a016
@ -650,3 +650,134 @@ def get_patient_history(cus_code):
|
||||
except Exception as e:
|
||||
logging.error(f"환자 이전 처방 조회 오류: {e}")
|
||||
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
|
||||
|
||||
@ -150,6 +150,125 @@
|
||||
color: #92400e !important;
|
||||
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 {
|
||||
@ -443,6 +562,19 @@
|
||||
<button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button>
|
||||
</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 style="max-width:400px;margin:50px auto;background:#fff;border-radius:12px;padding:20px;">
|
||||
@ -480,6 +612,7 @@
|
||||
let historyData = [];
|
||||
let historyIndex = 0;
|
||||
let compareMode = false;
|
||||
let otcData = null;
|
||||
|
||||
// HTML 이스케이프
|
||||
function escapeHtml(text) {
|
||||
@ -644,6 +777,7 @@
|
||||
currentPatientCode = data.patient.code;
|
||||
if (currentPatientCode) {
|
||||
loadPatientHistory(currentPatientCode, prescriptionId);
|
||||
checkOtcHistory(currentPatientCode);
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
document.getElementById('detailHeader').style.display = 'none';
|
||||
@ -925,6 +1142,7 @@
|
||||
document.getElementById('compareToggle').style.display = 'none';
|
||||
document.getElementById('compareMode').checked = false;
|
||||
document.getElementById('compareLegend').style.display = 'none';
|
||||
document.getElementById('otcModal').style.display = 'none';
|
||||
document.getElementById('medicationList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="icon">👈</div>
|
||||
@ -937,6 +1155,7 @@
|
||||
historyData = [];
|
||||
historyIndex = 0;
|
||||
compareMode = false;
|
||||
otcData = null;
|
||||
}
|
||||
|
||||
// 전체 선택 토글
|
||||
|
||||
Loading…
Reference in New Issue
Block a user