fix: 처방목록 조회 기준을 발행일에서 조제일로 변경

문제:
- PMR 처방 목록이 PassDay(처방전 발행일) 기준으로 조회되어
  발행일과 조제일이 다른 처방(예: 3일 전 발행, 오늘 조제)이
  오늘 목록에 표시되지 않는 버그

해결:
- PS_MAIN 테이블 조회 시 PassDay 대신 Indate(조제일) 기준으로 변경
- issue_date(발행일), dispense_date(조제일) 필드 추가로 구분 명확화

추가 변경:
- WebSocket 연결/해제 시 토스트 알림 추가
- WebSocket 프록시 트러블슈팅 문서 추가 (NPM 설정 가이드)
This commit is contained in:
thug0bin
2026-03-05 10:56:24 +09:00
parent f3b6496c91
commit cb90d4a7a6
3 changed files with 771 additions and 12 deletions

View File

@@ -70,6 +70,7 @@ def get_prescriptions_by_date():
PreSerial,
Day_Serial,
PassDay,
Indate,
Paname,
PaNum,
CusCode,
@@ -81,7 +82,7 @@ def get_prescriptions_by_date():
PRICE_P,
PRICE_C
FROM PS_MAIN
WHERE PassDay = ?
WHERE Indate = ?
ORDER BY Day_Serial ASC
""", (date_yyyymmdd,))
@@ -112,7 +113,9 @@ def get_prescriptions_by_date():
prescriptions.append({
'prescription_id': row.PreSerial,
'order_number': row.Day_Serial,
'date': row.PassDay,
'date': row.Indate, # 조제일 기준
'issue_date': row.PassDay, # 처방전 발행일
'dispense_date': row.Indate, # 조제일
'patient_name': row.Paname,
'patient_id': row.PaNum[:6] + '******' if row.PaNum and len(row.PaNum) > 6 else row.PaNum,
'patient_code': row.CusCode,
@@ -221,8 +224,6 @@ def get_prescription_detail(prescription_id):
'sung_code': row.SUNG_CODE or ''
})
conn.close()
# 나이/성별 계산
panum = rx_row.PaNum or ''
age = None
@@ -255,6 +256,23 @@ def get_prescription_detail(prescription_id):
'name_2': disease_name_2
}
# 환자 특이사항(CUSETC) 조회 - CD_PERSON 테이블
cusetc = ''
cus_code = rx_row.CusCode
if cus_code:
try:
# PM_BASE.dbo.CD_PERSON에서 조회
cursor.execute("""
SELECT CUSETC FROM PM_BASE.dbo.CD_PERSON WHERE CUSCODE = ?
""", (cus_code,))
person_row = cursor.fetchone()
if person_row and person_row.CUSETC:
cusetc = person_row.CUSETC
except Exception as e:
logging.warning(f"특이사항 조회 실패: {e}")
conn.close()
return jsonify({
'success': True,
'prescription': {
@@ -271,8 +289,10 @@ def get_prescription_detail(prescription_id):
'patient': {
'name': rx_row.Paname,
'code': rx_row.CusCode,
'cus_code': rx_row.CusCode, # 호환성
'age': age,
'gender': gender
'gender': gender,
'cusetc': cusetc # 특이사항
},
'disease_info': disease_info,
'medications': medications,
@@ -822,6 +842,102 @@ def get_patient_otc_history(cus_code):
# PAAI (Pharmacist Assistant AI) API
# ─────────────────────────────────────────────────────────────
@pmr_bp.route('/api/paai/result/<pre_serial>', methods=['GET'])
def paai_get_result(pre_serial):
"""
PAAI 분석 결과 조회 (캐시)
Response:
- 결과 있음: {exists: true, status: "success", result: {...}, log_id: 123}
- 생성 중: {exists: true, status: "generating", log_id: 123}
- 없음: {exists: false}
"""
import sqlite3
import json
from pathlib import Path
try:
db_path = Path(__file__).parent / 'db' / 'paai_logs.db'
if not db_path.exists():
return jsonify({'exists': False})
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 해당 처방의 최신 성공 로그 조회
cursor.execute('''
SELECT id, status, created_at, patient_name,
disease_name_1, disease_name_2,
current_med_count, kims_interaction_count, kims_has_severe,
kims_response_time_ms, ai_response_time_ms,
ai_response
FROM paai_logs
WHERE pre_serial = ?
ORDER BY id DESC
LIMIT 1
''', (pre_serial,))
row = cursor.fetchone()
conn.close()
if not row:
return jsonify({'exists': False})
status = row['status']
# 생성 중 상태
if status in ('pending', 'kims_done', 'generating'):
return jsonify({
'exists': True,
'status': 'generating',
'log_id': row['id']
})
# 에러 상태
if status == 'error':
return jsonify({
'exists': True,
'status': 'error',
'log_id': row['id']
})
# 성공 상태 - 전체 결과 반환
ai_response = {}
if row['ai_response']:
try:
ai_response = json.loads(row['ai_response'])
except:
pass
return jsonify({
'exists': True,
'status': 'success',
'log_id': row['id'],
'result': {
'created_at': row['created_at'],
'patient_name': row['patient_name'],
'disease_name_1': row['disease_name_1'],
'disease_name_2': row['disease_name_2'],
'medication_count': row['current_med_count'],
'kims_summary': {
'interaction_count': row['kims_interaction_count'],
'has_severe': bool(row['kims_has_severe'])
},
'timing': {
'kims_ms': row['kims_response_time_ms'],
'ai_ms': row['ai_response_time_ms'],
'total_ms': (row['kims_response_time_ms'] or 0) + (row['ai_response_time_ms'] or 0)
},
'analysis': ai_response
}
})
except Exception as e:
logging.error(f"PAAI 결과 조회 오류: {e}")
return jsonify({'exists': False, 'error': str(e)})
@pmr_bp.route('/api/paai/analyze', methods=['POST'])
def paai_analyze():
"""

