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, PreSerial,
Day_Serial, Day_Serial,
PassDay, PassDay,
Indate,
Paname, Paname,
PaNum, PaNum,
CusCode, CusCode,
@ -81,7 +82,7 @@ def get_prescriptions_by_date():
PRICE_P, PRICE_P,
PRICE_C PRICE_C
FROM PS_MAIN FROM PS_MAIN
WHERE PassDay = ? WHERE Indate = ?
ORDER BY Day_Serial ASC ORDER BY Day_Serial ASC
""", (date_yyyymmdd,)) """, (date_yyyymmdd,))
@ -112,7 +113,9 @@ def get_prescriptions_by_date():
prescriptions.append({ prescriptions.append({
'prescription_id': row.PreSerial, 'prescription_id': row.PreSerial,
'order_number': row.Day_Serial, 'order_number': row.Day_Serial,
'date': row.PassDay, 'date': row.Indate, # 조제일 기준
'issue_date': row.PassDay, # 처방전 발행일
'dispense_date': row.Indate, # 조제일
'patient_name': row.Paname, 'patient_name': row.Paname,
'patient_id': row.PaNum[:6] + '******' if row.PaNum and len(row.PaNum) > 6 else row.PaNum, 'patient_id': row.PaNum[:6] + '******' if row.PaNum and len(row.PaNum) > 6 else row.PaNum,
'patient_code': row.CusCode, 'patient_code': row.CusCode,
@ -221,8 +224,6 @@ def get_prescription_detail(prescription_id):
'sung_code': row.SUNG_CODE or '' 'sung_code': row.SUNG_CODE or ''
}) })
conn.close()
# 나이/성별 계산 # 나이/성별 계산
panum = rx_row.PaNum or '' panum = rx_row.PaNum or ''
age = None age = None
@ -255,6 +256,23 @@ def get_prescription_detail(prescription_id):
'name_2': disease_name_2 '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({ return jsonify({
'success': True, 'success': True,
'prescription': { 'prescription': {
@ -271,8 +289,10 @@ def get_prescription_detail(prescription_id):
'patient': { 'patient': {
'name': rx_row.Paname, 'name': rx_row.Paname,
'code': rx_row.CusCode, 'code': rx_row.CusCode,
'cus_code': rx_row.CusCode, # 호환성
'age': age, 'age': age,
'gender': gender 'gender': gender,
'cusetc': cusetc # 특이사항
}, },
'disease_info': disease_info, 'disease_info': disease_info,
'medications': medications, 'medications': medications,
@ -822,6 +842,102 @@ def get_patient_otc_history(cus_code):
# PAAI (Pharmacist Assistant AI) API # 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']) @pmr_bp.route('/api/paai/analyze', methods=['POST'])
def paai_analyze(): def paai_analyze():
""" """

View File

@ -161,6 +161,51 @@
transform: scale(1.05); 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 버튼 */ /* PAAI 버튼 */
.detail-header .rx-info .paai-badge { .detail-header .rx-info .paai-badge {
background: linear-gradient(135deg, #10b981, #059669) !important; background: linear-gradient(135deg, #10b981, #059669) !important;
@ -412,6 +457,115 @@
vertical-align: middle; 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 모달 */
.otc-modal { .otc-modal {
display: none; display: none;
@ -820,8 +974,11 @@
<!-- 오른쪽: 처방 상세 --> <!-- 오른쪽: 처방 상세 -->
<div class="prescription-detail"> <div class="prescription-detail">
<div class="detail-header" id="detailHeader" style="display:none;"> <div class="detail-header" id="detailHeader" style="display:none;">
<div class="patient-name" id="detailName">-</div> <div class="patient-row">
<div class="patient-info" id="detailInfo">-</div> <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 class="rx-info" id="rxInfo"></div>
</div> </div>
<div class="medication-list" id="medicationList"> <div class="medication-list" id="medicationList">
@ -871,6 +1028,25 @@
</div> </div>
</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 구매 이력 모달 --> <!-- OTC 구매 이력 모달 -->
<div class="otc-modal" id="otcModal"> <div class="otc-modal" id="otcModal">
<div class="otc-modal-content"> <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) { async function selectPatient(prescriptionId, element) {
// UI 활성화 // UI 활성화
@ -1019,13 +1233,14 @@
// PAAI용 처방 데이터 저장 // PAAI용 처방 데이터 저장
currentPrescriptionData = { currentPrescriptionData = {
pre_serial: prescriptionId, pre_serial: prescriptionId,
cus_code: data.patient.cus_code, cus_code: data.patient.cus_code || data.patient.code,
name: data.patient.name, name: data.patient.name,
st1: data.disease_info?.code_1 || '', st1: data.disease_info?.code_1 || '',
st1_name: data.disease_info?.name_1 || '', st1_name: data.disease_info?.name_1 || '',
st2: data.disease_info?.code_2 || '', st2: data.disease_info?.code_2 || '',
st2_name: data.disease_info?.name_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 = ` document.getElementById('rxInfo').innerHTML = `
<span>🏥 ${data.prescription.hospital || '-'}</span> <span>🏥 ${data.prescription.hospital || '-'}</span>
<span>👨‍⚕️ ${data.prescription.doctor || '-'}</span> <span>👨‍⚕️ ${data.prescription.doctor || '-'}</span>
@ -1375,6 +1603,8 @@
// OTC 구매 이력 체크 // OTC 구매 이력 체크
async function checkOtcHistory(cusCode) { async function checkOtcHistory(cusCode) {
const preSerial = currentPrescriptionData?.pre_serial;
try { try {
const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`); const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`);
const data = await res.json(); const data = await res.json();
@ -1389,12 +1619,17 @@
otcData = null; otcData = null;
} }
// PAAI 버튼 추가 (항상 표시) // 서버에서 기존 PAAI 결과 확인 (트리거가 미리 생성한 결과)
await checkPaaiStatus(preSerial);
// PAAI 버튼 추가 (캐시/진행중/기본 상태에 따라)
addPaaiButton(); addPaaiButton();
} catch (err) { } catch (err) {
console.error('OTC check error:', err); console.error('OTC check error:', err);
otcData = null; otcData = null;
// OTC 오류여도 PAAI 버튼은 추가
// OTC 오류여도 PAAI 상태 확인 및 버튼 추가
await checkPaaiStatus(preSerial);
addPaaiButton(); addPaaiButton();
} }
} }
@ -1468,6 +1703,88 @@
document.getElementById('otcModal').style.display = 'none'; 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) 함수들 - 비동기 토스트 방식 // PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@ -1476,6 +1793,43 @@
const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } } const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } }
const paaiPendingRequests = new Set(); // 진행 중인 요청 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() { function addPaaiButton() {
const rxInfo = document.getElementById('rxInfo'); const rxInfo = document.getElementById('rxInfo');
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return; if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
@ -1967,7 +2321,12 @@
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
(function() { (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; const TRIGGER_DEBUG = true;
let triggerWs = null; let triggerWs = null;
@ -1991,6 +2350,7 @@
triggerLog('✅ 연결됨'); triggerLog('✅ 연결됨');
triggerConnected = true; triggerConnected = true;
updateTriggerIndicator(true); updateTriggerIndicator(true);
showToast('🔗 처방감지 연결됨', 'success');
if (triggerReconnectTimer) { if (triggerReconnectTimer) {
clearTimeout(triggerReconnectTimer); clearTimeout(triggerReconnectTimer);
triggerReconnectTimer = null; triggerReconnectTimer = null;
@ -2012,6 +2372,7 @@
triggerConnected = false; triggerConnected = false;
triggerWs = null; triggerWs = null;
updateTriggerIndicator(false); updateTriggerIndicator(false);
showToast('🔌 처방감지 연결 끊김 (재연결 중...)', 'warning');
// 3초 후 재연결 // 3초 후 재연결
triggerReconnectTimer = setTimeout(() => { triggerReconnectTimer = setTimeout(() => {
@ -2036,9 +2397,13 @@
switch (event) { switch (event) {
case 'prescription_detected': case 'prescription_detected':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋'); showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋');
// 환자 목록 자동 갱신 (현재 선택 유지)
refreshPatientList();
break; break;
case 'prescription_updated': case 'prescription_updated':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄'); showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄');
// 환자 목록 갱신 (수정된 정보 반영)
refreshPatientList();
break; break;
case 'prescription_deleted': case 'prescription_deleted':
removeTriggerToast(data.pre_serial); removeTriggerToast(data.pre_serial);

View 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)*