Compare commits
9 Commits
0c52542713
...
ccb0067a1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb0067a1c | ||
|
|
da51f4bfd1 | ||
|
|
db5f6063ec | ||
|
|
4c3e1d08b2 | ||
|
|
a2829436d1 | ||
|
|
3e3934e2e5 | ||
|
|
5042cffb9f | ||
|
|
b5a99f7b3b | ||
|
|
a3ff69b67f |
638
backend/app.py
638
backend/app.py
@ -3,13 +3,21 @@ Flask 웹 서버 - QR 마일리지 적립
|
||||
간편 적립: 전화번호 + 이름만 입력
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
|
||||
from flask import Flask, request, render_template, jsonify, redirect, url_for, session
|
||||
import hashlib
|
||||
import base64
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from dotenv import load_dotenv
|
||||
@ -1109,7 +1117,7 @@ def my_page():
|
||||
tx_dict['created_at'] = utc_to_kst_str(tx['created_at'])
|
||||
transactions.append(tx_dict)
|
||||
|
||||
return render_template('my_page.html', user=user, transactions=transactions)
|
||||
return render_template('my_page.html', user=user, transactions=transactions, user_id=user['id'])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@ -1885,6 +1893,317 @@ def admin():
|
||||
recent_tokens=recent_tokens)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AI 업셀링 추천
|
||||
# ============================================================================
|
||||
|
||||
def _get_available_products():
|
||||
"""약국 보유 제품 목록 (최근 30일 판매 실적 기준 TOP 40)"""
|
||||
try:
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
rows = mssql_session.execute(text("""
|
||||
SELECT TOP 40
|
||||
ISNULL(G.GoodsName, '') AS name,
|
||||
COUNT(*) as sales,
|
||||
MAX(G.Saleprice) as price
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -30, GETDATE()), 112)
|
||||
AND G.GoodsName IS NOT NULL
|
||||
AND G.GoodsName NOT LIKE N'%(판매중지)%'
|
||||
GROUP BY G.GoodsName
|
||||
ORDER BY COUNT(*) DESC
|
||||
""")).fetchall()
|
||||
return [{'name': r.name, 'price': float(r.price or 0), 'sales': r.sales} for r in rows]
|
||||
except Exception as e:
|
||||
logging.warning(f"[AI추천] 보유 제품 목록 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _generate_upsell_recommendation(user_id, transaction_id, sale_items, user_name):
|
||||
"""키오스크 적립 후 AI 업셀링 추천 생성 (fire-and-forget)"""
|
||||
from services.clawdbot_client import generate_upsell, generate_upsell_real
|
||||
|
||||
if not sale_items:
|
||||
return
|
||||
|
||||
# 현재 구매 품목
|
||||
current_items = ', '.join(item['name'] for item in sale_items if item.get('name'))
|
||||
if not current_items:
|
||||
return
|
||||
|
||||
# 최근 구매 이력 수집
|
||||
recent_products = current_items # 기본값
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT ct.transaction_id
|
||||
FROM claim_tokens ct
|
||||
WHERE ct.claimed_by_user_id = ? AND ct.transaction_id != ?
|
||||
ORDER BY ct.claimed_at DESC LIMIT 5
|
||||
""", (user_id, transaction_id))
|
||||
recent_tokens = cursor.fetchall()
|
||||
|
||||
if recent_tokens:
|
||||
all_products = []
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
for token in recent_tokens:
|
||||
rows = mssql_session.execute(text("""
|
||||
SELECT ISNULL(G.GoodsName, '') AS goods_name
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_NO_order = :tid
|
||||
"""), {'tid': token['transaction_id']}).fetchall()
|
||||
for r in rows:
|
||||
if r.goods_name:
|
||||
all_products.append(r.goods_name)
|
||||
if all_products:
|
||||
recent_products = ', '.join(set(all_products))
|
||||
except Exception as e:
|
||||
logging.warning(f"[AI추천] 구매 이력 수집 실패 (현재 품목만 사용): {e}")
|
||||
|
||||
# 실데이터 기반 추천 (보유 제품 목록 제공)
|
||||
available = _get_available_products()
|
||||
rec = None
|
||||
|
||||
if available:
|
||||
logging.info(f"[AI추천] 실데이터 생성 시작: user={user_name}, items={current_items}, 보유제품={len(available)}개")
|
||||
rec = generate_upsell_real(user_name, current_items, recent_products, available)
|
||||
|
||||
# 실데이터 실패 시 기존 방식 fallback
|
||||
if not rec:
|
||||
logging.info(f"[AI추천] 자유 생성 fallback: user={user_name}")
|
||||
rec = generate_upsell(user_name, current_items, recent_products)
|
||||
|
||||
if not rec:
|
||||
logging.warning("[AI추천] 생성 실패 (AI 응답 없음)")
|
||||
return
|
||||
|
||||
# SQLite에 저장
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
expires_at = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute("""
|
||||
INSERT INTO ai_recommendations
|
||||
(user_id, transaction_id, recommended_product, recommendation_message,
|
||||
recommendation_reason, trigger_products, ai_raw_response, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
user_id, transaction_id,
|
||||
rec['product'], rec['message'], rec['reason'],
|
||||
json.dumps([item['name'] for item in sale_items], ensure_ascii=False),
|
||||
json.dumps(rec, ensure_ascii=False),
|
||||
expires_at
|
||||
))
|
||||
conn.commit()
|
||||
logging.info(f"[AI추천] 저장 완료: user_id={user_id}, product={rec['product']}")
|
||||
except Exception as e:
|
||||
logging.warning(f"[AI추천] DB 저장 실패: {e}")
|
||||
|
||||
|
||||
@app.route('/api/recommendation/<int:user_id>')
|
||||
def api_get_recommendation(user_id):
|
||||
"""마이페이지용 AI 추천 조회"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute("""
|
||||
SELECT id, recommended_product, recommendation_message, created_at
|
||||
FROM ai_recommendations
|
||||
WHERE user_id = ? AND status = 'active'
|
||||
AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (user_id, now))
|
||||
|
||||
rec = cursor.fetchone()
|
||||
if not rec:
|
||||
return jsonify({'success': True, 'has_recommendation': False})
|
||||
|
||||
# 표시 횟수 업데이트
|
||||
cursor.execute("""
|
||||
UPDATE ai_recommendations
|
||||
SET displayed_count = displayed_count + 1,
|
||||
displayed_at = COALESCE(displayed_at, ?)
|
||||
WHERE id = ?
|
||||
""", (now, rec['id']))
|
||||
conn.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'has_recommendation': True,
|
||||
'recommendation': {
|
||||
'id': rec['id'],
|
||||
'product': rec['recommended_product'],
|
||||
'message': rec['recommendation_message']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/recommendation/<int:rec_id>/dismiss', methods=['POST'])
|
||||
def api_dismiss_recommendation(rec_id):
|
||||
"""추천 닫기 / 관심 표시"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
action = data.get('action', 'dismissed')
|
||||
if action not in ('dismissed', 'interested'):
|
||||
action = 'dismissed'
|
||||
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute("""
|
||||
UPDATE ai_recommendations SET status = ?, dismissed_at = ?
|
||||
WHERE id = ?
|
||||
""", (action, now, rec_id))
|
||||
conn.commit()
|
||||
logging.info(f"[AI추천] id={rec_id} → {action}")
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 알림톡 로그
|
||||
# ============================================================================
|
||||
|
||||
@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('/admin/ai-crm')
|
||||
def admin_ai_crm():
|
||||
"""AI 업셀링 CRM 대시보드"""
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 추천 목록 (최근 50건) + 사용자 정보 JOIN
|
||||
cursor.execute("""
|
||||
SELECT r.*, u.nickname, u.phone as user_phone
|
||||
FROM ai_recommendations r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
recs = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# trigger_products JSON 파싱
|
||||
for rec in recs:
|
||||
tp = rec.get('trigger_products')
|
||||
if tp:
|
||||
try:
|
||||
rec['trigger_list'] = json.loads(tp)
|
||||
except Exception:
|
||||
rec['trigger_list'] = [tp]
|
||||
else:
|
||||
rec['trigger_list'] = []
|
||||
|
||||
# 통계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'active' AND (expires_at IS NULL OR expires_at > datetime('now')) THEN 1 ELSE 0 END) as active_count,
|
||||
SUM(CASE WHEN status = 'interested' THEN 1 ELSE 0 END) as interested_count,
|
||||
SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count,
|
||||
SUM(CASE WHEN displayed_count > 0 THEN 1 ELSE 0 END) as displayed_count
|
||||
FROM ai_recommendations
|
||||
""")
|
||||
stats = dict(cursor.fetchone())
|
||||
|
||||
# 오늘 생성 건수
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as today_count
|
||||
FROM ai_recommendations
|
||||
WHERE date(created_at) = date('now')
|
||||
""")
|
||||
stats['today_count'] = cursor.fetchone()['today_count']
|
||||
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
return render_template('admin_ai_crm.html', recs=recs, stats=stats, now=now)
|
||||
|
||||
|
||||
@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 +2413,24 @@ 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}")
|
||||
|
||||
# AI 업셀링 추천 생성 (별도 스레드 — 적립 응답 블로킹 방지)
|
||||
import threading
|
||||
def _bg_upsell():
|
||||
try:
|
||||
_generate_upsell_recommendation(user_id, transaction_id, sale_items, user_name)
|
||||
except Exception as rec_err:
|
||||
logging.warning(f"[AI추천] 생성 예외 (적립은 완료): {rec_err}")
|
||||
threading.Thread(target=_bg_upsell, daemon=True).start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@ -2111,6 +2445,300 @@ def api_kiosk_claim():
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ===== AI Gateway 모니터 페이지 =====
|
||||
|
||||
@app.route('/admin/ai-gw')
|
||||
def admin_ai_gw():
|
||||
"""AI Gateway 모니터 페이지"""
|
||||
return render_template('admin_ai_gw.html')
|
||||
|
||||
|
||||
# ===== 판매 상세 조회 페이지 =====
|
||||
|
||||
@app.route('/admin/sales-detail')
|
||||
def admin_sales_detail():
|
||||
"""판매 상세 조회 페이지 (상품코드/바코드/표준코드 매핑)"""
|
||||
return render_template('admin_sales_detail.html')
|
||||
|
||||
|
||||
@app.route('/admin/sales')
|
||||
def admin_sales_pos():
|
||||
"""판매 내역 페이지 (POS 스타일, 거래별 그룹핑)"""
|
||||
return render_template('admin_sales_pos.html')
|
||||
|
||||
|
||||
@app.route('/api/sales-detail')
|
||||
def api_sales_detail():
|
||||
"""
|
||||
판매 상세 조회 API (바코드 포함)
|
||||
GET /api/sales-detail?days=7&search=타이레놀&barcode=has&customer=홍길동
|
||||
"""
|
||||
try:
|
||||
days = int(request.args.get('days', 7))
|
||||
search = request.args.get('search', '').strip()
|
||||
barcode_filter = request.args.get('barcode', 'all') # all, has, none
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 판매 내역 조회 (최근 N일)
|
||||
sales_query = text("""
|
||||
SELECT
|
||||
S.SL_DT_appl as sale_date,
|
||||
S.SL_NO_order as item_order,
|
||||
S.DrugCode as drug_code,
|
||||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||
ISNULL(G.BARCODE, '') as barcode,
|
||||
ISNULL(G.SplName, '') as supplier,
|
||||
ISNULL(S.QUAN, 1) as quantity,
|
||||
ISNULL(S.SL_TOTAL_PRICE, 0) as total_price_db,
|
||||
ISNULL(G.Saleprice, 0) as unit_price
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -:days, GETDATE()), 112)
|
||||
ORDER BY S.SL_DT_appl DESC, S.SL_NO_order DESC
|
||||
""")
|
||||
|
||||
rows = mssql_session.execute(sales_query, {'days': days}).fetchall()
|
||||
|
||||
items = []
|
||||
total_amount = 0
|
||||
barcode_count = 0
|
||||
unique_products = set()
|
||||
|
||||
for row in rows:
|
||||
drug_code = row.drug_code or ''
|
||||
product_name = row.product_name or ''
|
||||
barcode = row.barcode or ''
|
||||
|
||||
# 검색 필터 (상품명, 코드, 바코드)
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
if (search_lower not in product_name.lower() and
|
||||
search_lower not in drug_code.lower() and
|
||||
search_lower not in barcode.lower()):
|
||||
continue
|
||||
|
||||
# 바코드 필터
|
||||
if barcode_filter == 'has' and not barcode:
|
||||
continue
|
||||
if barcode_filter == 'none' and barcode:
|
||||
continue
|
||||
|
||||
# 표준코드 조회 (CD_BARCODE 테이블)
|
||||
standard_code = ''
|
||||
if barcode:
|
||||
try:
|
||||
std_result = drug_session.execute(text("""
|
||||
SELECT BASECODE FROM CD_BARCODE WHERE BARCODE = :barcode
|
||||
"""), {'barcode': barcode}).fetchone()
|
||||
if std_result and std_result[0]:
|
||||
standard_code = std_result[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
quantity = int(row.quantity or 1)
|
||||
unit_price = float(row.unit_price or 0)
|
||||
total_price_from_db = float(row.total_price_db or 0)
|
||||
# DB에 합계가 있으면 사용, 없으면 계산
|
||||
total_price = total_price_from_db if total_price_from_db > 0 else (quantity * unit_price)
|
||||
|
||||
# 날짜 포맷팅
|
||||
sale_date_str = str(row.sale_date or '')
|
||||
if len(sale_date_str) == 8:
|
||||
sale_date_str = f"{sale_date_str[:4]}-{sale_date_str[4:6]}-{sale_date_str[6:]}"
|
||||
|
||||
items.append({
|
||||
'sale_date': sale_date_str,
|
||||
'drug_code': drug_code,
|
||||
'product_name': product_name,
|
||||
'barcode': barcode,
|
||||
'standard_code': standard_code,
|
||||
'supplier': row.supplier or '',
|
||||
'quantity': quantity,
|
||||
'unit_price': int(unit_price),
|
||||
'total_price': int(total_price)
|
||||
})
|
||||
|
||||
total_amount += total_price
|
||||
if barcode:
|
||||
barcode_count += 1
|
||||
unique_products.add(drug_code)
|
||||
|
||||
# 바코드 매핑률 계산
|
||||
barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items[:500], # 최대 500건
|
||||
'stats': {
|
||||
'total_count': len(items),
|
||||
'total_amount': int(total_amount),
|
||||
'barcode_rate': barcode_rate,
|
||||
'unique_products': len(unique_products)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"판매 상세 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ===== Claude 상태 API =====
|
||||
|
||||
@app.route('/api/claude-status')
|
||||
def api_claude_status():
|
||||
"""
|
||||
Claude 사용량 상태 조회 (토큰 차감 없음)
|
||||
GET /api/claude-status
|
||||
GET /api/claude-status?detail=true — 전체 세션 상세 포함
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ok": true,
|
||||
"connected": true,
|
||||
"model": "claude-opus-4-5",
|
||||
"mainSession": { ... },
|
||||
"summary": { ... },
|
||||
"sessions": [ ... ], // detail=true 일 때만
|
||||
"timestamp": "2026-02-27T09:45:00+09:00"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from services.clawdbot_client import get_claude_status
|
||||
|
||||
# 상세 모드 여부
|
||||
detail_mode = request.args.get('detail', 'false').lower() == 'true'
|
||||
|
||||
status = get_claude_status()
|
||||
|
||||
if not status or not status.get('connected'):
|
||||
return jsonify({
|
||||
'ok': False,
|
||||
'connected': False,
|
||||
'error': status.get('error', 'Gateway 연결 실패'),
|
||||
'timestamp': datetime.now(KST).isoformat()
|
||||
}), 503
|
||||
|
||||
sessions_data = status.get('sessions', {})
|
||||
sessions_list = sessions_data.get('sessions', [])
|
||||
defaults = sessions_data.get('defaults', {})
|
||||
|
||||
# 메인 세션 찾기
|
||||
main_session = None
|
||||
for s in sessions_list:
|
||||
if s.get('key') == 'agent:main:main':
|
||||
main_session = s
|
||||
break
|
||||
|
||||
# 전체 토큰 합계
|
||||
total_tokens = sum(s.get('totalTokens', 0) for s in sessions_list)
|
||||
|
||||
# 메인 세션 컨텍스트 사용률
|
||||
context_used = 0
|
||||
context_max = defaults.get('contextTokens', 200000)
|
||||
context_percent = 0
|
||||
if main_session:
|
||||
context_used = main_session.get('totalTokens', 0)
|
||||
context_max = main_session.get('contextTokens', context_max)
|
||||
if context_max > 0:
|
||||
context_percent = round(context_used / context_max * 100, 1)
|
||||
|
||||
# 기본 응답
|
||||
response = {
|
||||
'ok': True,
|
||||
'connected': True,
|
||||
'model': f"{defaults.get('modelProvider', 'unknown')}/{defaults.get('model', 'unknown')}",
|
||||
'context': {
|
||||
'used': context_used,
|
||||
'max': context_max,
|
||||
'percent': context_percent,
|
||||
'display': f"{context_used//1000}k/{context_max//1000}k ({context_percent}%)"
|
||||
},
|
||||
'mainSession': {
|
||||
'key': main_session.get('key') if main_session else None,
|
||||
'inputTokens': main_session.get('inputTokens', 0) if main_session else 0,
|
||||
'outputTokens': main_session.get('outputTokens', 0) if main_session else 0,
|
||||
'totalTokens': main_session.get('totalTokens', 0) if main_session else 0,
|
||||
'lastChannel': main_session.get('lastChannel') if main_session else None
|
||||
} if main_session else None,
|
||||
'summary': {
|
||||
'totalSessions': len(sessions_list),
|
||||
'totalTokens': total_tokens,
|
||||
'activeModel': defaults.get('model')
|
||||
},
|
||||
'timestamp': datetime.now(KST).isoformat()
|
||||
}
|
||||
|
||||
# 상세 모드: 전체 세션 목록 추가
|
||||
if detail_mode:
|
||||
detailed_sessions = []
|
||||
for s in sessions_list:
|
||||
session_tokens = s.get('totalTokens', 0)
|
||||
session_context_max = s.get('contextTokens', 200000)
|
||||
session_percent = round(session_tokens / session_context_max * 100, 1) if session_context_max > 0 else 0
|
||||
|
||||
# 세션 키에서 이름 추출 (agent:main:xxx → xxx)
|
||||
session_key = s.get('key', '')
|
||||
session_name = session_key.split(':')[-1] if ':' in session_key else session_key
|
||||
|
||||
# 마지막 활동 시간
|
||||
updated_at = s.get('updatedAt')
|
||||
updated_str = None
|
||||
if updated_at:
|
||||
try:
|
||||
dt = datetime.fromtimestamp(updated_at / 1000, tz=KST)
|
||||
updated_str = dt.strftime('%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
detailed_sessions.append({
|
||||
'key': session_key,
|
||||
'name': session_name,
|
||||
'displayName': s.get('displayName', session_name),
|
||||
'model': f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}",
|
||||
'tokens': {
|
||||
'input': s.get('inputTokens', 0),
|
||||
'output': s.get('outputTokens', 0),
|
||||
'total': session_tokens,
|
||||
'contextMax': session_context_max,
|
||||
'contextPercent': session_percent,
|
||||
'display': f"{session_tokens//1000}k/{session_context_max//1000}k ({session_percent}%)"
|
||||
},
|
||||
'channel': s.get('lastChannel') or s.get('origin', {}).get('provider'),
|
||||
'kind': s.get('kind'),
|
||||
'updatedAt': updated_str
|
||||
})
|
||||
|
||||
# 토큰 사용량 순으로 정렬
|
||||
detailed_sessions.sort(key=lambda x: x['tokens']['total'], reverse=True)
|
||||
response['sessions'] = detailed_sessions
|
||||
|
||||
# 모델별 통계
|
||||
model_stats = {}
|
||||
for s in sessions_list:
|
||||
model_key = f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}"
|
||||
if model_key not in model_stats:
|
||||
model_stats[model_key] = {'sessions': 0, 'tokens': 0}
|
||||
model_stats[model_key]['sessions'] += 1
|
||||
model_stats[model_key]['tokens'] += s.get('totalTokens', 0)
|
||||
response['modelStats'] = model_stats
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Claude 상태 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'ok': False,
|
||||
'connected': False,
|
||||
'error': str(e),
|
||||
'timestamp': datetime.now(KST).isoformat()
|
||||
}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 개발 모드로 실행
|
||||
app.run(host='0.0.0.0', port=7001, debug=True)
|
||||
|
||||
@ -193,6 +193,13 @@ class DatabaseManager:
|
||||
Returns:
|
||||
sqlite3.Connection: SQLite 연결 객체
|
||||
"""
|
||||
# 연결이 닫혀있으면 재생성
|
||||
if self.sqlite_conn is not None:
|
||||
try:
|
||||
self.sqlite_conn.execute("SELECT 1")
|
||||
except Exception:
|
||||
self.sqlite_conn = None
|
||||
|
||||
if self.sqlite_conn is None:
|
||||
# 파일 존재 여부 확인
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
@ -237,7 +244,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 +253,56 @@ 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 테이블 생성")
|
||||
|
||||
# ai_recommendations 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
@ -80,3 +80,43 @@ 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);
|
||||
|
||||
-- 7. AI 추천 테이블
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
|
||||
@ -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
|
||||
@ -172,8 +181,7 @@ class SalesQueryThread(QThread):
|
||||
finally:
|
||||
if mssql_conn:
|
||||
mssql_conn.close()
|
||||
if sqlite_conn:
|
||||
sqlite_conn.close()
|
||||
# sqlite_conn은 싱글톤이므로 닫지 않음 (닫으면 다른 곳에서 I/O 에러 발생)
|
||||
|
||||
|
||||
class QRGeneratorThread(QThread):
|
||||
@ -591,9 +599,7 @@ class UserMileageDialog(QDialog):
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
# conn은 싱글톤이므로 닫지 않음
|
||||
|
||||
|
||||
class POSSalesGUI(QMainWindow):
|
||||
@ -862,6 +868,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']:
|
||||
|
||||
353
backend/services/clawdbot_client.py
Normal file
353
backend/services/clawdbot_client.py
Normal file
@ -0,0 +1,353 @@
|
||||
"""
|
||||
Clawdbot Gateway Python 클라이언트
|
||||
카카오톡 봇과 동일한 Gateway WebSocket API를 통해 Claude와 통신
|
||||
추가 API 비용 없음 (Claude Max 구독 재활용)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
if hasattr(sys.stdout, 'buffer'):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import websockets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gateway 설정 (clawdbot.json에서 읽기)
|
||||
CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json'
|
||||
|
||||
|
||||
def _load_gateway_config():
|
||||
"""clawdbot.json에서 Gateway 설정 로드"""
|
||||
try:
|
||||
with open(CLAWDBOT_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
gw = config.get('gateway', {})
|
||||
return {
|
||||
'port': gw.get('port', 18789),
|
||||
'token': gw.get('auth', {}).get('token', ''),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 설정 파일 로드 실패: {e}")
|
||||
return {'port': 18789, 'token': ''}
|
||||
|
||||
|
||||
async def _ask_gateway(message, session_id='pharmacy-upsell',
|
||||
system_prompt=None, timeout=60, model=None):
|
||||
"""
|
||||
Clawdbot Gateway WebSocket API 호출
|
||||
|
||||
프로토콜:
|
||||
1. WS 연결
|
||||
2. 서버 → connect.challenge (nonce)
|
||||
3. 클라이언트 → connect 요청 (token)
|
||||
4. 서버 → connect 응답 (ok)
|
||||
5. 클라이언트 → agent 요청
|
||||
6. 서버 → accepted (ack) → 최종 응답
|
||||
|
||||
Returns:
|
||||
str: AI 응답 텍스트 (실패 시 None)
|
||||
"""
|
||||
config = _load_gateway_config()
|
||||
url = f"ws://127.0.0.1:{config['port']}"
|
||||
token = config['token']
|
||||
|
||||
try:
|
||||
async with websockets.connect(url, max_size=25 * 1024 * 1024,
|
||||
close_timeout=5) as ws:
|
||||
# 1. connect.challenge 대기
|
||||
nonce = None
|
||||
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
challenge = json.loads(challenge_msg)
|
||||
if challenge.get('event') == 'connect.challenge':
|
||||
nonce = challenge.get('payload', {}).get('nonce')
|
||||
|
||||
# 2. connect 요청
|
||||
connect_id = str(uuid.uuid4())
|
||||
connect_frame = {
|
||||
'type': 'req',
|
||||
'id': connect_id,
|
||||
'method': 'connect',
|
||||
'params': {
|
||||
'minProtocol': 3,
|
||||
'maxProtocol': 3,
|
||||
'client': {
|
||||
'id': 'gateway-client',
|
||||
'displayName': 'Pharmacy Upsell',
|
||||
'version': '1.0.0',
|
||||
'platform': 'win32',
|
||||
'mode': 'backend',
|
||||
'instanceId': str(uuid.uuid4()),
|
||||
},
|
||||
'caps': [],
|
||||
'auth': {
|
||||
'token': token,
|
||||
},
|
||||
'role': 'operator',
|
||||
'scopes': ['operator.admin'],
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(connect_frame))
|
||||
|
||||
# 3. connect 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
if data.get('id') == connect_id:
|
||||
if not data.get('ok'):
|
||||
error = data.get('error', {}).get('message', 'unknown')
|
||||
logger.warning(f"[Clawdbot] connect 실패: {error}")
|
||||
return None
|
||||
break # 연결 성공
|
||||
|
||||
# 4. 모델 오버라이드 (sessions.patch)
|
||||
if model:
|
||||
patch_id = str(uuid.uuid4())
|
||||
patch_frame = {
|
||||
'type': 'req',
|
||||
'id': patch_id,
|
||||
'method': 'sessions.patch',
|
||||
'params': {
|
||||
'key': session_id,
|
||||
'model': model,
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(patch_frame))
|
||||
# patch 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
if data.get('id') == patch_id:
|
||||
if not data.get('ok'):
|
||||
logger.warning(f"[Clawdbot] sessions.patch 실패: {data.get('error', {}).get('message', 'unknown')}")
|
||||
break
|
||||
|
||||
# 5. agent 요청
|
||||
agent_id = str(uuid.uuid4())
|
||||
agent_params = {
|
||||
'message': message,
|
||||
'sessionId': session_id,
|
||||
'sessionKey': session_id,
|
||||
'timeout': timeout,
|
||||
'idempotencyKey': str(uuid.uuid4()),
|
||||
}
|
||||
if system_prompt:
|
||||
agent_params['extraSystemPrompt'] = system_prompt
|
||||
|
||||
agent_frame = {
|
||||
'type': 'req',
|
||||
'id': agent_id,
|
||||
'method': 'agent',
|
||||
'params': agent_params,
|
||||
}
|
||||
await ws.send(json.dumps(agent_frame))
|
||||
|
||||
# 5. agent 응답 대기 (accepted → final)
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=timeout + 30)
|
||||
data = json.loads(msg)
|
||||
|
||||
# 이벤트 무시 (tick 등)
|
||||
if data.get('event'):
|
||||
continue
|
||||
|
||||
# 우리 요청에 대한 응답인지 확인
|
||||
if data.get('id') != agent_id:
|
||||
continue
|
||||
|
||||
payload = data.get('payload', {})
|
||||
status = payload.get('status')
|
||||
|
||||
# accepted는 대기
|
||||
if status == 'accepted':
|
||||
continue
|
||||
|
||||
# 최종 응답
|
||||
if data.get('ok'):
|
||||
payloads = payload.get('result', {}).get('payloads', [])
|
||||
text = '\n'.join(p.get('text', '') for p in payloads if p.get('text'))
|
||||
return text or None
|
||||
else:
|
||||
error = data.get('error', {}).get('message', 'unknown')
|
||||
logger.warning(f"[Clawdbot] agent 실패: {error}")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[Clawdbot] Gateway 타임아웃")
|
||||
return None
|
||||
except (ConnectionRefusedError, OSError) as e:
|
||||
logger.warning(f"[Clawdbot] Gateway 연결 실패 (꺼져있음?): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] Gateway 오류: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def ask_clawdbot(message, session_id='pharmacy-upsell',
|
||||
system_prompt=None, timeout=60, model=None):
|
||||
"""
|
||||
동기 래퍼: Flask에서 직접 호출 가능
|
||||
|
||||
Args:
|
||||
message: 사용자 메시지
|
||||
session_id: 세션 ID (대화 구분용)
|
||||
system_prompt: 추가 시스템 프롬프트
|
||||
timeout: 타임아웃 (초)
|
||||
model: 모델 오버라이드 (예: 'anthropic/claude-sonnet-4-5')
|
||||
|
||||
Returns:
|
||||
str: AI 응답 텍스트 (실패 시 None)
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(
|
||||
_ask_gateway(message, session_id, system_prompt, timeout, model=model)
|
||||
)
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 업셀링 전용 ──────────────────────────────────────
|
||||
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # 업셀링은 Sonnet (빠르고 충분)
|
||||
|
||||
UPSELL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
|
||||
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
|
||||
|
||||
|
||||
def generate_upsell(user_name, current_items, recent_products):
|
||||
"""
|
||||
업셀링 추천 생성
|
||||
|
||||
Args:
|
||||
user_name: 고객명
|
||||
current_items: 오늘 구매 품목 문자열 (예: "타이레놀, 챔프 시럽")
|
||||
recent_products: 최근 구매 이력 문자열
|
||||
|
||||
Returns:
|
||||
dict: {'product': '...', 'reason': '...', 'message': '...'} 또는 None
|
||||
"""
|
||||
prompt = f"""고객 이름: {user_name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
위 정보를 바탕으로 이 고객에게 추천할 약품 하나를 제안해주세요.
|
||||
|
||||
규칙:
|
||||
1. 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
|
||||
2. 실제 약국에서 판매하는 일반의약품/건강기능식품만 추천 (처방약 제외)
|
||||
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
|
||||
4. 구체적인 제품명 사용 (예: "비타민C 1000", "오메가3" 등)
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만:
|
||||
{{"product": "추천 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [오늘 구매 품목]과 함께 [추천약]도 추천드려요. [간단한 이유]."}}"""
|
||||
|
||||
response_text = ask_clawdbot(
|
||||
prompt,
|
||||
session_id=f'upsell-{user_name}',
|
||||
system_prompt=UPSELL_SYSTEM_PROMPT,
|
||||
timeout=30,
|
||||
model=UPSELL_MODEL
|
||||
)
|
||||
|
||||
if not response_text:
|
||||
return None
|
||||
|
||||
return _parse_upsell_response(response_text)
|
||||
|
||||
|
||||
UPSELL_REAL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다.
|
||||
반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요.
|
||||
목록에 없는 제품은 절대 추천하지 마세요.
|
||||
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
|
||||
|
||||
|
||||
def generate_upsell_real(user_name, current_items, recent_products, available_products):
|
||||
"""
|
||||
실데이터 기반 업셀링 추천 생성
|
||||
available_products: 약국 보유 제품 리스트 [{'name': ..., 'price': ..., 'sales': ...}, ...]
|
||||
"""
|
||||
product_list = '\n'.join(
|
||||
f"- {p['name']} ({int(p['price'])}원, 최근 {p['sales']}건 판매)"
|
||||
for p in available_products if p.get('name')
|
||||
)
|
||||
|
||||
prompt = f"""고객 이름: {user_name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
[약국 보유 제품 목록 — 이 중에서만 추천하세요]
|
||||
{product_list}
|
||||
|
||||
규칙:
|
||||
1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
|
||||
2. 오늘 이미 구매한 제품은 추천하지 마세요
|
||||
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
|
||||
4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만:
|
||||
{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [추천 메시지 2문장 이내]"}}"""
|
||||
|
||||
response_text = ask_clawdbot(
|
||||
prompt,
|
||||
session_id=f'upsell-real-{user_name}',
|
||||
system_prompt=UPSELL_REAL_SYSTEM_PROMPT,
|
||||
timeout=30,
|
||||
model=UPSELL_MODEL
|
||||
)
|
||||
|
||||
if not response_text:
|
||||
return None
|
||||
|
||||
return _parse_upsell_response(response_text)
|
||||
|
||||
|
||||
def _parse_upsell_response(text):
|
||||
"""AI 응답에서 JSON 추출"""
|
||||
import re
|
||||
try:
|
||||
# ```json ... ``` 블록 추출 시도
|
||||
json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
else:
|
||||
# 직접 JSON 파싱 시도
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start >= 0 and end > start:
|
||||
json_str = text[start:end + 1]
|
||||
else:
|
||||
return None
|
||||
|
||||
data = json.loads(json_str)
|
||||
|
||||
if 'product' not in data or 'message' not in data:
|
||||
return None
|
||||
|
||||
return {
|
||||
'product': data['product'],
|
||||
'reason': data.get('reason', ''),
|
||||
'message': data['message'],
|
||||
}
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.warning(f"[Clawdbot] 업셀 응답 파싱 실패: {e}")
|
||||
return None
|
||||
@ -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,16 @@
|
||||
</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>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/sales" 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>
|
||||
<a href="/admin/ai-crm" 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;">🤖 AI CRM</a>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
415
backend/templates/admin_ai_crm.html
Normal file
415
backend/templates/admin_ai_crm.html
Normal file
@ -0,0 +1,415 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 업셀링 CRM - 청춘약국</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;600;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: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6366f1 50%, #8b5cf6 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.default { color: #1e293b; }
|
||||
.stat-value.green { color: #16a34a; }
|
||||
.stat-value.orange { color: #d97706; }
|
||||
.stat-value.indigo { color: #6366f1; }
|
||||
|
||||
/* ── 테이블 섹션 ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.section-sub {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
letter-spacing: -0.2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr { cursor: pointer; transition: background .15s; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 배지 ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #16a34a; }
|
||||
.badge-interested { background: #fef3c7; color: #d97706; }
|
||||
.badge-dismissed { background: #f1f5f9; color: #64748b; }
|
||||
.badge-expired { background: #fee2e2; color: #dc2626; }
|
||||
.badge-trigger {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
margin: 1px 2px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.badge-product {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 메시지 말줄임 ── */
|
||||
.msg-ellipsis {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 노출 횟수 ── */
|
||||
.display-count {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.display-count.zero { color: #cbd5e1; }
|
||||
|
||||
/* ── 아코디언 상세 ── */
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-content {
|
||||
padding: 20px 24px;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-field {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.detail-raw {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.detail-raw pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
.empty-text { font-size: 14px; font-weight: 500; }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.header { padding: 20px 16px 18px; }
|
||||
.content { padding: 16px 12px 40px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 700px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
||||
</div>
|
||||
<h1>AI 업셀링 CRM</h1>
|
||||
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">전체 생성</div>
|
||||
<div class="stat-value default">{{ stats.total or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Active</div>
|
||||
<div class="stat-value green">{{ stats.active_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">관심있어요</div>
|
||||
<div class="stat-value orange">{{ stats.interested_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">오늘 생성</div>
|
||||
<div class="stat-value indigo">{{ stats.today_count or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추천 목록 -->
|
||||
<div class="section-header">
|
||||
<div class="section-title">추천 생성 로그</div>
|
||||
<div class="section-sub">최근 50건 · 클릭하여 상세 보기</div>
|
||||
</div>
|
||||
|
||||
{% if recs %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>생성일시</th>
|
||||
<th>고객</th>
|
||||
<th>트리거 품목</th>
|
||||
<th>추천 제품</th>
|
||||
<th>AI 메시지</th>
|
||||
<th>상태</th>
|
||||
<th style="text-align:center">노출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rec in recs %}
|
||||
<tr onclick="toggleDetail({{ rec.id }})">
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">
|
||||
{{ rec.created_at[5:16] if rec.created_at else '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-weight:600;font-size:13px;">{{ rec.nickname or '알 수 없음' }}</div>
|
||||
{% if rec.user_phone %}
|
||||
<div style="font-size:11px;color:#94a3b8;">{{ rec.user_phone[:3] }}-****-{{ rec.user_phone[-4:] }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if rec.trigger_list %}
|
||||
{% for item in rec.trigger_list %}
|
||||
<span class="badge badge-trigger">{{ item }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color:#cbd5e1;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-product">{{ rec.recommended_product }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="msg-ellipsis" title="{{ rec.recommendation_message }}">{{ rec.recommendation_message }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if rec.status == 'interested' %}
|
||||
<span class="badge badge-interested">관심있어요</span>
|
||||
{% elif rec.status == 'active' and (not rec.expires_at or rec.expires_at > now) %}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{% elif rec.status == 'dismissed' %}
|
||||
<span class="badge badge-dismissed">Dismissed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-expired">Expired</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="display-count {{ 'zero' if not rec.displayed_count else '' }}">
|
||||
{{ rec.displayed_count or 0 }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 상세 아코디언 -->
|
||||
<tr class="detail-row" id="detail-{{ rec.id }}">
|
||||
<td colspan="7">
|
||||
<div class="detail-content">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">추천 이유</div>
|
||||
<div class="detail-value">{{ rec.recommendation_reason or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">거래 ID</div>
|
||||
<div class="detail-value">{{ rec.transaction_id or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">노출 일시</div>
|
||||
<div class="detail-value">{{ rec.displayed_at or '미노출' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">닫기 일시</div>
|
||||
<div class="detail-value">{{ rec.dismissed_at or '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">만료 일시</div>
|
||||
<div class="detail-value">{{ rec.expires_at or '없음' }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">노출 횟수</div>
|
||||
<div class="detail-value">{{ rec.displayed_count or 0 }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if rec.ai_raw_response %}
|
||||
<div class="detail-raw">
|
||||
<div class="detail-label">AI 원본 응답</div>
|
||||
<pre>{{ rec.ai_raw_response }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🤖</div>
|
||||
<div class="empty-text">아직 생성된 AI 추천이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleDetail(id) {
|
||||
const row = document.getElementById('detail-' + id);
|
||||
if (!row) return;
|
||||
// 다른 열린 것 닫기
|
||||
document.querySelectorAll('.detail-row.open').forEach(function(el) {
|
||||
if (el.id !== 'detail-' + id) el.classList.remove('open');
|
||||
});
|
||||
row.classList.toggle('open');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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>
|
||||
479
backend/templates/admin_sales_detail.html
Normal file
479
backend/templates/admin_sales_detail.html
Normal file
@ -0,0 +1,479 @@
|
||||
<!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;600;700&family=JetBrains+Mono:wght@400;500&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: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 검색/필터 영역 ── */
|
||||
.search-section {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
min-width: 150px;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: #0d9488;
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.1);
|
||||
}
|
||||
.search-btn {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.search-btn:hover { background: #0f766e; }
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.teal { color: #0d9488; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-value.purple { color: #8b5cf6; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.table-count {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 코드 스타일 ── */
|
||||
.code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.code-barcode {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.code-standard {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.code-na {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 제품명 ── */
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.product-category {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── 금액 ── */
|
||||
.price {
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.qty {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 코드 전환 버튼 ── */
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border-color: #0d9488;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.search-section { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 900px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/sales" style="margin-right: 16px;">판매 내역</a>
|
||||
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
|
||||
<a href="/admin/ai-gw">Gateway 모니터</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>판매 상세 조회</h1>
|
||||
<p>상품코드 · 바코드 · 표준코드 매핑 조회</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색/필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="7" selected>최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색 (상품명/코드)</label>
|
||||
<input type="text" id="searchInput" placeholder="타이레놀, LB000...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드 필터</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">바코드 있음</option>
|
||||
<option value="none">바코드 없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 판매 건수</div>
|
||||
<div class="stat-value teal" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 매출액</div>
|
||||
<div class="stat-value blue" id="statAmount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
<div class="stat-value purple" id="statBarcode">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">고유 상품 수</div>
|
||||
<div class="stat-value orange" id="statProducts">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 코드 표시 토글 -->
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체 표시</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<div class="table-header">
|
||||
<div class="table-title">판매 내역</div>
|
||||
<div class="table-count" id="tableCount">-</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일시</th>
|
||||
<th>상품명</th>
|
||||
<th id="codeHeader">상품코드</th>
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
<th>합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesTableBody">
|
||||
<tr><td colspan="6" class="loading">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let salesData = [];
|
||||
let currentCodeView = 'drug';
|
||||
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const header = document.getElementById('codeHeader');
|
||||
if (view === 'drug') header.textContent = '상품코드';
|
||||
else if (view === 'barcode') header.textContent = '바코드';
|
||||
else if (view === 'standard') header.textContent = '표준코드';
|
||||
else header.textContent = '코드 (상품/바코드/표준)';
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num) + '원';
|
||||
}
|
||||
|
||||
function renderCodeCell(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
return item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">N/A</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code code-na">N/A</span>`;
|
||||
} else {
|
||||
// 전체 표시
|
||||
let html = `<span class="code code-drug">${item.drug_code}</span><br>`;
|
||||
html += item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span><br>`
|
||||
: `<span class="code code-na">바코드 없음</span><br>`;
|
||||
html += item.standard_code
|
||||
? `<span class="code code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code code-na">표준코드 없음</span>`;
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('salesTableBody');
|
||||
|
||||
if (salesData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = salesData.map(item => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-name">${escapeHtml(item.product_name)}</div>
|
||||
${item.supplier ? `<div class="product-category">${escapeHtml(item.supplier)}</div>` : ''}
|
||||
</td>
|
||||
<td>${renderCodeCell(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}</td>
|
||||
<td class="price">${formatPrice(item.total_price)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
'<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
fetch(`/api/sales-detail?days=${period}&search=${encodeURIComponent(search)}&barcode=${barcodeFilter}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
salesData = data.items;
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTotal').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
document.getElementById('tableCount').textContent = `${salesData.length}건`;
|
||||
|
||||
renderTable();
|
||||
} else {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="6" class="empty-state">오류: ${data.error}</td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="6" class="empty-state">데이터 로드 실패</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
902
backend/templates/admin_sales_pos.html
Normal file
902
backend/templates/admin_sales_pos.html
Normal file
@ -0,0 +1,902 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>판매 내역 - 청춘약국 POS</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;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--border: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-emerald: #10b981;
|
||||
--accent-rose: #f43f5e;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
|
||||
padding: 20px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left p {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ══════════════════ 컨텐츠 ══════════════════ */
|
||||
.content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 검색 영역 ══════════════════ */
|
||||
.search-bar {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
min-width: 140px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
|
||||
}
|
||||
.search-group input::placeholder { color: var(--text-muted); }
|
||||
.search-btn {
|
||||
background: linear-gradient(135deg, var(--accent-teal), var(--accent-emerald));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.4);
|
||||
}
|
||||
|
||||
/* ══════════════════ 통계 카드 ══════════════════ */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.stat-card.teal::before { background: var(--accent-teal); }
|
||||
.stat-card.blue::before { background: var(--accent-blue); }
|
||||
.stat-card.purple::before { background: var(--accent-purple); }
|
||||
.stat-card.amber::before { background: var(--accent-amber); }
|
||||
.stat-card.emerald::before { background: var(--accent-emerald); }
|
||||
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card.teal .stat-value { color: var(--accent-teal); }
|
||||
.stat-card.blue .stat-value { color: var(--accent-blue); }
|
||||
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
||||
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
||||
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 뷰 토글 ══════════════════ */
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: var(--accent-teal);
|
||||
color: #fff;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.view-mode {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.view-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-btn.active {
|
||||
border-color: var(--accent-teal);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
/* ══════════════════ 거래 카드 (그룹별) ══════════════════ */
|
||||
.transactions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tx-card:hover {
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
.tx-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tx-header:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
.tx-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.tx-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
.tx-time {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tx-customer {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.tx-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tx-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-emerald);
|
||||
}
|
||||
.tx-toggle {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.tx-card.open .tx-toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 품목 테이블 */
|
||||
.tx-items {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
.tx-card.open .tx-items {
|
||||
max-height: 2000px;
|
||||
}
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.items-table th {
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.items-table th:nth-child(4),
|
||||
.items-table th:nth-child(5),
|
||||
.items-table th:nth-child(6) {
|
||||
text-align: right;
|
||||
}
|
||||
.items-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.items-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* 제품 셀 */
|
||||
.product-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 코드 뱃지 */
|
||||
.code-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.code-barcode {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.code-standard {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.code-na {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.code-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 바코드 시각화 */
|
||||
.barcode-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.barcode-bars {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
.barcode-bars span {
|
||||
width: 2px;
|
||||
background: var(--accent-emerald);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 숫자 정렬 */
|
||||
.items-table td.qty,
|
||||
.items-table td.price {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.items-table td.price.total {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ══════════════════ 리스트 뷰 ══════════════════ */
|
||||
.list-view {
|
||||
display: none;
|
||||
}
|
||||
.list-view.active {
|
||||
display: block;
|
||||
}
|
||||
.list-table-wrap {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.list-table th {
|
||||
padding: 14px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.list-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent-teal);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 반응형 ══════════════════ */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.header-nav { display: none; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.tx-info { flex-wrap: wrap; gap: 8px; }
|
||||
.view-controls { flex-direction: column; gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<h1>🧾 판매 내역</h1>
|
||||
<p>POS 판매 데이터 · 바코드 · 표준코드 조회</p>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">📊 대시보드</a>
|
||||
<a href="/admin/ai-crm">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk">📨 알림톡</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="3" selected>최근 3일</option>
|
||||
<option value="7">최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색어</label>
|
||||
<input type="text" id="searchInput" placeholder="상품명, 코드, 바코드...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">있음</option>
|
||||
<option value="none">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card teal">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-value" id="statTxCount">-</div>
|
||||
<div class="stat-label">조회 일수</div>
|
||||
</div>
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">📦</div>
|
||||
<div class="stat-value" id="statItemCount">-</div>
|
||||
<div class="stat-label">총 판매 품목</div>
|
||||
</div>
|
||||
<div class="stat-card emerald">
|
||||
<div class="stat-icon">💰</div>
|
||||
<div class="stat-value" id="statAmount">-</div>
|
||||
<div class="stat-label">총 매출액</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value" id="statBarcode">-</div>
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
</div>
|
||||
<div class="stat-card amber">
|
||||
<div class="stat-icon">🏷️</div>
|
||||
<div class="stat-value" id="statProducts">-</div>
|
||||
<div class="stat-label">고유 상품</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰 컨트롤 -->
|
||||
<div class="view-controls">
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체</button>
|
||||
</div>
|
||||
<div class="view-mode">
|
||||
<button class="view-btn active" data-view="group" onclick="setViewMode('group')">📁 거래별</button>
|
||||
<button class="view-btn" data-view="list" onclick="setViewMode('list')">📋 목록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 거래별 뷰 -->
|
||||
<div id="groupView" class="transactions-container">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="list-view">
|
||||
<div class="list-table-wrap">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일</th>
|
||||
<th>상품명</th>
|
||||
<th id="listCodeHeader">상품코드</th>
|
||||
<th style="text-align:center">수량</th>
|
||||
<th style="text-align:right">단가</th>
|
||||
<th style="text-align:right">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let rawData = []; // API에서 받은 원본 데이터
|
||||
let groupedData = []; // 거래별 그룹화된 데이터
|
||||
let currentCodeView = 'drug';
|
||||
let currentViewMode = 'group';
|
||||
|
||||
// ──────────────── 코드 뷰 전환 ────────────────
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'drug': '상품코드',
|
||||
'barcode': '바코드',
|
||||
'standard': '표준코드',
|
||||
'all': '코드 정보'
|
||||
};
|
||||
document.querySelectorAll('#codeHeader, #listCodeHeader').forEach(el => {
|
||||
if (el) el.textContent = headers[view];
|
||||
});
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
// ──────────────── 뷰 모드 전환 ────────────────
|
||||
function setViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||
});
|
||||
document.getElementById('groupView').style.display = mode === 'group' ? 'flex' : 'none';
|
||||
document.getElementById('listView').classList.toggle('active', mode === 'list');
|
||||
}
|
||||
|
||||
// ──────────────── 코드 렌더링 ────────────────
|
||||
function renderCode(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code-badge code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
if (item.barcode) {
|
||||
return `
|
||||
<div class="barcode-visual">
|
||||
<span class="code-badge code-barcode">${item.barcode}</span>
|
||||
${renderBarcodeBars(item.barcode)}
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="code-badge code-na">—</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code-badge code-na">—</span>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="code-stack">
|
||||
<span class="code-badge code-drug">${item.drug_code}</span>
|
||||
${item.barcode
|
||||
? `<span class="code-badge code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code-badge code-na">바코드 없음</span>`}
|
||||
${item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: ''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 바코드 시각화 바
|
||||
function renderBarcodeBars(barcode) {
|
||||
const bars = barcode.split('').map(c => {
|
||||
const h = 8 + (parseInt(c) || c.charCodeAt(0) % 10) * 1.2;
|
||||
return `<span style="height:${h}px"></span>`;
|
||||
}).join('');
|
||||
return `<div class="barcode-bars">${bars}</div>`;
|
||||
}
|
||||
|
||||
// ──────────────── 포맷 ────────────────
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 그룹화 (날짜별) ────────────────
|
||||
function groupByDate(items) {
|
||||
const map = new Map();
|
||||
items.forEach(item => {
|
||||
const key = item.sale_date;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
date: item.sale_date,
|
||||
items: [],
|
||||
total: 0
|
||||
});
|
||||
}
|
||||
const group = map.get(key);
|
||||
group.items.push(item);
|
||||
group.total += item.total_price || 0;
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
b.date.localeCompare(a.date)
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── 렌더링 ────────────────
|
||||
function render() {
|
||||
renderGroupView();
|
||||
renderListView();
|
||||
}
|
||||
|
||||
function renderGroupView() {
|
||||
const container = document.getElementById('groupView');
|
||||
|
||||
if (groupedData.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div>판매 내역이 없습니다</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = groupedData.map((tx, idx) => `
|
||||
<div class="tx-card" id="tx-${idx}">
|
||||
<div class="tx-header" onclick="toggleTransaction(${idx})">
|
||||
<div class="tx-info">
|
||||
<span class="tx-id">📅 ${tx.date}</span>
|
||||
</div>
|
||||
<div class="tx-summary">
|
||||
<span class="tx-count">${tx.items.length}개 품목</span>
|
||||
<span class="tx-amount">${formatPrice(tx.total)}원</span>
|
||||
<span class="tx-toggle">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-items">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">상품명</th>
|
||||
<th id="codeHeader-${idx}">상품코드</th>
|
||||
<th style="text-align:right;width:8%">수량</th>
|
||||
<th style="text-align:right;width:12%">단가</th>
|
||||
<th style="text-align:right;width:12%">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tx.items.map(item => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}원</td>
|
||||
<td class="price total">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderListView() {
|
||||
const tbody = document.getElementById('listTableBody');
|
||||
|
||||
if (rawData.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rawData.map(item => `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td style="text-align:center">${item.quantity}</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;">${formatPrice(item.unit_price)}원</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;color:var(--accent-teal);font-weight:600;">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function toggleTransaction(idx) {
|
||||
const card = document.getElementById(`tx-${idx}`);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 로드 ────────────────
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>`;
|
||||
|
||||
let url = `/api/sales-detail?days=${period}&barcode=${barcodeFilter}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
rawData = data.items;
|
||||
groupedData = groupByDate(rawData);
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTxCount').textContent = groupedData.length.toLocaleString();
|
||||
document.getElementById('statItemCount').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
|
||||
render();
|
||||
} else {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<div>오류: ${data.error}</div>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">❌</div>
|
||||
<div>데이터 로드 실패</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 엔터키 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') loadSalesData();
|
||||
});
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</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;
|
||||
}
|
||||
@ -395,16 +396,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.claim-left {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.claim-info-card { flex: 1; min-width: 200px; }
|
||||
.qr-container { flex-shrink: 0; }
|
||||
.items-card { width: 100%; max-height: 160px; }
|
||||
.qr-container img { width: 140px; height: 140px; }
|
||||
.claim-info-card { width: 100%; max-width: 480px; }
|
||||
.qr-container { align-self: center; }
|
||||
.items-card { width: 100%; max-width: 480px; max-height: 160px; }
|
||||
.qr-container img { width: 160px; height: 160px; }
|
||||
|
||||
.divider { flex-direction: row; }
|
||||
.divider-line { width: 60px; height: 2px; }
|
||||
|
||||
@ -392,6 +392,156 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- AI 추천 바텀시트 -->
|
||||
<div id="rec-sheet" style="display:none;">
|
||||
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
|
||||
<!-- 드래그 핸들 영역 -->
|
||||
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
</div>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec('dismissed')" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec('interested')" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes recFadeIn { from{opacity:0} to{opacity:1} }
|
||||
@keyframes recSlideUp { from{transform:translate(-50%,100%)} to{transform:translate(-50%,0)} }
|
||||
@keyframes recSlideDown { from{transform:translate(-50%,0)} to{transform:translate(-50%,100%)} }
|
||||
</style>
|
||||
<script>
|
||||
let _recId = null;
|
||||
|
||||
// ── 드래그 닫기 ──
|
||||
(function() {
|
||||
let startY = 0, currentY = 0, isDragging = false;
|
||||
const DISMISS_THRESHOLD = 80;
|
||||
|
||||
function getContent() { return document.getElementById('rec-content'); }
|
||||
function getBackdrop() { return document.getElementById('rec-backdrop'); }
|
||||
|
||||
function onStart(y) {
|
||||
const c = getContent();
|
||||
if (!c) return;
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
currentY = 0;
|
||||
c.style.animation = 'none';
|
||||
c.style.transition = 'none';
|
||||
}
|
||||
function onMove(y) {
|
||||
if (!isDragging) return;
|
||||
const c = getContent();
|
||||
const b = getBackdrop();
|
||||
currentY = Math.max(0, y - startY); // 아래로만
|
||||
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
|
||||
// 배경 투명도도 같이
|
||||
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
|
||||
b.style.background = 'rgba(0,0,0,' + opacity + ')';
|
||||
}
|
||||
function onEnd() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const c = getContent();
|
||||
if (currentY > DISMISS_THRESHOLD) {
|
||||
// 충분히 내렸으면 닫기
|
||||
c.style.transition = 'transform .25s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
getBackdrop().style.transition = 'opacity .25s';
|
||||
getBackdrop().style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
document.getElementById('rec-sheet').style.display = 'none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 250);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
} else {
|
||||
// 복귀
|
||||
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
|
||||
c.style.transform = 'translate(-50%, 0)';
|
||||
getBackdrop().style.transition = 'background .25s';
|
||||
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
|
||||
setTimeout(function() { c.style.transition = ''; }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('rec-content');
|
||||
if (!el) return;
|
||||
|
||||
// 터치 (모바일)
|
||||
el.addEventListener('touchstart', function(e) {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, {passive: true});
|
||||
el.addEventListener('touchmove', function(e) {
|
||||
if (isDragging && currentY > 0) e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, {passive: false});
|
||||
el.addEventListener('touchend', onEnd);
|
||||
|
||||
// 마우스 (데스크톱 테스트용)
|
||||
el.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
onStart(e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) onMove(e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 추천 로드 ──
|
||||
window.addEventListener('load', function() {
|
||||
{% if user_id %}
|
||||
setTimeout(async function() {
|
||||
try {
|
||||
const res = await fetch('/api/recommendation/{{ user_id }}');
|
||||
const data = await res.json();
|
||||
if (data.success && data.has_recommendation) {
|
||||
_recId = data.recommendation.id;
|
||||
document.getElementById('rec-message').textContent = data.recommendation.message;
|
||||
document.getElementById('rec-product').textContent = data.recommendation.product;
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[AI추천] 에러:', e);
|
||||
}
|
||||
}, 1500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function dismissRec(action) {
|
||||
action = action || 'dismissed';
|
||||
const c = document.getElementById('rec-content');
|
||||
const b = document.getElementById('rec-backdrop');
|
||||
c.style.transition = 'transform .3s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
b.style.opacity = '0';
|
||||
b.style.transition = 'opacity .3s';
|
||||
setTimeout(function(){
|
||||
document.getElementById('rec-sheet').style.display='none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 300);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action: action})
|
||||
}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -115,8 +115,8 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
|
||||
- token_hash가 이미 존재하면 실패 (UNIQUE 제약)
|
||||
"""
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
from db.dbsetup import db_manager as _db_manager
|
||||
conn = _db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 중복 체크 (transaction_id)
|
||||
|
||||
173
docs/ai-upselling-crm.md
Normal file
173
docs/ai-upselling-crm.md
Normal file
@ -0,0 +1,173 @@
|
||||
# AI 업셀링 CRM — 마이페이지 맞춤 추천 시스템
|
||||
|
||||
## 개요
|
||||
키오스크 적립 시 고객 구매이력을 AI가 분석하여 맞춤 제품을 추천.
|
||||
고객이 알림톡 → 마이페이지 접속 시 바텀시트 팝업으로 자연스럽게 표시.
|
||||
|
||||
## 기술 스택
|
||||
- **AI 엔진**: Clawdbot Gateway (Claude Max 구독 재활용, 추가 비용 없음)
|
||||
- **통신**: WebSocket (`ws://127.0.0.1:18789`) — JSON-RPC 프로토콜
|
||||
- **저장소**: SQLite `ai_recommendations` 테이블
|
||||
- **프론트**: 바텀시트 UI (드래그 닫기 지원)
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
```
|
||||
키오스크 적립 (POST /api/kiosk/claim)
|
||||
│
|
||||
├─ 1. 적립 처리 (기존)
|
||||
├─ 2. 알림톡 발송 (기존)
|
||||
└─ 3. AI 추천 생성 (fire-and-forget)
|
||||
│
|
||||
├─ 최근 구매 이력 수집 (SQLite + MSSQL SALE_SUB)
|
||||
├─ Clawdbot Gateway → Claude 호출
|
||||
├─ 추천 결과 → ai_recommendations 저장
|
||||
└─ 실패 시 무시 (추천은 부가 기능)
|
||||
|
||||
고객: 알림톡 버튼 클릭 → /my-page
|
||||
│
|
||||
├─ 1.5초 후 GET /api/recommendation/{user_id}
|
||||
│
|
||||
├─ 추천 있음 → 바텀시트 슬라이드업
|
||||
│ ├─ 아래로 드래그 → 닫기
|
||||
│ ├─ "다음에요" → dismiss
|
||||
│ └─ "관심있어요!" → dismiss + 기록
|
||||
│
|
||||
└─ 추천 없음 → 아무것도 안 뜸
|
||||
```
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
### `backend/services/clawdbot_client.py`
|
||||
Clawdbot Gateway Python 클라이언트.
|
||||
|
||||
**Gateway WebSocket 프로토콜 (v3):**
|
||||
1. WS 연결 → `ws://127.0.0.1:{port}`
|
||||
2. 서버 → `connect.challenge` 이벤트 (nonce 전달)
|
||||
3. 클라이언트 → `connect` 요청 (token + client info)
|
||||
4. 서버 → connect 응답 (ok)
|
||||
5. 클라이언트 → `agent` 요청 (message + systemPrompt)
|
||||
6. 서버 → `accepted` ack → 최종 응답 (`payloads[].text`)
|
||||
|
||||
**주요 함수:**
|
||||
| 함수 | 설명 |
|
||||
|------|------|
|
||||
| `_load_gateway_config()` | `~/.clawdbot/clawdbot.json`에서 port, token 읽기 |
|
||||
| `_ask_gateway(message, ...)` | async WebSocket 통신 |
|
||||
| `ask_clawdbot(message, ...)` | 동기 래퍼 (Flask에서 호출) |
|
||||
| `generate_upsell(user_name, current_items, recent_products)` | 업셀 프롬프트 구성 + 호출 + JSON 파싱 |
|
||||
| `_parse_upsell_response(text)` | AI 응답에서 JSON 추출 |
|
||||
|
||||
**Gateway 설정:**
|
||||
- 설정 파일: `~/.clawdbot/clawdbot.json`
|
||||
- Client ID: `gateway-client` (허용된 상수 중 하나)
|
||||
- Protocol: v3 (minProtocol=3, maxProtocol=3)
|
||||
|
||||
### `backend/db/mileage_schema.sql` — ai_recommendations 테이블
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL, -- "고려은단 비타민C 1000"
|
||||
recommendation_message TEXT NOT NULL, -- 고객에게 보여줄 메시지
|
||||
recommendation_reason TEXT, -- 내부용 추천 이유
|
||||
trigger_products TEXT, -- JSON: 트리거된 구매 품목
|
||||
ai_raw_response TEXT, -- AI 원본 응답
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/dismissed
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME, -- 7일 후 만료
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### `backend/app.py` — API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/recommendation/<user_id>` | GET | 최신 active 추천 조회 (마이페이지용) |
|
||||
| `/api/recommendation/<rec_id>/dismiss` | POST | 추천 닫기 (status→dismissed) |
|
||||
|
||||
**추천 생성 위치**: `api_kiosk_claim()` 함수 끝부분, `_generate_upsell_recommendation()` 호출
|
||||
|
||||
### `backend/templates/my_page.html` — 바텀시트 UI
|
||||
|
||||
**기능:**
|
||||
- 페이지 로드 1.5초 후 추천 API fetch
|
||||
- 💊 아이콘 + AI 메시지 + 제품명 배지 (보라색 그라디언트)
|
||||
- **터치 드래그 닫기**: 아래로 80px 이상 드래그하면 dismiss
|
||||
- 배경 탭 닫기, "다음에요"/"관심있어요!" 버튼
|
||||
- 슬라이드업/다운 CSS 애니메이션
|
||||
|
||||
## AI 프롬프트
|
||||
|
||||
**시스템 프롬프트:**
|
||||
```
|
||||
당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
|
||||
반드시 JSON 형식으로만 응답하세요.
|
||||
```
|
||||
|
||||
**유저 프롬프트 구조:**
|
||||
```
|
||||
고객 이름: {name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
규칙:
|
||||
1. 함께 먹으면 좋은 약 1가지만 추천 (일반의약품/건강기능식품)
|
||||
2. 메시지 2문장 이내, 따뜻한 톤
|
||||
3. JSON: {"product": "...", "reason": "...", "message": "..."}
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"product": "고려은단 비타민C 1000",
|
||||
"reason": "감기약 구매로 면역력 보충 필요",
|
||||
"message": "김영빈님, 감기약 드시는 동안 비타민C도 함께 챙겨드시면 회복에 도움이 돼요."
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback 정책
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| Gateway 꺼져있음 | 추천 생성 스킵, 로그만 남김 |
|
||||
| AI 응답 파싱 실패 | 저장 안 함 |
|
||||
| 추천 없을 때 마이페이지 방문 | 바텀시트 안 뜸 |
|
||||
| 7일 경과 | `expires_at` 만료, 조회 안 됨 |
|
||||
| dismiss 후 재방문 | 같은 추천 안 뜸 (새 적립 시 새 추천 생성) |
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 1. Gateway 연결 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
from services.clawdbot_client import ask_clawdbot
|
||||
print(ask_clawdbot('안녕'))
|
||||
"
|
||||
|
||||
# 2. 업셀 생성 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
import json
|
||||
from services.clawdbot_client import generate_upsell
|
||||
result = generate_upsell('홍길동', '타이레놀, 챔프시럽', '비타민C, 소화제')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
"
|
||||
|
||||
# 3. API 테스트
|
||||
curl https://mile.0bin.in/api/recommendation/1
|
||||
|
||||
# 4. DB 확인
|
||||
python -c "
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('db/mileage.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
for r in conn.execute('SELECT * FROM ai_recommendations ORDER BY id DESC LIMIT 5'):
|
||||
print(json.dumps(dict(r), ensure_ascii=False))
|
||||
"
|
||||
```
|
||||
74
docs/windows-utf8-encoding.md
Normal file
74
docs/windows-utf8-encoding.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Windows 콘솔 한글 인코딩 (UTF-8) 가이드
|
||||
|
||||
## 문제
|
||||
Windows 콘솔 기본 인코딩이 `cp949`여서 Python에서 한글 출력 시 깨짐 발생.
|
||||
Claude Code bash 터미널, cmd, PowerShell 모두 동일 증상.
|
||||
|
||||
```
|
||||
# 깨진 출력 예시
|
||||
{"product": "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>", "message": "<22>迵<EFBFBD><E8BFB5><EFBFBD>, ..."}
|
||||
```
|
||||
|
||||
## 해결: 3단계 방어
|
||||
|
||||
### 1단계: Python 파일 상단 — sys.stdout UTF-8 래핑
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
```
|
||||
|
||||
**적용 위치**: `app.py`, `clawdbot_client.py` 등 진입점 파일 맨 위 (import 전)
|
||||
|
||||
> 모듈로 import되는 파일은 `hasattr(sys.stdout, 'buffer')` 체크 추가:
|
||||
> ```python
|
||||
> if sys.platform == 'win32':
|
||||
> import io
|
||||
> if hasattr(sys.stdout, 'buffer'):
|
||||
> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
> sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
> ```
|
||||
|
||||
### 2단계: 환경변수 — PYTHONIOENCODING
|
||||
```bash
|
||||
# ~/.bashrc (Claude Code bash 세션)
|
||||
export PYTHONIOENCODING=utf-8
|
||||
```
|
||||
|
||||
또는 실행 시:
|
||||
```bash
|
||||
PYTHONIOENCODING=utf-8 python backend/app.py
|
||||
```
|
||||
|
||||
### 3단계: json.dumps — ensure_ascii=False
|
||||
```python
|
||||
import json
|
||||
data = {"product": "비타민C", "message": "추천드려요"}
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
```
|
||||
`ensure_ascii=False` 없으면 `\uBE44\uD0C0\uBBFCC` 같은 유니코드 이스케이프로 출력됨.
|
||||
|
||||
## 프로젝트 내 적용 현황
|
||||
|
||||
| 파일 | 방식 |
|
||||
|------|------|
|
||||
| `backend/app.py` | sys.stdout 래핑 + PYTHONIOENCODING |
|
||||
| `backend/services/clawdbot_client.py` | sys.stdout 래핑 (buffer 체크) |
|
||||
| `backend/ai_tag_products.py` | sys.stdout 래핑 |
|
||||
| `backend/view_products.py` | sys.stdout 래핑 |
|
||||
| `backend/import_il1beta_foods.py` | sys.stdout 래핑 |
|
||||
| `backend/import_products_from_mssql.py` | sys.stdout 래핑 |
|
||||
| `backend/update_product_category.py` | sys.stdout 래핑 |
|
||||
| `backend/gui/check_cash.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `backend/gui/check_sunab.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `~/.bashrc` | `export PYTHONIOENCODING=utf-8` |
|
||||
|
||||
## 주의사항
|
||||
- Flask 로거(`logging.info()` 등)도 stderr로 출력하므로 **stderr도 반드시 래핑**
|
||||
- `io.TextIOWrapper`는 이미 래핑된 스트림에 중복 적용하면 에러남 → `hasattr(sys.stdout, 'buffer')` 체크
|
||||
- PyQt GUI에서는 stdout이 다를 수 있음 → `hasattr` 가드 필수
|
||||
277
docs/결제수납구조.md
277
docs/결제수납구조.md
@ -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
91
docs/실행구조.md
Normal 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`
|
||||
Loading…
Reference in New Issue
Block a user