feat: 더미 POS GUI 및 영수증 프린터 설정 추가
- 바코드 스캔 → 제품 조회 → 장바구니 → 결제 흐름의 더미 POS GUI 추가 - ESC/POS 영수증 프린터 설정 다이얼로그 추가 - barcode_reader_gui.py dbsetup import 경로 수정 - POS 프린터 config.json 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
68ad59285a
commit
e499e19342
7
backend/config.json
Normal file
7
backend/config.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"pos_printer": {
|
||||||
|
"ip": "192.168.0.174",
|
||||||
|
"port": 9100,
|
||||||
|
"name": "메인 POS"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
backend/gui/pos_thermal.py
Normal file
222
backend/gui/pos_thermal.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
# pos_settings_dialog.py
|
||||||
|
# POS 영수증 프린터 설정 다이얼로그
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
|
QLineEdit, QFormLayout, QMessageBox
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class POSSettingsDialog(QDialog):
|
||||||
|
"""POS 영수증 프린터 설정"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
|
||||||
|
self.setWindowTitle("POS 영수증 프린터 설정")
|
||||||
|
self.setMinimumSize(500, 300)
|
||||||
|
self.init_ui()
|
||||||
|
self.load_settings()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 제목
|
||||||
|
title = QLabel("POS 영수증 프린터 설정")
|
||||||
|
title.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
# 설명
|
||||||
|
desc = QLabel("ESC/POS 프로토콜을 지원하는 영수증 프린터 설정\n올댓포스 AGENT가 설치된 PC IP를 입력하세요")
|
||||||
|
desc.setStyleSheet("color: gray; margin-bottom: 20px;")
|
||||||
|
layout.addWidget(desc)
|
||||||
|
|
||||||
|
# 폼 레이아웃
|
||||||
|
form_layout = QFormLayout()
|
||||||
|
|
||||||
|
# IP 주소
|
||||||
|
self.ip_input = QLineEdit()
|
||||||
|
self.ip_input.setPlaceholderText("예: 192.168.0.174")
|
||||||
|
form_layout.addRow("IP 주소 *", self.ip_input)
|
||||||
|
|
||||||
|
# 포트
|
||||||
|
self.port_input = QLineEdit()
|
||||||
|
self.port_input.setText("9100")
|
||||||
|
form_layout.addRow("포트", self.port_input)
|
||||||
|
|
||||||
|
# 프린터 이름
|
||||||
|
self.name_input = QLineEdit()
|
||||||
|
self.name_input.setPlaceholderText("예: 메인 POS 프린터")
|
||||||
|
form_layout.addRow("프린터 이름", self.name_input)
|
||||||
|
|
||||||
|
layout.addLayout(form_layout)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# 버튼들
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.test_button = QPushButton("테스트 인쇄")
|
||||||
|
self.test_button.clicked.connect(self.test_print)
|
||||||
|
self.test_button.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #2196F3;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #1976D2;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
button_layout.addWidget(self.test_button)
|
||||||
|
|
||||||
|
button_layout.addStretch()
|
||||||
|
|
||||||
|
self.cancel_button = QPushButton("취소")
|
||||||
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
|
button_layout.addWidget(self.cancel_button)
|
||||||
|
|
||||||
|
self.save_button = QPushButton("저장")
|
||||||
|
self.save_button.clicked.connect(self.save_settings)
|
||||||
|
self.save_button.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
button_layout.addWidget(self.save_button)
|
||||||
|
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def load_settings(self):
|
||||||
|
"""설정 불러오기"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.config_path):
|
||||||
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
pos_config = config.get('pos_printer', {})
|
||||||
|
self.ip_input.setText(pos_config.get('ip', ''))
|
||||||
|
self.port_input.setText(str(pos_config.get('port', 9100)))
|
||||||
|
self.name_input.setText(pos_config.get('name', ''))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[POS Settings] 설정 로드 오류: {e}")
|
||||||
|
|
||||||
|
def save_settings(self):
|
||||||
|
"""설정 저장"""
|
||||||
|
ip = self.ip_input.text().strip()
|
||||||
|
port = self.port_input.text().strip()
|
||||||
|
name = self.name_input.text().strip()
|
||||||
|
|
||||||
|
# 유효성 검사
|
||||||
|
if not ip:
|
||||||
|
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
port_num = int(port)
|
||||||
|
except ValueError:
|
||||||
|
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 설정 저장
|
||||||
|
try:
|
||||||
|
config = {}
|
||||||
|
if os.path.exists(self.config_path):
|
||||||
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
config['pos_printer'] = {
|
||||||
|
'ip': ip,
|
||||||
|
'port': port_num,
|
||||||
|
'name': name if name else f"POS Printer ({ip})"
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
QMessageBox.information(self, "성공", "POS 프린터 설정이 저장되었습니다.")
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "오류", f"설정 저장 실패: {str(e)}")
|
||||||
|
|
||||||
|
def test_print(self):
|
||||||
|
"""테스트 인쇄"""
|
||||||
|
ip = self.ip_input.text().strip()
|
||||||
|
port = self.port_input.text().strip()
|
||||||
|
|
||||||
|
if not ip:
|
||||||
|
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
port_num = int(port)
|
||||||
|
except ValueError:
|
||||||
|
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ESC/POS 테스트 인쇄
|
||||||
|
try:
|
||||||
|
# ESC/POS 명령어
|
||||||
|
ESC = b'\x1b'
|
||||||
|
INIT = ESC + b'@' # 프린터 초기화
|
||||||
|
CUT = ESC + b'd\x03' # 용지 커트
|
||||||
|
|
||||||
|
# 테스트 메시지
|
||||||
|
message = f"""
|
||||||
|
================================
|
||||||
|
POS 프린터 테스트!
|
||||||
|
================================
|
||||||
|
|
||||||
|
IP: {ip}
|
||||||
|
Port: {port_num}
|
||||||
|
Time: {time.strftime('%Y-%m-%d %H:%M:%S')}
|
||||||
|
|
||||||
|
ESC/POS 명령으로 인쇄됨
|
||||||
|
정상 작동 확인!
|
||||||
|
================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
# EUC-KR 인코딩 (한글 지원)
|
||||||
|
message_bytes = message.encode('euc-kr')
|
||||||
|
command = INIT + message_bytes + b'\n\n\n' + CUT
|
||||||
|
|
||||||
|
# TCP 소켓으로 전송
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(5)
|
||||||
|
sock.connect((ip, port_num))
|
||||||
|
sock.sendall(command)
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "성공",
|
||||||
|
f"테스트 인쇄 명령을 전송했습니다!\n\n"
|
||||||
|
f"IP: {ip}:{port_num}\n\n"
|
||||||
|
f"POS 프린터에서 영수증 출력을 확인하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
except socket.timeout:
|
||||||
|
QMessageBox.warning(self, "실패", f"연결 시간 초과\n\n프린터가 켜져있는지 확인하세요.")
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
QMessageBox.warning(self, "실패", f"연결 거부됨\n\nIP 주소와 포트를 확인하세요.")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
QMessageBox.warning(self, "인코딩 오류", "EUC-KR로 인코딩할 수 없는 문자가 있습니다.")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "실패", f"테스트 인쇄 실패\n\n{type(e).__name__}: {str(e)}")
|
||||||
@ -5,6 +5,7 @@ MSSQL DB에서 약품 정보 조회 기능 포함
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import serial
|
import serial
|
||||||
import serial.tools.list_ports
|
import serial.tools.list_ports
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -19,6 +20,8 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
# MSSQL 데이터베이스 연결
|
# MSSQL 데이터베이스 연결
|
||||||
sys.path.insert(0, '.')
|
sys.path.insert(0, '.')
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
from dbsetup import DatabaseManager
|
from dbsetup import DatabaseManager
|
||||||
|
|
||||||
# 바코드 라벨 출력
|
# 바코드 라벨 출력
|
||||||
|
|||||||
713
backend/samples/pos_dummy_gui.py
Normal file
713
backend/samples/pos_dummy_gui.py
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
"""
|
||||||
|
더미 POS 시스템 GUI (PyQt5)
|
||||||
|
바코드 스캐너로 제품을 추가하고 수량 조절, 할인 적용, 결제까지 지원
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
from datetime import datetime
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
|
QPushButton, QLabel, QGroupBox, QComboBox, QSpinBox,
|
||||||
|
QTableWidget, QTableWidgetItem, QHeaderView, QFrame,
|
||||||
|
QLineEdit, QDialog, QFormLayout, QDoubleSpinBox, QMessageBox,
|
||||||
|
QAbstractItemView, QCheckBox, QSplitter
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||||
|
from PyQt5.QtGui import QFont, QColor, QBrush, QIcon
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# DB 연결
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
from dbsetup import DatabaseManager
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GS1 바코드 파싱 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_gs1_barcode(barcode):
|
||||||
|
candidates = [barcode]
|
||||||
|
if barcode.startswith('01') and len(barcode) >= 16:
|
||||||
|
gtin14 = barcode[2:16]
|
||||||
|
candidates.append(gtin14)
|
||||||
|
if gtin14.startswith('0'):
|
||||||
|
candidates.append(gtin14[1:])
|
||||||
|
elif barcode.startswith('01') and len(barcode) == 15:
|
||||||
|
candidates.append(barcode[2:15])
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def search_drug_by_barcode(barcode):
|
||||||
|
try:
|
||||||
|
db_manager = DatabaseManager()
|
||||||
|
engine = db_manager.get_engine('PM_DRUG')
|
||||||
|
query = text('''
|
||||||
|
SELECT TOP 1
|
||||||
|
BARCODE, GoodsName, DrugCode, SplName,
|
||||||
|
Price, Saleprice, SUNG_CODE, IsUSE
|
||||||
|
FROM CD_GOODS
|
||||||
|
WHERE BARCODE = :barcode
|
||||||
|
AND (GoodsName NOT LIKE N'%(판매중지)%'
|
||||||
|
AND GoodsName NOT LIKE N'%(판매중단)%')
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END,
|
||||||
|
CASE WHEN Price > 0 THEN 0 ELSE 1 END,
|
||||||
|
CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END,
|
||||||
|
DrugCode DESC
|
||||||
|
''')
|
||||||
|
candidates = parse_gs1_barcode(barcode)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for candidate in candidates:
|
||||||
|
result = conn.execute(query, {"barcode": candidate})
|
||||||
|
row = result.fetchone()
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
'barcode': row.BARCODE,
|
||||||
|
'goods_name': row.GoodsName,
|
||||||
|
'drug_code': row.DrugCode,
|
||||||
|
'manufacturer': row.SplName or '',
|
||||||
|
'price': float(row.Price) if row.Price else 0,
|
||||||
|
'sale_price': float(row.Saleprice) if row.Saleprice else 0,
|
||||||
|
'sung_code': row.SUNG_CODE or ''
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[오류] 약품 조회 실패: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 바코드 리더 스레드 ────────────────────────────────────────
|
||||||
|
|
||||||
|
class BarcodeReaderThread(QThread):
|
||||||
|
barcode_received = pyqtSignal(str)
|
||||||
|
connection_status = pyqtSignal(bool, str)
|
||||||
|
|
||||||
|
def __init__(self, port='COM3', baudrate=115200):
|
||||||
|
super().__init__()
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.running = False
|
||||||
|
self.serial_connection = None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.running = True
|
||||||
|
try:
|
||||||
|
self.serial_connection = serial.Serial(
|
||||||
|
port=self.port, baudrate=self.baudrate,
|
||||||
|
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE, timeout=1
|
||||||
|
)
|
||||||
|
self.connection_status.emit(True, f'{self.port} 연결됨 ({self.baudrate} bps)')
|
||||||
|
while self.running:
|
||||||
|
if self.serial_connection.in_waiting > 0:
|
||||||
|
data = self.serial_connection.read(self.serial_connection.in_waiting)
|
||||||
|
try:
|
||||||
|
text_data = data.decode('utf-8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text_data = data.decode('ascii', errors='ignore')
|
||||||
|
for line in text_data.strip().split('\n'):
|
||||||
|
barcode = line.strip()
|
||||||
|
if barcode and len(barcode) in [13, 15, 16]:
|
||||||
|
self.barcode_received.emit(barcode)
|
||||||
|
except serial.SerialException as e:
|
||||||
|
self.connection_status.emit(False, f'연결 실패: {e}')
|
||||||
|
except Exception as e:
|
||||||
|
self.connection_status.emit(False, f'오류: {e}')
|
||||||
|
finally:
|
||||||
|
if self.serial_connection and self.serial_connection.is_open:
|
||||||
|
self.serial_connection.close()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
if self.serial_connection and self.serial_connection.is_open:
|
||||||
|
self.serial_connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
class DrugSearchThread(QThread):
|
||||||
|
search_complete = pyqtSignal(str, object)
|
||||||
|
|
||||||
|
def __init__(self, barcode):
|
||||||
|
super().__init__()
|
||||||
|
self.barcode = barcode
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
info = search_drug_by_barcode(self.barcode)
|
||||||
|
self.search_complete.emit(self.barcode, info)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 할인 다이얼로그 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class DiscountDialog(QDialog):
|
||||||
|
def __init__(self, item_name, current_price, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(f'할인 적용 - {item_name}')
|
||||||
|
self.setMinimumWidth(350)
|
||||||
|
self.result_discount = 0
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
info = QLabel(f'제품: {item_name}\n판매가: {current_price:,.0f}원')
|
||||||
|
info.setStyleSheet('font-size: 14px; padding: 10px;')
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.discount_type = QComboBox()
|
||||||
|
self.discount_type.addItems(['금액 할인 (원)', '비율 할인 (%)'])
|
||||||
|
form.addRow('할인 방식:', self.discount_type)
|
||||||
|
|
||||||
|
self.discount_value = QDoubleSpinBox()
|
||||||
|
self.discount_value.setMaximum(999999)
|
||||||
|
self.discount_value.setDecimals(0)
|
||||||
|
form.addRow('할인값:', self.discount_value)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
self.preview_label = QLabel('')
|
||||||
|
self.preview_label.setStyleSheet('font-size: 13px; color: #E53935; padding: 10px; font-weight: bold;')
|
||||||
|
layout.addWidget(self.preview_label)
|
||||||
|
|
||||||
|
self.discount_value.valueChanged.connect(
|
||||||
|
lambda: self._update_preview(current_price))
|
||||||
|
self.discount_type.currentIndexChanged.connect(
|
||||||
|
lambda: self._update_preview(current_price))
|
||||||
|
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
ok_btn = QPushButton('적용')
|
||||||
|
ok_btn.setStyleSheet('background: #4CAF50; color: white; font-weight: bold; padding: 8px 24px;')
|
||||||
|
ok_btn.clicked.connect(lambda: self._apply(current_price))
|
||||||
|
cancel_btn = QPushButton('취소')
|
||||||
|
cancel_btn.setStyleSheet('padding: 8px 24px;')
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_layout.addWidget(cancel_btn)
|
||||||
|
btn_layout.addWidget(ok_btn)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def _update_preview(self, price):
|
||||||
|
val = self.discount_value.value()
|
||||||
|
if self.discount_type.currentIndex() == 0:
|
||||||
|
disc = val
|
||||||
|
else:
|
||||||
|
disc = price * val / 100
|
||||||
|
final = max(0, price - disc)
|
||||||
|
self.preview_label.setText(f'할인: -{disc:,.0f}원 → 최종가: {final:,.0f}원')
|
||||||
|
|
||||||
|
def _apply(self, price):
|
||||||
|
val = self.discount_value.value()
|
||||||
|
if self.discount_type.currentIndex() == 0:
|
||||||
|
self.result_discount = val
|
||||||
|
else:
|
||||||
|
self.result_discount = price * val / 100
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 메인 POS GUI ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class POSDummyGUI(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.reader_thread = None
|
||||||
|
self.search_threads = []
|
||||||
|
self.cart_items = [] # [{barcode, goods_name, manufacturer, price, sale_price, qty, discount}]
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
self.setWindowTitle('청춘약국 POS')
|
||||||
|
self.setGeometry(50, 50, 1200, 800)
|
||||||
|
self.setStyleSheet('''
|
||||||
|
QMainWindow { background: #F5F5F5; }
|
||||||
|
QGroupBox {
|
||||||
|
font-weight: bold; font-size: 13px;
|
||||||
|
border: 1px solid #E0E0E0; border-radius: 6px;
|
||||||
|
margin-top: 12px; padding-top: 18px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
QGroupBox::title {
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
left: 12px; padding: 0 6px;
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
root_layout = QVBoxLayout()
|
||||||
|
root_layout.setContentsMargins(12, 8, 12, 8)
|
||||||
|
central.setLayout(root_layout)
|
||||||
|
|
||||||
|
# ── 상단: 연결 설정 ──
|
||||||
|
conn_group = QGroupBox('스캐너 연결')
|
||||||
|
conn_layout = QHBoxLayout()
|
||||||
|
conn_group.setLayout(conn_layout)
|
||||||
|
|
||||||
|
conn_layout.addWidget(QLabel('포트:'))
|
||||||
|
self.port_combo = QComboBox()
|
||||||
|
self.port_combo.setMinimumWidth(200)
|
||||||
|
self._refresh_ports()
|
||||||
|
conn_layout.addWidget(self.port_combo)
|
||||||
|
|
||||||
|
refresh_btn = QPushButton('⟳')
|
||||||
|
refresh_btn.setFixedWidth(36)
|
||||||
|
refresh_btn.clicked.connect(self._refresh_ports)
|
||||||
|
conn_layout.addWidget(refresh_btn)
|
||||||
|
|
||||||
|
conn_layout.addWidget(QLabel('속도:'))
|
||||||
|
self.baudrate_spin = QSpinBox()
|
||||||
|
self.baudrate_spin.setRange(9600, 921600)
|
||||||
|
self.baudrate_spin.setValue(115200)
|
||||||
|
self.baudrate_spin.setSingleStep(9600)
|
||||||
|
conn_layout.addWidget(self.baudrate_spin)
|
||||||
|
|
||||||
|
self.connect_btn = QPushButton('연결')
|
||||||
|
self.connect_btn.setStyleSheet(
|
||||||
|
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||||
|
self.connect_btn.clicked.connect(self._toggle_connection)
|
||||||
|
conn_layout.addWidget(self.connect_btn)
|
||||||
|
|
||||||
|
self.status_label = QLabel('대기 중')
|
||||||
|
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||||
|
conn_layout.addWidget(self.status_label)
|
||||||
|
conn_layout.addStretch()
|
||||||
|
|
||||||
|
# 수동 바코드 입력
|
||||||
|
conn_layout.addWidget(QLabel('수동입력:'))
|
||||||
|
self.manual_input = QLineEdit()
|
||||||
|
self.manual_input.setPlaceholderText('바코드 번호 입력 후 Enter')
|
||||||
|
self.manual_input.setMinimumWidth(180)
|
||||||
|
self.manual_input.returnPressed.connect(self._manual_barcode)
|
||||||
|
conn_layout.addWidget(self.manual_input)
|
||||||
|
|
||||||
|
root_layout.addWidget(conn_group)
|
||||||
|
|
||||||
|
# ── 중앙: 장바구니 테이블 + 우측 요약 ──
|
||||||
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
|
||||||
|
# 장바구니 테이블
|
||||||
|
cart_group = QGroupBox('장바구니')
|
||||||
|
cart_layout = QVBoxLayout()
|
||||||
|
cart_group.setLayout(cart_layout)
|
||||||
|
|
||||||
|
self.cart_table = QTableWidget()
|
||||||
|
self.cart_table.setColumnCount(8)
|
||||||
|
self.cart_table.setHorizontalHeaderLabels([
|
||||||
|
'제품명', '제조사', '바코드', '입고가', '판매가', '수량', '할인', '소계'
|
||||||
|
])
|
||||||
|
header = self.cart_table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
for i in [1]:
|
||||||
|
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||||
|
for i in [2, 3, 4, 5, 6, 7]:
|
||||||
|
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||||
|
|
||||||
|
self.cart_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.cart_table.setAlternatingRowColors(True)
|
||||||
|
self.cart_table.setStyleSheet('''
|
||||||
|
QTableWidget {
|
||||||
|
font-size: 13px; gridline-color: #E0E0E0;
|
||||||
|
alternate-background-color: #FAFAFA;
|
||||||
|
}
|
||||||
|
QHeaderView::section {
|
||||||
|
background: #37474F; color: white;
|
||||||
|
font-weight: bold; font-size: 12px;
|
||||||
|
padding: 6px; border: none;
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
self.cart_table.verticalHeader().setVisible(False)
|
||||||
|
cart_layout.addWidget(self.cart_table)
|
||||||
|
|
||||||
|
# 장바구니 아래 버튼들
|
||||||
|
cart_btn_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
qty_up_btn = QPushButton('+1')
|
||||||
|
qty_up_btn.setStyleSheet(
|
||||||
|
'background: #2196F3; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||||
|
qty_up_btn.clicked.connect(lambda: self._change_qty(1))
|
||||||
|
cart_btn_layout.addWidget(qty_up_btn)
|
||||||
|
|
||||||
|
qty_down_btn = QPushButton('-1')
|
||||||
|
qty_down_btn.setStyleSheet(
|
||||||
|
'background: #FF9800; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||||
|
qty_down_btn.clicked.connect(lambda: self._change_qty(-1))
|
||||||
|
cart_btn_layout.addWidget(qty_down_btn)
|
||||||
|
|
||||||
|
discount_btn = QPushButton('할인')
|
||||||
|
discount_btn.setStyleSheet(
|
||||||
|
'background: #9C27B0; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||||
|
discount_btn.clicked.connect(self._apply_discount)
|
||||||
|
cart_btn_layout.addWidget(discount_btn)
|
||||||
|
|
||||||
|
remove_btn = QPushButton('삭제')
|
||||||
|
remove_btn.setStyleSheet(
|
||||||
|
'background: #F44336; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||||
|
remove_btn.clicked.connect(self._remove_selected)
|
||||||
|
cart_btn_layout.addWidget(remove_btn)
|
||||||
|
|
||||||
|
cart_btn_layout.addStretch()
|
||||||
|
|
||||||
|
clear_btn = QPushButton('전체 삭제')
|
||||||
|
clear_btn.setStyleSheet(
|
||||||
|
'background: #757575; color: white; font-size: 13px; padding: 8px 16px; border-radius: 4px;')
|
||||||
|
clear_btn.clicked.connect(self._clear_cart)
|
||||||
|
cart_btn_layout.addWidget(clear_btn)
|
||||||
|
|
||||||
|
cart_layout.addLayout(cart_btn_layout)
|
||||||
|
splitter.addWidget(cart_group)
|
||||||
|
|
||||||
|
# ── 우측 패널: 요약 + 결제 ──
|
||||||
|
right_panel = QWidget()
|
||||||
|
right_layout = QVBoxLayout()
|
||||||
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
right_panel.setLayout(right_layout)
|
||||||
|
|
||||||
|
# 최근 스캔
|
||||||
|
scan_group = QGroupBox('최근 스캔')
|
||||||
|
scan_layout = QVBoxLayout()
|
||||||
|
scan_group.setLayout(scan_layout)
|
||||||
|
|
||||||
|
self.last_scan_label = QLabel('바코드를 스캔하세요')
|
||||||
|
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||||
|
self.last_scan_label.setWordWrap(True)
|
||||||
|
self.last_scan_label.setMinimumHeight(80)
|
||||||
|
scan_layout.addWidget(self.last_scan_label)
|
||||||
|
|
||||||
|
right_layout.addWidget(scan_group)
|
||||||
|
|
||||||
|
# 합계 요약
|
||||||
|
summary_group = QGroupBox('합계')
|
||||||
|
summary_layout = QVBoxLayout()
|
||||||
|
summary_group.setLayout(summary_layout)
|
||||||
|
|
||||||
|
self.item_count_label = QLabel('품목: 0개 / 수량: 0개')
|
||||||
|
self.item_count_label.setStyleSheet('font-size: 14px; color: #616161; padding: 4px 8px;')
|
||||||
|
summary_layout.addWidget(self.item_count_label)
|
||||||
|
|
||||||
|
sep1 = QFrame()
|
||||||
|
sep1.setFrameShape(QFrame.HLine)
|
||||||
|
sep1.setStyleSheet('color: #E0E0E0;')
|
||||||
|
summary_layout.addWidget(sep1)
|
||||||
|
|
||||||
|
self.cost_label = QLabel('입고 합계: 0원')
|
||||||
|
self.cost_label.setStyleSheet('font-size: 13px; color: #9E9E9E; padding: 4px 8px;')
|
||||||
|
summary_layout.addWidget(self.cost_label)
|
||||||
|
|
||||||
|
self.subtotal_label = QLabel('판매 합계: 0원')
|
||||||
|
self.subtotal_label.setStyleSheet('font-size: 14px; color: #424242; padding: 4px 8px;')
|
||||||
|
summary_layout.addWidget(self.subtotal_label)
|
||||||
|
|
||||||
|
self.discount_total_label = QLabel('할인 합계: -0원')
|
||||||
|
self.discount_total_label.setStyleSheet('font-size: 14px; color: #E53935; padding: 4px 8px;')
|
||||||
|
summary_layout.addWidget(self.discount_total_label)
|
||||||
|
|
||||||
|
sep2 = QFrame()
|
||||||
|
sep2.setFrameShape(QFrame.HLine)
|
||||||
|
sep2.setStyleSheet('color: #37474F; border: 1px solid #37474F;')
|
||||||
|
summary_layout.addWidget(sep2)
|
||||||
|
|
||||||
|
self.total_label = QLabel('총 결제금액: 0원')
|
||||||
|
self.total_label.setStyleSheet(
|
||||||
|
'font-size: 22px; font-weight: bold; color: #1B5E20; padding: 8px;')
|
||||||
|
summary_layout.addWidget(self.total_label)
|
||||||
|
|
||||||
|
self.margin_label = QLabel('마진: 0원 (0%)')
|
||||||
|
self.margin_label.setStyleSheet('font-size: 13px; color: #1565C0; padding: 4px 8px;')
|
||||||
|
summary_layout.addWidget(self.margin_label)
|
||||||
|
|
||||||
|
right_layout.addWidget(summary_group)
|
||||||
|
right_layout.addStretch()
|
||||||
|
|
||||||
|
# 결제 버튼
|
||||||
|
pay_btn = QPushButton('결 제')
|
||||||
|
pay_btn.setMinimumHeight(70)
|
||||||
|
pay_btn.setStyleSheet('''
|
||||||
|
QPushButton {
|
||||||
|
background: #1B5E20; color: white;
|
||||||
|
font-size: 26px; font-weight: bold;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background: #2E7D32; }
|
||||||
|
QPushButton:pressed { background: #1B5E20; }
|
||||||
|
''')
|
||||||
|
pay_btn.clicked.connect(self._pay)
|
||||||
|
right_layout.addWidget(pay_btn)
|
||||||
|
|
||||||
|
splitter.addWidget(right_panel)
|
||||||
|
splitter.setSizes([800, 350])
|
||||||
|
|
||||||
|
root_layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
# ── 하단 상태바 ──
|
||||||
|
self.statusBar().setStyleSheet('font-size: 12px; color: #757575;')
|
||||||
|
self.statusBar().showMessage('청춘약국 POS | 바코드 스캐너를 연결하고 "연결" 버튼을 누르세요')
|
||||||
|
|
||||||
|
# ── 포트 관리 ──
|
||||||
|
|
||||||
|
def _refresh_ports(self):
|
||||||
|
self.port_combo.clear()
|
||||||
|
for port in serial.tools.list_ports.comports():
|
||||||
|
self.port_combo.addItem(f'{port.device} - {port.description}', port.device)
|
||||||
|
for i in range(self.port_combo.count()):
|
||||||
|
if 'COM3' in (self.port_combo.itemData(i) or ''):
|
||||||
|
self.port_combo.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _toggle_connection(self):
|
||||||
|
if self.reader_thread and self.reader_thread.isRunning():
|
||||||
|
self.reader_thread.stop()
|
||||||
|
self.reader_thread.wait()
|
||||||
|
self.reader_thread = None
|
||||||
|
self.connect_btn.setText('연결')
|
||||||
|
self.connect_btn.setStyleSheet(
|
||||||
|
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||||
|
self.status_label.setText('연결 해제됨')
|
||||||
|
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||||
|
self.statusBar().showMessage('스캐너 연결 해제')
|
||||||
|
else:
|
||||||
|
port = self.port_combo.currentData()
|
||||||
|
if not port:
|
||||||
|
self.status_label.setText('포트를 선택하세요')
|
||||||
|
return
|
||||||
|
self.reader_thread = BarcodeReaderThread(port, self.baudrate_spin.value())
|
||||||
|
self.reader_thread.barcode_received.connect(self._on_barcode)
|
||||||
|
self.reader_thread.connection_status.connect(self._on_connection)
|
||||||
|
self.reader_thread.start()
|
||||||
|
self.connect_btn.setText('연결 해제')
|
||||||
|
self.connect_btn.setStyleSheet(
|
||||||
|
'background: #F44336; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||||
|
|
||||||
|
def _on_connection(self, ok, msg):
|
||||||
|
if ok:
|
||||||
|
self.status_label.setText(msg)
|
||||||
|
self.status_label.setStyleSheet(
|
||||||
|
'color: #2E7D32; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||||
|
self.statusBar().showMessage(f'스캐너 {msg} | 바코드를 스캔하세요')
|
||||||
|
else:
|
||||||
|
self.status_label.setText(msg)
|
||||||
|
self.status_label.setStyleSheet(
|
||||||
|
'color: #D32F2F; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||||
|
self.connect_btn.setText('연결')
|
||||||
|
self.connect_btn.setStyleSheet(
|
||||||
|
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||||
|
|
||||||
|
# ── 바코드 수신 ──
|
||||||
|
|
||||||
|
def _manual_barcode(self):
|
||||||
|
barcode = self.manual_input.text().strip()
|
||||||
|
if barcode:
|
||||||
|
self.manual_input.clear()
|
||||||
|
self._on_barcode(barcode)
|
||||||
|
|
||||||
|
def _on_barcode(self, barcode):
|
||||||
|
self.last_scan_label.setText(f'스캔: {barcode}\n조회 중...')
|
||||||
|
self.last_scan_label.setStyleSheet('font-size: 14px; color: #FF6F00; padding: 12px;')
|
||||||
|
self.statusBar().showMessage(f'바코드 {barcode} 조회 중...')
|
||||||
|
|
||||||
|
thread = DrugSearchThread(barcode)
|
||||||
|
thread.search_complete.connect(self._on_search_done)
|
||||||
|
thread.start()
|
||||||
|
self.search_threads.append(thread)
|
||||||
|
|
||||||
|
def _on_search_done(self, barcode, info):
|
||||||
|
sender = self.sender()
|
||||||
|
if sender in self.search_threads:
|
||||||
|
self.search_threads.remove(sender)
|
||||||
|
|
||||||
|
if not info:
|
||||||
|
self.last_scan_label.setText(f'스캔: {barcode}\n제품을 찾을 수 없습니다')
|
||||||
|
self.last_scan_label.setStyleSheet('font-size: 14px; color: #D32F2F; padding: 12px;')
|
||||||
|
self.statusBar().showMessage(f'바코드 {barcode}: 데이터베이스에서 찾을 수 없음')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 이미 장바구니에 있으면 수량 +1
|
||||||
|
for item in self.cart_items:
|
||||||
|
if item['barcode'] == info['barcode']:
|
||||||
|
item['qty'] += 1
|
||||||
|
self._refresh_table()
|
||||||
|
self.last_scan_label.setText(
|
||||||
|
f'{info["goods_name"]}\n수량 → {item["qty"]}개')
|
||||||
|
self.last_scan_label.setStyleSheet('font-size: 14px; color: #1565C0; padding: 12px;')
|
||||||
|
self.statusBar().showMessage(f'{info["goods_name"]} 수량 +1 ({item["qty"]}개)')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 새 항목 추가
|
||||||
|
self.cart_items.append({
|
||||||
|
'barcode': info['barcode'],
|
||||||
|
'goods_name': info['goods_name'],
|
||||||
|
'manufacturer': info['manufacturer'],
|
||||||
|
'price': info['price'],
|
||||||
|
'sale_price': info['sale_price'],
|
||||||
|
'qty': 1,
|
||||||
|
'discount': 0,
|
||||||
|
})
|
||||||
|
self._refresh_table()
|
||||||
|
self.last_scan_label.setText(
|
||||||
|
f'{info["goods_name"]}\n{info["manufacturer"]} | {info["sale_price"]:,.0f}원')
|
||||||
|
self.last_scan_label.setStyleSheet('font-size: 14px; color: #2E7D32; padding: 12px; font-weight: bold;')
|
||||||
|
self.statusBar().showMessage(f'{info["goods_name"]} 추가됨 ({info["sale_price"]:,.0f}원)')
|
||||||
|
|
||||||
|
# ── 장바구니 조작 ──
|
||||||
|
|
||||||
|
def _selected_row(self):
|
||||||
|
rows = self.cart_table.selectionModel().selectedRows()
|
||||||
|
return rows[0].row() if rows else -1
|
||||||
|
|
||||||
|
def _change_qty(self, delta):
|
||||||
|
row = self._selected_row()
|
||||||
|
if row < 0:
|
||||||
|
self.statusBar().showMessage('제품을 선택하세요')
|
||||||
|
return
|
||||||
|
item = self.cart_items[row]
|
||||||
|
item['qty'] = max(1, item['qty'] + delta)
|
||||||
|
self._refresh_table()
|
||||||
|
self.cart_table.selectRow(row)
|
||||||
|
|
||||||
|
def _apply_discount(self):
|
||||||
|
row = self._selected_row()
|
||||||
|
if row < 0:
|
||||||
|
self.statusBar().showMessage('할인할 제품을 선택하세요')
|
||||||
|
return
|
||||||
|
item = self.cart_items[row]
|
||||||
|
dlg = DiscountDialog(item['goods_name'], item['sale_price'], self)
|
||||||
|
if dlg.exec_() == QDialog.Accepted:
|
||||||
|
item['discount'] = dlg.result_discount
|
||||||
|
self._refresh_table()
|
||||||
|
self.cart_table.selectRow(row)
|
||||||
|
|
||||||
|
def _remove_selected(self):
|
||||||
|
row = self._selected_row()
|
||||||
|
if row < 0:
|
||||||
|
self.statusBar().showMessage('삭제할 제품을 선택하세요')
|
||||||
|
return
|
||||||
|
name = self.cart_items[row]['goods_name']
|
||||||
|
del self.cart_items[row]
|
||||||
|
self._refresh_table()
|
||||||
|
self.statusBar().showMessage(f'{name} 삭제됨')
|
||||||
|
|
||||||
|
def _clear_cart(self):
|
||||||
|
if not self.cart_items:
|
||||||
|
return
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, '전체 삭제', '장바구니를 비우시겠습니까?',
|
||||||
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.cart_items.clear()
|
||||||
|
self._refresh_table()
|
||||||
|
self.statusBar().showMessage('장바구니 초기화')
|
||||||
|
|
||||||
|
# ── 테이블 갱신 ──
|
||||||
|
|
||||||
|
def _refresh_table(self):
|
||||||
|
self.cart_table.setRowCount(len(self.cart_items))
|
||||||
|
|
||||||
|
total_cost = 0
|
||||||
|
total_sale = 0
|
||||||
|
total_discount = 0
|
||||||
|
total_qty = 0
|
||||||
|
|
||||||
|
for i, item in enumerate(self.cart_items):
|
||||||
|
subtotal = (item['sale_price'] - item['discount']) * item['qty']
|
||||||
|
cost_total = item['price'] * item['qty']
|
||||||
|
|
||||||
|
cols = [
|
||||||
|
item['goods_name'],
|
||||||
|
item['manufacturer'],
|
||||||
|
item['barcode'],
|
||||||
|
f'{item["price"]:,.0f}',
|
||||||
|
f'{item["sale_price"]:,.0f}',
|
||||||
|
str(item['qty']),
|
||||||
|
f'-{item["discount"]:,.0f}' if item['discount'] > 0 else '',
|
||||||
|
f'{subtotal:,.0f}',
|
||||||
|
]
|
||||||
|
|
||||||
|
for j, val in enumerate(cols):
|
||||||
|
cell = QTableWidgetItem(val)
|
||||||
|
cell.setFlags(cell.flags() & ~Qt.ItemIsEditable)
|
||||||
|
# 숫자 컬럼 오른쪽 정렬
|
||||||
|
if j >= 3:
|
||||||
|
cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
# 할인 빨간색
|
||||||
|
if j == 6 and item['discount'] > 0:
|
||||||
|
cell.setForeground(QBrush(QColor('#E53935')))
|
||||||
|
# 소계 볼드
|
||||||
|
if j == 7:
|
||||||
|
font = cell.font()
|
||||||
|
font.setBold(True)
|
||||||
|
cell.setFont(font)
|
||||||
|
self.cart_table.setItem(i, j, cell)
|
||||||
|
|
||||||
|
total_cost += cost_total
|
||||||
|
total_sale += item['sale_price'] * item['qty']
|
||||||
|
total_discount += item['discount'] * item['qty']
|
||||||
|
total_qty += item['qty']
|
||||||
|
|
||||||
|
final_total = total_sale - total_discount
|
||||||
|
margin = final_total - total_cost
|
||||||
|
margin_pct = (margin / final_total * 100) if final_total > 0 else 0
|
||||||
|
|
||||||
|
self.item_count_label.setText(f'품목: {len(self.cart_items)}개 / 수량: {total_qty}개')
|
||||||
|
self.cost_label.setText(f'입고 합계: {total_cost:,.0f}원')
|
||||||
|
self.subtotal_label.setText(f'판매 합계: {total_sale:,.0f}원')
|
||||||
|
self.discount_total_label.setText(f'할인 합계: -{total_discount:,.0f}원')
|
||||||
|
self.total_label.setText(f'총 결제금액: {final_total:,.0f}원')
|
||||||
|
self.margin_label.setText(f'마진: {margin:,.0f}원 ({margin_pct:.1f}%)')
|
||||||
|
|
||||||
|
# ── 결제 ──
|
||||||
|
|
||||||
|
def _pay(self):
|
||||||
|
if not self.cart_items:
|
||||||
|
self.statusBar().showMessage('장바구니가 비어있습니다')
|
||||||
|
return
|
||||||
|
|
||||||
|
total_sale = sum(it['sale_price'] * it['qty'] for it in self.cart_items)
|
||||||
|
total_discount = sum(it['discount'] * it['qty'] for it in self.cart_items)
|
||||||
|
final = total_sale - total_discount
|
||||||
|
|
||||||
|
items_text = '\n'.join(
|
||||||
|
f' {it["goods_name"]} x{it["qty"]} {(it["sale_price"] - it["discount"]) * it["qty"]:,.0f}원'
|
||||||
|
for it in self.cart_items
|
||||||
|
)
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, '결제 확인',
|
||||||
|
f'총 결제금액: {final:,.0f}원\n\n{items_text}\n\n결제하시겠습니까?',
|
||||||
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
QMessageBox.information(
|
||||||
|
self, '결제 완료',
|
||||||
|
f'결제가 완료되었습니다.\n\n'
|
||||||
|
f'시각: {now}\n'
|
||||||
|
f'금액: {final:,.0f}원\n'
|
||||||
|
f'품목: {len(self.cart_items)}개'
|
||||||
|
)
|
||||||
|
self.cart_items.clear()
|
||||||
|
self._refresh_table()
|
||||||
|
self.last_scan_label.setText('바코드를 스캔하세요')
|
||||||
|
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||||
|
self.statusBar().showMessage(f'결제 완료 ({final:,.0f}원) | {now}')
|
||||||
|
|
||||||
|
# ── 종료 ──
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self.reader_thread:
|
||||||
|
self.reader_thread.stop()
|
||||||
|
self.reader_thread.wait()
|
||||||
|
for t in self.search_threads:
|
||||||
|
if t.isRunning():
|
||||||
|
t.wait()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setStyle('Fusion')
|
||||||
|
window = POSDummyGUI()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods;
|
|||||||
4. **효과 추적**: 3개월 후 재검사 결과 비교
|
4. **효과 추적**: 3개월 후 재검사 결과 비교
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**작성자**: Claude Sonnet 4.5
|
|
||||||
**버전**: 1.0
|
|
||||||
**최종 수정**: 2026-02-04
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user