diff --git a/backend/config.json b/backend/config.json new file mode 100644 index 0000000..8bce42c --- /dev/null +++ b/backend/config.json @@ -0,0 +1,7 @@ +{ + "pos_printer": { + "ip": "192.168.0.174", + "port": 9100, + "name": "메인 POS" + } +} \ No newline at end of file diff --git a/backend/gui/pos_thermal.py b/backend/gui/pos_thermal.py new file mode 100644 index 0000000..3c58534 --- /dev/null +++ b/backend/gui/pos_thermal.py @@ -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)}") diff --git a/backend/samples/barcode_reader_gui.py b/backend/samples/barcode_reader_gui.py index db9d2a1..904ce37 100644 --- a/backend/samples/barcode_reader_gui.py +++ b/backend/samples/barcode_reader_gui.py @@ -5,6 +5,7 @@ MSSQL DB에서 약품 정보 조회 기능 포함 """ import sys +import os import serial import serial.tools.list_ports from datetime import datetime @@ -19,6 +20,8 @@ from sqlalchemy import text # MSSQL 데이터베이스 연결 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 # 바코드 라벨 출력 diff --git a/backend/samples/pos_dummy_gui.py b/backend/samples/pos_dummy_gui.py new file mode 100644 index 0000000..09988e0 --- /dev/null +++ b/backend/samples/pos_dummy_gui.py @@ -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() diff --git a/docs/il1beta-food-graphrag-guide.md b/docs/il1beta-food-graphrag-guide.md index 8ece317..ea990ac 100644 --- a/docs/il1beta-food-graphrag-guide.md +++ b/docs/il1beta-food-graphrag-guide.md @@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods; 4. **효과 추적**: 3개월 후 재검사 결과 비교 --- - -**작성자**: Claude Sonnet 4.5 -**버전**: 1.0 -**최종 수정**: 2026-02-04