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:
시골약사 2026-03-13 15:02:48 +09:00
parent 68ad59285a
commit e499e19342
5 changed files with 945 additions and 4 deletions

7
backend/config.json Normal file
View File

@ -0,0 +1,7 @@
{
"pos_printer": {
"ip": "192.168.0.174",
"port": 9100,
"name": "메인 POS"
}
}

222
backend/gui/pos_thermal.py Normal file
View 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)}")

View File

@ -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
# 바코드 라벨 출력 # 바코드 라벨 출력

View 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()

View File

@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods;
4. **효과 추적**: 3개월 후 재검사 결과 비교 4. **효과 추적**: 3개월 후 재검사 결과 비교
--- ---
**작성자**: Claude Sonnet 4.5
**버전**: 1.0
**최종 수정**: 2026-02-04