feat: QR 프린터 선택 기능 추가 (Zebra + ESC/POS)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
5339204fca
commit
2ec73dd73d
@ -5,6 +5,7 @@ MSSQL SALE_MAIN 테이블에서 오늘 판매 내역을 조회하여 표시
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
@ -138,19 +139,24 @@ class QRGeneratorThread(QThread):
|
|||||||
"""
|
"""
|
||||||
qr_complete = pyqtSignal(bool, str, str) # 성공 여부, 메시지, 이미지 경로
|
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:
|
Args:
|
||||||
transaction_id (str): POS 거래 ID
|
transaction_id (str): POS 거래 ID
|
||||||
total_amount (float): 판매 금액
|
total_amount (float): 판매 금액
|
||||||
transaction_time (datetime): 거래 시간
|
transaction_time (datetime): 거래 시간
|
||||||
preview_mode (bool): 미리보기 모드
|
preview_mode (bool): 미리보기 모드
|
||||||
|
printer_mode (str): 프린터 모드 ('zebra' or 'pos')
|
||||||
|
pos_config (dict): POS 프린터 설정 {'ip': ..., 'port': ...}
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.transaction_id = transaction_id
|
self.transaction_id = transaction_id
|
||||||
self.total_amount = total_amount
|
self.total_amount = total_amount
|
||||||
self.transaction_time = transaction_time
|
self.transaction_time = transaction_time
|
||||||
self.preview_mode = preview_mode
|
self.preview_mode = preview_mode
|
||||||
|
self.printer_mode = printer_mode
|
||||||
|
self.pos_config = pos_config
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""스레드 실행"""
|
"""스레드 실행"""
|
||||||
@ -175,7 +181,9 @@ class QRGeneratorThread(QThread):
|
|||||||
self.qr_complete.emit(False, error, "")
|
self.qr_complete.emit(False, error, "")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. QR 라벨 생성
|
# 3. 프린터 모드별 인쇄
|
||||||
|
if self.printer_mode == 'zebra':
|
||||||
|
# Zebra 라벨 프린터
|
||||||
if self.preview_mode:
|
if self.preview_mode:
|
||||||
# 미리보기
|
# 미리보기
|
||||||
success, image_path = print_qr_label(
|
success, image_path = print_qr_label(
|
||||||
@ -209,11 +217,38 @@ class QRGeneratorThread(QThread):
|
|||||||
if success:
|
if success:
|
||||||
self.qr_complete.emit(
|
self.qr_complete.emit(
|
||||||
True,
|
True,
|
||||||
f"QR 출력 완료 ({token_info['claimable_points']}P)",
|
f"Zebra 라벨 출력 완료 ({token_info['claimable_points']}P)",
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.qr_complete.emit(False, "프린터 전송 실패", "")
|
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,
|
||||||
|
self.pos_config['ip'],
|
||||||
|
self.pos_config.get('port', 9100)
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.qr_complete.emit(
|
||||||
|
True,
|
||||||
|
f"POS 영수증 출력 완료 ({token_info['claimable_points']}P)",
|
||||||
|
''
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.qr_complete.emit(False, 'POS 프린터 인쇄 실패', '')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.qr_complete.emit(False, f"오류: {str(e)}", "")
|
self.qr_complete.emit(False, f"오류: {str(e)}", "")
|
||||||
@ -577,6 +612,43 @@ class POSSalesGUI(QMainWindow):
|
|||||||
self.preview_checkbox.setToolTip('체크: PNG 미리보기, 해제: 프린터 직접 출력')
|
self.preview_checkbox.setToolTip('체크: PNG 미리보기, 해제: 프린터 직접 출력')
|
||||||
settings_layout.addWidget(self.preview_checkbox)
|
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()
|
settings_layout.addStretch()
|
||||||
|
|
||||||
main_layout.addWidget(settings_group)
|
main_layout.addWidget(settings_group)
|
||||||
@ -782,6 +854,56 @@ class POSSalesGUI(QMainWindow):
|
|||||||
mileage_dialog = UserMileageDialog(phone, self)
|
mileage_dialog = UserMileageDialog(phone, self)
|
||||||
mileage_dialog.exec_()
|
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):
|
def generate_qr_label(self):
|
||||||
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
||||||
# 선택된 행 확인
|
# 선택된 행 확인
|
||||||
@ -816,18 +938,41 @@ class POSSalesGUI(QMainWindow):
|
|||||||
# 미리보기 모드 확인
|
# 미리보기 모드 확인
|
||||||
preview_mode = self.preview_checkbox.isChecked()
|
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 생성 스레드 시작
|
# QR 생성 스레드 시작
|
||||||
self.qr_thread = QRGeneratorThread(
|
self.qr_thread = QRGeneratorThread(
|
||||||
order_no,
|
order_no,
|
||||||
amount,
|
amount,
|
||||||
transaction_time,
|
transaction_time,
|
||||||
preview_mode
|
preview_mode,
|
||||||
|
printer_mode,
|
||||||
|
pos_config
|
||||||
)
|
)
|
||||||
self.qr_thread.qr_complete.connect(self.on_qr_generated)
|
self.qr_thread.qr_complete.connect(self.on_qr_generated)
|
||||||
self.qr_thread.start()
|
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.status_label.setStyleSheet('color: orange; font-size: 12px; padding: 5px;')
|
||||||
self.qr_btn.setEnabled(False)
|
self.qr_btn.setEnabled(False)
|
||||||
|
|
||||||
|
|||||||
146
backend/utils/pos_qr_printer.py
Normal file
146
backend/utils/pos_qr_printer.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user