""" 더미 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()