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:
|
except Exception as e:
|
||||||
logging.error(f"피드백 통계 조회 오류: {e}")
|
logging.error(f"피드백 통계 조회 오류: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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;
|
align-items: center;
|
||||||
gap: 15px;
|
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 {
|
.date-picker {
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
border: 2px solid #8b5cf6;
|
border: 2px solid #8b5cf6;
|
||||||
@ -940,6 +993,17 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
||||||
<div class="controls">
|
<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">
|
<input type="date" id="dateSelect" class="date-picker">
|
||||||
<div class="stats-box">
|
<div class="stats-box">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
@ -1947,6 +2011,9 @@
|
|||||||
|
|
||||||
// 토스트 알림
|
// 토스트 알림
|
||||||
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
|
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
|
||||||
|
|
||||||
|
// 자동인쇄 (활성화된 경우)
|
||||||
|
printPaaiResult(preSerial, patientName, result);
|
||||||
} else {
|
} else {
|
||||||
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
|
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
|
||||||
}
|
}
|
||||||
@ -2435,6 +2502,13 @@
|
|||||||
true // clickable
|
true // clickable
|
||||||
);
|
);
|
||||||
playTriggerSound();
|
playTriggerSound();
|
||||||
|
|
||||||
|
// 자동인쇄 (활성화된 경우)
|
||||||
|
printPaaiResult(data.pre_serial, data.patient_name, {
|
||||||
|
success: true,
|
||||||
|
analysis: data.analysis,
|
||||||
|
kims_summary: data.kims_summary
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'analysis_failed':
|
case 'analysis_failed':
|
||||||
showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌');
|
showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌');
|
||||||
@ -2562,39 +2636,75 @@
|
|||||||
|
|
||||||
// 연결 상태 표시
|
// 연결 상태 표시
|
||||||
function updateTriggerIndicator(isConnected) {
|
function updateTriggerIndicator(isConnected) {
|
||||||
let indicator = document.getElementById('triggerIndicator');
|
const indicator = document.getElementById('triggerIndicator');
|
||||||
if (!indicator) {
|
if (indicator) {
|
||||||
const controls = document.querySelector('.header .controls');
|
indicator.className = `status-badge ${isConnected ? 'connected' : 'disconnected'}`;
|
||||||
if (controls) {
|
indicator.innerHTML = `
|
||||||
indicator = document.createElement('div');
|
<span class="status-dot"></span>
|
||||||
indicator.id = 'triggerIndicator';
|
자동감지 ${isConnected ? 'ON' : 'OFF'}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indicator) {
|
// ═══════════════════════════════════════════════════════════════
|
||||||
indicator.innerHTML = `
|
// 자동인쇄 기능
|
||||||
<span style="
|
// ═══════════════════════════════════════════════════════════════
|
||||||
width: 8px;
|
let autoPrintEnabled = localStorage.getItem('pmr_auto_print') === 'true';
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
// 초기화
|
||||||
background: ${isConnected ? '#10b981' : '#ef4444'};
|
function initAutoPrint() {
|
||||||
"></span>
|
updateAutoPrintIndicator();
|
||||||
${isConnected ? '자동감지 ON' : '자동감지 OFF'}
|
}
|
||||||
|
|
||||||
|
// 토글
|
||||||
|
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() {
|
function playTriggerSound() {
|
||||||
try {
|
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