- 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>
836 lines
33 KiB
Markdown
836 lines
33 KiB
Markdown
# 후향적 고객 매핑 및 마일리지 적립 시스템 기획서
|
||
|
||
> **버전**: 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)
|