Compare commits

...

9 Commits

Author SHA1 Message Date
thug0bin
ccb0067a1c feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회
- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언)
- /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지
- 상품코드/바코드/표준코드 전환 버튼
- 바코드 시각화 + 매핑률 통계
- 대시보드 메뉴에 판매내역 링크 추가
2026-02-27 12:14:50 +09:00
thug0bin
da51f4bfd1 fix: 키오스크 세로 모니터 QR 코드 중앙 정렬
- portrait 모드 claim-left: row → column 레이아웃으로 변경
- QR 컨테이너, 결제 카드, 품목 카드 모두 중앙 정렬
- QR 이미지 크기 140px → 160px 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:37:24 +09:00
thug0bin
db5f6063ec fix: SQLite 싱글톤 연결 I/O 에러 수정 + clawdbot 모델 오버라이드
- dbsetup: get_sqlite_connection()에 SELECT 1 헬스체크 추가 (죽은 연결 자동 재생성)
- pos_sales_gui: 싱글톤 SQLite conn.close() 제거 (I/O closed file 에러 원인)
- qr_token_generator: DatabaseManager() 새 생성 → 전역 db_manager 싱글톤 사용
- clawdbot_client: model 파라미터 추가, 업셀링에 claude-sonnet-4-5 지정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:27:47 +09:00
thug0bin
4c3e1d08b2 feat: 실데이터 기반 AI 업셀링 추천 — 약국 보유 제품 목록에서 추천
- generate_upsell_real(): MSSQL 최근 30일 판매 TOP 40 제품 목록을 AI에 제공
- AI가 실제 약국 보유 제품 중에서만 선택하여 추천
- 실데이터 실패 시 기존 자유 생성(generate_upsell) fallback
- 기존 generate_upsell은 그대로 보존

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:21:48 +09:00
thug0bin
a2829436d1 feat: 바텀시트 '관심있어요' 버튼 분리 — interested 상태 DB 저장 + 어드민 표시
- "관심있어요!" 클릭 → status='interested' (기존: dismissed와 동일했음)
- "다음에요" / 드래그 닫기 → status='dismissed'
- dismiss API에 action 파라미터 추가
- AI CRM 대시보드: interested 배지(주황) + 통계 카드 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:47:20 +09:00
thug0bin
3e3934e2e5 fix: AI 업셀링 생성을 별도 스레드로 분리 — 키오스크 적립 응답 블로킹 방지
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:41:40 +09:00
thug0bin
5042cffb9f feat: AI CRM 어드민 대시보드 + 바텀시트 드래그 닫기 + UTF-8 인코딩 + 문서화
- /admin/ai-crm: AI 업셀링 추천 생성 현황 대시보드 (통계 카드 + 로그 테이블 + 아코디언 상세)
- 마이페이지 바텀시트: 터치 드래그로 닫기 기능 추가 (80px 임계값)
- Windows 콘솔 UTF-8 인코딩 강제 (app.py, clawdbot_client.py)
- admin.html 헤더에 AI CRM 네비 링크 추가
- docs: ai-upselling-crm.md, windows-utf8-encoding.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:38:04 +09:00
thug0bin
b5a99f7b3b feat: AI 업셀링 CRM - Clawdbot Gateway 기반 맞춤 추천 시스템
키오스크 적립 시 Clawdbot Gateway(Claude Max)를 통해 구매 이력 기반
맞춤 제품 추천을 생성하고, 마이페이지 방문 시 바텀시트 팝업으로 표시.

- ai_recommendations SQLite 테이블 추가 (스키마 + 마이그레이션)
- clawdbot_client.py: Gateway WebSocket 프로토콜 v3 Python 클라이언트
- app.py: 추천 생성 + GET/POST API 엔드포인트
- my_page.html: 바텀시트 UI (슬라이드업 애니메이션, 1.5초 후 자동 표시)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:57:03 +09:00
thug0bin
a3ff69b67f feat: 알림톡 발송 로그 시스템 + 현영 표시 + 문서화
- 알림톡 발송 로그: alimtalk_logs SQLite 테이블 + DB 자동 기록
- /admin/alimtalk 페이지: 서버 로그, NHN Cloud 내역 조회, 수동 발송 테스트
- 적립일시 포맷 수정: %Y-%m-%d %H:%M (16자 초과) → %m/%d %H:%M (11자)
- POS GUI 현금영수증(현영) 표시: 청록색 볼드
- 결제수납구조.md: CD_SUNAB/PS_main/SALE_MAIN 3테이블 관계 문서
- 실행구조.md: Flask 서버 + Qt GUI 실행 가이드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:28:29 +09:00
18 changed files with 4267 additions and 99 deletions

View File

