feat: 알림톡 발송 로그 시스템 + 현영 표시 + 문서화

- 알림톡 발송 로그: alimtalk_logs SQLite 테이블 + DB 자동 기록
- /admin/alimtalk 페이지: 서버 로그, NHN Cloud 내역 조회, 수동 발송 테스트
- 적립일시 포맷 수정: %Y-%m-%d %H:%M (16자 초과) → %m/%d %H:%M (11자)
- POS GUI 현금영수증(현영) 표시: 청록색 볼드
- 결제수납구조.md: CD_SUNAB/PS_main/SALE_MAIN 3테이블 관계 문서
- 실행구조.md: Flask 서버 + Qt GUI 실행 가이드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin 2026-02-26 19:28:29 +09:00
parent 0c52542713
commit a3ff69b67f
10 changed files with 1117 additions and 82 deletions

View File

@ -1885,6 +1885,97 @@ def admin():
recent_tokens=recent_tokens)
# ============================================================================
# 알림톡 로그
# ============================================================================
@app.route('/admin/alimtalk')
def admin_alimtalk():
"""알림톡 발송 로그 + NHN 발송 내역"""
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# 로컬 발송 로그 (최근 50건)
cursor.execute("""
SELECT a.*, u.nickname, u.phone as user_phone
FROM alimtalk_logs a
LEFT JOIN users u ON a.user_id = u.id
ORDER BY a.created_at DESC
LIMIT 50
""")
local_logs = [dict(row) for row in cursor.fetchall()]
# 통계
cursor.execute("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as fail_count
FROM alimtalk_logs
""")
stats = dict(cursor.fetchone())
# 오늘 통계
cursor.execute("""
SELECT
COUNT(*) as today_total,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as today_success
FROM alimtalk_logs
WHERE date(created_at) = date('now')
""")
today = dict(cursor.fetchone())
stats.update(today)
return render_template('admin_alimtalk.html', local_logs=local_logs, stats=stats)
@app.route('/api/admin/alimtalk/nhn-history')
def api_admin_alimtalk_nhn_history():
"""NHN Cloud 실제 발송 내역 API"""
from services.nhn_alimtalk import get_nhn_send_history
date_str = request.args.get('date', datetime.now().strftime('%Y-%m-%d'))
start = f"{date_str} 00:00"
end = f"{date_str} 23:59"
messages = get_nhn_send_history(start, end)
result = []
for m in messages:
result.append({
'requestDate': m.get('requestDate', ''),
'recipientNo': m.get('recipientNo', ''),
'templateCode': m.get('templateCode', ''),
'messageStatus': m.get('messageStatus', ''),
'resultCode': m.get('resultCode', ''),
'resultMessage': m.get('resultMessage', ''),
'content': m.get('content', ''),
})
return jsonify({'success': True, 'messages': result})
@app.route('/api/admin/alimtalk/test-send', methods=['POST'])
def api_admin_alimtalk_test_send():
"""관리자 수동 알림톡 발송 테스트"""
from services.nhn_alimtalk import send_mileage_claim_alimtalk
data = request.get_json()
phone = data.get('phone', '').strip().replace('-', '')
name = data.get('name', '테스트')
if len(phone) < 10:
return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400
success, msg = send_mileage_claim_alimtalk(
phone, name, 100, 500,
items=[{'name': '테스트 발송', 'qty': 1, 'total': 1000}],
trigger_source='admin_test'
)
return jsonify({'success': success, 'message': msg})
# ============================================================================
# 키오스크 적립
# ============================================================================
@ -2094,9 +2185,15 @@ def api_kiosk_claim():
user_row = cursor.fetchone()
user_name = user_row['nickname'] if user_row else '고객'
send_mileage_claim_alimtalk(phone, user_name, claimed_points, new_balance, items=sale_items)
logging.warning(f"[알림톡] 발송 시도: phone={phone}, name={user_name}, points={claimed_points}, balance={new_balance}, items={sale_items}")
success, msg = send_mileage_claim_alimtalk(
phone, user_name, claimed_points, new_balance,
items=sale_items, user_id=user_id,
trigger_source='kiosk', transaction_id=transaction_id
)
logging.warning(f"[알림톡] 발송 결과: success={success}, msg={msg}")
except Exception as alimtalk_err:
logging.warning(f"알림톡 발송 실패 (적립은 완료): {alimtalk_err}")
logging.warning(f"[알림톡] 발송 예외 (적립은 완료): {alimtalk_err}")
return jsonify({
'success': True,

View File

@ -237,7 +237,7 @@ class DatabaseManager:
print(f"[DB Manager] SQLite 스키마 초기화 완료")
def _migrate_sqlite(self):
"""기존 DB에 새 컬럼 추가 (마이그레이션)"""
"""기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
cursor = self.sqlite_conn.cursor()
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
@ -246,6 +246,29 @@ class DatabaseManager:
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가")
# alimtalk_logs 테이블 생성
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS alimtalk_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_code VARCHAR(50) NOT NULL,
recipient_no VARCHAR(20) NOT NULL,
user_id INTEGER,
trigger_source VARCHAR(20) NOT NULL,
template_params TEXT,
success BOOLEAN NOT NULL,
result_message TEXT,
transaction_id VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성")
def test_connection(self, database='PM_BASE'):
"""연결 테스트"""
try:

View File

@ -80,3 +80,21 @@ CREATE TABLE IF NOT EXISTS pos_customer_links (
);
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
-- 6. 알림톡 발송 로그 테이블
CREATE TABLE IF NOT EXISTS alimtalk_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_code VARCHAR(50) NOT NULL,
recipient_no VARCHAR(20) NOT NULL,
user_id INTEGER,
trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등
template_params TEXT, -- JSON 문자열
success BOOLEAN NOT NULL,
result_message TEXT,
transaction_id VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);

