From 5074adce20647ce20887c18f84089739ed529fc1 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Wed, 4 Mar 2026 11:46:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ESC/POS=20=EC=98=81=EC=88=98=EC=A6=9D?= =?UTF-8?q?=20=ED=94=84=EB=A6=B0=ED=84=B0=EB=A1=9C=20=ED=8A=B9=EC=9D=B4?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=9D=B8=EC=87=84=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pos_printer.py: ESC/POS 유틸리티 (192.168.0.174:9100) - POST /api/print/cusetc API 추가 - admin.html: 특이사항 옆 [🖨️ 인쇄] 버튼 추가 - EUC-KR 인코딩으로 한글 지원 --- backend/app.py | 38 +++++++ backend/pos_printer.py | 208 +++++++++++++++++++++++++++++++++++ backend/templates/admin.html | 29 +++++ 3 files changed, 275 insertions(+) create mode 100644 backend/pos_printer.py diff --git a/backend/app.py b/backend/app.py index 7e86186..dd7d2e5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3929,6 +3929,44 @@ def api_update_cusetc(cuscode): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/print/cusetc', methods=['POST']) +def api_print_cusetc(): + """특이(참고)사항 영수증 인쇄 API""" + try: + from pos_printer import print_cusetc + + data = request.get_json() or {} + customer_name = data.get('customer_name', '').strip() + cusetc = data.get('cusetc', '').strip() + phone = data.get('phone', '').strip() + + if not customer_name: + return jsonify({'success': False, 'error': '고객 이름이 필요합니다.'}), 400 + + if not cusetc: + return jsonify({'success': False, 'error': '특이사항이 비어있습니다.'}), 400 + + # ESC/POS 프린터로 출력 + result = print_cusetc(customer_name, cusetc, phone) + + if result: + return jsonify({ + 'success': True, + 'message': f'{customer_name}님의 특이사항이 인쇄되었습니다.' + }) + else: + return jsonify({ + 'success': False, + 'error': '프린터 연결 실패. 프린터가 켜져있는지 확인하세요.' + }), 500 + + except ImportError: + return jsonify({'success': False, 'error': 'pos_printer 모듈을 찾을 수 없습니다.'}), 500 + except Exception as e: + logging.error(f"특이사항 인쇄 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/members/history/') def api_member_history(phone): """ diff --git a/backend/pos_printer.py b/backend/pos_printer.py new file mode 100644 index 0000000..bd43357 --- /dev/null +++ b/backend/pos_printer.py @@ -0,0 +1,208 @@ +# pos_printer.py - ESC/POS 영수증 프린터 유틸리티 +# 0bin-label-app/src/pos_settings_dialog.py 기반 + +import socket +import logging +from datetime import datetime + +# 프린터 설정 (config에서 불러올 수도 있음) +POS_PRINTER_IP = "192.168.0.174" +POS_PRINTER_PORT = 9100 +POS_PRINTER_NAME = "올댓포스 오른쪽" + +# ESC/POS 명령어 +ESC = b'\x1b' +GS = b'\x1d' + +# 기본 명령 +INIT = ESC + b'@' # 프린터 초기화 +CUT = ESC + b'd\x03' # 피드 + 커트 (원본 방식) +FEED = b'\n\n\n' # 줄바꿈 + +# 정렬 +ALIGN_LEFT = ESC + b'a\x00' +ALIGN_CENTER = ESC + b'a\x01' +ALIGN_RIGHT = ESC + b'a\x02' + +# 폰트 스타일 +BOLD_ON = ESC + b'E\x01' +BOLD_OFF = ESC + b'E\x00' +DOUBLE_HEIGHT = ESC + b'!\x10' +DOUBLE_WIDTH = ESC + b'!\x20' +DOUBLE_SIZE = ESC + b'!\x30' # 가로세로 2배 +NORMAL_SIZE = ESC + b'!\x00' + +# 로깅 +logging.basicConfig(level=logging.INFO) + + +def print_raw(data: bytes, ip: str = None, port: int = None) -> bool: + """ + ESC/POS 바이트 데이터를 프린터로 전송 + + Args: + data: ESC/POS 명령어 + 텍스트 바이트 + ip: 프린터 IP (기본값: POS_PRINTER_IP) + port: 프린터 포트 (기본값: POS_PRINTER_PORT) + + Returns: + bool: 성공 여부 + """ + ip = ip or POS_PRINTER_IP + port = port or POS_PRINTER_PORT + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + sock.sendall(data) + sock.close() + logging.info(f"[POS Printer] 전송 성공: {ip}:{port}") + return True + except socket.timeout: + logging.error(f"[POS Printer] 연결 시간 초과: {ip}:{port}") + return False + except ConnectionRefusedError: + logging.error(f"[POS Printer] 연결 거부됨: {ip}:{port}") + return False + except Exception as e: + logging.error(f"[POS Printer] 전송 실패: {e}") + return False + + +def print_text(text: str, cut: bool = True) -> bool: + """ + 텍스트를 영수증 프린터로 출력 + + Args: + text: 출력할 텍스트 (한글 지원) + cut: 출력 후 용지 커트 여부 + + Returns: + bool: 성공 여부 + """ + try: + # EUC-KR 인코딩 (한글 지원) + text_bytes = text.encode('euc-kr', errors='replace') + + # 명령어 조합 + command = INIT + text_bytes + b'\n\n\n' + if cut: + command += CUT + + return print_raw(command) + except Exception as e: + logging.error(f"[POS Printer] 텍스트 인쇄 실패: {e}") + return False + + +def print_cusetc(customer_name: str, cusetc: str, phone: str = None) -> bool: + """ + 특이(참고)사항 영수증 출력 + + Args: + customer_name: 고객 이름 + cusetc: 특이사항 내용 + phone: 전화번호 (선택) + + Returns: + bool: 성공 여부 + """ + now = datetime.now().strftime('%Y-%m-%d %H:%M') + + # 전화번호 포맷팅 + phone_display = "" + if phone: + phone_clean = phone.replace("-", "").replace(" ", "") + if len(phone_clean) == 11: + phone_display = f"{phone_clean[:3]}-{phone_clean[3:7]}-{phone_clean[7:]}" + else: + phone_display = phone + + try: + # ESC/POS 명령어 조합 + commands = bytearray() + commands.extend(INIT) + + # 헤더 (중앙 정렬, 크게) + commands.extend(ALIGN_CENTER) + commands.extend(DOUBLE_SIZE) + commands.extend("[ 특이사항 ]\n".encode('euc-kr')) + commands.extend(NORMAL_SIZE) + + # 구분선 + commands.extend("================================\n".encode('euc-kr')) + + # 고객 정보 (왼쪽 정렬) + commands.extend(ALIGN_LEFT) + commands.extend(BOLD_ON) + commands.extend(f"고객: {customer_name}\n".encode('euc-kr')) + commands.extend(BOLD_OFF) + + if phone_display: + commands.extend(f"연락처: {phone_display}\n".encode('euc-kr')) + + commands.extend(f"출력: {now}\n".encode('euc-kr')) + + # 구분선 + commands.extend("--------------------------------\n".encode('euc-kr')) + + # 특이사항 내용 (굵게) + commands.extend(BOLD_ON) + + # 긴 텍스트 줄바꿈 처리 (32자 기준) + lines = [] + for line in cusetc.split('\n'): + while len(line) > 32: + lines.append(line[:32]) + line = line[32:] + lines.append(line) + + for line in lines: + commands.extend(f"{line}\n".encode('euc-kr', errors='replace')) + + commands.extend(BOLD_OFF) + + # 하단 구분선 + commands.extend("================================\n".encode('euc-kr')) + + # 약국명 (중앙 정렬) + commands.extend(ALIGN_CENTER) + commands.extend("청춘약국\n".encode('euc-kr')) + + # 피드 + 커트 + commands.extend(b'\n\n\n') + commands.extend(CUT) + + return print_raw(bytes(commands)) + + except Exception as e: + logging.error(f"[POS Printer] 특이사항 인쇄 실패: {e}") + return False + + +def test_print() -> bool: + """테스트 인쇄""" + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + test_message = f""" +================================ + POS 프린터 테스트 +================================ + +IP: {POS_PRINTER_IP} +Port: {POS_PRINTER_PORT} +Time: {now} + +청춘약국 마일리지 시스템 +ESC/POS 정상 작동! +================================ +""" + return print_text(test_message, cut=True) + + +if __name__ == "__main__": + # 테스트 + print("POS 프린터 테스트 인쇄...") + result = test_print() + print(f"결과: {'성공' if result else '실패'}") diff --git a/backend/templates/admin.html b/backend/templates/admin.html index b43e6fe..1a51e7f 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -943,6 +943,34 @@ const editBtn = document.querySelector('#cusetc-view').parentElement.querySelector('button'); if (editBtn) editBtn.style.display = 'inline-block'; } + + // 특이사항 인쇄 + async function printCusetc(customerName, cusetc, phone) { + if (!confirm(`${customerName}님의 특이사항을 인쇄하시겠습니까?`)) { + return; + } + + try { + const res = await fetch('/api/print/cusetc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customer_name: customerName, + cusetc: cusetc, + phone: phone + }) + }); + const data = await res.json(); + + if (data.success) { + alert('🖨️ ' + data.message); + } else { + alert('❌ 인쇄 실패: ' + (data.error || '알 수 없는 오류')); + } + } catch (err) { + alert('❌ 오류: ' + err.message); + } + } function renderUserDetail(data) { // 전역 변수에 데이터 저장 @@ -987,6 +1015,7 @@
⚠️ 특이사항 + ${data.pos_customer.cusetc ? `` : ''}
${data.pos_customer.cusetc || '없음'}