feat: PAAI 자동인쇄 기능 완성 (EUC-KR 텍스트 방식)

추가:
- 자동인쇄 ON/OFF 토글 (헤더)
- ESC/POS 영수증 인쇄 (EUC-KR 인코딩)
- ESCPOS_TROUBLESHOOTING.md 트러블슈팅 문서

핵심 변경:
- 이미지 방식 -> 텍스트 방식 (socket 직접 전송)
- UTF-8 -> EUC-KR 인코딩
- 이모지 제거 ([V], [!], >> 사용)
- 48자 기준 줄바꿈

인쇄 흐름:
1. PAAI 분석 완료
2. 자동인쇄 ON이면 /pmr/api/paai/print 호출
3. _format_paai_receipt()로 텍스트 생성
4. _print_escpos_text()로 프린터 전송

참고: docs/ESCPOS_TROUBLESHOOTING.md
This commit is contained in:
thug0bin 2026-03-05 12:19:56 +09:00
parent 7ac3f7a8b4
commit 0b17139daa
6 changed files with 939 additions and 27 deletions

159
backend/paai_printer.py Normal file
View File

@ -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)

201
backend/paai_printer_cli.py Normal file
View File

@ -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 <json_file>", 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()

View File

@ -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]]

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@ -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 @@
<header class="header">
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
<div class="controls">
<!-- 자동감지/자동인쇄 상태 -->
<div class="auto-controls">
<div id="triggerIndicator" class="status-badge disconnected">
<span class="status-dot"></span>
자동감지 OFF
</div>
<div id="autoPrintToggle" class="status-badge auto-print-off" onclick="toggleAutoPrint()">
<span class="status-dot"></span>
자동인쇄 OFF
</div>
</div>
<input type="date" id="dateSelect" class="date-picker">
<div class="stats-box">
<div class="stat-item">
@ -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 = `
<span style="
width: 8px;
height: 8px;
border-radius: 50%;
background: ${isConnected ? '#10b981' : '#ef4444'};
"></span>
${isConnected ? '자동감지 ON' : '자동감지 OFF'}
<span class="status-dot"></span>
자동감지 ${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 = `
<span class="status-dot"></span>
자동인쇄 ${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 {

View File

@ -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 직접 전송으로 변경 |