commit a9041e9c9e9e9303b8d99e3e7e448364faa6ffc5 Author: 시골약사 Date: Fri Jan 23 13:59:00 2026 +0900 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..508e3a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# PyQt +*.ui~ +*.pyc + +# Database +*.db +*.db-journal +*.sqlite +*.sqlite3 +mileage.db +rxprint.db + +# Logs +*.log +logs/ +temp/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# Node.js (for web app) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Next.js +.next/ +out/ +next-env.d.ts + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Docker +docker-compose.override.yml + +# Backup files +*.backup +*.bak + +# Temp files +tmp/ +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..d708a1a --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# 약국 POS QR 적립 시스템 + +후향적 고객 매핑 및 마일리지 적립 시스템 (QR 기반) + +## 프로젝트 개요 + +약국 POS 판매 시 영수증에 QR 코드를 인쇄하고, 고객이 나중에 QR을 스캔하여 카카오 로그인 후 마일리지를 적립받는 시스템입니다. + +### 핵심 문제 해결 + +- **문제**: 약국 POS 판매의 80%는 고객 정보 없이 판매됨 +- **솔루션**: 영수증 QR → 카카오 로그인 → 후향적 고객 매핑 → 마일리지 적립 + +## 프로젝트 구조 + +``` +pharmacy-pos-qr-system/ +├── backend/ +│ ├── gui/ # PyQt5 GUI 애플리케이션 +│ │ └── pos_sales_gui.py +│ ├── api/ # Flask API 서버 +│ ├── db/ # 데이터베이스 설정 +│ │ └── dbsetup.py +│ └── utils/ # 유틸리티 함수 +│ +├── web/ # 웹 애플리케이션 (Next.js/React) +│ +├── docs/ # 문서 +│ └── 후향적적립QR_POS만들기.md +│ +└── docker/ # Docker 설정 +``` + +## 개발 단계 + +### Phase 1: POS 판매 조회 GUI ✅ + +- [x] PyQt5 기반 GUI 구현 +- [x] MSSQL SALE_MAIN 테이블 조회 +- [x] 날짜별 판매 내역 표시 +- [x] 상세 품목 조회 (더블클릭) + +### Phase 2: QR 토큰 생성 (진행 예정) + +- [ ] SQLite mileage.db 스키마 설계 +- [ ] claim_token 생성 로직 +- [ ] QR 코드 생성 및 라벨 인쇄 +- [ ] Flask API 백엔드 구축 + +### Phase 3: 카카오 로그인 연동 (계획) + +- [ ] 카카오 로그인 API 연동 +- [ ] 웹앱 개발 (QR 스캔 랜딩 페이지) +- [ ] 마이페이지 구현 + +### Phase 4: 마일리지 시스템 (계획) + +- [ ] 마일리지 적립/사용 로직 +- [ ] POS 고객 연결 기능 +- [ ] 관리자 대시보드 + +## 기술 스택 + +### Backend +- **Python 3.12+** +- **PyQt5** - GUI 프로그램 +- **Flask** - REST API 서버 +- **SQLAlchemy** - ORM +- **pyodbc** - MSSQL 연결 + +### Database +- **MSSQL** - 기존 POS 데이터 (PM_PRES) +- **SQLite** - 마일리지 데이터 (mileage.db) + +### Web +- **Next.js** or **React** - 웹앱 프레임워크 +- **Tailwind CSS** - 스타일링 + +## 설치 및 실행 + +### 1. Backend GUI (POS 판매 조회) + +```bash +cd backend/gui +python pos_sales_gui.py +``` + +### 2. Flask API 서버 (Phase 2) + +```bash +cd backend/api +pip install -r requirements.txt +python flask_app.py +``` + +### 3. 웹 애플리케이션 (Phase 3) + +```bash +cd web +npm install +npm run dev +``` + +## 데이터베이스 구조 + +### MSSQL (기존 POS) + +- **PM_PRES.SALE_MAIN**: 판매 헤더 +- **PM_PRES.SALE_SUB**: 판매 상세 +- **PM_BASE.CD_PERSON**: 고객 정보 + +### SQLite (신규 마일리지) + +- **users**: 카카오 로그인 계정 +- **customer_identities**: 외부 로그인 매핑 +- **claim_tokens**: 영수증 QR 토큰 +- **mileage_ledger**: 마일리지 원장 +- **pos_customer_links**: POS 고객 연결 + +## 라이선스 + +MIT License + +## 작성자 + +thug0bin (양구청춘약국) diff --git a/backend/api/flask_app.py b/backend/api/flask_app.py new file mode 100644 index 0000000..0cd3f49 --- /dev/null +++ b/backend/api/flask_app.py @@ -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 + ) diff --git a/backend/api/requirements.txt b/backend/api/requirements.txt new file mode 100644 index 0000000..a7b5827 --- /dev/null +++ b/backend/api/requirements.txt @@ -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 diff --git a/backend/db/dbsetup.py b/backend/db/dbsetup.py new file mode 100644 index 0000000..e0ed69c --- /dev/null +++ b/backend/db/dbsetup.py @@ -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() \ No newline at end of file diff --git a/backend/db/mileage_schema.sql b/backend/db/mileage_schema.sql new file mode 100644 index 0000000..20d80d9 --- /dev/null +++ b/backend/db/mileage_schema.sql @@ -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); diff --git a/backend/gui/README.md b/backend/gui/README.md new file mode 100644 index 0000000..45fbd17 --- /dev/null +++ b/backend/gui/README.md @@ -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원 │ +└─────────────────────────────────────────────────┘ +``` diff --git a/backend/gui/pos_sales_gui.py b/backend/gui/pos_sales_gui.py new file mode 100644 index 0000000..02a6d6f --- /dev/null +++ b/backend/gui/pos_sales_gui.py @@ -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_()) diff --git a/backend/gui/requirements.txt b/backend/gui/requirements.txt new file mode 100644 index 0000000..94c7af5 --- /dev/null +++ b/backend/gui/requirements.txt @@ -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 diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..50fd45e --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,113 @@ +# 데이터베이스 구조 + +## 데이터베이스 개요 + +이 시스템은 **두 개의 독립적인 데이터베이스**를 사용합니다: + +1. **MSSQL (PM_PRES)**: 기존 POS 판매 데이터 +2. **SQLite (mileage.db)**: 신규 마일리지 시스템 데이터 + +## MSSQL (기존 POS 데이터) + +### 1. SALE_MAIN (판매 헤더) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| SL_NO_order | VARCHAR(20) | 주문번호 (PK) | +| SL_DT_appl | VARCHAR(8) | 판매일자 (YYYYMMDD) | +| SL_NM_custom | VARCHAR(50) | 고객명 | +| SL_CD_custom | VARCHAR(10) | 고객코드 | +| SL_MY_total | DECIMAL | 총 매출액 | +| SL_MY_sale | DECIMAL | 실 판매액 | +| InsertTime | DATETIME | 등록시간 | + +### 2. SALE_SUB (판매 상세) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| SL_NO_order | VARCHAR(20) | 주문번호 (FK) | +| DrugCode | VARCHAR(20) | 약품코드 | +| SL_NM_item | INT | 수량 | +| SL_TOTAL_PRICE | DECIMAL | 판매가 | + +### 3. CD_PERSON (고객 정보) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| CUSCODE | VARCHAR(10) | 고객코드 (PK) | +| PANAME | VARCHAR(20) | 고객명 | +| PHONE | VARCHAR(20) | 휴대폰 | + +## SQLite (신규 마일리지 데이터) + +### 1. users (카카오 계정) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| id | INTEGER | PK | +| nickname | VARCHAR(100) | 닉네임 | +| email | VARCHAR(200) | 이메일 | +| phone | VARCHAR(20) | 전화번호 | +| mileage_balance | INTEGER | 마일리지 잔액 | + +### 2. customer_identities (외부 로그인) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| id | INTEGER | PK | +| user_id | INTEGER | FK → users.id | +| provider | VARCHAR(20) | 'kakao' | +| provider_user_id | VARCHAR(100) | 카카오 user id | + +### 3. claim_tokens (QR 토큰) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| id | INTEGER | PK | +| transaction_id | VARCHAR(20) | SALE_MAIN.SL_NO_order | +| token_hash | VARCHAR(64) | SHA256 해시 | +| claimable_points | INTEGER | 적립 가능 포인트 | +| expires_at | DATETIME | 만료시간 | +| claimed_at | DATETIME | 적립 완료 시간 | +| claimed_by_user_id | INTEGER | FK → users.id | + +### 4. mileage_ledger (마일리지 원장) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| id | INTEGER | PK | +| user_id | INTEGER | FK → users.id | +| transaction_id | VARCHAR(20) | SALE_MAIN.SL_NO_order | +| points | INTEGER | + 적립, - 사용 | +| balance_after | INTEGER | 거래 후 잔액 | +| reason | VARCHAR(50) | 'PURCHASE_CLAIM' 등 | + +### 5. pos_customer_links (POS 고객 연결) + +| 컬럼명 | 타입 | 설명 | +|--------|------|------| +| id | INTEGER | PK | +| user_id | INTEGER | FK → users.id | +| cuscode | VARCHAR(10) | CD_PERSON.CUSCODE | +| customer_name | VARCHAR(50) | 고객명 | + +## 데이터 연결 구조 + +``` +MSSQL SQLite +──────────────────────────── ──────────────────────────── +SALE_MAIN.SL_NO_order ────────→ claim_tokens.transaction_id + ↓ + mileage_ledger.transaction_id + ↓ + users.id + ↓ +CD_PERSON.CUSCODE ─────────────→ pos_customer_links.cuscode +``` + +## 스키마 초기화 + +```bash +# SQLite 데이터베이스 생성 +sqlite3 backend/db/mileage.db < backend/db/mileage_schema.sql +``` diff --git a/docs/후향적적립QR_POS만들기.md b/docs/후향적적립QR_POS만들기.md new file mode 100644 index 0000000..475d250 --- /dev/null +++ b/docs/후향적적립QR_POS만들기.md @@ -0,0 +1,835 @@ +# 후향적 고객 매핑 및 마일리지 적립 시스템 기획서 + +> **버전**: 1.0 +> **작성일**: 2026-01-23 +> **목적**: POS 판매 후 QR 코드 + 카카오 로그인을 통한 후향적 고객 매핑 및 마일리지 적립 시스템 + +--- + +## 1. 개요 + +### 1-1. 배경 및 문제점 + +현재 약국 POS 시스템의 고객 데이터 현황: + +| 구분 | 비율 | 설명 | +|------|------|------| +| **비고객 판매** | ~80% | 고객 정보 없이 판매 (SL_CD_custom = NULL) | +| **이름만 기록** | ~15% | 고객명만 있고 코드 없음 (SL_NM_custom만 기록) | +| **완전 매핑** | ~5% | 고객코드로 CD_PERSON과 연결됨 | + +**문제점**: +- 대부분의 거래에서 구매자 정보가 누락됨 +- 단골 고객 분석/마케팅 불가 +- 고객 충성도 프로그램 운영 어려움 + +### 1-2. 솔루션 개요 + +**핵심 컨셉**: 영수증 QR → 카카오 로그인 → 후향적 고객 매핑 → 마일리지 적립 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 후향적 고객 매핑 흐름 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [POS 판매] │ +│ ↓ │ +│ 영수증/라벨에 QR 인쇄 (claim_token 포함) │ +│ ↓ │ +│ (시간 경과... 고객이 나중에 QR 촬영) │ +│ ↓ │ +│ 웹앱 랜딩 → 카카오 간편로그인 │ +│ ↓ │ +│ 토큰 검증 → 거래(transaction) 매핑 │ +│ ↓ │ +│ ✅ 마일리지 적립 + CD_PERSON 연결 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 현재 DB 구조 분석 + +### 2-1. 데이터베이스 구성 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ PM_PRES DB (판매 데이터) │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ SALE_MAIN (판매 주문 헤더) │ +│ ├── SL_NO_order: 주문번호 (예: 20251024000002) ← 고유값 │ +│ ├── SL_DAY_SERIAL: 당일 거래 순번 (1, 2, 3...) │ +│ ├── SL_DT_appl: 판매일자 (YYYYMMDD) │ +│ ├── SL_NM_custom: 고객명 (대부분 NULL) │ +│ ├── SL_CD_custom: 고객코드 → CD_PERSON.CUSCODE │ +│ ├── SL_MY_total: 총 매출액 │ +│ ├── SL_MY_discount: 할인액 │ +│ └── InsertTime: 등록시간 │ +│ │ │ +│ └──> SALE_SUB (판매 상세 품목) │ +│ ├── SL_NO_order: 주문번호 (FK) │ +│ ├── DrugCode: 약품코드 │ +│ ├── SL_TOTAL_PRICE: 판매가 │ +│ ├── SL_MY_in_cost: 매입가 │ +│ └── SL_NM_item: 수량 │ +│ │ +│ CD_SUNAB (결제 정보) │ +│ ├── PRESERIAL: 주문번호 참조 ← SALE_MAIN.SL_NO_order │ +│ ├── OTC_CARD: 카드 결제금액 │ +│ └── OTC_CASH: 현금 결제금액 │ +│ │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ PM_BASE DB (고객 마스터) │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ CD_PERSON (고객 정보) │ +│ ├── CUSCODE: 고객코드 (PK) ← SALE_MAIN.SL_CD_custom │ +│ ├── PANAME: 고객명 │ +│ ├── PANUM: 주민번호 (개인식별) │ +│ ├── TEL_NO: 전화번호 1 (집) │ +│ ├── PHONE: 전화번호 2 (휴대폰) │ +│ ├── PHONE2: 전화번호 3 (대체) │ +│ └── CUSETC: 고객 메모/특이사항 (2000자) │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 2-2. 테이블 상세 구조 + +#### SALE_MAIN (판매 주문 헤더) + +| 컬럼명 | 타입 | 설명 | 예시 | +|--------|------|------|------| +| SL_NO_order | VARCHAR(20) | 주문번호 (PK) | `20251024000002` | +| SL_DAY_SERIAL | INT | 당일 거래 순번 | `1`, `2`, `3`... | +| SL_DT_appl | VARCHAR(8) | 판매일자 | `20251024` | +| SL_NM_custom | VARCHAR(50) | 고객명 | `김철수` 또는 NULL | +| SL_CD_custom | VARCHAR(10) | 고객코드 | `0000012345` 또는 NULL | +| SL_MY_total | DECIMAL | 총 매출액 | `50000` | +| SL_MY_discount | DECIMAL | 할인액 | `5000` | +| SL_MY_sale | DECIMAL | 실 판매액 | `45000` | +| InsertTime | DATETIME | 등록시간 | `2025-10-24 14:30:00` | + +#### CD_PERSON (고객 정보) + +| 컬럼명 | 타입 | 설명 | 예시 | +|--------|------|------|------| +| CUSCODE | VARCHAR(10) | 고객코드 (PK) | `0000012345` | +| INSCODE | VARCHAR(10) | 기관코드 | `0000000001` | +| SEQ | INT | 순번 | `1` | +| INDATE | VARCHAR(8) | 등록일 | `20251024` | +| PANAME | VARCHAR(20) | 고객명 | `김철수` | +| PANUM | VARCHAR(13) | 주민번호 | `800101-1******` | +| TEL_NO | VARCHAR(20) | 전화번호 1 | `02-123-4567` | +| PHONE | VARCHAR(20) | 휴대폰 | `010-1234-5678` | +| PHONE2 | VARCHAR(20) | 대체번호 | `010-9876-5432` | +| CUSETC | VARCHAR(2000) | 메모 | `당뇨병 주의` | + +### 2-3. 현재 고객-판매 연결 방식 + +```sql +-- 고객이 식별된 판매 (약 5%) +SELECT + M.SL_NO_order, + M.SL_NM_custom, -- '김철수' + M.SL_CD_custom, -- '0000012345' + P.PANAME, -- CD_PERSON에서 조회 + P.PHONE -- 전화번호 +FROM SALE_MAIN M +LEFT JOIN CD_PERSON P ON M.SL_CD_custom = P.CUSCODE +WHERE M.SL_CD_custom IS NOT NULL + AND M.SL_CD_custom != '' + +-- 비고객 판매 (약 80%) +SELECT * FROM SALE_MAIN +WHERE SL_CD_custom IS NULL OR SL_CD_custom = '' +``` + +--- + +## 3. 신규 테이블 설계 (SQLite - mileage.db) + +### 3-1. DB 분리 구조 + +``` +┌─────────────────────────┐ ┌─────────────────────────────┐ +│ MSSQL (기존) │ │ SQLite (신규: mileage.db) │ +├─────────────────────────┤ ├─────────────────────────────┤ +│ │ │ │ +│ PM_PRES │ │ users │ +│ ├── SALE_MAIN ─────────┼──────────┼─→ kakao_user_id (카카오 키) │ +│ ├── SALE_SUB │ 주문번호 │ │ +│ └── CD_SUNAB │ 연결 │ customer_identities │ +│ │ │ └── provider='kakao' │ +├─────────────────────────┤ │ │ +│ PM_BASE │ │ claim_tokens │ +│ └── CD_PERSON ─────────┼──────────┼─→ transaction_id (주문번호) │ +│ (CUSCODE) │ 고객코드 │ └── token_hash │ +│ │ 연결 │ │ +└─────────────────────────┘ │ mileage_ledger │ + │ ├── user_id │ + │ ├── transaction_id │ + │ └── points (+/-) │ + │ │ + │ pos_customer_links │ + │ ├── user_id │ + │ └── cuscode (CD_PERSON) │ + │ │ + └─────────────────────────────┘ +``` + +### 3-2. 테이블 DDL (SQLite) + +#### users (카카오 로그인 계정) + +```sql +CREATE TABLE 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 +); +``` + +#### customer_identities (외부 로그인 매핑) + +```sql +CREATE TABLE customer_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + provider VARCHAR(20) NOT NULL, -- 'kakao', 'naver', 'google' 등 + provider_user_id VARCHAR(100) NOT NULL, -- 카카오 user id + provider_data TEXT, -- JSON (추가 정보) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider, provider_user_id) -- 중복 방지 +); +CREATE INDEX idx_identities_user ON customer_identities(user_id); +``` + +#### claim_tokens (영수증 QR 토큰) + +```sql +CREATE TABLE claim_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + transaction_id VARCHAR(20) NOT NULL, -- SALE_MAIN.SL_NO_order + pharmacy_id VARCHAR(20), -- 약국 식별자 (다중 약국 대비) + token_hash VARCHAR(64) NOT NULL, -- SHA256 해시 (원문 저장 X) + total_amount INTEGER NOT NULL, -- 거래 금액 + claimable_points INTEGER NOT NULL, -- 적립 가능 포인트 + expires_at DATETIME NOT NULL, -- 만료시간 (14~30일) + claimed_at DATETIME, -- 적립 완료 시간 + claimed_by_user_id INTEGER REFERENCES users(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(transaction_id), -- 1거래 1토큰 + UNIQUE(token_hash) +); +CREATE INDEX idx_tokens_hash ON claim_tokens(token_hash); +CREATE INDEX idx_tokens_expires ON claim_tokens(expires_at); +``` + +#### mileage_ledger (마일리지 원장) + +```sql +CREATE TABLE mileage_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + transaction_id VARCHAR(20), -- SALE_MAIN.SL_NO_order (적립 시) + points INTEGER NOT NULL, -- + 적립, - 사용 + balance_after INTEGER NOT NULL, -- 거래 후 잔액 + reason VARCHAR(50) NOT NULL, -- 'PURCHASE_CLAIM', 'POINT_USE', 'EVENT_BONUS' 등 + description TEXT, -- 상세 설명 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(transaction_id) -- 1거래 1회 적립 보장 +); +CREATE INDEX idx_ledger_user ON mileage_ledger(user_id); +CREATE INDEX idx_ledger_transaction ON mileage_ledger(transaction_id); +``` + +#### pos_customer_links (POS 고객 ↔ 카카오 계정 연결) + +```sql +CREATE TABLE pos_customer_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + pharmacy_id VARCHAR(20), -- 약국 식별자 + cuscode VARCHAR(10), -- CD_PERSON.CUSCODE (기존 고객코드) + customer_name VARCHAR(50), -- 고객명 (캐시) + linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + UNIQUE(user_id, pharmacy_id) -- 약국별 1:1 연결 +); +CREATE INDEX idx_links_cuscode ON pos_customer_links(cuscode); +``` + +--- + +## 4. 후향적 매핑 흐름 상세 + +### 4-1. 전체 시퀀스 다이어그램 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ POS │ │ 영수증 │ │ 고객 │ │ 웹앱 │ │ 서버 │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ │ + │ 1. 판매 완료 │ │ │ │ + │───────────────┼───────────────┼───────────────┼──────────────>│ + │ │ │ │ │ + │ │ 2. QR 인쇄 │ │ │ + │ │<──────────────┼───────────────┼───────────────│ + │ │ (claim_token) │ │ │ + │ │ │ │ │ + │ │ │ 3. QR 촬영 │ │ + │ │ │──────────────>│ │ + │ │ │ │ │ + │ │ │ │ 4. 토큰 검증 │ + │ │ │ │──────────────>│ + │ │ │ │ │ + │ │ │ │ 5. 카카오로그인│ + │ │ │ │<─────────────>│ + │ │ │ │ │ + │ │ │ │ 6. 적립 요청 │ + │ │ │ │──────────────>│ + │ │ │ │ │ + │ │ │ │ 7. 마일리지 │ + │ │ │ │ 적립 완료 │ + │ │ │ │<──────────────│ + │ │ │ │ │ +``` + +### 4-2. 단계별 상세 + +#### Step 1: POS 판매 완료 + +```python +# MSSQL SALE_MAIN에 INSERT 발생 +# 주문번호(SL_NO_order) 생성: 20251024000042 +``` + +#### Step 2: QR 토큰 생성 및 인쇄 + +```python +# 토큰 생성 로직 +import hashlib +import secrets +import datetime + +def generate_claim_token(transaction_id, total_amount, mileage_rate=0.03): + # 1. 랜덤 nonce 생성 + nonce = secrets.token_hex(16) + + # 2. 토큰 원문 생성 + token_raw = f"{transaction_id}:{nonce}:{datetime.datetime.now().isoformat()}" + + # 3. 해시 생성 (원문 저장 X) + token_hash = hashlib.sha256(token_raw.encode()).hexdigest() + + # 4. QR URL 생성 + qr_url = f"https://pharmacy.example.com/claim?t={token_raw}" + + # 5. 적립 포인트 계산 + claimable_points = int(total_amount * mileage_rate) + + # 6. DB 저장 + # INSERT INTO claim_tokens (transaction_id, token_hash, total_amount, + # claimable_points, expires_at) + # VALUES (?, ?, ?, ?, datetime('now', '+30 days')) + + return qr_url +``` + +``` +QR 코드 내용 (URL): +https://pharmacy.example.com/claim?t=20251024000042:a1b2c3d4e5f6:2025-10-24T14:30:00 + +영수증 출력 예시: +┌─────────────────────────────────┐ +│ 양구청춘약국 │ +│ 2025-10-24 14:30 │ +│ │ +│ 타이레놀 500mg ×2 3,000원 │ +│ 밴드 ×1 2,000원 │ +│ ─────────────────────────────│ +│ 합계 5,000원 │ +│ │ +│ [QR 코드] │ +│ │ +│ QR 촬영하고 │ +│ 150P 적립받으세요! │ +│ (유효기간: 30일) │ +└─────────────────────────────────┘ +``` + +#### Step 3~4: QR 촬영 및 토큰 검증 + +```python +@app.route('/claim', methods=['GET']) +def claim_landing(): + token = request.args.get('t') + + # 토큰 파싱 + parts = token.split(':') + transaction_id = parts[0] + + # 해시 계산 + token_hash = hashlib.sha256(token.encode()).hexdigest() + + # DB 검증 + claim = db.query(""" + SELECT * FROM claim_tokens + WHERE token_hash = ? + AND expires_at > datetime('now') + AND claimed_at IS NULL + """, [token_hash]) + + if not claim: + return render_template('claim_error.html', + error="만료되었거나 이미 사용된 영수증입니다.") + + # 세션에 토큰 정보 저장 + session['claim_token'] = token + session['claim_info'] = { + 'transaction_id': claim['transaction_id'], + 'claimable_points': claim['claimable_points'] + } + + # 로그인 페이지로 리다이렉트 + return redirect('/auth/kakao/login') +``` + +#### Step 5: 카카오 로그인 + +```python +@app.route('/auth/kakao/callback') +def kakao_callback(): + code = request.args.get('code') + + # 액세스 토큰 발급 + access_token = kakao_api.get_access_token(code) + + # 사용자 정보 조회 + kakao_user = kakao_api.get_user_info(access_token) + # { + # "id": 1234567890, + # "kakao_account": { + # "email": "user@example.com", + # "profile": {"nickname": "홍길동"} + # } + # } + + # users 테이블에서 찾기 또는 생성 + user = get_or_create_user( + provider='kakao', + provider_user_id=str(kakao_user['id']), + nickname=kakao_user['kakao_account']['profile']['nickname'], + email=kakao_user['kakao_account'].get('email') + ) + + # 세션에 user_id 저장 + session['user_id'] = user['id'] + + # 클레임 진행 중이면 적립 페이지로 + if session.get('claim_token'): + return redirect('/claim/confirm') + + return redirect('/mypage') +``` + +#### Step 6~7: 마일리지 적립 + +```python +@app.route('/claim/confirm', methods=['POST']) +def claim_confirm(): + user_id = session.get('user_id') + claim_info = session.get('claim_info') + + if not user_id or not claim_info: + return jsonify({'error': '세션이 만료되었습니다.'}), 400 + + transaction_id = claim_info['transaction_id'] + points = claim_info['claimable_points'] + + try: + with db.transaction(): + # 1. 토큰 사용 처리 + db.execute(""" + UPDATE claim_tokens + SET claimed_at = datetime('now'), + claimed_by_user_id = ? + WHERE transaction_id = ? + AND claimed_at IS NULL + """, [user_id, transaction_id]) + + # 2. 마일리지 적립 + current_balance = db.query( + "SELECT mileage_balance FROM users WHERE id = ?", + [user_id] + )['mileage_balance'] + + new_balance = current_balance + points + + db.execute(""" + INSERT INTO mileage_ledger + (user_id, transaction_id, points, balance_after, reason, description) + VALUES (?, ?, ?, ?, 'PURCHASE_CLAIM', ?) + """, [user_id, transaction_id, points, new_balance, + f"영수증 QR 적립 ({transaction_id})"]) + + # 3. 잔액 업데이트 + db.execute(""" + UPDATE users SET mileage_balance = ? WHERE id = ? + """, [new_balance, user_id]) + + # 4. POS 고객 연결 (선택적) + link_pos_customer(user_id, transaction_id) + + # 세션 정리 + session.pop('claim_token', None) + session.pop('claim_info', None) + + return jsonify({ + 'success': True, + 'points_earned': points, + 'new_balance': new_balance + }) + + except sqlite3.IntegrityError: + # transaction_id UNIQUE 제약 위반 = 이미 적립됨 + return jsonify({'error': '이미 적립된 영수증입니다.'}), 400 +``` + +--- + +## 5. API 설계 + +### 5-1. 토큰 관련 API + +#### POST /api/claim/token/generate +영수증 QR 토큰 생성 (POS에서 호출) + +**Request**: +```json +{ + "transaction_id": "20251024000042", + "total_amount": 50000, + "pharmacy_id": "YANGGU001" +} +``` + +**Response**: +```json +{ + "success": true, + "qr_url": "https://pharmacy.example.com/claim?t=...", + "claimable_points": 1500, + "expires_at": "2025-11-23T14:30:00" +} +``` + +#### GET /api/claim?t={token} +토큰 검증 및 정보 조회 + +**Response (유효)**: +```json +{ + "valid": true, + "transaction_id": "20251024000042", + "claimable_points": 1500, + "total_amount": 50000, + "expires_at": "2025-11-23T14:30:00" +} +``` + +**Response (무효)**: +```json +{ + "valid": false, + "error": "expired|claimed|not_found" +} +``` + +#### POST /api/claim/confirm +적립 실행 (로그인 후) + +**Request**: +```json +{ + "token": "20251024000042:a1b2c3d4:..." +} +``` + +**Response**: +```json +{ + "success": true, + "points_earned": 1500, + "new_balance": 3500, + "transaction_id": "20251024000042" +} +``` + +### 5-2. 마일리지 조회 API + +#### GET /api/me/mileage +내 마일리지 잔액 조회 + +**Response**: +```json +{ + "user_id": 123, + "nickname": "홍길동", + "mileage_balance": 3500, + "total_earned": 10000, + "total_used": 6500 +} +``` + +#### GET /api/me/mileage/ledger +적립/사용 내역 조회 + +**Query Parameters**: +- `limit`: 조회 개수 (기본 20) +- `offset`: 페이지네이션 + +**Response**: +```json +{ + "ledger": [ + { + "id": 45, + "points": 1500, + "balance_after": 3500, + "reason": "PURCHASE_CLAIM", + "description": "영수증 QR 적립 (20251024000042)", + "created_at": "2025-10-24T15:30:00" + }, + { + "id": 44, + "points": -2000, + "balance_after": 2000, + "reason": "POINT_USE", + "description": "결제 시 사용", + "created_at": "2025-10-20T10:00:00" + } + ], + "total_count": 25, + "has_more": true +} +``` + +### 5-3. 인증 API + +#### GET /auth/kakao/login +카카오 로그인 페이지로 리다이렉트 + +#### GET /auth/kakao/callback +카카오 콜백 처리 + +#### POST /auth/logout +로그아웃 + +--- + +## 6. 보안 및 부정 사용 방지 + +### 6-1. 토큰 보안 + +| 보안 요소 | 구현 방법 | +|-----------|-----------| +| **1회성 사용** | `claimed_at IS NULL` 체크 후 즉시 업데이트 | +| **만료 시간** | `expires_at` 필드로 14~30일 제한 | +| **해시 저장** | 토큰 원문 저장 X, SHA256 해시만 저장 | +| **중복 적립 방지** | `mileage_ledger.transaction_id UNIQUE` | + +### 6-2. 부정 사용 시나리오 및 대응 + +| 시나리오 | 대응 방법 | +|----------|-----------| +| QR 사진 공유 | 1회성 토큰 + 로그인 필수 | +| 토큰 위조 | 서버 서명 검증 (HMAC) | +| 중복 적립 시도 | DB UNIQUE 제약 | +| 만료 토큰 사용 | `expires_at` 검증 | +| 타인 영수증 도용 | (완벽 방지 어려움) 영수증 일부 숫자 확인 옵션 | + +### 6-3. 적립률 정책 예시 + +```python +MILEAGE_CONFIG = { + 'default_rate': 0.03, # 기본 3% + 'vip_rate': 0.05, # VIP 5% + 'min_points': 10, # 최소 적립 10P + 'max_points': 10000, # 최대 적립 10,000P + 'expiry_days': 30, # 토큰 유효기간 30일 + 'excluded_categories': [], # 적립 제외 품목 (있으면) +} +``` + +--- + +## 7. POS 관리화면 연동 + +### 7-1. 거래 상세 화면 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 거래 상세 - 20251024000042 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 판매일시: 2025-10-24 14:30:00 │ +│ 총 금액: 50,000원 │ +│ 결제 방법: 카드 │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ 멤버십 상태: ✅ 연결됨 ││ +│ │ 연결 고객: 홍길동 (카카오) ││ +│ │ 연결 일시: 2025-10-24 16:00:00 ││ +│ │ 적립 포인트: 1,500P ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ +│ 품목: │ +│ - 타이레놀 500mg × 2 3,000원 │ +│ - 밴드 × 1 2,000원 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 7-2. 고객 목록 필터 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 고객 관리 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 필터: [전체 ▼] [카카오 연결 고객만 ☑] │ +│ │ +│ ┌───────┬──────────┬─────────────┬───────────┬───────────┐ │ +│ │ 고객명 │ 카카오 연결│ 마일리지 잔액│ 총 구매액 │ 방문 횟수 │ │ +│ ├───────┼──────────┼─────────────┼───────────┼───────────┤ │ +│ │ 홍길동 │ ✅ │ 3,500P │ 150,000원 │ 12회 │ │ +│ │ 김철수 │ ✅ │ 1,200P │ 80,000원 │ 5회 │ │ +│ │ 이영희 │ ❌ │ - │ 200,000원 │ 20회 │ │ +│ └───────┴──────────┴─────────────┴───────────┴───────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 7-3. CD_PERSON 연동 + +```python +def link_pos_customer(user_id, transaction_id): + """거래 정보를 기반으로 POS 고객과 연결""" + + # 1. MSSQL에서 거래 정보 조회 + sale = mssql_query(""" + SELECT SL_CD_custom, SL_NM_custom + FROM SALE_MAIN + WHERE SL_NO_order = ? + """, [transaction_id]) + + if not sale or not sale['SL_CD_custom']: + # 고객코드가 없으면 연결 스킵 + return + + cuscode = sale['SL_CD_custom'] + customer_name = sale['SL_NM_custom'] + + # 2. pos_customer_links에 저장 + sqlite_execute(""" + INSERT OR IGNORE INTO pos_customer_links + (user_id, cuscode, customer_name) + VALUES (?, ?, ?) + """, [user_id, cuscode, customer_name]) +``` + +--- + +## 8. 카카오 로그인 정책 참고 + +### 8-1. 수집 가능한 정보 + +| 정보 | 기본 제공 | 추가 심사 필요 | +|------|-----------|----------------| +| kakao_user_id | ✅ | - | +| 닉네임 | ✅ (동의 시) | - | +| 프로필 이미지 | ✅ (동의 시) | - | +| 이메일 | ⚠️ (동의 시) | - | +| 성별/연령대 | - | ✅ | +| **전화번호** | - | ✅ (비즈앱) | + +### 8-2. 전화번호 수집 방법 + +1. **카카오 비즈앱 전환** 필요 +2. **전화번호 권한 심사** 신청 +3. 로그인 시 **명시적 동의** 팝업 +4. 서비스 이용 목적, 개인정보처리방침 제출 + +**권장**: +- 초기에는 `kakao_user_id`만 사용 +- 전화번호는 직접 입력 UI로 수집 (선택) +- 규모 확대 후 비즈앱 심사 진행 + +--- + +## 9. 향후 확장 계획 + +### 9-1. Phase 1 (MVP) +- [x] SQLite 테이블 설계 +- [ ] 영수증 QR 생성 API +- [ ] 카카오 로그인 연동 +- [ ] 마일리지 적립 기능 +- [ ] 내 마일리지 조회 페이지 + +### 9-2. Phase 2 (확장) +- [ ] 마일리지 사용 (결제 시 차감) +- [ ] POS 관리화면 연동 +- [ ] 이벤트 보너스 포인트 +- [ ] 푸시 알림 (적립 완료) + +### 9-3. Phase 3 (고도화) +- [ ] 다중 약국 지원 +- [ ] 알림톡 연동 (적립 완료 알림) +- [ ] VIP 등급제 +- [ ] 포인트 양도/선물 + +--- + +## 10. 참고: 기존 코드 위치 + +| 기능 | 파일 위치 | 비고 | +|------|-----------|------| +| 카카오 로그인 | `full/src/kakao_login_example.py` | KakaoLoginAPI 클래스 | +| 카카오 설정 | `full/src/kakao_config.py` | REST_API_KEY 등 | +| QR 코드 생성 | `print_label.py` | Brother QL-710W 프린터 | +| MSSQL 연결 | `dbsetup.py` | PM_PRES, PM_BASE 연결 | +| OTC 판매 API | `otc_stats_api.py` | sales-details 엔드포인트 | +| 고객 분석 | `customer_analytics_api.py` | 단골 고객 조회 | + +--- + +## 부록 A: 용어 정리 + +| 용어 | 설명 | +|------|------| +| **후향적 매핑** | 판매 시점이 아닌, 판매 후 나중에 고객 정보를 연결하는 방식 | +| **claim_token** | 영수증에 인쇄되는 1회성 토큰 (QR 코드에 포함) | +| **mileage_ledger** | 마일리지 적립/사용 내역을 기록하는 원장 테이블 | +| **CUSCODE** | CD_PERSON 테이블의 고객 코드 (기존 POS 고객 식별자) | +| **kakao_user_id** | 카카오 로그인 시 발급되는 고유 사용자 ID | + +--- + +## 부록 B: 관련 문서 + +- [CLAUDE.md](../CLAUDE.md) - 프로젝트 가이드 +- [POS 입고 기능 개선 정리](./pos/관련정리.md) - drug_code, 바코드 구조 +- [OTC 통계 API 문서](./dev-guide/otc-stats-api-documentation.md)