fix: 처방목록 조회 기준을 발행일에서 조제일로 변경
문제: - PMR 처방 목록이 PassDay(처방전 발행일) 기준으로 조회되어 발행일과 조제일이 다른 처방(예: 3일 전 발행, 오늘 조제)이 오늘 목록에 표시되지 않는 버그 해결: - PS_MAIN 테이블 조회 시 PassDay 대신 Indate(조제일) 기준으로 변경 - issue_date(발행일), dispense_date(조제일) 필드 추가로 구분 명확화 추가 변경: - WebSocket 연결/해제 시 토스트 알림 추가 - WebSocket 프록시 트러블슈팅 문서 추가 (NPM 설정 가이드)
This commit is contained in:
parent
f3b6496c91
commit
cb90d4a7a6
@ -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():
|
||||
"""
|
||||
|
||||
@ -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);
|
||||
|
||||
278
docs/WEBSOCKET_PROXY_TROUBLESHOOTING.md
Normal file
278
docs/WEBSOCKET_PROXY_TROUBLESHOOTING.md
Normal file
@ -0,0 +1,278 @@
|
||||
# WebSocket 프록시 트러블슈팅 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
PAAI 처방감지 시스템에서 WebSocket(wss://)을 Nginx Proxy Manager(NPM)를 통해 프록시하는 과정에서 발생한 문제들과 해결 방법을 정리한 문서입니다.
|
||||
|
||||
**환경:**
|
||||
- Nginx Proxy Manager (NPM): Docker 컨테이너, CT 150 (192.168.0.105)
|
||||
- WebSocket 서버: trigger_server.py (192.168.0.14:8765)
|
||||
- 프론트엔드: https://mile.0bin.in/pmr/
|
||||
- Proxmox VE: 192.168.0.119
|
||||
|
||||
---
|
||||
|
||||
## 문제 1: WebSocket 404 오류
|
||||
|
||||
### 증상
|
||||
```
|
||||
WebSocket connection to 'wss://mile.0bin.in/ws' failed
|
||||
```
|
||||
nginx 액세스 로그에 `/ws` 요청이 404로 응답됨.
|
||||
|
||||
### 원인
|
||||
NPM에서 `/ws` 경로에 대한 프록시 설정이 없었음.
|
||||
|
||||
### 해결 방법
|
||||
|
||||
**NPM 웹 UI에서 Advanced 설정 추가:**
|
||||
|
||||
1. NPM 관리자 접속
|
||||
2. **Proxy Hosts** → **mile.0bin.in** → **Edit**
|
||||
3. **Advanced** 탭 클릭
|
||||
4. 아래 내용 입력:
|
||||
|
||||
```nginx
|
||||
location /ws {
|
||||
proxy_pass http://192.168.0.14:8765;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
```
|
||||
|
||||
5. **Save** 클릭
|
||||
|
||||
### ⚠️ 주의사항
|
||||
|
||||
**절대 conf 파일 직접 수정하지 말 것!**
|
||||
|
||||
NPM은 설정을 SQLite DB에 저장하고, 컨테이너 재시작 시 DB에서 conf 파일을 재생성합니다.
|
||||
`/data/nginx/proxy_host/167.conf` 같은 파일을 직접 수정해도 재시작 시 덮어씌워집니다.
|
||||
|
||||
**반드시 NPM 웹 UI의 Advanced 탭을 사용하세요.**
|
||||
|
||||
---
|
||||
|
||||
## 문제 2: duplicate location 오류
|
||||
|
||||
### 증상
|
||||
```
|
||||
nginx: [emerg] duplicate location "/ws" in /data/nginx/custom/server_proxy.conf:1
|
||||
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||
```
|
||||
모든 프록시가 작동하지 않음!
|
||||
|
||||
### 원인
|
||||
`/data/nginx/custom/server_proxy.conf`에 `/ws` location을 추가했는데,
|
||||
NPM Advanced 설정에서도 `/ws`를 추가하여 중복 발생.
|
||||
|
||||
### 해결 방법
|
||||
|
||||
중복 파일 삭제:
|
||||
```bash
|
||||
# Proxmox에서 실행
|
||||
pct exec 150 -- docker exec npm rm /data/nginx/custom/server_proxy.conf
|
||||
pct exec 150 -- docker exec npm nginx -t
|
||||
pct exec 150 -- docker exec npm nginx -s reload
|
||||
```
|
||||
|
||||
### 예방책
|
||||
- `/data/nginx/custom/` 폴더는 건드리지 않기
|
||||
- 모든 커스텀 설정은 **NPM 웹 UI Advanced 탭**에서만 추가
|
||||
|
||||
---
|
||||
|
||||
## 문제 3: HTTP/2와 WebSocket 충돌
|
||||
|
||||
### 증상
|
||||
```
|
||||
curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)
|
||||
```
|
||||
nginx 프록시를 통한 WebSocket 핸드셰이크 실패.
|
||||
|
||||
### 원인
|
||||
- NPM이 HTTPS에서 HTTP/2를 사용
|
||||
- 기존 HTTP/2는 WebSocket을 지원하지 않음 (RFC 8441 이후 지원)
|
||||
- nginx 1.25.1+ 필요
|
||||
|
||||
### 해결 방법
|
||||
|
||||
**방법 1: 내부 HTTP 접속 (권장, 간단)**
|
||||
```
|
||||
http://192.168.0.14:7001/pmr/
|
||||
```
|
||||
HTTP → ws:// 직접 연결로 HTTP/2 문제 회피
|
||||
|
||||
**방법 2: 프론트엔드에서 분기 처리**
|
||||
```javascript
|
||||
const TRIGGER_WS_URL = location.protocol === 'https:'
|
||||
? `wss://${location.host}/ws` // 외부: NPM 프록시
|
||||
: `ws://${location.hostname}:8765`; // 내부: 직접 연결
|
||||
```
|
||||
|
||||
**방법 3: 별도 WebSocket 전용 서브도메인**
|
||||
- ws.mile.0bin.in 생성
|
||||
- HTTP/2 비활성화 설정
|
||||
|
||||
---
|
||||
|
||||
## 문제 4: nginx 설정 반영 안 됨
|
||||
|
||||
### 증상
|
||||
conf 파일에 설정이 있지만 `nginx -T`에서 확인 안 됨.
|
||||
|
||||
### 진단 방법
|
||||
|
||||
```bash
|
||||
# 1. conf 파일 확인
|
||||
pct exec 150 -- docker exec npm cat /data/nginx/proxy_host/167.conf | grep 8765
|
||||
|
||||
# 2. nginx 로드된 설정 확인
|
||||
pct exec 150 -- docker exec npm nginx -T 2>&1 | grep 8765
|
||||
|
||||
# 3. 결과 비교
|
||||
# 파일에는 있는데 nginx -T에 없으면 → reload 필요 또는 문법 오류
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
```bash
|
||||
# 문법 체크
|
||||
pct exec 150 -- docker exec npm nginx -t
|
||||
|
||||
# 문제 없으면 reload
|
||||
pct exec 150 -- docker exec npm nginx -s reload
|
||||
|
||||
# 그래도 안 되면 컨테이너 재시작
|
||||
pct exec 150 -- docker restart npm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 유용한 진단 명령어
|
||||
|
||||
### NPM 상태 확인
|
||||
```bash
|
||||
# 컨테이너 상태
|
||||
pct exec 150 -- docker ps | grep npm
|
||||
|
||||
# nginx 설정 테스트
|
||||
pct exec 150 -- docker exec npm nginx -t
|
||||
|
||||
# nginx reload
|
||||
pct exec 150 -- docker exec npm nginx -s reload
|
||||
|
||||
# 컨테이너 재시작
|
||||
pct exec 150 -- docker restart npm
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
```bash
|
||||
# 특정 호스트 액세스 로그
|
||||
pct exec 150 -- docker exec npm tail -50 /data/logs/proxy-host-167_access.log
|
||||
|
||||
# 에러 로그
|
||||
pct exec 150 -- docker exec npm tail -20 /data/logs/proxy-host-167_error.log
|
||||
|
||||
# /ws 요청만 필터
|
||||
pct exec 150 -- docker exec npm tail -100 /data/logs/proxy-host-167_access.log | grep ws
|
||||
```
|
||||
|
||||
### WebSocket 연결 테스트
|
||||
```bash
|
||||
# 직접 연결 테스트 (CT 150에서)
|
||||
curl -v --max-time 5 \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
|
||||
-H "Sec-WebSocket-Version: 13" \
|
||||
http://192.168.0.14:8765
|
||||
|
||||
# 성공 시: HTTP/1.1 101 Switching Protocols
|
||||
```
|
||||
|
||||
### 프록시 호스트 번호 찾기
|
||||
```bash
|
||||
# 도메인으로 설정 파일 찾기
|
||||
pct exec 150 -- docker exec npm grep -r "mile.0bin" /data/nginx/proxy_host/
|
||||
# → /data/nginx/proxy_host/167.conf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 최종 작동 구성
|
||||
|
||||
### 네트워크 구조
|
||||
```
|
||||
[브라우저]
|
||||
│
|
||||
│ HTTPS (외부) 또는 HTTP (내부)
|
||||
▼
|
||||
[NPM - 192.168.0.105:443/80]
|
||||
│
|
||||
├─ /pmr/* → http://192.168.0.14:7001 (Flask)
|
||||
│
|
||||
└─ /ws → http://192.168.0.14:8765 (WebSocket)
|
||||
|
||||
[Flask 서버 - 192.168.0.14:7001]
|
||||
│
|
||||
└─ PMR 웹 앱
|
||||
|
||||
[Trigger 서버 - 192.168.0.14:8765]
|
||||
│
|
||||
└─ WebSocket 처방 감지 알림
|
||||
```
|
||||
|
||||
### 프론트엔드 WebSocket 연결 코드
|
||||
```javascript
|
||||
// pmr.html
|
||||
const TRIGGER_WS_URL = location.protocol === 'https:'
|
||||
? `wss://${location.host}/ws` // 외부: NPM 프록시
|
||||
: `ws://${location.hostname}:8765`; // 내부: 직접 연결
|
||||
```
|
||||
|
||||
### NPM Advanced 설정 (mile.0bin.in)
|
||||
```nginx
|
||||
location /ws {
|
||||
proxy_pass http://192.168.0.14:8765;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
WebSocket 프록시 설정 시 확인 사항:
|
||||
|
||||
- [ ] WebSocket 서버(trigger_server.py) 실행 중인가?
|
||||
- [ ] 8765 포트 리스닝 중인가? (`netstat -ano | findstr :8765`)
|
||||
- [ ] NPM Advanced에 /ws location 설정했는가?
|
||||
- [ ] `/data/nginx/custom/` 폴더에 중복 설정 없는가?
|
||||
- [ ] `nginx -t` 통과하는가?
|
||||
- [ ] nginx reload 했는가?
|
||||
- [ ] 브라우저 캐시 비웠는가? (Ctrl+Shift+R)
|
||||
|
||||
---
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **NPM 설정은 반드시 웹 UI로** - conf 파일 직접 수정 금지
|
||||
2. **duplicate location 주의** - custom 폴더와 Advanced 설정 중복 확인
|
||||
3. **HTTP/2 + WebSocket = 문제** - 내부 HTTP 접속 또는 별도 서브도메인 사용
|
||||
4. **변경 후 항상 테스트** - `nginx -t` 습관화
|
||||
5. **문제 생기면 로그 먼저** - access_log, error_log 확인
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-03-05*
|
||||
*작성: 용림 (Clawdbot)*
|
||||
Loading…
Reference in New Issue
Block a user