""" POS 판매 내역 조회 GUI (PyQt5) MSSQL SALE_MAIN 테이블에서 오늘 판매 내역을 조회하여 표시 """ import sys import os from datetime import datetime from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem, QDialog, QMessageBox, QDateEdit, QCheckBox ) from PyQt5.QtCore import QThread, pyqtSignal, Qt, QDate, QTimer from PyQt5.QtGui import QFont # 데이터베이스 연결 (backend/ 폴더를 Python 경로에 추가) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from db.dbsetup import DatabaseManager # QR 생성 모듈 import from utils.qr_token_generator import generate_claim_token, save_token_to_db from utils.qr_label_printer import print_qr_label class SalesQueryThread(QThread): """ 판매 내역 조회 백그라운드 스레드 GUI 블로킹을 방지하기 위해 DB 쿼리를 별도 스레드에서 실행 """ query_complete = pyqtSignal(list) # 조회 완료 시그널 query_error = pyqtSignal(str) # 에러 발생 시그널 def __init__(self, date_str): """ Args: date_str: 조회할 날짜 (YYYYMMDD 형식) """ super().__init__() self.date_str = date_str def run(self): """스레드 실행 (SALE_MAIN 조회 + SQLite 적립 사용자 조회)""" mssql_conn = None sqlite_conn = None try: db_manager = DatabaseManager() # MSSQL 연결 mssql_conn = db_manager.get_engine('PM_PRES').raw_connection() mssql_cursor = mssql_conn.cursor() # SQLite 연결 sqlite_conn = db_manager.get_sqlite_connection() sqlite_cursor = sqlite_conn.cursor() # 메인 쿼리: SALE_MAIN에서 오늘 판매 내역 조회 query = """ SELECT M.SL_NO_order, M.InsertTime, M.SL_MY_sale, ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name FROM SALE_MAIN M WHERE M.SL_DT_appl = ? ORDER BY M.InsertTime DESC """ mssql_cursor.execute(query, self.date_str) rows = mssql_cursor.fetchall() sales_list = [] for row in rows: order_no, insert_time, sale_amount, customer = row # 품목 수 조회 (SALE_SUB) mssql_cursor.execute(""" SELECT COUNT(*) FROM SALE_SUB WHERE SL_NO_order = ? """, order_no) item_count_row = mssql_cursor.fetchone() item_count = item_count_row[0] if item_count_row else 0 # SQLite에서 적립 사용자 조회 sqlite_cursor.execute(""" SELECT u.nickname, u.phone FROM claim_tokens ct LEFT JOIN users u ON ct.claimed_by_user_id = u.id WHERE ct.transaction_id = ? AND ct.claimed_at IS NOT NULL """, (order_no,)) claimed_user = sqlite_cursor.fetchone() # 적립 사용자 정보 포맷팅 if claimed_user and claimed_user['nickname'] and claimed_user['phone']: claimed_info = f"{claimed_user['nickname']} ({claimed_user['phone']})" else: claimed_info = "" sales_list.append({ 'order_no': order_no, 'time': insert_time.strftime('%H:%M') if insert_time else '--:--', 'amount': float(sale_amount) if sale_amount else 0.0, 'customer': customer, 'item_count': item_count, 'claimed_user': claimed_info }) self.query_complete.emit(sales_list) except Exception as e: self.query_error.emit(str(e)) finally: if mssql_conn: mssql_conn.close() if sqlite_conn: sqlite_conn.close() class QRGeneratorThread(QThread): """ QR 토큰 생성 및 라벨 출력 백그라운드 스레드 GUI 블로킹 방지 """ qr_complete = pyqtSignal(bool, str, str) # 성공 여부, 메시지, 이미지 경로 def __init__(self, transaction_id, total_amount, transaction_time, preview_mode=False): """ Args: transaction_id (str): POS 거래 ID total_amount (float): 판매 금액 transaction_time (datetime): 거래 시간 preview_mode (bool): 미리보기 모드 """ super().__init__() self.transaction_id = transaction_id self.total_amount = total_amount self.transaction_time = transaction_time self.preview_mode = preview_mode def run(self): """스레드 실행""" try: # 1. Claim Token 생성 token_info = generate_claim_token( self.transaction_id, self.total_amount ) # 2. DB 저장 success, error = save_token_to_db( self.transaction_id, token_info['token_hash'], self.total_amount, token_info['claimable_points'], token_info['expires_at'], token_info['pharmacy_id'] ) if not success: self.qr_complete.emit(False, error, "") return # 3. QR 라벨 생성 if self.preview_mode: # 미리보기 success, image_path = print_qr_label( token_info['qr_url'], self.transaction_id, self.total_amount, token_info['claimable_points'], self.transaction_time, preview_mode=True ) if success: self.qr_complete.emit( True, f"QR 생성 완료 ({token_info['claimable_points']}P)", image_path ) else: self.qr_complete.emit(False, "이미지 생성 실패", "") else: # 실제 인쇄 success = print_qr_label( token_info['qr_url'], self.transaction_id, self.total_amount, token_info['claimable_points'], self.transaction_time, preview_mode=False ) if success: self.qr_complete.emit( True, f"QR 출력 완료 ({token_info['claimable_points']}P)", "" ) else: self.qr_complete.emit(False, "프린터 전송 실패", "") except Exception as e: self.qr_complete.emit(False, f"오류: {str(e)}", "") class QRLabelPreviewDialog(QDialog): """ QR 라벨 미리보기 팝업 (barcode_reader_gui.py의 LabelPreviewDialog 참고) """ def __init__(self, image_path, transaction_id, parent=None): """ Args: image_path (str): 미리보기 이미지 파일 경로 transaction_id (str): 거래 번호 parent: 부모 위젯 """ super().__init__(parent) self.image_path = image_path self.transaction_id = transaction_id self.init_ui() def init_ui(self): """UI 초기화""" self.setWindowTitle(f'QR 라벨 미리보기 - {self.transaction_id}') self.setModal(False) # 모달 아님 (계속 작업 가능) layout = QVBoxLayout() # 안내 라벨 info_label = QLabel('[미리보기] 실제 인쇄하려면 "미리보기 모드" 체크를 해제하세요.') info_label.setStyleSheet('color: #2196F3; font-size: 12px; padding: 10px;') layout.addWidget(info_label) # 이미지 표시 from PyQt5.QtGui import QPixmap pixmap = QPixmap(self.image_path) # 스케일링 (최대 1000px 폭) if pixmap.width() > 1000: pixmap = pixmap.scaledToWidth(1000, Qt.SmoothTransformation) image_label = QLabel() image_label.setPixmap(pixmap) image_label.setAlignment(Qt.AlignCenter) layout.addWidget(image_label) # 닫기 버튼 close_btn = QPushButton('닫기') close_btn.setStyleSheet('background-color: #2196F3; color: white; font-weight: bold; padding: 8px 20px;') close_btn.clicked.connect(self.close) layout.addWidget(close_btn) self.setLayout(layout) self.adjustSize() class SaleDetailDialog(QDialog): """ 판매 상세 조회 팝업 더블클릭한 판매 건의 SALE_SUB 품목 상세 표시 """ def __init__(self, order_no, parent=None): """ Args: order_no: 판매 주문번호 (SL_NO_order) parent: 부모 위젯 """ super().__init__(parent) self.order_no = order_no self.setWindowTitle(f'판매 상세: {order_no}') self.setModal(True) self.resize(700, 400) self.init_ui() self.load_details() def init_ui(self): """UI 초기화""" layout = QVBoxLayout() # 제목 title_label = QLabel(f'주문번호: {self.order_no}') title_label.setStyleSheet('font-size: 14px; font-weight: bold; padding: 10px;') layout.addWidget(title_label) # 상세 테이블 self.detail_table = QTableWidget() self.detail_table.setColumnCount(4) self.detail_table.setHorizontalHeaderLabels([ '약품코드', '약품명', '수량', '금액' ]) self.detail_table.setColumnWidth(0, 100) self.detail_table.setColumnWidth(1, 300) self.detail_table.setColumnWidth(2, 80) self.detail_table.setColumnWidth(3, 100) layout.addWidget(self.detail_table) # 닫기 버튼 close_btn = QPushButton('닫기') close_btn.setStyleSheet('background-color: #2196F3; color: white; padding: 8px; font-weight: bold;') close_btn.clicked.connect(self.close) layout.addWidget(close_btn) self.setLayout(layout) def load_details(self): """SALE_SUB + CD_GOODS 조인 조회""" conn = None try: db_manager = DatabaseManager() conn = db_manager.get_engine('PM_PRES').raw_connection() cursor = conn.cursor() query = """ SELECT S.DrugCode, ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name, S.SL_NM_item AS quantity, S.SL_TOTAL_PRICE FROM SALE_SUB S LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode WHERE S.SL_NO_order = ? ORDER BY S.DrugCode """ cursor.execute(query, self.order_no) rows = cursor.fetchall() # 테이블에 데이터 채우기 self.detail_table.setRowCount(len(rows)) for row_idx, row in enumerate(rows): drug_code, goods_name, quantity, price = row # 약품코드 self.detail_table.setItem(row_idx, 0, QTableWidgetItem(str(drug_code))) # 약품명 self.detail_table.setItem(row_idx, 1, QTableWidgetItem(goods_name)) # 수량 (중앙 정렬) qty_item = QTableWidgetItem(str(quantity)) qty_item.setTextAlignment(Qt.AlignCenter) self.detail_table.setItem(row_idx, 2, qty_item) # 금액 (우측 정렬, 천단위 콤마) price_value = float(price) if price else 0.0 price_item = QTableWidgetItem(f'{price_value:,.0f}원') price_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.detail_table.setItem(row_idx, 3, price_item) except Exception as e: QMessageBox.critical(self, '오류', f'상세 조회 실패:\n{str(e)}') finally: if conn: conn.close() class POSSalesGUI(QMainWindow): """ POS 판매 내역 조회 메인 GUI """ def __init__(self): super().__init__() self.db_manager = DatabaseManager() self.sales_thread = None self.qr_thread = None # QR 생성 스레드 추가 self.sales_data = [] self.init_ui() def init_ui(self): """UI 초기화""" self.setWindowTitle('POS 판매 조회') self.setGeometry(100, 100, 1100, 600) # 중앙 위젯 central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout() central_widget.setLayout(main_layout) # === 1. 조회 설정 그룹 === settings_group = QGroupBox('조회 설정') settings_layout = QHBoxLayout() settings_group.setLayout(settings_layout) # 날짜 선택기 settings_layout.addWidget(QLabel('날짜:')) self.date_edit = QDateEdit() self.date_edit.setCalendarPopup(True) self.date_edit.setDate(QDate.currentDate()) self.date_edit.setDisplayFormat('yyyy-MM-dd') self.date_edit.dateChanged.connect(self.on_date_changed) settings_layout.addWidget(self.date_edit) # 새로고침 버튼 self.refresh_btn = QPushButton('새로고침') self.refresh_btn.setStyleSheet('background-color: #4CAF50; color: white; padding: 8px; font-weight: bold;') self.refresh_btn.clicked.connect(self.refresh_sales) settings_layout.addWidget(self.refresh_btn) # QR 생성 버튼 (활성화) self.qr_btn = QPushButton('QR 생성') self.qr_btn.setEnabled(True) # 활성화! self.qr_btn.setStyleSheet('background-color: #FF9800; color: white; padding: 8px; font-weight: bold;') self.qr_btn.setToolTip('선택된 거래의 QR 적립 라벨 생성') self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결 settings_layout.addWidget(self.qr_btn) # 미리보기 모드 체크박스 추가 self.preview_checkbox = QCheckBox('미리보기 모드') self.preview_checkbox.setChecked(True) # 기본값: 미리보기 self.preview_checkbox.setStyleSheet('font-size: 12px; color: #4CAF50;') self.preview_checkbox.setToolTip('체크: PNG 미리보기, 해제: 프린터 직접 출력') settings_layout.addWidget(self.preview_checkbox) settings_layout.addStretch() main_layout.addWidget(settings_group) # === 2. 판매 내역 테이블 === sales_group = QGroupBox('판매 내역') sales_layout = QVBoxLayout() sales_group.setLayout(sales_layout) self.sales_table = QTableWidget() self.sales_table.setColumnCount(6) self.sales_table.setHorizontalHeaderLabels([ '주문번호', '시간', '금액', '고객명', '품목수', '적립 사용자' ]) self.sales_table.setColumnWidth(0, 160) self.sales_table.setColumnWidth(1, 70) self.sales_table.setColumnWidth(2, 110) self.sales_table.setColumnWidth(3, 100) self.sales_table.setColumnWidth(4, 70) self.sales_table.setColumnWidth(5, 180) self.sales_table.setSelectionBehavior(QTableWidget.SelectRows) self.sales_table.doubleClicked.connect(self.show_sale_detail) sales_layout.addWidget(self.sales_table) main_layout.addWidget(sales_group) # === 3. 상태바 === status_layout = QHBoxLayout() self.status_label = QLabel('대기 중...') self.status_label.setStyleSheet('color: gray; font-size: 12px; padding: 5px;') status_layout.addWidget(self.status_label) status_layout.addStretch() self.total_label = QLabel('총 매출: 0원') self.total_label.setStyleSheet('color: blue; font-size: 12px; font-weight: bold; padding: 5px;') status_layout.addWidget(self.total_label) main_layout.addLayout(status_layout) # 초기 조회 실행 self.refresh_sales() # 자동 새로고침 타이머 (30초마다) self.refresh_timer = QTimer() self.refresh_timer.timeout.connect(self.refresh_sales) self.refresh_timer.start(30000) # 30초 = 30000ms def on_date_changed(self, date): """날짜 변경 시 유효성 검사""" today = QDate.currentDate() if date > today: QMessageBox.warning(self, '경고', '미래 날짜는 선택할 수 없습니다.') self.date_edit.setDate(today) def refresh_sales(self): """판매 내역 조회 시작""" # 중복 방지 if self.sales_thread and self.sales_thread.isRunning(): return date_str = self.date_edit.date().toString('yyyyMMdd') # 스레드 시작 self.sales_thread = SalesQueryThread(date_str) self.sales_thread.query_complete.connect(self.on_sales_loaded) self.sales_thread.query_error.connect(self.on_query_error) self.sales_thread.start() # UI 업데이트 self.status_label.setText('조회 중...') self.status_label.setStyleSheet('color: orange; font-size: 12px; padding: 5px;') self.refresh_btn.setEnabled(False) def on_sales_loaded(self, sales_list): """조회 완료 시 테이블 갱신""" self.sales_data = sales_list self.populate_table(sales_list) # 총 매출 계산 total_amount = sum(sale['amount'] for sale in sales_list) # 상태 업데이트 self.status_label.setText(f'{len(sales_list)}건 조회 완료') self.status_label.setStyleSheet('color: green; font-size: 12px; padding: 5px;') self.total_label.setText(f'총 매출: {total_amount:,.0f}원') self.refresh_btn.setEnabled(True) def populate_table(self, sales_list): """QTableWidget에 데이터 채우기""" self.sales_table.setRowCount(len(sales_list)) for row, sale in enumerate(sales_list): # 주문번호 self.sales_table.setItem(row, 0, QTableWidgetItem(sale['order_no'])) # 시간 self.sales_table.setItem(row, 1, QTableWidgetItem(sale['time'])) # 금액 (우측 정렬, 천단위 콤마) amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원") amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.sales_table.setItem(row, 2, amount_item) # 고객명 (MSSQL) self.sales_table.setItem(row, 3, QTableWidgetItem(sale['customer'])) # 품목수 (중앙 정렬) count_item = QTableWidgetItem(str(sale['item_count'])) count_item.setTextAlignment(Qt.AlignCenter) self.sales_table.setItem(row, 4, count_item) # 적립 사용자 (SQLite) claimed_item = QTableWidgetItem(sale['claimed_user']) if sale['claimed_user']: from PyQt5.QtGui import QColor, QFont claimed_item.setForeground(QColor('#4CAF50')) font = QFont() font.setBold(True) claimed_item.setFont(font) self.sales_table.setItem(row, 5, claimed_item) def on_query_error(self, error_msg): """DB 조회 에러 처리""" QMessageBox.critical(self, '오류', f'조회 실패:\n{error_msg}') self.status_label.setText('오류 발생') self.status_label.setStyleSheet('color: red; font-size: 12px; padding: 5px;') self.refresh_btn.setEnabled(True) def show_sale_detail(self): """선택된 판매 건의 상세 조회""" current_row = self.sales_table.currentRow() if current_row < 0: return order_no = self.sales_table.item(current_row, 0).text() detail_dialog = SaleDetailDialog(order_no, self) detail_dialog.exec_() def generate_qr_label(self): """선택된 판매 건에 대해 QR 라벨 생성""" # 선택된 행 확인 current_row = self.sales_table.currentRow() if current_row < 0: QMessageBox.warning(self, '경고', '거래를 선택해주세요.') return # 거래 정보 가져오기 order_no = self.sales_table.item(current_row, 0).text() amount_text = self.sales_table.item(current_row, 2).text() # 금액 파싱 (예: "50,000원" → 50000.0) amount = float(amount_text.replace(',', '').replace('원', '')) # sales_data에서 거래 시간 찾기 sale = next((s for s in self.sales_data if s['order_no'] == order_no), None) if not sale: QMessageBox.warning(self, '오류', '거래 정보를 찾을 수 없습니다.') return # 거래 시간 파싱 date_str = self.date_edit.date().toString('yyyy-MM-dd') time_str = sale['time'] transaction_time = datetime.strptime(f"{date_str} {time_str}", '%Y-%m-%d %H:%M') # 중복 방지 확인 (이미 QR 생성된 거래인지) if self.qr_thread and self.qr_thread.isRunning(): QMessageBox.warning(self, '경고', 'QR 생성 중입니다. 잠시만 기다려주세요.') return # 미리보기 모드 확인 preview_mode = self.preview_checkbox.isChecked() # QR 생성 스레드 시작 self.qr_thread = QRGeneratorThread( order_no, amount, transaction_time, preview_mode ) self.qr_thread.qr_complete.connect(self.on_qr_generated) self.qr_thread.start() # 상태 표시 self.status_label.setText(f'QR 생성 중... ({order_no})') self.status_label.setStyleSheet('color: orange; font-size: 12px; padding: 5px;') self.qr_btn.setEnabled(False) def on_qr_generated(self, success, message, image_path): """QR 생성 완료 시그널 핸들러""" # 버튼 재활성화 self.qr_btn.setEnabled(True) if success: # 성공 self.status_label.setText(f'✓ {message}') self.status_label.setStyleSheet('color: green; font-size: 12px; padding: 5px;') # 미리보기 모드면 Dialog 표시 if image_path: order_no = self.sales_table.item(self.sales_table.currentRow(), 0).text() preview_dialog = QRLabelPreviewDialog(image_path, order_no, self) preview_dialog.show() else: # 실제 인쇄 완료 QMessageBox.information(self, '완료', f'{message}\n프린터: 192.168.0.168') else: # 실패 self.status_label.setText('QR 생성 실패') self.status_label.setStyleSheet('color: red; font-size: 12px; padding: 5px;') QMessageBox.critical(self, '오류', f'QR 생성 실패:\n{message}') def closeEvent(self, event): """종료 시 정리""" # 자동 새로고침 타이머 중지 if hasattr(self, 'refresh_timer'): self.refresh_timer.stop() if self.sales_thread and self.sales_thread.isRunning(): self.sales_thread.wait() if self.qr_thread and self.qr_thread.isRunning(): # QR 스레드 추가 self.qr_thread.wait() self.db_manager.close_all() event.accept() if __name__ == '__main__': app = QApplication(sys.argv) # 한글 폰트 설정 font = QFont('맑은 고딕', 10) app.setFont(font) window = POSSalesGUI() window.show() sys.exit(app.exec_())