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:
commit
a9041e9c9e
87
.gitignore
vendored
Normal file
87
.gitignore
vendored
Normal file
@ -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
|
||||||
126
README.md
Normal file
126
README.md
Normal file
@ -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 (양구청춘약국)
|
||||||
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
|
||||||
113
docs/DATABASE.md
Normal file
113
docs/DATABASE.md
Normal file
@ -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
|
||||||
|
```
|
||||||
835
docs/후향적적립QR_POS만들기.md
Normal file
835
docs/후향적적립QR_POS만들기.md
Normal file
@ -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)
|
||||||
Loading…
Reference in New Issue
Block a user