From 2ec73dd73d2b3c98d701e52beff37f0c2a46aa77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=EA=B3=A8=EC=95=BD=EC=82=AC?= Date: Thu, 29 Jan 2026 20:00:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20QR=20=ED=94=84=EB=A6=B0=ED=84=B0=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(Zebra=20+=20ESC/POS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ESC/POS QR 영수증 인쇄 함수 추가 (pos_qr_printer.py) - QR 코드 이미지를 ESC/POS 비트맵 래스터로 변환 - 150x150px QR 코드 + 거래 정보 텍스트 인쇄 - EUC-KR 인코딩으로 한글 지원 - TCP 소켓으로 프린터 전송 - POS GUI에 프린터 선택 토글 버튼 추가 - 🖨️ Zebra 라벨 ⇄ 🖨️ POS 영수증 전환 - POS 모드 시 설정 버튼 표시 (IP/포트 설정) - 미리보기 모드는 Zebra 전용 - POSSettingsDialog 재사용 (pos_thermal.py 연동) - config.json에 POS 프린터 설정 저장 - 테스트 인쇄 기능 활용 - QRGeneratorThread 프린터 모드 지원 - printer_mode 매개변수 추가 ('zebra' or 'pos') - pos_config 설정 전달 - 프린터별 분기 처리 Co-Authored-By: Claude Sonnet 4.5 --- backend/gui/pos_sales_gui.py | 205 +++++++++++++++++++++++++++----- backend/utils/pos_qr_printer.py | 146 +++++++++++++++++++++++ 2 files changed, 321 insertions(+), 30 deletions(-) create mode 100644 backend/utils/pos_qr_printer.py diff --git a/backend/gui/pos_sales_gui.py b/backend/gui/pos_sales_gui.py index 3f946c2..03ced60 100644 --- a/backend/gui/pos_sales_gui.py +++ b/backend/gui/pos_sales_gui.py @@ -5,6 +5,7 @@ MSSQL SALE_MAIN 테이블에서 오늘 판매 내역을 조회하여 표시 import sys import os +import json from datetime import datetime from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, @@ -138,19 +139,24 @@ class QRGeneratorThread(QThread): """ qr_complete = pyqtSignal(bool, str, str) # 성공 여부, 메시지, 이미지 경로 - def __init__(self, transaction_id, total_amount, transaction_time, preview_mode=False): + def __init__(self, transaction_id, total_amount, transaction_time, preview_mode=False, + printer_mode='zebra', pos_config=None): """ Args: transaction_id (str): POS 거래 ID total_amount (float): 판매 금액 transaction_time (datetime): 거래 시간 preview_mode (bool): 미리보기 모드 + printer_mode (str): 프린터 모드 ('zebra' or 'pos') + pos_config (dict): POS 프린터 설정 {'ip': ..., 'port': ...} """ super().__init__() self.transaction_id = transaction_id self.total_amount = total_amount self.transaction_time = transaction_time self.preview_mode = preview_mode + self.printer_mode = printer_mode + self.pos_config = pos_config def run(self): """스레드 실행""" @@ -175,45 +181,74 @@ class QRGeneratorThread(QThread): self.qr_complete.emit(False, error, "") return - # 3. QR 라벨 생성 - if self.preview_mode: - # 미리보기 - success, image_path = print_qr_label( + # 3. 프린터 모드별 인쇄 + if self.printer_mode == 'zebra': + # Zebra 라벨 프린터 + if self.preview_mode: + # 미리보기 + success, image_path = print_qr_label( + token_info['qr_url'], + self.transaction_id, + self.total_amount, + token_info['claimable_points'], + self.transaction_time, + preview_mode=True + ) + + if success: + self.qr_complete.emit( + True, + f"QR 생성 완료 ({token_info['claimable_points']}P)", + image_path + ) + else: + self.qr_complete.emit(False, "이미지 생성 실패", "") + else: + # 실제 인쇄 + success = print_qr_label( + token_info['qr_url'], + self.transaction_id, + self.total_amount, + token_info['claimable_points'], + self.transaction_time, + preview_mode=False + ) + + if success: + self.qr_complete.emit( + True, + f"Zebra 라벨 출력 완료 ({token_info['claimable_points']}P)", + "" + ) + else: + self.qr_complete.emit(False, "Zebra 프린터 전송 실패", "") + + elif self.printer_mode == 'pos': + # POS 영수증 프린터 + from utils.pos_qr_printer import print_qr_receipt_escpos + + if not self.pos_config or not self.pos_config.get('ip'): + self.qr_complete.emit(False, 'POS 프린터 설정이 필요합니다', '') + return + + success = print_qr_receipt_escpos( token_info['qr_url'], self.transaction_id, self.total_amount, token_info['claimable_points'], self.transaction_time, - preview_mode=True + self.pos_config['ip'], + self.pos_config.get('port', 9100) ) if success: self.qr_complete.emit( True, - f"QR 생성 완료 ({token_info['claimable_points']}P)", - image_path + f"POS 영수증 출력 완료 ({token_info['claimable_points']}P)", + '' ) else: - self.qr_complete.emit(False, "이미지 생성 실패", "") - else: - # 실제 인쇄 - success = print_qr_label( - token_info['qr_url'], - self.transaction_id, - self.total_amount, - token_info['claimable_points'], - self.transaction_time, - preview_mode=False - ) - - if success: - self.qr_complete.emit( - True, - f"QR 출력 완료 ({token_info['claimable_points']}P)", - "" - ) - else: - self.qr_complete.emit(False, "프린터 전송 실패", "") + self.qr_complete.emit(False, 'POS 프린터 인쇄 실패', '') except Exception as e: self.qr_complete.emit(False, f"오류: {str(e)}", "") @@ -577,6 +612,43 @@ class POSSalesGUI(QMainWindow): self.preview_checkbox.setToolTip('체크: PNG 미리보기, 해제: 프린터 직접 출력') settings_layout.addWidget(self.preview_checkbox) + # === 프린터 선택 토글 버튼 추가 === + self.printer_toggle = QPushButton('🖨️ Zebra 라벨') + self.printer_toggle.setCheckable(True) + self.printer_toggle.setChecked(True) # 기본값: Zebra + self.printer_toggle.setToolTip('클릭하여 프린터 전환\n✓ Zebra QL-810W 라벨\n✗ POS 영수증 프린터') + self.printer_toggle.clicked.connect(self.toggle_printer_mode) + self.printer_toggle.setStyleSheet(""" + QPushButton { + padding: 8px 16px; + font-weight: bold; + border: 2px solid #2196F3; + border-radius: 4px; + background-color: #E3F2FD; + color: #1976D2; + } + QPushButton:checked { + background-color: #FFF3E0; + border-color: #FF9800; + color: #E65100; + } + QPushButton:hover { + background-color: #BBDEFB; + } + QPushButton:checked:hover { + background-color: #FFE0B2; + } + """) + settings_layout.addWidget(self.printer_toggle) + + # POS 프린터 설정 버튼 (토글이 POS일 때만 표시) + self.pos_settings_btn = QPushButton('⚙️ POS 설정') + self.pos_settings_btn.setVisible(False) + self.pos_settings_btn.clicked.connect(self.open_pos_settings) + self.pos_settings_btn.setToolTip('POS 프린터 IP/포트 설정') + self.pos_settings_btn.setStyleSheet('background-color: #9E9E9E; color: white; padding: 8px; font-weight: bold;') + settings_layout.addWidget(self.pos_settings_btn) + settings_layout.addStretch() main_layout.addWidget(settings_group) @@ -782,6 +854,56 @@ class POSSalesGUI(QMainWindow): mileage_dialog = UserMileageDialog(phone, self) mileage_dialog.exec_() + def toggle_printer_mode(self): + """프린터 모드 토글""" + is_zebra = self.printer_toggle.isChecked() + + if is_zebra: + # Zebra 모드 + self.printer_toggle.setText('🖨️ Zebra 라벨') + self.pos_settings_btn.setVisible(False) + self.preview_checkbox.setEnabled(True) # 미리보기 활성화 + else: + # POS 모드 + self.printer_toggle.setText('🖨️ POS 영수증') + self.pos_settings_btn.setVisible(True) + self.preview_checkbox.setChecked(False) # 미리보기 비활성화 + self.preview_checkbox.setEnabled(False) # ESC/POS는 미리보기 불가 + + # POS 설정 확인 + if not self.check_pos_printer_config(): + QMessageBox.warning( + self, '설정 필요', + 'POS 프린터 설정이 필요합니다.\n' + '[POS 설정] 버튼을 클릭하여 IP/포트를 입력하세요.' + ) + + def open_pos_settings(self): + """POS 프린터 설정 다이얼로그 열기""" + from pos_thermal import POSSettingsDialog + + dialog = POSSettingsDialog(self) + if dialog.exec_() == QDialog.Accepted: + self.status_label.setText('POS 프린터 설정이 저장되었습니다.') + + def check_pos_printer_config(self): + """POS 프린터 설정 확인""" + config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json') + + if not os.path.exists(config_path): + return False + + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + pos_config = config.get('pos_printer', {}) + ip = pos_config.get('ip') + + return bool(ip) + except: + return False + def generate_qr_label(self): """선택된 판매 건에 대해 QR 라벨 생성""" # 선택된 행 확인 @@ -816,18 +938,41 @@ class POSSalesGUI(QMainWindow): # 미리보기 모드 확인 preview_mode = self.preview_checkbox.isChecked() + # 프린터 모드 확인 + is_zebra = self.printer_toggle.isChecked() + printer_mode = 'zebra' if is_zebra else 'pos' + + # POS 모드일 때 설정 로드 + pos_config = None + if printer_mode == 'pos': + config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json') + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + pos_config = config.get('pos_printer', {}) + + if not pos_config.get('ip'): + QMessageBox.warning(self, '설정 오류', 'POS 프린터 IP가 설정되지 않았습니다.\n[POS 설정] 버튼을 클릭하세요.') + return + except Exception as e: + QMessageBox.warning(self, '설정 오류', f'POS 프린터 설정을 불러올 수 없습니다.\n{str(e)}') + return + # QR 생성 스레드 시작 self.qr_thread = QRGeneratorThread( order_no, amount, transaction_time, - preview_mode + preview_mode, + printer_mode, + pos_config ) self.qr_thread.qr_complete.connect(self.on_qr_generated) self.qr_thread.start() # 상태 표시 - self.status_label.setText(f'QR 생성 중... ({order_no})') + printer_name = 'Zebra 라벨' if printer_mode == 'zebra' else 'POS 영수증' + self.status_label.setText(f'QR 생성 중... ({order_no}) - {printer_name}') self.status_label.setStyleSheet('color: orange; font-size: 12px; padding: 5px;') self.qr_btn.setEnabled(False) diff --git a/backend/utils/pos_qr_printer.py b/backend/utils/pos_qr_printer.py new file mode 100644 index 0000000..35b7606 --- /dev/null +++ b/backend/utils/pos_qr_printer.py @@ -0,0 +1,146 @@ +# pos_qr_printer.py +# ESC/POS 프린터로 QR 영수증 인쇄 + +import socket +import qrcode +from PIL import Image +from datetime import datetime + + +def print_qr_receipt_escpos(qr_url, transaction_id, total_amount, + claimable_points, transaction_time, + printer_ip, printer_port=9100): + """ + ESC/POS 프린터로 QR 영수증 인쇄 + + Args: + qr_url (str): QR 코드 URL + transaction_id (str): 거래번호 + total_amount (float): 판매 금액 + claimable_points (int): 적립 예정 포인트 + transaction_time (datetime): 거래 시간 + printer_ip (str): 프린터 IP 주소 + printer_port (int): 프린터 포트 (기본 9100) + + Returns: + bool: 성공 여부 + """ + + try: + # 1. QR 코드 이미지 생성 (150x150px) + qr = qrcode.QRCode(version=1, box_size=5, border=2) + qr.add_data(qr_url) + qr.make(fit=True) + qr_image = qr.make_image(fill_color="black", back_color="white") + qr_image = qr_image.resize((150, 150)) + + # 2. QR 이미지를 ESC/POS 비트맵으로 변환 + qr_bitmap = image_to_raster(qr_image) + + # 3. ESC/POS 명령어 조립 + ESC = b'\x1b' + GS = b'\x1d' + + commands = [] + commands.append(ESC + b'@') # 프린터 초기화 + commands.append(ESC + b'a\x01') # 중앙 정렬 + + # 헤더 + commands.append(ESC + b'!\x10') # 크게 + commands.append("청춘약국\n".encode('euc-kr')) + commands.append(ESC + b'!\x00') # 보통 + + commands.append("================================\n".encode('euc-kr')) + + # 거래 정보 + date_str = transaction_time.strftime('%Y-%m-%d %H:%M') + commands.append(f"거래일시: {date_str}\n".encode('euc-kr')) + commands.append(f"거래번호: {transaction_id}\n".encode('euc-kr')) + commands.append("\n".encode('euc-kr')) + + # 금액 정보 + commands.append(ESC + b'!\x10') # 크게 + commands.append(f"결제금액: {total_amount:,.0f}원\n".encode('euc-kr')) + commands.append(f"적립예정: {claimable_points:,}P\n".encode('euc-kr')) + commands.append(ESC + b'!\x00') # 보통 + + commands.append("================================\n".encode('euc-kr')) + commands.append("\n".encode('euc-kr')) + + # QR 코드 인쇄 + commands.append(qr_bitmap) # QR 비트맵 데이터 + commands.append("\n".encode('euc-kr')) + + # 안내 문구 + commands.append(ESC + b'!\x08') # 작게 + commands.append("QR 촬영하고 포인트 받으세요!\n".encode('euc-kr')) + commands.append(ESC + b'!\x00') # 보통 + + commands.append("\n\n\n".encode('euc-kr')) + commands.append(GS + b'V\x00') # 용지 커트 + + # 4. TCP 소켓으로 전송 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((printer_ip, printer_port)) + sock.sendall(b''.join(commands)) + sock.close() + + print(f"[ESC/POS] 인쇄 완료: {printer_ip}:{printer_port}") + return True + + except socket.timeout: + print(f"[ESC/POS] 연결 시간 초과: {printer_ip}:{printer_port}") + return False + except ConnectionRefusedError: + print(f"[ESC/POS] 연결 거부됨: {printer_ip}:{printer_port}") + return False + except Exception as e: + print(f"[ESC/POS] 인쇄 오류: {e}") + return False + + +def image_to_raster(image): + """ + PIL 이미지를 ESC/POS 비트맵 래스터 데이터로 변환 + + ESC/POS GS v 0 명령어 사용: + GS v 0 m xL xH yL yH d1...dk + + Args: + image (PIL.Image): PIL 이미지 객체 + + Returns: + bytes: ESC/POS 래스터 비트맵 명령어 + """ + # 이미지를 흑백으로 변환 + image = image.convert('1') # 1-bit 흑백 + width, height = image.size + + # 바이트 정렬 (8픽셀 = 1바이트) + width_bytes = (width + 7) // 8 + + # 헤더 생성 + GS = b'\x1d' + cmd = GS + b'v0' # 래스터 비트맵 모드 + cmd += b'\x00' # 보통 모드 + cmd += bytes([width_bytes & 0xFF, (width_bytes >> 8) & 0xFF]) # xL, xH + cmd += bytes([height & 0xFF, (height >> 8) & 0xFF]) # yL, yH + + # 이미지 데이터 변환 + pixels = image.load() + data = [] + + for y in range(height): + line = [] + for x in range(0, width, 8): + byte = 0 + for bit in range(8): + if x + bit < width: + if pixels[x + bit, y] == 0: # 검은색 + byte |= (1 << (7 - bit)) + line.append(byte) + data.extend(line) + + cmd += bytes(data) + return cmd