- GUI: SALES_COLUMNS 상수 정의, 칼럼 폭/윈도우 위치 gui_settings.json에 저장 - 전화번호 입력: 적립페이지/마이페이지에서 010 고정 + 뒷번호만 입력 - 적립페이지: MSSQL SALE_SUB에서 구매 품목 조회 및 토글 표시 - 마이페이지: 적립 내역 탭 시 품목 상세 AJAX 조회 (캐시 적용) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1083 lines
41 KiB
Python
1083 lines
41 KiB
Python
"""
|
|
POS 판매 내역 조회 GUI (PyQt5)
|
|
MSSQL SALE_MAIN 테이블에서 오늘 판매 내역을 조회하여 표시
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
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, QColor
|
|
|
|
# 데이터베이스 연결 (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에서 QR 발행 여부 확인
|
|
sqlite_cursor.execute("""
|
|
SELECT id FROM claim_tokens
|
|
WHERE transaction_id = ?
|
|
""", (order_no,))
|
|
qr_record = sqlite_cursor.fetchone()
|
|
qr_issued = bool(qr_record)
|
|
|
|
# SQLite에서 적립 사용자 조회
|
|
sqlite_cursor.execute("""
|
|
SELECT u.nickname, u.phone, ct.claimable_points
|
|
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_name = claimed_user['nickname']
|
|
claimed_phone = claimed_user['phone']
|
|
claimed_points = claimed_user['claimable_points']
|
|
else:
|
|
claimed_name = ""
|
|
claimed_phone = ""
|
|
claimed_points = 0
|
|
|
|
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_name': claimed_name,
|
|
'claimed_phone': claimed_phone,
|
|
'claimed_points': claimed_points,
|
|
'qr_issued': qr_issued
|
|
})
|
|
|
|
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,
|
|
printer_mode='zebra', pos_config=None):
|
|
"""
|
|
Args:
|
|
transaction_id (str): POS 거래 ID
|
|
total_amount (float): 판매 금액
|
|
transaction_time (datetime): 거래 시간
|
|
preview_mode (bool): 미리보기 모드
|
|
printer_mode (str): 프린터 모드 ('zebra' or 'pos')
|
|
pos_config (dict): POS 프린터 설정 {'ip': ..., 'port': ...}
|
|
"""
|
|
super().__init__()
|
|
self.transaction_id = transaction_id
|
|
self.total_amount = total_amount
|
|
self.transaction_time = transaction_time
|
|
self.preview_mode = preview_mode
|
|
self.printer_mode = printer_mode
|
|
self.pos_config = pos_config
|
|
|
|
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. 프린터 모드별 인쇄
|
|
if self.printer_mode == 'zebra':
|
|
# Zebra 라벨 프린터
|
|
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"Zebra 라벨 출력 완료 ({token_info['claimable_points']}P)",
|
|
""
|
|
)
|
|
else:
|
|
self.qr_complete.emit(False, "Zebra 프린터 전송 실패", "")
|
|
|
|
elif self.printer_mode == 'pos':
|
|
# POS 영수증 프린터
|
|
from utils.pos_qr_printer import print_qr_receipt_escpos
|
|
|
|
if not self.pos_config or not self.pos_config.get('ip'):
|
|
self.qr_complete.emit(False, 'POS 프린터 설정이 필요합니다', '')
|
|
return
|
|
|
|
success = print_qr_receipt_escpos(
|
|
token_info['qr_url'],
|
|
self.transaction_id,
|
|
self.total_amount,
|
|
token_info['claimable_points'],
|
|
self.transaction_time,
|
|
self.pos_config['ip'],
|
|
self.pos_config.get('port', 9100)
|
|
)
|
|
|
|
if success:
|
|
self.qr_complete.emit(
|
|
True,
|
|
f"POS 영수증 출력 완료 ({token_info['claimable_points']}P)",
|
|
''
|
|
)
|
|
else:
|
|
self.qr_complete.emit(False, 'POS 프린터 인쇄 실패', '')
|
|
|
|
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 UserMileageDialog(QDialog):
|
|
"""
|
|
회원 마일리지 내역 조회 팝업
|
|
적립 사용자 클릭 시 해당 회원의 적립 내역 표시
|
|
"""
|
|
|
|
def __init__(self, phone, parent=None):
|
|
"""
|
|
Args:
|
|
phone: 전화번호
|
|
parent: 부모 위젯
|
|
"""
|
|
super().__init__(parent)
|
|
self.phone = phone
|
|
self.setWindowTitle(f'회원 마일리지 내역')
|
|
self.setModal(True)
|
|
self.resize(800, 500)
|
|
self.init_ui()
|
|
self.load_user_info()
|
|
|
|
def init_ui(self):
|
|
"""UI 초기화"""
|
|
layout = QVBoxLayout()
|
|
|
|
# 회원 정보 그룹박스
|
|
info_group = QGroupBox('회원 정보')
|
|
info_layout = QVBoxLayout()
|
|
info_group.setLayout(info_layout)
|
|
|
|
self.info_label = QLabel('조회 중...')
|
|
self.info_label.setStyleSheet('font-size: 13px; padding: 10px; background: #f5f7fa; border-radius: 8px;')
|
|
info_layout.addWidget(self.info_label)
|
|
|
|
layout.addWidget(info_group)
|
|
|
|
# 적립 내역 테이블
|
|
history_group = QGroupBox('적립 내역')
|
|
history_layout = QVBoxLayout()
|
|
history_group.setLayout(history_layout)
|
|
|
|
self.history_table = QTableWidget()
|
|
self.history_table.setColumnCount(5)
|
|
self.history_table.setHorizontalHeaderLabels([
|
|
'날짜', '구분', '포인트', '잔액', '설명'
|
|
])
|
|
self.history_table.setColumnWidth(0, 150)
|
|
self.history_table.setColumnWidth(1, 80)
|
|
self.history_table.setColumnWidth(2, 100)
|
|
self.history_table.setColumnWidth(3, 100)
|
|
self.history_table.setColumnWidth(4, 300)
|
|
history_layout.addWidget(self.history_table)
|
|
|
|
layout.addWidget(history_group)
|
|
|
|
# 닫기 버튼
|
|
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_user_info(self):
|
|
"""SQLite에서 회원 정보 및 적립 내역 조회"""
|
|
conn = None
|
|
try:
|
|
db_manager = DatabaseManager()
|
|
conn = db_manager.get_sqlite_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# 전화번호로 사용자 조회
|
|
cursor.execute("""
|
|
SELECT id, nickname, phone, mileage_balance, created_at
|
|
FROM users WHERE phone = ?
|
|
""", (self.phone,))
|
|
|
|
user = cursor.fetchone()
|
|
|
|
if not user:
|
|
self.info_label.setText('등록되지 않은 회원입니다.')
|
|
return
|
|
|
|
# 회원 정보 표시
|
|
info_text = f"""
|
|
<b>이름:</b> {user['nickname']}<br>
|
|
<b>전화번호:</b> {user['phone']}<br>
|
|
<b>포인트 잔액:</b> <span style="color: #4CAF50; font-weight: bold;">{user['mileage_balance']:,}P</span><br>
|
|
<b>가입일:</b> {user['created_at']}
|
|
"""
|
|
self.info_label.setText(info_text)
|
|
|
|
# 적립 내역 조회
|
|
cursor.execute("""
|
|
SELECT points, balance_after, reason, description, created_at
|
|
FROM mileage_ledger
|
|
WHERE user_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
""", (user['id'],))
|
|
|
|
transactions = cursor.fetchall()
|
|
|
|
# 테이블에 데이터 채우기
|
|
self.history_table.setRowCount(len(transactions))
|
|
for row_idx, tx in enumerate(transactions):
|
|
from PyQt5.QtGui import QColor
|
|
|
|
# 날짜
|
|
date_item = QTableWidgetItem(tx['created_at'])
|
|
self.history_table.setItem(row_idx, 0, date_item)
|
|
|
|
# 구분 (CLAIM, USE 등)
|
|
reason_text = '적립' if tx['reason'] == 'CLAIM' else '사용'
|
|
reason_item = QTableWidgetItem(reason_text)
|
|
reason_item.setTextAlignment(Qt.AlignCenter)
|
|
if tx['reason'] == 'CLAIM':
|
|
reason_item.setForeground(QColor('#4CAF50'))
|
|
else:
|
|
reason_item.setForeground(QColor('#f03e3e'))
|
|
self.history_table.setItem(row_idx, 1, reason_item)
|
|
|
|
# 포인트 (우측 정렬)
|
|
points_item = QTableWidgetItem(f"{tx['points']:+,}P")
|
|
points_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
if tx['points'] > 0:
|
|
points_item.setForeground(QColor('#4CAF50'))
|
|
else:
|
|
points_item.setForeground(QColor('#f03e3e'))
|
|
self.history_table.setItem(row_idx, 2, points_item)
|
|
|
|
# 잔액 (우측 정렬)
|
|
balance_item = QTableWidgetItem(f"{tx['balance_after']:,}P")
|
|
balance_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
self.history_table.setItem(row_idx, 3, balance_item)
|
|
|
|
# 설명
|
|
self.history_table.setItem(row_idx, 4, QTableWidgetItem(tx['description']))
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
|
|
class POSSalesGUI(QMainWindow):
|
|
"""
|
|
POS 판매 내역 조회 메인 GUI
|
|
"""
|
|
CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'gui_settings.json')
|
|
|
|
# 판매 테이블 컬럼 정의: (헤더명, 기본폭, 데이터키)
|
|
SALES_COLUMNS = [
|
|
('주문번호', 150, 'order_no'),
|
|
('시간', 70, 'time'),
|
|
('금액', 100, 'amount'),
|
|
('고객명', 80, 'customer'),
|
|
('품목수', 55, 'item_count'),
|
|
('적립자', 90, 'claimed_name'),
|
|
('전화번호', 120, 'claimed_phone'),
|
|
('적립포인트', 90, 'claimed_points'),
|
|
('QR', 50, 'qr_issued'),
|
|
]
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.db_manager = DatabaseManager()
|
|
self.sales_thread = None
|
|
self.qr_thread = None # QR 생성 스레드 추가
|
|
self.sales_data = []
|
|
self._gui_settings = self._load_settings()
|
|
self.init_ui()
|
|
|
|
def init_ui(self):
|
|
"""UI 초기화"""
|
|
self.setWindowTitle('POS 판매 조회')
|
|
saved_geo = self._gui_settings.get('window_geometry')
|
|
if saved_geo and len(saved_geo) == 4:
|
|
self.setGeometry(*saved_geo)
|
|
else:
|
|
self.setGeometry(100, 100, 1300, 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)
|
|
|
|
# === 프린터 선택 토글 버튼 추가 ===
|
|
self.printer_toggle = QPushButton('🖨️ Zebra 라벨')
|
|
self.printer_toggle.setCheckable(True)
|
|
self.printer_toggle.setChecked(True) # 기본값: Zebra
|
|
self.printer_toggle.setToolTip('클릭하여 프린터 전환\n✓ Zebra QL-810W 라벨\n✗ POS 영수증 프린터')
|
|
self.printer_toggle.clicked.connect(self.toggle_printer_mode)
|
|
self.printer_toggle.setStyleSheet("""
|
|
QPushButton {
|
|
padding: 8px 16px;
|
|
font-weight: bold;
|
|
border: 2px solid #2196F3;
|
|
border-radius: 4px;
|
|
background-color: #E3F2FD;
|
|
color: #1976D2;
|
|
}
|
|
QPushButton:checked {
|
|
background-color: #FFF3E0;
|
|
border-color: #FF9800;
|
|
color: #E65100;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #BBDEFB;
|
|
}
|
|
QPushButton:checked:hover {
|
|
background-color: #FFE0B2;
|
|
}
|
|
""")
|
|
settings_layout.addWidget(self.printer_toggle)
|
|
|
|
# POS 프린터 설정 버튼 (토글이 POS일 때만 표시)
|
|
self.pos_settings_btn = QPushButton('⚙️ POS 설정')
|
|
self.pos_settings_btn.setVisible(False)
|
|
self.pos_settings_btn.clicked.connect(self.open_pos_settings)
|
|
self.pos_settings_btn.setToolTip('POS 프린터 IP/포트 설정')
|
|
self.pos_settings_btn.setStyleSheet('background-color: #9E9E9E; color: white; padding: 8px; font-weight: bold;')
|
|
settings_layout.addWidget(self.pos_settings_btn)
|
|
|
|
settings_layout.addStretch()
|
|
|
|
main_layout.addWidget(settings_group)
|
|
|
|
# === 2. 판매 내역 테이블 ===
|
|
sales_group = QGroupBox('판매 내역')
|
|
sales_layout = QVBoxLayout()
|
|
sales_group.setLayout(sales_layout)
|
|
|
|
self.sales_table = QTableWidget()
|
|
col_count = len(self.SALES_COLUMNS)
|
|
self.sales_table.setColumnCount(col_count)
|
|
self.sales_table.setHorizontalHeaderLabels([c[0] for c in self.SALES_COLUMNS])
|
|
|
|
# 컬럼 폭: 저장된 값 우선, 없으면 SALES_COLUMNS 기본값
|
|
saved_widths = self._gui_settings.get('sales_column_widths')
|
|
for i, (_, default_w, _) in enumerate(self.SALES_COLUMNS):
|
|
w = saved_widths[i] if saved_widths and len(saved_widths) == col_count else default_w
|
|
self.sales_table.setColumnWidth(i, w)
|
|
|
|
self.sales_table.horizontalHeader().setStretchLastSection(True)
|
|
self.sales_table.horizontalHeader().sectionResized.connect(self._on_column_resized)
|
|
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.sales_table.doubleClicked.connect(self.show_sale_detail)
|
|
self.sales_table.cellClicked.connect(self.on_cell_clicked)
|
|
|
|
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()
|
|
|
|
# 자동 새로고침 타이머 (5초마다)
|
|
self.refresh_timer = QTimer()
|
|
self.refresh_timer.timeout.connect(self.refresh_sales)
|
|
self.refresh_timer.start(5000) # 5초 = 5000ms
|
|
|
|
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에 데이터 채우기"""
|
|
# 컬럼 인덱스 맵 (SALES_COLUMNS 순서 기반)
|
|
COL = {key: i for i, (_, _, key) in enumerate(self.SALES_COLUMNS)}
|
|
|
|
self.sales_table.setRowCount(len(sales_list))
|
|
|
|
# 적립 완료 셀 스타일
|
|
CLAIMED_COLOR = QColor('#4CAF50')
|
|
def make_claimed_font(underline=True):
|
|
f = QFont()
|
|
f.setBold(True)
|
|
if underline:
|
|
f.setUnderline(True)
|
|
return f
|
|
|
|
for row, sale in enumerate(sales_list):
|
|
# 주문번호
|
|
self.sales_table.setItem(row, COL['order_no'],
|
|
QTableWidgetItem(sale['order_no']))
|
|
|
|
# 시간
|
|
self.sales_table.setItem(row, COL['time'],
|
|
QTableWidgetItem(sale['time']))
|
|
|
|
# 금액 (우측 정렬, 천단위 콤마)
|
|
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원")
|
|
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
self.sales_table.setItem(row, COL['amount'], amount_item)
|
|
|
|
# 고객명 (MSSQL POS)
|
|
self.sales_table.setItem(row, COL['customer'],
|
|
QTableWidgetItem(sale['customer']))
|
|
|
|
# 품목수 (중앙 정렬)
|
|
count_item = QTableWidgetItem(str(sale['item_count']))
|
|
count_item.setTextAlignment(Qt.AlignCenter)
|
|
self.sales_table.setItem(row, COL['item_count'], count_item)
|
|
|
|
# 적립자 (SQLite 마일리지)
|
|
claimed_name_item = QTableWidgetItem(sale['claimed_name'])
|
|
if sale['claimed_name']:
|
|
claimed_name_item.setForeground(CLAIMED_COLOR)
|
|
claimed_name_item.setFont(make_claimed_font())
|
|
claimed_name_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
|
|
self.sales_table.setItem(row, COL['claimed_name'], claimed_name_item)
|
|
|
|
# 전화번호 (SQLite 마일리지)
|
|
claimed_phone_item = QTableWidgetItem(sale['claimed_phone'])
|
|
if sale['claimed_phone']:
|
|
claimed_phone_item.setForeground(CLAIMED_COLOR)
|
|
claimed_phone_item.setFont(make_claimed_font())
|
|
claimed_phone_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
|
|
self.sales_table.setItem(row, COL['claimed_phone'], claimed_phone_item)
|
|
|
|
# 적립포인트 (SQLite 마일리지)
|
|
points_text = f"{sale['claimed_points']:,}P" if sale['claimed_points'] > 0 else ""
|
|
claimed_points_item = QTableWidgetItem(points_text)
|
|
if sale['claimed_points'] > 0:
|
|
claimed_points_item.setForeground(CLAIMED_COLOR)
|
|
claimed_points_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
claimed_points_item.setFont(make_claimed_font())
|
|
claimed_points_item.setToolTip('클릭하여 회원 마일리지 내역 보기')
|
|
self.sales_table.setItem(row, COL['claimed_points'], claimed_points_item)
|
|
|
|
# QR 발행 여부
|
|
qr_status_item = QTableWidgetItem()
|
|
qr_status_item.setTextAlignment(Qt.AlignCenter)
|
|
if sale['qr_issued']:
|
|
qr_status_item.setText('✓')
|
|
qr_status_item.setForeground(CLAIMED_COLOR)
|
|
f = QFont()
|
|
f.setBold(True)
|
|
f.setPointSize(14)
|
|
qr_status_item.setFont(f)
|
|
qr_status_item.setToolTip('QR 발행 완료')
|
|
else:
|
|
qr_status_item.setText('-')
|
|
qr_status_item.setForeground(QColor('#BDBDBD'))
|
|
qr_status_item.setToolTip('QR 미발행')
|
|
self.sales_table.setItem(row, COL['qr_issued'], qr_status_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 on_cell_clicked(self, row, column):
|
|
"""테이블 셀 클릭 이벤트 - 적립 사용자 클릭 시 마일리지 내역 표시"""
|
|
# 컬럼 5(적립자명), 6(전화번호), 7(적립포인트) 중 하나를 클릭했는지 확인
|
|
if column not in [5, 6, 7]:
|
|
return
|
|
|
|
# 전화번호 가져오기 (6번 컬럼)
|
|
phone_item = self.sales_table.item(row, 6)
|
|
if not phone_item or not phone_item.text():
|
|
# 적립 사용자가 없는 경우
|
|
return
|
|
|
|
phone = phone_item.text()
|
|
|
|
# 회원 마일리지 내역 Dialog 표시
|
|
mileage_dialog = UserMileageDialog(phone, self)
|
|
mileage_dialog.exec_()
|
|
|
|
def toggle_printer_mode(self):
|
|
"""프린터 모드 토글"""
|
|
is_zebra = self.printer_toggle.isChecked()
|
|
|
|
if is_zebra:
|
|
# Zebra 모드
|
|
self.printer_toggle.setText('🖨️ Zebra 라벨')
|
|
self.pos_settings_btn.setVisible(False)
|
|
self.preview_checkbox.setEnabled(True) # 미리보기 활성화
|
|
else:
|
|
# POS 모드
|
|
self.printer_toggle.setText('🖨️ POS 영수증')
|
|
self.pos_settings_btn.setVisible(True)
|
|
self.preview_checkbox.setChecked(False) # 미리보기 비활성화
|
|
self.preview_checkbox.setEnabled(False) # ESC/POS는 미리보기 불가
|
|
|
|
# POS 설정 확인
|
|
if not self.check_pos_printer_config():
|
|
QMessageBox.warning(
|
|
self, '설정 필요',
|
|
'POS 프린터 설정이 필요합니다.\n'
|
|
'[POS 설정] 버튼을 클릭하여 IP/포트를 입력하세요.'
|
|
)
|
|
|
|
def open_pos_settings(self):
|
|
"""POS 프린터 설정 다이얼로그 열기"""
|
|
from pos_thermal import POSSettingsDialog
|
|
|
|
dialog = POSSettingsDialog(self)
|
|
if dialog.exec_() == QDialog.Accepted:
|
|
self.status_label.setText('POS 프린터 설정이 저장되었습니다.')
|
|
|
|
def check_pos_printer_config(self):
|
|
"""POS 프린터 설정 확인"""
|
|
config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
|
|
|
|
if not os.path.exists(config_path):
|
|
return False
|
|
|
|
try:
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
config = json.load(f)
|
|
|
|
pos_config = config.get('pos_printer', {})
|
|
ip = pos_config.get('ip')
|
|
|
|
return bool(ip)
|
|
except:
|
|
return False
|
|
|
|
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()
|
|
|
|
# 프린터 모드 확인
|
|
is_zebra = self.printer_toggle.isChecked()
|
|
printer_mode = 'zebra' if is_zebra else 'pos'
|
|
|
|
# POS 모드일 때 설정 로드
|
|
pos_config = None
|
|
if printer_mode == 'pos':
|
|
config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
|
|
try:
|
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
config = json.load(f)
|
|
pos_config = config.get('pos_printer', {})
|
|
|
|
if not pos_config.get('ip'):
|
|
QMessageBox.warning(self, '설정 오류', 'POS 프린터 IP가 설정되지 않았습니다.\n[POS 설정] 버튼을 클릭하세요.')
|
|
return
|
|
except Exception as e:
|
|
QMessageBox.warning(self, '설정 오류', f'POS 프린터 설정을 불러올 수 없습니다.\n{str(e)}')
|
|
return
|
|
|
|
# QR 생성 스레드 시작
|
|
self.qr_thread = QRGeneratorThread(
|
|
order_no,
|
|
amount,
|
|
transaction_time,
|
|
preview_mode,
|
|
printer_mode,
|
|
pos_config
|
|
)
|
|
self.qr_thread.qr_complete.connect(self.on_qr_generated)
|
|
self.qr_thread.start()
|
|
|
|
# 상태 표시
|
|
printer_name = 'Zebra 라벨' if printer_mode == 'zebra' else 'POS 영수증'
|
|
self.status_label.setText(f'QR 생성 중... ({order_no}) - {printer_name}')
|
|
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;')
|
|
|
|
# QR 생성 성공 시 즉시 테이블 새로고침 (QR 발행 상태 실시간 반영)
|
|
self.refresh_sales()
|
|
|
|
# 미리보기 모드면 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 _load_settings(self):
|
|
try:
|
|
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
def _save_settings(self):
|
|
try:
|
|
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(self._gui_settings, f, ensure_ascii=False, indent=2)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_column_resized(self, index, old_size, new_size):
|
|
widths = [self.sales_table.columnWidth(i) for i in range(self.sales_table.columnCount())]
|
|
self._gui_settings['sales_column_widths'] = widths
|
|
|
|
def closeEvent(self, event):
|
|
"""종료 시 정리 + 설정 저장"""
|
|
# 컬럼 폭 최종 저장
|
|
if hasattr(self, 'sales_table'):
|
|
widths = [self.sales_table.columnWidth(i) for i in range(self.sales_table.columnCount())]
|
|
self._gui_settings['sales_column_widths'] = widths
|
|
# 윈도우 위치/크기 저장
|
|
geo = self.geometry()
|
|
self._gui_settings['window_geometry'] = [geo.x(), geo.y(), geo.width(), geo.height()]
|
|
self._save_settings()
|
|
|
|
# 자동 새로고침 타이머 중지
|
|
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_())
|