View File

@ -78,12 +78,16 @@ class SalesQueryThread(QThread):
ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount,
ISNULL(M.SL_MY_discount, 0) AS discount
ISNULL(M.SL_MY_discount, 0) AS discount,
S.cash_receipt_mode,
S.cash_receipt_num
FROM SALE_MAIN M
OUTER APPLY (
SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
nCASHINMODE AS cash_receipt_mode,
nAPPROVAL_NUM AS cash_receipt_num
FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order
) S
@ -96,7 +100,7 @@ class SalesQueryThread(QThread):
sales_list = []
for row in rows:
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount = row
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
# 품목 수 조회 (SALE_SUB)
mssql_cursor.execute("""
@ -136,12 +140,17 @@ class SalesQueryThread(QThread):
# 결제수단 판별
card_amt = float(card_total) if card_total else 0.0
cash_amt = float(cash_total) if cash_total else 0.0
# 현금영수증: nCASHINMODE='1' AND nAPPROVAL_NUM 존재 (mode=2는 카드거래 자동세팅)
has_cash_receipt = (
str(cash_receipt_mode or '').strip() == '1'
and str(cash_receipt_num or '').strip() != ''
)
if card_amt > 0 and cash_amt > 0:
pay_method = '카드+현금'
elif card_amt > 0:
pay_method = '카드'
elif cash_amt > 0:
pay_method = ''
pay_method = '' if has_cash_receipt else ''
else:
pay_method = ''
paid = (card_amt + cash_amt) > 0
@ -862,6 +871,11 @@ class POSSalesGUI(QMainWindow):
pay_item.setTextAlignment(Qt.AlignCenter)
if sale['pay_method'] == '카드':
pay_item.setForeground(QColor('#1976D2'))
elif sale['pay_method'] == '현영':
pay_item.setForeground(QColor('#00897B')) # 청록 (현금영수증)
f = QFont()
f.setBold(True)
pay_item.setFont(f)
elif sale['pay_method'] == '현금':
pay_item.setForeground(QColor('#E65100'))
elif sale['pay_method']:

View File

@ -1,9 +1,10 @@
"""
NHN Cloud 알림톡 발송 서비스
마일리지 적립 완료 알림톡 발송
마일리지 적립 완료 알림톡 발송 + SQLite 로깅
"""
import os
import json
import logging
from datetime import datetime, timezone, timedelta
@ -22,6 +23,34 @@ API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}
KST = timezone(timedelta(hours=9))
def _log_to_db(template_code, recipient_no, success, result_message,
template_params=None, user_id=None, trigger_source='unknown',
transaction_id=None):
"""발송 결과를 SQLite에 저장"""
try:
from db.dbsetup import db_manager
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO alimtalk_logs
(template_code, recipient_no, user_id, trigger_source,
template_params, success, result_message, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
template_code,
recipient_no,
user_id,
trigger_source,
json.dumps(template_params, ensure_ascii=False) if template_params else None,
success,
result_message,
transaction_id
))
conn.commit()
except Exception as e:
logger.warning(f"알림톡 로그 DB 저장 실패: {e}")
def _send_alimtalk(template_code, recipient_no, template_params):
"""
알림톡 발송 공통 함수
@ -82,7 +111,9 @@ def build_item_summary(items):
return f"{first}{len(items) - 1}"
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None):
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
user_id=None, trigger_source='kiosk',
transaction_id=None):
"""
마일리지 적립 완료 알림톡 발송
@ -92,11 +123,14 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None):
points: 적립 포인트
balance: 적립 잔액
items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...]
user_id: 사용자 ID (로그용)
trigger_source: 발송 주체 ('kiosk', 'admin', 'manual')
transaction_id: 거래 ID (로그용)
Returns:
tuple: (성공 여부, 메시지)
"""
now_kst = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
now_kst = datetime.now(KST).strftime('%m/%d %H:%M')
item_summary = build_item_summary(items)
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도
@ -113,15 +147,56 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None):
success, msg = _send_alimtalk(template_code, phone, params)
if not success:
# V3 실패 시 V2 폴백 (구매품목 변수 없는 버전)
# V3 실패 로그
_log_to_db(template_code, phone, False, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
# V2 폴백
template_code = 'MILEAGE_CLAIM_V2'
params_v2 = {
params = {
'고객명': name,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst,
'전화번호': phone
}
success, msg = _send_alimtalk(template_code, phone, params_v2)
success, msg = _send_alimtalk(template_code, phone, params)
# 최종 결과 로그
_log_to_db(template_code, phone, success, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
return (success, msg)
def get_nhn_send_history(start_date, end_date, page=1, page_size=15):
"""
NHN Cloud API에서 실제 발송 내역 조회
Args:
start_date: 시작일 (YYYY-MM-DD HH:mm)
end_date: 종료일 (YYYY-MM-DD HH:mm)
Returns:
list: 발송 메시지 목록
"""
url = (f'{API_BASE}/messages'
f'?startRequestDate={start_date}'
f'&endRequestDate={end_date}'
f'&pageNum={page}&pageSize={page_size}')
headers = {
'Content-Type': 'application/json;charset=UTF-8',
'X-Secret-Key': SECRET_KEY
}
try:
resp = requests.get(url, headers=headers, timeout=10)
data = resp.json()
if data.get('messageSearchResultResponse'):
return data['messageSearchResultResponse'].get('messages', [])
return []
except Exception as e:
logger.warning(f"NHN 발송내역 조회 실패: {e}")
return []

View File

@ -393,9 +393,12 @@
</head>
<body>
<div class="header">
<div class="header-content">
<div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div>
<div class="header-content" style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div>
</div>
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡 로그</a>
</div>
</div>

View File

@ -0,0 +1,552 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>알림톡 발송 로그 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f7fa;
-webkit-font-smoothing: antialiased;
}
.header {
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
padding: 28px 24px;
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
.header-subtitle { font-size: 14px; opacity: 0.85; margin-top: 4px; }
.header-nav a {
color: rgba(255,255,255,0.85);
text-decoration: none;
font-size: 14px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.header-nav a:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.stat-label { font-size: 13px; color: #64748b; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
.stat-value.success { color: #10b981; }
.stat-value.fail { color: #ef4444; }
.stat-value.today { color: #6366f1; }
/* Tabs */
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: #fff;
padding: 4px;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.tab {
padding: 10px 24px;
border: none;
background: none;
font-size: 14px;
font-weight: 500;
color: #64748b;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.tab.active {
background: #6366f1;
color: #fff;
}
.tab:hover:not(.active) { background: #f1f5f9; }
/* Tab Panels */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Table */
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title { font-size: 16px; font-weight: 600; color: #1e293b; }
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 12px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 12px 16px;
font-size: 13px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:hover td { background: #f8fafc; }
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-fail { background: #fee2e2; color: #dc2626; }
.badge-kiosk { background: #dbeafe; color: #2563eb; }
.badge-admin { background: #f3e8ff; color: #7c3aed; }
.badge-manual { background: #fef3c7; color: #d97706; }
.badge-completed { background: #dcfce7; color: #16a34a; }
.badge-sending { background: #fef3c7; color: #d97706; }
.badge-failed { background: #fee2e2; color: #dc2626; }
.phone-mask { font-family: 'Courier New', monospace; font-size: 13px; }
.param-toggle {
font-size: 12px;
color: #6366f1;
cursor: pointer;
text-decoration: underline;
}
.param-detail {
display: none;
margin-top: 8px;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #475569;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
}
.param-detail.show { display: block; }
/* NHN Tab */
.date-picker-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.date-picker-row input {
padding: 8px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.btn-primary { background: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-teal { background: #0d9488; color: #fff; }
.btn-teal:hover { background: #0f766e; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.loading {
text-align: center;
padding: 40px;
color: #94a3b8;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.empty-state .text { font-size: 15px; }
/* Test Send */
.test-form {
display: flex;
gap: 12px;
align-items: flex-end;
padding: 16px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; font-weight: 500; color: #64748b; }
.form-group input {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
}
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 14px 20px;
border-radius: 10px;
color: #fff;
font-size: 14px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 1000;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s ease;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.test-form { flex-wrap: wrap; }
.header-nav { display: none; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div>
<div class="header-title">알림톡 발송 로그</div>
<div class="header-subtitle">NHN Cloud 카카오 알림톡 발송 기록 및 상태 모니터링</div>
</div>
<div class="header-nav">
<a href="/admin">관리자 홈</a>
</div>
</div>
</div>
<div class="container">
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">전체 발송</div>
<div class="stat-value">{{ stats.total or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">성공</div>
<div class="stat-value success">{{ stats.success_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">실패</div>
<div class="stat-value fail">{{ stats.fail_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">오늘 발송</div>
<div class="stat-value today">{{ stats.today_total or 0 }}</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab('local')">발송 로그 (서버)</button>
<button class="tab" onclick="switchTab('nhn')">NHN Cloud 내역</button>
<button class="tab" onclick="switchTab('test')">수동 발송</button>
</div>
<!-- Tab 1: Local Logs -->
<div id="panel-local" class="tab-panel active">
<div class="card">
<div class="card-header">
<div class="card-title">서버 발송 로그 (최근 50건)</div>
</div>
{% if local_logs %}
<table>
<thead>
<tr>
<th>시간</th>
<th>수신번호</th>
<th>고객</th>
<th>템플릿</th>
<th>발송 주체</th>
<th>결과</th>
<th>상세</th>
</tr>
</thead>
<tbody>
{% for log in local_logs %}
<tr>
<td>{{ log.created_at[:16] if log.created_at else '-' }}</td>
<td class="phone-mask">{{ log.recipient_no[:3] + '-' + log.recipient_no[3:7] + '-' + log.recipient_no[7:] if log.recipient_no|length >= 11 else log.recipient_no }}</td>
<td>{{ log.nickname or '-' }}</td>
<td><code>{{ log.template_code }}</code></td>
<td>
{% if log.trigger_source == 'kiosk' %}
<span class="badge badge-kiosk">키오스크</span>
{% elif log.trigger_source == 'admin_test' %}
<span class="badge badge-admin">관리자</span>
{% else %}
<span class="badge badge-manual">{{ log.trigger_source }}</span>
{% endif %}
</td>
<td>
{% if log.success %}
<span class="badge badge-success">성공</span>
{% else %}
<span class="badge badge-fail">실패</span>
{% endif %}
</td>
<td>
{% if log.template_params %}
<span class="param-toggle" onclick="toggleParam(this)">변수 보기</span>
<div class="param-detail">{{ log.template_params }}</div>
{% endif %}
{% if not log.success and log.result_message %}
<div style="color: #ef4444; font-size: 12px; margin-top: 4px;">{{ log.result_message }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="icon">📭</div>
<div class="text">아직 발송 기록이 없습니다</div>
</div>
{% endif %}
</div>
</div>
<!-- Tab 2: NHN Cloud -->
<div id="panel-nhn" class="tab-panel">
<div class="card">
<div class="card-header">
<div class="card-title">NHN Cloud 발송 내역</div>
</div>
<div style="padding: 16px 20px;">
<div class="date-picker-row">
<input type="date" id="nhn-date" value="{{ now_date }}" />
<button class="btn btn-primary" onclick="loadNhnHistory()">조회</button>
</div>
</div>
<div id="nhn-table-area">
<div class="empty-state">
<div class="icon">🔍</div>
<div class="text">날짜를 선택하고 조회를 눌러주세요</div>
</div>
</div>
</div>
</div>
<!-- Tab 3: Test Send -->
<div id="panel-test" class="tab-panel">
<div class="card">
<div class="card-header">
<div class="card-title">수동 알림톡 발송 테스트</div>
</div>
<div class="test-form">
<div class="form-group">
<label>전화번호</label>
<input type="tel" id="test-phone" placeholder="01012345678" style="width: 160px;" />
</div>
<div class="form-group">
<label>고객명</label>
<input type="text" id="test-name" placeholder="테스트" value="테스트" style="width: 120px;" />
</div>
<button class="btn btn-teal" onclick="sendTest()">테스트 발송</button>
</div>
<div style="padding: 20px; color: #64748b; font-size: 13px; line-height: 1.8;">
<strong>안내</strong><br>
- MILEAGE_CLAIM_V3 템플릿으로 테스트 메시지를 발송합니다.<br>
- 테스트 값: 적립 100P, 잔액 500P, 품목 "테스트 발송"<br>
- 발송 결과는 "발송 로그 (서버)" 탭에서 확인 가능합니다.
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('panel-' + tabName).classList.add('active');
if (tabName === 'nhn' && !document.getElementById('nhn-table-area').dataset.loaded) {
loadNhnHistory();
}
}
// Toggle param detail
function toggleParam(el) {
const detail = el.nextElementSibling;
detail.classList.toggle('show');
el.textContent = detail.classList.contains('show') ? '접기' : '변수 보기';
}
// Toast notification
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast ' + type + ' show';
setTimeout(() => toast.classList.remove('show'), 3000);
}
// Load NHN history
async function loadNhnHistory() {
const date = document.getElementById('nhn-date').value;
const area = document.getElementById('nhn-table-area');
area.innerHTML = '<div class="loading">조회 중...</div>';
try {
const resp = await fetch('/api/admin/alimtalk/nhn-history?date=' + date);
const data = await resp.json();
area.dataset.loaded = '1';
if (!data.messages || data.messages.length === 0) {
area.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div class="text">' + date + ' 발송 내역이 없습니다</div></div>';
return;
}
let html = '<table><thead><tr><th>요청 시간</th><th>수신번호</th><th>템플릿</th><th>상태</th><th>결과코드</th></tr></thead><tbody>';
data.messages.forEach(m => {
const time = m.requestDate ? m.requestDate.substring(0, 19) : '-';
const phone = m.recipientNo || '-';
const tpl = m.templateCode || '-';
let statusBadge = '';
const st = (m.messageStatus || '').toUpperCase();
if (st === 'COMPLETED') {
statusBadge = '<span class="badge badge-completed">전송완료</span>';
} else if (st === 'SENDING' || st === 'READY') {
statusBadge = '<span class="badge badge-sending">발송중</span>';
} else {
statusBadge = '<span class="badge badge-failed">' + (m.messageStatus || '알수없음') + '</span>';
}
const code = m.resultCode || '-';
html += '<tr><td>' + time + '</td><td class="phone-mask">' + phone + '</td><td><code>' + tpl + '</code></td><td>' + statusBadge + '</td><td>' + code + '</td></tr>';
});
html += '</tbody></table>';
area.innerHTML = html;
} catch(e) {
area.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div class="text">조회 실패: ' + e.message + '</div></div>';
}
}
// Test send
async function sendTest() {
const phone = document.getElementById('test-phone').value.trim();
const name = document.getElementById('test-name').value.trim() || '테스트';
if (phone.length < 10) {
showToast('전화번호를 입력해주세요', 'error');
return;
}
try {
const resp = await fetch('/api/admin/alimtalk/test-send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, name })
});
const data = await resp.json();
if (data.success) {
showToast('발송 성공!', 'success');
} else {
showToast('발송 실패: ' + data.message, 'error');
}
} catch(e) {
showToast('오류: ' + e.message, 'error');
}
}
// Set today's date
document.getElementById('nhn-date').value = new Date().toISOString().split('T')[0];
</script>
</body>
</html>

View File

@ -59,7 +59,8 @@
width: 100%;
max-width: 780px;
position: relative;
height: 380px;
height: 450px;
overflow: hidden;
}
.slide {
position: absolute;
@ -98,31 +99,31 @@
display: inline-block;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
}
.slide-title {
font-size: 30px;
font-size: 42px;
font-weight: 900;
color: #fff;
letter-spacing: -0.8px;
line-height: 1.3;
}
.slide-desc {
font-size: 17px;
color: rgba(255,255,255,0.65);
font-size: 23px;
color: rgba(255,255,255,0.7);
line-height: 1.6;
max-width: 500px;
max-width: 520px;
}
.slide-highlight {
display: inline-block;
padding: 10px 28px;
padding: 12px 32px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 14px;
color: rgba(255,255,255,0.9);
font-size: 15px;
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}

View File

@ -1,22 +1,217 @@
# PIT3000 결제/수납/할인 데이터 구조
# PIT3000 판매/조제/수납 데이터 구조
## 핵심 테이블 관계
```
SALE_MAIN (판매)
└── SL_NO_order (PK, 주문번호)
CD_SUNAB (수납/결제) ─── 모든 거래의 결제 기록 (130건/일 기준)
├── PS_main (처방접수) ─── 조제 건만 (89건/일 기준)
│ │ 조인: PS_main.PreSerial = CD_SUNAB.PRESERIAL
│ │ 조인: PS_main.Indate = CD_SUNAB.INDATE
│ │
│ ├── PS_sub_hosp (처방 의약품 상세)
│ └── PS_sub_pharm (조제 의약품 상세)
└── SALE_MAIN (OTC 판매) ─── OTC 직접 판매만 (39건/일 기준)
│ 조인: SALE_MAIN.SL_NO_order = CD_SUNAB.PRESERIAL
├── SALE_SUB (품목 상세) — SL_NO_order로 조인
└── CD_SUNAB (수납/결제) — CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order
└── SALE_SUB (판매 품목 상세) ─── SL_NO_order로 조인
```
**주의**: `CD_SUNAB.PRESERIAL``SALE_MAIN.SL_NO_order`(주문번호)와 매칭됨.
`SALE_MAIN.PRESERIAL`(처방번호)과는 다른 키임.
## 테이블별 역할
### 1. CD_SUNAB — 수납/결제 (모든 거래 포함)
- **역할**: 조제 + OTC 모든 거래의 결제/수납 기록
- **1주문 = 1행** (복수행 없음)
- **키**: `PRESERIAL` (주문번호), `INDATE` (수납일)
- **건수**: 하루 약 130건 (조제 91 + OTC 39)
| 컬럼 | 설명 |
|------|------|
| `PRESERIAL` | 주문번호 (PS_main.PreSerial 또는 SALE_MAIN.SL_NO_order와 매칭) |
| `INDATE` | 수납일 (YYYYMMDD) |
| `DAY_SERIAL` | 일련번호 |
| `CUSCODE` | 고객코드 |
| `ETC_CARD` | 조제 카드결제 금액 |
| `ETC_CASH` | 조제 현금결제 금액 |
| `ETC_PAPER` | 조제 외상 금액 |
| `OTC_CARD` | 일반약 카드결제 금액 |
| `OTC_CASH` | 일반약 현금결제 금액 |
| `OTC_PAPER` | 일반약 외상 금액 |
| `pAPPROVAL_NUM` | 카드 승인번호 |
| `pMCHDATA` | 카드사 이름 |
| `pCARDINMODE` | 카드 입력방식 (1=IC칩) |
| `pTRDTYPE` | 거래유형 (D1=일반승인) |
| `nCASHINMODE` | 현금영수증 모드 (1=발행, 2=카드거래 자동세팅) |
| `nAPPROVAL_NUM` | 현금영수증 승인번호 |
| `Appr_Gubun` | 승인구분 (1, 2, 9 등) |
| `APPR_DATE` | 승인일시 (YYYYMMDDHHmmss) |
| `DaeRiSunab` | 대리수납 여부 |
| `YOHUDATE` | 요후일 |
| 총 **54개 컬럼** | |
### 2. PS_main — 처방전 접수 (조제 전용)
- **역할**: 처방전 기반 조제 접수 기록
- **키**: `PreSerial` (처방번호 = CD_SUNAB.PRESERIAL)
- **건수**: 하루 약 89건
- **SALE_MAIN에는 없음** — 조제건은 SALE_MAIN을 거치지 않음
| 컬럼 | 설명 |
|------|------|
| `PreSerial` | 처방번호 (= CD_SUNAB.PRESERIAL) |
| `Day_Serial` | 일일 접수 순번 (1~89) |
| `Indate` | 접수일 (YYYYMMDD) |
| `CusCode` | 환자 코드 |
| `Paname` | 환자명 |
| `PaNum` | 주민번호 |
| `InsName` | 보험구분 (건강보험, 의료급여 등) |
| `OrderName` | 의료기관명 |
| `Drname` | 처방의사명 |
| `PresTime` | 접수 시간 |
| `PRICE_T` | 총금액 |
| `PRICE_P` | 본인부담금 |
| `PRICE_C` | 보험자부담금 |
| `Pre_State` | 처방 상태 |
| `InsertTime` | 입력 시간 |
| 총 **58개 컬럼** | |
### 3. SALE_MAIN — OTC 직접 판매
- **역할**: 일반의약품(OTC) 직접 판매 기록
- **키**: `SL_NO_order` (주문번호 = CD_SUNAB.PRESERIAL)
- **건수**: 하루 약 39건
- **조제건은 포함되지 않음**
| 컬럼 | 설명 |
|------|------|
| `SL_NO_order` | 주문번호 (= CD_SUNAB.PRESERIAL) |
| `SL_DT_appl` | 판매일 (YYYYMMDD) |
| `SL_NM_custom` | 고객명 (대부분 빈값 → `[비고객]`) |
| `SL_MY_total` | 원가 (할인 전) |
| `SL_MY_discount` | 할인 금액 |
| `SL_MY_sale` | 실판매가 (= total - discount) |
| `InsertTime` | 입력 시간 |
| `PRESERIAL` | 처방번호 (OTC는 'V' 고정, 의미 없음) |
| 총 **30개 컬럼** | |
---
## SALE_MAIN 금액 컬럼
## 데이터 흐름 정리
### 조제 (처방전 기반)
```
처방전 접수 → PS_main 생성 → 조제 → CD_SUNAB 수납 기록
(ETC_CARD/ETC_CASH에 금액)
```
- SALE_MAIN에는 **기록되지 않음**
- SALE_SUB에도 품목이 **들어가지 않음**
- 환자명은 PS_main.Paname에 있음
### OTC 판매 (직접 판매)
```
POS에서 품목 선택 → SALE_MAIN + SALE_SUB 생성 → CD_SUNAB 수납 기록
(OTC_CARD/OTC_CASH에 금액)
```
- PS_main에는 **기록되지 않음**
- 고객명은 보통 빈값 (`[비고객]`)
### 조제 + OTC 동시 (하루 약 10건)
```
처방전 조제 + 일반약 동시 구매
→ PS_main (조제 부분)
→ SALE_MAIN + SALE_SUB (OTC 부분)
→ CD_SUNAB 1행에 ETC + OTC 금액 모두 기록
```
---
## 조인 키 관계
```
CD_SUNAB.PRESERIAL = PS_main.PreSerial (조제건)
CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (OTC건)
```
**주의**: `SALE_MAIN.PRESERIAL`은 OTC에서 항상 `'V'`로, 조인키가 아님.
실제 조인키는 `SALE_MAIN.SL_NO_order`임.
---
## 건수 관계 (2025-02-25 기준)
| 구분 | 건수 | 설명 |
|------|------|------|
| CD_SUNAB | 130 | 모든 수납 기록 |
| PS_main | 89 | 처방전 접수 (= 조제) |
| SALE_MAIN | 39 | OTC 직접 판매 |
| CD_SUNAB에만 존재 | 91 | 조제건 (SALE_MAIN 없음) |
| PS_main 매칭 | 89 | 91건 중 PS_main과 매칭 |
| 미매칭 | 2 | PS_main 없이 수납만 존재 (미수금 수납 등 특수 케이스) |
### 130건 = 39 (OTC) + 89 (조제) + 2 (특수)
---
## 조제/OTC 구분 방법
CD_SUNAB의 ETC/OTC 금액으로 판별:
```python
etc_total = ETC_CARD + ETC_CASH # 조제 금액
otc_total = OTC_CARD + OTC_CASH # 일반약 금액
if etc_total > 0 and otc_total > 0:
구분 = "조제+판매"
elif etc_total > 0:
구분 = "조제"
elif otc_total > 0:
구분 = "판매(OTC)"
else:
구분 = "본인부담금 없음" # 건강보험 전액 부담
```
---
## 결제수단 판별
```python
card_total = ETC_CARD + OTC_CARD
cash_total = ETC_CASH + OTC_CASH
# 현금영수증 판별 (nCASHINMODE=2는 카드거래 자동세팅이므로 제외)
has_cash_receipt = (nCASHINMODE == '1' and nAPPROVAL_NUM != '')
if card_total > 0 and cash_total > 0:
결제 = "카드+현금"
elif card_total > 0:
결제 = "카드"
elif cash_total > 0:
결제 = "현영" if has_cash_receipt else "현금"
else:
결제 = "-"
```
---
## GUI 표시 색상
### 결제 컬럼
- **카드**: 파란색 (#1976D2)
- **현영**: 청록색 볼드 (#00897B) — 현금영수증 발행
- **현금**: 주황색 (#E65100) — 현금영수증 미발행
- **카드+현금**: 보라색 (#7B1FA2)
- **-**: 회색 (수납 없음)
### 수납 컬럼
- **✓**: 녹색 (#4CAF50)
- **-**: 회색 (미수납)
### 할인 표시
- 할인 없음: `12,000원`
- 할인 있음: `54,000원 (-6,000)` 주황색 볼드 + 툴팁
---
## SALE_MAIN 금액 컬럼 상세
| 컬럼 | 설명 | 예시 |
|------|------|------|
@ -40,30 +235,7 @@ SL_MY_recive ≈ SL_MY_sale / 1.1 (부가세 제외 금액 추정)
---
## CD_SUNAB 결제수단 컬럼
### 금액 기반 결제수단 구분
단일 구분 컬럼이 없음. **금액이 0보다 크면 해당 결제수단 사용**.
| 구분 | 카드 | 현금 | 외상 |
|------|------|------|------|
| 조제(ETC, 전문의약품) | `ETC_CARD` | `ETC_CASH` | `ETC_PAPER` |
| OTC(일반의약품) | `OTC_CARD` | `OTC_CASH` | `OTC_PAPER` |
### 결제수단 판별 로직
```python
card_total = ETC_CARD + OTC_CARD
cash_total = ETC_CASH + OTC_CASH
if card_total > 0 and cash_total > 0:
결제수단 = "카드+현금"
elif card_total > 0:
결제수단 = "카드"
elif cash_total > 0:
결제수단 = "현금"
else:
결제수단 = "-" (미수납 또는 외상)
```
## CD_SUNAB 카드/현금 상세 컬럼
### 카드 상세 정보
| 컬럼 | 설명 | 예시 |
@ -79,32 +251,13 @@ else:
### 현금 상세 정보
| 컬럼 | 설명 | 예시 |
|------|------|------|
| `nCASHINMODE` | 현금영수증 입력 방식 | 1, 2 (빈값=미발행) |
| `nAPPROVAL_NUM` | 현금영수증 승인번호 | |
| `nCHK_GUBUN` | 현금 체크 구분 | TASA |
| `nCASHINMODE` | 현금영수증 입력 방식 | 1=실제발행, 2=카드거래 자동세팅 |
| `nAPPROVAL_NUM` | 현금영수증 승인번호 | 116624870 |
| `nCHK_GUBUN` | 현금 체크 구분 | KOV, TASA |
---
## GUI 표시 방식
### 결제 컬럼
- **카드**: 파란색 (#1976D2)
- **현금**: 주황색 (#E65100)
- **카드+현금**: 보라색 (#7B1FA2)
- **-**: 회색 (수납 정보 없음)
### 수납 컬럼
- **✓**: 녹색 (card + cash > 0)
- **-**: 회색 (미수납)
### 할인 표시
- 할인 없는 건: `12,000원` (기본)
- 할인 있는 건: `54,000원 (-6,000)` 주황색 볼드
- 마우스 툴팁: 원가 / 할인 / 결제 상세
---
## SQL 쿼리 (GUI에서 사용)
## SQL 쿼리 (현재 GUI에서 사용)
```sql
SELECT
@ -115,12 +268,16 @@ SELECT
ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount,
ISNULL(M.SL_MY_discount, 0) AS discount
ISNULL(M.SL_MY_discount, 0) AS discount,
S.cash_receipt_mode,
S.cash_receipt_num
FROM SALE_MAIN M
OUTER APPLY (
SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
nCASHINMODE AS cash_receipt_mode,
nAPPROVAL_NUM AS cash_receipt_num
FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order
) S
@ -128,6 +285,10 @@ WHERE M.SL_DT_appl = ?
ORDER BY M.InsertTime DESC
```
**한계**: SALE_MAIN 기준이므로 OTC 판매(39건)만 표시됨.
조제건(~89건)은 표시되지 않음. 조제건까지 보려면 CD_SUNAB을
기본 테이블로 사용하거나 PS_main과 조인하는 쿼리 재설계 필요.
---
## 카드사 분포 (전체 데이터 기준)

91
docs/실행구조.md Normal file
View File

@ -0,0 +1,91 @@
# 청춘약국 마일리지 시스템 — 실행 구조
## 실행해야 할 프로그램 (2개)
### 1. Flask 서버 (`backend/app.py`)
```bash
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
python backend/app.py
```
- **포트**: 7001 (0.0.0.0)
- **외부 도메인**: `mile.0bin.in` (→ 내부 7001 포트로 프록시)
- **역할**: 웹 서비스 전체 담당
#### 제공하는 페이지/API
| 경로 | 설명 |
|------|------|
| `/` | 메인 페이지 |
| `/signup` | 회원가입 |
| `/claim` | QR 적립 (폰번호 방식) |
| `/claim/kakao/start` | QR 적립 (카카오 로그인) |
| `/my-page` | 마이페이지 |
| `/kiosk` | **키오스크 대기 화면** (약국 내 태블릿) |
| `/admin` | 관리자 페이지 |
| `/admin/transaction/<id>` | 거래 상세 |
| `/admin/user/<id>` | 회원 상세 |
| `/admin/search/user` | 회원 검색 |
| `/admin/search/product` | 상품 검색 |
| `/api/kiosk/trigger` | 키오스크 QR 트리거 (POST) |
| `/api/kiosk/current` | 키오스크 현재 상태 |
| `/api/kiosk/claim` | 키오스크 적립 처리 (POST) |
#### 사용하는 DB
- **SQLite** (`backend/db/mileage.db`) — 회원, 적립, QR 토큰
- **MSSQL** (`192.168.0.4\PM2014`, DB: `PM_PRES`) — POS 판매 데이터 (읽기 전용)
---
### 2. Qt POS GUI (`backend/gui/pos_sales_gui.py`)
```bash
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
python backend/gui/pos_sales_gui.py
```
- **역할**: POS 판매 내역 조회 + QR 라벨 발행
- **PyQt5 기반** 데스크톱 앱
- Flask 서버와 **독립적으로 실행** (별도 프로세스)
#### 주요 기능
- 일자별 판매 내역 조회 (SALE_MAIN + CD_SUNAB)
- 결제수단 표시 (카드/현금/현영)
- 할인 표시
- QR 라벨 프린터 출력 (Zebra / POS 프린터)
- 적립자 클릭 → 회원 적립 내역 팝업
#### 사용하는 DB
- **MSSQL** — SALE_MAIN, SALE_SUB, CD_SUNAB 조회
- **SQLite** — claim_tokens, users 조회 (적립 정보)
---
## 실행 순서
```
1. Flask 서버 먼저 실행 (키오스크, 웹 서비스 제공)
2. Qt POS GUI 실행 (판매 내역 조회, QR 발행)
```
순서는 상관없으나, Flask가 먼저 떠 있어야 키오스크(`mile.0bin.in/kiosk`)와
웹 서비스(`mile.0bin.in`)가 접속 가능.
---
## 프로세스 확인
```bash
# 실행 중인 Python 프로세스 확인
tasklist /FI "IMAGENAME eq python.exe"
# 정상 상태: Python 프로세스 3개
# - Flask 서버 (메인)
# - Flask 서버 (debug reloader 워커)
# - Qt POS GUI
```
---
## 주의사항
- `taskkill /F /IM python.exe` 사용 시 **Flask + GUI 모두 종료됨**
- GUI만 재시작하려면 해당 PID만 종료할 것
- Flask 서버는 `debug=True`로 실행되어 코드 변경 시 자동 리로드
- Python 경로: `C:\Users\청춘약국\AppData\Local\Programs\Python\Python312\python.exe`