diff --git a/backend/paai_printer.py b/backend/paai_printer.py new file mode 100644 index 0000000..d5f6788 --- /dev/null +++ b/backend/paai_printer.py @@ -0,0 +1,159 @@ +"""PAAI ESC/POS 프린터 모듈""" +import json +from datetime import datetime +from escpos.printer import Network +from PIL import Image, ImageDraw, ImageFont + +# 프린터 설정 +PRINTER_IP = "192.168.0.174" +PRINTER_PORT = 9100 +THERMAL_WIDTH = 576 + + +def print_paai_result(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> dict: + """PAAI 분석 결과 인쇄""" + try: + # 이미지 생성 + img = create_receipt_image(pre_serial, patient_name, analysis, kims_summary) + + # 프린터 연결 및 출력 + p = Network(PRINTER_IP, port=PRINTER_PORT, timeout=15) + p.image(img) + p.text('\n\n\n') + p.cut() + + return {'success': True, 'message': '인쇄 완료'} + except Exception as e: + return {'success': False, 'error': str(e)} + + +def create_receipt_image(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> Image: + """영수증 이미지 생성""" + # 폰트 + try: + font_title = ImageFont.truetype('malgun.ttf', 28) + font_section = ImageFont.truetype('malgunbd.ttf', 20) + font_normal = ImageFont.truetype('malgun.ttf', 18) + font_small = ImageFont.truetype('malgun.ttf', 15) + except: + font_title = ImageFont.load_default() + font_section = font_title + font_normal = font_title + font_small = font_title + + width = THERMAL_WIDTH + padding = 20 + y = padding + + # 이미지 생성 + img = Image.new('RGB', (width, 1000), 'white') + draw = ImageDraw.Draw(img) + + # 헤더 + draw.text((width//2, y), 'PAAI 복약안내', font=font_title, fill='black', anchor='mt') + y += 40 + draw.line([(padding, y), (width-padding, y)], fill='black', width=1) + y += 15 + + # 환자 정보 + draw.text((padding, y), f'환자: {patient_name}', font=font_normal, fill='black') + y += 25 + draw.text((padding, y), f'처방번호: {pre_serial}', font=font_small, fill='black') + y += 20 + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + draw.text((padding, y), f'출력: {now_str}', font=font_small, fill='black') + y += 25 + draw.line([(padding, y), (width-padding, y)], fill='black', width=1) + y += 15 + + # 상호작용 + interaction_count = kims_summary.get('interaction_count', 0) + has_severe = kims_summary.get('has_severe', False) + + if has_severe: + draw.text((padding, y), '[주의] 중증 상호작용 있음!', font=font_section, fill='black') + elif interaction_count > 0: + draw.text((padding, y), f'약물 상호작용: {interaction_count}건', font=font_normal, fill='black') + else: + draw.text((padding, y), '상호작용 없음', font=font_normal, fill='black') + y += 30 + + # 처방 해석 + insight = analysis.get('prescription_insight', '') + if insight: + draw.text((padding, y), '[처방 해석]', font=font_section, fill='black') + y += 28 + for line in wrap_text(insight, 40)[:3]: + draw.text((padding, y), line, font=font_small, fill='black') + y += 20 + y += 10 + + # 주의사항 + cautions = analysis.get('cautions', []) + if cautions: + draw.text((padding, y), '[복용 주의사항]', font=font_section, fill='black') + y += 28 + for i, c in enumerate(cautions[:3], 1): + for line in wrap_text(f'{i}. {c}', 40)[:2]: + draw.text((padding, y), line, font=font_small, fill='black') + y += 20 + y += 10 + + # 상담 포인트 + counseling = analysis.get('counseling_points', []) + if counseling: + draw.text((padding, y), '[상담 포인트]', font=font_section, fill='black') + y += 28 + for i, c in enumerate(counseling[:2], 1): + for line in wrap_text(f'{i}. {c}', 40)[:2]: + draw.text((padding, y), line, font=font_small, fill='black') + y += 20 + y += 10 + + # 푸터 + y += 10 + draw.line([(padding, y), (width-padding, y)], fill='black', width=1) + y += 15 + draw.text((width//2, y), '양구청춘약국 PAAI', font=font_small, fill='black', anchor='mt') + + return img.crop((0, 0, width, y + 30)) + + +def wrap_text(text: str, max_chars: int = 40) -> list: + """텍스트 줄바꿈""" + lines = [] + words = text.split() + current = "" + + for word in words: + if len(current) + len(word) + 1 <= max_chars: + current = current + " " + word if current else word + else: + if current: + lines.append(current) + current = word + + if current: + lines.append(current) + + return lines if lines else [text[:max_chars]] + + +if __name__ == '__main__': + # CLI 테스트 + import sys + if len(sys.argv) > 1: + pre_serial = sys.argv[1] + else: + pre_serial = '20260305000075' + + # 테스트 데이터 + analysis = { + 'prescription_insight': '테스트 처방입니다.', + 'cautions': ['주의사항 1', '주의사항 2'], + 'counseling_points': ['상담 포인트 1'] + } + kims_summary = {'interaction_count': 0, 'has_severe': False} + + result = print_paai_result(pre_serial, '테스트환자', analysis, kims_summary) + print(result) diff --git a/backend/paai_printer_cli.py b/backend/paai_printer_cli.py new file mode 100644 index 0000000..cadcd56 --- /dev/null +++ b/backend/paai_printer_cli.py @@ -0,0 +1,201 @@ +"""PAAI ESC/POS 프린터 CLI - EUC-KR 텍스트 방식""" +import sys +import json +import socket +from datetime import datetime + +# 프린터 설정 +PRINTER_IP = "192.168.0.174" +PRINTER_PORT = 9100 + +# ESC/POS 명령어 +ESC = b'\x1b' +INIT = ESC + b'@' # 프린터 초기화 +CUT = ESC + b'd\x03' # 피드 + 커트 + + +def print_raw(data: bytes) -> bool: + """바이트 데이터를 프린터로 전송""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((PRINTER_IP, PRINTER_PORT)) + sock.sendall(data) + sock.close() + return True + except Exception as e: + print(f"프린터 오류: {e}", file=sys.stderr) + return False + + +def wrap_text(text: str, width: int = 44) -> list: + """텍스트 줄바꿈 (44자 기준, 들여쓰기 고려)""" + if not text: + return [] + + lines = [] + words = text.split() + current = "" + + for word in words: + if len(current) + len(word) + 1 <= width: + current = current + " " + word if current else word + else: + if current: + lines.append(current) + current = word + + if current: + lines.append(current) + + return lines if lines else [text[:width]] + + +def center_text(text: str, width: int = 48) -> str: + """중앙 정렬""" + text_len = len(text) + if text_len >= width: + return text + spaces = (width - text_len) // 2 + return " " * spaces + text + + +def format_paai_receipt(pre_serial: str, patient_name: str, + analysis: dict, kims_summary: dict) -> str: + """PAAI 복약안내 영수증 텍스트 생성 (48자 기준)""" + + LINE = "=" * 48 + THIN = "-" * 48 + now = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 헤더 + msg = f"\n{LINE}\n" + msg += center_text("[ PAAI 복약안내 ]") + "\n" + msg += f"{LINE}\n" + + # 환자 정보 + msg += f"환자: {patient_name}\n" + msg += f"처방번호: {pre_serial}\n" + msg += f"출력: {now}\n" + msg += f"{THIN}\n" + + # 상호작용 요약 + interaction_count = kims_summary.get('interaction_count', 0) + has_severe = kims_summary.get('has_severe', False) + + if has_severe: + msg += "[!!] 중증 상호작용 있음!\n" + elif interaction_count > 0: + msg += f"[!] 약물 상호작용: {interaction_count}건\n" + else: + msg += "[V] 상호작용 없음\n" + msg += "\n" + + # 처방 해석 + insight = analysis.get('prescription_insight', '') + if insight: + msg += f"{THIN}\n" + msg += ">> 처방 해석\n" + for line in wrap_text(insight, 44): + msg += f" {line}\n" + msg += "\n" + + # 복용 주의사항 + cautions = analysis.get('cautions', []) + if cautions: + msg += f"{THIN}\n" + msg += ">> 복용 주의사항\n" + for i, caution in enumerate(cautions[:4], 1): + # 첫 줄 + first_line = True + for line in wrap_text(f"{i}. {caution}", 44): + if first_line: + msg += f" {line}\n" + first_line = False + else: + msg += f" {line}\n" + msg += "\n" + + # 상담 포인트 + counseling = analysis.get('counseling_points', []) + if counseling: + msg += f"{THIN}\n" + msg += ">> 상담 포인트\n" + for i, point in enumerate(counseling[:3], 1): + first_line = True + for line in wrap_text(f"{i}. {point}", 44): + if first_line: + msg += f" {line}\n" + first_line = False + else: + msg += f" {line}\n" + msg += "\n" + + # OTC 추천 + otc_recs = analysis.get('otc_recommendations', []) + if otc_recs: + msg += f"{THIN}\n" + msg += ">> OTC 추천\n" + for rec in otc_recs[:2]: + product = rec.get('product', '') + reason = rec.get('reason', '') + msg += f" - {product}\n" + for line in wrap_text(reason, 42): + msg += f" {line}\n" + msg += "\n" + + # 푸터 + msg += f"{LINE}\n" + msg += center_text("양구청춘약국 PAAI") + "\n" + msg += center_text("Tel: 033-481-5222") + "\n" + msg += "\n" + + return msg + + +def print_paai_receipt(data: dict) -> bool: + """PAAI 영수증 인쇄""" + try: + pre_serial = data.get('pre_serial', '') + patient_name = data.get('patient_name', '') + analysis = data.get('analysis', {}) + kims_summary = data.get('kims_summary', {}) + + # 텍스트 생성 + message = format_paai_receipt(pre_serial, patient_name, analysis, kims_summary) + + # EUC-KR 인코딩 (한글 지원) + text_bytes = message.encode('euc-kr', errors='replace') + + # 명령어 조합 + command = INIT + text_bytes + b'\n\n\n' + CUT + + return print_raw(command) + except Exception as e: + print(f"인쇄 오류: {e}", file=sys.stderr) + return False + + +def main(): + if len(sys.argv) < 2: + print("사용법: python paai_printer_cli.py ", file=sys.stderr) + sys.exit(1) + + json_path = sys.argv[1] + + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if print_paai_receipt(data): + print("인쇄 완료") + sys.exit(0) + else: + sys.exit(1) + except Exception as e: + print(f"오류: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/pmr_api.py b/backend/pmr_api.py index 2573292..f5e7914 100644 --- a/backend/pmr_api.py +++ b/backend/pmr_api.py @@ -1443,3 +1443,193 @@ def paai_admin_feedback_stats(): except Exception as e: logging.error(f"피드백 통계 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 + + +# ───────────────────────────────────────────────────────────── +# ESC/POS 자동인쇄 API (EUC-KR 텍스트 방식) +# ───────────────────────────────────────────────────────────── + +import socket + +# 프린터 설정 +ESCPOS_PRINTER_IP = "192.168.0.174" +ESCPOS_PRINTER_PORT = 9100 + +# ESC/POS 명령어 +_ESC = b'\x1b' +_INIT = _ESC + b'@' # 프린터 초기화 +_CUT = _ESC + b'd\x03' # 피드 + 커트 + + +@pmr_bp.route('/api/paai/print', methods=['POST']) +def paai_print(): + """PAAI 분석 결과 ESC/POS 인쇄""" + try: + data = request.get_json() + pre_serial = data.get('pre_serial', '') + patient_name = data.get('patient_name', '') + result = data.get('result', {}) + + analysis = result.get('analysis', {}) + kims_summary = result.get('kims_summary', {}) + + # 영수증 텍스트 생성 + message = _format_paai_receipt(pre_serial, patient_name, analysis, kims_summary) + + # 인쇄 + success = _print_escpos_text(message) + + if success: + logging.info(f"PAAI 인쇄 완료: {pre_serial} ({patient_name})") + return jsonify({'success': True, 'message': '인쇄 완료'}) + else: + return jsonify({'success': False, 'error': '프린터 연결 실패'}), 500 + + except Exception as e: + logging.error(f"PAAI 인쇄 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _print_escpos_text(message: str) -> bool: + """ESC/POS 프린터로 텍스트 전송 (EUC-KR)""" + try: + # EUC-KR 인코딩 + text_bytes = message.encode('euc-kr', errors='replace') + + # 명령어 조합 + command = _INIT + text_bytes + b'\n\n\n' + _CUT + + # 소켓 전송 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((ESCPOS_PRINTER_IP, ESCPOS_PRINTER_PORT)) + sock.sendall(command) + sock.close() + return True + except Exception as e: + logging.error(f"ESC/POS 전송 실패: {e}") + return False + + +def _format_paai_receipt(pre_serial: str, patient_name: str, + analysis: dict, kims_summary: dict) -> str: + """PAAI 복약안내 영수증 텍스트 생성 (48자 기준)""" + + LINE = "=" * 48 + THIN = "-" * 48 + now = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 헤더 + msg = f"\n{LINE}\n" + msg += _center_text("[ PAAI 복약안내 ]") + "\n" + msg += f"{LINE}\n" + + # 환자 정보 + msg += f"환자: {patient_name}\n" + msg += f"처방번호: {pre_serial}\n" + msg += f"출력: {now}\n" + msg += f"{THIN}\n" + + # 상호작용 요약 + interaction_count = kims_summary.get('interaction_count', 0) + has_severe = kims_summary.get('has_severe', False) + + if has_severe: + msg += "[!!] 중증 상호작용 있음!\n" + elif interaction_count > 0: + msg += f"[!] 약물 상호작용: {interaction_count}건\n" + else: + msg += "[V] 상호작용 없음\n" + msg += "\n" + + # 처방 해석 + insight = analysis.get('prescription_insight', '') + if insight: + msg += f"{THIN}\n" + msg += ">> 처방 해석\n" + for line in _wrap_text(insight, 44): + msg += f" {line}\n" + msg += "\n" + + # 복용 주의사항 + cautions = analysis.get('cautions', []) + if cautions: + msg += f"{THIN}\n" + msg += ">> 복용 주의사항\n" + for i, caution in enumerate(cautions[:4], 1): + first_line = True + for line in _wrap_text(f"{i}. {caution}", 44): + if first_line: + msg += f" {line}\n" + first_line = False + else: + msg += f" {line}\n" + msg += "\n" + + # 상담 포인트 + counseling = analysis.get('counseling_points', []) + if counseling: + msg += f"{THIN}\n" + msg += ">> 상담 포인트\n" + for i, point in enumerate(counseling[:3], 1): + first_line = True + for line in _wrap_text(f"{i}. {point}", 44): + if first_line: + msg += f" {line}\n" + first_line = False + else: + msg += f" {line}\n" + msg += "\n" + + # OTC 추천 + otc_recs = analysis.get('otc_recommendations', []) + if otc_recs: + msg += f"{THIN}\n" + msg += ">> OTC 추천\n" + for rec in otc_recs[:2]: + product = rec.get('product', '') + reason = rec.get('reason', '') + msg += f" - {product}\n" + for line in _wrap_text(reason, 42): + msg += f" {line}\n" + msg += "\n" + + # 푸터 + msg += f"{LINE}\n" + msg += _center_text("양구청춘약국 PAAI") + "\n" + msg += _center_text("Tel: 033-481-5222") + "\n" + msg += "\n" + + return msg + + +def _center_text(text: str, width: int = 48) -> str: + """중앙 정렬""" + text_len = len(text) + if text_len >= width: + return text + spaces = (width - text_len) // 2 + return " " * spaces + text + + +def _wrap_text(text: str, width: int = 44) -> list: + """텍스트 줄바꿈""" + if not text: + return [] + + lines = [] + words = text.split() + current = "" + + for word in words: + if len(current) + len(word) + 1 <= width: + current = current + " " + word if current else word + else: + if current: + lines.append(current) + current = word + + if current: + lines.append(current) + + return lines if lines else [text[:width]] diff --git a/backend/static/uploads/pets/pet_4_98a97580.jpg b/backend/static/uploads/pets/pet_4_98a97580.jpg new file mode 100644 index 0000000..756050a Binary files /dev/null and b/backend/static/uploads/pets/pet_4_98a97580.jpg differ diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html index bfe799b..d96d466 100644 --- a/backend/templates/pmr.html +++ b/backend/templates/pmr.html @@ -33,6 +33,59 @@ align-items: center; gap: 15px; } + .auto-controls { + display: flex; + gap: 8px; + } + .status-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + .status-badge .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + } + .status-badge.disconnected { + background: #fef2f2; + color: #dc2626; + } + .status-badge.disconnected .status-dot { + background: #ef4444; + } + .status-badge.connected { + background: #ecfdf5; + color: #059669; + } + .status-badge.connected .status-dot { + background: #10b981; + } + .status-badge.auto-print-off { + background: #f3f4f6; + color: #6b7280; + } + .status-badge.auto-print-off .status-dot { + background: #9ca3af; + } + .status-badge.auto-print-on { + background: #dbeafe; + color: #2563eb; + } + .status-badge.auto-print-on .status-dot { + background: #3b82f6; + animation: pulse 1.5s infinite; + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } .date-picker { padding: 8px 15px; border: 2px solid #8b5cf6; @@ -940,6 +993,17 @@

