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:
101
backend/app.py
101
backend/app.py
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
552
backend/templates/admin_alimtalk.html
Normal file
552
backend/templates/admin_alimtalk.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user