@ -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 from flask import Flask, request, render_template, jsonify, redirect, url_for, session
import hashlib import hashlib
import base64 import base64
import secrets import secrets
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import sys
import os
import logging import logging
from sqlalchemy import text from sqlalchemy import text
from dotenv import load_dotenv from dotenv import load_dotenv
@ -1109,7 +1117,7 @@ def my_page():
tx_dict['created_at'] = utc_to_kst_str(tx['created_at']) tx_dict['created_at'] = utc_to_kst_str(tx['created_at'])
transactions.append(tx_dict) 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) 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_row = cursor.fetchone()
user_name = user_row['nickname'] if user_row else '고객' 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: 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({ return jsonify({
'success': True, 'success': True,
@ -2111,6 +2445,300 @@ def api_kiosk_claim():
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500 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__': if __name__ == '__main__':
# 개발 모드로 실행 # 개발 모드로 실행
app.run(host='0.0.0.0', port=7001, debug=True) app.run(host='0.0.0.0', port=7001, debug=True)

View File

@ -193,6 +193,13 @@ class DatabaseManager:
Returns: Returns:
sqlite3.Connection: SQLite 연결 객체 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: if self.sqlite_conn is None:
# 파일 존재 여부 확인 # 파일 존재 여부 확인
is_new_db = not self.sqlite_db_path.exists() is_new_db = not self.sqlite_db_path.exists()
@ -237,7 +244,7 @@ class DatabaseManager:
print(f"[DB Manager] SQLite 스키마 초기화 완료") print(f"[DB Manager] SQLite 스키마 초기화 완료")
def _migrate_sqlite(self): def _migrate_sqlite(self):
"""기존 DB에 새 컬럼 추가 (마이그레이션)""" """기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
cursor = self.sqlite_conn.cursor() cursor = self.sqlite_conn.cursor()
cursor.execute("PRAGMA table_info(users)") cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
@ -246,6 +253,56 @@ class DatabaseManager:
self.sqlite_conn.commit() self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가") 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'): def test_connection(self, database='PM_BASE'):
"""연결 테스트""" """연결 테스트"""
try: try:

View File

@ -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); 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);

View File

@ -78,12 +78,16 @@ class SalesQueryThread(QThread):
ISNULL(S.card_total, 0) AS card_total, ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total, ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount, 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 FROM SALE_MAIN M
OUTER APPLY ( OUTER APPLY (
SELECT TOP 1 SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, 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 FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order WHERE PRESERIAL = M.SL_NO_order
) S ) S
@ -96,7 +100,7 @@ class SalesQueryThread(QThread):
sales_list = [] sales_list = []
for row in rows: 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) # 품목 수 조회 (SALE_SUB)
mssql_cursor.execute(""" mssql_cursor.execute("""
@ -136,12 +140,17 @@ class SalesQueryThread(QThread):
# 결제수단 판별 # 결제수단 판별
card_amt = float(card_total) if card_total else 0.0 card_amt = float(card_total) if card_total else 0.0
cash_amt = float(cash_total) if cash_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: if card_amt > 0 and cash_amt > 0:
pay_method = '카드+현금' pay_method = '카드+현금'
elif card_amt > 0: elif card_amt > 0:
pay_method = '카드' pay_method = '카드'
elif cash_amt > 0: elif cash_amt > 0:
pay_method = '' pay_method = '' if has_cash_receipt else ''
else: else:
pay_method = '' pay_method = ''
paid = (card_amt + cash_amt) > 0 paid = (card_amt + cash_amt) > 0
@ -172,8 +181,7 @@ class SalesQueryThread(QThread):
finally: finally:
if mssql_conn: if mssql_conn:
mssql_conn.close() mssql_conn.close()
if sqlite_conn: # sqlite_conn은 싱글톤이므로 닫지 않음 (닫으면 다른 곳에서 I/O 에러 발생)
sqlite_conn.close()
class QRGeneratorThread(QThread): class QRGeneratorThread(QThread):
@ -591,9 +599,7 @@ class UserMileageDialog(QDialog):
except Exception as e: except Exception as e:
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}') QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
finally: # conn은 싱글톤이므로 닫지 않음
if conn:
conn.close()
class POSSalesGUI(QMainWindow): class POSSalesGUI(QMainWindow):
@ -862,6 +868,11 @@ class POSSalesGUI(QMainWindow):
pay_item.setTextAlignment(Qt.AlignCenter) pay_item.setTextAlignment(Qt.AlignCenter)
if sale['pay_method'] == '카드': if sale['pay_method'] == '카드':
pay_item.setForeground(QColor('#1976D2')) 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'] == '현금': elif sale['pay_method'] == '현금':
pay_item.setForeground(QColor('#E65100')) pay_item.setForeground(QColor('#E65100'))
elif sale['pay_method']: elif sale['pay_method']:

View 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

View File

@ -1,9 +1,10 @@
""" """
NHN Cloud 알림톡 발송 서비스 NHN Cloud 알림톡 발송 서비스
마일리지 적립 완료 알림톡 발송 마일리지 적립 완료 알림톡 발송 + SQLite 로깅
""" """
import os import os
import json
import logging import logging
from datetime import datetime, timezone, timedelta 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)) 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): def _send_alimtalk(template_code, recipient_no, template_params):
""" """
알림톡 발송 공통 함수 알림톡 발송 공통 함수
@ -82,7 +111,9 @@ def build_item_summary(items):
return f"{first}{len(items) - 1}" 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: 적립 포인트 points: 적립 포인트
balance: 적립 잔액 balance: 적립 잔액
items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...] items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...]
user_id: 사용자 ID (로그용)
trigger_source: 발송 주체 ('kiosk', 'admin', 'manual')
transaction_id: 거래 ID (로그용)
Returns: Returns:
tuple: (성공 여부, 메시지) 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) item_summary = build_item_summary(items)
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도 # 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) success, msg = _send_alimtalk(template_code, phone, params)
if not success: 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' template_code = 'MILEAGE_CLAIM_V2'
params_v2 = { params = {
'고객명': name, '고객명': name,
'적립포인트': f'{points:,}', '적립포인트': f'{points:,}',
'총잔액': f'{balance:,}', '총잔액': f'{balance:,}',
'적립일시': now_kst, '적립일시': now_kst,
'전화번호': phone '전화번호': 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) return (success, msg)
def get_nhn_send_history(start_date, end_date, page=1, page_size=15):
"""
NHN Cloud API에서 실제 발송 내역 조회
Args:
start_date: 시작일 (YYYY-MM-DD HH:mm)
end_date: 종료일 (YYYY-MM-DD HH:mm)
Returns:
list: 발송 메시지 목록
"""
url = (f'{API_BASE}/messages'
f'?startRequestDate={start_date}'
f'&endRequestDate={end_date}'
f'&pageNum={page}&pageSize={page_size}')
headers = {
'Content-Type': 'application/json;charset=UTF-8',
'X-Secret-Key': SECRET_KEY
}
try:
resp = requests.get(url, headers=headers, timeout=10)
data = resp.json()
if data.get('messageSearchResultResponse'):
return data['messageSearchResultResponse'].get('messages', [])
return []
except Exception as e:
logger.warning(f"NHN 발송내역 조회 실패: {e}")
return []

View File

@ -393,9 +393,16 @@
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<div class="header-content"> <div class="header-content" style="display:flex;justify-content:space-between;align-items:center;">
<div class="header-title">📊 관리자 대시보드</div> <div>
<div class="header-subtitle">청춘약국 마일리지 관리</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>
</div> </div>

View 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>

View File

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

View File

@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>

View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>

View File

@ -59,7 +59,8 @@
width: 100%; width: 100%;
max-width: 780px; max-width: 780px;
position: relative; position: relative;
height: 380px; height: 450px;
overflow: hidden;
} }
.slide { .slide {
position: absolute; position: absolute;
@ -98,31 +99,31 @@
display: inline-block; display: inline-block;
padding: 6px 16px; padding: 6px 16px;
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.slide-title { .slide-title {
font-size: 30px; font-size: 42px;
font-weight: 900; font-weight: 900;
color: #fff; color: #fff;
letter-spacing: -0.8px; letter-spacing: -0.8px;
line-height: 1.3; line-height: 1.3;
} }
.slide-desc { .slide-desc {
font-size: 17px; font-size: 23px;
color: rgba(255,255,255,0.65); color: rgba(255,255,255,0.7);
line-height: 1.6; line-height: 1.6;
max-width: 500px; max-width: 520px;
} }
.slide-highlight { .slide-highlight {
display: inline-block; display: inline-block;
padding: 10px 28px; padding: 12px 32px;
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.15);
border-radius: 14px; border-radius: 14px;
color: rgba(255,255,255,0.9); color: rgba(255,255,255,0.9);
font-size: 15px; font-size: 19px;
font-weight: 600; font-weight: 600;
margin-top: 4px; margin-top: 4px;
} }
@ -395,16 +396,15 @@
justify-content: center; justify-content: center;
} }
.claim-left { .claim-left {
flex-direction: row; flex-direction: column;
flex-wrap: wrap;
gap: 16px; gap: 16px;
width: 100%; width: 100%;
align-items: flex-start; align-items: center;
} }
.claim-info-card { flex: 1; min-width: 200px; } .claim-info-card { width: 100%; max-width: 480px; }
.qr-container { flex-shrink: 0; } .qr-container { align-self: center; }
.items-card { width: 100%; max-height: 160px; } .items-card { width: 100%; max-width: 480px; max-height: 160px; }
.qr-container img { width: 140px; height: 140px; } .qr-container img { width: 160px; height: 160px; }
.divider { flex-direction: row; } .divider { flex-direction: row; }
.divider-line { width: 60px; height: 2px; } .divider-line { width: 60px; height: 2px; }

View File

@ -392,6 +392,156 @@
} }
} }
</script> </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> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
</body> </body>
</html> </html>

View File

@ -115,8 +115,8 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
- token_hash가 이미 존재하면 실패 (UNIQUE 제약) - token_hash가 이미 존재하면 실패 (UNIQUE 제약)
""" """
try: try:
db_manager = DatabaseManager() from db.dbsetup import db_manager as _db_manager
conn = db_manager.get_sqlite_connection() conn = _db_manager.get_sqlite_connection()
cursor = conn.cursor() cursor = conn.cursor()
# 중복 체크 (transaction_id) # 중복 체크 (transaction_id)

173
docs/ai-upselling-crm.md Normal file
View 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))
"
```

View 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` 가드 필수

View File

@ -1,22 +1,217 @@
# PIT3000 결제/수납/할인 데이터 구조 # PIT3000 판매/조제/수납 데이터 구조
## 핵심 테이블 관계 ## 핵심 테이블 관계
``` ```
SALE_MAIN (판매) CD_SUNAB (수납/결제) ─── 모든 거래의 결제 기록 (130건/일 기준)
└── SL_NO_order (PK, 주문번호)
├── 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로 조인 └── SALE_SUB (판매 품목 상세) ─── SL_NO_order로 조인
└── CD_SUNAB (수납/결제) — CD_SUNAB.PRESERIAL = SALE_MAIN.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 결제수단 컬럼 ## 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:
결제수단 = "-" (미수납 또는 외상)
```
### 카드 상세 정보 ### 카드 상세 정보
| 컬럼 | 설명 | 예시 | | 컬럼 | 설명 | 예시 |
@ -79,32 +251,13 @@ else:
### 현금 상세 정보 ### 현금 상세 정보
| 컬럼 | 설명 | 예시 | | 컬럼 | 설명 | 예시 |
|------|------|------| |------|------|------|
| `nCASHINMODE` | 현금영수증 입력 방식 | 1, 2 (빈값=미발행) | | `nCASHINMODE` | 현금영수증 입력 방식 | 1=실제발행, 2=카드거래 자동세팅 |
| `nAPPROVAL_NUM` | 현금영수증 승인번호 | | | `nAPPROVAL_NUM` | 현금영수증 승인번호 | 116624870 |
| `nCHK_GUBUN` | 현금 체크 구분 | TASA | | `nCHK_GUBUN` | 현금 체크 구분 | KOV, TASA |
--- ---
## GUI 표시 방식 ## SQL 쿼리 (현재 GUI에서 사용)
### 결제 컬럼
- **카드**: 파란색 (#1976D2)
- **현금**: 주황색 (#E65100)
- **카드+현금**: 보라색 (#7B1FA2)
- **-**: 회색 (수납 정보 없음)
### 수납 컬럼
- **✓**: 녹색 (card + cash > 0)
- **-**: 회색 (미수납)
### 할인 표시
- 할인 없는 건: `12,000원` (기본)
- 할인 있는 건: `54,000원 (-6,000)` 주황색 볼드
- 마우스 툴팁: 원가 / 할인 / 결제 상세
---
## SQL 쿼리 (GUI에서 사용)
```sql ```sql
SELECT SELECT
@ -115,12 +268,16 @@ SELECT
ISNULL(S.card_total, 0) AS card_total, ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total, ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount, 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 FROM SALE_MAIN M
OUTER APPLY ( OUTER APPLY (
SELECT TOP 1 SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, 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 FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order WHERE PRESERIAL = M.SL_NO_order
) S ) S
@ -128,6 +285,10 @@ WHERE M.SL_DT_appl = ?
ORDER BY M.InsertTime DESC ORDER BY M.InsertTime DESC
``` ```
**한계**: SALE_MAIN 기준이므로 OTC 판매(39건)만 표시됨.
조제건(~89건)은 표시되지 않음. 조제건까지 보려면 CD_SUNAB을
기본 테이블로 사용하거나 PS_main과 조인하는 쿼리 재설계 필요.
--- ---
## 카드사 분포 (전체 데이터 기준) ## 카드사 분포 (전체 데이터 기준)

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

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