feat(admin): 제품 사용이력 + 환자 최근처방 모달 기능

- GET /api/products/<drug_code>/usage-history
  - 제품별 처방 이력 조회 (환자명, 수량, 횟수, 일수, 총투약량)
  - 페이지네이션 + 기간 필터 지원

- GET /api/patients/<cus_code>/recent-prescriptions
  - 환자 최근 6개월 처방 내역
  - 약품별 분류(CD_MC.PRINT_TYPE) 표시 (당뇨치료, 고지혈치료 등)

- admin_products.html 모달 2개 추가
  - 제품명 클릭 → 사용이력 모달 (횟수 포함 정확한 총투약량)
  - 환자명 클릭 → 최근처방 모달 (z-index 2100으로 위에 표시)

DB 조인:
- PS_main.PreSerial = PS_sub_pharm.PreSerial
- CD_GOODS + CD_MC (PRINT_TYPE 분류)
This commit is contained in:
thug0bin 2026-03-11 16:50:48 +09:00
parent 83ecf88bd4
commit 688bdb40f2
2 changed files with 502 additions and 1 deletions

View File

@ -3872,6 +3872,238 @@ def api_drug_purchase_history(drug_code):
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 처방 사용이력 API ====================
@app.route('/api/products/<drug_code>/usage-history')
def api_product_usage_history(drug_code):
"""
제품 처방 사용이력 조회 API
- PS_main + PS_sub_pharm JOIN
- 페이지네이션, 기간 필터 지원
- 환자명 마스킹 처리
"""
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 20))
months = int(request.args.get('months', 12))
offset = (page - 1) * per_page
try:
pres_session = db_manager.get_session('PM_PRES')
# 기간 계산 (N개월 전부터)
from datetime import datetime, timedelta
start_date = (datetime.now() - timedelta(days=months * 30)).strftime('%Y%m%d')
# 총 건수 조회 (COUNT)
count_result = pres_session.execute(text("""
SELECT COUNT(*) as total_count
FROM PS_sub_pharm sp
JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
WHERE sp.DrugCode = :drug_code
AND pm.Indate >= :start_date
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
"""), {'drug_code': drug_code, 'start_date': start_date})
total_count = count_result.fetchone()[0]
# 데이터 조회 (페이지네이션)
data_result = pres_session.execute(text("""
SELECT
pm.Paname as patient_name,
pm.CusCode as cus_code,
pm.Indate as rx_date,
sp.QUAN as quantity,
sp.QUAN_TIME as times,
sp.Days as days
FROM PS_sub_pharm sp
JOIN PS_main pm ON pm.PreSerial = sp.PreSerial
WHERE sp.DrugCode = :drug_code
AND pm.Indate >= :start_date
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
ORDER BY pm.Indate DESC
OFFSET :offset ROWS
FETCH NEXT :per_page ROWS ONLY
"""), {
'drug_code': drug_code,
'start_date': start_date,
'offset': offset,
'per_page': per_page
})
items = []
for row in data_result.fetchall():
patient_name = row.patient_name or ''
cus_code = row.cus_code or ''
# 날짜 포맷팅 (YYYYMMDD -> YYYY-MM-DD)
rx_date = row.rx_date or ''
if len(rx_date) == 8:
rx_date = f"{rx_date[:4]}-{rx_date[4:6]}-{rx_date[6:8]}"
quantity = int(row.quantity) if row.quantity else 1
times = int(row.times) if row.times else 1 # 횟수 (QUAN_TIME)
days = int(row.days) if row.days else 1
total_dose = quantity * times * days # 수량 × 횟수 × 일수
items.append({
'patient_name': patient_name,
'cus_code': cus_code,
'rx_date': rx_date,
'quantity': quantity,
'times': times,
'days': days,
'total_dose': total_dose
})
# 약품명 조회
drug_session = db_manager.get_session('PM_DRUG')
name_result = drug_session.execute(text("""
SELECT GoodsName FROM CD_GOODS WHERE DrugCode = :drug_code
"""), {'drug_code': drug_code})
name_row = name_result.fetchone()
product_name = name_row[0] if name_row else drug_code
# 총 페이지 수 계산
total_pages = (total_count + per_page - 1) // per_page
return jsonify({
'success': True,
'product_name': product_name,
'pagination': {
'page': page,
'per_page': per_page,
'total_count': total_count,
'total_pages': total_pages
},
'items': items
})
except Exception as e:
logging.error(f"사용이력 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/patients/<cus_code>/recent-prescriptions')
def api_patient_recent_prescriptions(cus_code):
"""
환자 최근 처방 내역 조회 API
- 해당 환자가 최근에 어떤 약을 처방받았는지 확인
- DB 부담 최소화: 최근 6개월, 최대 30
"""
try:
pres_session = db_manager.get_session('PM_PRES')
drug_session = db_manager.get_session('PM_DRUG')
# 최근 6개월
from datetime import datetime, timedelta
start_date = (datetime.now() - timedelta(days=180)).strftime('%Y%m%d')
# 환자의 최근 처방전 조회 (최대 10건)
rx_result = pres_session.execute(text("""
SELECT TOP 10
pm.PreSerial,
pm.Paname as patient_name,
pm.Indate as rx_date,
pm.Drname as doctor_name,
pm.OrderName as hospital_name
FROM PS_main pm
WHERE pm.CusCode = :cus_code
AND pm.Indate >= :start_date
ORDER BY pm.Indate DESC
"""), {'cus_code': cus_code, 'start_date': start_date})
prescriptions = []
for rx in rx_result.fetchall():
# 날짜 포맷팅
rx_date = rx.rx_date or ''
if len(rx_date) == 8:
rx_date = f"{rx_date[:4]}-{rx_date[4:6]}-{rx_date[6:8]}"
# 해당 처방의 약품 목록 조회
items_result = pres_session.execute(text("""
SELECT
sp.DrugCode,
sp.QUAN as quantity,
sp.QUAN_TIME as times,
sp.Days as days
FROM PS_sub_pharm sp
WHERE sp.PreSerial = :pre_serial
AND (sp.PS_Type IS NULL OR sp.PS_Type != '9')
"""), {'pre_serial': rx.PreSerial})
# 먼저 모든 약품 데이터를 리스트로 가져오기
raw_items = items_result.fetchall()
drug_codes = [item.DrugCode for item in raw_items]
# 약품명 + 성분명 + 분류(PRINT_TYPE) 일괄 조회
drug_info_map = {}
if drug_codes:
placeholders = ','.join([f"'{dc}'" for dc in drug_codes])
name_result = drug_session.execute(text(f"""
SELECT g.DrugCode, g.GoodsName, s.SUNG_HNM, m.PRINT_TYPE
FROM CD_GOODS g
LEFT JOIN CD_SUNG s ON g.SUNG_CODE = s.SUNG_CODE
LEFT JOIN CD_MC m ON g.DrugCode = m.DRUGCODE
WHERE g.DrugCode IN ({placeholders})
"""))
for row in name_result.fetchall():
drug_info_map[row[0]] = {
'name': row[1],
'ingredient': row[2] or '',
'category': row[3] or '' # 분류 (알러지질환약 등)
}
items = []
for item in raw_items:
info = drug_info_map.get(item.DrugCode, {})
drug_name = info.get('name', item.DrugCode)
ingredient = info.get('ingredient', '')
category = info.get('category', '') # 분류 (알러지질환약 등)
quantity = int(item.quantity) if item.quantity else 1
times = int(item.times) if item.times else 1
days = int(item.days) if item.days else 1
items.append({
'drug_code': item.DrugCode,
'drug_name': drug_name,
'category': category, # 분류 추가
'quantity': quantity,
'times': times,
'days': days,
'total_dose': quantity * times * days
})
prescriptions.append({
'pre_serial': rx.PreSerial,
'rx_date': rx_date,
'doctor_name': rx.doctor_name or '',
'hospital_name': rx.hospital_name or '',
'items': items
})
# 환자명
patient_name = prescriptions[0]['items'][0]['drug_name'] if prescriptions else ''
if prescriptions and pres_session.execute(text("""
SELECT TOP 1 Paname FROM PS_main WHERE CusCode = :cus_code
"""), {'cus_code': cus_code}).fetchone():
patient_name = pres_session.execute(text("""
SELECT TOP 1 Paname FROM PS_main WHERE CusCode = :cus_code
"""), {'cus_code': cus_code}).fetchone()[0]
return jsonify({
'success': True,
'cus_code': cus_code,
'patient_name': patient_name,
'prescription_count': len(prescriptions),
'prescriptions': prescriptions
})
except Exception as e:
logging.error(f"환자 처방 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 위치 정보 API ====================
@app.route('/api/locations')

View File

@ -712,6 +712,57 @@
.purchase-modal-btn:hover {
background: #e2e8f0;
}
/* ── 제품명 링크 스타일 ── */
.product-name-link {
cursor: pointer;
transition: color 0.15s;
}
.product-name-link:hover {
color: #8b5cf6;
text-decoration: underline;
}
/* ── 환자명 링크 스타일 ── */
.patient-name-link {
cursor: pointer;
color: #1e293b;
transition: all 0.15s;
padding: 2px 6px;
border-radius: 4px;
}
.patient-name-link:hover {
color: #8b5cf6;
background: #f3e8ff;
text-decoration: underline;
}
/* ── 페이지네이션 버튼 ── */
.pagination-btn {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
.pagination-btn:hover:not(:disabled) {
background: #f1f5f9;
border-color: #cbd5e1;
}
.pagination-btn.active {
background: #10b981;
color: #fff;
border-color: #10b981;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
tbody tr {
cursor: pointer;
}
@ -1151,6 +1202,53 @@
</div>
</div>
<!-- 사용이력 모달 -->
<!-- 환자 최근 처방 모달 (z-index 더 높게 - 제품 모달 위에 표시) -->
<div class="purchase-modal" id="patientPrescriptionsModal" style="z-index: 2100;" onclick="if(event.target===this)closePatientPrescriptionsModal()">
<div class="purchase-modal-content" style="max-width: 850px;">
<div class="purchase-modal-header" style="background: linear-gradient(135deg, #8b5cf6, #7c3aed);">
<h3>📋 환자 최근 처방</h3>
<div class="drug-name" id="patientPrescriptionsName">-</div>
</div>
<div class="purchase-modal-body" id="patientPrescriptionsBody" style="max-height: 500px; overflow-y: auto;">
<div class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></div>
</div>
<div class="purchase-modal-footer">
<button class="purchase-modal-btn" onclick="closePatientPrescriptionsModal()">닫기</button>
</div>
</div>
</div>
<div class="purchase-modal" id="usageHistoryModal" onclick="if(event.target===this)closeUsageHistoryModal()">
<div class="purchase-modal-content" style="max-width: 700px;">
<div class="purchase-modal-header" style="background: linear-gradient(135deg, #10b981, #059669);">
<h3>💊 처방 사용이력</h3>
<div class="drug-name" id="usageHistoryDrugName">-</div>
</div>
<div class="purchase-modal-body">
<table class="purchase-history-table">
<thead>
<tr>
<th>환자명</th>
<th>처방일</th>
<th>수량</th>
<th>횟수</th>
<th>일수</th>
<th>총투약량</th>
</tr>
</thead>
<tbody id="usageHistoryBody">
<tr><td colspan="6" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
</tbody>
</table>
</div>
<div class="purchase-modal-footer" style="flex-direction: column; gap: 12px;">
<div id="usageHistoryPagination" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center;"></div>
<button class="purchase-modal-btn" onclick="closeUsageHistoryModal()">닫기</button>
</div>
</div>
</div>
<script>
let productsData = [];
let selectedItem = null;
@ -1236,7 +1334,7 @@
</td>
<td>
<div class="product-name">
${escapeHtml(item.product_name)}
<span class="product-name-link" onclick="event.stopPropagation();openUsageHistoryModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')" title="클릭하여 사용이력 보기">${escapeHtml(item.product_name)}</span>
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="event.stopPropagation();printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
${categoryBadge}
</div>
@ -2155,6 +2253,177 @@
function closePurchaseModal() {
document.getElementById('purchaseModal').classList.remove('show');
}
// ══════════════════════════════════════════════════════════════════
// 사용이력 모달
// ══════════════════════════════════════════════════════════════════
let currentUsageDrugCode = '';
let currentUsageDrugName = '';
let currentUsagePage = 1;
async function openUsageHistoryModal(drugCode, drugName) {
currentUsageDrugCode = drugCode;
currentUsageDrugName = drugName;
currentUsagePage = 1;
const modal = document.getElementById('usageHistoryModal');
const nameEl = document.getElementById('usageHistoryDrugName');
nameEl.textContent = drugName || drugCode;
modal.classList.add('show');
await loadUsageHistoryPage(1);
}
async function loadUsageHistoryPage(page) {
currentUsagePage = page;
const tbody = document.getElementById('usageHistoryBody');
const pagination = document.getElementById('usageHistoryPagination');
tbody.innerHTML = '<tr><td colspan="5" class="purchase-empty"><div class="icon"></div><p>사용이력 조회 중...</p></td></tr>';
pagination.innerHTML = '';
try {
const res = await fetch(`/api/products/${currentUsageDrugCode}/usage-history?page=${page}&per_page=20&months=12`);
const data = await res.json();
if (data.success) {
if (data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="purchase-empty"><div class="icon">📭</div><p>최근 12개월간 사용이력이 없습니다</p></td></tr>';
} else {
tbody.innerHTML = data.items.map(item => `
<tr>
<td>
<span class="patient-name-link" onclick="event.stopPropagation();openPatientPrescriptionsModal('${item.cus_code}', '${escapeHtml(item.patient_name).replace(/'/g, "\\'")}')" title="클릭하여 최근 처방 보기">${escapeHtml(item.patient_name)}</span>
</td>
<td class="purchase-date">${item.rx_date}</td>
<td style="text-align: center;">${item.quantity}</td>
<td style="text-align: center;">${item.times}</td>
<td style="text-align: center;">${item.days}</td>
<td style="text-align: center; font-weight: 600; color: #10b981;">${item.total_dose}</td>
</tr>
`).join('');
}
// 페이지네이션 렌더링
renderUsageHistoryPagination(data.pagination);
} else {
tbody.innerHTML = `<tr><td colspan="6" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="6" class="purchase-empty"><div class="icon"></div><p>오류: ${err.message}</p></td></tr>`;
}
}
function renderUsageHistoryPagination(pg) {
const pagination = document.getElementById('usageHistoryPagination');
if (pg.total_pages <= 1) {
pagination.innerHTML = `<span style="color: #64748b; font-size: 13px;">총 ${pg.total_count}건</span>`;
return;
}
let html = '';
// 이전 버튼
html += `<button class="pagination-btn" ${pg.page <= 1 ? 'disabled' : ''} onclick="loadUsageHistoryPage(${pg.page - 1})">◀ 이전</button>`;
// 페이지 번호들
const maxPages = 5;
let startPage = Math.max(1, pg.page - Math.floor(maxPages / 2));
let endPage = Math.min(pg.total_pages, startPage + maxPages - 1);
if (endPage - startPage < maxPages - 1) {
startPage = Math.max(1, endPage - maxPages + 1);
}
if (startPage > 1) {
html += `<button class="pagination-btn" onclick="loadUsageHistoryPage(1)">1</button>`;
if (startPage > 2) html += `<span style="color: #94a3b8;">...</span>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `<button class="pagination-btn ${i === pg.page ? 'active' : ''}" onclick="loadUsageHistoryPage(${i})">${i}</button>`;
}
if (endPage < pg.total_pages) {
if (endPage < pg.total_pages - 1) html += `<span style="color: #94a3b8;">...</span>`;
html += `<button class="pagination-btn" onclick="loadUsageHistoryPage(${pg.total_pages})">${pg.total_pages}</button>`;
}
// 다음 버튼
html += `<button class="pagination-btn" ${pg.page >= pg.total_pages ? 'disabled' : ''} onclick="loadUsageHistoryPage(${pg.page + 1})">다음 ▶</button>`;
// 총 건수
html += `<span style="color: #64748b; font-size: 13px; margin-left: 12px;">총 ${pg.total_count}건</span>`;
pagination.innerHTML = html;
}
function closeUsageHistoryModal() {
document.getElementById('usageHistoryModal').classList.remove('show');
}
// 환자 최근 처방 모달
async function openPatientPrescriptionsModal(cusCode, patientName) {
const modal = document.getElementById('patientPrescriptionsModal');
const nameEl = document.getElementById('patientPrescriptionsName');
const bodyEl = document.getElementById('patientPrescriptionsBody');
nameEl.textContent = patientName || cusCode;
bodyEl.innerHTML = '<div class="purchase-empty"><div class="icon"></div><p>처방 내역 조회 중...</p></div>';
modal.classList.add('show');
try {
const response = await fetch(`/api/patients/${cusCode}/recent-prescriptions`);
const data = await response.json();
if (data.success && data.prescriptions.length > 0) {
let html = '';
data.prescriptions.forEach(rx => {
html += `
<div class="rx-card" style="margin-bottom: 16px; padding: 16px; background: #f8fafc; border-radius: 12px; border-left: 4px solid #8b5cf6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<span style="font-weight: 600; color: #1e293b; font-size: 15px;">📅 ${rx.rx_date}</span>
<span style="color: #64748b; font-size: 13px; margin-left: 12px;">${escapeHtml(rx.hospital_name || '')}</span>
</div>
<span style="color: #8b5cf6; font-size: 13px; font-weight: 500;">${escapeHtml(rx.doctor_name || '')}</span>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: #e2e8f0;">
<th style="padding: 8px; text-align: left; border-radius: 6px 0 0 6px;">약품명</th>
<th style="padding: 8px; text-align: center; width: 80px;">용법</th>
<th style="padding: 8px; text-align: center; width: 60px; border-radius: 0 6px 6px 0;">총량</th>
</tr>
</thead>
<tbody>
${rx.items.map(item => `
<tr style="border-bottom: 1px solid #e2e8f0;">
<td style="padding: 8px;">
<div style="color: #334155; font-weight: 500;">${escapeHtml(item.drug_name)}</div>
${item.category ? `<div style="font-size: 11px; color: #8b5cf6; margin-top: 2px;">${escapeHtml(item.category)}</div>` : ''}
</td>
<td style="padding: 8px; text-align: center; color: #475569; font-size: 12px;">${item.quantity}×${item.times}×${item.days}</td>
<td style="padding: 8px; text-align: center; font-weight: 600; color: #8b5cf6;">${item.total_dose}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
});
bodyEl.innerHTML = html;
} else {
bodyEl.innerHTML = '<div class="purchase-empty"><div class="icon">📭</div><p>최근 6개월간 처방 내역이 없습니다</p></div>';
}
} catch (err) {
bodyEl.innerHTML = `<div class="purchase-empty"><div class="icon"></div><p>오류: ${err.message}</p></div>`;
}
}
function closePatientPrescriptionsModal() {
document.getElementById('patientPrescriptionsModal').classList.remove('show');
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {