feat: 프로젝트 초기 구조 설정
- PyQt5 POS 판매 조회 GUI (Phase 1 완료) - Flask API 서버 스켈레톤 (Phase 2 준비) - SQLite 마일리지 DB 스키마 설계 - 프로젝트 문서 및 README 추가 - 기본 디렉터리 구조 생성 Phase 1: POS 판매 내역 조회 GUI 완료 Phase 2: QR 토큰 생성 및 마일리지 적립 (예정) Phase 3: 카카오 로그인 연동 (예정) Phase 4: 마일리지 시스템 완성 (예정) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
87
backend/api/flask_app.py
Normal file
87
backend/api/flask_app.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Flask API 서버
|
||||
QR 토큰 생성 및 마일리지 적립 API
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# TODO: Phase 2에서 구현 예정
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""헬스 체크 엔드포인트"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'message': 'Pharmacy POS QR System API'
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/claim/token/generate', methods=['POST'])
|
||||
def generate_claim_token():
|
||||
"""
|
||||
영수증 QR 토큰 생성
|
||||
|
||||
Request:
|
||||
{
|
||||
"transaction_id": "20251024000042",
|
||||
"total_amount": 50000,
|
||||
"pharmacy_id": "YANGGU001"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"qr_url": "https://pharmacy.example.com/claim?t=...",
|
||||
"claimable_points": 1500,
|
||||
"expires_at": "2025-11-23T14:30:00"
|
||||
}
|
||||
"""
|
||||
# TODO: Phase 2에서 구현
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Not implemented yet (Phase 2)'
|
||||
}), 501
|
||||
|
||||
|
||||
@app.route('/api/claim', methods=['GET'])
|
||||
def validate_claim_token():
|
||||
"""
|
||||
토큰 검증 및 정보 조회
|
||||
|
||||
Query: ?t={token}
|
||||
"""
|
||||
# TODO: Phase 2에서 구현
|
||||
return jsonify({
|
||||
'valid': False,
|
||||
'error': 'not_implemented'
|
||||
}), 501
|
||||
|
||||
|
||||
@app.route('/api/claim/confirm', methods=['POST'])
|
||||
def confirm_claim():
|
||||
"""
|
||||
마일리지 적립 실행 (로그인 후)
|
||||
"""
|
||||
# TODO: Phase 2에서 구현
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Not implemented yet (Phase 2)'
|
||||
}), 501
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
port=5000,
|
||||
debug=True
|
||||
)
|
||||
6
backend/api/requirements.txt
Normal file
6
backend/api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.0
|
||||
Flask-CORS==4.0.0
|
||||
sqlalchemy==2.0.23
|
||||
pyodbc==5.0.1
|
||||
qrcode==7.4.2
|
||||
Pillow==10.1.0
|
||||
222
backend/db/dbsetup.py
Normal file
222
backend/db/dbsetup.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
PIT3000 Database Setup
|
||||
SQLAlchemy 기반 데이터베이스 연결 및 스키마 정의
|
||||
Windows/Linux 크로스 플랫폼 지원
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, MetaData, text
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Text
|
||||
import urllib.parse
|
||||
import platform
|
||||
import pyodbc
|
||||
|
||||
# 기본 설정
|
||||
Base = declarative_base()
|
||||
|
||||
def get_available_odbc_driver():
|
||||
"""
|
||||
시스템에서 사용 가능한 SQL Server ODBC 드라이버를 자동 감지
|
||||
Windows/Linux 환경에서 모두 동작하도록 설계
|
||||
"""
|
||||
try:
|
||||
# 사용 가능한 ODBC 드라이버 목록 조회
|
||||
available_drivers = pyodbc.drivers()
|
||||
|
||||
# SQL Server 드라이버 우선순위 (최신 버전 우선)
|
||||
preferred_drivers = [
|
||||
"ODBC Driver 18 for SQL Server", # 최신
|
||||
"ODBC Driver 17 for SQL Server", # 일반적
|
||||
"ODBC Driver 13 for SQL Server", # 구버전
|
||||
"ODBC Driver 11 for SQL Server", # 구버전
|
||||
"SQL Server Native Client 11.0", # Windows 레거시
|
||||
"SQL Server", # 기본
|
||||
]
|
||||
|
||||
# 시스템별 특화 드라이버 체크
|
||||
os_name = platform.system().lower()
|
||||
|
||||
if os_name == "linux":
|
||||
# Linux에서 주로 사용되는 드라이버들
|
||||
linux_drivers = [
|
||||
"ODBC Driver 18 for SQL Server",
|
||||
"ODBC Driver 17 for SQL Server",
|
||||
"FreeTDS", # Linux 오픈소스 드라이버
|
||||
]
|
||||
# Linux 드라이버를 우선순위에 추가
|
||||
for driver in linux_drivers:
|
||||
if driver not in preferred_drivers:
|
||||
preferred_drivers.insert(0, driver)
|
||||
|
||||
# 우선순위에 따라 사용 가능한 드라이버 찾기
|
||||
for driver in preferred_drivers:
|
||||
if driver in available_drivers:
|
||||
print(f"[DBSETUP] 감지된 ODBC 드라이버: {driver} (OS: {platform.system()})")
|
||||
return driver
|
||||
|
||||
# 사용 가능한 드라이버가 없으면 기본값 반환
|
||||
if available_drivers:
|
||||
fallback_driver = available_drivers[0]
|
||||
print(f"[DBSETUP] 기본 드라이버 사용: {fallback_driver}")
|
||||
return fallback_driver
|
||||
else:
|
||||
# 최후의 수단으로 기본 드라이버명 반환
|
||||
default_driver = "ODBC Driver 17 for SQL Server"
|
||||
print(f"[DBSETUP] 경고: ODBC 드라이버를 찾을 수 없어 기본값 사용: {default_driver}")
|
||||
return default_driver
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DBSETUP] 드라이버 감지 중 오류 발생: {e}")
|
||||
return "ODBC Driver 17 for SQL Server" # 기본값 반환
|
||||
|
||||
class DatabaseConfig:
|
||||
"""PIT3000 데이터베이스 연결 설정"""
|
||||
|
||||
SERVER = "192.168.0.4\\PM2014"
|
||||
USERNAME = "sa"
|
||||
PASSWORD = "tmddls214!%(" # 원본 비밀번호
|
||||
|
||||
# 동적 ODBC 드라이버 감지
|
||||
DRIVER = get_available_odbc_driver()
|
||||
|
||||
# URL 인코딩된 비밀번호
|
||||
PASSWORD_ENCODED = urllib.parse.quote_plus(PASSWORD)
|
||||
|
||||
# URL 인코딩된 드라이버
|
||||
DRIVER_ENCODED = urllib.parse.quote_plus(DRIVER)
|
||||
|
||||
# 데이터베이스별 연결 문자열 (동적 드라이버 사용)
|
||||
@classmethod
|
||||
def get_database_urls(cls):
|
||||
"""동적으로 생성된 데이터베이스 연결 URL 딕셔너리 반환"""
|
||||
base_url = f"mssql+pyodbc://{cls.USERNAME}:{cls.PASSWORD_ENCODED}@{cls.SERVER}"
|
||||
# Connection Timeout을 60초로 증가, Login Timeout 추가
|
||||
driver_params = f"driver={cls.DRIVER_ENCODED}&Encrypt=no&TrustServerCertificate=yes&Connection+Timeout=60&Login+Timeout=30"
|
||||
|
||||
return {
|
||||
# 핵심 업무 데이터베이스
|
||||
'PM_BASE': f"{base_url}/PM_BASE?{driver_params}", # 환자 정보, 개인정보 관리
|
||||
'PM_PRES': f"{base_url}/PM_PRES?{driver_params}", # 처방전, 실제 판매 데이터 (SALE_sub)
|
||||
'PM_DRUG': f"{base_url}/PM_DRUG?{driver_params}", # 약품 마스터 데이터 (CD_GOODS), 창고 거래 (WH_sub)
|
||||
|
||||
# 재고 관리 시스템 (2025-09-20 추가) ⭐ 핵심
|
||||
'PM_DUMS': f"{base_url}/PM_DUMS?{driver_params}", # 실제 재고 관리 (INVENTORY, NIMS_REALTIME_INVENTORY)
|
||||
|
||||
# 알림 및 통신 시스템
|
||||
'PM_ALIMI': f"{base_url}/PM_ALIMI?{driver_params}", # 알림톡, SMS 관리
|
||||
'PM_ALDB': f"{base_url}/PM_ALDB?{driver_params}", # 알림 데이터베이스
|
||||
|
||||
# EDI 전자문서교환 시스템
|
||||
'PM_EDIRECE': f"{base_url}/PM_EDIRECE?{driver_params}", # EDI 수신 데이터
|
||||
'PM_EDISEND': f"{base_url}/PM_EDISEND?{driver_params}", # EDI 발송 데이터
|
||||
|
||||
# 부가 시스템
|
||||
'PM_IMAGE': f"{base_url}/PM_IMAGE?{driver_params}", # 약품 이미지, 사진 관리
|
||||
'PM_JOBLOG': f"{base_url}/PM_JOBLOG?{driver_params}", # 작업 로그, 시스템 로그
|
||||
}
|
||||
|
||||
# 하위 호환성을 위한 DATABASES 속성
|
||||
@property
|
||||
def DATABASES(self):
|
||||
"""하위 호환성을 위한 DATABASES 속성"""
|
||||
return self.get_database_urls()
|
||||
|
||||
class DatabaseManager:
|
||||
"""데이터베이스 연결 관리자"""
|
||||
|
||||
def __init__(self):
|
||||
self.engines = {}
|
||||
self.sessions = {}
|
||||
self.database_urls = DatabaseConfig.get_database_urls()
|
||||
|
||||
def get_engine(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 엔진 반환"""
|
||||
if database not in self.engines:
|
||||
self.engines[database] = create_engine(
|
||||
self.database_urls[database],
|
||||
pool_size=5, # 커넥션 수 감소 (불필요한 연결 방지)
|
||||
max_overflow=10, # 최대 15개까지
|
||||
pool_timeout=60, # 풀 대기 시간 60초로 증가
|
||||
pool_recycle=1800, # 30분마다 재활용 (끊어진 연결 방지)
|
||||
pool_pre_ping=True, # 🔥 사용 전 연결 체크 (가장 중요!)
|
||||
echo=False, # True로 설정하면 SQL 쿼리 로깅
|
||||
connect_args={
|
||||
'timeout': 60, # pyodbc 레벨 타임아웃
|
||||
}
|
||||
)
|
||||
return self.engines[database]
|
||||
|
||||
def get_session(self, database='PM_BASE'):
|
||||
"""특정 데이터베이스 세션 반환"""
|
||||
if database not in self.sessions:
|
||||
engine = self.get_engine(database)
|
||||
Session = sessionmaker(bind=engine)
|
||||
self.sessions[database] = Session()
|
||||
return self.sessions[database]
|
||||
|
||||
def rollback_session(self, database='PM_BASE'):
|
||||
"""세션 롤백 (트랜잭션 에러 복구용)"""
|
||||
if database in self.sessions:
|
||||
try:
|
||||
self.sessions[database].rollback()
|
||||
print(f"[DB Manager] {database} 세션 롤백 완료")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] {database} 세션 롤백 실패: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
def reset_session(self, database='PM_BASE'):
|
||||
"""세션 재생성 (복구 불가능한 경우)"""
|
||||
if database in self.sessions:
|
||||
try:
|
||||
self.sessions[database].close()
|
||||
del self.sessions[database]
|
||||
print(f"[DB Manager] {database} 세션 삭제 완료")
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] {database} 세션 삭제 실패: {e}")
|
||||
# 새 세션 생성
|
||||
return self.get_session(database)
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
engine = self.get_engine(database)
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT 1"))
|
||||
return True, f"{database} 연결 성공"
|
||||
except Exception as e:
|
||||
return False, f"{database} 연결 실패: {e}"
|
||||
|
||||
def close_all(self):
|
||||
"""모든 연결 종료"""
|
||||
for session in self.sessions.values():
|
||||
session.close()
|
||||
for engine in self.engines.values():
|
||||
engine.dispose()
|
||||
|
||||
# 전역 데이터베이스 매니저 인스턴스
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
def get_db_session(database='PM_BASE'):
|
||||
"""데이터베이스 세션 획득"""
|
||||
return db_manager.get_session(database)
|
||||
|
||||
def test_all_connections():
|
||||
"""모든 데이터베이스 연결 테스트"""
|
||||
print("=== PIT3000 데이터베이스 연결 테스트 ===")
|
||||
print(f"감지된 ODBC 드라이버: {DatabaseConfig.DRIVER}")
|
||||
print(f"운영체제: {platform.system()} {platform.release()}")
|
||||
print("-" * 50)
|
||||
|
||||
database_urls = DatabaseConfig.get_database_urls()
|
||||
for db_name in database_urls.keys():
|
||||
success, message = db_manager.test_connection(db_name)
|
||||
status = "[OK]" if success else "[FAIL]"
|
||||
print(f"{status} {message}")
|
||||
|
||||
print("\n연결 테스트 완료!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_all_connections()
|
||||
81
backend/db/mileage_schema.sql
Normal file
81
backend/db/mileage_schema.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- SQLite 마일리지 데이터베이스 스키마
|
||||
-- pharmacy-pos-qr-system/backend/db/mileage_schema.sql
|
||||
|
||||
-- 1. 사용자 테이블 (카카오 로그인 계정)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nickname VARCHAR(100),
|
||||
profile_image_url VARCHAR(500),
|
||||
email VARCHAR(200),
|
||||
is_email_verified BOOLEAN DEFAULT FALSE,
|
||||
phone VARCHAR(20),
|
||||
mileage_balance INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. 외부 로그인 매핑 테이블
|
||||
CREATE TABLE IF NOT EXISTS customer_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
provider VARCHAR(20) NOT NULL,
|
||||
provider_user_id VARCHAR(100) NOT NULL,
|
||||
provider_data TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(provider, provider_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_identities_user ON customer_identities(user_id);
|
||||
|
||||
-- 3. 영수증 QR 토큰 테이블
|
||||
CREATE TABLE IF NOT EXISTS claim_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id VARCHAR(20) NOT NULL,
|
||||
pharmacy_id VARCHAR(20),
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
total_amount INTEGER NOT NULL,
|
||||
claimable_points INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME,
|
||||
claimed_by_user_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (claimed_by_user_id) REFERENCES users(id),
|
||||
UNIQUE(transaction_id),
|
||||
UNIQUE(token_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON claim_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_expires ON claim_tokens(expires_at);
|
||||
|
||||
-- 4. 마일리지 원장 테이블
|
||||
CREATE TABLE IF NOT EXISTS mileage_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
points INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
reason VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(transaction_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_user ON mileage_ledger(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_transaction ON mileage_ledger(transaction_id);
|
||||
|
||||
-- 5. POS 고객 연결 테이블
|
||||
CREATE TABLE IF NOT EXISTS pos_customer_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
pharmacy_id VARCHAR(20),
|
||||
cuscode VARCHAR(10),
|
||||
customer_name VARCHAR(50),
|
||||
linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(user_id, pharmacy_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
|
||||
44
backend/gui/README.md
Normal file
44
backend/gui/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# POS 판매 조회 GUI
|
||||
|
||||
PyQt5 기반 POS 판매 내역 조회 프로그램
|
||||
|
||||
## 기능
|
||||
|
||||
- 날짜별 판매 내역 조회
|
||||
- 실시간 총 매출 집계
|
||||
- 판매 상세 품목 조회 (더블클릭)
|
||||
- QR 생성 버튼 (Phase 2 준비)
|
||||
|
||||
## 실행 방법
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# GUI 실행
|
||||
python pos_sales_gui.py
|
||||
```
|
||||
|
||||
## 데이터베이스 연결
|
||||
|
||||
MSSQL PM_PRES 데이터베이스에 연결합니다.
|
||||
- SALE_MAIN: 판매 헤더
|
||||
- SALE_SUB: 판매 상세
|
||||
|
||||
연결 설정은 `../db/dbsetup.py`에서 관리됩니다.
|
||||
|
||||
## 스크린샷
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ POS 판매 조회 [_] [□] [X] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 날짜: [2026-01-23] [새로고침] [QR 생성] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 주문번호 시간 금액 고객명 품목수 │
|
||||
│ 20260123000042 14:30 45,000원 김철수 3 │
|
||||
│ 20260123000041 14:15 12,000원 [비고객] 1 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 상태: 3건 조회 완료 | 총 매출: 125,500원 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
384
backend/gui/pos_sales_gui.py
Normal file
384
backend/gui/pos_sales_gui.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
POS 판매 내역 조회 GUI (PyQt5)
|
||||
MSSQL SALE_MAIN 테이블에서 오늘 판매 내역을 조회하여 표시
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
||||
QDialog, QMessageBox, QDateEdit
|
||||
)
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QDate
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
# 데이터베이스 연결
|
||||
sys.path.insert(0, '.')
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
|
||||
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 조회)"""
|
||||
conn = None
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
conn = db_manager.get_engine('PM_PRES').raw_connection()
|
||||
cursor = 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
|
||||
"""
|
||||
|
||||
cursor.execute(query, self.date_str)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
sales_list = []
|
||||
for row in rows:
|
||||
order_no, insert_time, sale_amount, customer = row
|
||||
|
||||
# 품목 수 조회 (SALE_SUB)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM SALE_SUB
|
||||
WHERE SL_NO_order = ?
|
||||
""", order_no)
|
||||
item_count_row = cursor.fetchone()
|
||||
item_count = item_count_row[0] if item_count_row else 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
|
||||
})
|
||||
|
||||
self.query_complete.emit(sales_list)
|
||||
|
||||
except Exception as e:
|
||||
self.query_error.emit(str(e))
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
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.sales_data = []
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle('POS 판매 조회')
|
||||
self.setGeometry(100, 100, 900, 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 생성 버튼 (Phase 2 준비 - 현재 비활성화)
|
||||
self.qr_btn = QPushButton('QR 생성')
|
||||
self.qr_btn.setEnabled(False)
|
||||
self.qr_btn.setStyleSheet('background-color: #9E9E9E; color: white; padding: 8px; font-weight: bold;')
|
||||
self.qr_btn.setToolTip('후향적 적립 QR (추후 개발)')
|
||||
settings_layout.addWidget(self.qr_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()
|
||||
self.sales_table.setColumnCount(5)
|
||||
self.sales_table.setHorizontalHeaderLabels([
|
||||
'주문번호', '시간', '금액', '고객명', '품목수'
|
||||
])
|
||||
self.sales_table.setColumnWidth(0, 180)
|
||||
self.sales_table.setColumnWidth(1, 80)
|
||||
self.sales_table.setColumnWidth(2, 120)
|
||||
self.sales_table.setColumnWidth(3, 120)
|
||||
self.sales_table.setColumnWidth(4, 80)
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
# 고객명
|
||||
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)
|
||||
|
||||
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 closeEvent(self, event):
|
||||
"""종료 시 정리"""
|
||||
if self.sales_thread and self.sales_thread.isRunning():
|
||||
self.sales_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_())
|
||||
5
backend/gui/requirements.txt
Normal file
5
backend/gui/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
PyQt5==5.15.10
|
||||
PyQt5-Qt5==5.15.2
|
||||
PyQt5-sip==12.13.0
|
||||
sqlalchemy==2.0.23
|
||||
pyodbc==5.0.1
|
||||
Reference in New Issue
Block a user