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:
parent
7ac3f7a8b4
commit
0b17139daa
159
backend/paai_printer.py
Normal file
159
backend/paai_printer.py
Normal 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
201
backend/paai_printer_cli.py
Normal 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()
|
||||
@ -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]]
|
||||
|
||||
BIN
backend/static/uploads/pets/pet_4_98a97580.jpg
Normal file
BIN
backend/static/uploads/pets/pet_4_98a97580.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
@ -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 {
|
||||
|
||||
252
docs/ESCPOS_TROUBLESHOOTING.md
Normal file
252
docs/ESCPOS_TROUBLESHOOTING.md
Normal 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 직접 전송으로 변경 |
|
||||
Loading…
Reference in New Issue
Block a user