View File

@@ -161,6 +161,51 @@
transform: scale(1.05);
}
/* 환자 정보 행 */
.detail-header .patient-row {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
/* 특이사항 인라인 (환자명 옆) */
.detail-header .cusetc-inline {
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
line-height: 1.4;
max-width: 400px;
padding: 6px 12px;
border-radius: 8px;
margin-left: auto;
}
.detail-header .cusetc-inline.has-note {
background: linear-gradient(135deg, #fae8ff, #f5d0fe);
color: #86198f;
border: 1px solid #e879f9;
}
.detail-header .cusetc-inline.no-note {
background: #f9fafb;
color: #9ca3af;
border: 1px dashed #d1d5db;
font-size: 0.85rem;
}
.detail-header .cusetc-inline:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.detail-header .cusetc-inline .cusetc-label {
font-weight: 600;
margin-right: 6px;
}
.detail-header .cusetc-inline .cusetc-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* PAAI 버튼 */
.detail-header .rx-info .paai-badge {
background: linear-gradient(135deg, #10b981, #059669) !important;
@@ -412,6 +457,115 @@
vertical-align: middle;
}
/* 특이사항 모달 */
.cusetc-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
align-items: center;
justify-content: center;
}
.cusetc-modal-content {
background: #fff;
border-radius: 16px;
width: 90%;
max-width: 500px;
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
overflow: hidden;
}
.cusetc-modal-header {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #fff;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.cusetc-modal-header h3 {
font-size: 1.2rem;
margin: 0;
}
.cusetc-modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 1.3rem;
cursor: pointer;
transition: background 0.2s;
}
.cusetc-modal-close:hover { background: rgba(255,255,255,0.3); }
.cusetc-modal-body {
padding: 24px;
}
.cusetc-patient-info {
background: #f9fafb;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.95rem;
color: #374151;
}
.cusetc-modal-body textarea {
width: 100%;
min-height: 120px;
padding: 14px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
}
.cusetc-modal-body textarea:focus {
outline: none;
border-color: #f59e0b;
}
.cusetc-hint {
font-size: 0.85rem;
color: #6b7280;
margin-top: 10px;
}
.cusetc-modal-footer {
padding: 16px 24px;
background: #f9fafb;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cusetc-btn-cancel {
padding: 10px 20px;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
}
.cusetc-btn-cancel:hover {
background: #f3f4f6;
}
.cusetc-btn-save {
padding: 10px 24px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cusetc-btn-save:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
/* OTC 모달 */
.otc-modal {
display: none;
@@ -820,8 +974,11 @@
<!-- 오른쪽: 처방 상세 -->
<div class="prescription-detail">
<div class="detail-header" id="detailHeader" style="display:none;">
<div class="patient-name" id="detailName">-</div>
<div class="patient-info" id="detailInfo">-</div>
<div class="patient-row">
<div class="patient-name" id="detailName">-</div>
<div class="patient-info" id="detailInfo">-</div>
<div class="cusetc-inline" id="cusetcInline" onclick="openCusetcModal()"></div>
</div>
<div class="rx-info" id="rxInfo"></div>
</div>
<div class="medication-list" id="medicationList">
@@ -871,6 +1028,25 @@
</div>
</div>
<!-- 특이사항 모달 -->
<div class="cusetc-modal" id="cusetcModal">
<div class="cusetc-modal-content">
<div class="cusetc-modal-header">
<h3>📝 환자 특이사항</h3>
<button class="cusetc-modal-close" onclick="closeCusetcModal()">×</button>
</div>
<div class="cusetc-modal-body">
<div class="cusetc-patient-info" id="cusetcPatientInfo"></div>
<textarea id="cusetcTextarea" placeholder="특이사항을 입력하세요... (예: 약 삼키기 어려움, 당뇨 주의, 수유 중 등)"></textarea>
<div class="cusetc-hint">💡 Tip: 복약지도 시 참고할 정보를 기록하세요</div>
</div>
<div class="cusetc-modal-footer">
<button class="cusetc-btn-cancel" onclick="closeCusetcModal()">취소</button>
<button class="cusetc-btn-save" onclick="saveCusetc()">💾 저장</button>
</div>
</div>
</div>
<!-- OTC 구매 이력 모달 -->
<div class="otc-modal" id="otcModal">
<div class="otc-modal-content">
@@ -1001,6 +1177,44 @@
}
}
// 환자 목록 새로고침 (현재 선택 유지)
async function refreshPatientList() {
const container = document.getElementById('patientItems');
const date = document.getElementById('dateSelect').value;
const selectedId = currentPrescriptionId; // 현재 선택된 환자 ID 저장
try {
const res = await fetch(`/pmr/api/prescriptions?date=${date}`);
const data = await res.json();
if (data.success && data.prescriptions.length > 0) {
container.innerHTML = data.prescriptions.map(p => `
<div class="patient-card ${p.prescription_id === selectedId ? 'active' : ''}"
onclick="selectPatient('${p.prescription_id}', this)"
data-id="${p.prescription_id}">
<div class="top">
<span class="name">${p.patient_name || '이름없음'}</span>
<span class="order">${p.order_number || '-'}</span>
</div>
<div class="info">
${p.age ? p.age + '세' : ''} ${p.gender || ''}
${p.time ? '• ' + p.time : ''}
</div>
<div class="hospital">${p.hospital || ''} ${p.doctor ? '(' + p.doctor + ')' : ''}</div>
</div>
`).join('');
document.getElementById('patientCount').textContent = data.count + '명';
// 통계도 갱신
loadStats(date);
console.log('[PMR] 환자 목록 갱신됨:', data.count + '명');
}
} catch (err) {
console.error('목록 갱신 오류:', err);
}
}
// 환자 선택
async function selectPatient(prescriptionId, element) {
// UI 활성화
@@ -1019,13 +1233,14 @@
// PAAI용 처방 데이터 저장
currentPrescriptionData = {
pre_serial: prescriptionId,
cus_code: data.patient.cus_code,
cus_code: data.patient.cus_code || data.patient.code,
name: data.patient.name,
st1: data.disease_info?.code_1 || '',
st1_name: data.disease_info?.name_1 || '',
st2: data.disease_info?.code_2 || '',
st2_name: data.disease_info?.name_2 || '',
medications: data.medications || []
medications: data.medications || [],
cusetc: data.patient.cusetc || '' // 특이사항
};
// 헤더 업데이트
@@ -1046,6 +1261,19 @@
}
}
// 특이사항 (환자명 옆 인라인 표시)
const cusetc = data.patient.cusetc || '';
const cusetcInline = document.getElementById('cusetcInline');
if (cusetc) {
cusetcInline.className = 'cusetc-inline has-note';
cusetcInline.innerHTML = `<span class="cusetc-label">📝</span><span class="cusetc-text">${escapeHtml(cusetc)}</span>`;
cusetcInline.title = cusetc;
} else {
cusetcInline.className = 'cusetc-inline no-note';
cusetcInline.innerHTML = '+ 특이사항 추가';
cusetcInline.title = '';
}
document.getElementById('rxInfo').innerHTML = `
<span>🏥 ${data.prescription.hospital || '-'}</span>
<span>👨‍⚕️ ${data.prescription.doctor || '-'}</span>
@@ -1375,6 +1603,8 @@
// OTC 구매 이력 체크
async function checkOtcHistory(cusCode) {
const preSerial = currentPrescriptionData?.pre_serial;
try {
const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`);
const data = await res.json();
@@ -1389,12 +1619,17 @@
otcData = null;
}
// PAAI 버튼 추가 (항상 표시)
// 서버에서 기존 PAAI 결과 확인 (트리거가 미리 생성한 결과)
await checkPaaiStatus(preSerial);
// PAAI 버튼 추가 (캐시/진행중/기본 상태에 따라)
addPaaiButton();
} catch (err) {
console.error('OTC check error:', err);
otcData = null;
// OTC 오류여도 PAAI 버튼은 추가
// OTC 오류여도 PAAI 상태 확인 및 버튼 추가
await checkPaaiStatus(preSerial);
addPaaiButton();
}
}
@@ -1468,6 +1703,88 @@
document.getElementById('otcModal').style.display = 'none';
}
// ─────────────────────────────────────────────────────────────
// 특이사항 모달 함수들
// ─────────────────────────────────────────────────────────────
function openCusetcModal() {
if (!currentPrescriptionData) return;
const modal = document.getElementById('cusetcModal');
const patientInfo = document.getElementById('cusetcPatientInfo');
const textarea = document.getElementById('cusetcTextarea');
// 환자 정보 표시
patientInfo.innerHTML = `
<strong>${currentPrescriptionData.name || '환자'}</strong>
<span style="margin-left: 10px; color: #6b7280;">고객코드: ${currentPrescriptionData.cus_code || '-'}</span>
`;
// 기존 특이사항 로드
textarea.value = currentPrescriptionData.cusetc || '';
// 모달 표시
modal.style.display = 'flex';
textarea.focus();
}
function closeCusetcModal() {
document.getElementById('cusetcModal').style.display = 'none';
}
async function saveCusetc() {
if (!currentPrescriptionData || !currentPrescriptionData.cus_code) {
alert('❌ 환자 정보가 없습니다.');
return;
}
const textarea = document.getElementById('cusetcTextarea');
const newCusetc = textarea.value.trim();
const cusCode = currentPrescriptionData.cus_code;
try {
const res = await fetch(`/api/members/${cusCode}/cusetc`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cusetc: newCusetc })
});
const data = await res.json();
if (data.success) {
// 로컬 데이터 업데이트
currentPrescriptionData.cusetc = newCusetc;
// 뱃지 업데이트
updateCusetcBadge(newCusetc);
closeCusetcModal();
// 토스트 알림
showPaaiToast(currentPrescriptionData.name, '특이사항이 저장되었습니다.', 'completed');
} else {
alert('❌ ' + (data.error || '저장 실패'));
}
} catch (err) {
alert('❌ 오류: ' + err.message);
}
}
function updateCusetcBadge(cusetc) {
const cusetcInline = document.getElementById('cusetcInline');
if (!cusetcInline) return;
if (cusetc) {
cusetcInline.className = 'cusetc-inline has-note';
cusetcInline.innerHTML = `<span class="cusetc-label">📝</span><span class="cusetc-text">${escapeHtml(cusetc)}</span>`;
cusetcInline.title = cusetc;
} else {
cusetcInline.className = 'cusetc-inline no-note';
cusetcInline.innerHTML = '+ 특이사항 추가';
cusetcInline.title = '';
}
}
// ─────────────────────────────────────────────────────────────
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
// ─────────────────────────────────────────────────────────────
@@ -1476,6 +1793,43 @@
const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } }
const paaiPendingRequests = new Set(); // 진행 중인 요청
// 서버에서 기존 PAAI 결과 조회 (트리거 모듈이 미리 생성한 결과)
async function checkPaaiStatus(preSerial) {
if (!preSerial) return;
// 이미 캐시에 있으면 스킵
if (paaiResultCache[preSerial]) return;
try {
const res = await fetch(`/pmr/api/paai/result/${preSerial}`);
const data = await res.json();
if (data.exists) {
if (data.status === 'success' && data.result) {
// 성공한 결과 캐시에 저장
const patientName = data.result.patient_name || '환자';
paaiResultCache[preSerial] = {
result: {
success: true,
log_id: data.log_id,
analysis: data.result.analysis,
kims_summary: data.result.kims_summary,
timing: data.result.timing
},
patientName: patientName
};
console.log(`[PAAI] 기존 결과 로드: ${preSerial} (${patientName})`);
} else if (data.status === 'generating') {
// 생성 중인 요청으로 표시
paaiPendingRequests.add(preSerial);
console.log(`[PAAI] 생성 중: ${preSerial}`);
}
}
} catch (err) {
console.error('PAAI status check error:', err);
}
}
function addPaaiButton() {
const rxInfo = document.getElementById('rxInfo');
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
@@ -1967,7 +2321,12 @@
// ═══════════════════════════════════════════════════════════════════════════
(function() {
const TRIGGER_WS_URL = 'ws://localhost:8765';
// WebSocket 연결 URL 결정
// - HTTPS(프록시) 접속 → wss:// 프록시 경로 사용
// - HTTP(내부) 접속 → ws:// 직접 연결
const TRIGGER_WS_URL = location.protocol === 'https:'
? `wss://${location.host}/ws` // NPM 프록시 경로
: `ws://${location.hostname}:8765`; // 내부 직접 연결
const TRIGGER_DEBUG = true;
let triggerWs = null;
@@ -1991,6 +2350,7 @@
triggerLog('✅ 연결됨');
triggerConnected = true;
updateTriggerIndicator(true);
showToast('🔗 처방감지 연결됨', 'success');
if (triggerReconnectTimer) {
clearTimeout(triggerReconnectTimer);
triggerReconnectTimer = null;
@@ -2012,6 +2372,7 @@
triggerConnected = false;
triggerWs = null;
updateTriggerIndicator(false);
showToast('🔌 처방감지 연결 끊김 (재연결 중...)', 'warning');
// 3초 후 재연결
triggerReconnectTimer = setTimeout(() => {
@@ -2036,9 +2397,13 @@
switch (event) {
case 'prescription_detected':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋');
// 환자 목록 자동 갱신 (현재 선택 유지)
refreshPatientList();
break;
case 'prescription_updated':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄');
// 환자 목록 갱신 (수정된 정보 반영)
refreshPatientList();
break;
case 'prescription_deleted':
removeTriggerToast(data.pre_serial);