💊 조제관리 청춘라벨 v2

+ +
+
+ + 자동감지 OFF +
+
+ + 자동인쇄 OFF +
+
@@ -1947,6 +2011,9 @@ // 토스트 알림 showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial); + + // 자동인쇄 (활성화된 경우) + printPaaiResult(preSerial, patientName, result); } else { showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial); } @@ -2435,6 +2502,13 @@ true // clickable ); playTriggerSound(); + + // 자동인쇄 (활성화된 경우) + printPaaiResult(data.pre_serial, data.patient_name, { + success: true, + analysis: data.analysis, + kims_summary: data.kims_summary + }); break; case 'analysis_failed': showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌'); @@ -2562,39 +2636,75 @@ // 연결 상태 표시 function updateTriggerIndicator(isConnected) { - let indicator = document.getElementById('triggerIndicator'); - if (!indicator) { - const controls = document.querySelector('.header .controls'); - if (controls) { - indicator = document.createElement('div'); - indicator.id = 'triggerIndicator'; - indicator.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: #f1f5f9; - border-radius: 8px; - font-size: 0.8rem; - color: #64748b; - `; - controls.insertBefore(indicator, controls.firstChild); - } - } - + const indicator = document.getElementById('triggerIndicator'); if (indicator) { + indicator.className = `status-badge ${isConnected ? 'connected' : 'disconnected'}`; indicator.innerHTML = ` - - ${isConnected ? '자동감지 ON' : '자동감지 OFF'} + + 자동감지 ${isConnected ? 'ON' : 'OFF'} `; } } + // ═══════════════════════════════════════════════════════════════ + // 자동인쇄 기능 + // ═══════════════════════════════════════════════════════════════ + let autoPrintEnabled = localStorage.getItem('pmr_auto_print') === 'true'; + + // 초기화 + function initAutoPrint() { + updateAutoPrintIndicator(); + } + + // 토글 + function toggleAutoPrint() { + autoPrintEnabled = !autoPrintEnabled; + localStorage.setItem('pmr_auto_print', autoPrintEnabled); + updateAutoPrintIndicator(); + showToast(autoPrintEnabled ? '🖨️ 자동인쇄 ON' : '🖨️ 자동인쇄 OFF', autoPrintEnabled ? 'success' : 'info'); + } + + // 표시 업데이트 + function updateAutoPrintIndicator() { + const toggle = document.getElementById('autoPrintToggle'); + if (toggle) { + toggle.className = `status-badge ${autoPrintEnabled ? 'auto-print-on' : 'auto-print-off'}`; + toggle.innerHTML = ` + + 자동인쇄 ${autoPrintEnabled ? 'ON' : 'OFF'} + `; + } + } + + // PAAI 결과 인쇄 + async function printPaaiResult(preSerial, patientName, result) { + if (!autoPrintEnabled) return; + + try { + const response = await fetch('/pmr/api/paai/print', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + pre_serial: preSerial, + patient_name: patientName, + result: result + }) + }); + + const data = await response.json(); + if (data.success) { + console.log('[AutoPrint] 인쇄 완료:', preSerial); + } else { + console.error('[AutoPrint] 인쇄 실패:', data.error); + } + } catch (err) { + console.error('[AutoPrint] 오류:', err); + } + } + + // 페이지 로드 시 초기화 + document.addEventListener('DOMContentLoaded', initAutoPrint); + // 알림 소리 function playTriggerSound() { try { diff --git a/docs/ESCPOS_TROUBLESHOOTING.md b/docs/ESCPOS_TROUBLESHOOTING.md new file mode 100644 index 0000000..f9d8830 --- /dev/null +++ b/docs/ESCPOS_TROUBLESHOOTING.md @@ -0,0 +1,252 @@ +# ESC/POS 영수증 프린터 트러블슈팅 가이드 + +> 작성일: 2026-03-05 +> 프린터: 192.168.0.174:9100 (올댓포스 오른쪽) + +--- + +## 핵심 요약 + +| 항목 | 올바른 방식 | 잘못된 방식 | +|------|------------|------------| +| **인코딩** | EUC-KR | UTF-8 | +| **전송 방식** | socket 직접 전송 | python-escpos 라이브러리 | +| **이모지** | 사용 금지 (`>>`, `[V]`) | ❌ 🖨️ ✅ | +| **이미지** | 사용 금지 | PIL Image | +| **용지 폭** | 48자 기준 | 글자수 무제한 | +| **용지 길이** | 무제한 (롤 용지) | 제한 없음 | + +--- + +## 증상별 해결책 + +### 1. 아무것도 안 나옴 +``` +원인: 프린터 연결 실패 +해결: +1. ping 192.168.0.174 확인 +2. 포트 9100 확인 (Test-NetConnection -ComputerName 192.168.0.174 -Port 9100) +3. 프린터 전원 확인 +``` + +### 2. "EAT" 또는 깨진 문자만 나옴 +``` +원인: 이미지 인쇄 방식 사용 +해결: 이미지 방식 사용 금지! 텍스트 + EUC-KR 인코딩 사용 + +❌ 잘못된 코드: +from escpos.printer import Network +p = Network(...) +p.image(img) # 이미지 인쇄 - 안 됨! + +✅ 올바른 코드: +sock = socket.socket(...) +text_bytes = message.encode('euc-kr', errors='replace') +sock.sendall(INIT + text_bytes + CUT) +``` + +### 3. 한글이 ???? 로 나옴 +``` +원인: UTF-8 인코딩 사용 +해결: EUC-KR 인코딩 사용 + +❌ 잘못된 코드: +text.encode('utf-8') + +✅ 올바른 코드: +text.encode('euc-kr', errors='replace') +``` + +### 4. 이모지가 ? 로 나옴 +``` +원인: ESC/POS 프린터는 이모지 미지원 +해결: 텍스트로 대체 + +❌ ✅ 상호작용 없음 +✅ [V] 상호작용 없음 + +❌ ⚠️ 주의 필요 +✅ [!] 주의 필요 + +❌ 📋 처방 해석 +✅ >> 처방 해석 +``` + +### 5. 첫 줄만 나오고 잘림 +``` +원인: python-escpos 라이브러리의 set() 함수 문제 +해결: socket 직접 전송 방식 사용 + +❌ 잘못된 코드: +from escpos.printer import Network +p = Network(...) +p.set(align='center', bold=True) # 이 명령이 문제! +p.text("내용") + +✅ 올바른 코드: +sock = socket.socket(...) +sock.sendall(INIT + text.encode('euc-kr') + CUT) +``` + +### 6. 연결은 되는데 인쇄 안 됨 +``` +원인: 프린터가 이전 작업에서 hang 상태 +해결: +1. 프린터 전원 껐다 켜기 +2. 또는 INIT 명령 먼저 전송: ESC + b'@' +``` + +--- + +## 올바른 코드 템플릿 + +### 기본 텍스트 인쇄 +```python +import socket + +# 프린터 설정 +PRINTER_IP = "192.168.0.174" +PRINTER_PORT = 9100 + +# ESC/POS 명령어 +ESC = b'\x1b' +INIT = ESC + b'@' # 프린터 초기화 +CUT = ESC + b'd\x03' # 피드 + 커트 + +def print_text(message: str) -> bool: + try: + # EUC-KR 인코딩 (한글 지원) + text_bytes = message.encode('euc-kr', errors='replace') + + # 명령어 조합 + command = INIT + text_bytes + b'\n\n\n' + CUT + + # 소켓 전송 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((PRINTER_IP, PRINTER_PORT)) + sock.sendall(command) + sock.close() + return True + except Exception as e: + print(f"인쇄 오류: {e}") + return False + +# 사용 예시 +message = """ +================================================ + [ 테스트 출력 ] +================================================ +환자: 홍길동 +처방번호: 20260305000001 + +[V] 상호작용 없음 + +>> 처방 해석 + 감기 증상 완화를 위한 처방입니다. + +================================================ + 청춘약국 +================================================ +""" +print_text(message) +``` + +### 중앙 정렬 헬퍼 +```python +def center_text(text: str, width: int = 48) -> str: + """48자 기준 중앙 정렬""" + text_len = len(text) + if text_len >= width: + return text + spaces = (width - text_len) // 2 + return " " * spaces + text + +# 사용 +print(center_text("[ PAAI 복약안내 ]")) +# 출력: " [ PAAI 복약안내 ]" +``` + +### 줄바꿈 헬퍼 +```python +def wrap_text(text: str, width: int = 44) -> list: + """44자 기준 줄바꿈 (들여쓰기 여유)""" + lines = [] + words = text.split() + current = "" + + for word in words: + if len(current) + len(word) + 1 <= width: + current = current + " " + word if current else word + else: + if current: + lines.append(current) + current = word + + if current: + lines.append(current) + + return lines if lines else [text[:width]] + +# 사용 +long_text = "경골 하단 및 중족골 골절로 인한 통증과 부종 관리를 위해 NSAIDs를 처방합니다." +for line in wrap_text(long_text, 44): + print(f" {line}") +``` + +--- + +## 프린터 사양 + +| 항목 | 값 | +|------|-----| +| IP | 192.168.0.174 | +| Port | 9100 | +| 용지 폭 | 80mm (48자) | +| 인코딩 | EUC-KR (CP949) | +| 한글 | 지원 | +| 이모지 | 미지원 | +| 이미지 | 미지원 (이 프린터) | + +--- + +## 참고 파일 + +| 파일 | 설명 | +|------|------| +| `backend/pos_printer.py` | ESC/POS 기본 유틸리티 | +| `backend/paai_printer_cli.py` | PAAI 인쇄 전용 CLI | +| `clawd/memory/3월4일 동물약_복약지도서.md` | 동물약 인쇄 가이드 | + +--- + +## 테스트 명령어 + +```powershell +# 연결 테스트 +Test-NetConnection -ComputerName 192.168.0.174 -Port 9100 + +# 간단 인쇄 테스트 +python -c " +import socket +sock = socket.socket() +sock.connect(('192.168.0.174', 9100)) +sock.sendall(b'\x1b@Test OK\n\n\n\x1bd\x03') +sock.close() +print('OK') +" + +# pos_printer.py 테스트 +cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend +python pos_printer.py +``` + +--- + +## 히스토리 + +| 날짜 | 문제 | 해결 | +|------|------|------| +| 2026-03-04 | 동물약 투약지도서 이모지 깨짐 | 이모지 제거, 텍스트로 대체 | +| 2026-03-05 | PAAI 인쇄 "EAT"만 출력 | 이미지 방식 → 텍스트 방식 변경 | +| 2026-03-05 | python-escpos 라이브러리 문제 | socket 직접 전송으로 변경 |