Compare commits
64 Commits
62632cb7b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e499e19342 | |||
|
|
68ad59285a | ||
|
|
d106db64f3 | ||
|
|
197ded3806 | ||
|
|
431909e50b | ||
|
|
8c127cfb95 | ||
|
|
8c366cc4db | ||
|
|
3fc9bbaf8e | ||
|
|
c33d857fa6 | ||
|
|
d0e7d6bbd2 | ||
|
|
04b0f3a8ca | ||
|
|
159386942e | ||
|
|
3467cacd2f | ||
|
|
a3a0bc8868 | ||
|
|
bd30ece284 | ||
|
|
94a8df6653 | ||
|
|
4691d65c14 | ||
|
|
866d10fd92 | ||
|
|
1414bb1432 | ||
|
|
87a56d0f6c | ||
|
|
76da7d9cd1 | ||
|
|
870e40a6db | ||
|
|
d44aed16be | ||
|
|
a1640f55f8 | ||
|
|
753df2c13c | ||
|
|
79369d9a56 | ||
|
|
02e56b9413 | ||
|
|
8c3bcb525d | ||
|
|
7843ca8fcf | ||
|
|
a7e96e5efa | ||
|
|
625012f5ee | ||
|
|
c4ab865c93 | ||
|
|
6e23dc8b20 | ||
|
|
705696a7fb | ||
|
|
9bd2174501 | ||
|
|
f3fa4707ac | ||
|
|
1b78704ca6 | ||
|
|
2a090c9704 | ||
|
|
ccb0067a1c | ||
|
|
da51f4bfd1 | ||
|
|
db5f6063ec | ||
|
|
4c3e1d08b2 | ||
|
|
a2829436d1 | ||
|
|
3e3934e2e5 | ||
|
|
5042cffb9f | ||
|
|
b5a99f7b3b | ||
|
|
a3ff69b67f | ||
|
|
0c52542713 | ||
|
|
ac59464612 | ||
|
|
e4ccfd60c9 | ||
|
|
2625430ca5 | ||
|
|
e7c529c22c | ||
|
|
cb927d2207 | ||
|
|
22cbf3d42e | ||
|
|
a4410f5fe0 | ||
|
|
f80c19567a | ||
|
|
a30374cd4a | ||
|
|
d868a494c2 | ||
|
|
f969756caa | ||
|
|
2b3d8649ba | ||
|
|
c4fa655005 | ||
|
|
ed2a3f28bf | ||
|
|
62502c81b3 | ||
|
|
d1a5964bb7 |
2098
backend/app.py
2098
backend/app.py
File diff suppressed because it is too large
Load Diff
7
backend/config.json
Normal file
7
backend/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pos_printer": {
|
||||
"ip": "192.168.0.174",
|
||||
"port": 9100,
|
||||
"name": "메인 POS"
|
||||
}
|
||||
}
|
||||
@@ -185,37 +185,61 @@ class DatabaseManager:
|
||||
# 새 세션 생성
|
||||
return self.get_session(database)
|
||||
|
||||
def get_sqlite_connection(self):
|
||||
def get_sqlite_connection(self, new_connection=False):
|
||||
"""
|
||||
SQLite mileage.db 연결 반환 (싱글톤 패턴)
|
||||
최초 호출 시 스키마 자동 초기화
|
||||
SQLite mileage.db 연결 반환
|
||||
|
||||
Args:
|
||||
new_connection: True면 항상 새 연결 생성 (멀티스레드 안전)
|
||||
|
||||
Returns:
|
||||
sqlite3.Connection: SQLite 연결 객체
|
||||
"""
|
||||
# 새 연결 요청 시 항상 새로 생성
|
||||
if new_connection:
|
||||
return self._create_sqlite_connection()
|
||||
|
||||
# 기존 싱글톤 방식 (하위 호환)
|
||||
if self.sqlite_conn is not None:
|
||||
try:
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] SQLite 연결 체크 실패, 재연결: {e}")
|
||||
try:
|
||||
self.sqlite_conn.close()
|
||||
except:
|
||||
pass
|
||||
self.sqlite_conn = None
|
||||
|
||||
if self.sqlite_conn is None:
|
||||
# 파일 존재 여부 확인
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
|
||||
# 연결 생성
|
||||
self.sqlite_conn = sqlite3.connect(
|
||||
str(self.sqlite_db_path),
|
||||
check_same_thread=False, # 멀티스레드 허용
|
||||
timeout=10.0 # 10초 대기
|
||||
)
|
||||
|
||||
# Row Factory 설정 (dict 형태로 결과 반환)
|
||||
self.sqlite_conn.row_factory = sqlite3.Row
|
||||
|
||||
# 신규 DB면 스키마 초기화
|
||||
if is_new_db:
|
||||
self.init_sqlite_schema()
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
else:
|
||||
print(f"[DB Manager] SQLite 기존 DB 연결: {self.sqlite_db_path}")
|
||||
self.sqlite_conn = self._create_sqlite_connection()
|
||||
|
||||
return self.sqlite_conn
|
||||
|
||||
def _create_sqlite_connection(self):
|
||||
"""새 SQLite 연결 생성"""
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
|
||||
conn = sqlite3.connect(
|
||||
str(self.sqlite_db_path),
|
||||
check_same_thread=False,
|
||||
timeout=10.0
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
if is_new_db:
|
||||
# 스키마 초기화 (임시로 self.sqlite_conn 설정)
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
"""
|
||||
mileage_schema.sql 실행하여 테이블 생성
|
||||
@@ -235,6 +259,66 @@ class DatabaseManager:
|
||||
|
||||
print(f"[DB Manager] SQLite 스키마 초기화 완료")
|
||||
|
||||
def _migrate_sqlite(self):
|
||||
"""기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'birthday' not in columns:
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN birthday VARCHAR(10)")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가")
|
||||
|
||||
# alimtalk_logs 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS alimtalk_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
recipient_no VARCHAR(20) NOT NULL,
|
||||
user_id INTEGER,
|
||||
trigger_source VARCHAR(20) NOT NULL,
|
||||
template_params TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
result_message TEXT,
|
||||
transaction_id VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성")
|
||||
|
||||
# ai_recommendations 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email VARCHAR(200),
|
||||
is_email_verified BOOLEAN DEFAULT FALSE,
|
||||
phone VARCHAR(20),
|
||||
birthday VARCHAR(10),
|
||||
mileage_balance INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -79,3 +80,43 @@ CREATE TABLE IF NOT EXISTS pos_customer_links (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
|
||||
|
||||
-- 6. 알림톡 발송 로그 테이블
|
||||
CREATE TABLE IF NOT EXISTS alimtalk_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
recipient_no VARCHAR(20) NOT NULL,
|
||||
user_id INTEGER,
|
||||
trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등
|
||||
template_params TEXT, -- JSON 문자열
|
||||
success BOOLEAN NOT NULL,
|
||||
result_message TEXT,
|
||||
transaction_id VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
|
||||
|
||||
-- 7. AI 추천 테이블
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
|
||||
121
backend/gui/check_cash.py
Normal file
121
backend/gui/check_cash.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import pyodbc, sys
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
conn = pyodbc.connect(
|
||||
r'DRIVER={ODBC Driver 17 for SQL Server};SERVER=192.168.0.4\PM2014;DATABASE=PM_PRES;UID=sa;PWD=tmddls214!%(;Encrypt=no;TrustServerCertificate=yes;'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 조제 주문(180)이 SALE_MAIN에 있는지 확인
|
||||
cur.execute("""
|
||||
SELECT SL_NO_order, SL_DT_appl, SL_NM_custom, SL_MY_sale, InsertTime, PRESERIAL
|
||||
FROM SALE_MAIN
|
||||
WHERE SL_NO_order = '20260225000180'
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
print(f'=== 조제 주문 180 in SALE_MAIN: {"있음" if r else "없음"} ===')
|
||||
if r:
|
||||
print(f' 주문={r[0]} 날짜={r[1]} 고객={r[2]} 금액={r[3]} 시간={r[4]} PRESERIAL={r[5]}')
|
||||
|
||||
# SALE_MAIN 총 건수 vs CD_SUNAB 총 건수
|
||||
cur.execute("SELECT COUNT(*) FROM SALE_MAIN WHERE SL_DT_appl = '20260225'")
|
||||
sale_cnt = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM CD_SUNAB WHERE INDATE = '20260225'")
|
||||
sunab_cnt = cur.fetchone()[0]
|
||||
print(f'\n=== 오늘 건수 비교 ===')
|
||||
print(f' SALE_MAIN: {sale_cnt}건')
|
||||
print(f' CD_SUNAB: {sunab_cnt}건')
|
||||
|
||||
# CD_SUNAB 컬럼 구조 확인
|
||||
cur.execute("SELECT TOP 1 * FROM CD_SUNAB WHERE INDATE = '20260225'")
|
||||
cols = [d[0] for d in cur.description]
|
||||
print(f'\n=== CD_SUNAB 컬럼 ({len(cols)}개) ===')
|
||||
for i, c in enumerate(cols):
|
||||
print(f' {i}: {c}')
|
||||
|
||||
# CD_SUNAB 조제건(SALE_MAIN 없는 91건)의 PRESERIAL vs PS_main.PreSerial 매칭
|
||||
cur.execute("""
|
||||
SELECT S.PRESERIAL
|
||||
FROM CD_SUNAB S
|
||||
WHERE S.INDATE = '20260225'
|
||||
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
|
||||
""")
|
||||
sunab_only = [r[0] for r in cur.fetchall()]
|
||||
print(f'\n=== CD_SUNAB만 있는 91건 vs PS_main 매칭 ===')
|
||||
|
||||
# PS_main의 PreSerial 패턴 확인
|
||||
cur.execute("SELECT TOP 5 PreSerial, Day_Serial, Indate, Paname FROM PS_main WHERE Indate = '20260225' ORDER BY PreSerial DESC")
|
||||
print('PS_main 샘플:')
|
||||
for r in cur.fetchall():
|
||||
print(f' PreSerial={r[0]} | Day_Serial={r[1]} | Indate={r[2]} | 환자={r[3]}')
|
||||
|
||||
# CD_SUNAB PRESERIAL vs PS_main PreSerial 직접 비교
|
||||
# CD_SUNAB.PRESERIAL = '20260225000180' 형태
|
||||
# PS_main.PreSerial = ? 형태 확인
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM CD_SUNAB S
|
||||
WHERE S.INDATE = '20260225'
|
||||
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
|
||||
AND EXISTS (SELECT 1 FROM PS_main P WHERE P.PreSerial = S.PRESERIAL AND P.Indate = '20260225')
|
||||
""")
|
||||
matched = cur.fetchone()[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT S.PRESERIAL
|
||||
FROM CD_SUNAB S
|
||||
WHERE S.INDATE = '20260225'
|
||||
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
|
||||
AND NOT EXISTS (SELECT 1 FROM PS_main P WHERE P.PreSerial = S.PRESERIAL AND P.Indate = '20260225')
|
||||
""")
|
||||
unmatched = cur.fetchall()
|
||||
|
||||
print(f'\nCD_SUNAB 91건 중 PS_main 매칭: {matched}건')
|
||||
print(f'CD_SUNAB 91건 중 PS_main 미매칭: {len(unmatched)}건')
|
||||
|
||||
for r in unmatched:
|
||||
serial = r[0]
|
||||
print(f'\n=== 미매칭 {serial} ===')
|
||||
|
||||
# CD_SUNAB에서 금액, 승인일시
|
||||
cur.execute("""
|
||||
SELECT ISNULL(ETC_CARD,0)+ISNULL(ETC_CASH,0) as etc,
|
||||
ISNULL(OTC_CARD,0)+ISNULL(OTC_CASH,0) as otc,
|
||||
APPR_DATE, CUSCODE, DaeRiSunab, YOHUDATE
|
||||
FROM CD_SUNAB WHERE PRESERIAL = ? AND INDATE = '20260225'
|
||||
""", serial)
|
||||
d = cur.fetchone()
|
||||
print(f' ETC={d[0]:,.0f} OTC={d[1]:,.0f} | 승인일시={d[2]} | CUSCODE={d[3]} | 대리수납={d[4]} | 요후일={d[5]}')
|
||||
|
||||
# 다른 날짜의 PS_main에서 같은 PRESERIAL 검색 (날짜 무관)
|
||||
cur.execute("SELECT PreSerial, Indate, Paname, Day_Serial FROM PS_main WHERE PreSerial = ?", serial)
|
||||
ps = cur.fetchone()
|
||||
if ps:
|
||||
print(f' → PS_main 발견! 날짜={ps[1]} 환자={ps[2]} Day_Serial={ps[3]}')
|
||||
else:
|
||||
print(f' → PS_main 전체에서도 없음')
|
||||
|
||||
# PRESERIAL 번호 앞 8자리가 다른 날짜인 CD_SUNAB 검색
|
||||
cur.execute("""
|
||||
SELECT INDATE, PRESERIAL, ISNULL(ETC_CARD,0)+ISNULL(ETC_CASH,0) as etc
|
||||
FROM CD_SUNAB WHERE PRESERIAL = ? AND INDATE != '20260225'
|
||||
""", serial)
|
||||
other = cur.fetchall()
|
||||
if other:
|
||||
for o in other:
|
||||
print(f' → 다른 날짜 CD_SUNAB 발견! INDATE={o[0]} ETC={o[2]:,.0f}')
|
||||
|
||||
# CUSCODE로 PS_main 검색 (같은 환자의 이전 처방?)
|
||||
if d[3] and d[3].strip():
|
||||
cur.execute("""
|
||||
SELECT TOP 3 PreSerial, Indate, Paname, Day_Serial
|
||||
FROM PS_main WHERE CusCode = ?
|
||||
ORDER BY Indate DESC, Day_Serial DESC
|
||||
""", d[3].strip())
|
||||
ps_list = cur.fetchall()
|
||||
if ps_list:
|
||||
print(f' → 같은 CUSCODE({d[3]})의 최근 PS_main:')
|
||||
for p in ps_list:
|
||||
print(f' PreSerial={p[0]} 날짜={p[1]} 환자={p[2]}')
|
||||
|
||||
conn.close()
|
||||
49
backend/gui/check_sunab.py
Normal file
49
backend/gui/check_sunab.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import pyodbc, sys
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
conn = pyodbc.connect(
|
||||
r'DRIVER={ODBC Driver 17 for SQL Server};SERVER=192.168.0.4\PM2014;DATABASE=PM_PRES;UID=sa;PWD=tmddls214!%(;Encrypt=no;TrustServerCertificate=yes;'
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 오늘 현금영수증 발행 건 확인
|
||||
cur.execute("""
|
||||
SELECT
|
||||
PRESERIAL,
|
||||
ETC_CASH, OTC_CASH, ETC_CARD, OTC_CARD,
|
||||
nCASHINMODE, nAPPROVAL_NUM, nCHK_GUBUN
|
||||
FROM CD_SUNAB
|
||||
WHERE INDATE = '20260225'
|
||||
AND nAPPROVAL_NUM IS NOT NULL AND nAPPROVAL_NUM != ''
|
||||
ORDER BY PRESERIAL DESC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print(f'=== 오늘 현금영수증 발행 건: {len(rows)}건 ===')
|
||||
for r in rows:
|
||||
cash = (r[1] or 0) + (r[2] or 0)
|
||||
card = (r[3] or 0) + (r[4] or 0)
|
||||
pay = '카드' if card > 0 else '현금' if cash > 0 else '?'
|
||||
print(f' 주문={r[0]} | {pay} | 현금={cash:,} 카드={card:,} | 영수증모드={r[5]} | 승인번호={r[6]} | 구분={r[7]}')
|
||||
|
||||
# 오늘 전체 현금 결제 건 (영수증 무관)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM CD_SUNAB
|
||||
WHERE INDATE = '20260225'
|
||||
AND (ETC_CASH > 0 OR OTC_CASH > 0)
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
print(f'\n=== 오늘 현금 결제 건: {r[0]}건 ===')
|
||||
|
||||
# 오늘 nCASHINMODE가 있는 건 (영수증 입력 방식 있음)
|
||||
cur.execute("""
|
||||
SELECT nCASHINMODE, COUNT(*) as cnt
|
||||
FROM CD_SUNAB
|
||||
WHERE INDATE = '20260225'
|
||||
AND nCASHINMODE IS NOT NULL AND nCASHINMODE != ''
|
||||
GROUP BY nCASHINMODE
|
||||
""")
|
||||
print(f'\n=== 오늘 nCASHINMODE 분포 ===')
|
||||
for r in cur.fetchall():
|
||||
print(f' 모드={r[0]} → {r[1]}건')
|
||||
|
||||
conn.close()
|
||||
@@ -7,6 +7,18 @@ import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Qt 플랫폼 플러그인 경로 자동 설정 (PyQt5 import 전에 반드시 설정)
|
||||
if not os.environ.get('QT_QPA_PLATFORM_PLUGIN_PATH'):
|
||||
import importlib.util
|
||||
_spec = importlib.util.find_spec('PyQt5')
|
||||
if _spec and _spec.origin:
|
||||
_pyqt5_plugins = os.path.join(
|
||||
os.path.dirname(_spec.origin), 'Qt5', 'plugins', 'platforms'
|
||||
)
|
||||
if os.path.isdir(_pyqt5_plugins):
|
||||
os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = _pyqt5_plugins
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
||||
@@ -55,14 +67,30 @@ class SalesQueryThread(QThread):
|
||||
sqlite_conn = db_manager.get_sqlite_connection()
|
||||
sqlite_cursor = sqlite_conn.cursor()
|
||||
|
||||
# 메인 쿼리: SALE_MAIN에서 오늘 판매 내역 조회
|
||||
# 메인 쿼리: SALE_MAIN + CD_SUNAB(수납) 조인
|
||||
# CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (주문번호 기준)
|
||||
query = """
|
||||
SELECT
|
||||
M.SL_NO_order,
|
||||
M.InsertTime,
|
||||
M.SL_MY_sale,
|
||||
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name
|
||||
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name,
|
||||
ISNULL(S.card_total, 0) AS card_total,
|
||||
ISNULL(S.cash_total, 0) AS cash_total,
|
||||
ISNULL(M.SL_MY_total, 0) AS total_amount,
|
||||
ISNULL(M.SL_MY_discount, 0) AS discount,
|
||||
S.cash_receipt_mode,
|
||||
S.cash_receipt_num
|
||||
FROM SALE_MAIN M
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1
|
||||
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
|
||||
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
|
||||
nCASHINMODE AS cash_receipt_mode,
|
||||
nAPPROVAL_NUM AS cash_receipt_num
|
||||
FROM CD_SUNAB
|
||||
WHERE PRESERIAL = M.SL_NO_order
|
||||
) S
|
||||
WHERE M.SL_DT_appl = ?
|
||||
ORDER BY M.InsertTime DESC
|
||||
"""
|
||||
@@ -72,7 +100,7 @@ class SalesQueryThread(QThread):
|
||||
|
||||
sales_list = []
|
||||
for row in rows:
|
||||
order_no, insert_time, sale_amount, customer = row
|
||||
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
|
||||
|
||||
# 품목 수 조회 (SALE_SUB)
|
||||
mssql_cursor.execute("""
|
||||
@@ -109,11 +137,36 @@ class SalesQueryThread(QThread):
|
||||
claimed_phone = ""
|
||||
claimed_points = 0
|
||||
|
||||
# 결제수단 판별
|
||||
card_amt = float(card_total) if card_total else 0.0
|
||||
cash_amt = float(cash_total) if cash_total else 0.0
|
||||
# 현금영수증: nCASHINMODE='1' AND nAPPROVAL_NUM 존재 (mode=2는 카드거래 자동세팅)
|
||||
has_cash_receipt = (
|
||||
str(cash_receipt_mode or '').strip() == '1'
|
||||
and str(cash_receipt_num or '').strip() != ''
|
||||
)
|
||||
if card_amt > 0 and cash_amt > 0:
|
||||
pay_method = '카드+현금'
|
||||
elif card_amt > 0:
|
||||
pay_method = '카드'
|
||||
elif cash_amt > 0:
|
||||
pay_method = '현영' if has_cash_receipt else '현금'
|
||||
else:
|
||||
pay_method = ''
|
||||
paid = (card_amt + cash_amt) > 0
|
||||
|
||||
disc_amt = float(discount) if discount else 0.0
|
||||
total_amt = float(total_amount) if total_amount else 0.0
|
||||
|
||||
sales_list.append({
|
||||
'order_no': order_no,
|
||||
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
|
||||
'amount': float(sale_amount) if sale_amount else 0.0,
|
||||
'discount': disc_amt,
|
||||
'total_before_dc': total_amt,
|
||||
'customer': customer,
|
||||
'pay_method': pay_method,
|
||||
'paid': paid,
|
||||
'item_count': item_count,
|
||||
'claimed_name': claimed_name,
|
||||
'claimed_phone': claimed_phone,
|
||||
@@ -128,8 +181,7 @@ class SalesQueryThread(QThread):
|
||||
finally:
|
||||
if mssql_conn:
|
||||
mssql_conn.close()
|
||||
if sqlite_conn:
|
||||
sqlite_conn.close()
|
||||
# sqlite_conn은 싱글톤이므로 닫지 않음 (닫으면 다른 곳에서 I/O 에러 발생)
|
||||
|
||||
|
||||
class QRGeneratorThread(QThread):
|
||||
@@ -547,9 +599,7 @@ class UserMileageDialog(QDialog):
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
# conn은 싱글톤이므로 닫지 않음
|
||||
|
||||
|
||||
class POSSalesGUI(QMainWindow):
|
||||
@@ -563,6 +613,8 @@ class POSSalesGUI(QMainWindow):
|
||||
('주문번호', 150, 'order_no'),
|
||||
('시간', 70, 'time'),
|
||||
('금액', 100, 'amount'),
|
||||
('결제', 80, 'pay_method'),
|
||||
('수납', 50, 'paid'),
|
||||
('고객명', 80, 'customer'),
|
||||
('품목수', 55, 'item_count'),
|
||||
('적립자', 90, 'claimed_name'),
|
||||
@@ -624,6 +676,14 @@ class POSSalesGUI(QMainWindow):
|
||||
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
|
||||
settings_layout.addWidget(self.qr_btn)
|
||||
|
||||
# 키오스크 적립 버튼
|
||||
self.kiosk_btn = QPushButton('키오스크 적립')
|
||||
self.kiosk_btn.setStyleSheet(
|
||||
'background-color: #6366f1; color: white; padding: 8px; font-weight: bold;')
|
||||
self.kiosk_btn.setToolTip('선택된 거래를 키오스크 화면에 표시')
|
||||
self.kiosk_btn.clicked.connect(self.trigger_kiosk_claim)
|
||||
settings_layout.addWidget(self.kiosk_btn)
|
||||
|
||||
# 미리보기 모드 체크박스 추가
|
||||
self.preview_checkbox = QCheckBox('미리보기 모드')
|
||||
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
|
||||
@@ -688,7 +748,7 @@ class POSSalesGUI(QMainWindow):
|
||||
w = saved_widths[i] if saved_widths and len(saved_widths) == col_count else default_w
|
||||
self.sales_table.setColumnWidth(i, w)
|
||||
|
||||
self.sales_table.horizontalHeader().setStretchLastSection(True)
|
||||
self.sales_table.horizontalHeader().setStretchLastSection(False)
|
||||
self.sales_table.horizontalHeader().sectionResized.connect(self._on_column_resized)
|
||||
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.sales_table.doubleClicked.connect(self.show_sale_detail)
|
||||
@@ -786,11 +846,56 @@ class POSSalesGUI(QMainWindow):
|
||||
self.sales_table.setItem(row, COL['time'],
|
||||
QTableWidgetItem(sale['time']))
|
||||
|
||||
# 금액 (우측 정렬, 천단위 콤마)
|
||||
# 금액 (우측 정렬, 천단위 콤마, 할인 표시)
|
||||
if sale['discount'] > 0:
|
||||
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원 (-{sale['discount']:,.0f})")
|
||||
amount_item.setForeground(QColor('#E65100'))
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
amount_item.setFont(f)
|
||||
amount_item.setToolTip(
|
||||
f"원가: {sale['total_before_dc']:,.0f}원\n"
|
||||
f"할인: -{sale['discount']:,.0f}원\n"
|
||||
f"결제: {sale['amount']:,.0f}원"
|
||||
)
|
||||
else:
|
||||
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원")
|
||||
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.sales_table.setItem(row, COL['amount'], amount_item)
|
||||
|
||||
# 결제수단
|
||||
pay_item = QTableWidgetItem(sale['pay_method'])
|
||||
pay_item.setTextAlignment(Qt.AlignCenter)
|
||||
if sale['pay_method'] == '카드':
|
||||
pay_item.setForeground(QColor('#1976D2'))
|
||||
elif sale['pay_method'] == '현영':
|
||||
pay_item.setForeground(QColor('#00897B')) # 청록 (현금영수증)
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
pay_item.setFont(f)
|
||||
elif sale['pay_method'] == '현금':
|
||||
pay_item.setForeground(QColor('#E65100'))
|
||||
elif sale['pay_method']:
|
||||
pay_item.setForeground(QColor('#7B1FA2'))
|
||||
else:
|
||||
pay_item.setText('-')
|
||||
pay_item.setForeground(QColor('#BDBDBD'))
|
||||
self.sales_table.setItem(row, COL['pay_method'], pay_item)
|
||||
|
||||
# 수납 여부
|
||||
paid_item = QTableWidgetItem()
|
||||
paid_item.setTextAlignment(Qt.AlignCenter)
|
||||
if sale['paid']:
|
||||
paid_item.setText('✓')
|
||||
paid_item.setForeground(QColor('#4CAF50'))
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
paid_item.setFont(f)
|
||||
else:
|
||||
paid_item.setText('-')
|
||||
paid_item.setForeground(QColor('#BDBDBD'))
|
||||
self.sales_table.setItem(row, COL['paid'], paid_item)
|
||||
|
||||
# 고객명 (MSSQL POS)
|
||||
self.sales_table.setItem(row, COL['customer'],
|
||||
QTableWidgetItem(sale['customer']))
|
||||
@@ -862,12 +967,14 @@ class POSSalesGUI(QMainWindow):
|
||||
|
||||
def on_cell_clicked(self, row, column):
|
||||
"""테이블 셀 클릭 이벤트 - 적립 사용자 클릭 시 마일리지 내역 표시"""
|
||||
# 컬럼 5(적립자명), 6(전화번호), 7(적립포인트) 중 하나를 클릭했는지 확인
|
||||
if column not in [5, 6, 7]:
|
||||
# SALES_COLUMNS 기반 인덱스 사용
|
||||
COL = {key: i for i, (_, _, key) in enumerate(self.SALES_COLUMNS)}
|
||||
mileage_cols = [COL['claimed_name'], COL['claimed_phone'], COL['claimed_points']]
|
||||
if column not in mileage_cols:
|
||||
return
|
||||
|
||||
# 전화번호 가져오기 (6번 컬럼)
|
||||
phone_item = self.sales_table.item(row, 6)
|
||||
# 전화번호 가져오기
|
||||
phone_item = self.sales_table.item(row, COL['claimed_phone'])
|
||||
if not phone_item or not phone_item.text():
|
||||
# 적립 사용자가 없는 경우
|
||||
return
|
||||
@@ -928,6 +1035,35 @@ class POSSalesGUI(QMainWindow):
|
||||
except:
|
||||
return False
|
||||
|
||||
def trigger_kiosk_claim(self):
|
||||
"""선택된 판매 건을 키오스크에 표시"""
|
||||
current_row = self.sales_table.currentRow()
|
||||
if current_row < 0:
|
||||
QMessageBox.warning(self, '경고', '거래를 선택해주세요.')
|
||||
return
|
||||
|
||||
order_no = self.sales_table.item(current_row, 0).text()
|
||||
amount_text = self.sales_table.item(current_row, 2).text()
|
||||
amount = float(amount_text.replace(',', '').replace('원', ''))
|
||||
|
||||
try:
|
||||
import requests as req
|
||||
resp = req.post(
|
||||
'http://localhost:7001/api/kiosk/trigger',
|
||||
json={'transaction_id': order_no, 'amount': amount},
|
||||
timeout=5
|
||||
)
|
||||
result = resp.json()
|
||||
|
||||
if result.get('success'):
|
||||
self.status_label.setText(f'키오스크 적립 대기 중 ({result.get("points", 0)}P)')
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #6366f1; font-size: 12px; padding: 5px; font-weight: bold;')
|
||||
else:
|
||||
QMessageBox.warning(self, '키오스크', result.get('message', '전송 실패'))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, '오류', f'Flask 서버 연결 실패:\n{str(e)}')
|
||||
|
||||
def generate_qr_label(self):
|
||||
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
||||
# 선택된 행 확인
|
||||
|
||||
222
backend/gui/pos_thermal.py
Normal file
222
backend/gui/pos_thermal.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# pos_settings_dialog.py
|
||||
# POS 영수증 프린터 설정 다이얼로그
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QLineEdit, QFormLayout, QMessageBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
class POSSettingsDialog(QDialog):
|
||||
"""POS 영수증 프린터 설정"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
|
||||
self.setWindowTitle("POS 영수증 프린터 설정")
|
||||
self.setMinimumSize(500, 300)
|
||||
self.init_ui()
|
||||
self.load_settings()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 제목
|
||||
title = QLabel("POS 영수증 프린터 설정")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 설명
|
||||
desc = QLabel("ESC/POS 프로토콜을 지원하는 영수증 프린터 설정\n올댓포스 AGENT가 설치된 PC IP를 입력하세요")
|
||||
desc.setStyleSheet("color: gray; margin-bottom: 20px;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# 폼 레이아웃
|
||||
form_layout = QFormLayout()
|
||||
|
||||
# IP 주소
|
||||
self.ip_input = QLineEdit()
|
||||
self.ip_input.setPlaceholderText("예: 192.168.0.174")
|
||||
form_layout.addRow("IP 주소 *", self.ip_input)
|
||||
|
||||
# 포트
|
||||
self.port_input = QLineEdit()
|
||||
self.port_input.setText("9100")
|
||||
form_layout.addRow("포트", self.port_input)
|
||||
|
||||
# 프린터 이름
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("예: 메인 POS 프린터")
|
||||
form_layout.addRow("프린터 이름", self.name_input)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 버튼들
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.test_button = QPushButton("테스트 인쇄")
|
||||
self.test_button.clicked.connect(self.test_print)
|
||||
self.test_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #1976D2;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(self.test_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
self.cancel_button = QPushButton("취소")
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
|
||||
self.save_button = QPushButton("저장")
|
||||
self.save_button.clicked.connect(self.save_settings)
|
||||
self.save_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
""")
|
||||
button_layout.addWidget(self.save_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
self.setLayout(layout)
|
||||
|
||||
def load_settings(self):
|
||||
"""설정 불러오기"""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
pos_config = config.get('pos_printer', {})
|
||||
self.ip_input.setText(pos_config.get('ip', ''))
|
||||
self.port_input.setText(str(pos_config.get('port', 9100)))
|
||||
self.name_input.setText(pos_config.get('name', ''))
|
||||
except Exception as e:
|
||||
print(f"[POS Settings] 설정 로드 오류: {e}")
|
||||
|
||||
def save_settings(self):
|
||||
"""설정 저장"""
|
||||
ip = self.ip_input.text().strip()
|
||||
port = self.port_input.text().strip()
|
||||
name = self.name_input.text().strip()
|
||||
|
||||
# 유효성 검사
|
||||
if not ip:
|
||||
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
|
||||
return
|
||||
|
||||
try:
|
||||
port_num = int(port)
|
||||
except ValueError:
|
||||
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
|
||||
return
|
||||
|
||||
# 설정 저장
|
||||
try:
|
||||
config = {}
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
config['pos_printer'] = {
|
||||
'ip': ip,
|
||||
'port': port_num,
|
||||
'name': name if name else f"POS Printer ({ip})"
|
||||
}
|
||||
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||
|
||||
QMessageBox.information(self, "성공", "POS 프린터 설정이 저장되었습니다.")
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "오류", f"설정 저장 실패: {str(e)}")
|
||||
|
||||
def test_print(self):
|
||||
"""테스트 인쇄"""
|
||||
ip = self.ip_input.text().strip()
|
||||
port = self.port_input.text().strip()
|
||||
|
||||
if not ip:
|
||||
QMessageBox.warning(self, "입력 오류", "IP 주소를 입력해주세요.")
|
||||
return
|
||||
|
||||
try:
|
||||
port_num = int(port)
|
||||
except ValueError:
|
||||
QMessageBox.warning(self, "입력 오류", "포트는 숫자여야 합니다.")
|
||||
return
|
||||
|
||||
# ESC/POS 테스트 인쇄
|
||||
try:
|
||||
# ESC/POS 명령어
|
||||
ESC = b'\x1b'
|
||||
INIT = ESC + b'@' # 프린터 초기화
|
||||
CUT = ESC + b'd\x03' # 용지 커트
|
||||
|
||||
# 테스트 메시지
|
||||
message = f"""
|
||||
================================
|
||||
POS 프린터 테스트!
|
||||
================================
|
||||
|
||||
IP: {ip}
|
||||
Port: {port_num}
|
||||
Time: {time.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
ESC/POS 명령으로 인쇄됨
|
||||
정상 작동 확인!
|
||||
================================
|
||||
"""
|
||||
|
||||
# EUC-KR 인코딩 (한글 지원)
|
||||
message_bytes = message.encode('euc-kr')
|
||||
command = INIT + message_bytes + b'\n\n\n' + CUT
|
||||
|
||||
# TCP 소켓으로 전송
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((ip, port_num))
|
||||
sock.sendall(command)
|
||||
sock.close()
|
||||
|
||||
QMessageBox.information(
|
||||
self, "성공",
|
||||
f"테스트 인쇄 명령을 전송했습니다!\n\n"
|
||||
f"IP: {ip}:{port_num}\n\n"
|
||||
f"POS 프린터에서 영수증 출력을 확인하세요."
|
||||
)
|
||||
|
||||
except socket.timeout:
|
||||
QMessageBox.warning(self, "실패", f"연결 시간 초과\n\n프린터가 켜져있는지 확인하세요.")
|
||||
except ConnectionRefusedError:
|
||||
QMessageBox.warning(self, "실패", f"연결 거부됨\n\nIP 주소와 포트를 확인하세요.")
|
||||
except UnicodeEncodeError:
|
||||
QMessageBox.warning(self, "인코딩 오류", "EUC-KR로 인코딩할 수 없는 문자가 있습니다.")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "실패", f"테스트 인쇄 실패\n\n{type(e).__name__}: {str(e)}")
|
||||
262
backend/qr_printer.py
Normal file
262
backend/qr_printer.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# qr_printer.py - Brother QL-710W QR 라벨 인쇄
|
||||
# person-lookup-web-local/print_label.py에서 핵심 기능만 추출
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
import logging
|
||||
import qrcode
|
||||
|
||||
# 프린터 설정
|
||||
PRINTER_IP = "192.168.0.121"
|
||||
PRINTER_MODEL = "QL-710W"
|
||||
LABEL_TYPE = "29" # 29mm 연속 출력 용지
|
||||
|
||||
# Windows 폰트 경로
|
||||
FONT_PATH = "C:/Windows/Fonts/malgunbd.ttf"
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
약품 QR 라벨 이미지 생성
|
||||
|
||||
Parameters:
|
||||
drug_name (str): 약품명
|
||||
barcode (str): 바코드 (QR 코드로 변환)
|
||||
sale_price (float): 판매가격
|
||||
drug_code (str, optional): 약품 코드 (바코드가 없을 때 대체)
|
||||
pharmacy_name (str, optional): 약국 이름
|
||||
|
||||
Returns:
|
||||
PIL.Image: 생성된 라벨 이미지
|
||||
"""
|
||||
label_width = 306
|
||||
label_height = 380
|
||||
image = Image.new("1", (label_width, label_height), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 폰트 설정
|
||||
try:
|
||||
drug_name_font = ImageFont.truetype(FONT_PATH, 32)
|
||||
price_font = ImageFont.truetype(FONT_PATH, 36)
|
||||
label_font = ImageFont.truetype(FONT_PATH, 24)
|
||||
except IOError:
|
||||
drug_name_font = ImageFont.load_default()
|
||||
price_font = ImageFont.load_default()
|
||||
label_font = ImageFont.load_default()
|
||||
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
|
||||
|
||||
# 바코드가 없으면 약품 코드 사용
|
||||
qr_data = barcode if barcode else (drug_code if drug_code else "NO_BARCODE")
|
||||
|
||||
# QR 코드 생성
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=4,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# QR 코드 크기 조정 및 배치
|
||||
qr_size = 130
|
||||
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
|
||||
qr_x = (label_width - qr_size) // 2
|
||||
qr_y = 15
|
||||
|
||||
if qr_img.mode != '1':
|
||||
qr_img = qr_img.convert('1')
|
||||
image.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
# 약품명 (QR 코드 아래)
|
||||
y_position = qr_y + qr_size + 10
|
||||
|
||||
def draw_wrapped_text(draw, text, y, font, max_width):
|
||||
"""텍스트를 여러 줄로 표시"""
|
||||
chars = list(text)
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for char in chars:
|
||||
test_line = current_line + char
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
w = bbox[2] - bbox[0]
|
||||
|
||||
if w <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
lines = lines[:2] # 최대 2줄
|
||||
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
|
||||
y += h + 5
|
||||
|
||||
return y
|
||||
|
||||
y_position = draw_wrapped_text(draw, drug_name, y_position, drug_name_font, label_width - 40)
|
||||
y_position += 8
|
||||
|
||||
# 가격
|
||||
if sale_price and sale_price > 0:
|
||||
price_text = f"₩{int(sale_price):,}"
|
||||
else:
|
||||
price_text = "가격 미정"
|
||||
|
||||
bbox = draw.textbbox((0, 0), price_text, font=price_font)
|
||||
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((label_width - w) / 2, y_position), price_text, font=price_font, fill="black")
|
||||
y_position += h + 15
|
||||
|
||||
# 구분선
|
||||
line_margin = 30
|
||||
draw.line([(line_margin, y_position), (label_width - line_margin, y_position)], fill="black", width=2)
|
||||
y_position += 20
|
||||
|
||||
# 약국 이름
|
||||
signature_text = " ".join(pharmacy_name)
|
||||
bbox = draw.textbbox((0, 0), signature_text, font=label_font)
|
||||
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
padding = 10
|
||||
box_x = (label_width - w_sig) / 2 - padding
|
||||
box_y = y_position
|
||||
box_x2 = box_x + w_sig + 2 * padding
|
||||
box_y2 = box_y + h_sig + 2 * padding
|
||||
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2)
|
||||
draw.text(((label_width - w_sig) / 2, box_y + padding), signature_text, font=label_font, fill="black")
|
||||
|
||||
# 절취선 테두리
|
||||
draw_scissor_border(draw, label_width, label_height)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def draw_scissor_border(draw, width, height, edge_size=10, steps=20):
|
||||
"""절취선 테두리"""
|
||||
# 상단
|
||||
top_points = []
|
||||
step_x = width / (steps * 2)
|
||||
for i in range(steps * 2 + 1):
|
||||
x = i * step_x
|
||||
y = 0 if i % 2 == 0 else edge_size
|
||||
top_points.append((int(x), int(y)))
|
||||
draw.line(top_points, fill="black", width=2)
|
||||
|
||||
# 하단
|
||||
bottom_points = []
|
||||
for i in range(steps * 2 + 1):
|
||||
x = i * step_x
|
||||
y = height if i % 2 == 0 else height - edge_size
|
||||
bottom_points.append((int(x), int(y)))
|
||||
draw.line(bottom_points, fill="black", width=2)
|
||||
|
||||
# 좌측
|
||||
left_points = []
|
||||
step_y = height / (steps * 2)
|
||||
for i in range(steps * 2 + 1):
|
||||
y = i * step_y
|
||||
x = 0 if i % 2 == 0 else edge_size
|
||||
left_points.append((int(x), int(y)))
|
||||
draw.line(left_points, fill="black", width=2)
|
||||
|
||||
# 우측
|
||||
right_points = []
|
||||
for i in range(steps * 2 + 1):
|
||||
y = i * step_y
|
||||
x = width if i % 2 == 0 else width - edge_size
|
||||
right_points.append((int(x), int(y)))
|
||||
draw.line(right_points, fill="black", width=2)
|
||||
|
||||
|
||||
def print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
약품 QR 라벨 인쇄 실행
|
||||
|
||||
Parameters:
|
||||
drug_name (str): 약품명
|
||||
barcode (str): 바코드
|
||||
sale_price (float): 판매가격
|
||||
drug_code (str, optional): 약품 코드
|
||||
pharmacy_name (str, optional): 약국 이름
|
||||
|
||||
Returns:
|
||||
dict: 성공/실패 결과
|
||||
"""
|
||||
try:
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.backends.helpers import send
|
||||
|
||||
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
|
||||
|
||||
# 이미지를 메모리 스트림으로 변환
|
||||
image_stream = io.BytesIO()
|
||||
label_image.save(image_stream, format="PNG")
|
||||
image_stream.seek(0)
|
||||
|
||||
# Brother QL 프린터로 전송
|
||||
qlr = BrotherQLRaster(PRINTER_MODEL)
|
||||
instructions = convert(
|
||||
qlr=qlr,
|
||||
images=[Image.open(image_stream)],
|
||||
label=LABEL_TYPE,
|
||||
rotate="0",
|
||||
threshold=70.0,
|
||||
dither=False,
|
||||
compress=False,
|
||||
lq=True,
|
||||
red=False
|
||||
)
|
||||
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
|
||||
|
||||
logging.info(f"QR 라벨 인쇄 성공: {drug_name}, 바코드={barcode}")
|
||||
return {"success": True, "message": f"{drug_name} QR 라벨 인쇄 완료"}
|
||||
|
||||
except ImportError as e:
|
||||
logging.error(f"brother_ql 라이브러리 없음: {e}")
|
||||
return {"success": False, "error": "brother_ql 라이브러리가 설치되지 않았습니다"}
|
||||
except Exception as e:
|
||||
logging.error(f"QR 라벨 인쇄 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def preview_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
|
||||
"""
|
||||
QR 라벨 미리보기 (base64 이미지 반환)
|
||||
"""
|
||||
import base64
|
||||
|
||||
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
|
||||
|
||||
# PNG로 변환
|
||||
image_stream = io.BytesIO()
|
||||
# 1-bit 이미지를 RGB로 변환하여 더 깔끔하게
|
||||
rgb_image = label_image.convert('RGB')
|
||||
rgb_image.save(image_stream, format="PNG")
|
||||
image_stream.seek(0)
|
||||
|
||||
base64_image = base64.b64encode(image_stream.read()).decode('utf-8')
|
||||
return f"data:image/png;base64,{base64_image}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트
|
||||
result = print_drug_qr_label(
|
||||
drug_name="벤포파워Z",
|
||||
barcode="8806418067510",
|
||||
sale_price=3000,
|
||||
pharmacy_name="청춘약국"
|
||||
)
|
||||
print(result)
|
||||
@@ -5,6 +5,7 @@ MSSQL DB에서 약품 정보 조회 기능 포함
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
@@ -19,6 +20,8 @@ from sqlalchemy import text
|
||||
|
||||
# MSSQL 데이터베이스 연결
|
||||
sys.path.insert(0, '.')
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
# 바코드 라벨 출력
|
||||
|
||||
713
backend/samples/pos_dummy_gui.py
Normal file
713
backend/samples/pos_dummy_gui.py
Normal file
@@ -0,0 +1,713 @@
|
||||
"""
|
||||
더미 POS 시스템 GUI (PyQt5)
|
||||
바코드 스캐너로 제품을 추가하고 수량 조절, 할인 적용, 결제까지 지원
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QGroupBox, QComboBox, QSpinBox,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView, QFrame,
|
||||
QLineEdit, QDialog, QFormLayout, QDoubleSpinBox, QMessageBox,
|
||||
QAbstractItemView, QCheckBox, QSplitter
|
||||
)
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt5.QtGui import QFont, QColor, QBrush, QIcon
|
||||
from sqlalchemy import text
|
||||
|
||||
# DB 연결
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
|
||||
# ─── GS1 바코드 파싱 ───────────────────────────────────────────
|
||||
|
||||
def parse_gs1_barcode(barcode):
|
||||
candidates = [barcode]
|
||||
if barcode.startswith('01') and len(barcode) >= 16:
|
||||
gtin14 = barcode[2:16]
|
||||
candidates.append(gtin14)
|
||||
if gtin14.startswith('0'):
|
||||
candidates.append(gtin14[1:])
|
||||
elif barcode.startswith('01') and len(barcode) == 15:
|
||||
candidates.append(barcode[2:15])
|
||||
return candidates
|
||||
|
||||
|
||||
def search_drug_by_barcode(barcode):
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
engine = db_manager.get_engine('PM_DRUG')
|
||||
query = text('''
|
||||
SELECT TOP 1
|
||||
BARCODE, GoodsName, DrugCode, SplName,
|
||||
Price, Saleprice, SUNG_CODE, IsUSE
|
||||
FROM CD_GOODS
|
||||
WHERE BARCODE = :barcode
|
||||
AND (GoodsName NOT LIKE N'%(판매중지)%'
|
||||
AND GoodsName NOT LIKE N'%(판매중단)%')
|
||||
ORDER BY
|
||||
CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END,
|
||||
CASE WHEN Price > 0 THEN 0 ELSE 1 END,
|
||||
CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END,
|
||||
DrugCode DESC
|
||||
''')
|
||||
candidates = parse_gs1_barcode(barcode)
|
||||
with engine.connect() as conn:
|
||||
for candidate in candidates:
|
||||
result = conn.execute(query, {"barcode": candidate})
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return {
|
||||
'barcode': row.BARCODE,
|
||||
'goods_name': row.GoodsName,
|
||||
'drug_code': row.DrugCode,
|
||||
'manufacturer': row.SplName or '',
|
||||
'price': float(row.Price) if row.Price else 0,
|
||||
'sale_price': float(row.Saleprice) if row.Saleprice else 0,
|
||||
'sung_code': row.SUNG_CODE or ''
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f'[오류] 약품 조회 실패: {e}')
|
||||
return None
|
||||
|
||||
|
||||
# ─── 바코드 리더 스레드 ────────────────────────────────────────
|
||||
|
||||
class BarcodeReaderThread(QThread):
|
||||
barcode_received = pyqtSignal(str)
|
||||
connection_status = pyqtSignal(bool, str)
|
||||
|
||||
def __init__(self, port='COM3', baudrate=115200):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.running = False
|
||||
self.serial_connection = None
|
||||
|
||||
def run(self):
|
||||
self.running = True
|
||||
try:
|
||||
self.serial_connection = serial.Serial(
|
||||
port=self.port, baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE, timeout=1
|
||||
)
|
||||
self.connection_status.emit(True, f'{self.port} 연결됨 ({self.baudrate} bps)')
|
||||
while self.running:
|
||||
if self.serial_connection.in_waiting > 0:
|
||||
data = self.serial_connection.read(self.serial_connection.in_waiting)
|
||||
try:
|
||||
text_data = data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text_data = data.decode('ascii', errors='ignore')
|
||||
for line in text_data.strip().split('\n'):
|
||||
barcode = line.strip()
|
||||
if barcode and len(barcode) in [13, 15, 16]:
|
||||
self.barcode_received.emit(barcode)
|
||||
except serial.SerialException as e:
|
||||
self.connection_status.emit(False, f'연결 실패: {e}')
|
||||
except Exception as e:
|
||||
self.connection_status.emit(False, f'오류: {e}')
|
||||
finally:
|
||||
if self.serial_connection and self.serial_connection.is_open:
|
||||
self.serial_connection.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.serial_connection and self.serial_connection.is_open:
|
||||
self.serial_connection.close()
|
||||
|
||||
|
||||
class DrugSearchThread(QThread):
|
||||
search_complete = pyqtSignal(str, object)
|
||||
|
||||
def __init__(self, barcode):
|
||||
super().__init__()
|
||||
self.barcode = barcode
|
||||
|
||||
def run(self):
|
||||
info = search_drug_by_barcode(self.barcode)
|
||||
self.search_complete.emit(self.barcode, info)
|
||||
|
||||
|
||||
# ─── 할인 다이얼로그 ──────────────────────────────────────────
|
||||
|
||||
class DiscountDialog(QDialog):
|
||||
def __init__(self, item_name, current_price, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f'할인 적용 - {item_name}')
|
||||
self.setMinimumWidth(350)
|
||||
self.result_discount = 0
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
info = QLabel(f'제품: {item_name}\n판매가: {current_price:,.0f}원')
|
||||
info.setStyleSheet('font-size: 14px; padding: 10px;')
|
||||
layout.addWidget(info)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
self.discount_type = QComboBox()
|
||||
self.discount_type.addItems(['금액 할인 (원)', '비율 할인 (%)'])
|
||||
form.addRow('할인 방식:', self.discount_type)
|
||||
|
||||
self.discount_value = QDoubleSpinBox()
|
||||
self.discount_value.setMaximum(999999)
|
||||
self.discount_value.setDecimals(0)
|
||||
form.addRow('할인값:', self.discount_value)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
self.preview_label = QLabel('')
|
||||
self.preview_label.setStyleSheet('font-size: 13px; color: #E53935; padding: 10px; font-weight: bold;')
|
||||
layout.addWidget(self.preview_label)
|
||||
|
||||
self.discount_value.valueChanged.connect(
|
||||
lambda: self._update_preview(current_price))
|
||||
self.discount_type.currentIndexChanged.connect(
|
||||
lambda: self._update_preview(current_price))
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton('적용')
|
||||
ok_btn.setStyleSheet('background: #4CAF50; color: white; font-weight: bold; padding: 8px 24px;')
|
||||
ok_btn.clicked.connect(lambda: self._apply(current_price))
|
||||
cancel_btn = QPushButton('취소')
|
||||
cancel_btn.setStyleSheet('padding: 8px 24px;')
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
btn_layout.addWidget(ok_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _update_preview(self, price):
|
||||
val = self.discount_value.value()
|
||||
if self.discount_type.currentIndex() == 0:
|
||||
disc = val
|
||||
else:
|
||||
disc = price * val / 100
|
||||
final = max(0, price - disc)
|
||||
self.preview_label.setText(f'할인: -{disc:,.0f}원 → 최종가: {final:,.0f}원')
|
||||
|
||||
def _apply(self, price):
|
||||
val = self.discount_value.value()
|
||||
if self.discount_type.currentIndex() == 0:
|
||||
self.result_discount = val
|
||||
else:
|
||||
self.result_discount = price * val / 100
|
||||
self.accept()
|
||||
|
||||
|
||||
# ─── 메인 POS GUI ─────────────────────────────────────────────
|
||||
|
||||
class POSDummyGUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reader_thread = None
|
||||
self.search_threads = []
|
||||
self.cart_items = [] # [{barcode, goods_name, manufacturer, price, sale_price, qty, discount}]
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
self.setWindowTitle('청춘약국 POS')
|
||||
self.setGeometry(50, 50, 1200, 800)
|
||||
self.setStyleSheet('''
|
||||
QMainWindow { background: #F5F5F5; }
|
||||
QGroupBox {
|
||||
font-weight: bold; font-size: 13px;
|
||||
border: 1px solid #E0E0E0; border-radius: 6px;
|
||||
margin-top: 12px; padding-top: 18px;
|
||||
background: white;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px; padding: 0 6px;
|
||||
}
|
||||
''')
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QVBoxLayout()
|
||||
root_layout.setContentsMargins(12, 8, 12, 8)
|
||||
central.setLayout(root_layout)
|
||||
|
||||
# ── 상단: 연결 설정 ──
|
||||
conn_group = QGroupBox('스캐너 연결')
|
||||
conn_layout = QHBoxLayout()
|
||||
conn_group.setLayout(conn_layout)
|
||||
|
||||
conn_layout.addWidget(QLabel('포트:'))
|
||||
self.port_combo = QComboBox()
|
||||
self.port_combo.setMinimumWidth(200)
|
||||
self._refresh_ports()
|
||||
conn_layout.addWidget(self.port_combo)
|
||||
|
||||
refresh_btn = QPushButton('⟳')
|
||||
refresh_btn.setFixedWidth(36)
|
||||
refresh_btn.clicked.connect(self._refresh_ports)
|
||||
conn_layout.addWidget(refresh_btn)
|
||||
|
||||
conn_layout.addWidget(QLabel('속도:'))
|
||||
self.baudrate_spin = QSpinBox()
|
||||
self.baudrate_spin.setRange(9600, 921600)
|
||||
self.baudrate_spin.setValue(115200)
|
||||
self.baudrate_spin.setSingleStep(9600)
|
||||
conn_layout.addWidget(self.baudrate_spin)
|
||||
|
||||
self.connect_btn = QPushButton('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
self.connect_btn.clicked.connect(self._toggle_connection)
|
||||
conn_layout.addWidget(self.connect_btn)
|
||||
|
||||
self.status_label = QLabel('대기 중')
|
||||
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||
conn_layout.addWidget(self.status_label)
|
||||
conn_layout.addStretch()
|
||||
|
||||
# 수동 바코드 입력
|
||||
conn_layout.addWidget(QLabel('수동입력:'))
|
||||
self.manual_input = QLineEdit()
|
||||
self.manual_input.setPlaceholderText('바코드 번호 입력 후 Enter')
|
||||
self.manual_input.setMinimumWidth(180)
|
||||
self.manual_input.returnPressed.connect(self._manual_barcode)
|
||||
conn_layout.addWidget(self.manual_input)
|
||||
|
||||
root_layout.addWidget(conn_group)
|
||||
|
||||
# ── 중앙: 장바구니 테이블 + 우측 요약 ──
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 장바구니 테이블
|
||||
cart_group = QGroupBox('장바구니')
|
||||
cart_layout = QVBoxLayout()
|
||||
cart_group.setLayout(cart_layout)
|
||||
|
||||
self.cart_table = QTableWidget()
|
||||
self.cart_table.setColumnCount(8)
|
||||
self.cart_table.setHorizontalHeaderLabels([
|
||||
'제품명', '제조사', '바코드', '입고가', '판매가', '수량', '할인', '소계'
|
||||
])
|
||||
header = self.cart_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
for i in [1]:
|
||||
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||
for i in [2, 3, 4, 5, 6, 7]:
|
||||
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||
|
||||
self.cart_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.cart_table.setAlternatingRowColors(True)
|
||||
self.cart_table.setStyleSheet('''
|
||||
QTableWidget {
|
||||
font-size: 13px; gridline-color: #E0E0E0;
|
||||
alternate-background-color: #FAFAFA;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #37474F; color: white;
|
||||
font-weight: bold; font-size: 12px;
|
||||
padding: 6px; border: none;
|
||||
}
|
||||
''')
|
||||
self.cart_table.verticalHeader().setVisible(False)
|
||||
cart_layout.addWidget(self.cart_table)
|
||||
|
||||
# 장바구니 아래 버튼들
|
||||
cart_btn_layout = QHBoxLayout()
|
||||
|
||||
qty_up_btn = QPushButton('+1')
|
||||
qty_up_btn.setStyleSheet(
|
||||
'background: #2196F3; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||
qty_up_btn.clicked.connect(lambda: self._change_qty(1))
|
||||
cart_btn_layout.addWidget(qty_up_btn)
|
||||
|
||||
qty_down_btn = QPushButton('-1')
|
||||
qty_down_btn.setStyleSheet(
|
||||
'background: #FF9800; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||
qty_down_btn.clicked.connect(lambda: self._change_qty(-1))
|
||||
cart_btn_layout.addWidget(qty_down_btn)
|
||||
|
||||
discount_btn = QPushButton('할인')
|
||||
discount_btn.setStyleSheet(
|
||||
'background: #9C27B0; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||
discount_btn.clicked.connect(self._apply_discount)
|
||||
cart_btn_layout.addWidget(discount_btn)
|
||||
|
||||
remove_btn = QPushButton('삭제')
|
||||
remove_btn.setStyleSheet(
|
||||
'background: #F44336; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||
remove_btn.clicked.connect(self._remove_selected)
|
||||
cart_btn_layout.addWidget(remove_btn)
|
||||
|
||||
cart_btn_layout.addStretch()
|
||||
|
||||
clear_btn = QPushButton('전체 삭제')
|
||||
clear_btn.setStyleSheet(
|
||||
'background: #757575; color: white; font-size: 13px; padding: 8px 16px; border-radius: 4px;')
|
||||
clear_btn.clicked.connect(self._clear_cart)
|
||||
cart_btn_layout.addWidget(clear_btn)
|
||||
|
||||
cart_layout.addLayout(cart_btn_layout)
|
||||
splitter.addWidget(cart_group)
|
||||
|
||||
# ── 우측 패널: 요약 + 결제 ──
|
||||
right_panel = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_panel.setLayout(right_layout)
|
||||
|
||||
# 최근 스캔
|
||||
scan_group = QGroupBox('최근 스캔')
|
||||
scan_layout = QVBoxLayout()
|
||||
scan_group.setLayout(scan_layout)
|
||||
|
||||
self.last_scan_label = QLabel('바코드를 스캔하세요')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||
self.last_scan_label.setWordWrap(True)
|
||||
self.last_scan_label.setMinimumHeight(80)
|
||||
scan_layout.addWidget(self.last_scan_label)
|
||||
|
||||
right_layout.addWidget(scan_group)
|
||||
|
||||
# 합계 요약
|
||||
summary_group = QGroupBox('합계')
|
||||
summary_layout = QVBoxLayout()
|
||||
summary_group.setLayout(summary_layout)
|
||||
|
||||
self.item_count_label = QLabel('품목: 0개 / 수량: 0개')
|
||||
self.item_count_label.setStyleSheet('font-size: 14px; color: #616161; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.item_count_label)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.HLine)
|
||||
sep1.setStyleSheet('color: #E0E0E0;')
|
||||
summary_layout.addWidget(sep1)
|
||||
|
||||
self.cost_label = QLabel('입고 합계: 0원')
|
||||
self.cost_label.setStyleSheet('font-size: 13px; color: #9E9E9E; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.cost_label)
|
||||
|
||||
self.subtotal_label = QLabel('판매 합계: 0원')
|
||||
self.subtotal_label.setStyleSheet('font-size: 14px; color: #424242; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.subtotal_label)
|
||||
|
||||
self.discount_total_label = QLabel('할인 합계: -0원')
|
||||
self.discount_total_label.setStyleSheet('font-size: 14px; color: #E53935; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.discount_total_label)
|
||||
|
||||
sep2 = QFrame()
|
||||
sep2.setFrameShape(QFrame.HLine)
|
||||
sep2.setStyleSheet('color: #37474F; border: 1px solid #37474F;')
|
||||
summary_layout.addWidget(sep2)
|
||||
|
||||
self.total_label = QLabel('총 결제금액: 0원')
|
||||
self.total_label.setStyleSheet(
|
||||
'font-size: 22px; font-weight: bold; color: #1B5E20; padding: 8px;')
|
||||
summary_layout.addWidget(self.total_label)
|
||||
|
||||
self.margin_label = QLabel('마진: 0원 (0%)')
|
||||
self.margin_label.setStyleSheet('font-size: 13px; color: #1565C0; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.margin_label)
|
||||
|
||||
right_layout.addWidget(summary_group)
|
||||
right_layout.addStretch()
|
||||
|
||||
# 결제 버튼
|
||||
pay_btn = QPushButton('결 제')
|
||||
pay_btn.setMinimumHeight(70)
|
||||
pay_btn.setStyleSheet('''
|
||||
QPushButton {
|
||||
background: #1B5E20; color: white;
|
||||
font-size: 26px; font-weight: bold;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton:hover { background: #2E7D32; }
|
||||
QPushButton:pressed { background: #1B5E20; }
|
||||
''')
|
||||
pay_btn.clicked.connect(self._pay)
|
||||
right_layout.addWidget(pay_btn)
|
||||
|
||||
splitter.addWidget(right_panel)
|
||||
splitter.setSizes([800, 350])
|
||||
|
||||
root_layout.addWidget(splitter, 1)
|
||||
|
||||
# ── 하단 상태바 ──
|
||||
self.statusBar().setStyleSheet('font-size: 12px; color: #757575;')
|
||||
self.statusBar().showMessage('청춘약국 POS | 바코드 스캐너를 연결하고 "연결" 버튼을 누르세요')
|
||||
|
||||
# ── 포트 관리 ──
|
||||
|
||||
def _refresh_ports(self):
|
||||
self.port_combo.clear()
|
||||
for port in serial.tools.list_ports.comports():
|
||||
self.port_combo.addItem(f'{port.device} - {port.description}', port.device)
|
||||
for i in range(self.port_combo.count()):
|
||||
if 'COM3' in (self.port_combo.itemData(i) or ''):
|
||||
self.port_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
def _toggle_connection(self):
|
||||
if self.reader_thread and self.reader_thread.isRunning():
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait()
|
||||
self.reader_thread = None
|
||||
self.connect_btn.setText('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
self.status_label.setText('연결 해제됨')
|
||||
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||
self.statusBar().showMessage('스캐너 연결 해제')
|
||||
else:
|
||||
port = self.port_combo.currentData()
|
||||
if not port:
|
||||
self.status_label.setText('포트를 선택하세요')
|
||||
return
|
||||
self.reader_thread = BarcodeReaderThread(port, self.baudrate_spin.value())
|
||||
self.reader_thread.barcode_received.connect(self._on_barcode)
|
||||
self.reader_thread.connection_status.connect(self._on_connection)
|
||||
self.reader_thread.start()
|
||||
self.connect_btn.setText('연결 해제')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #F44336; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
|
||||
def _on_connection(self, ok, msg):
|
||||
if ok:
|
||||
self.status_label.setText(msg)
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #2E7D32; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||
self.statusBar().showMessage(f'스캐너 {msg} | 바코드를 스캔하세요')
|
||||
else:
|
||||
self.status_label.setText(msg)
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #D32F2F; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||
self.connect_btn.setText('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
|
||||
# ── 바코드 수신 ──
|
||||
|
||||
def _manual_barcode(self):
|
||||
barcode = self.manual_input.text().strip()
|
||||
if barcode:
|
||||
self.manual_input.clear()
|
||||
self._on_barcode(barcode)
|
||||
|
||||
def _on_barcode(self, barcode):
|
||||
self.last_scan_label.setText(f'스캔: {barcode}\n조회 중...')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #FF6F00; padding: 12px;')
|
||||
self.statusBar().showMessage(f'바코드 {barcode} 조회 중...')
|
||||
|
||||
thread = DrugSearchThread(barcode)
|
||||
thread.search_complete.connect(self._on_search_done)
|
||||
thread.start()
|
||||
self.search_threads.append(thread)
|
||||
|
||||
def _on_search_done(self, barcode, info):
|
||||
sender = self.sender()
|
||||
if sender in self.search_threads:
|
||||
self.search_threads.remove(sender)
|
||||
|
||||
if not info:
|
||||
self.last_scan_label.setText(f'스캔: {barcode}\n제품을 찾을 수 없습니다')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #D32F2F; padding: 12px;')
|
||||
self.statusBar().showMessage(f'바코드 {barcode}: 데이터베이스에서 찾을 수 없음')
|
||||
return
|
||||
|
||||
# 이미 장바구니에 있으면 수량 +1
|
||||
for item in self.cart_items:
|
||||
if item['barcode'] == info['barcode']:
|
||||
item['qty'] += 1
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText(
|
||||
f'{info["goods_name"]}\n수량 → {item["qty"]}개')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #1565C0; padding: 12px;')
|
||||
self.statusBar().showMessage(f'{info["goods_name"]} 수량 +1 ({item["qty"]}개)')
|
||||
return
|
||||
|
||||
# 새 항목 추가
|
||||
self.cart_items.append({
|
||||
'barcode': info['barcode'],
|
||||
'goods_name': info['goods_name'],
|
||||
'manufacturer': info['manufacturer'],
|
||||
'price': info['price'],
|
||||
'sale_price': info['sale_price'],
|
||||
'qty': 1,
|
||||
'discount': 0,
|
||||
})
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText(
|
||||
f'{info["goods_name"]}\n{info["manufacturer"]} | {info["sale_price"]:,.0f}원')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #2E7D32; padding: 12px; font-weight: bold;')
|
||||
self.statusBar().showMessage(f'{info["goods_name"]} 추가됨 ({info["sale_price"]:,.0f}원)')
|
||||
|
||||
# ── 장바구니 조작 ──
|
||||
|
||||
def _selected_row(self):
|
||||
rows = self.cart_table.selectionModel().selectedRows()
|
||||
return rows[0].row() if rows else -1
|
||||
|
||||
def _change_qty(self, delta):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('제품을 선택하세요')
|
||||
return
|
||||
item = self.cart_items[row]
|
||||
item['qty'] = max(1, item['qty'] + delta)
|
||||
self._refresh_table()
|
||||
self.cart_table.selectRow(row)
|
||||
|
||||
def _apply_discount(self):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('할인할 제품을 선택하세요')
|
||||
return
|
||||
item = self.cart_items[row]
|
||||
dlg = DiscountDialog(item['goods_name'], item['sale_price'], self)
|
||||
if dlg.exec_() == QDialog.Accepted:
|
||||
item['discount'] = dlg.result_discount
|
||||
self._refresh_table()
|
||||
self.cart_table.selectRow(row)
|
||||
|
||||
def _remove_selected(self):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('삭제할 제품을 선택하세요')
|
||||
return
|
||||
name = self.cart_items[row]['goods_name']
|
||||
del self.cart_items[row]
|
||||
self._refresh_table()
|
||||
self.statusBar().showMessage(f'{name} 삭제됨')
|
||||
|
||||
def _clear_cart(self):
|
||||
if not self.cart_items:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, '전체 삭제', '장바구니를 비우시겠습니까?',
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.cart_items.clear()
|
||||
self._refresh_table()
|
||||
self.statusBar().showMessage('장바구니 초기화')
|
||||
|
||||
# ── 테이블 갱신 ──
|
||||
|
||||
def _refresh_table(self):
|
||||
self.cart_table.setRowCount(len(self.cart_items))
|
||||
|
||||
total_cost = 0
|
||||
total_sale = 0
|
||||
total_discount = 0
|
||||
total_qty = 0
|
||||
|
||||
for i, item in enumerate(self.cart_items):
|
||||
subtotal = (item['sale_price'] - item['discount']) * item['qty']
|
||||
cost_total = item['price'] * item['qty']
|
||||
|
||||
cols = [
|
||||
item['goods_name'],
|
||||
item['manufacturer'],
|
||||
item['barcode'],
|
||||
f'{item["price"]:,.0f}',
|
||||
f'{item["sale_price"]:,.0f}',
|
||||
str(item['qty']),
|
||||
f'-{item["discount"]:,.0f}' if item['discount'] > 0 else '',
|
||||
f'{subtotal:,.0f}',
|
||||
]
|
||||
|
||||
for j, val in enumerate(cols):
|
||||
cell = QTableWidgetItem(val)
|
||||
cell.setFlags(cell.flags() & ~Qt.ItemIsEditable)
|
||||
# 숫자 컬럼 오른쪽 정렬
|
||||
if j >= 3:
|
||||
cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
# 할인 빨간색
|
||||
if j == 6 and item['discount'] > 0:
|
||||
cell.setForeground(QBrush(QColor('#E53935')))
|
||||
# 소계 볼드
|
||||
if j == 7:
|
||||
font = cell.font()
|
||||
font.setBold(True)
|
||||
cell.setFont(font)
|
||||
self.cart_table.setItem(i, j, cell)
|
||||
|
||||
total_cost += cost_total
|
||||
total_sale += item['sale_price'] * item['qty']
|
||||
total_discount += item['discount'] * item['qty']
|
||||
total_qty += item['qty']
|
||||
|
||||
final_total = total_sale - total_discount
|
||||
margin = final_total - total_cost
|
||||
margin_pct = (margin / final_total * 100) if final_total > 0 else 0
|
||||
|
||||
self.item_count_label.setText(f'품목: {len(self.cart_items)}개 / 수량: {total_qty}개')
|
||||
self.cost_label.setText(f'입고 합계: {total_cost:,.0f}원')
|
||||
self.subtotal_label.setText(f'판매 합계: {total_sale:,.0f}원')
|
||||
self.discount_total_label.setText(f'할인 합계: -{total_discount:,.0f}원')
|
||||
self.total_label.setText(f'총 결제금액: {final_total:,.0f}원')
|
||||
self.margin_label.setText(f'마진: {margin:,.0f}원 ({margin_pct:.1f}%)')
|
||||
|
||||
# ── 결제 ──
|
||||
|
||||
def _pay(self):
|
||||
if not self.cart_items:
|
||||
self.statusBar().showMessage('장바구니가 비어있습니다')
|
||||
return
|
||||
|
||||
total_sale = sum(it['sale_price'] * it['qty'] for it in self.cart_items)
|
||||
total_discount = sum(it['discount'] * it['qty'] for it in self.cart_items)
|
||||
final = total_sale - total_discount
|
||||
|
||||
items_text = '\n'.join(
|
||||
f' {it["goods_name"]} x{it["qty"]} {(it["sale_price"] - it["discount"]) * it["qty"]:,.0f}원'
|
||||
for it in self.cart_items
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, '결제 확인',
|
||||
f'총 결제금액: {final:,.0f}원\n\n{items_text}\n\n결제하시겠습니까?',
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
QMessageBox.information(
|
||||
self, '결제 완료',
|
||||
f'결제가 완료되었습니다.\n\n'
|
||||
f'시각: {now}\n'
|
||||
f'금액: {final:,.0f}원\n'
|
||||
f'품목: {len(self.cart_items)}개'
|
||||
)
|
||||
self.cart_items.clear()
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText('바코드를 스캔하세요')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||
self.statusBar().showMessage(f'결제 완료 ({final:,.0f}원) | {now}')
|
||||
|
||||
# ── 종료 ──
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.reader_thread:
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait()
|
||||
for t in self.search_threads:
|
||||
if t.isRunning():
|
||||
t.wait()
|
||||
event.accept()
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle('Fusion')
|
||||
window = POSDummyGUI()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
4
backend/scripts/start_server.bat
Normal file
4
backend/scripts/start_server.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0start_server.ps1"
|
||||
pause
|
||||
35
backend/scripts/start_server.ps1
Normal file
35
backend/scripts/start_server.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# start_server.ps1 - Flask 서버 시작 스크립트
|
||||
# 기존 프로세스 종료 후 새로 시작
|
||||
|
||||
$PORT = 7001
|
||||
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$BACKEND_DIR = Split-Path -Parent $SCRIPT_DIR
|
||||
|
||||
Write-Host "=== 청춘약국 마일리지 서버 시작 ===" -ForegroundColor Cyan
|
||||
|
||||
# 1. 기존 포트 사용 프로세스 종료
|
||||
Write-Host "1. 포트 $PORT 확인 중..." -ForegroundColor Yellow
|
||||
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($netstat) {
|
||||
$pid = ($netstat -split '\s+')[-1]
|
||||
Write-Host " 기존 프로세스 발견 (PID: $pid). 종료합니다..." -ForegroundColor Red
|
||||
taskkill /F /PID $pid 2>$null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 2. 서버 시작
|
||||
Write-Host "2. 서버 시작 중..." -ForegroundColor Yellow
|
||||
Set-Location $BACKEND_DIR
|
||||
Start-Process python -ArgumentList "app.py" -WindowStyle Hidden
|
||||
|
||||
# 3. 시작 확인
|
||||
Start-Sleep -Seconds 3
|
||||
$check = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($check) {
|
||||
Write-Host "=== 서버 시작 완료! ===" -ForegroundColor Green
|
||||
Write-Host "URL: http://localhost:$PORT" -ForegroundColor Cyan
|
||||
Write-Host "외부: http://192.168.0.14:$PORT" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "=== 서버 시작 실패 ===" -ForegroundColor Red
|
||||
Write-Host "로그를 확인하세요." -ForegroundColor Red
|
||||
}
|
||||
4
backend/scripts/stop_server.bat
Normal file
4
backend/scripts/stop_server.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0stop_server.ps1"
|
||||
pause
|
||||
15
backend/scripts/stop_server.ps1
Normal file
15
backend/scripts/stop_server.ps1
Normal file
@@ -0,0 +1,15 @@
|
||||
# stop_server.ps1 - Flask 서버 중지 스크립트
|
||||
|
||||
$PORT = 7001
|
||||
|
||||
Write-Host "=== 청춘약국 마일리지 서버 중지 ===" -ForegroundColor Cyan
|
||||
|
||||
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($netstat) {
|
||||
$pid = ($netstat -split '\s+')[-1]
|
||||
Write-Host "서버 프로세스 종료 중 (PID: $pid)..." -ForegroundColor Yellow
|
||||
taskkill /F /PID $pid 2>$null
|
||||
Write-Host "=== 서버 중지 완료 ===" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "실행 중인 서버가 없습니다." -ForegroundColor Yellow
|
||||
}
|
||||
175
backend/scripts/tag_animal_drugs.py
Normal file
175
backend/scripts/tag_animal_drugs.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
동물약 태깅 및 MSSQL 동기화
|
||||
1. 키워드로 CD_GOODS에서 동물약 검색
|
||||
2. SQLite drug_tags.db에 태깅
|
||||
3. MSSQL CD_GOODS.POS_BOON = '010103' 업데이트
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from db.dbsetup import db_manager
|
||||
from sqlalchemy import text
|
||||
|
||||
# SQLite DB 경로
|
||||
DB_PATH = Path(__file__).parent.parent / 'db' / 'drug_tags.db'
|
||||
|
||||
# 동물약 키워드
|
||||
ANIMAL_KEYWORDS = [
|
||||
'동물', '반려', '애견', '강아지', '고양이', '반려견',
|
||||
'넥스가드', '브라벡토', '심파리카', '크레델리오', '컴포티스',
|
||||
'하트세이버', '하트가드', '다이로하트', '하트웜', '하트캅',
|
||||
'안텔민', '파라캅', '제스타제',
|
||||
'캐치원', '셀라이트', '가드닐', '리펠로', '심피드독',
|
||||
'세레니아', '아포퀄', '갈리프란트', '클라펫',
|
||||
'펫팜', '동물약품', '애니팜'
|
||||
]
|
||||
|
||||
# 제외 키워드 (사람용 약)
|
||||
EXCLUDE_KEYWORDS = [
|
||||
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
|
||||
]
|
||||
|
||||
def init_sqlite_db():
|
||||
"""SQLite DB 초기화"""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drug_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drug_code TEXT NOT NULL,
|
||||
drug_name TEXT,
|
||||
barcode TEXT,
|
||||
tag_type TEXT NOT NULL,
|
||||
tag_value TEXT,
|
||||
note TEXT,
|
||||
source TEXT DEFAULT 'keyword',
|
||||
confidence REAL DEFAULT 0.8,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(drug_code, tag_type)
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"✅ SQLite DB 준비: {DB_PATH}")
|
||||
|
||||
def search_animal_drugs():
|
||||
"""MSSQL에서 동물약 키워드 검색"""
|
||||
print("🔍 CD_GOODS에서 동물약 검색 중...")
|
||||
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 키워드 조건 생성
|
||||
conditions = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
|
||||
|
||||
query = text(f"""
|
||||
SELECT DrugCode, GoodsName, BARCODE, POS_BOON
|
||||
FROM CD_GOODS
|
||||
WHERE ({conditions})
|
||||
AND GoodsSelCode = 'B'
|
||||
""")
|
||||
|
||||
result = session.execute(query)
|
||||
drugs = result.fetchall()
|
||||
print(f"✅ 발견: {len(drugs)}개")
|
||||
return drugs
|
||||
|
||||
def tag_to_sqlite(drugs):
|
||||
"""SQLite에 동물약 태깅"""
|
||||
print("\n📝 SQLite 태깅 중...")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
excluded = 0
|
||||
|
||||
for drug in drugs:
|
||||
drug_code = drug[0]
|
||||
drug_name = drug[1] or ''
|
||||
barcode = drug[2]
|
||||
|
||||
# 제외 키워드 체크
|
||||
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
|
||||
excluded += 1
|
||||
print(f" ⛔ 제외: {drug_code} - {drug_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note)
|
||||
VALUES (?, ?, ?, 'animal_drug', 'all', '키워드 자동 태깅')
|
||||
''', (drug_code, drug_name, barcode))
|
||||
added += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\n📊 태깅 결과: 추가 {added}개, 중복 {skipped}개, 제외 {excluded}개")
|
||||
return added
|
||||
|
||||
def sync_to_mssql():
|
||||
"""SQLite 태그를 MSSQL POS_BOON에 동기화"""
|
||||
print("\n🔄 MSSQL 동기화 중...")
|
||||
|
||||
# SQLite에서 동물약 목록 가져오기
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT drug_code, drug_name FROM drug_tags
|
||||
WHERE tag_type = 'animal_drug' AND is_active = 1
|
||||
''')
|
||||
animal_drugs = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
print(f" 동물약 {len(animal_drugs)}개 → POS_BOON='010103' 업데이트")
|
||||
|
||||
# MSSQL 업데이트
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
updated = 0
|
||||
|
||||
for drug_code, drug_name in animal_drugs:
|
||||
try:
|
||||
result = session.execute(text('''
|
||||
UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = :dc
|
||||
'''), {'dc': drug_code})
|
||||
session.commit()
|
||||
if result.rowcount > 0:
|
||||
updated += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {drug_code}: {e}")
|
||||
|
||||
print(f"\n🎉 완료! MSSQL 업데이트: {updated}개")
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("🐾 동물약 태깅 시스템")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. SQLite 초기화
|
||||
init_sqlite_db()
|
||||
|
||||
# 2. 동물약 검색
|
||||
drugs = search_animal_drugs()
|
||||
|
||||
# 3. SQLite 태깅
|
||||
tag_to_sqlite(drugs)
|
||||
|
||||
# 4. MSSQL 동기화
|
||||
sync_to_mssql()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("✅ 모든 작업 완료!")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
469
backend/services/clawdbot_client.py
Normal file
469
backend/services/clawdbot_client.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
# ===== Claude 상태 조회 =====
|
||||
|
||||
async def _get_gateway_status():
|
||||
"""
|
||||
Clawdbot Gateway에서 세션 목록 조회
|
||||
토큰 차감 없음 (AI 호출 아님)
|
||||
"""
|
||||
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 대기
|
||||
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
challenge = json.loads(challenge_msg)
|
||||
nonce = None
|
||||
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 Status',
|
||||
'version': '1.0.0',
|
||||
'platform': 'win32',
|
||||
'mode': 'backend',
|
||||
'instanceId': str(uuid.uuid4()),
|
||||
},
|
||||
'caps': [],
|
||||
'auth': {'token': token},
|
||||
'role': 'operator',
|
||||
'scopes': ['operator.read'],
|
||||
}
|
||||
}
|
||||
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', 'connect failed')
|
||||
logger.warning(f"[Clawdbot] connect 실패: {error}")
|
||||
return {'error': error, 'connected': False}
|
||||
break
|
||||
|
||||
# 4. sessions.list 요청
|
||||
list_id = str(uuid.uuid4())
|
||||
list_frame = {
|
||||
'type': 'req',
|
||||
'id': list_id,
|
||||
'method': 'sessions.list',
|
||||
'params': {
|
||||
'limit': 10
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(list_frame))
|
||||
|
||||
# 5. 응답 대기
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
data = json.loads(msg)
|
||||
|
||||
# 이벤트 무시
|
||||
if data.get('event'):
|
||||
continue
|
||||
|
||||
if data.get('id') == list_id:
|
||||
if data.get('ok'):
|
||||
return {
|
||||
'connected': True,
|
||||
'sessions': data.get('payload', {})
|
||||
}
|
||||
else:
|
||||
error = data.get('error', {}).get('message', 'unknown')
|
||||
return {'error': error, 'connected': True}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[Clawdbot] Gateway 타임아웃")
|
||||
return {'error': 'timeout', 'connected': False}
|
||||
except (ConnectionRefusedError, OSError) as e:
|
||||
logger.warning(f"[Clawdbot] Gateway 연결 실패: {e}")
|
||||
return {'error': str(e), 'connected': False}
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
|
||||
return {'error': str(e), 'connected': False}
|
||||
|
||||
|
||||
def get_claude_status():
|
||||
"""
|
||||
동기 래퍼: Claude 상태 조회
|
||||
|
||||
Returns:
|
||||
dict: 상태 정보
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(_get_gateway_status())
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
|
||||
return {'error': str(e), 'connected': False}
|
||||
|
||||
|
||||
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
|
||||
@@ -39,7 +39,7 @@ class KakaoAPIClient:
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'profile_nickname,profile_image,account_email,name'
|
||||
'scope': 'profile_nickname,profile_image,account_email,name,phone_number,birthday'
|
||||
}
|
||||
|
||||
if state:
|
||||
@@ -137,6 +137,8 @@ class KakaoAPIClient:
|
||||
'is_email_verified': kakao_account.get('is_email_verified', False),
|
||||
'name': kakao_account.get('name'),
|
||||
'phone_number': kakao_account.get('phone_number'),
|
||||
'birthday': kakao_account.get('birthday'), # MMDD 형식
|
||||
'birthyear': kakao_account.get('birthyear'), # YYYY 형식
|
||||
}
|
||||
|
||||
# None 값 제거
|
||||
|
||||
202
backend/services/nhn_alimtalk.py
Normal file
202
backend/services/nhn_alimtalk.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
NHN Cloud 알림톡 발송 서비스
|
||||
마일리지 적립 완료 등 알림톡 발송 + SQLite 로깅
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NHN Cloud 알림톡 설정
|
||||
APPKEY = os.getenv('NHN_ALIMTALK_APPKEY', 'u0TLUaXXY9bfQFkY')
|
||||
SECRET_KEY = os.getenv('NHN_ALIMTALK_SECRET', 'naraGEUJfpkRu1fgirKewJtwADqWQ5gY')
|
||||
SENDER_KEY = os.getenv('NHN_ALIMTALK_SENDER', '341352077bce225195ccc2697fb449f723e70982')
|
||||
|
||||
API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}'
|
||||
|
||||
# KST 타임존
|
||||
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):
|
||||
"""
|
||||
알림톡 발송 공통 함수
|
||||
|
||||
Args:
|
||||
template_code: 템플릿 코드
|
||||
recipient_no: 수신 번호 (01012345678)
|
||||
template_params: 템플릿 변수 딕셔너리
|
||||
|
||||
Returns:
|
||||
tuple: (성공 여부, 메시지)
|
||||
"""
|
||||
url = f'{API_BASE}/messages'
|
||||
headers = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
'X-Secret-Key': SECRET_KEY
|
||||
}
|
||||
data = {
|
||||
'senderKey': SENDER_KEY,
|
||||
'templateCode': template_code,
|
||||
'recipientList': [
|
||||
{
|
||||
'recipientNo': recipient_no,
|
||||
'templateParameter': template_params
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=data, timeout=10)
|
||||
result = resp.json()
|
||||
|
||||
if resp.status_code == 200 and result.get('header', {}).get('isSuccessful'):
|
||||
logger.info(f"알림톡 발송 성공: {template_code} → {recipient_no}")
|
||||
return (True, "발송 성공")
|
||||
else:
|
||||
error_msg = result.get('header', {}).get('resultMessage', str(result))
|
||||
logger.warning(f"알림톡 발송 실패: {template_code} → {recipient_no}: {error_msg}")
|
||||
return (False, error_msg)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"알림톡 발송 타임아웃: {template_code} → {recipient_no}")
|
||||
return (False, "타임아웃")
|
||||
except Exception as e:
|
||||
logger.warning(f"알림톡 발송 오류: {template_code} → {recipient_no}: {e}")
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def build_item_summary(items):
|
||||
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
|
||||
if not items:
|
||||
return "약국 구매"
|
||||
first = items[0]['name']
|
||||
if len(first) > 20:
|
||||
first = first[:18] + '..'
|
||||
if len(items) == 1:
|
||||
return first
|
||||
return f"{first} 외 {len(items) - 1}건"
|
||||
|
||||
|
||||
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
|
||||
user_id=None, trigger_source='kiosk',
|
||||
transaction_id=None):
|
||||
"""
|
||||
마일리지 적립 완료 알림톡 발송
|
||||
|
||||
Args:
|
||||
phone: 수신 전화번호 (01012345678)
|
||||
name: 고객명
|
||||
points: 적립 포인트
|
||||
balance: 적립 후 총 잔액
|
||||
items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...]
|
||||
user_id: 사용자 ID (로그용)
|
||||
trigger_source: 발송 주체 ('kiosk', 'admin', 'manual')
|
||||
transaction_id: 거래 ID (로그용)
|
||||
|
||||
Returns:
|
||||
tuple: (성공 여부, 메시지)
|
||||
"""
|
||||
now_kst = datetime.now(KST).strftime('%m/%d %H:%M')
|
||||
item_summary = build_item_summary(items)
|
||||
|
||||
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도
|
||||
template_code = 'MILEAGE_CLAIM_V3'
|
||||
params = {
|
||||
'고객명': name,
|
||||
'구매품목': item_summary,
|
||||
'적립포인트': f'{points:,}',
|
||||
'총잔액': f'{balance:,}',
|
||||
'적립일시': now_kst,
|
||||
'전화번호': phone
|
||||
}
|
||||
|
||||
success, msg = _send_alimtalk(template_code, phone, params)
|
||||
|
||||
if not success:
|
||||
# V3 실패 로그
|
||||
_log_to_db(template_code, phone, False, msg,
|
||||
template_params=params, user_id=user_id,
|
||||
trigger_source=trigger_source, transaction_id=transaction_id)
|
||||
|
||||
# V2 폴백
|
||||
template_code = 'MILEAGE_CLAIM_V2'
|
||||
params = {
|
||||
'고객명': name,
|
||||
'적립포인트': f'{points:,}',
|
||||
'총잔액': f'{balance:,}',
|
||||
'적립일시': now_kst,
|
||||
'전화번호': phone
|
||||
}
|
||||
success, msg = _send_alimtalk(template_code, phone, params)
|
||||
|
||||
# 최종 결과 로그
|
||||
_log_to_db(template_code, phone, success, msg,
|
||||
template_params=params, user_id=user_id,
|
||||
trigger_source=trigger_source, transaction_id=transaction_id)
|
||||
|
||||
return (success, msg)
|
||||
|
||||
|
||||
def get_nhn_send_history(start_date, end_date, page=1, page_size=15):
|
||||
"""
|
||||
NHN Cloud API에서 실제 발송 내역 조회
|
||||
|
||||
Args:
|
||||
start_date: 시작일 (YYYY-MM-DD HH:mm)
|
||||
end_date: 종료일 (YYYY-MM-DD HH:mm)
|
||||
|
||||
Returns:
|
||||
list: 발송 메시지 목록
|
||||
"""
|
||||
url = (f'{API_BASE}/messages'
|
||||
f'?startRequestDate={start_date}'
|
||||
f'&endRequestDate={end_date}'
|
||||
f'&pageNum={page}&pageSize={page_size}')
|
||||
headers = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
'X-Secret-Key': SECRET_KEY
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get('messageSearchResultResponse'):
|
||||
return data['messageSearchResultResponse'].get('messages', [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"NHN 발송내역 조회 실패: {e}")
|
||||
return []
|
||||
147
backend/sms_client.py
Normal file
147
backend/sms_client.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# sms_client.py - NHN Cloud SMS API 클라이언트
|
||||
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
# NHN Cloud SMS 설정 (SMS 전용 앱키)
|
||||
SMS_CONFIG = {
|
||||
"BASE_URL": "https://api-sms.cloud.toast.com",
|
||||
"APP_KEY": "YWWBZkuJ0ck03cje",
|
||||
"SECRET_KEY": "jxXbBPnQN2tUL8QnEp4O3YfraGd8ZuNh",
|
||||
"SENDER_NO": "0334817390", # 발신번호 (033-481-7390)
|
||||
}
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SMSClient:
|
||||
"""NHN Cloud SMS 발송 클라이언트"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = SMS_CONFIG["BASE_URL"]
|
||||
self.app_key = SMS_CONFIG["APP_KEY"]
|
||||
self.secret_key = SMS_CONFIG["SECRET_KEY"]
|
||||
self.sender_no = SMS_CONFIG["SENDER_NO"]
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"X-Secret-Key": self.secret_key
|
||||
}
|
||||
|
||||
def send_sms(self, recipients: List[Dict], message: str) -> Dict:
|
||||
"""
|
||||
SMS 발송
|
||||
|
||||
Args:
|
||||
recipients: [{"phone": "01012345678", "name": "홍길동"}]
|
||||
message: 메시지 내용 (90바이트 이하 SMS, 초과시 LMS)
|
||||
|
||||
Returns:
|
||||
발송 결과
|
||||
"""
|
||||
# 메시지 길이에 따라 SMS/LMS 결정
|
||||
msg_bytes = len(message.encode('utf-8'))
|
||||
is_lms = msg_bytes > 90
|
||||
|
||||
url = f"{self.base_url}/sms/v3.0/appKeys/{self.app_key}/sender/{'mms' if is_lms else 'sms'}"
|
||||
|
||||
# 수신자 리스트 생성
|
||||
recipient_list = []
|
||||
for r in recipients:
|
||||
phone = (r.get('phone') or '').replace('-', '').replace(' ', '')
|
||||
if phone and len(phone) >= 10:
|
||||
recipient_list.append({
|
||||
"recipientNo": phone,
|
||||
"countryCode": "82"
|
||||
})
|
||||
|
||||
if not recipient_list:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "유효한 수신자가 없습니다"
|
||||
}
|
||||
|
||||
# 요청 데이터
|
||||
data = {
|
||||
"body": message,
|
||||
"sendNo": self.sender_no,
|
||||
"recipientList": recipient_list
|
||||
}
|
||||
|
||||
# LMS인 경우 제목 추가
|
||||
if is_lms:
|
||||
data["title"] = "청춘약국"
|
||||
|
||||
try:
|
||||
logger.info(f"SMS 발송 요청: {len(recipient_list)}명, {msg_bytes}bytes ({'LMS' if is_lms else 'SMS'})")
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
data=json.dumps(data),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info(f"SMS 응답: {result}")
|
||||
|
||||
header = result.get("header", {})
|
||||
if header.get("isSuccessful"):
|
||||
body = result.get("body", {})
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"SMS 발송 성공 ({len(recipient_list)}명)",
|
||||
"type": "LMS" if is_lms else "SMS",
|
||||
"request_id": body.get("data", {}).get("requestId"),
|
||||
"sent_count": len(recipient_list)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": header.get("resultMessage", "발송 실패"),
|
||||
"code": header.get("resultCode")
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "error": "요청 시간 초과"}
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"SMS 발송 오류: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"SMS 발송 예외: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def check_balance(self) -> Dict:
|
||||
"""잔여 발송량 확인"""
|
||||
url = f"{self.base_url}/sms/v3.0/appKeys/{self.app_key}/stats"
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
sms_client = SMSClient()
|
||||
|
||||
|
||||
def send_test_sms(phone: str, message: str = None) -> Dict:
|
||||
"""테스트 SMS 발송"""
|
||||
if not message:
|
||||
message = "[청춘약국] 테스트 문자입니다. 정상 수신되었다면 회신 부탁드립니다."
|
||||
|
||||
return sms_client.send_sms(
|
||||
recipients=[{"phone": phone, "name": "테스트"}],
|
||||
message=message
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트 발송
|
||||
result = send_test_sms("01027027390")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
BIN
backend/static/icons/icon-192.png
Normal file
BIN
backend/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
backend/static/icons/icon-512.png
Normal file
BIN
backend/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
25
backend/static/manifest.json
Normal file
25
backend/static/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "청춘약국 마일리지",
|
||||
"short_name": "청춘약국",
|
||||
"description": "청춘약국 QR 마일리지 적립 서비스",
|
||||
"start_url": "/my-page",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f7fa",
|
||||
"theme_color": "#6366f1",
|
||||
"orientation": "portrait",
|
||||
"lang": "ko",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
backend/static/sw.js
Normal file
63
backend/static/sw.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const CACHE_NAME = 'chungchun-pharmacy-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/static/js/lottie.min.js',
|
||||
'/static/animations/ai-loading.json',
|
||||
'/static/icons/icon-192.png',
|
||||
'/static/icons/icon-512.png'
|
||||
];
|
||||
|
||||
// Install: pre-cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch: cache-first for static, network-only for dynamic
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip dynamic routes entirely
|
||||
if (url.pathname.startsWith('/api/') ||
|
||||
url.pathname.startsWith('/admin') ||
|
||||
url.pathname.startsWith('/claim') ||
|
||||
url.pathname.startsWith('/my-page') ||
|
||||
url.pathname === '/privacy' ||
|
||||
url.pathname === '/logout' ||
|
||||
url.pathname === '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets and fonts
|
||||
if (url.pathname.startsWith('/static/') ||
|
||||
url.hostname === 'fonts.googleapis.com' ||
|
||||
url.hostname === 'fonts.gstatic.com') {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
return cached || fetch(event.request).then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -202,6 +202,11 @@
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.section table tbody tr[onclick]:hover {
|
||||
background: #eef2ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 사이드바 레이아웃 */
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
@@ -388,10 +393,20 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-content" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div class="header-title">📊 관리자 대시보드</div>
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/products" 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/members" 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/sales-detail" 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/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 class="layout-wrapper">
|
||||
@@ -457,6 +472,8 @@
|
||||
<th>전화번호</th>
|
||||
<th>포인트</th>
|
||||
<th>가입일</th>
|
||||
<th>카카오</th>
|
||||
<th>조제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -467,6 +484,20 @@
|
||||
<td class="phone-masked">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</td>
|
||||
<td class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
|
||||
<td>{{ user.created_at[:16].replace('T', ' ') }}</td>
|
||||
<td>
|
||||
{% if user.kakao_verified_at %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💬 {{ user.kakao_verified_at[:10] }}</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #868e96; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">미인증</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.has_prescription %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 3px; background: #d3f9d8; color: #2b8a3e; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💊 환자</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #adb5bd; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">일반</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -495,12 +526,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in recent_transactions %}
|
||||
<tr>
|
||||
<tr{% if tx.transaction_id %} onclick="showTransactionDetail('{{ tx.transaction_id }}')" style="cursor: pointer;" title="클릭하여 품목 상세 보기"{% endif %}>
|
||||
<td>{{ tx.nickname }}</td>
|
||||
<td class="phone-masked">{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}</td>
|
||||
<td class="points-positive">{{ "{:,}".format(tx.points) }}P</td>
|
||||
<td>{{ "{:,}".format(tx.balance_after) }}P</td>
|
||||
<td>{{ tx.description or tx.reason }}</td>
|
||||
<td>{{ tx.description or tx.reason }}{% if tx.transaction_id %} <span style="color: #6366f1; font-size: 12px;">🔍</span>{% endif %}</td>
|
||||
<td>{{ tx.created_at[:16].replace('T', ' ') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -650,7 +681,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script src="/static/js/lottie.min.js"></script>
|
||||
<script>
|
||||
function showTransactionDetail(transactionId) {
|
||||
document.getElementById('transactionModal').style.display = 'block';
|
||||
@@ -834,7 +865,10 @@
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">
|
||||
${user.name}
|
||||
${user.is_kakao_verified ? '<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; margin-left: 8px;"><span style="font-size: 13px;">💬</span>카카오</span>' : '<span style="display: inline-flex; align-items: center; gap: 3px; background: #e9ecef; color: #868e96; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; margin-left: 8px;">미인증</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
|
||||
@@ -848,6 +882,12 @@
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
|
||||
</div>
|
||||
${user.birthday ? `
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">🎂 생일</div>
|
||||
<div style="color: #ec4899; font-size: 16px; font-weight: 600;">${user.birthday.includes('-') ? user.birthday.split('-')[0] + '월 ' + user.birthday.split('-')[1] + '일' : user.birthday.slice(0,2) + '월 ' + user.birthday.slice(2,4) + '일'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||||
@@ -862,10 +902,16 @@
|
||||
<!-- 탭 메뉴 -->
|
||||
<div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;">
|
||||
<button onclick="switchTab('purchases')" id="tab-purchases" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid #6366f1; color: #6366f1;">
|
||||
구매 이력 (${purchases.length})
|
||||
🛒 구매 (${purchases.length})
|
||||
</button>
|
||||
<button onclick="switchTab('mileage')" id="tab-mileage" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
적립 이력 (${mileageHistory.length})
|
||||
💰 적립 (${mileageHistory.length})
|
||||
</button>
|
||||
<button onclick="switchTab('prescriptions')" id="tab-prescriptions" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
💊 조제 (${data.prescriptions ? data.prescriptions.length : 0})
|
||||
</button>
|
||||
<button onclick="switchTab('interests')" id="tab-interests" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
💝 관심 (${data.interests ? data.interests.length : 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -922,6 +968,96 @@
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 조제 이력 탭 -->
|
||||
<div id="tab-content-prescriptions" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 조제 이력 렌더링
|
||||
const prescriptions = data.prescriptions || [];
|
||||
if (prescriptions.length > 0) {
|
||||
prescriptions.forEach(rx => {
|
||||
// 날짜 포맷
|
||||
const dateStr = rx.date || '';
|
||||
let formattedDate = dateStr;
|
||||
if (dateStr.length === 8) {
|
||||
formattedDate = `${dateStr.slice(0,4)}.${dateStr.slice(4,6)}.${dateStr.slice(6,8)}`;
|
||||
}
|
||||
|
||||
// 처방 품목
|
||||
const itemsHtml = (rx.items || []).map(item => {
|
||||
const dosage = item.quantity || 1;
|
||||
const freq = item.times_per_day || 1;
|
||||
const days = item.days || 0;
|
||||
return `
|
||||
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f3f5;">
|
||||
<span style="color: #495057; font-size: 14px;">${item.name}</span>
|
||||
<span style="color: #6366f1; font-size: 13px; font-weight: 600;">${dosage}정 × ${freq}회 × ${days}일</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #212529;">📅 ${formattedDate}</span>
|
||||
<span style="font-size: 13px; color: #6366f1; font-weight: 600;">${rx.total_days || ''}일분</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #64748b; margin-bottom: 12px;">
|
||||
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
|
||||
</div>
|
||||
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else if (!data.pos_customer) {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 POS 회원으로 등록되지 않았습니다<br><small>전화번호가 POS에 등록되면 조제 이력이 표시됩니다</small></p>';
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 조제 이력이 없습니다</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 관심상품 탭 -->
|
||||
<div id="tab-content-interests" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 관심상품 렌더링
|
||||
const interests = data.interests || [];
|
||||
if (interests.length > 0) {
|
||||
interests.forEach(item => {
|
||||
// 날짜 포맷
|
||||
const date = item.created_at || '';
|
||||
|
||||
// 트리거 상품 파싱
|
||||
let triggerText = '';
|
||||
try {
|
||||
const triggers = JSON.parse(item.trigger_products || '[]');
|
||||
if (triggers.length > 0) {
|
||||
triggerText = triggers.join(', ');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #ec4899;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 15px; font-weight: 700; color: #ec4899;">💝 ${item.product}</span>
|
||||
<span style="font-size: 12px; color: #868e96;">${date}</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #64748b; margin-bottom: 8px;">
|
||||
${item.reason || ''}
|
||||
</div>
|
||||
${triggerText ? `<div style="font-size: 12px; color: #94a3b8; background: #f8f9fa; padding: 8px 12px; border-radius: 6px;">🛒 구매: ${triggerText}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
`;
|
||||
@@ -1288,9 +1424,12 @@
|
||||
`;
|
||||
|
||||
users.forEach(user => {
|
||||
const kakaoBadge = user.is_kakao_verified
|
||||
? '<span style="display: inline-flex; align-items: center; gap: 2px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">💬</span>'
|
||||
: '<span style="display: inline-flex; align-items: center; background: #e9ecef; color: #868e96; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">미인증</span>';
|
||||
html += `
|
||||
<tr style="border-bottom: 1px solid #f1f3f5;">
|
||||
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}</td>
|
||||
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}${kakaoBadge}</td>
|
||||
<td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${user.phone}</td>
|
||||
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td>
|
||||
<td style="padding: 14px; text-align: center;">
|
||||
|
||||
418
backend/templates/admin_ai_crm.html
Normal file
418
backend/templates/admin_ai_crm.html
Normal file
@@ -0,0 +1,418 @@
|
||||
<!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>
|
||||
<div>
|
||||
<a href="/admin/ai-gw" style="margin-right: 16px;">Gateway 모니터</a>
|
||||
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
||||
</div>
|
||||
</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>
|
||||
559
backend/templates/admin_ai_gw.html
Normal file
559
backend/templates/admin_ai_gw.html
Normal file
@@ -0,0 +1,559 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Gateway 모니터 - 청춘약국</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: #0f172a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
padding: 28px 32px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header h1 .live-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.header h1 .live-dot.offline { background: #ef4444; animation: none; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { opacity: 0.8; box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 메인 카드 ── */
|
||||
.main-card {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.main-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.main-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.main-model {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #a78bfa;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.7);
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
}
|
||||
.refresh-btn.loading { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
/* 컨텍스트 표시 */
|
||||
.context-display {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.context-numbers {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.context-used {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -3px;
|
||||
line-height: 1;
|
||||
}
|
||||
.context-max {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.context-percent {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.context-percent.warning { color: #fbbf24; }
|
||||
.context-percent.danger { color: #ef4444; }
|
||||
|
||||
/* 프로그레스 바 */
|
||||
.progress-wrap {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 100px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
background: linear-gradient(90deg, #22c55e, #84cc16);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.progress-bar.warning { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.progress-bar.danger { background: linear-gradient(90deg, #ef4444, #f97316); }
|
||||
|
||||
/* 통계 그리드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.stat-item {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.4);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stat-value.purple { color: #a78bfa; }
|
||||
.stat-value.blue { color: #38bdf8; }
|
||||
.stat-value.yellow { color: #fbbf24; }
|
||||
.stat-value.green { color: #34d399; }
|
||||
|
||||
/* ── 세션 목록 ── */
|
||||
.sessions-card {
|
||||
background: #1e293b;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sessions-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.sessions-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sessions-count {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.sessions-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.session-item:hover { background: rgba(255,255,255,0.02); }
|
||||
.session-item:last-child { border-bottom: none; }
|
||||
.session-info { flex: 1; }
|
||||
.session-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.session-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.session-model {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.session-usage {
|
||||
text-align: right;
|
||||
}
|
||||
.session-percent {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.session-percent.low { color: #22c55e; }
|
||||
.session-percent.mid { color: #fbbf24; }
|
||||
.session-percent.high { color: #ef4444; }
|
||||
.session-tokens {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
.session-bar-wrap {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 100px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.session-bar {
|
||||
height: 100%;
|
||||
border-radius: 100px;
|
||||
background: #22c55e;
|
||||
}
|
||||
.session-bar.mid { background: #fbbf24; }
|
||||
.session-bar.high { background: #ef4444; }
|
||||
|
||||
/* ── 모델별 통계 ── */
|
||||
.model-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.model-stat-card {
|
||||
background: #1e293b;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.model-stat-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #a78bfa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.model-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.model-stat-row span:last-child {
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── 에러 상태 ── */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #f87171;
|
||||
}
|
||||
.error-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.error-text { font-size: 16px; font-weight: 500; margin-bottom: 8px; }
|
||||
.error-sub { font-size: 13px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* ── 타임스탬프 ── */
|
||||
.timestamp {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.context-used { font-size: 48px; }
|
||||
.context-max { font-size: 18px; }
|
||||
.context-percent { font-size: 18px; }
|
||||
.header { padding: 20px 16px 18px; }
|
||||
.content { padding: 16px 12px 40px; }
|
||||
.main-card { padding: 24px 20px; }
|
||||
.session-bar-wrap { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
|
||||
<a href="/admin/alimtalk">알림톡 로그</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>
|
||||
<span class="live-dot" id="statusDot"></span>
|
||||
AI Gateway 모니터
|
||||
</h1>
|
||||
<p>Clawdbot Gateway 실시간 상태 · Claude / GPT 토큰 사용량</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div id="mainContent">
|
||||
<!-- 메인 카드 -->
|
||||
<div class="main-card">
|
||||
<div class="main-header">
|
||||
<div>
|
||||
<div class="main-title">현재 모델</div>
|
||||
<div class="main-model" id="currentModel">로딩중...</div>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refreshBtn" onclick="refresh()">
|
||||
<span>↻</span> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="context-display">
|
||||
<div class="context-numbers">
|
||||
<span class="context-used" id="contextUsed">--</span>
|
||||
<span class="context-max" id="contextMax">/ 200k</span>
|
||||
<span class="context-percent" id="contextPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">입력 토큰</div>
|
||||
<div class="stat-value purple" id="inputTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">출력 토큰</div>
|
||||
<div class="stat-value blue" id="outputTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">전체 토큰 (모든 세션)</div>
|
||||
<div class="stat-value yellow" id="totalTokens">-</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">활성 세션</div>
|
||||
<div class="stat-value green" id="sessionCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모델별 통계 -->
|
||||
<div class="model-stats" id="modelStats"></div>
|
||||
|
||||
<!-- 세션 목록 -->
|
||||
<div class="sessions-card">
|
||||
<div class="sessions-header">
|
||||
<div class="sessions-title">세션별 상세</div>
|
||||
<div class="sessions-count" id="sessionsCount">-</div>
|
||||
</div>
|
||||
<div class="sessions-list" id="sessionsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="timestamp" id="timestamp">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return Math.round(num / 1000) + 'k';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function getPercentClass(percent) {
|
||||
if (percent >= 70) return 'high';
|
||||
if (percent >= 40) return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const btn = document.getElementById('refreshBtn');
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = '<span>⟳</span> 로딩중...';
|
||||
|
||||
fetch('/api/claude-status?detail=true')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '<span>↻</span> 새로고침';
|
||||
|
||||
if (!data.ok || !data.connected) {
|
||||
document.getElementById('statusDot').classList.add('offline');
|
||||
document.getElementById('mainContent').innerHTML = `
|
||||
<div class="main-card">
|
||||
<div class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<div class="error-text">Gateway 연결 실패</div>
|
||||
<div class="error-sub">${data.error || 'Clawdbot이 실행 중인지 확인하세요'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('statusDot').classList.remove('offline');
|
||||
updateUI(data);
|
||||
})
|
||||
.catch(err => {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '<span>↻</span> 새로고침';
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(data) {
|
||||
const ctx = data.context;
|
||||
const main = data.mainSession || {};
|
||||
const summary = data.summary;
|
||||
|
||||
// 모델
|
||||
document.getElementById('currentModel').textContent = data.model;
|
||||
|
||||
// 컨텍스트
|
||||
document.getElementById('contextUsed').textContent = formatNumber(ctx.used);
|
||||
document.getElementById('contextMax').textContent = '/ ' + formatNumber(ctx.max);
|
||||
|
||||
const percentEl = document.getElementById('contextPercent');
|
||||
percentEl.textContent = ctx.percent + '%';
|
||||
percentEl.className = 'context-percent';
|
||||
if (ctx.percent >= 70) percentEl.classList.add('danger');
|
||||
else if (ctx.percent >= 40) percentEl.classList.add('warning');
|
||||
|
||||
// 프로그레스 바
|
||||
const bar = document.getElementById('progressBar');
|
||||
bar.style.width = ctx.percent + '%';
|
||||
bar.className = 'progress-bar';
|
||||
if (ctx.percent >= 70) bar.classList.add('danger');
|
||||
else if (ctx.percent >= 40) bar.classList.add('warning');
|
||||
|
||||
// 통계
|
||||
document.getElementById('inputTokens').textContent = formatNumber(main.inputTokens || 0);
|
||||
document.getElementById('outputTokens').textContent = formatNumber(main.outputTokens || 0);
|
||||
document.getElementById('totalTokens').textContent = formatNumber(summary.totalTokens);
|
||||
document.getElementById('sessionCount').textContent = summary.totalSessions + '개';
|
||||
|
||||
// 모델별 통계
|
||||
if (data.modelStats) {
|
||||
const statsHtml = Object.entries(data.modelStats).map(([model, stat]) => `
|
||||
<div class="model-stat-card">
|
||||
<div class="model-stat-name">${escapeHtml(model)}</div>
|
||||
<div class="model-stat-row">
|
||||
<span>세션 수</span>
|
||||
<span>${stat.sessions}개</span>
|
||||
</div>
|
||||
<div class="model-stat-row">
|
||||
<span>총 토큰</span>
|
||||
<span>${formatNumber(stat.tokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('modelStats').innerHTML = statsHtml;
|
||||
}
|
||||
|
||||
// 세션 목록
|
||||
if (data.sessions) {
|
||||
document.getElementById('sessionsCount').textContent =
|
||||
`토큰 사용량 순 · ${data.sessions.length}개`;
|
||||
|
||||
const sessionsHtml = data.sessions.map(s => {
|
||||
const pct = s.tokens.contextPercent;
|
||||
const pctClass = getPercentClass(pct);
|
||||
return `
|
||||
<div class="session-item">
|
||||
<div class="session-info">
|
||||
<div class="session-name">${escapeHtml(s.displayName || s.name)}</div>
|
||||
<div class="session-meta">
|
||||
<span class="session-model">${escapeHtml(s.model)}</span>
|
||||
<span>${s.channel || '-'}</span>
|
||||
<span>${s.updatedAt || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-usage">
|
||||
<div class="session-percent ${pctClass}">${pct}%</div>
|
||||
<div class="session-tokens">${s.tokens.display}</div>
|
||||
<div class="session-bar-wrap">
|
||||
<div class="session-bar ${pctClass}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
document.getElementById('sessionsList').innerHTML = sessionsHtml;
|
||||
}
|
||||
|
||||
// 타임스탬프
|
||||
const ts = new Date(data.timestamp);
|
||||
document.getElementById('timestamp').textContent =
|
||||
`마지막 업데이트: ${ts.toLocaleTimeString('ko-KR')}`;
|
||||
}
|
||||
|
||||
// 초기 로드 & 30초 자동 갱신
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
554
backend/templates/admin_alimtalk.html
Normal file
554
backend/templates/admin_alimtalk.html
Normal file
@@ -0,0 +1,554 @@
|
||||
<!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>
|
||||
<a href="/admin/ai-crm">AI 업셀링</a>
|
||||
<a href="/admin/ai-gw">Gateway 모니터</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>
|
||||
1116
backend/templates/admin_members.html
Normal file
1116
backend/templates/admin_members.html
Normal file
File diff suppressed because it is too large
Load Diff
619
backend/templates/admin_products.html
Normal file
619
backend/templates/admin_products.html
Normal file
@@ -0,0 +1,619 @@
|
||||
<!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, #7c3aed 0%, #8b5cf6 50%, #a78bfa 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: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.search-btn {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover { background: #7c3aed; }
|
||||
.search-btn:active { transform: scale(0.98); }
|
||||
.search-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.search-hint span {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ── 결과 카운트 ── */
|
||||
.result-count {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
.result-count strong {
|
||||
color: #8b5cf6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #faf5ff; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 상품 정보 ── */
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.product-supplier.set {
|
||||
color: #8b5cf6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 코드/바코드 ── */
|
||||
.code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.code-barcode {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.code-na {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 가격 ── */
|
||||
.price {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── QR 버튼 ── */
|
||||
.btn-qr {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-qr:hover { background: #7c3aed; }
|
||||
.btn-qr:active { transform: scale(0.95); }
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-preview {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.modal-preview img {
|
||||
max-width: 200px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 수량 선택기 ── */
|
||||
.qty-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
.qty-btn:first-child { border-radius: 12px 0 0 12px; }
|
||||
.qty-btn:last-child { border-radius: 0 12px 12px 0; }
|
||||
.qty-btn:hover { background: #e2e8f0; color: #334155; }
|
||||
.qty-btn:active { transform: scale(0.95); background: #cbd5e1; }
|
||||
.qty-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.qty-value {
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-btn.cancel { background: #f1f5f9; color: #64748b; }
|
||||
.modal-btn.cancel:hover { background: #e2e8f0; }
|
||||
.modal-btn.confirm { background: #8b5cf6; color: #fff; }
|
||||
.modal-btn.confirm:hover { background: #7c3aed; }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.search-box { flex-direction: column; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 700px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/sales-detail" style="margin-right: 16px;">판매 조회</a>
|
||||
<a href="/admin/sales">판매 내역</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>🔍 제품 검색</h1>
|
||||
<p>전체 제품 검색 · QR 라벨 인쇄</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-section">
|
||||
<div class="search-box">
|
||||
<input type="text" class="search-input" id="searchInput"
|
||||
placeholder="상품명, 바코드, 상품코드로 검색..."
|
||||
onkeypress="if(event.key==='Enter')searchProducts()">
|
||||
<button class="search-btn" onclick="searchProducts()">🔍 검색</button>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
|
||||
<div class="search-hint">
|
||||
<span>예시</span> 타이레놀, 벤포파워, 8806418067510, LB000001423
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #475569;">
|
||||
<input type="checkbox" id="animalOnly" style="width: 18px; height: 18px; accent-color: #10b981; cursor: pointer;">
|
||||
<span style="display: flex; align-items: center; gap: 4px;">
|
||||
🐾 <strong style="color: #10b981;">동물약만</strong> 보기
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 -->
|
||||
<div class="result-count" id="resultCount" style="display:none;">
|
||||
검색 결과: <strong id="resultNum">0</strong>건
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>상품명</th>
|
||||
<th>상품코드</th>
|
||||
<th>바코드</th>
|
||||
<th>판매가</th>
|
||||
<th>QR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="productsTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="icon">🔍</div>
|
||||
<p>상품명, 바코드, 상품코드로 검색하세요</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR 인쇄 모달 -->
|
||||
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
|
||||
<div id="qrInfo" style="margin-bottom:12px;"></div>
|
||||
<div class="modal-preview" id="qrPreview">
|
||||
<p style="color:#64748b;">미리보기 로딩 중...</p>
|
||||
</div>
|
||||
<div class="qty-label">인쇄 매수</div>
|
||||
<div class="qty-selector">
|
||||
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus">−</button>
|
||||
<div class="qty-value" id="qtyValue">1</div>
|
||||
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
|
||||
</div>
|
||||
<div class="modal-btns">
|
||||
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
|
||||
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let productsData = [];
|
||||
let selectedItem = null;
|
||||
let printQty = 1;
|
||||
const MAX_QTY = 10;
|
||||
const MIN_QTY = 1;
|
||||
|
||||
function formatPrice(num) {
|
||||
if (!num) return '-';
|
||||
return new Intl.NumberFormat('ko-KR').format(num) + '원';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function searchProducts() {
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
if (!search) {
|
||||
alert('검색어를 입력하세요');
|
||||
return;
|
||||
}
|
||||
if (search.length < 2) {
|
||||
alert('2글자 이상 입력하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('productsTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><p>검색 중...</p></td></tr>';
|
||||
|
||||
const animalOnly = document.getElementById('animalOnly').checked;
|
||||
fetch(`/api/products?search=${encodeURIComponent(search)}${animalOnly ? '&animal_only=1' : ''}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
productsData = data.items;
|
||||
document.getElementById('resultCount').style.display = 'block';
|
||||
document.getElementById('resultNum').textContent = productsData.length;
|
||||
renderTable();
|
||||
} else {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state"><p>오류: ${data.error}</p></td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><p>검색 실패</p></td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('productsTableBody');
|
||||
|
||||
if (productsData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="icon">📭</div><p>검색 결과가 없습니다</p></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = productsData.map((item, idx) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-name">
|
||||
${escapeHtml(item.product_name)}
|
||||
${item.is_animal_drug ? '<span style="display:inline-block;background:#10b981;color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;margin-left:6px;">🐾 동물약</span>' : ''}
|
||||
</div>
|
||||
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
|
||||
</td>
|
||||
<td><span class="code code-drug">${item.drug_code}</span></td>
|
||||
<td>${item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">없음</span>`}</td>
|
||||
<td class="price">${formatPrice(item.sale_price)}</td>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})">🏷️ QR</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ── QR 인쇄 관련 ──
|
||||
function adjustQty(delta) {
|
||||
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
|
||||
updateQtyUI();
|
||||
}
|
||||
|
||||
function updateQtyUI() {
|
||||
document.getElementById('qtyValue').textContent = printQty;
|
||||
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
|
||||
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
|
||||
document.getElementById('printBtn').textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
|
||||
}
|
||||
|
||||
function printQR(idx) {
|
||||
selectedItem = productsData[idx];
|
||||
printQty = 1;
|
||||
|
||||
const modal = document.getElementById('qrModal');
|
||||
const preview = document.getElementById('qrPreview');
|
||||
const info = document.getElementById('qrInfo');
|
||||
|
||||
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
|
||||
info.innerHTML = `
|
||||
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
|
||||
<span style="color:#64748b;font-size:13px;">
|
||||
바코드: ${selectedItem.barcode || selectedItem.drug_code || 'N/A'}<br>
|
||||
가격: ${formatPrice(selectedItem.sale_price)}
|
||||
</span>
|
||||
`;
|
||||
updateQtyUI();
|
||||
modal.classList.add('active');
|
||||
|
||||
fetch('/api/qr-preview', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.sale_price || 0
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.image) {
|
||||
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
|
||||
} else {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeQRModal() {
|
||||
document.getElementById('qrModal').classList.remove('active');
|
||||
selectedItem = null;
|
||||
printQty = 1;
|
||||
}
|
||||
|
||||
async function confirmPrintQR() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const btn = document.getElementById('printBtn');
|
||||
const totalQty = printQty;
|
||||
btn.disabled = true;
|
||||
|
||||
let successCount = 0;
|
||||
let errorMsg = '';
|
||||
|
||||
for (let i = 0; i < totalQty; i++) {
|
||||
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/qr-print', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.sale_price || 0
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorMsg = data.error || '알 수 없는 오류';
|
||||
break;
|
||||
}
|
||||
|
||||
if (i < totalQty - 1) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg = err.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
updateQtyUI();
|
||||
|
||||
if (successCount === totalQty) {
|
||||
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
|
||||
closeQRModal();
|
||||
} else if (successCount > 0) {
|
||||
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
|
||||
} else {
|
||||
alert(`❌ 인쇄 실패: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
document.getElementById('searchInput').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
778
backend/templates/admin_sales_detail.html
Normal file
778
backend/templates/admin_sales_detail.html
Normal file
@@ -0,0 +1,778 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
/* ── QR 인쇄 버튼 ── */
|
||||
.btn-qr {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-qr:hover { background: #7c3aed; }
|
||||
.btn-qr:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-qr.printing {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-preview {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.modal-preview img {
|
||||
max-width: 200px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 수량 선택기 ── */
|
||||
.qty-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
.qty-btn:first-child {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
.qty-btn:last-child {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
.qty-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
.qty-btn:active {
|
||||
transform: scale(0.95);
|
||||
background: #cbd5e1;
|
||||
}
|
||||
.qty-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.qty-value {
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-btn.cancel {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
.modal-btn.cancel:hover { background: #e2e8f0; }
|
||||
.modal-btn.confirm {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
}
|
||||
.modal-btn.confirm:hover { background: #7c3aed; }
|
||||
.modal-btn.confirm:active { transform: scale(0.98); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@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>
|
||||
<th>QR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesTableBody">
|
||||
<tr><td colspan="7" 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="7" class="empty-state">판매 내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = salesData.map((item, idx) => `
|
||||
<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>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})" title="QR 라벨 인쇄">
|
||||
🏷️ QR
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
'<tr><td colspan="7" 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="7" class="empty-state">오류: ${data.error}</td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="7" class="empty-state">데이터 로드 실패</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
// QR 인쇄 관련
|
||||
let selectedItem = null;
|
||||
let printQty = 1;
|
||||
const MAX_QTY = 10;
|
||||
const MIN_QTY = 1;
|
||||
|
||||
function adjustQty(delta) {
|
||||
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
|
||||
updateQtyUI();
|
||||
}
|
||||
|
||||
function updateQtyUI() {
|
||||
document.getElementById('qtyValue').textContent = printQty;
|
||||
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
|
||||
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
|
||||
|
||||
const btn = document.getElementById('printBtn');
|
||||
btn.textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
|
||||
}
|
||||
|
||||
function printQR(idx) {
|
||||
selectedItem = salesData[idx];
|
||||
printQty = 1;
|
||||
|
||||
// 미리보기 요청
|
||||
const modal = document.getElementById('qrModal');
|
||||
const preview = document.getElementById('qrPreview');
|
||||
const info = document.getElementById('qrInfo');
|
||||
|
||||
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
|
||||
info.innerHTML = `
|
||||
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
|
||||
<span style="color:#64748b;font-size:13px;">
|
||||
바코드: ${selectedItem.barcode || selectedItem.drug_code || 'N/A'}<br>
|
||||
가격: ${formatPrice(selectedItem.unit_price)}
|
||||
</span>
|
||||
`;
|
||||
updateQtyUI();
|
||||
modal.classList.add('active');
|
||||
|
||||
// 미리보기 이미지 로드
|
||||
fetch('/api/qr-preview', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.unit_price || 0
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.image) {
|
||||
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
|
||||
} else {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeQRModal() {
|
||||
document.getElementById('qrModal').classList.remove('active');
|
||||
selectedItem = null;
|
||||
printQty = 1;
|
||||
}
|
||||
|
||||
async function confirmPrintQR() {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const btn = document.getElementById('printBtn');
|
||||
const totalQty = printQty;
|
||||
btn.disabled = true;
|
||||
|
||||
let successCount = 0;
|
||||
let errorMsg = '';
|
||||
|
||||
for (let i = 0; i < totalQty; i++) {
|
||||
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/qr-print', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
drug_name: selectedItem.product_name,
|
||||
barcode: selectedItem.barcode || '',
|
||||
drug_code: selectedItem.drug_code || '',
|
||||
sale_price: selectedItem.unit_price || 0
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorMsg = data.error || '알 수 없는 오류';
|
||||
break;
|
||||
}
|
||||
|
||||
// 연속 인쇄 시 약간의 딜레이
|
||||
if (i < totalQty - 1) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg = err.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
updateQtyUI();
|
||||
|
||||
if (successCount === totalQty) {
|
||||
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
|
||||
closeQRModal();
|
||||
} else if (successCount > 0) {
|
||||
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
|
||||
} else {
|
||||
alert(`❌ 인쇄 실패: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
|
||||
<!-- QR 인쇄 모달 -->
|
||||
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
|
||||
<div id="qrInfo" style="margin-bottom:12px;"></div>
|
||||
<div class="modal-preview" id="qrPreview">
|
||||
<p style="color:#64748b;">미리보기 로딩 중...</p>
|
||||
</div>
|
||||
<div class="qty-label">인쇄 매수</div>
|
||||
<div class="qty-selector">
|
||||
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus">−</button>
|
||||
<div class="qty-value" id="qtyValue">1</div>
|
||||
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
|
||||
</div>
|
||||
<div class="modal-btns">
|
||||
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
|
||||
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
902
backend/templates/admin_sales_pos.html
Normal file
902
backend/templates/admin_sales_pos.html
Normal file
@@ -0,0 +1,902 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>판매 내역 - 청춘약국 POS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--border: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-emerald: #10b981;
|
||||
--accent-rose: #f43f5e;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
|
||||
padding: 20px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left p {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ══════════════════ 컨텐츠 ══════════════════ */
|
||||
.content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 검색 영역 ══════════════════ */
|
||||
.search-bar {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
min-width: 140px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
|
||||
}
|
||||
.search-group input::placeholder { color: var(--text-muted); }
|
||||
.search-btn {
|
||||
background: linear-gradient(135deg, var(--accent-teal), var(--accent-emerald));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.4);
|
||||
}
|
||||
|
||||
/* ══════════════════ 통계 카드 ══════════════════ */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.stat-card.teal::before { background: var(--accent-teal); }
|
||||
.stat-card.blue::before { background: var(--accent-blue); }
|
||||
.stat-card.purple::before { background: var(--accent-purple); }
|
||||
.stat-card.amber::before { background: var(--accent-amber); }
|
||||
.stat-card.emerald::before { background: var(--accent-emerald); }
|
||||
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card.teal .stat-value { color: var(--accent-teal); }
|
||||
.stat-card.blue .stat-value { color: var(--accent-blue); }
|
||||
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
||||
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
||||
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 뷰 토글 ══════════════════ */
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: var(--accent-teal);
|
||||
color: #fff;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.view-mode {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.view-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-btn.active {
|
||||
border-color: var(--accent-teal);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
/* ══════════════════ 거래 카드 (그룹별) ══════════════════ */
|
||||
.transactions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tx-card:hover {
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
.tx-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tx-header:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
.tx-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.tx-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
.tx-time {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tx-customer {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.tx-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tx-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-emerald);
|
||||
}
|
||||
.tx-toggle {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.tx-card.open .tx-toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 품목 테이블 */
|
||||
.tx-items {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
.tx-card.open .tx-items {
|
||||
max-height: 2000px;
|
||||
}
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.items-table th {
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.items-table th:nth-child(4),
|
||||
.items-table th:nth-child(5),
|
||||
.items-table th:nth-child(6) {
|
||||
text-align: right;
|
||||
}
|
||||
.items-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.items-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* 제품 셀 */
|
||||
.product-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 코드 뱃지 */
|
||||
.code-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.code-barcode {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.code-standard {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.code-na {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.code-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 바코드 시각화 */
|
||||
.barcode-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.barcode-bars {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
.barcode-bars span {
|
||||
width: 2px;
|
||||
background: var(--accent-emerald);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 숫자 정렬 */
|
||||
.items-table td.qty,
|
||||
.items-table td.price {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.items-table td.price.total {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ══════════════════ 리스트 뷰 ══════════════════ */
|
||||
.list-view {
|
||||
display: none;
|
||||
}
|
||||
.list-view.active {
|
||||
display: block;
|
||||
}
|
||||
.list-table-wrap {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.list-table th {
|
||||
padding: 14px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.list-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent-teal);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 반응형 ══════════════════ */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.header-nav { display: none; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.tx-info { flex-wrap: wrap; gap: 8px; }
|
||||
.view-controls { flex-direction: column; gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<h1>🧾 판매 내역</h1>
|
||||
<p>POS 판매 데이터 · 바코드 · 표준코드 조회</p>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">📊 대시보드</a>
|
||||
<a href="/admin/ai-crm">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk">📨 알림톡</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="3" selected>최근 3일</option>
|
||||
<option value="7">최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색어</label>
|
||||
<input type="text" id="searchInput" placeholder="상품명, 코드, 바코드...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">있음</option>
|
||||
<option value="none">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card teal">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-value" id="statTxCount">-</div>
|
||||
<div class="stat-label">조회 일수</div>
|
||||
</div>
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">📦</div>
|
||||
<div class="stat-value" id="statItemCount">-</div>
|
||||
<div class="stat-label">총 판매 품목</div>
|
||||
</div>
|
||||
<div class="stat-card emerald">
|
||||
<div class="stat-icon">💰</div>
|
||||
<div class="stat-value" id="statAmount">-</div>
|
||||
<div class="stat-label">총 매출액</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value" id="statBarcode">-</div>
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
</div>
|
||||
<div class="stat-card amber">
|
||||
<div class="stat-icon">🏷️</div>
|
||||
<div class="stat-value" id="statProducts">-</div>
|
||||
<div class="stat-label">고유 상품</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰 컨트롤 -->
|
||||
<div class="view-controls">
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체</button>
|
||||
</div>
|
||||
<div class="view-mode">
|
||||
<button class="view-btn active" data-view="group" onclick="setViewMode('group')">📁 거래별</button>
|
||||
<button class="view-btn" data-view="list" onclick="setViewMode('list')">📋 목록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 거래별 뷰 -->
|
||||
<div id="groupView" class="transactions-container">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="list-view">
|
||||
<div class="list-table-wrap">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일</th>
|
||||
<th>상품명</th>
|
||||
<th id="listCodeHeader">상품코드</th>
|
||||
<th style="text-align:center">수량</th>
|
||||
<th style="text-align:right">단가</th>
|
||||
<th style="text-align:right">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let rawData = []; // API에서 받은 원본 데이터
|
||||
let groupedData = []; // 거래별 그룹화된 데이터
|
||||
let currentCodeView = 'drug';
|
||||
let currentViewMode = 'group';
|
||||
|
||||
// ──────────────── 코드 뷰 전환 ────────────────
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'drug': '상품코드',
|
||||
'barcode': '바코드',
|
||||
'standard': '표준코드',
|
||||
'all': '코드 정보'
|
||||
};
|
||||
document.querySelectorAll('#codeHeader, #listCodeHeader').forEach(el => {
|
||||
if (el) el.textContent = headers[view];
|
||||
});
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
// ──────────────── 뷰 모드 전환 ────────────────
|
||||
function setViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||
});
|
||||
document.getElementById('groupView').style.display = mode === 'group' ? 'flex' : 'none';
|
||||
document.getElementById('listView').classList.toggle('active', mode === 'list');
|
||||
}
|
||||
|
||||
// ──────────────── 코드 렌더링 ────────────────
|
||||
function renderCode(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code-badge code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
if (item.barcode) {
|
||||
return `
|
||||
<div class="barcode-visual">
|
||||
<span class="code-badge code-barcode">${item.barcode}</span>
|
||||
${renderBarcodeBars(item.barcode)}
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="code-badge code-na">—</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code-badge code-na">—</span>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="code-stack">
|
||||
<span class="code-badge code-drug">${item.drug_code}</span>
|
||||
${item.barcode
|
||||
? `<span class="code-badge code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code-badge code-na">바코드 없음</span>`}
|
||||
${item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: ''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 바코드 시각화 바
|
||||
function renderBarcodeBars(barcode) {
|
||||
const bars = barcode.split('').map(c => {
|
||||
const h = 8 + (parseInt(c) || c.charCodeAt(0) % 10) * 1.2;
|
||||
return `<span style="height:${h}px"></span>`;
|
||||
}).join('');
|
||||
return `<div class="barcode-bars">${bars}</div>`;
|
||||
}
|
||||
|
||||
// ──────────────── 포맷 ────────────────
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 그룹화 (날짜별) ────────────────
|
||||
function groupByDate(items) {
|
||||
const map = new Map();
|
||||
items.forEach(item => {
|
||||
const key = item.sale_date;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
date: item.sale_date,
|
||||
items: [],
|
||||
total: 0
|
||||
});
|
||||
}
|
||||
const group = map.get(key);
|
||||
group.items.push(item);
|
||||
group.total += item.total_price || 0;
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
b.date.localeCompare(a.date)
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── 렌더링 ────────────────
|
||||
function render() {
|
||||
renderGroupView();
|
||||
renderListView();
|
||||
}
|
||||
|
||||
function renderGroupView() {
|
||||
const container = document.getElementById('groupView');
|
||||
|
||||
if (groupedData.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div>판매 내역이 없습니다</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = groupedData.map((tx, idx) => `
|
||||
<div class="tx-card" id="tx-${idx}">
|
||||
<div class="tx-header" onclick="toggleTransaction(${idx})">
|
||||
<div class="tx-info">
|
||||
<span class="tx-id">📅 ${tx.date}</span>
|
||||
</div>
|
||||
<div class="tx-summary">
|
||||
<span class="tx-count">${tx.items.length}개 품목</span>
|
||||
<span class="tx-amount">${formatPrice(tx.total)}원</span>
|
||||
<span class="tx-toggle">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-items">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">상품명</th>
|
||||
<th id="codeHeader-${idx}">상품코드</th>
|
||||
<th style="text-align:right;width:8%">수량</th>
|
||||
<th style="text-align:right;width:12%">단가</th>
|
||||
<th style="text-align:right;width:12%">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tx.items.map(item => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}원</td>
|
||||
<td class="price total">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderListView() {
|
||||
const tbody = document.getElementById('listTableBody');
|
||||
|
||||
if (rawData.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rawData.map(item => `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td style="text-align:center">${item.quantity}</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;">${formatPrice(item.unit_price)}원</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;color:var(--accent-teal);font-weight:600;">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function toggleTransaction(idx) {
|
||||
const card = document.getElementById(`tx-${idx}`);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 로드 ────────────────
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>`;
|
||||
|
||||
let url = `/api/sales-detail?days=${period}&barcode=${barcodeFilter}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
rawData = data.items;
|
||||
groupedData = groupByDate(rawData);
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTxCount').textContent = groupedData.length.toLocaleString();
|
||||
document.getElementById('statItemCount').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
|
||||
render();
|
||||
} else {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<div>오류: ${data.error}</div>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">❌</div>
|
||||
<div>데이터 로드 실패</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 엔터키 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') loadSalesData();
|
||||
});
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<title>포인트 적립 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -548,7 +555,7 @@
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="privacyConsent" required>
|
||||
<span class="checkmark"></span>
|
||||
<span class="consent-text">개인정보 수집·이용 동의</span>
|
||||
<span class="consent-text"><a href="/privacy" target="_blank" style="color: #6366f1; text-decoration: underline;">개인정보 수집·이용</a> 동의</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -562,19 +569,26 @@
|
||||
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e9ecef; z-index: 0;"></div>
|
||||
</div>
|
||||
|
||||
<a href="/claim/kakao/start?t={{ request.args.get('t') }}"
|
||||
<button type="button" onclick="kakaoLogin()"
|
||||
style="display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
width: 100%; padding: 16px; background: #FEE500; color: #191919;
|
||||
border: none; border-radius: 14px; font-size: 16px; font-weight: 600;
|
||||
text-decoration: none; letter-spacing: -0.3px; transition: all 0.2s ease;
|
||||
letter-spacing: -0.3px; transition: all 0.2s ease; cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 적립하기
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<div class="alert error" id="alertMsg"></div>
|
||||
|
||||
<div style="text-align: center; padding: 16px 0 8px;">
|
||||
<a href="/privacy" target="_blank"
|
||||
style="color: #adb5bd; font-size: 12px; text-decoration: none; letter-spacing: -0.2px;">
|
||||
개인정보 처리방침
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -594,6 +608,17 @@
|
||||
<a href="/" class="btn-secondary">홈으로</a>
|
||||
<a href="#" class="btn-primary" id="btnMyPage">내역 보기</a>
|
||||
</div>
|
||||
|
||||
<!-- PWA 설치 유도 배너 -->
|
||||
<div id="installBanner" style="display:none; margin-top:24px; padding:16px 20px; background:#f8f9fa; border-radius:14px; text-align:left;">
|
||||
<div style="font-size:14px; font-weight:700; color:#212529; margin-bottom:6px; letter-spacing:-0.3px;">
|
||||
홈 화면에 추가하면 더 편해요!
|
||||
</div>
|
||||
<div id="installDesc" style="font-size:13px; color:#868e96; line-height:1.6; letter-spacing:-0.2px;"></div>
|
||||
<button id="installBtn" style="display:none; margin-top:10px; width:100%; padding:12px; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; border:none; border-radius:10px; font-size:14px; font-weight:600; cursor:pointer; letter-spacing:-0.2px;">
|
||||
앱 설치하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -707,5 +732,61 @@
|
||||
successScreen.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js"
|
||||
integrity="sha384-DKYJZ8NLiK8MN4/C5P2dtSmLQ4KwPaoqAfyA/DfmOGfnSNqoARCbb2xl4Kh1v6Q"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
// 카카오 JS SDK 초기화
|
||||
if (typeof Kakao !== 'undefined') {
|
||||
Kakao.init('3d1e098107157c5021b73bd5ab48600f');
|
||||
}
|
||||
|
||||
function kakaoLogin() {
|
||||
if (typeof Kakao !== 'undefined' && Kakao.isInitialized()) {
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: 'https://mile.0bin.in/claim/kakao/callback',
|
||||
state: '{{ kakao_state }}'
|
||||
});
|
||||
} else {
|
||||
// JS SDK 로드 실패 시 서버 리다이렉트 폴백
|
||||
window.location.href = '/claim/kakao/start?t={{ request.args.get("t") }}';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
// PWA 설치 유도
|
||||
(function() {
|
||||
const banner = document.getElementById('installBanner');
|
||||
const desc = document.getElementById('installDesc');
|
||||
const btn = document.getElementById('installBtn');
|
||||
if (!banner) return;
|
||||
|
||||
if (window.matchMedia('(display-mode: standalone)').matches || navigator.standalone) return;
|
||||
|
||||
let deferredPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function(e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
desc.textContent = '다음부터 QR 스캔하면 입력 없이 바로 적립됩니다.';
|
||||
btn.style.display = 'block';
|
||||
banner.style.display = 'block';
|
||||
});
|
||||
btn.addEventListener('click', function() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function() { banner.style.display = 'none'; });
|
||||
}
|
||||
});
|
||||
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isSafari = /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS/.test(navigator.userAgent);
|
||||
if (isIOS && isSafari && !deferredPrompt) {
|
||||
desc.innerHTML = '하단 <strong style="color:#495057;">공유 버튼</strong> ➜ <strong style="color:#495057;">홈 화면에 추가</strong>를 누르면<br>다음부터 QR만 찍으면 바로 적립!';
|
||||
banner.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<title>카카오 적립 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -347,6 +354,13 @@
|
||||
</form>
|
||||
|
||||
<div class="alert error" id="alertMsg"></div>
|
||||
|
||||
<div style="text-align: center; padding: 16px 0 8px;">
|
||||
<a href="/privacy" target="_blank"
|
||||
style="color: #adb5bd; font-size: 12px; text-decoration: none; letter-spacing: -0.2px;">
|
||||
개인정보 처리방침
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -438,5 +452,6 @@
|
||||
document.getElementById('successScreen').style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<title>적립 완료 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -182,6 +189,57 @@
|
||||
<a href="/" class="btn-secondary">홈으로</a>
|
||||
<a href="/my-page?phone={{ phone }}" class="btn-primary">내역 보기</a>
|
||||
</div>
|
||||
|
||||
<!-- PWA 설치 유도 배너 -->
|
||||
<div id="installBanner" style="display:none; margin-top:24px; padding:16px 20px; background:#f8f9fa; border-radius:14px; text-align:left;">
|
||||
<div style="font-size:14px; font-weight:700; color:#212529; margin-bottom:6px; letter-spacing:-0.3px;">
|
||||
홈 화면에 추가하면 더 편해요!
|
||||
</div>
|
||||
<div id="installDesc" style="font-size:13px; color:#868e96; line-height:1.6; letter-spacing:-0.2px;"></div>
|
||||
<button id="installBtn" style="display:none; margin-top:10px; width:100%; padding:12px; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; border:none; border-radius:10px; font-size:14px; font-weight:600; cursor:pointer; letter-spacing:-0.2px;">
|
||||
앱 설치하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
// PWA 설치 유도
|
||||
(function() {
|
||||
const banner = document.getElementById('installBanner');
|
||||
const desc = document.getElementById('installDesc');
|
||||
const btn = document.getElementById('installBtn');
|
||||
|
||||
// 이미 PWA로 실행 중이면 표시 안 함
|
||||
if (window.matchMedia('(display-mode: standalone)').matches || navigator.standalone) return;
|
||||
|
||||
let deferredPrompt = null;
|
||||
|
||||
// Android Chrome: beforeinstallprompt 이벤트
|
||||
window.addEventListener('beforeinstallprompt', function(e) {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
desc.textContent = '다음부터 QR 스캔하면 입력 없이 바로 적립됩니다.';
|
||||
btn.style.display = 'block';
|
||||
banner.style.display = 'block';
|
||||
});
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
deferredPrompt.userChoice.then(function() { banner.style.display = 'none'; });
|
||||
}
|
||||
});
|
||||
|
||||
// iOS Safari 감지
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isSafari = /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS/.test(navigator.userAgent);
|
||||
if (isIOS && isSafari && !deferredPrompt) {
|
||||
desc.innerHTML = '하단 <strong style="color:#495057;">공유 버튼</strong> ➜ <strong style="color:#495057;">홈 화면에 추가</strong>를 누르면<br>다음부터 QR만 찍으면 바로 적립!';
|
||||
banner.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<title>오류 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -98,5 +105,6 @@
|
||||
<a href="/" class="btn-home">홈으로 이동</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
451
backend/templates/index.html
Normal file
451
backend/templates/index.html
Normal file
@@ -0,0 +1,451 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<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, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 48px 24px 56px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 20px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
/* 로그인 상태 배지 */
|
||||
.user-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px;
|
||||
margin-top: -20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* QR 스캔 버튼 */
|
||||
.scan-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.scan-desc {
|
||||
font-size: 13px;
|
||||
color: #868e96;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.btn-scan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-scan:active { transform: scale(0.98); }
|
||||
|
||||
.btn-scan svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
||||
/* 메뉴 카드들 */
|
||||
.menu-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-card:active {
|
||||
transform: scale(0.98);
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon.purple { background: rgba(99, 102, 241, 0.1); }
|
||||
.menu-icon.yellow { background: rgba(254, 229, 0, 0.3); }
|
||||
.menu-icon.green { background: rgba(16, 185, 129, 0.1); }
|
||||
|
||||
.menu-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.menu-desc {
|
||||
font-size: 13px;
|
||||
color: #868e96;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #ced4da;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* QR 스캐너 모달 */
|
||||
.scanner-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.95);
|
||||
z-index: 1000;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scanner-overlay.open { display: flex; }
|
||||
|
||||
.scanner-header {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
padding: 16px 24px;
|
||||
padding-top: calc(16px + env(safe-area-inset-top, 0px));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scanner-title {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-close-scanner {
|
||||
color: #ffffff;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#qr-reader {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanner-hint {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 14px;
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #adb5bd;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.footer-link + .footer-link { margin-left: 16px; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero {
|
||||
padding-top: calc(48px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="hero">
|
||||
<div class="hero-logo">💊</div>
|
||||
<div class="hero-title">청춘약국 마일리지</div>
|
||||
<div class="hero-desc">구매금액의 3%를 포인트로 돌려드려요</div>
|
||||
{% if logged_in %}
|
||||
<div class="user-badge">
|
||||
<span>{{ logged_in_name }}님 로그인 중</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- QR 스캔 카드 -->
|
||||
<div class="scan-card">
|
||||
<div class="scan-title">영수증 QR 스캔</div>
|
||||
<div class="scan-desc">
|
||||
{% if logged_in %}
|
||||
카메라로 QR을 찍으면 바로 적립됩니다
|
||||
{% else %}
|
||||
영수증의 QR 코드를 스캔하여 적립하세요
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn-scan" id="btnOpenScanner">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 11h2V9H3v2zm0 2h2v-2H3v2zm4 8h2v-2H7v2zM3 15h2v-2H3v2zm8-12h-2v2h2V3zm4 0v2h2V3h-2zm-4 18h2v-2h-2v2zM3 3v2h2V3H3zm0 16h2v-2H3v2zm8-16h-2v2h2V3zm8 4h2V5h-2v2zm0 4h2V9h-2v2zm0-8v2h2V3h-2zm0 12h2v-2h-2v2zm0 4v-2h-2v2h2zm-4 0h2v-2h-2v2zm-8-8h10v-2H7v2zm0 4h6v-2H7v2zm4-8h2V7h-2v2zM3 7h2V5H3v2z"/></svg>
|
||||
QR 코드 스캔하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 카드들 -->
|
||||
<div class="menu-grid">
|
||||
{% if logged_in %}
|
||||
<a href="/my-page?phone={{ logged_in_phone }}" class="menu-card">
|
||||
<div class="menu-icon purple">📊</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">내 마일리지</div>
|
||||
<div class="menu-desc">적립 내역 및 포인트 확인</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/logout" class="menu-card">
|
||||
<div class="menu-icon green">🔓</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">로그아웃</div>
|
||||
<div class="menu-desc">다른 계정으로 전환</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/my-page/kakao/start" class="menu-card">
|
||||
<div class="menu-icon yellow">
|
||||
<svg width="22" height="22" viewBox="0 0 20 20" fill="none"><path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/></svg>
|
||||
</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">카카오로 시작하기</div>
|
||||
<div class="menu-desc">로그인하면 QR만 찍으면 바로 적립</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/signup" class="menu-card">
|
||||
<div class="menu-icon green">📝</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">회원가입</div>
|
||||
<div class="menu-desc">전화번호 + 이름으로 간편 가입</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/my-page" class="menu-card">
|
||||
<div class="menu-icon purple">📊</div>
|
||||
<div class="menu-text">
|
||||
<div class="menu-title">마일리지 조회</div>
|
||||
<div class="menu-desc">전화번호로 적립 내역 확인</div>
|
||||
</div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="/privacy" class="footer-link">개인정보 처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR 스캐너 모달 -->
|
||||
<div class="scanner-overlay" id="scannerOverlay">
|
||||
<div class="scanner-header">
|
||||
<div class="scanner-title">QR 코드 스캔</div>
|
||||
<button class="btn-close-scanner" id="btnCloseScanner">✕</button>
|
||||
</div>
|
||||
<div id="qr-reader"></div>
|
||||
<div class="scanner-hint">영수증의 QR 코드를 카메라에 비춰주세요</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
(function() {
|
||||
const overlay = document.getElementById('scannerOverlay');
|
||||
const btnOpen = document.getElementById('btnOpenScanner');
|
||||
const btnClose = document.getElementById('btnCloseScanner');
|
||||
let scanner = null;
|
||||
let scanning = false;
|
||||
|
||||
btnOpen.addEventListener('click', startScanner);
|
||||
btnClose.addEventListener('click', stopScanner);
|
||||
|
||||
async function startScanner() {
|
||||
overlay.classList.add('open');
|
||||
|
||||
if (scanner) {
|
||||
scanner.clear();
|
||||
}
|
||||
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanning = true;
|
||||
|
||||
try {
|
||||
await scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
onScanSuccess,
|
||||
function() {} // ignore scan errors
|
||||
);
|
||||
} catch (err) {
|
||||
document.querySelector('.scanner-hint').textContent =
|
||||
'카메라 접근이 거부되었습니다. 설정에서 카메라 권한을 허용해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (scanner && scanning) {
|
||||
try { await scanner.stop(); } catch(e) {}
|
||||
scanning = false;
|
||||
}
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
// QR 코드 URL에서 /claim?t= 파라미터 추출
|
||||
try {
|
||||
let url;
|
||||
if (decodedText.startsWith('http')) {
|
||||
url = new URL(decodedText);
|
||||
// 같은 도메인이거나 claim 경로면 이동
|
||||
if (url.pathname.startsWith('/claim')) {
|
||||
stopScanner();
|
||||
window.location.href = url.pathname + url.search;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 직접 t= 값인 경우 (예: "12345:abc123")
|
||||
if (decodedText.includes(':') && !decodedText.startsWith('http')) {
|
||||
stopScanner();
|
||||
window.location.href = '/claim?t=' + encodeURIComponent(decodedText);
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 인식은 됐지만 유효하지 않은 QR
|
||||
document.querySelector('.scanner-hint').textContent =
|
||||
'유효한 영수증 QR 코드가 아닙니다. 다시 시도해주세요.';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
798
backend/templates/kiosk.html
Normal file
798
backend/templates/kiosk.html
Normal file
@@ -0,0 +1,798 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<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;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #0f0b2e;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-logo { color: #fff; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.header-time { color: rgba(255,255,255,0.7); font-size: 15px; }
|
||||
|
||||
/* ── 메인 ── */
|
||||
.main {
|
||||
height: calc(100vh - 56px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.screen { display: none; width: 100%; }
|
||||
.screen.active { display: flex; }
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
대기 화면 - 슬라이드쇼 + 브랜딩
|
||||
══════════════════════════════════════ */
|
||||
.idle-screen {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 슬라이드 컨테이너 */
|
||||
.slides-wrapper {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
position: relative;
|
||||
height: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
opacity: 0;
|
||||
transform: translateX(60px);
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.slide.exit {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px);
|
||||
}
|
||||
|
||||
.slide-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 52px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
}
|
||||
.slide-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.slide-title {
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.slide-desc {
|
||||
font-size: 23px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
line-height: 1.6;
|
||||
max-width: 520px;
|
||||
}
|
||||
.slide-highlight {
|
||||
display: inline-block;
|
||||
padding: 12px 32px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 14px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 슬라이드별 색상 */
|
||||
.slide-1 .slide-icon { background: linear-gradient(135deg, #fbbf24, #f59e0b); }
|
||||
.slide-1 .slide-tag { background: #fef3c7; color: #92400e; }
|
||||
.slide-2 .slide-icon { background: linear-gradient(135deg, #34d399, #10b981); }
|
||||
.slide-2 .slide-tag { background: #d1fae5; color: #065f46; }
|
||||
.slide-3 .slide-icon { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
|
||||
.slide-3 .slide-tag { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
/* 인디케이터 */
|
||||
.slide-dots {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.slide-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.slide-dot.active {
|
||||
width: 32px;
|
||||
border-radius: 5px;
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
/* 브랜딩 영역 */
|
||||
.branding {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
.branding-divider {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.branding-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.branding-item span.icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
적립 화면
|
||||
══════════════════════════════════════ */
|
||||
.claim-screen {
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.claim-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.claim-info-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 36px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.claim-amount-label { font-size: 15px; color: #6b7280; margin-bottom: 4px; }
|
||||
.claim-amount { font-size: 36px; font-weight: 900; color: #1e1b4b; letter-spacing: -1px; }
|
||||
.claim-points { font-size: 20px; color: #6366f1; font-weight: 700; margin-top: 8px; }
|
||||
/* 품목 카드 */
|
||||
.items-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.items-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.items-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.item-row:last-child { border-bottom: none; }
|
||||
.item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.item-qty { color: #9ca3af; font-size: 13px; flex-shrink: 0; }
|
||||
.item-total { font-weight: 600; color: #6366f1; flex-shrink: 0; min-width: 60px; text-align: right; }
|
||||
|
||||
.qr-container {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
}
|
||||
.qr-container img { width: 200px; height: 200px; }
|
||||
.qr-hint { font-size: 15px; color: #6b7280; text-align: center; margin-top: 12px; }
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.divider-line { width: 2px; height: 80px; background: #e5e7eb; }
|
||||
.divider-text { font-size: 16px; color: #9ca3af; font-weight: 500; }
|
||||
|
||||
.claim-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.phone-section-title { font-size: 20px; font-weight: 700; color: #1e1b4b; }
|
||||
|
||||
/* 전화번호 디스플레이 */
|
||||
.phone-display-wrap {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: #fff;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.2s;
|
||||
min-height: 64px;
|
||||
}
|
||||
.phone-display-wrap.focus { border-color: #6366f1; }
|
||||
.phone-display-wrap.error { border-color: #ef4444; animation: shake 0.3s; }
|
||||
.phone-prefix {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #9ca3af;
|
||||
letter-spacing: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.phone-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1e1b4b;
|
||||
letter-spacing: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.phone-number.placeholder { color: #d1d5db; }
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-8px); }
|
||||
75% { transform: translateX(8px); }
|
||||
}
|
||||
|
||||
/* 숫자 패드 */
|
||||
.numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; max-width: 360px; }
|
||||
.numpad-btn {
|
||||
background: #fff;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1e1b4b;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.numpad-btn:active { background: #6366f1; color: #fff; border-color: #6366f1; transform: scale(0.95); }
|
||||
.numpad-btn.delete { background: #fef2f2; border-color: #fecaca; color: #ef4444; font-size: 18px; }
|
||||
.numpad-btn.delete:active { background: #ef4444; color: #fff; }
|
||||
.numpad-btn.clear { background: #f5f5f5; border-color: #d4d4d4; color: #737373; font-size: 14px; }
|
||||
.numpad-btn.clear:active { background: #737373; color: #fff; }
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.submit-btn:active { transform: scale(0.97); }
|
||||
.submit-btn:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||
|
||||
.error-msg { color: #ef4444; font-size: 14px; font-weight: 500; min-height: 20px; }
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
성공 화면
|
||||
══════════════════════════════════════ */
|
||||
.success-screen {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.success-icon {
|
||||
width: 120px; height: 120px;
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 60px;
|
||||
animation: pop 0.4s ease-out;
|
||||
}
|
||||
@keyframes pop { 0% { transform: scale(0); } 80% { transform: scale(1.1); } 100% { transform: scale(1); } }
|
||||
.success-title { font-size: 36px; font-weight: 900; color: #16a34a; }
|
||||
.success-points { font-size: 48px; font-weight: 900; color: #1e1b4b; letter-spacing: -1px; }
|
||||
.success-balance { font-size: 20px; color: #6b7280; }
|
||||
.success-balance strong { color: #6366f1; font-weight: 700; }
|
||||
.success-countdown { font-size: 15px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); display: none; align-items: center; justify-content: center; z-index: 100; }
|
||||
.loading-overlay.active { display: flex; }
|
||||
.loading-spinner { width: 60px; height: 60px; border: 6px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── 적립/성공 화면 배경 밝게 ── */
|
||||
.claim-screen, .success-screen { background: #f5f7fa; border-radius: 24px; padding: 32px; }
|
||||
|
||||
/* ── 반응형: 세로 모니터 (portrait, 폭 700px 이상) ── */
|
||||
@media (orientation: portrait) and (min-width: 700px) {
|
||||
.main { padding: 32px; }
|
||||
|
||||
/* 적립 화면: 세로 스택, 공간 활용 */
|
||||
.claim-screen {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 48px;
|
||||
max-width: 640px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.claim-left {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
.claim-info-card { width: 100%; max-width: 480px; }
|
||||
.qr-container { align-self: center; }
|
||||
.items-card { width: 100%; max-width: 480px; max-height: 160px; }
|
||||
.qr-container img { width: 160px; height: 160px; }
|
||||
|
||||
.divider { flex-direction: row; }
|
||||
.divider-line { width: 60px; height: 2px; }
|
||||
|
||||
.claim-right { width: 100%; align-items: center; }
|
||||
.phone-display-wrap { max-width: 440px; }
|
||||
.phone-prefix { font-size: 30px; }
|
||||
.phone-number { font-size: 30px; white-space: nowrap; }
|
||||
.numpad { max-width: 440px; }
|
||||
.submit-btn { max-width: 440px; }
|
||||
|
||||
/* 슬라이드 더 크게 */
|
||||
.slides-wrapper { height: 420px; }
|
||||
.slide-icon { width: 110px; height: 110px; font-size: 56px; }
|
||||
.slide-title { font-size: 34px; }
|
||||
.slide-desc { font-size: 19px; }
|
||||
.slide-highlight { font-size: 16px; padding: 12px 32px; }
|
||||
}
|
||||
|
||||
/* ── 반응형: 좁은 화면 (모바일) ── */
|
||||
@media (max-width: 700px) {
|
||||
.claim-screen { flex-direction: column; gap: 24px; padding: 20px; }
|
||||
.divider { flex-direction: row; }
|
||||
.divider-line { width: 60px; height: 2px; }
|
||||
.claim-amount { font-size: 28px; }
|
||||
.qr-container img { width: 150px; height: 150px; }
|
||||
.branding { flex-direction: column; gap: 12px; }
|
||||
.branding-divider { display: none; }
|
||||
.slide-title { font-size: 24px; }
|
||||
.slides-wrapper { height: 320px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="header">
|
||||
<div class="header-logo">청춘약국 마일리지</div>
|
||||
<div class="header-time" id="headerTime"></div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 -->
|
||||
<div class="main">
|
||||
|
||||
<!-- 1. 대기 화면 (슬라이드쇼 + 브랜딩) -->
|
||||
<div class="screen idle-screen active" id="idleScreen">
|
||||
<div class="slides-wrapper">
|
||||
<!-- 슬라이드 1: 동물의약품 -->
|
||||
<div class="slide slide-1 active" data-slide="0">
|
||||
<div class="slide-icon">🐾</div>
|
||||
<div class="slide-tag">반려동물 케어</div>
|
||||
<div class="slide-title">우리 아이 약도<br>마일리지로 구매!</div>
|
||||
<div class="slide-desc">
|
||||
청춘약국 마일리지로<br>
|
||||
동물의약품을 구매할 수 있어요
|
||||
</div>
|
||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 슬라이드 2: 건기식 -->
|
||||
<div class="slide slide-2" data-slide="1">
|
||||
<div class="slide-icon">🌿</div>
|
||||
<div class="slide-tag">건강기능식품</div>
|
||||
<div class="slide-title">팜큐 건강기능식품<br>마일리지로 챙기세요</div>
|
||||
<div class="slide-desc">
|
||||
비타민, 유산균, 오메가3 등<br>
|
||||
엄선된 건기식을 포인트로 구매!
|
||||
</div>
|
||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
||||
</div>
|
||||
|
||||
<!-- 슬라이드 3: 부외품 -->
|
||||
<div class="slide slide-3" data-slide="2">
|
||||
<div class="slide-icon">💧</div>
|
||||
<div class="slide-tag">약국 용품</div>
|
||||
<div class="slide-title">투약병, 부외품도<br>마일리지로 OK</div>
|
||||
<div class="slide-desc">
|
||||
물약병, 연고통, 밴드 등<br>
|
||||
필요한 약국 용품을 포인트로!
|
||||
</div>
|
||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인디케이터 -->
|
||||
<div class="slide-dots">
|
||||
<div class="slide-dot active" data-dot="0"></div>
|
||||
<div class="slide-dot" data-dot="1"></div>
|
||||
<div class="slide-dot" data-dot="2"></div>
|
||||
</div>
|
||||
|
||||
<!-- 브랜딩 -->
|
||||
<div class="branding">
|
||||
<div class="branding-item"><span class="icon">🤖</span> AI 에이전트 개발 약국</div>
|
||||
<div class="branding-divider"></div>
|
||||
<div class="branding-item"><span class="icon">💊</span> 복약안내에 진심인 약사</div>
|
||||
<div class="branding-divider"></div>
|
||||
<div class="branding-item"><span class="icon">📱</span> 모바일 약료 시스템 도입</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 적립 화면 -->
|
||||
<div class="screen claim-screen" id="claimScreen">
|
||||
<div class="claim-left">
|
||||
<div class="claim-info-card">
|
||||
<div class="claim-amount-label">결제 금액</div>
|
||||
<div class="claim-amount" id="claimAmount">0원</div>
|
||||
<div class="claim-points">적립 <span id="claimPoints">0</span>P</div>
|
||||
</div>
|
||||
<div class="items-card" id="itemsCard" style="display:none;">
|
||||
<div class="items-title">구매 품목</div>
|
||||
<div class="items-list" id="itemsList"></div>
|
||||
</div>
|
||||
<div class="qr-container" id="qrContainer" style="display:none;">
|
||||
<img id="qrImage" src="" alt="QR Code">
|
||||
<div class="qr-hint">휴대폰으로 QR을 스캔하여<br>적립할 수도 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" id="dividerEl" style="display:none;">
|
||||
<div class="divider-line"></div>
|
||||
<div class="divider-text">또는</div>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="claim-right">
|
||||
<div class="phone-section-title">전화번호로 적립하기</div>
|
||||
<div class="phone-display-wrap" id="phoneWrap">
|
||||
<span class="phone-prefix">010-</span>
|
||||
<span class="phone-number placeholder" id="phoneDisplay">0000-0000</span>
|
||||
</div>
|
||||
<div class="error-msg" id="errorMsg"></div>
|
||||
<div class="numpad">
|
||||
<button class="numpad-btn" onclick="numPress('1')">1</button>
|
||||
<button class="numpad-btn" onclick="numPress('2')">2</button>
|
||||
<button class="numpad-btn" onclick="numPress('3')">3</button>
|
||||
<button class="numpad-btn" onclick="numPress('4')">4</button>
|
||||
<button class="numpad-btn" onclick="numPress('5')">5</button>
|
||||
<button class="numpad-btn" onclick="numPress('6')">6</button>
|
||||
<button class="numpad-btn" onclick="numPress('7')">7</button>
|
||||
<button class="numpad-btn" onclick="numPress('8')">8</button>
|
||||
<button class="numpad-btn" onclick="numPress('9')">9</button>
|
||||
<button class="numpad-btn clear" onclick="numClear()">전체삭제</button>
|
||||
<button class="numpad-btn" onclick="numPress('0')">0</button>
|
||||
<button class="numpad-btn delete" onclick="numDelete()">← 삭제</button>
|
||||
</div>
|
||||
<button class="submit-btn" id="submitBtn" onclick="submitClaim()" disabled>적립하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 성공 화면 -->
|
||||
<div class="screen success-screen" id="successScreen">
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="success-title">적립 완료!</div>
|
||||
<div class="success-points" id="successPoints">0P</div>
|
||||
<div class="success-balance">총 잔액: <strong id="successBalance">0P</strong></div>
|
||||
<div class="success-countdown" id="successCountdown"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── 상태 ──
|
||||
let phoneNumber = ''; // 010 이후 8자리만 관리
|
||||
let currentSession = null;
|
||||
let pollingInterval = null;
|
||||
let successTimeout = null;
|
||||
|
||||
// ── 슬라이드쇼 ──
|
||||
let currentSlide = 0;
|
||||
const TOTAL_SLIDES = 3;
|
||||
const SLIDE_INTERVAL = 4000; // 4초
|
||||
let slideTimer = null;
|
||||
|
||||
function nextSlide() {
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
const dots = document.querySelectorAll('.slide-dot');
|
||||
|
||||
// 현재 슬라이드 exit
|
||||
slides[currentSlide].classList.remove('active');
|
||||
slides[currentSlide].classList.add('exit');
|
||||
dots[currentSlide].classList.remove('active');
|
||||
|
||||
// 다음 슬라이드
|
||||
currentSlide = (currentSlide + 1) % TOTAL_SLIDES;
|
||||
|
||||
// exit 클래스 제거 (transition 후)
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.slide.exit').forEach(s => s.classList.remove('exit'));
|
||||
}, 600);
|
||||
|
||||
slides[currentSlide].classList.add('active');
|
||||
dots[currentSlide].classList.add('active');
|
||||
}
|
||||
|
||||
function startSlideshow() {
|
||||
if (slideTimer) clearInterval(slideTimer);
|
||||
slideTimer = setInterval(nextSlide, SLIDE_INTERVAL);
|
||||
}
|
||||
|
||||
function stopSlideshow() {
|
||||
if (slideTimer) { clearInterval(slideTimer); slideTimer = null; }
|
||||
}
|
||||
|
||||
startSlideshow();
|
||||
|
||||
// ── 화면 전환 ──
|
||||
function showScreen(name) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(name + 'Screen').classList.add('active');
|
||||
|
||||
if (name === 'idle') {
|
||||
document.body.style.background = '#0f0b2e';
|
||||
startSlideshow();
|
||||
} else {
|
||||
document.body.style.background = '#f5f7fa';
|
||||
stopSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 시계 ──
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const m = String(now.getMinutes()).padStart(2, '0');
|
||||
document.getElementById('headerTime').textContent = h + ':' + m;
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 30000);
|
||||
|
||||
// ── 전화번호 (010 고정, 나머지 8자리) ──
|
||||
function formatSuffix(num) {
|
||||
if (num.length <= 4) return num + '●'.repeat(Math.max(0, 4 - num.length)) + '-' + '●●●●';
|
||||
return num.slice(0, 4) + '-' + num.slice(4) + '●'.repeat(Math.max(0, 8 - num.length));
|
||||
}
|
||||
|
||||
function updatePhoneDisplay() {
|
||||
const display = document.getElementById('phoneDisplay');
|
||||
const wrap = document.getElementById('phoneWrap');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
|
||||
if (phoneNumber.length === 0) {
|
||||
display.textContent = '0000-0000';
|
||||
display.className = 'phone-number placeholder';
|
||||
wrap.classList.remove('focus');
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
display.textContent = formatSuffix(phoneNumber);
|
||||
display.className = 'phone-number';
|
||||
wrap.classList.add('focus');
|
||||
btn.disabled = phoneNumber.length < 8;
|
||||
}
|
||||
|
||||
wrap.classList.remove('error');
|
||||
document.getElementById('errorMsg').textContent = '';
|
||||
}
|
||||
|
||||
function numPress(digit) {
|
||||
if (phoneNumber.length >= 8) return;
|
||||
phoneNumber += digit;
|
||||
updatePhoneDisplay();
|
||||
}
|
||||
|
||||
function numDelete() {
|
||||
phoneNumber = phoneNumber.slice(0, -1);
|
||||
updatePhoneDisplay();
|
||||
}
|
||||
|
||||
function numClear() {
|
||||
phoneNumber = '';
|
||||
updatePhoneDisplay();
|
||||
}
|
||||
|
||||
// ── 적립 ──
|
||||
async function submitClaim() {
|
||||
const fullPhone = '010' + phoneNumber;
|
||||
if (fullPhone.length < 11) {
|
||||
document.getElementById('phoneWrap').classList.add('error');
|
||||
document.getElementById('errorMsg').textContent = '전화번호를 정확히 입력해주세요';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/kiosk/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: fullPhone })
|
||||
});
|
||||
const data = await resp.json();
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
|
||||
if (data.success) {
|
||||
showSuccess(data.points, data.balance);
|
||||
} else {
|
||||
document.getElementById('phoneWrap').classList.add('error');
|
||||
document.getElementById('errorMsg').textContent = data.message || '적립 실패';
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
document.getElementById('errorMsg').textContent = '서버 연결 실패';
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 성공 화면 ──
|
||||
function showSuccess(points, balance) {
|
||||
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
|
||||
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
|
||||
showScreen('success');
|
||||
|
||||
let countdown = 5;
|
||||
const el = document.getElementById('successCountdown');
|
||||
el.textContent = countdown + '초 후 처음 화면으로 돌아갑니다';
|
||||
|
||||
if (successTimeout) clearInterval(successTimeout);
|
||||
successTimeout = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) { clearInterval(successTimeout); resetToIdle(); }
|
||||
else { el.textContent = countdown + '초 후 처음 화면으로 돌아갑니다'; }
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function resetToIdle() {
|
||||
phoneNumber = '';
|
||||
currentSession = null;
|
||||
updatePhoneDisplay();
|
||||
showScreen('idle');
|
||||
}
|
||||
|
||||
// ── 폴링 ──
|
||||
async function pollKioskSession() {
|
||||
try {
|
||||
const resp = await fetch('/api/kiosk/current');
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.active && !currentSession) {
|
||||
currentSession = data;
|
||||
phoneNumber = '';
|
||||
updatePhoneDisplay();
|
||||
|
||||
document.getElementById('claimAmount').textContent = data.amount.toLocaleString() + '원';
|
||||
document.getElementById('claimPoints').textContent = data.points.toLocaleString();
|
||||
|
||||
// 품목 목록 표시
|
||||
const itemsCard = document.getElementById('itemsCard');
|
||||
const itemsList = document.getElementById('itemsList');
|
||||
if (data.items && data.items.length > 0) {
|
||||
itemsList.innerHTML = data.items.map(item =>
|
||||
`<div class="item-row">
|
||||
<span class="item-name">${item.name}</span>
|
||||
<span class="item-qty">${item.qty}개</span>
|
||||
<span class="item-total">${item.total.toLocaleString()}원</span>
|
||||
</div>`
|
||||
).join('');
|
||||
itemsCard.style.display = '';
|
||||
} else {
|
||||
itemsCard.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data.qr_url) {
|
||||
document.getElementById('qrImage').src =
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' +
|
||||
encodeURIComponent(data.qr_url);
|
||||
document.getElementById('qrContainer').style.display = '';
|
||||
document.getElementById('dividerEl').style.display = '';
|
||||
} else {
|
||||
document.getElementById('qrContainer').style.display = 'none';
|
||||
document.getElementById('dividerEl').style.display = 'none';
|
||||
}
|
||||
|
||||
showScreen('claim');
|
||||
} else if (!data.active && currentSession) {
|
||||
resetToIdle();
|
||||
}
|
||||
} catch (err) { /* 다음 폴링에서 재시도 */ }
|
||||
}
|
||||
|
||||
pollingInterval = setInterval(pollKioskSession, 1000);
|
||||
pollKioskSession();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<title>마이페이지 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -271,11 +278,14 @@
|
||||
<div class="app-container">
|
||||
<div class="header-top">
|
||||
<div class="header-title">마이페이지</div>
|
||||
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<a href="/my-page" class="btn-logout">다른 번호</a>
|
||||
<a href="/my-page/kakao/start" class="btn-logout" style="display: flex; align-items: center; gap: 4px; background: #FEE500; color: #191919; padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 600;">
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="none"><path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/></svg>
|
||||
카카오 조회
|
||||
카카오
|
||||
</a>
|
||||
<a href="/logout" class="btn-logout" style="font-size: 12px; opacity: 0.7;">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-profile">
|
||||
@@ -382,5 +392,156 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- AI 추천 바텀시트 -->
|
||||
<div id="rec-sheet" style="display:none;">
|
||||
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
|
||||
<!-- 드래그 핸들 영역 -->
|
||||
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
</div>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec('dismissed')" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec('interested')" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes recFadeIn { from{opacity:0} to{opacity:1} }
|
||||
@keyframes recSlideUp { from{transform:translate(-50%,100%)} to{transform:translate(-50%,0)} }
|
||||
@keyframes recSlideDown { from{transform:translate(-50%,0)} to{transform:translate(-50%,100%)} }
|
||||
</style>
|
||||
<script>
|
||||
let _recId = null;
|
||||
|
||||
// ── 드래그 닫기 ──
|
||||
(function() {
|
||||
let startY = 0, currentY = 0, isDragging = false;
|
||||
const DISMISS_THRESHOLD = 80;
|
||||
|
||||
function getContent() { return document.getElementById('rec-content'); }
|
||||
function getBackdrop() { return document.getElementById('rec-backdrop'); }
|
||||
|
||||
function onStart(y) {
|
||||
const c = getContent();
|
||||
if (!c) return;
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
currentY = 0;
|
||||
c.style.animation = 'none';
|
||||
c.style.transition = 'none';
|
||||
}
|
||||
function onMove(y) {
|
||||
if (!isDragging) return;
|
||||
const c = getContent();
|
||||
const b = getBackdrop();
|
||||
currentY = Math.max(0, y - startY); // 아래로만
|
||||
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
|
||||
// 배경 투명도도 같이
|
||||
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
|
||||
b.style.background = 'rgba(0,0,0,' + opacity + ')';
|
||||
}
|
||||
function onEnd() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const c = getContent();
|
||||
if (currentY > DISMISS_THRESHOLD) {
|
||||
// 충분히 내렸으면 닫기
|
||||
c.style.transition = 'transform .25s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
getBackdrop().style.transition = 'opacity .25s';
|
||||
getBackdrop().style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
document.getElementById('rec-sheet').style.display = 'none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 250);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
} else {
|
||||
// 복귀
|
||||
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
|
||||
c.style.transform = 'translate(-50%, 0)';
|
||||
getBackdrop().style.transition = 'background .25s';
|
||||
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
|
||||
setTimeout(function() { c.style.transition = ''; }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('rec-content');
|
||||
if (!el) return;
|
||||
|
||||
// 터치 (모바일)
|
||||
el.addEventListener('touchstart', function(e) {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, {passive: true});
|
||||
el.addEventListener('touchmove', function(e) {
|
||||
if (isDragging && currentY > 0) e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, {passive: false});
|
||||
el.addEventListener('touchend', onEnd);
|
||||
|
||||
// 마우스 (데스크톱 테스트용)
|
||||
el.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
onStart(e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) onMove(e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 추천 로드 ──
|
||||
window.addEventListener('load', function() {
|
||||
{% if user_id %}
|
||||
setTimeout(async function() {
|
||||
try {
|
||||
const res = await fetch('/api/recommendation/{{ user_id }}');
|
||||
const data = await res.json();
|
||||
if (data.success && data.has_recommendation) {
|
||||
_recId = data.recommendation.id;
|
||||
document.getElementById('rec-message').textContent = data.recommendation.message;
|
||||
document.getElementById('rec-product').textContent = data.recommendation.product;
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[AI추천] 에러:', e);
|
||||
}
|
||||
}, 1500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function dismissRec(action) {
|
||||
action = action || 'dismissed';
|
||||
const c = document.getElementById('rec-content');
|
||||
const b = document.getElementById('rec-backdrop');
|
||||
c.style.transition = 'transform .3s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
b.style.opacity = '0';
|
||||
b.style.transition = 'opacity .3s';
|
||||
setTimeout(function(){
|
||||
document.getElementById('rec-sheet').style.display='none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 300);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action: action})
|
||||
}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<title>마이페이지 - 청춘약국</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -173,13 +180,13 @@
|
||||
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e9ecef; z-index: 0;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 카카오 로그인 버튼 -->
|
||||
<a href="/my-page/kakao/start" style="display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 18px; background: #FEE500; color: #191919; border: none; border-radius: 14px; font-size: 17px; font-weight: 700; cursor: pointer; letter-spacing: -0.3px; text-decoration: none; transition: all 0.2s ease;">
|
||||
<!-- 카카오 로그인 버튼 (JS SDK) -->
|
||||
<button type="button" onclick="kakaoLogin()" style="display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 18px; background: #FEE500; color: #191919; border: none; border-radius: 14px; font-size: 17px; font-weight: 700; cursor: pointer; letter-spacing: -0.3px; transition: all 0.2s ease;">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 조회하기
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<a href="/" class="btn-back">← 홈으로</a>
|
||||
</div>
|
||||
@@ -206,5 +213,23 @@
|
||||
|
||||
phoneInput.focus();
|
||||
</script>
|
||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js"
|
||||
integrity="sha384-DKYJZ8NLiK8MN4/C5P2dtSmLQ4KwPaoqAfyA/DfmOGfnSNqoARCbb2xl4Kh1v6Q"
|
||||
crossorigin="anonymous"></script>
|
||||
<script>
|
||||
if (typeof Kakao !== 'undefined') Kakao.init('3d1e098107157c5021b73bd5ab48600f');
|
||||
|
||||
function kakaoLogin() {
|
||||
if (typeof Kakao !== 'undefined' && Kakao.isInitialized()) {
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: 'https://mile.0bin.in/claim/kakao/callback',
|
||||
state: '{{ kakao_state }}'
|
||||
});
|
||||
} else {
|
||||
window.location.href = '/my-page/kakao/start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
300
backend/templates/privacy.html
Normal file
300
backend/templates/privacy.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<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, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin: 28px 0 12px 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0 12px 0;
|
||||
}
|
||||
|
||||
li {
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #495057;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-req { background: #fff0f0; color: #e03131; }
|
||||
.badge-opt { background: #f0f4ff; color: #6366f1; }
|
||||
|
||||
.effective-date {
|
||||
color: #868e96;
|
||||
font-size: 13px;
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
height: calc(56px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="header">
|
||||
<div class="header-title">개인정보 처리방침</div>
|
||||
<a href="javascript:history.back()" class="btn-back">돌아가기</a>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>청춘약국(이하 "약국")은 「개인정보 보호법」에 따라 고객의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 다음과 같이 개인정보 처리방침을 수립·공개합니다.</p>
|
||||
|
||||
<div class="section-title">1. 수집하는 개인정보 항목 및 수집 방법</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>수집 방법</th>
|
||||
<th>수집 항목</th>
|
||||
<th>필수/선택</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>직접 입력<br>(회원가입)</td>
|
||||
<td>전화번호, 이름</td>
|
||||
<td><span class="badge badge-req">필수</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>직접 입력<br>(회원가입)</td>
|
||||
<td>생년월일</td>
|
||||
<td><span class="badge badge-opt">선택</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>카카오 로그인</td>
|
||||
<td>카카오 계정 식별자(ID), 닉네임, 프로필 이미지, 이메일, 이름, 전화번호, 생년월일</td>
|
||||
<td><span class="badge badge-req">필수</span> / <span class="badge badge-opt">선택</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>자동 수집</td>
|
||||
<td>구매 내역(품목명, 수량, 금액, 일시)</td>
|
||||
<td><span class="badge badge-req">필수</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">2. 개인정보의 수집 및 이용 목적</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>수집 항목</th>
|
||||
<th>이용 목적</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>전화번호</td>
|
||||
<td>마일리지 적립 계정의 고유 식별자, 포인트 조회 시 본인 확인, 약국 방문 시 포인트 사용을 위한 본인 확인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>이름</td>
|
||||
<td>동명이인 구분 및 약국 방문 시 본인 확인, 적립 내역 안내</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>생년월일</td>
|
||||
<td>생일 기념 포인트 2배 적립 이벤트, 연령대별 맞춤 건강 정보 및 제품 추천 서비스 제공</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>카카오 계정 정보</td>
|
||||
<td>간편 로그인 및 자동 적립 기능 지원, 기존 회원과의 계정 연동</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>구매 내역</td>
|
||||
<td>마일리지 포인트 적립 금액 산정, 적립 내역 조회 서비스 제공</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">3. 개인정보의 보유 및 이용 기간</div>
|
||||
<p>약국은 개인정보 수집 및 이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관계 법령에 의해 보존이 필요한 경우에는 해당 법령에서 정한 기간 동안 보관합니다.</p>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>보존 항목</th>
|
||||
<th>보존 기간</th>
|
||||
<th>근거 법령</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>거래 기록</td>
|
||||
<td>5년</td>
|
||||
<td>부가가치세법</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>회원 정보</td>
|
||||
<td>탈퇴 시까지</td>
|
||||
<td>개인정보 보호법</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">4. 개인정보의 제3자 제공</div>
|
||||
<p>약국은 고객의 개인정보를 제3자에게 제공하지 않습니다. 카카오 로그인은 본인 인증 목적으로만 사용되며, 약국에서 카카오에 고객 정보를 제공하지 않습니다.</p>
|
||||
|
||||
<div class="section-title">5. 개인정보의 파기 절차 및 방법</div>
|
||||
<ul>
|
||||
<li>전자적 파일: 복구 및 재생이 불가능한 기술적 방법으로 삭제</li>
|
||||
<li>종이 문서: 분쇄기로 분쇄하거나 소각</li>
|
||||
</ul>
|
||||
|
||||
<div class="section-title">6. 정보주체의 권리·의무 및 행사 방법</div>
|
||||
<p>고객은 언제든지 자신의 개인정보에 대해 다음과 같은 권리를 행사할 수 있습니다.</p>
|
||||
<ul>
|
||||
<li>개인정보 열람 요구</li>
|
||||
<li>오류 등이 있을 경우 정정 요구</li>
|
||||
<li>삭제 요구</li>
|
||||
<li>처리 정지 요구</li>
|
||||
</ul>
|
||||
<p>위 권리 행사는 약국에 직접 방문하시거나, 아래 연락처로 문의해주시기 바랍니다.</p>
|
||||
|
||||
<div class="section-title">7. 개인정보 보호 책임자</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>청춘약국</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>책임자</th>
|
||||
<td>약국 대표</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>연락처</th>
|
||||
<td>약국 방문 또는 전화 문의</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">8. 개인정보 자동 수집 장치의 설치·운영 및 거부</div>
|
||||
<p>약국은 서비스 이용 과정에서 세션 쿠키를 사용하여 로그인 상태를 유지합니다. 쿠키는 브라우저 설정을 통해 거부할 수 있으나, 이 경우 자동 적립 기능 등 일부 서비스 이용이 제한될 수 있습니다.</p>
|
||||
|
||||
<div class="section-title">9. 선택 정보 미제공에 따른 불이익</div>
|
||||
<p>생년월일 등 선택 항목을 제공하지 않더라도 마일리지 적립·조회 등 기본 서비스 이용에는 제한이 없습니다. 다만 생일 기념 포인트 이벤트, 연령대별 맞춤 추천 등 부가 서비스를 받으실 수 없습니다.</p>
|
||||
|
||||
<div class="effective-date">
|
||||
시행일: 2026년 2월 25일
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
699
backend/templates/signup.html
Normal file
699
backend/templates/signup.html
Normal file
@@ -0,0 +1,699 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="청춘약국">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<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, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
font-size: 14px;
|
||||
color: #868e96;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.label-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fff0f0;
|
||||
color: #e03131;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: #f0f4ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.input-purpose {
|
||||
font-size: 12px;
|
||||
color: #868e96;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.2px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1.5px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
color: #212529;
|
||||
transition: border-color 0.2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.phone-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.phone-prefix {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
white-space: nowrap;
|
||||
padding: 16px 0 16px 4px;
|
||||
}
|
||||
|
||||
.phone-wrapper input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.birthday-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.birthday-wrapper select {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
border: 1.5px solid #e9ecef;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
color: #212529;
|
||||
background: #fff;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23868e96' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
}
|
||||
|
||||
.birthday-wrapper select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.birthday-wrapper select.placeholder {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* 수집 항목 안내 카드 */
|
||||
.info-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #495057;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-item:last-child { margin-bottom: 0; }
|
||||
|
||||
.info-item-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-item-icon.blue { background: rgba(99, 102, 241, 0.1); }
|
||||
.info-item-icon.green { background: rgba(16, 185, 129, 0.1); }
|
||||
.info-item-icon.pink { background: rgba(244, 63, 94, 0.1); }
|
||||
|
||||
.info-item-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-item-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.info-item-desc {
|
||||
font-size: 12px;
|
||||
color: #868e96;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.privacy-consent {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-container input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 2px;
|
||||
accent-color: #6366f1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.consent-text {
|
||||
font-size: 14px;
|
||||
color: #495057;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.consent-text a {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-submit:active { transform: scale(0.98); }
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
color: #adb5bd;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.btn-kakao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: #FEE500;
|
||||
color: #191919;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-kakao:active { transform: scale(0.98); }
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 16px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.alert.error { background: #fff5f5; color: #e03131; }
|
||||
.alert.success { background: #f0fdf4; color: #16a34a; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #adb5bd;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 성공 화면 */
|
||||
.success-screen {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: scaleIn 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes drawCheck {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.success-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: none;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: 15px;
|
||||
color: #868e96;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.success-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.success-buttons a {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-home-s { background: #f1f3f5; color: #495057; }
|
||||
.btn-mypage-s { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
height: calc(56px + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="header">
|
||||
<div class="header-title">회원가입</div>
|
||||
<a href="/" class="btn-back">홈으로</a>
|
||||
</div>
|
||||
|
||||
<!-- 가입 폼 -->
|
||||
<div id="signupForm">
|
||||
<div class="form-section">
|
||||
<div class="form-title">회원가입</div>
|
||||
<div class="form-desc">
|
||||
청춘약국 마일리지 서비스에 가입하세요.<br>
|
||||
영수증 QR 스캔으로 구매금액의 3%를 적립할 수 있습니다.
|
||||
</div>
|
||||
|
||||
<!-- 수집 항목 안내 카드 -->
|
||||
<div class="info-card">
|
||||
<div class="info-card-title">수집 항목 및 이용 목적</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-icon blue">📱</div>
|
||||
<div class="info-item-text">
|
||||
<div class="info-item-label">전화번호 (필수)</div>
|
||||
<div class="info-item-desc">마일리지 적립 계정의 고유 식별자로 사용됩니다. 포인트 조회 및 사용 시 본인 확인에 필요합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-icon green">👤</div>
|
||||
<div class="info-item-text">
|
||||
<div class="info-item-label">이름 (필수)</div>
|
||||
<div class="info-item-desc">동명이인 구분 및 약국 방문 시 본인 확인에 사용됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-icon pink">🎂</div>
|
||||
<div class="info-item-text">
|
||||
<div class="info-item-label">생년월일 (선택)</div>
|
||||
<div class="info-item-desc">생일 기념 포인트 2배 적립 이벤트 및 연령대별 맞춤 건강 정보 제공에 활용됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="formSignup" onsubmit="return false;">
|
||||
<div class="input-group">
|
||||
<label for="name">
|
||||
이름
|
||||
<span class="label-badge badge-required">필수</span>
|
||||
</label>
|
||||
<div class="input-purpose">약국 방문 시 본인 확인 및 동명이인 구분에 사용됩니다.</div>
|
||||
<input type="text" id="name" placeholder="이름을 입력하세요" autocomplete="name" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="phone">
|
||||
전화번호
|
||||
<span class="label-badge badge-required">필수</span>
|
||||
</label>
|
||||
<div class="input-purpose">마일리지 적립·조회의 고유 식별자로 사용됩니다.</div>
|
||||
<div class="phone-wrapper">
|
||||
<span class="phone-prefix">010 -</span>
|
||||
<input type="tel" id="phone"
|
||||
placeholder="0000-0000"
|
||||
inputmode="numeric"
|
||||
maxlength="9"
|
||||
autocomplete="tel"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>
|
||||
생년월일
|
||||
<span class="label-badge badge-optional">선택</span>
|
||||
</label>
|
||||
<div class="input-purpose">생일 기념 포인트 2배 적립 및 연령대별 맞춤 건강 정보 제공에 활용됩니다.</div>
|
||||
<div class="birthday-wrapper">
|
||||
<select id="birthYear" class="placeholder">
|
||||
<option value="">년도</option>
|
||||
</select>
|
||||
<select id="birthMonth" class="placeholder">
|
||||
<option value="">월</option>
|
||||
</select>
|
||||
<select id="birthDay" class="placeholder">
|
||||
<option value="">일</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="privacy-consent">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="privacyConsent" required>
|
||||
<span class="consent-text">
|
||||
<a href="/privacy" target="_blank">개인정보 수집·이용</a>에 동의합니다.
|
||||
<br><span style="font-size:12px; color:#868e96;">전화번호, 이름(필수), 생년월일(선택)을 마일리지 적립·조회 및 맞춤 서비스 목적으로 수집합니다.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit" id="btnSubmit">가입하기</button>
|
||||
</form>
|
||||
|
||||
<div class="divider"><span>또는</span></div>
|
||||
|
||||
<a href="/my-page/kakao/start" class="btn-kakao">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||
</svg>
|
||||
카카오로 간편 가입
|
||||
</a>
|
||||
|
||||
<div class="alert error" id="alertMsg"></div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="/privacy" target="_blank">개인정보 처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성공 화면 -->
|
||||
<div id="successScreen" class="success-screen">
|
||||
<div class="success-icon">
|
||||
<svg viewBox="0 0 52 52">
|
||||
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"
|
||||
style="stroke-dasharray:100; stroke-dashoffset:100; animation: drawCheck 0.6s 0.3s ease forwards;"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="success-title">가입 완료!</div>
|
||||
<div class="success-desc" id="successDesc"></div>
|
||||
<div class="success-buttons">
|
||||
<a href="/" class="btn-home-s">홈으로</a>
|
||||
<a href="#" class="btn-mypage-s" id="btnMyPage">내 마일리지</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}
|
||||
|
||||
// 생년월일 셀렉트 초기화
|
||||
(function() {
|
||||
var yearSel = document.getElementById('birthYear');
|
||||
var monthSel = document.getElementById('birthMonth');
|
||||
var daySel = document.getElementById('birthDay');
|
||||
var currentYear = new Date().getFullYear();
|
||||
|
||||
for (var y = currentYear; y >= 1920; y--) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = y;
|
||||
opt.textContent = y + '년';
|
||||
yearSel.appendChild(opt);
|
||||
}
|
||||
for (var m = 1; m <= 12; m++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m < 10 ? '0' + m : '' + m;
|
||||
opt.textContent = m + '월';
|
||||
monthSel.appendChild(opt);
|
||||
}
|
||||
for (var d = 1; d <= 31; d++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = d < 10 ? '0' + d : '' + d;
|
||||
opt.textContent = d + '일';
|
||||
daySel.appendChild(opt);
|
||||
}
|
||||
|
||||
// 선택 시 placeholder 클래스 제거
|
||||
[yearSel, monthSel, daySel].forEach(function(sel) {
|
||||
sel.addEventListener('change', function() {
|
||||
if (this.value) this.classList.remove('placeholder');
|
||||
else this.classList.add('placeholder');
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
const phoneInput = document.getElementById('phone');
|
||||
const form = document.getElementById('formSignup');
|
||||
const alertMsg = document.getElementById('alertMsg');
|
||||
|
||||
// 자동 하이픈
|
||||
phoneInput.addEventListener('input', function(e) {
|
||||
let v = e.target.value.replace(/[^0-9]/g, '');
|
||||
if (v.length <= 4) e.target.value = v;
|
||||
else e.target.value = v.slice(0, 4) + '-' + v.slice(4, 8);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const raw = phoneInput.value.replace(/[^0-9]/g, '');
|
||||
const phone = '010' + raw;
|
||||
const consent = document.getElementById('privacyConsent').checked;
|
||||
|
||||
// 생년월일 (선택)
|
||||
const birthYear = document.getElementById('birthYear').value;
|
||||
const birthMonth = document.getElementById('birthMonth').value;
|
||||
const birthDay = document.getElementById('birthDay').value;
|
||||
let birthday = null;
|
||||
if (birthYear && birthMonth && birthDay) {
|
||||
birthday = birthYear + '-' + birthMonth + '-' + birthDay;
|
||||
}
|
||||
|
||||
if (!name) return showAlert('이름을 입력해주세요.');
|
||||
if (raw.length < 7) return showAlert('올바른 전화번호를 입력해주세요.');
|
||||
if (!consent) return showAlert('개인정보 수집·이용에 동의해주세요.');
|
||||
|
||||
const btn = document.getElementById('btnSubmit');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '가입 중...';
|
||||
|
||||
try {
|
||||
const body = { name, phone };
|
||||
if (birthday) body.birthday = birthday;
|
||||
|
||||
const res = await fetch('/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('signupForm').style.display = 'none';
|
||||
document.getElementById('successScreen').style.display = 'block';
|
||||
document.getElementById('successDesc').innerHTML =
|
||||
'<strong>' + name + '</strong>님, 환영합니다!<br>이제 영수증 QR을 스캔하면 포인트가 적립됩니다.';
|
||||
document.getElementById('btnMyPage').href = '/my-page?phone=' + encodeURIComponent(phone);
|
||||
} else {
|
||||
showAlert(data.message || '가입 중 오류가 발생했습니다.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '가입하기';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('서버 연결에 실패했습니다.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '가입하기';
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(msg) {
|
||||
alertMsg.textContent = msg;
|
||||
alertMsg.style.display = 'block';
|
||||
setTimeout(() => { alertMsg.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -115,8 +115,8 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
|
||||
- token_hash가 이미 존재하면 실패 (UNIQUE 제약)
|
||||
"""
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
from db.dbsetup import db_manager as _db_manager
|
||||
conn = _db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 중복 체크 (transaction_id)
|
||||
|
||||
113
docs/TROUBLESHOOTING-SQLITE-CONNECTION.md
Normal file
113
docs/TROUBLESHOOTING-SQLITE-CONNECTION.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 트러블슈팅: SQLite "I/O operation on closed file" 에러
|
||||
|
||||
## 발생일
|
||||
2026-02-27
|
||||
|
||||
## 증상
|
||||
- 관리자 페이지에서 회원 검색 시 500 에러 발생
|
||||
- 에러 메시지: `조회 실패: I/O operation on closed file.`
|
||||
- 서버 로그에는 200 OK로 찍히지만 응답 body에 에러 포함
|
||||
|
||||
## 원인
|
||||
|
||||
### 1. SQLite 싱글톤 연결 문제
|
||||
Flask의 멀티스레드 환경에서 `db_manager.get_sqlite_connection()`이 **싱글톤 연결**을 반환.
|
||||
한 요청에서 연결을 닫으면 다른 요청에서 "closed file" 에러 발생.
|
||||
|
||||
**문제 코드:**
|
||||
```python
|
||||
conn = db_manager.get_sqlite_connection() # 싱글톤 연결 반환
|
||||
cursor = conn.cursor()
|
||||
# ... 작업 ...
|
||||
# finally에서 conn.close() 호출 시 다른 요청에 영향
|
||||
```
|
||||
|
||||
### 2. 존재하지 않는 테이블 참조
|
||||
`product_category_mapping` 테이블이 DB에 없는데 쿼리 시도 → SQLite 에러 발생
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. 새 연결 사용 + finally에서 close
|
||||
```python
|
||||
conn = None
|
||||
try:
|
||||
conn = db_manager.get_sqlite_connection(new_connection=True) # 새 연결!
|
||||
cursor = conn.cursor()
|
||||
# ... 작업 ...
|
||||
except Exception as e:
|
||||
logging.error(f"에러: {e}")
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. 없는 테이블 조회 시 예외 처리
|
||||
```python
|
||||
try:
|
||||
cursor.execute("SELECT * FROM product_category_mapping WHERE ...")
|
||||
# ...
|
||||
except Exception:
|
||||
pass # 테이블 없으면 무시
|
||||
```
|
||||
|
||||
## 수정된 API 목록
|
||||
|
||||
| API | 파일 | 커밋 |
|
||||
|-----|------|------|
|
||||
| `/api/members/search` | app.py | 87a56d0 |
|
||||
| `/api/members/history/<id>` | app.py | 87a56d0 |
|
||||
| `/admin/search/user` | app.py | 1414bb1 |
|
||||
| `/admin/search/product` | app.py | 1414bb1 |
|
||||
| `/admin/user/<id>` | app.py | 4691d65, 94a8df6 |
|
||||
|
||||
## dbsetup.py 수정사항
|
||||
|
||||
`get_sqlite_connection()` 메서드에 `new_connection` 파라미터 추가:
|
||||
|
||||
```python
|
||||
def get_sqlite_connection(self, new_connection=False):
|
||||
"""
|
||||
SQLite 연결 반환
|
||||
- new_connection=True: 새 연결 생성 (API 요청마다 독립적 연결 필요시)
|
||||
- new_connection=False: 기존 싱글톤 연결 반환 (기본값, 하위 호환성)
|
||||
"""
|
||||
if new_connection:
|
||||
conn = sqlite3.connect(self.sqlite_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# 기존 싱글톤 로직
|
||||
if self._sqlite_conn is None:
|
||||
self._sqlite_conn = sqlite3.connect(self.sqlite_path, check_same_thread=False)
|
||||
self._sqlite_conn.row_factory = sqlite3.Row
|
||||
return self._sqlite_conn
|
||||
```
|
||||
|
||||
## 추가 수정사항
|
||||
|
||||
### CDN 차단 문제
|
||||
Edge 브라우저의 Tracking Prevention이 cdnjs.cloudflare.com 차단
|
||||
→ lottie.min.js를 로컬 파일(`/static/js/lottie.min.js`)로 변경
|
||||
|
||||
**커밋:** 866d10f
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **Flask 멀티스레드 환경에서 SQLite 연결은 요청마다 새로 생성**해야 안전
|
||||
2. **API 응답은 HTTP 상태코드로 판단하지 말고 body의 success 필드 확인**
|
||||
3. **없을 수 있는 테이블/컬럼 조회는 try-except로 감싸기**
|
||||
4. **CDN 의존성은 로컬 fallback 준비**
|
||||
|
||||
## 관련 커밋
|
||||
|
||||
```
|
||||
94a8df6 fix: product_category_mapping 테이블 없을 때 에러 무시
|
||||
4691d65 fix: /admin/user/<id> SQLite 연결 에러 해결
|
||||
866d10f fix: lottie CDN을 로컬 파일로 변경
|
||||
1414bb1 fix: /admin 사이드바 검색 SQLite 연결 에러 해결
|
||||
87a56d0 fix: /api/members/* SQLite 연결 에러 해결
|
||||
```
|
||||
324
docs/ai-upselling-architecture.md
Normal file
324
docs/ai-upselling-architecture.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# AI 업셀링 시스템 아키텍처
|
||||
|
||||
> 청춘약국 AI 기반 맞춤 제품 추천 시스템의 전체 구조 및 데이터 흐름
|
||||
|
||||
## 개요
|
||||
|
||||
고객이 마일리지를 적립할 때, 실시간으로 AI가 추가 구매 추천을 생성하는 시스템.
|
||||
|
||||
**핵심 특징:**
|
||||
- POS(PIT3000) 판매 데이터 기반 추천
|
||||
- 고객별 구매 이력 분석
|
||||
- 약국 실제 재고(최근 판매 제품) 기반
|
||||
- Clawdbot Gateway를 통한 Claude 연동 (추가 API 비용 없음)
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 전체 흐름 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [POS 판매] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ [MSSQL: PM_PRES] ←─── PIT3000 POS 데이터 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ [키오스크 적립 요청] POST /api/kiosk/claim │
|
||||
│ │ │
|
||||
│ ├──────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ [SQLite: mileage.db] [백그라운드 스레드] │
|
||||
│ - claim_tokens _generate_upsell_recommendation()
|
||||
│ - users │ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┼────────────────┐ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────────┐ │ │
|
||||
│ │ │ 데이터 수집 │ │ │
|
||||
│ │ ├─────────────────────┤ │ │
|
||||
│ │ │ 1. 현재 구매 품목 │ │ │
|
||||
│ │ │ 2. 고객 구매 이력 │ │ │
|
||||
│ │ │ 3. 약국 보유 제품 │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────────┐ │ │
|
||||
│ │ │ Clawdbot Gateway │ │ │
|
||||
│ │ │ (WebSocket) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Model: Sonnet │ │ │
|
||||
│ │ │ (비용 최적화) │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────────┐ │ │
|
||||
│ │ │ Claude AI 응답 │ │ │
|
||||
│ │ │ {product, reason, │ │ │
|
||||
│ │ │ message} │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ └───────────────────┼─────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ SQLite: ai_recommendations │
|
||||
│ │ - recommended_product │ │
|
||||
│ │ - recommendation_message│ │
|
||||
│ │ - trigger_products │ │
|
||||
│ │ - expires_at │ │
|
||||
│ └──────────┬──────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 마이페이지 / 키오스크 │ │
|
||||
│ │ 추천 카드 노출 │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름 상세
|
||||
|
||||
### 1단계: 트리거 (키오스크 적립)
|
||||
|
||||
```python
|
||||
# POST /api/kiosk/claim
|
||||
# 고객이 전화번호로 마일리지 적립 요청
|
||||
|
||||
# 적립 완료 후 백그라운드에서 AI 추천 생성
|
||||
threading.Thread(target=_bg_upsell, daemon=True).start()
|
||||
```
|
||||
|
||||
**포인트:** 적립 응답은 즉시 반환, AI 추천은 백그라운드에서 처리 (non-blocking)
|
||||
|
||||
---
|
||||
|
||||
### 2단계: 데이터 수집
|
||||
|
||||
#### 2-1. 현재 구매 품목
|
||||
|
||||
```python
|
||||
# 키오스크 트리거 시 전달받은 sale_items에서 추출
|
||||
current_items = ', '.join(item['name'] for item in sale_items)
|
||||
# 예: "타이레놀, 판피린, 비타민C"
|
||||
```
|
||||
|
||||
#### 2-2. 고객 구매 이력 (최근 5건)
|
||||
|
||||
```sql
|
||||
-- SQLite: 최근 적립한 거래 ID 조회
|
||||
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
|
||||
|
||||
-- MSSQL: 각 거래의 품목 조회
|
||||
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
|
||||
```
|
||||
|
||||
#### 2-3. 약국 보유 제품 목록 (TOP 40)
|
||||
|
||||
```sql
|
||||
-- MSSQL: 최근 30일 판매 상위 40개 제품
|
||||
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
|
||||
```
|
||||
|
||||
**왜 TOP 40?**
|
||||
- AI 컨텍스트 토큰 절약
|
||||
- 실제로 많이 팔리는 제품만 추천 (재고 있음 보장)
|
||||
- 판매불가 제품 자동 제외
|
||||
|
||||
---
|
||||
|
||||
### 3단계: AI 프롬프트 구성
|
||||
|
||||
```python
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # Opus 대신 Sonnet (비용 최적화)
|
||||
|
||||
SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다.
|
||||
반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요.
|
||||
목록에 없는 제품은 절대 추천하지 마세요.
|
||||
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
|
||||
반드시 아래 JSON 형식으로만 응답하세요."""
|
||||
|
||||
USER_PROMPT = f"""고객 이름: {user_name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
[약국 보유 제품 목록 — 이 중에서만 추천하세요]
|
||||
{product_list}
|
||||
|
||||
규칙:
|
||||
1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
|
||||
2. 오늘 이미 구매한 제품은 추천하지 마세요
|
||||
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
|
||||
4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요
|
||||
|
||||
응답 JSON:
|
||||
{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용)", "message": "고객용 메시지"}}"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4단계: AI 응답 및 저장
|
||||
|
||||
```json
|
||||
// Claude 응답 예시
|
||||
{
|
||||
"product": "종근당 비타민D 1000IU",
|
||||
"reason": "감기약과 함께 면역력 강화에 도움",
|
||||
"message": "홍길동님, 감기약 드시면서 비타민D도 같이 챙기시면 회복에 도움이 되실 거예요. 요즘 일조량 적을 때 특히 좋답니다."
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
-- SQLite: ai_recommendations 테이블에 저장
|
||||
INSERT INTO ai_recommendations
|
||||
(user_id, transaction_id, recommended_product, recommendation_message,
|
||||
recommendation_reason, trigger_products, ai_raw_response, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5단계: 추천 노출
|
||||
|
||||
```
|
||||
GET /api/recommendation/{user_id}
|
||||
```
|
||||
|
||||
- 마이페이지에서 조회
|
||||
- 키오스크에서 적립 직후 표시
|
||||
- 7일 후 만료 (expires_at)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 쿼리 정리
|
||||
|
||||
| 용도 | DB | 쿼리 |
|
||||
|------|-----|------|
|
||||
| 고객 최근 거래 | SQLite | `claim_tokens WHERE claimed_by_user_id = ?` |
|
||||
| 거래별 품목 | MSSQL | `SALE_SUB JOIN CD_GOODS WHERE SL_NO_order = ?` |
|
||||
| 보유 제품 TOP 40 | MSSQL | `SALE_SUB GROUP BY GoodsName ORDER BY COUNT DESC` |
|
||||
| 추천 저장 | SQLite | `INSERT INTO ai_recommendations` |
|
||||
| 추천 조회 | SQLite | `SELECT FROM ai_recommendations WHERE user_id = ?` |
|
||||
|
||||
---
|
||||
|
||||
## 비용 최적화 전략
|
||||
|
||||
### 1. 모델 선택
|
||||
|
||||
```python
|
||||
# 업셀링은 Sonnet (빠르고 저렴)
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5'
|
||||
|
||||
# 복잡한 분석은 Opus (메인 세션)
|
||||
# sessions.patch로 세션별 모델 오버라이드
|
||||
```
|
||||
|
||||
### 2. 토큰 절약
|
||||
|
||||
- 보유 제품 TOP 40개만 전달 (전체 재고 X)
|
||||
- 시스템 프롬프트 간결하게
|
||||
- JSON 응답 강제 (불필요한 설명 제거)
|
||||
|
||||
### 3. 세션 분리
|
||||
|
||||
```python
|
||||
# 고객별 세션 분리 → 컨텍스트 축적 방지
|
||||
session_id = f'upsell-real-{user_name}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fallback 전략
|
||||
|
||||
```python
|
||||
# 1차 시도: 실데이터 기반 (보유 제품 목록 제공)
|
||||
rec = generate_upsell_real(user_name, current_items, recent_products, available)
|
||||
|
||||
# 2차 시도: 자유 생성 (보유 제품 목록 없이)
|
||||
if not rec:
|
||||
rec = generate_upsell(user_name, current_items, recent_products)
|
||||
```
|
||||
|
||||
**왜 Fallback?**
|
||||
- MSSQL 연결 실패 시에도 추천 가능
|
||||
- 보유 제품 쿼리 실패해도 서비스 지속
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
```
|
||||
pharmacy-pos-qr-system/
|
||||
├── backend/
|
||||
│ ├── app.py
|
||||
│ │ ├── _get_available_products() # 보유 제품 조회
|
||||
│ │ ├── _generate_upsell_recommendation() # 메인 로직
|
||||
│ │ └── /api/recommendation/{user_id} # 추천 조회 API
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ └── clawdbot_client.py
|
||||
│ │ ├── generate_upsell() # 자유 생성
|
||||
│ │ ├── generate_upsell_real() # 실데이터 기반
|
||||
│ │ └── ask_clawdbot() # Gateway 호출
|
||||
│ │
|
||||
│ ├── templates/
|
||||
│ │ └── admin_ai_crm.html # CRM 관리 페이지
|
||||
│ │
|
||||
│ └── db/
|
||||
│ └── mileage.db # SQLite (ai_recommendations)
|
||||
│
|
||||
└── docs/
|
||||
├── ai-upselling-architecture.md # 이 문서
|
||||
└── clawdbot-gateway-api.md # Gateway 연동 가이드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 향후 개선 방향
|
||||
|
||||
### 1. 추천 정확도 향상
|
||||
- 제품 카테고리 분류 추가 (감기약, 영양제, 외용제 등)
|
||||
- 계절/시간대별 추천 가중치
|
||||
- 고객 연령대/성별 기반 필터
|
||||
|
||||
### 2. 성과 측정
|
||||
- 추천 → 실제 구매 전환율 추적
|
||||
- A/B 테스트 (추천 vs 비추천)
|
||||
- 인기 추천 제품 통계
|
||||
|
||||
### 3. 실시간 재고 연동
|
||||
- 현재: 최근 30일 판매 기준 (간접 재고)
|
||||
- 개선: 실제 재고 수량 기반 추천
|
||||
|
||||
### 4. 멀티 추천
|
||||
- 현재: 1개 제품만 추천
|
||||
- 개선: 상황별 2-3개 옵션 제시
|
||||
|
||||
---
|
||||
|
||||
*작성: 2026-02-27 | 용림 🐉*
|
||||
173
docs/ai-upselling-crm.md
Normal file
173
docs/ai-upselling-crm.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# AI 업셀링 CRM — 마이페이지 맞춤 추천 시스템
|
||||
|
||||
## 개요
|
||||
키오스크 적립 시 고객 구매이력을 AI가 분석하여 맞춤 제품을 추천.
|
||||
고객이 알림톡 → 마이페이지 접속 시 바텀시트 팝업으로 자연스럽게 표시.
|
||||
|
||||
## 기술 스택
|
||||
- **AI 엔진**: Clawdbot Gateway (Claude Max 구독 재활용, 추가 비용 없음)
|
||||
- **통신**: WebSocket (`ws://127.0.0.1:18789`) — JSON-RPC 프로토콜
|
||||
- **저장소**: SQLite `ai_recommendations` 테이블
|
||||
- **프론트**: 바텀시트 UI (드래그 닫기 지원)
|
||||
|
||||
## 전체 흐름
|
||||
|
||||
```
|
||||
키오스크 적립 (POST /api/kiosk/claim)
|
||||
│
|
||||
├─ 1. 적립 처리 (기존)
|
||||
├─ 2. 알림톡 발송 (기존)
|
||||
└─ 3. AI 추천 생성 (fire-and-forget)
|
||||
│
|
||||
├─ 최근 구매 이력 수집 (SQLite + MSSQL SALE_SUB)
|
||||
├─ Clawdbot Gateway → Claude 호출
|
||||
├─ 추천 결과 → ai_recommendations 저장
|
||||
└─ 실패 시 무시 (추천은 부가 기능)
|
||||
|
||||
고객: 알림톡 버튼 클릭 → /my-page
|
||||
│
|
||||
├─ 1.5초 후 GET /api/recommendation/{user_id}
|
||||
│
|
||||
├─ 추천 있음 → 바텀시트 슬라이드업
|
||||
│ ├─ 아래로 드래그 → 닫기
|
||||
│ ├─ "다음에요" → dismiss
|
||||
│ └─ "관심있어요!" → dismiss + 기록
|
||||
│
|
||||
└─ 추천 없음 → 아무것도 안 뜸
|
||||
```
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
### `backend/services/clawdbot_client.py`
|
||||
Clawdbot Gateway Python 클라이언트.
|
||||
|
||||
**Gateway WebSocket 프로토콜 (v3):**
|
||||
1. WS 연결 → `ws://127.0.0.1:{port}`
|
||||
2. 서버 → `connect.challenge` 이벤트 (nonce 전달)
|
||||
3. 클라이언트 → `connect` 요청 (token + client info)
|
||||
4. 서버 → connect 응답 (ok)
|
||||
5. 클라이언트 → `agent` 요청 (message + systemPrompt)
|
||||
6. 서버 → `accepted` ack → 최종 응답 (`payloads[].text`)
|
||||
|
||||
**주요 함수:**
|
||||
| 함수 | 설명 |
|
||||
|------|------|
|
||||
| `_load_gateway_config()` | `~/.clawdbot/clawdbot.json`에서 port, token 읽기 |
|
||||
| `_ask_gateway(message, ...)` | async WebSocket 통신 |
|
||||
| `ask_clawdbot(message, ...)` | 동기 래퍼 (Flask에서 호출) |
|
||||
| `generate_upsell(user_name, current_items, recent_products)` | 업셀 프롬프트 구성 + 호출 + JSON 파싱 |
|
||||
| `_parse_upsell_response(text)` | AI 응답에서 JSON 추출 |
|
||||
|
||||
**Gateway 설정:**
|
||||
- 설정 파일: `~/.clawdbot/clawdbot.json`
|
||||
- Client ID: `gateway-client` (허용된 상수 중 하나)
|
||||
- Protocol: v3 (minProtocol=3, maxProtocol=3)
|
||||
|
||||
### `backend/db/mileage_schema.sql` — ai_recommendations 테이블
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL, -- "고려은단 비타민C 1000"
|
||||
recommendation_message TEXT NOT NULL, -- 고객에게 보여줄 메시지
|
||||
recommendation_reason TEXT, -- 내부용 추천 이유
|
||||
trigger_products TEXT, -- JSON: 트리거된 구매 품목
|
||||
ai_raw_response TEXT, -- AI 원본 응답
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/dismissed
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME, -- 7일 후 만료
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### `backend/app.py` — API 엔드포인트
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/recommendation/<user_id>` | GET | 최신 active 추천 조회 (마이페이지용) |
|
||||
| `/api/recommendation/<rec_id>/dismiss` | POST | 추천 닫기 (status→dismissed) |
|
||||
|
||||
**추천 생성 위치**: `api_kiosk_claim()` 함수 끝부분, `_generate_upsell_recommendation()` 호출
|
||||
|
||||
### `backend/templates/my_page.html` — 바텀시트 UI
|
||||
|
||||
**기능:**
|
||||
- 페이지 로드 1.5초 후 추천 API fetch
|
||||
- 💊 아이콘 + AI 메시지 + 제품명 배지 (보라색 그라디언트)
|
||||
- **터치 드래그 닫기**: 아래로 80px 이상 드래그하면 dismiss
|
||||
- 배경 탭 닫기, "다음에요"/"관심있어요!" 버튼
|
||||
- 슬라이드업/다운 CSS 애니메이션
|
||||
|
||||
## AI 프롬프트
|
||||
|
||||
**시스템 프롬프트:**
|
||||
```
|
||||
당신은 동네 약국(청춘약국)의 친절한 약사입니다.
|
||||
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
|
||||
반드시 JSON 형식으로만 응답하세요.
|
||||
```
|
||||
|
||||
**유저 프롬프트 구조:**
|
||||
```
|
||||
고객 이름: {name}
|
||||
오늘 구매한 약: {current_items}
|
||||
최근 구매 이력: {recent_products}
|
||||
|
||||
규칙:
|
||||
1. 함께 먹으면 좋은 약 1가지만 추천 (일반의약품/건강기능식품)
|
||||
2. 메시지 2문장 이내, 따뜻한 톤
|
||||
3. JSON: {"product": "...", "reason": "...", "message": "..."}
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"product": "고려은단 비타민C 1000",
|
||||
"reason": "감기약 구매로 면역력 보충 필요",
|
||||
"message": "김영빈님, 감기약 드시는 동안 비타민C도 함께 챙겨드시면 회복에 도움이 돼요."
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback 정책
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| Gateway 꺼져있음 | 추천 생성 스킵, 로그만 남김 |
|
||||
| AI 응답 파싱 실패 | 저장 안 함 |
|
||||
| 추천 없을 때 마이페이지 방문 | 바텀시트 안 뜸 |
|
||||
| 7일 경과 | `expires_at` 만료, 조회 안 됨 |
|
||||
| dismiss 후 재방문 | 같은 추천 안 뜸 (새 적립 시 새 추천 생성) |
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 1. Gateway 연결 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
from services.clawdbot_client import ask_clawdbot
|
||||
print(ask_clawdbot('안녕'))
|
||||
"
|
||||
|
||||
# 2. 업셀 생성 테스트
|
||||
PYTHONIOENCODING=utf-8 python -c "
|
||||
import json
|
||||
from services.clawdbot_client import generate_upsell
|
||||
result = generate_upsell('홍길동', '타이레놀, 챔프시럽', '비타민C, 소화제')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
"
|
||||
|
||||
# 3. API 테스트
|
||||
curl https://mile.0bin.in/api/recommendation/1
|
||||
|
||||
# 4. DB 확인
|
||||
python -c "
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('db/mileage.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
for r in conn.execute('SELECT * FROM ai_recommendations ORDER BY id DESC LIMIT 5'):
|
||||
print(json.dumps(dict(r), ensure_ascii=False))
|
||||
"
|
||||
```
|
||||
186
docs/alimipharm-set-product-structure.md
Normal file
186
docs/alimipharm-set-product-structure.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 알리미팜 세트 상품 구조 (PIT3000)
|
||||
|
||||
> 작성일: 2026-02-27
|
||||
> 약국: 양구청춘약국
|
||||
|
||||
## 개요
|
||||
|
||||
PIT3000(팜잇3000) DB는 세트 상품을 기본적으로 잘 처리하지 못하게 설계되어 있다.
|
||||
하지만 알리미팜에서는 세트 상품을 등록하고 **자체 바코드**를 생성하여 사용한다.
|
||||
|
||||
이 문서는 세트 상품의 DB 구조와 바코드 조회 방법을 정리한 참고 문서이다.
|
||||
|
||||
---
|
||||
|
||||
## 테이블 구조
|
||||
|
||||
### 1. CD_GOODS (기본 상품 테이블)
|
||||
```
|
||||
Database: PM_DRUG
|
||||
Table: CD_GOODS
|
||||
```
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| DrugCode | 상품 코드 (PK) |
|
||||
| GoodsName | 상품명 |
|
||||
| BARCODE | **바코드** (세트상품은 대부분 비어있음!) |
|
||||
| SplName | 공급업체 |
|
||||
| Saleprice | 판매가 |
|
||||
| Price | 매입가 |
|
||||
|
||||
⚠️ **주의**: 세트 상품의 경우 `BARCODE` 컬럼이 비어있는 경우가 많음!
|
||||
|
||||
---
|
||||
|
||||
### 2. CD_ITEM_UNIT_MEMBER (단위/바코드 확장 테이블) ⭐
|
||||
```
|
||||
Database: PM_DRUG
|
||||
Table: CD_ITEM_UNIT_MEMBER
|
||||
```
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| DRUGCODE | 상품 코드 (FK → CD_GOODS.DrugCode) |
|
||||
| CD_CD_UNIT | 단위 코드 |
|
||||
| CD_NM_UNIT | 단위 수량 |
|
||||
| CD_MY_UNIT | 판매가 |
|
||||
| CD_IN_UNIT | 매입가 |
|
||||
| **CD_CD_BARCODE** | **세트상품 바코드** ⭐ |
|
||||
| CD_CD_POS | POS 코드 |
|
||||
| CHANGE_DATE | 변경일 |
|
||||
|
||||
✅ **핵심**: 세트 상품/자체 등록 상품의 바코드는 이 테이블의 `CD_CD_BARCODE`에 저장됨!
|
||||
|
||||
---
|
||||
|
||||
### 3. CD_item_set (세트 구성품 테이블)
|
||||
```
|
||||
Database: PM_DRUG
|
||||
Table: CD_item_set
|
||||
```
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| SetCode | 세트 상품 코드 (FK → CD_GOODS.DrugCode) |
|
||||
| DrugCode | 구성품 코드 ('SET0000' = 헤더, 그 외 = 구성품) |
|
||||
| CD_NM_UNIT | 구성품 수량 |
|
||||
|
||||
**구조 예시 (투엑스벤포파워 LB000003181):**
|
||||
```
|
||||
SetCode | DrugCode | CD_NM_UNIT
|
||||
--------------|---------------|------------
|
||||
LB000003181 | SET0000 | NULL ← 세트 헤더
|
||||
LB000003181 | LB000003324 | 1.0 ← 구성품 1
|
||||
LB000003181 | LB000001423 | 1.0 ← 구성품 2 (벤포파워Z)
|
||||
LB000003181 | LB000001412 | 1.0 ← 구성품 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. CD_item_pack / CD_ITEM_PACK_UNIT
|
||||
```
|
||||
Database: PM_DRUG
|
||||
```
|
||||
포장 단위 관련 테이블. 굿팜/알리미팜 처리 방식이 다를 수 있음.
|
||||
|
||||
---
|
||||
|
||||
## 바코드 조회 쿼리
|
||||
|
||||
### 세트 상품 바코드까지 포함한 조회
|
||||
```sql
|
||||
SELECT
|
||||
S.DrugCode,
|
||||
G.GoodsName,
|
||||
-- CD_GOODS.BARCODE가 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용
|
||||
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 CD_CD_BARCODE
|
||||
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
|
||||
WHERE DRUGCODE = S.DrugCode
|
||||
AND CD_CD_BARCODE IS NOT NULL
|
||||
AND CD_CD_BARCODE != ''
|
||||
) U
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 세트 상품 바코드 패턴
|
||||
|
||||
| 패턴 | 설명 |
|
||||
|------|------|
|
||||
| `999XXXXXXXXX` | 알리미팜 자체 생성 바코드 (세트/자체등록) |
|
||||
| `880XXXXXXXXX` | 일반 제조사 바코드 |
|
||||
|
||||
예시:
|
||||
- `9990000001101` - 투엑스벤포파워 (세트상품)
|
||||
- `8806418067510` - 벤포파워Z (일반상품)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 마진 계산 시 주의사항
|
||||
|
||||
### 세트 상품 마진 계산의 복잡성
|
||||
|
||||
세트 상품의 **실제 마진**을 계산하려면 **구성품을 물고 들어가서** 각 구성품의 매입가를 합산해야 한다!
|
||||
|
||||
```
|
||||
세트 판매가: 9,000원
|
||||
세트 매입가(CD_GOODS.Price): 3,300원 ← 이건 정확하지 않을 수 있음!
|
||||
|
||||
실제 계산 필요:
|
||||
├── 구성품1 매입가: 1,500원
|
||||
├── 구성품2 매입가: 1,200원
|
||||
└── 구성품3 매입가: 800원
|
||||
────────────────────
|
||||
실제 매입가 합계: 3,500원
|
||||
실제 마진: 9,000 - 3,500 = 5,500원
|
||||
```
|
||||
|
||||
### 마진 계산 쿼리 예시 (향후 개발용)
|
||||
```sql
|
||||
-- 세트 상품의 실제 매입가 계산
|
||||
SELECT
|
||||
S.SetCode,
|
||||
G1.GoodsName as set_name,
|
||||
G1.Saleprice as set_sale_price,
|
||||
SUM(G2.Price * S.CD_NM_UNIT) as actual_cost
|
||||
FROM CD_item_set S
|
||||
JOIN CD_GOODS G1 ON S.SetCode = G1.DrugCode
|
||||
JOIN CD_GOODS G2 ON S.DrugCode = G2.DrugCode
|
||||
WHERE S.DrugCode != 'SET0000' -- 헤더 제외
|
||||
GROUP BY S.SetCode, G1.GoodsName, G1.Saleprice
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 테이블 요약
|
||||
|
||||
| 테이블 | 데이터베이스 | 용도 |
|
||||
|--------|-------------|------|
|
||||
| CD_GOODS | PM_DRUG | 기본 상품 정보 |
|
||||
| CD_ITEM_UNIT_MEMBER | PM_DRUG | 단위별 바코드 (세트 바코드 저장) |
|
||||
| CD_item_set | PM_DRUG | 세트 구성품 매핑 |
|
||||
| CD_item_pack | PM_DRUG | 포장 단위 |
|
||||
| CD_BARCODE | PM_DRUG | 표준코드 매핑 |
|
||||
| SALE_SUB | PM_PRES | 판매 상세 |
|
||||
| SALE_MAIN | PM_PRES | 판매 헤더 |
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-02-27 | 세트상품 바코드 조회 문제 해결 (`CD_ITEM_UNIT_MEMBER` 연동) |
|
||||
| 2026-02-27 | 바코드 매핑률 89.8% → 99.8% 개선 |
|
||||
|
||||
---
|
||||
|
||||
## 참고
|
||||
|
||||
- PIT3000 DB 서버: `192.168.0.4\PM2014`
|
||||
- 굿팜 vs 알리미팜: 세트 처리 방식이 다를 수 있음 (확인 필요)
|
||||
342
docs/clawdbot-gateway-api.md
Normal file
342
docs/clawdbot-gateway-api.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Clawdbot Gateway WebSocket API 가이드
|
||||
|
||||
> 외부 애플리케이션에서 Clawdbot Gateway에 연결하여 AI 호출 또는 상태 조회하는 방법
|
||||
|
||||
## 개요
|
||||
|
||||
Clawdbot Gateway는 WebSocket API를 제공합니다. 이를 통해:
|
||||
- **AI 호출** (`agent` 메서드) — Claude/GPT 등 모델에 질문 (토큰 소비)
|
||||
- **상태 조회** (`sessions.list` 등) — 세션 정보 조회 (토큰 무소비)
|
||||
- **세션 설정** (`sessions.patch`) — 모델 오버라이드 등
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌─────────────────┐
|
||||
│ Flask 서버 │ ◄─────────────────► │ Clawdbot Gateway│
|
||||
│ (pharmacy-pos) │ Port 18789 │ (localhost) │
|
||||
└─────────────────┘ └────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Claude / GPT │
|
||||
│ (Providers) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 설정 파일 위치
|
||||
|
||||
Gateway 설정은 `~/.clawdbot/clawdbot.json`에 있음:
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"port": 18789,
|
||||
"auth": {
|
||||
"mode": "token",
|
||||
"token": "your-gateway-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 연결 프로토콜 (Python)
|
||||
|
||||
### 1. 기본 연결 흐름
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
import websockets
|
||||
|
||||
async def connect_to_gateway():
|
||||
config = load_gateway_config() # ~/.clawdbot/clawdbot.json 읽기
|
||||
url = f"ws://127.0.0.1:{config['port']}"
|
||||
token = config['token']
|
||||
|
||||
async with websockets.connect(url) as ws:
|
||||
# 1단계: challenge 수신
|
||||
challenge = json.loads(await ws.recv())
|
||||
# {'event': 'connect.challenge', 'payload': {'nonce': '...'}}
|
||||
|
||||
# 2단계: connect 요청
|
||||
connect_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'connect',
|
||||
'params': {
|
||||
'minProtocol': 3,
|
||||
'maxProtocol': 3,
|
||||
'client': {
|
||||
'id': 'gateway-client', # 고정값
|
||||
'displayName': 'My App',
|
||||
'version': '1.0.0',
|
||||
'platform': 'win32',
|
||||
'mode': 'backend', # 고정값
|
||||
'instanceId': str(uuid.uuid4()),
|
||||
},
|
||||
'caps': [],
|
||||
'auth': {'token': token},
|
||||
'role': 'operator',
|
||||
'scopes': ['operator.admin'], # 또는 ['operator.read']
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(connect_frame))
|
||||
|
||||
# 3단계: connect 응답 대기
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
if msg.get('id') == connect_frame['id']:
|
||||
if msg.get('ok'):
|
||||
print("연결 성공!")
|
||||
break
|
||||
else:
|
||||
print(f"연결 실패: {msg.get('error')}")
|
||||
return
|
||||
|
||||
# 이제 다른 메서드 호출 가능
|
||||
# ...
|
||||
```
|
||||
|
||||
### 2. 주의사항: client 파라미터
|
||||
|
||||
⚠️ **중요**: `client.id`와 `client.mode`는 Gateway 스키마에 정의된 값만 허용됨
|
||||
|
||||
| 필드 | 허용되는 값 | 설명 |
|
||||
|------|-------------|------|
|
||||
| `client.id` | `'gateway-client'` | 백엔드 클라이언트용 |
|
||||
| `client.mode` | `'backend'` | 백엔드 모드 |
|
||||
| `role` | `'operator'` | 제어 클라이언트 |
|
||||
| `scopes` | `['operator.admin']` 또는 `['operator.read']` | 권한 범위 |
|
||||
|
||||
잘못된 값 사용 시 에러:
|
||||
```
|
||||
invalid connect params: at /client/id: must be equal to constant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 메서드 종류
|
||||
|
||||
### 토큰 소비 없는 메서드 (관리용)
|
||||
|
||||
| 메서드 | 용도 | 파라미터 |
|
||||
|--------|------|----------|
|
||||
| `sessions.list` | 세션 목록 조회 | `{limit: 10}` |
|
||||
| `sessions.patch` | 세션 설정 변경 | `{key: '...', model: '...'}` |
|
||||
|
||||
### 토큰 소비하는 메서드 (AI 호출)
|
||||
|
||||
| 메서드 | 용도 | 파라미터 |
|
||||
|--------|------|----------|
|
||||
| `agent` | AI에게 질문 | `{message: '...', sessionId: '...'}` |
|
||||
|
||||
---
|
||||
|
||||
## 실제 구현 예제
|
||||
|
||||
### 예제 1: 상태 조회 (토큰 0)
|
||||
|
||||
```python
|
||||
# services/clawdbot_client.py 참고
|
||||
|
||||
async def _get_gateway_status():
|
||||
"""세션 목록 조회 — 토큰 소비 없음"""
|
||||
# ... (연결 코드 생략)
|
||||
|
||||
# sessions.list 요청
|
||||
list_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'sessions.list',
|
||||
'params': {'limit': 10}
|
||||
}
|
||||
await ws.send(json.dumps(list_frame))
|
||||
|
||||
# 응답 대기
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
if msg.get('event'): # 이벤트는 무시
|
||||
continue
|
||||
if msg.get('id') == list_frame['id']:
|
||||
return msg.get('payload', {})
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"key": "agent:main:main",
|
||||
"totalTokens": 30072,
|
||||
"contextTokens": 200000,
|
||||
"model": "claude-opus-4-5"
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
"model": "claude-opus-4-5",
|
||||
"contextTokens": 200000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 예제 2: AI 호출 (토큰 소비)
|
||||
|
||||
```python
|
||||
async def ask_ai(message, session_id='my-session', model=None):
|
||||
"""AI에게 질문 — 토큰 소비함"""
|
||||
# ... (연결 코드)
|
||||
|
||||
# 모델 오버라이드 (선택)
|
||||
if model:
|
||||
patch_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'sessions.patch',
|
||||
'params': {'key': session_id, 'model': model}
|
||||
}
|
||||
await ws.send(json.dumps(patch_frame))
|
||||
# 응답 대기...
|
||||
|
||||
# agent 요청
|
||||
agent_frame = {
|
||||
'type': 'req',
|
||||
'id': str(uuid.uuid4()),
|
||||
'method': 'agent',
|
||||
'params': {
|
||||
'message': message,
|
||||
'sessionId': session_id,
|
||||
'sessionKey': session_id,
|
||||
'timeout': 60,
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(agent_frame))
|
||||
|
||||
# 응답 대기 (accepted → final)
|
||||
while True:
|
||||
msg = json.loads(await ws.recv())
|
||||
if msg.get('event'):
|
||||
continue
|
||||
if msg.get('id') == agent_frame['id']:
|
||||
if msg.get('payload', {}).get('status') == 'accepted':
|
||||
continue # 아직 처리 중
|
||||
# 최종 응답
|
||||
payloads = msg.get('payload', {}).get('result', {}).get('payloads', [])
|
||||
return '\n'.join(p.get('text', '') for p in payloads)
|
||||
```
|
||||
|
||||
### 예제 3: 모델 오버라이드
|
||||
|
||||
비싼 Opus 대신 저렴한 Sonnet 사용:
|
||||
|
||||
```python
|
||||
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5'
|
||||
|
||||
response = await ask_ai(
|
||||
message="추천 멘트 만들어줘",
|
||||
session_id='upsell-customer1',
|
||||
model=UPSELL_MODEL # Sonnet으로 오버라이드
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flask API 엔드포인트 예제
|
||||
|
||||
```python
|
||||
# app.py
|
||||
|
||||
@app.route('/api/claude-status')
|
||||
def api_claude_status():
|
||||
"""토큰 차감 없이 상태 조회"""
|
||||
from services.clawdbot_client import get_claude_status
|
||||
|
||||
status = get_claude_status()
|
||||
|
||||
if not status.get('connected'):
|
||||
return jsonify({'ok': False, 'error': status.get('error')}), 503
|
||||
|
||||
sessions = status.get('sessions', {})
|
||||
# ... 데이터 가공
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'context': {'used': 30000, 'max': 200000, 'percent': 15},
|
||||
'model': 'claude-opus-4-5'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 토큰 관리 전략
|
||||
|
||||
### 모델별 용도 분리
|
||||
|
||||
| 용도 | 모델 | 이유 |
|
||||
|------|------|------|
|
||||
| 메인 컨트롤러 | Claude Opus | 복잡한 추론, 도구 사용 |
|
||||
| 단순 생성 (업셀링 등) | Claude Sonnet | 빠르고 저렴 |
|
||||
| 코딩 작업 | GPT-5 Codex | 정식 지원, 안정적 |
|
||||
|
||||
### 세션 분리
|
||||
|
||||
```python
|
||||
# 용도별 세션 ID 분리
|
||||
ask_ai("...", session_id='upsell-고객명') # 업셀링 전용
|
||||
ask_ai("...", session_id='analysis-daily') # 분석 전용
|
||||
ask_ai("...", session_id='chat-main') # 일반 대화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 1. "invalid connect params" 에러
|
||||
|
||||
```
|
||||
at /client/id: must be equal to constant
|
||||
at /client/mode: must be equal to constant
|
||||
```
|
||||
|
||||
**해결**: `client.id`는 `'gateway-client'`, `client.mode`는 `'backend'` 사용
|
||||
|
||||
### 2. Gateway 연결 실패
|
||||
|
||||
```python
|
||||
ConnectionRefusedError: [WinError 10061]
|
||||
```
|
||||
|
||||
**해결**: Clawdbot Gateway가 실행 중인지 확인
|
||||
```bash
|
||||
clawdbot gateway status
|
||||
```
|
||||
|
||||
### 3. CLI 명령어가 hang됨
|
||||
|
||||
Clawdbot 내부(agent 세션)에서 `clawdbot status` 같은 CLI 호출하면 충돌.
|
||||
→ WebSocket API 직접 사용할 것
|
||||
|
||||
---
|
||||
|
||||
## 파일 위치
|
||||
|
||||
```
|
||||
pharmacy-pos-qr-system/
|
||||
└── backend/
|
||||
└── services/
|
||||
└── clawdbot_client.py # Gateway 클라이언트 구현
|
||||
└── app.py # Flask API (/api/claude-status)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- Clawdbot 문서: `C:\Users\청춘약국\AppData\Roaming\npm\node_modules\clawdbot\docs\`
|
||||
- Gateway 프로토콜: `docs/gateway/protocol.md`
|
||||
- 설정 예제: `docs/gateway/configuration-examples.md`
|
||||
|
||||
---
|
||||
|
||||
*작성: 2026-02-27 | 용림 🐉*
|
||||
@@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods;
|
||||
4. **효과 추적**: 3개월 후 재검사 결과 비교
|
||||
|
||||
---
|
||||
|
||||
**작성자**: Claude Sonnet 4.5
|
||||
**버전**: 1.0
|
||||
**최종 수정**: 2026-02-04
|
||||
|
||||
456
docs/kakao-chanell-rest-api.md
Normal file
456
docs/kakao-chanell-rest-api.md
Normal file
@@ -0,0 +1,456 @@
|
||||
REST API
|
||||
이 문서는 REST API를 이용하여 카카오톡 채널 관계 조회 및 카카오톡 채널 고객 관리 기능을 구현하는 방법을 안내합니다.
|
||||
|
||||
카카오톡 채널 관계 조회
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
GET https://kapi.kakao.com/v2/api/talk/channels 액세스 토큰
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
필요: 동의항목 어드민 키
|
||||
카카오 로그인 활성화
|
||||
동의항목
|
||||
앱에 카카오톡 채널 연결 필요 필요:
|
||||
카카오톡 채널 추가 상태 및 내역
|
||||
|
||||
신규 API 제공 안내
|
||||
카카오톡 채널 관계 조회 API가 v2 버전으로 업그레이드되었습니다. 기존 API 정보는 별도 문서에서 확인할 수 있습니다.
|
||||
|
||||
참고
|
||||
사용자가 서비스와 연결된 카카오톡 채널을 추가 또는 차단했을 때 알림을 받으려면 카카오톡 채널 웹훅을 사용합니다.
|
||||
|
||||
현재 로그인한 사용자와 앱에 연결된 카카오톡 채널의 친구 관계를 확인합니다.
|
||||
|
||||
사용자 액세스 토큰(Access Token)을 헤더에 담아 GET으로 요청합니다. 서비스 서버에서 관리자가 요청할 경우, 앱별 어드민 키(Admin Key)로 특정 사용자의 카카오톡 채널 관계를 확인할 수 있습니다. 어드민 키는 보안에 유의해야 하므로 서버에서 호출할 때만 사용해야 합니다.
|
||||
|
||||
특정 카카오톡 채널의 정보만 받아보려면 channel_ids 파라미터로 해당 카카오톡 채널의 프로필 ID를 지정하여 요청합니다.
|
||||
|
||||
요청 성공 시 응답은 서비스 앱과 연결된 카카오톡 채널과 사용자의 관계 정보를 제공합니다. 각 카카오톡 채널 정보는 사용자와 카카오톡 채널의 현재 관계, 변경 시점과 같은 자세한 정보를 포함합니다.
|
||||
|
||||
사용자가 [카카오톡 채널 추가 상태 및 내역] 동의항목에 동의하지 않아 에러 응답을 받았을 경우, 동의항목 추가 동의 요청 기능을 사용해 사용자에게 다시 동의를 요청할 수 있습니다.
|
||||
|
||||
요청: 액세스 토큰 방식
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: Bearer ${ACCESS_TOKEN}
|
||||
인증 방식, 액세스 토큰으로 인증 요청 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
channel_ids String 사용자와의 친구 관계를 확인할 카카오톡 채널 프로필 ID 목록
|
||||
쉼표로 구분된 하나의 문자열로 전달
|
||||
(예: _Bxkd,_RQxl,_vxfxm, 기본값: 앱과 연결된 모든 카카오톡 채널의 프로필 ID 목록)
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 X
|
||||
channel_id_type String 카카오톡 채널 ID 타입, channel_public_id로 고정 X
|
||||
요청: 서비스 앱 어드민 키 방식
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}
|
||||
인증 방식, 서비스 앱 어드민 키로 인증 요청 O
|
||||
Content-Type Content-Type: application/x-www-form-urlencoded;charset=utf-8
|
||||
요청 데이터 타입 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
target_id String 회원번호 O
|
||||
target_id_type String 사용자 ID 타입, user_id로 고정 O
|
||||
channel_ids String 사용자와의 친구 관계를 확인할 카카오톡 채널 프로필 ID 목록
|
||||
쉼표로 구분된 하나의 문자열로 전달
|
||||
(예: _Bxkd,_RQxl,_vxfxm, 기본값: 앱과 연결된 모든 카카오톡 채널의 프로필 ID 목록)
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 X
|
||||
channel_id_type String 카카오톡 채널 ID 타입, channel_public_id로 고정 X
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
user_id Long 회원번호 O
|
||||
channels Channels[] 카카오톡 채널 정보 X
|
||||
Channels
|
||||
이름 타입 설명 필수
|
||||
channel_uuid String 카카오톡 채널의 검색용 ID O
|
||||
channel_public_id String 카카오톡 채널 프로필 ID O
|
||||
relation String 카카오톡 채널과 사용자 관계
|
||||
ADDED: 카카오톡 채널이 추가된 상태
|
||||
BLOCKED: 카카오톡 채널이 차단된 상태
|
||||
NONE: 카카오톡 채널이 추가되거나 차단된 적 없는 상태 O
|
||||
created_at Datetime 카카오톡 채널 추가 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 상태인 경우만 포함 X
|
||||
updated_at Datetime 카카오톡 채널 상태 변경 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 또는 차단(BLOCKED)된 상태일 경우만 포함 X
|
||||
* UTC: 한국 시간(KST)과 9시간 차이, RFC3339: Date and Time on the Internet 참고
|
||||
|
||||
예제
|
||||
요청: 액세스 토큰 방식
|
||||
curl -v -G GET "https://kapi.kakao.com/v2/api/talk/channels" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-d "channel_ids=_frxjem,_xnrxjem,_Brxjem"
|
||||
요청: 서비스 앱 어드민 키 방식
|
||||
curl -v -G GET "https://kapi.kakao.com/v2/api/talk/channels" \
|
||||
-H "Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}" \
|
||||
-d "target_id_type=user_id" \
|
||||
-d "target_id=${USER_ID}" \
|
||||
-d "channel_ids=_frxjem,_xnrxjem,_Brxjem"
|
||||
응답: 성공
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"user_id": ${USER_ID},
|
||||
"channels": [
|
||||
{
|
||||
"channel_uuid": "@테스트",
|
||||
"channel_public_id": "_ZeUTxl",
|
||||
"relation": "ADDED", // ADDED, BLOCKED, NONE 중 하나
|
||||
"created_at": "2020-04-18T03:17:05Z", // ADDED 상태일 때만 존재
|
||||
"updated_at": "2021-05-17T05:25:01Z" // ADDED, BLOCKED 상태일 때만 존재
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
응답: 실패, 카카오톡 미사용자를 대상으로 요청한 경우
|
||||
HTTP/1.1 400 Bad Request
|
||||
{
|
||||
"msg": "given account is not connected to any talk user.",
|
||||
"code": -501
|
||||
}
|
||||
여러 사용자 카카오톡 채널 관계 조회
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
GET https://kapi.kakao.com/v2/api/talk/channels/multi 서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
필요: 동의항목 어드민 키
|
||||
카카오 로그인 활성화
|
||||
동의항목
|
||||
앱에 카카오톡 채널 연결 필요 필요:
|
||||
카카오톡 채널 추가 상태 및 내역
|
||||
|
||||
참고
|
||||
사용자가 서비스와 연결된 카카오톡 채널을 추가 또는 차단했을 때 알림을 받으려면 카카오톡 채널 웹훅을 사용합니다.
|
||||
|
||||
앱에 연결된 카카오톡 채널과 여러 사용자의 친구 관계를 확인합니다. 전체 또는 그룹 단위의 사용자를 대상으로 특정 카카오톡 채널과의 친구 관계를 확인하는 데 사용합니다.
|
||||
|
||||
서비스 앱 어드민 키를 헤더에 담아 GET으로 요청합니다. 사용자 회원번호 목록, 카카오톡 채널 프로필 ID 목록을 쿼리 파라미터로 전달해야 합니다. 한 번에 최대 200명의 사용자를 대상으로 요청 가능합니다.
|
||||
|
||||
요청 처리 성공 시 응답은 각 사용자의 카카오톡 채널별 친구 관계 목록을 포함합니다. 확인에 실패한 사용자의 정보는 응답에서 제외됩니다.
|
||||
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}
|
||||
인증 방식, 서비스 앱 어드민 키로 인증 요청 O
|
||||
Content-Type Content-Type: application/x-www-form-urlencoded;charset=utf-8
|
||||
요청 데이터 타입 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
target_ids String 회원번호 목록, 쉼표로 구분된 하나의 문자열로 구성 O
|
||||
target_id_type String 사용자 ID 타입, user_id로 고정 O
|
||||
channel_ids String[] 사용자와의 친구 관계를 확인할 카카오톡 채널의 프로필 ID 목록, 쉼표로 구분된 하나의 문자열로 구성
|
||||
(예: _Bxkd,_RQxl,_vxfxm, 기본값: 앱과 연결된 모든 카카오톡 채널의 프로필 ID 목록)
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 X
|
||||
channel_id_type String 카카오톡 채널 ID 타입, channel_public_id로 고정 X
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
- TalkChannelsResult[] 각 사용자의 카카오톡 채널별 친구 관계 목록 O
|
||||
TalkChannelsResult
|
||||
이름 타입 설명 필수
|
||||
user_id Long 회원번호 O
|
||||
channels TalkChannelRelation[] 각 카카오톡 채널과 사용자의 관계 정보 X
|
||||
TalkChannelRelation
|
||||
이름 타입 설명 필수
|
||||
channel_public_id String 카카오톡 채널 프로필 ID O
|
||||
channel_uuid String 카카오톡 채널의 검색용 ID O
|
||||
relation String 카카오톡 채널과 사용자 관계
|
||||
ADDED: 카카오톡 채널이 추가된 상태
|
||||
BLOCKED: 카카오톡 채널이 차단된 상태
|
||||
NONE: 카카오톡 채널이 추가되거나 차단된 적 없는 상태 O
|
||||
created_at Datetime 카카오톡 채널 추가 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 상태인 경우만 포함 X
|
||||
updated_at Datetime 카카오톡 채널 상태 변경 시간, UTC*
|
||||
카카오톡 채널이 추가(ADDED) 또는 차단(BLOCKED)된 상태일 경우만 포함 X
|
||||
* UTC: 한국 시간(KST)과 9시간 차이, RFC3339: Date and Time on the Internet 참고
|
||||
|
||||
예제
|
||||
요청
|
||||
curl -v -G GET "https://kapi.kakao.com/v2/api/talk/channels/multi" \
|
||||
-H "Authorization: KakaoAK ${SERVICE_APP_ADMIN_KEY}" \
|
||||
-d "target_id_type=user_id" \
|
||||
-d "target_ids=${USER_ID_1},${USER_ID_2},${USER_ID_3}" \
|
||||
--data-urlencode 'channel_ids=_frxjem,_xnrxjem,_Brxjem'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
[
|
||||
{
|
||||
"user_id": ${USER_ID_1},
|
||||
"channels": [
|
||||
{
|
||||
"channel_public_id": "_xnrxjem",
|
||||
"channel_uuid": "@플러스친구",
|
||||
"relation": "ADDED",
|
||||
"created_at": "2022-11-09T07:08:48Z",
|
||||
"updated_at": "2023-07-20T07:21:05Z"
|
||||
}
|
||||
]
|
||||
}, {
|
||||
"user_id": ${USER_ID_2},
|
||||
"channels": [
|
||||
{
|
||||
"channel_public_id": "_xnrxjem",
|
||||
"channel_uuid": "@플러스친구",
|
||||
"relation": "NONE"
|
||||
}
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
응답: 확인에 실패한 사용자 제외
|
||||
HTTP/1.1 200 OK
|
||||
[
|
||||
{
|
||||
"user_id": ${USER_ID_1},
|
||||
"channels": [
|
||||
{
|
||||
"channel_public_id": "_xnrxjem",
|
||||
"channel_uuid": "@플러스친구",
|
||||
"relation": "ADDED",
|
||||
"created_at": "2022-11-09T07:08:48Z",
|
||||
"updated_at": "2023-07-20T07:21:05Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
고객 관리: 고객파일 등록
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
POST https://kapi.kakao.com/v1/talkchannel/create/target_user_file REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
새로운 고객파일을 만듭니다. 새 파일 이름은 file_name, 적용할 필터링 기준은 schema에 각각 정의합니다. 한 번 정의한 스키마(Schema)는 수정할 수 없으니 주의합니다.
|
||||
|
||||
새 고객파일을 만드는 데 성공하면 file_id 값으로 등록된 파일 ID가 반환됩니다. 파일 ID는 해당 파일에 사용자를 추가하거나 제외할 때 사용합니다.
|
||||
|
||||
제약 사항: 스키마
|
||||
카카오톡 채널 고객 관리 API를 이용하여 고객파일을 등록할 경우, 반드시 지정된 스키마 규칙을 따라야 합니다.
|
||||
|
||||
고객의 데이터가 문자열(String)인 경우, 지원하는 키만 사용 가능
|
||||
생년월일, 국가, 지역, 성별, 연령, 구매금액, 포인트, 가입일, 최근 구매일, 응모일
|
||||
새로운 키 추가 시 "앱유저아이디" 또는 "전화번호" 키 사용 불가, 키에 해당하는 값은 숫자(Number) 자료형만 허용
|
||||
스키마는 최대 30개 항목 포함 가능
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
schema JSON 고객파일에 등록되는 데이터 항목과 항목의 종류를 정의
|
||||
키(Key)와 값(Value)의 JSON 자료형(Type)으로 구성
|
||||
키: 생년월일, 국가, 지역, 성별, 연령, 구매금액, 포인트, 가입일, 최근 구매일, 응모일
|
||||
값의 자료형: String 또는 Number O
|
||||
file_name String 관리할 파일의 이름 O
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명
|
||||
file_id Integer 등록된 고객파일 ID
|
||||
예제
|
||||
요청
|
||||
curl -v -X POST "https://kapi.kakao.com/v1/talkchannel/create/target_user_file" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"channel_public_id": "_ZeUTxl",
|
||||
"file_name": "vip고객리스트",
|
||||
"schema":{
|
||||
"생년월일":"string",
|
||||
"성별":"string",
|
||||
"연령":"number"
|
||||
}
|
||||
}'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
{
|
||||
"file_id" : 437
|
||||
}
|
||||
고객 관리: 고객파일 조회
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
GET https://kapi.kakao.com/v1/talkchannel/target_user_file REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
카카오톡 채널에 등록된 고객파일 정보들을 확인합니다. 어떤 카카오톡 채널에 등록된 파일 정보들을 알고 싶은지 channel_public_id 파라미터의 값을 명시하여 요청합니다. 요청 성공 시 해당 카카오톡 채널에 등록된 고객파일들의 정보를 받습니다.
|
||||
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
쿼리 파라미터
|
||||
이름 타입 설명 필수
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명
|
||||
empty_slot Integer 사용 가능한 슬롯 수
|
||||
using_slot Integer 사용 중인 슬롯 수
|
||||
results Results[] 카카오톡 채널에 등록된 고객파일들의 정보
|
||||
Results
|
||||
이름 타입 설명
|
||||
file_id Integer 파일 ID
|
||||
file_name String 파일 이름
|
||||
status String 파일 상태
|
||||
using, deleting, failed 중 하나
|
||||
update_at String 파일이 업로드 된 시간
|
||||
schema JSON 파일에 등록된 데이터 항목과 항목의 종류
|
||||
예제
|
||||
요청
|
||||
curl -v -G GET "https://kapi.kakao.com/v1/talkchannel/target_user_file" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-d "channel_public_id=_ZeUTxl"
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
{
|
||||
"empty_slot":27,
|
||||
"using_slot":3,
|
||||
"results":[
|
||||
{
|
||||
"file_id":437,
|
||||
"file_name": "vip고객리스트",
|
||||
"status":"USING",
|
||||
"update_at":"2019-02-03 13:22:33",
|
||||
"schema": "{\"생년월일\":\"string\",\"성별\":\"string\",\"age\":\"number\"}"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
}
|
||||
고객 관리: 고객파일에 사용자 추가
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
POST https://kapi.kakao.com/v1/talkchannel/update/target_users REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
고객파일에 사용자 정보를 추가합니다. 한 번에 2,000명 이하의 고객 정보를 업로드할 수 있습니다. 각 사용자를 구분하는 값인 id는 회원번호(user_id)와 카카오톡 전화번호 중 하나여야 하고, 지정된 타입에 맞는 값을 입력해야 합니다. 스키마의 경우, 파일마다 다르게 지정되어 있으나 예제를 참고해 JSON 배열 형식으로 값을 전달합니다.
|
||||
|
||||
요청 성공 시, 어떤 고객파일에 대한 요청이었는지 알려주는 file_id와 고객파일에 추가 요청한 사용자 수, 실제로 추가된 사용자 수를 각각 받습니다. 추가 대상 사용자 정보가 유효하지 않거나 아래의 경우에는 고객파일에 사용자가 추가되지 않습니다. 따라서 추가 요청 사용자 수와 실제로 추가된 사용자 수는 차이가 날 수 있습니다.
|
||||
|
||||
참고: 고객파일에 일부 사용자가 추가되지 않은 경우 확인 항목
|
||||
아래 내용을 확인합니다.
|
||||
|
||||
카카오톡 채널과 친구 상태인 사용자만 고객파일에 추가 가능합니다.
|
||||
user_type이 app인 경우, ID 값이 카카오 로그인으로 발급된 회원번호(user id)여야 합니다. 즉, 해당 사용자가 카카오계정으로 서비스에 연결된 상태여야 합니다.
|
||||
user_type이 phone인 경우, ID 값이 카카오톡에 가입되어 있는 전화번호여야 합니다.
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
file_id Integer 파일 ID O
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
user_type String 등록할 사용자 ID의 기준 값
|
||||
app(회원번호) 또는 phone(카카오톡 전화번호) 중 하나 O
|
||||
users User[] 추가할 사용자 상세 정보 목록, ID와 스키마 값 포함 O
|
||||
User
|
||||
이름 타입 설명 필수
|
||||
id String 사용자 ID O
|
||||
field JSON 지정된 스키마 대한 값
|
||||
key, value 형태로 입력
|
||||
|
||||
참고: Number 또는 String 타입만 허용, String 타입인 경우 지정된 문자열만 사용 가능, 문자열은 카카오톡 채널 파트너센터 공지에 명시된 항목만 지정된 형식으로 변환해 입력 가능 O
|
||||
응답
|
||||
본문
|
||||
이름 타입 설명
|
||||
file_id Integer 파일 ID
|
||||
request_count Integer 고객파일에 추가 요청한 사용자 수
|
||||
success_count Integer 고객파일에 추가된 사용자 수
|
||||
예제
|
||||
요청
|
||||
curl -v -X POST "https://kapi.kakao.com/v1/talkchannel/update/target_users" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"file_id": 437,
|
||||
"channel_public_id": "_ZeUTxl",
|
||||
"user_type": "app",
|
||||
"users": [
|
||||
{
|
||||
"id": "12345",
|
||||
"field" : {
|
||||
"생년월일": "2000-01-01",
|
||||
"성별": "남자",
|
||||
"age": 19
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
}'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
{
|
||||
"file_id": 437,
|
||||
"request_count": 10,
|
||||
"success_count": 9
|
||||
}
|
||||
고객 관리: 고객파일의 사용자 삭제
|
||||
기본 정보
|
||||
메서드 URL 인증 방식
|
||||
POST https://kapi.kakao.com/v1/talkchannel/delete/target_users REST API 키
|
||||
서비스 앱 어드민 키
|
||||
권한 사전 설정 카카오 로그인 동의항목
|
||||
- REST API 키
|
||||
어드민 키
|
||||
고객 관리 API 정책 동의 - -
|
||||
|
||||
카카오톡 채널에 등록된 고객파일에서 특정 사용자를 삭제합니다. 고객파일에서 사용자를 삭제할 때는 성공 시에도 응답 본문이 없습니다. HTTP 상태 코드를 참고해 성공 여부를 판단합니다.
|
||||
|
||||
요청
|
||||
헤더
|
||||
이름 설명 필수
|
||||
Authorization Authorization: KakaoAK ${APP_KEY}
|
||||
인증 방식, REST API 키 또는 서비스 앱 어드민 키로 인증 요청 O
|
||||
본문
|
||||
이름 타입 설명 필수
|
||||
file_id Integer 파일 ID O
|
||||
channel_public_id String 카카오톡 채널 프로필 ID
|
||||
|
||||
참고: 카카오톡 채널 프로필 ID 확인 방법 O
|
||||
user_type String 삭제할 사용자 ID의 기준 값
|
||||
app(회원번호) 또는 phone(카카오톡 전화번호) 중 하나 O
|
||||
user_ids JSON[] 삭제할 사용자 ID 목록 O
|
||||
예제
|
||||
요청
|
||||
curl -v -X POST "https://kapi.kakao.com/v1/talkchannel/delete/target_users" \
|
||||
-H "Authorization: KakaoAK ${APP_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"file_id" : 437,
|
||||
"channel_public_id" : "_ZeUTxl",
|
||||
"user_type" : "app"
|
||||
"user_ids" : ["12345"]
|
||||
}'
|
||||
응답
|
||||
HTTP/1.1 200 OK
|
||||
Content-Length: 0
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
더 보기
|
||||
146
docs/kakao-channel-integration.md
Normal file
146
docs/kakao-channel-integration.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 카카오톡 채널 연동 분석
|
||||
|
||||
## 현재 상태
|
||||
- 카카오톡 채널 개설 완료
|
||||
- 앱과 채널 연결 완료
|
||||
|
||||
---
|
||||
|
||||
## API 종류 및 우리 서비스 적용 가능성
|
||||
|
||||
### 1. 채널 관계 조회 API
|
||||
- **기능**: 사용자가 우리 채널을 친구 추가했는지 확인
|
||||
- **우선순위**: 낮음 (나중에)
|
||||
- **활용**: 적립 완료 시 채널 친구가 아니면 "채널 추가하면 생일 2배 적립 알림을 받을 수 있어요!" 유도 배너
|
||||
|
||||
### 2. 고객파일 관리 API
|
||||
- **기능**: 채널 친구인 고객의 데이터를 카카오에 업로드 → 파트너센터에서 세그먼트 필터링
|
||||
- **우선순위**: 낮음 (고객 수백 명 이상 쌓인 후)
|
||||
- **활용**: 파트너센터에서 "포인트 5000 이상 고객"에게 친구톡 발송 등
|
||||
|
||||
#### 고객파일 스키마 (사용 가능한 키)
|
||||
```
|
||||
생년월일, 국가, 지역, 성별, 연령, 구매금액, 포인트, 가입일, 최근 구매일, 응모일
|
||||
```
|
||||
- 이 값들은 **카카오가 제공하는 것이 아님**
|
||||
- **우리 DB에서 꺼내서 카카오에 업로드**하는 것
|
||||
- 고객 본인이 카카오톡에서 보는 게 아니라, **약국(관리자)이 파트너센터에서 고객 분류/메시지 발송할 때 사용**하는 필터링 기준
|
||||
|
||||
---
|
||||
|
||||
## 메시지 발송 수단 비교
|
||||
|
||||
### 알림톡 (정보성 메시지)
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | 전화번호만 있으면 **누구에게나** (채널 친구 불필요) |
|
||||
| 템플릿 | 카카오 사전 심사 필수 (정형화된 형식) |
|
||||
| 용도 | 정보성 메시지 (적립 완료 알림, 주문 확인 등) |
|
||||
| 비용 | ~8원/건 |
|
||||
| 발송 방법 | NHN Cloud 알림톡 API |
|
||||
|
||||
### 친구톡 (마케팅 메시지)
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | **채널 친구에게만** |
|
||||
| 템플릿 | 자유 형식 (이미지, 버튼 등 자유롭게 구성) |
|
||||
| 용도 | 광고/마케팅 메시지 (생일 이벤트, 프로모션 등) |
|
||||
| 비용 | ~15원/건 |
|
||||
| 발송 방법 | NHN Cloud 친구톡 API 또는 카카오 파트너센터에서 직접 발송 |
|
||||
|
||||
### SMS/LMS (문자)
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | 전화번호만 있으면 누구에게나 |
|
||||
| 템플릿 | 제한 없음 |
|
||||
| 용도 | 범용 |
|
||||
| 비용 | SMS ~20원, LMS ~50원 |
|
||||
| 발송 방법 | NHN Cloud SMS API |
|
||||
|
||||
### 카카오 파트너센터 직접 발송
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 대상 | 채널 친구만 |
|
||||
| 방법 | 파트너센터 웹에서 수동 발송 |
|
||||
| 활용 | 고객파일 세그먼트 기반 타겟 메시지 |
|
||||
| 특징 | API 개발 불필요, UI에서 직접 조작 |
|
||||
|
||||
---
|
||||
|
||||
## 우리 서비스에 적용할 알림톡 시나리오
|
||||
|
||||
### 시나리오 1: QR 적립 완료 알림 (현재 불필요 → 키오스크 도입 시 필요)
|
||||
|
||||
현재는 고객이 직접 QR 스캔 → 적립 완료 화면을 본인이 확인하므로 알림 불필요.
|
||||
|
||||
**키오스크 도입 후**: 약사가 키오스크에서 직접 적립 → 고객은 화면을 못 봄 → 알림톡 필요
|
||||
|
||||
```
|
||||
[청춘약국] 마일리지 적립 완료
|
||||
|
||||
{고객명}님, 마일리지가 적립되었습니다.
|
||||
|
||||
- 적립 포인트: +3,500P
|
||||
- 총 잔액: 12,800P
|
||||
- 적립일시: 2026.02.25 14:30
|
||||
|
||||
▶ 내역 확인: https://mile.0bin.in/my-page
|
||||
```
|
||||
|
||||
### 시나리오 2: 포인트 사용 알림
|
||||
|
||||
```
|
||||
[청춘약국] 포인트 사용 완료
|
||||
|
||||
{고객명}님, 포인트가 사용되었습니다.
|
||||
|
||||
- 사용 포인트: -5,000P
|
||||
- 남은 잔액: 7,800P
|
||||
|
||||
▶ 내역 확인: https://mile.0bin.in/my-page
|
||||
```
|
||||
|
||||
### 시나리오 3: 생일 축하 (친구톡 — 채널 친구만)
|
||||
|
||||
```
|
||||
🎂 {고객명}님, 생일 축하드립니다!
|
||||
|
||||
오늘 청춘약국에서 구매하시면
|
||||
마일리지 포인트 2배 적립!
|
||||
|
||||
▶ 청춘약국 방문하기
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 로드맵
|
||||
|
||||
### Phase 1 (현재)
|
||||
- [x] 카카오 채널 개설 및 앱 연결
|
||||
- [ ] 카카오 스코프 심사 통과 (phone_number, birthday, birthyear)
|
||||
|
||||
### Phase 2 (키오스크 도입 시)
|
||||
- [ ] NHN Cloud 알림톡 API 연동
|
||||
- [ ] 알림톡 템플릿 등록 (적립 완료, 포인트 사용)
|
||||
- [ ] 키오스크 적립 시 알림톡 자동 발송
|
||||
|
||||
### Phase 3 (고객 확보 후)
|
||||
- [ ] 채널 친구 추가 유도 (적립 완료 화면에 배너)
|
||||
- [ ] 생일 축하 친구톡 발송 (birthday 데이터 활용)
|
||||
- [ ] (선택) 카카오 고객파일 동기화 → 파트너센터 세그먼트 마케팅
|
||||
|
||||
---
|
||||
|
||||
## 필요한 환경변수 (Phase 2 시점)
|
||||
|
||||
```env
|
||||
# NHN Cloud 알림톡
|
||||
NHN_CLOUD_APP_KEY=xxx
|
||||
NHN_CLOUD_SECRET_KEY=xxx
|
||||
NHN_ALIMTALK_SENDER_KEY=xxx # 카카오 채널 발신 프로필 키
|
||||
NHN_ALIMTALK_TEMPLATE_CODE=xxx # 적립 완료 템플릿 코드
|
||||
```
|
||||
|
||||
## 참고
|
||||
- 알림톡 템플릿은 카카오 비즈니스 채널 관리자에서 등록 후 검수 받아야 함 (1~2일 소요)
|
||||
- NHN Cloud 알림톡 발송 시 카카오톡 미설치 사용자에게는 자동으로 SMS 대체 발송 가능 (추가 비용)
|
||||
@@ -7,6 +7,118 @@
|
||||
- **앱 유형**: 비즈 앱
|
||||
- **개발자 콘솔**: https://developers.kakao.com/console/app/1165131
|
||||
|
||||
---
|
||||
|
||||
## 플랫폼 키 (앱 > 플랫폼 키)
|
||||
|
||||
| 키 종류 | 값 | 용도 |
|
||||
|---------|---|------|
|
||||
| **Native App Key** | `346b84c4e018e20f0f8` | Android/iOS 네이티브 앱 (현재 미사용) |
|
||||
| **JavaScript Key** | `3d1e098107157c5021b73bd5ab48600f` | 카카오 JS SDK (프론트엔드) |
|
||||
| **REST API Key** | `caad27ac4bc92d8dc83bdd6aae744811` | 서버 간 API 호출 (현재 사용 중) |
|
||||
| **Admin Key** | (콘솔에서 확인) | 서버 관리 기능 (사용 주의) |
|
||||
|
||||
### 키 사용 구분
|
||||
|
||||
```
|
||||
[현재 구현] REST API 방식
|
||||
프론트엔드 → 302 리다이렉트 → 카카오 웹 로그인 페이지 → 콜백
|
||||
사용 키: REST API Key (서버 환경변수 KAKAO_CLIENT_ID)
|
||||
|
||||
[향후 전환] JS SDK 방식
|
||||
프론트엔드 → Kakao.Auth.authorize() → 카카오톡 앱 직접 실행 → 콜백
|
||||
사용 키: JavaScript Key (프론트엔드 HTML에 노출)
|
||||
```
|
||||
|
||||
### Client Secret
|
||||
|
||||
```
|
||||
앱 > 보안 > Client Secret 코드
|
||||
```
|
||||
- 환경변수: `KAKAO_CLIENT_SECRET`
|
||||
- REST API 토큰 교환 시 필수
|
||||
|
||||
---
|
||||
|
||||
## REST API vs JS SDK 비교
|
||||
|
||||
| 항목 | REST API (폴백) | JS SDK (현재 적용) |
|
||||
|------|----------------|-------------------|
|
||||
| **인증 키** | REST API Key | JavaScript Key |
|
||||
| **로그인 UX** | 웹 브라우저에서 카카오 로그인 페이지 표시 (매번 동의 확인) | 카카오톡 앱이 직접 열림 → 원탭 동의 → 즉시 복귀 |
|
||||
| **모바일 경험** | 웹뷰 로그인 (느림) | 앱 ↔ 앱 전환 (빠름) |
|
||||
| **앱 미설치 시** | 웹 로그인 표시 | 자동으로 웹 로그인 폴백 |
|
||||
| **백엔드** | `kakao_client.get_authorization_url()` | 변경 없음 (콜백 동일) |
|
||||
| **보안** | 키가 서버에만 존재 | JavaScript Key는 공개 가능 (도메인 제한으로 보호) |
|
||||
|
||||
### 현재 적용 상태 (JS SDK)
|
||||
|
||||
JS SDK가 적용된 페이지:
|
||||
- `claim_form.html` — QR 적립 시 "카카오로 적립하기" 버튼
|
||||
- `my_page_login.html` — 마이페이지 "카카오로 조회하기" 버튼
|
||||
|
||||
서버 리다이렉트 유지 페이지 (보조 진입점):
|
||||
- `index.html`, `my_page.html`, `signup.html`, `error.html` → `/my-page/kakao/start`
|
||||
|
||||
### JS SDK 동작 방식
|
||||
|
||||
```
|
||||
모바일:
|
||||
카카오톡 앱 설치됨 → 앱으로 전환 (원탭 로그인) → 콜백
|
||||
카카오톡 앱 미설치 → 웹 로그인 페이지로 자동 폴백 → 콜백
|
||||
|
||||
PC:
|
||||
항상 웹 로그인 페이지 표시 → 콜백
|
||||
|
||||
JS SDK 로드 실패 시:
|
||||
서버 리다이렉트 폴백 (/claim/kakao/start 또는 /my-page/kakao/start)
|
||||
```
|
||||
|
||||
### 다른 카카오 계정으로 적립 (향후 구현)
|
||||
|
||||
폰이 2대이거나 다른 계정으로 적립하고 싶은 경우:
|
||||
|
||||
```javascript
|
||||
// 기본: 카카오톡 앱 계정으로 바로 로그인
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: '...',
|
||||
state: '...'
|
||||
});
|
||||
|
||||
// 다른 계정으로: 기존 세션 무시, 계정 입력 강제
|
||||
Kakao.Auth.authorize({
|
||||
redirectUri: '...',
|
||||
state: '...',
|
||||
prompt: 'login' // ← 핵심 파라미터
|
||||
});
|
||||
```
|
||||
|
||||
UI 구성안:
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ [카카오로 적립하기] │ ← 기본 (앱 → 원탭)
|
||||
│ │
|
||||
│ 다른 카카오 계정으로 적립 → │ ← prompt:'login'
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 카카오 개발자 콘솔 필수 설정
|
||||
|
||||
> **중요**: JS SDK 사용 시 JavaScript 키에도 Redirect URI 등록 필요
|
||||
|
||||
```
|
||||
앱 > 플랫폼 키 > JavaScript 키 클릭 > 리다이렉트 URI
|
||||
→ https://mile.0bin.in/claim/kakao/callback 추가
|
||||
```
|
||||
|
||||
Web 플랫폼 도메인도 등록 확인:
|
||||
```
|
||||
앱 > 플랫폼 > Web > 사이트 도메인
|
||||
→ https://mile.0bin.in 포함 확인
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redirect URI 등록 (2025년 12월 개편 후)
|
||||
|
||||
> **주의**: 2025년 12월 카카오 콘솔 UI가 개편되면서 Redirect URI 위치가 변경됨.
|
||||
@@ -18,12 +130,6 @@
|
||||
앱 > 플랫폼 키 > REST API 키 클릭 > 리다이렉트 URI
|
||||
```
|
||||
|
||||
### 이전 경로 (~ 2025.11, 더 이상 사용 안 함)
|
||||
|
||||
```
|
||||
카카오 로그인 > 일반 > Redirect URI ← 여기 더 이상 없음
|
||||
```
|
||||
|
||||
### 등록된 Redirect URI 목록
|
||||
|
||||
| 서비스 | Redirect URI |
|
||||
@@ -41,6 +147,8 @@
|
||||
|
||||
로그인용 Redirect URI와 혼동하지 않도록 주의.
|
||||
|
||||
---
|
||||
|
||||
## 웹 도메인 등록
|
||||
|
||||
```
|
||||
@@ -59,51 +167,73 @@
|
||||
- `https://ka.0bin.in`
|
||||
- `https://mile.0bin.in`
|
||||
|
||||
---
|
||||
|
||||
## 동의항목 설정
|
||||
|
||||
```
|
||||
카카오 로그인 > 동의항목
|
||||
```
|
||||
|
||||
| 항목 | ID | 용도 | 비즈앱 필요 |
|
||||
|------|-----|------|------------|
|
||||
| 닉네임 | profile_nickname | 사용자 이름 | X |
|
||||
| 프로필 사진 | profile_image | 아바타 | X |
|
||||
| 이메일 | account_email | 계정 연동 | X |
|
||||
| 이름 (실명) | name | 마일리지 적립자명 | O |
|
||||
| 전화번호 | phone_number | 마일리지 유저 매칭 | O |
|
||||
| 항목 | ID | 동의 목적 | 상태 | 비즈앱 필요 |
|
||||
|------|-----|----------|------|------------|
|
||||
| 닉네임 | profile_nickname | 사용자 식별 | 승인 | X |
|
||||
| 프로필 사진 | profile_image | 아바타 표시 | 승인 | X |
|
||||
| 이메일 | account_email | 계정 연동 | 승인 | X |
|
||||
| 이름 (실명) | name | 마일리지 적립자명 | 승인 | O |
|
||||
| 전화번호 | phone_number | 마일리지 적립 계정 식별 및 포인트 조회 | 승인 | O |
|
||||
| 생일 | birthday | 생일 기념 포인트 2배 적립 이벤트 제공 | 승인 | O |
|
||||
| 출생연도 | birthyear | 생일 기념 포인트 2배 적립 이벤트 제공 | 권한 없음 (미승인) | O |
|
||||
|
||||
### 현재 사용 중인 스코프
|
||||
|
||||
```
|
||||
profile_nickname,profile_image,account_email,name,phone_number,birthday
|
||||
```
|
||||
|
||||
> ⚠️ `birthyear`는 아직 권한 미승인 상태. 스코프에 포함하면 **KOE205 에러** 발생.
|
||||
> 승인되면 스코프에 추가하고, `kakao_client.py`의 scope 문자열 수정 필요.
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
```bash
|
||||
KAKAO_CLIENT_ID=<REST API 키>
|
||||
# 카카오 OAuth (REST API 방식)
|
||||
KAKAO_CLIENT_ID=caad27ac4bc92d8dc83bdd6aae744811 # REST API Key
|
||||
KAKAO_CLIENT_SECRET=<카카오 개발자 콘솔 > 앱 > 보안에서 확인>
|
||||
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
|
||||
|
||||
# JS SDK 전환 시 추가 (프론트엔드 전용, 서버 환경변수 불필요)
|
||||
# JavaScript Key: 3d1e098107157c5021b73bd5ab48600f
|
||||
```
|
||||
|
||||
### Client ID 확인 위치
|
||||
|
||||
```
|
||||
앱 > 플랫폼 키 > REST API 키 > 키 값
|
||||
```
|
||||
|
||||
### Client Secret 확인 위치
|
||||
|
||||
```
|
||||
앱 > 보안 > Client Secret 코드
|
||||
```
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
| 프로젝트 | 파일 | 설명 |
|
||||
|---------|------|------|
|
||||
| pharmacy-pos-qr-system | `backend/services/kakao_client.py` | 카카오 API 클라이언트 |
|
||||
| pharmacy-pos-qr-system | `backend/services/kakao_client.py` | 카카오 API 클라이언트 (REST API 방식) |
|
||||
| pharmacy-pos-qr-system | `backend/app.py` | OAuth 라우트 (`/claim/kakao/*`) |
|
||||
| board-system-project | `backend/services/kakao_client.py` | 카카오 API 클라이언트 (원본) |
|
||||
| board-system-project | `backend/routes/auth.py` | OAuth 라우트 (`/auth/kakao/*`) |
|
||||
|
||||
---
|
||||
|
||||
## 카카오 데이터 포맷 참고
|
||||
|
||||
| 필드 | 포맷 | 예시 | DB 저장 |
|
||||
|------|------|------|---------|
|
||||
| birthday | MMDD | `0315` | `YYYY-MM-DD`로 변환 |
|
||||
| birthyear | YYYY | `1990` | birthday와 결합 |
|
||||
| phone_number | +82 10-XXXX-XXXX | `+82 10-2130-7390` | 하이픈/국가코드 제거 후 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 참고 링크
|
||||
|
||||
- [카카오 로그인 REST API 문서](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api)
|
||||
- [카카오 JS SDK 문서](https://developers.kakao.com/docs/latest/ko/javascript/getting-started)
|
||||
- [카카오 로그인 설정하기](https://developers.kakao.com/docs/latest/ko/kakaologin/prerequisite)
|
||||
- [카카오 앱 키 구조 개편 공지 (2025.12)](https://devtalk.kakao.com/t/upcoming-kakao-developers-app-key-update/147295)
|
||||
|
||||
144
docs/kakao-phone-request.md
Normal file
144
docs/kakao-phone-request.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 카카오 개인정보 동의항목 - 추가 스코프 신청
|
||||
|
||||
## 요청 스코프 목록
|
||||
|
||||
| 스코프 | 항목 | 필수/선택 | 현재 상태 |
|
||||
|--------|------|-----------|-----------|
|
||||
| `profile_nickname` | 닉네임 | 필수 | 승인됨 |
|
||||
| `profile_image` | 프로필 이미지 | 필수 | 승인됨 |
|
||||
| `account_email` | 이메일 | 선택 | 승인됨 |
|
||||
| `name` | 이름 | 필수 | 승인됨 |
|
||||
| `phone_number` | 전화번호 | 필수 | **신청 필요** |
|
||||
| `birthday` | 생일 (월/일) | 선택 | **신청 필요** |
|
||||
| `birthyear` | 출생연도 | 선택 | **신청 필요** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 전화번호 (phone_number) 수집 사유
|
||||
|
||||
```
|
||||
청춘약국 마일리지 적립 서비스에서 고객 식별을 위해 전화번호가 필요합니다.
|
||||
|
||||
[서비스 개요]
|
||||
오프라인 약국(청춘약국)에서 의약품 구매 시 영수증 QR 코드를 스캔하면
|
||||
구매금액의 3%를 마일리지 포인트로 적립해주는 서비스입니다.
|
||||
|
||||
[전화번호가 필요한 이유]
|
||||
1. 고객 식별: 동일 고객의 마일리지를 하나의 계정으로 통합 관리하기 위해
|
||||
전화번호를 고유 식별자로 사용합니다.
|
||||
2. 포인트 조회: 고객이 마이페이지에서 자신의 적립 내역과 잔액을
|
||||
전화번호로 조회합니다.
|
||||
3. 오프라인 연계: 약국 방문 시 포인트 사용을 위해 전화번호로
|
||||
본인 확인이 필요합니다.
|
||||
4. 동명이인 구분: 이름만으로는 동일인 여부를 확인할 수 없어,
|
||||
전화번호가 필수적인 고유 식별 수단입니다.
|
||||
|
||||
[현재 상황]
|
||||
전화번호를 카카오에서 받지 못하는 경우, 카카오 로그인 후
|
||||
별도의 전화번호 입력 화면을 추가로 거쳐야 합니다.
|
||||
카카오 계정의 전화번호를 직접 받을 수 있으면 입력 단계를
|
||||
생략하여 사용자 경험이 크게 개선됩니다.
|
||||
|
||||
[수집 범위]
|
||||
- 수집 항목: 전화번호 (카카오 계정에 등록된 번호)
|
||||
- 이용 목적: 마일리지 적립 계정 식별 및 포인트 조회
|
||||
- 보유 기간: 회원 탈퇴 시까지
|
||||
- 제3자 제공: 없음
|
||||
```
|
||||
|
||||
## 2. 생일/출생연도 (birthday, birthyear) 수집 사유
|
||||
|
||||
```
|
||||
청춘약국 마일리지 서비스에서 생년월일 정보를 선택적으로 수집하여
|
||||
맞춤형 서비스를 제공하고자 합니다.
|
||||
|
||||
[생년월일이 필요한 이유]
|
||||
1. 생일 기념 이벤트: 회원 생일에 마일리지 포인트 2배 적립 이벤트를
|
||||
제공합니다. 생일 당일 구매 시 기본 적립률(3%) 대신 6%를 적립합니다.
|
||||
2. 연령대별 맞춤 건강 정보: 구매 이력과 연령대를 결합하여
|
||||
맞춤 건강 정보 및 추천 제품을 안내합니다.
|
||||
(예: 50대 이상 → 관절/영양 보충제 정보, 20~30대 → 피부/다이어트 관련)
|
||||
3. 서비스 통계: 연령대별 이용 현황 분석으로 서비스 품질을 개선합니다.
|
||||
|
||||
[선택 항목]
|
||||
생년월일은 선택 수집 항목이며, 미제공 시에도 마일리지 적립·조회 등
|
||||
기본 서비스 이용에는 제한이 없습니다.
|
||||
|
||||
[수집 범위]
|
||||
- 수집 항목: 생일(월/일), 출생연도
|
||||
- 이용 목적: 생일 이벤트, 연령대별 맞춤 서비스
|
||||
- 보유 기간: 회원 탈퇴 시까지
|
||||
- 제3자 제공: 없음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 회원가입 시나리오 (회원가입 화면 설명용)
|
||||
|
||||
### 시나리오 1: 직접 회원가입 (수동)
|
||||
|
||||
```
|
||||
1. https://mile.0bin.in 접속
|
||||
2. "회원가입" 메뉴 클릭 → https://mile.0bin.in/signup 이동
|
||||
3. 이름(필수), 전화번호(필수), 생년월일(선택) 입력
|
||||
4. 개인정보 수집·이용 동의 체크
|
||||
5. "가입하기" 버튼 클릭
|
||||
6. 가입 완료 → 마이페이지 이동 가능
|
||||
```
|
||||
|
||||
### 시나리오 2: 카카오 로그인으로 QR 적립 (메인 플로우)
|
||||
|
||||
```
|
||||
1. 고객이 약국에서 의약품 구매 후 영수증을 받음
|
||||
2. 영수증에 인쇄된 QR 코드를 스마트폰 카메라로 스캔
|
||||
3. https://mile.0bin.in/claim?t=거래번호:인증코드 페이지 이동
|
||||
4. "카카오로 적립하기" 버튼 클릭
|
||||
5. 카카오 로그인 동의 화면 표시 (닉네임, 프로필, 이메일, 이름, 전화번호, 생년월일)
|
||||
6-A. [전화번호 수집 가능 시] → 자동으로 마일리지 적립 완료
|
||||
6-B. [전화번호 미수집 시] → 전화번호 입력 화면으로 이동 → 수동 입력 후 적립
|
||||
7. 적립 완료 화면 표시 (적립 포인트, 총 잔액)
|
||||
```
|
||||
|
||||
### 시나리오 3: 카카오 로그인으로 마이페이지 조회
|
||||
|
||||
```
|
||||
1. https://mile.0bin.in 접속
|
||||
2. "카카오로 시작하기" 클릭
|
||||
3. 카카오 로그인 → 카카오 ID로 기존 회원 매칭
|
||||
4. 마이페이지 이동 (적립 내역, 포인트 잔액 확인)
|
||||
```
|
||||
|
||||
### 시나리오 4: PWA 앱에서 자동 적립 (재방문 고객)
|
||||
|
||||
```
|
||||
1. 이전에 카카오 로그인으로 적립한 이력이 있는 고객
|
||||
2. 홈 화면의 "청춘약국" PWA 앱에서 QR 스캔
|
||||
3. 세션이 유지되어 있으므로 입력 없이 자동 적립 완료
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 제출 체크리스트
|
||||
|
||||
### 전화번호 (phone_number) 신청
|
||||
- [x] 회원가입 링크: `https://mile.0bin.in/signup`
|
||||
- [x] 개인정보 처리방침: `https://mile.0bin.in/privacy`
|
||||
- [ ] 회원가입 화면 스크린샷: `/signup` 페이지 캡처 (전화번호 필드 + 수집 목적 안내 + 개인정보 동의 포함)
|
||||
- [ ] 수집 사유: 위 "1. 전화번호 수집 사유" 텍스트 복사하여 입력
|
||||
|
||||
### 생일/출생연도 (birthday, birthyear) 신청
|
||||
- [x] 회원가입 링크: `https://mile.0bin.in/signup`
|
||||
- [x] 개인정보 처리방침: `https://mile.0bin.in/privacy`
|
||||
- [ ] 회원가입 화면 스크린샷: `/signup` 페이지 캡처 (생년월일 필드 + "선택" 배지 + 수집 목적 안내 포함)
|
||||
- [ ] 수집 사유: 위 "2. 생일/출생연도 수집 사유" 텍스트 복사하여 입력
|
||||
|
||||
## 스크린샷 촬영 가이드
|
||||
|
||||
회원가입 화면으로 제출할 스크린샷:
|
||||
1. `https://mile.0bin.in/signup` 페이지 캡처
|
||||
- "수집 항목 및 이용 목적" 안내 카드가 보이게
|
||||
- 전화번호(필수), 이름(필수), 생년월일(선택) 필드가 보이게
|
||||
- 각 필드 아래 수집 목적 설명이 보이게
|
||||
- 개인정보 동의 체크박스가 보이게
|
||||
2. 캡처 시 개인정보(전화번호 등)가 보이면 마스킹 처리
|
||||
3. 화면이 길면 2장으로 나누어 캡처 (상단: 수집 항목 안내 + 입력 필드, 하단: 동의 + 가입 버튼)
|
||||
210
docs/member-detail-feature.md
Normal file
210
docs/member-detail-feature.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 회원 상세 기능 구현 계획
|
||||
|
||||
> 작성일: 2026-02-27
|
||||
> 상태: 계획 중
|
||||
|
||||
## 개요
|
||||
|
||||
회원 검색 페이지(`/admin/members`)에서 "상세" 버튼 클릭 시, 해당 회원의 구매 이력 및 QR 적립 내역을 조회하는 기능.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- ✅ 회원 검색 API (`/api/members/search`) - 완료
|
||||
- ✅ 회원 기본정보 조회 (`/api/members/<cuscode>`) - 완료
|
||||
- ❌ 회원 구매 이력 조회 - 미구현
|
||||
- ❌ QR 적립 내역 연동 - 미구현
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
### 1. 마일리지 DB (SQLite)
|
||||
```
|
||||
파일: backend/db/mileage.db
|
||||
테이블: transactions (적립/사용 내역)
|
||||
|
||||
주요 컬럼:
|
||||
- phone: 전화번호 (010XXXXXXXX)
|
||||
- points: 적립/사용 포인트
|
||||
- type: earn (적립) / use (사용)
|
||||
- amount: 구매금액
|
||||
- created_at: 거래일시
|
||||
- receipt_id: 영수증 ID (연동용)
|
||||
```
|
||||
|
||||
### 2. POS DB (MSSQL - PM_PRES)
|
||||
```
|
||||
테이블: SALE_MAIN (판매 메인)
|
||||
테이블: SALE_SUB (판매 상세 - 품목별)
|
||||
|
||||
SALE_MAIN:
|
||||
- SL_NO_order: 거래번호
|
||||
- SL_NM_custom: 고객명
|
||||
- SL_CD_custom: 고객코드
|
||||
- SL_MY_total: 총액
|
||||
- SL_DT_appl: 거래일자
|
||||
|
||||
SALE_SUB:
|
||||
- SL_NO_order: 거래번호 (FK)
|
||||
- DrugCode: 상품코드
|
||||
- QUAN: 수량
|
||||
- SL_TOTAL_PRICE: 금액
|
||||
```
|
||||
|
||||
### 3. 회원 DB (MSSQL - PM_BASE)
|
||||
```
|
||||
테이블: CD_PERSON
|
||||
|
||||
주요 컬럼:
|
||||
- CUSCODE: 고객코드
|
||||
- PANAME: 이름
|
||||
- PHONE, TEL_NO, PHONE2: 전화번호 3곳
|
||||
```
|
||||
|
||||
## 연동 전략
|
||||
|
||||
### 문제점
|
||||
- POS(SALE_MAIN)에는 `SL_CD_custom`(고객코드) 사용
|
||||
- 마일리지 DB에는 `phone`(전화번호) 사용
|
||||
- **전화번호 → 고객코드** 또는 **고객코드 → 전화번호** 매핑 필요
|
||||
|
||||
### 해결 방안
|
||||
|
||||
#### 방안 1: 전화번호 기반 통합 (권장)
|
||||
```
|
||||
1. CD_PERSON에서 전화번호로 CUSCODE 조회
|
||||
2. CUSCODE로 SALE_MAIN 조회
|
||||
3. 마일리지 DB에서 전화번호로 적립 내역 조회
|
||||
4. 두 결과 병합하여 표시
|
||||
```
|
||||
|
||||
#### 방안 2: 마일리지 테이블에 CUSCODE 추가
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN cuscode TEXT;
|
||||
```
|
||||
- QR 적립 시 POS 고객코드 연동
|
||||
|
||||
## API 설계
|
||||
|
||||
### GET /api/members/<cuscode>/history
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"member": {
|
||||
"cuscode": "0000012345",
|
||||
"name": "김영빈",
|
||||
"phone": "01027027390"
|
||||
},
|
||||
"mileage": {
|
||||
"balance": 19005,
|
||||
"total_earned": 25000,
|
||||
"total_used": 5995,
|
||||
"transactions": [
|
||||
{
|
||||
"date": "2026-02-27 01:29",
|
||||
"type": "earn",
|
||||
"points": 555,
|
||||
"amount": 18500,
|
||||
"products": ["투엑스벤포파워", "마데카솔"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchases": [
|
||||
{
|
||||
"date": "20260227",
|
||||
"order_no": "20260227001234",
|
||||
"total": 18500,
|
||||
"items": [
|
||||
{"name": "투엑스벤포파워", "qty": 1, "price": 9000},
|
||||
{"name": "마데카솔연고", "qty": 1, "price": 9500}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## UI 설계
|
||||
|
||||
### 회원 상세 모달
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 👤 김영빈 │
|
||||
│ 📱 010-2702-7390 │
|
||||
│ 💰 잔여 포인트: 19,005P │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [QR 적립 내역] [POS 구매 이력] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 📅 2026-02-27 01:29 │
|
||||
│ +555P (18,500원 구매) │
|
||||
│ └ 투엑스벤포파워, 마데카솔연고 │
|
||||
│ │
|
||||
│ 📅 2026-02-27 01:25 │
|
||||
│ +360P (12,000원 구매) │
|
||||
│ └ 벤포파워Z x2 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [ 메시지 발송 ] [ 닫기 ] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### Phase 1: 마일리지 내역 연동 (우선)
|
||||
1. [ ] `/api/members/<phone>/mileage` API 추가
|
||||
2. [ ] SQLite에서 전화번호로 적립/사용 내역 조회
|
||||
3. [ ] 회원 상세 모달 UI 구현
|
||||
|
||||
### Phase 2: POS 구매 이력 연동
|
||||
1. [ ] 전화번호 → CUSCODE 매핑 로직
|
||||
2. [ ] SALE_MAIN/SALE_SUB 조회 API
|
||||
3. [ ] 품목 상세 표시
|
||||
|
||||
### Phase 3: 통합 뷰
|
||||
1. [ ] 마일리지 + POS 데이터 병합
|
||||
2. [ ] 타임라인 형태로 통합 표시
|
||||
3. [ ] 상품 추천 (자주 구매 품목)
|
||||
|
||||
## 예상 쿼리
|
||||
|
||||
### 마일리지 내역 (SQLite)
|
||||
```sql
|
||||
SELECT
|
||||
t.created_at, t.type, t.points, t.amount,
|
||||
u.name, u.phone, u.balance
|
||||
FROM transactions t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE u.phone = '01027027390'
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
### POS 구매 이력 (MSSQL)
|
||||
```sql
|
||||
-- 1. 전화번호로 고객코드 조회
|
||||
SELECT CUSCODE FROM PM_BASE.dbo.CD_PERSON
|
||||
WHERE PHONE = '01027027390' OR TEL_NO = '01027027390';
|
||||
|
||||
-- 2. 고객코드로 구매 이력 조회
|
||||
SELECT
|
||||
M.SL_NO_order, M.SL_DT_appl, M.SL_MY_total,
|
||||
S.DrugCode, G.GoodsName, S.QUAN, S.SL_TOTAL_PRICE
|
||||
FROM PM_PRES.dbo.SALE_MAIN M
|
||||
JOIN PM_PRES.dbo.SALE_SUB S ON M.SL_NO_order = S.SL_NO_order
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE M.SL_CD_custom = '0000012345'
|
||||
ORDER BY M.SL_DT_appl DESC;
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- 마일리지 테이블 구조: `backend/db/dbsetup.py`
|
||||
- POS 테이블 가이드: `docs/alimipharm-set-product-structure.md`
|
||||
- 회원 검색 API: `backend/app.py` → `/api/members/search`
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-02-27 | 계획 문서 작성 |
|
||||
168
docs/user-identity-merge.md
Normal file
168
docs/user-identity-merge.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 사용자 계정 연동(머지) 시나리오
|
||||
|
||||
## 개요
|
||||
|
||||
청춘약국 마일리지 시스템은 두 가지 경로로 사용자가 생성됩니다:
|
||||
1. **전화번호 경로** — 키오스크에서 번호 입력, 회원가입 폼
|
||||
2. **카카오 경로** — QR 스캔 후 카카오 로그인
|
||||
|
||||
한 고객이 두 경로를 모두 사용할 수 있으므로, **전화번호 기반 유저에 카카오 계정을 연동(머지)** 하는 로직이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
```
|
||||
users 테이블
|
||||
├── id (PK)
|
||||
├── nickname ← 키오스크 신규: "고객" / 카카오 연동 후: 실명
|
||||
├── phone ← 전화번호 (유니크 키)
|
||||
├── mileage_balance
|
||||
└── ...
|
||||
|
||||
customer_identities 테이블
|
||||
├── user_id (FK → users.id)
|
||||
├── provider = 'kakao'
|
||||
└── provider_user_id = 카카오 ID
|
||||
```
|
||||
|
||||
**핵심 원칙**: `phone`이 유저의 기본 식별자. 카카오는 부가 연동.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오별 동작
|
||||
|
||||
### 시나리오 1: 키오스크만 사용하는 고객 (가장 흔함)
|
||||
|
||||
```
|
||||
1회차: 키오스크 → 010-1234-5678 입력
|
||||
→ get_or_create_user("01012345678", "고객")
|
||||
→ users: {id: 100, nickname: "고객", phone: "01012345678", balance: 0}
|
||||
→ 500P 적립 → balance: 500
|
||||
|
||||
2회차: 키오스크 → 같은 번호
|
||||
→ get_or_create_user → 기존 user_id=100 반환
|
||||
→ 300P 적립 → balance: 800
|
||||
|
||||
5회차까지: 전부 user_id=100에 누적
|
||||
```
|
||||
|
||||
**결과**: 한 유저에 전부 쌓임. 문제없음.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 2: 키오스크 N회 → 알림톡 → 카카오 연동 (핵심 플로우)
|
||||
|
||||
```
|
||||
[키오스크 5회 적립] → user_id=100, balance=2,000P, nickname="고객"
|
||||
|
||||
[알림톡 수신] → "적립 내역 확인" 버튼 탭
|
||||
→ https://mile.0bin.in/my-page?phone=01012345678
|
||||
→ 마이페이지 표시 (잔액 2,000P, 거래 5건)
|
||||
|
||||
[카카오 로그인 버튼 클릭]
|
||||
→ _handle_mypage_kakao_callback() 실행
|
||||
→ find_user_by_kakao_id(kakao_id) → 없음
|
||||
→ 카카오에서 전화번호 "01012345678" 수신
|
||||
→ phone으로 users 조회 → user_id=100 발견
|
||||
→ link_kakao_identity(100, kakao_id, ...) ← 여기서 머지!
|
||||
→ nickname "고객" → 카카오 실명 "김철수"로 업데이트
|
||||
→ /my-page?phone=01012345678 리다이렉트
|
||||
```
|
||||
|
||||
**결과**:
|
||||
- user_id=100에 카카오 계정 연결됨
|
||||
- 기존 5건의 적립 내역 그대로 유지
|
||||
- 이름 "고객" → "김철수"로 업데이트
|
||||
- 다음부터 QR 스캔으로도 같은 계정으로 적립
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 3: QR 스캔(카카오) 먼저 → 키오스크 사용
|
||||
|
||||
```
|
||||
[QR 스캔 + 카카오 로그인]
|
||||
→ 카카오에서 phone "01012345678" + kakao_id 수신
|
||||
→ get_or_create_user("01012345678", "김철수")
|
||||
→ user_id=100 생성 (phone + kakao 동시에 연결)
|
||||
→ 500P 적립
|
||||
|
||||
[이후 키오스크 사용] → 010-1234-5678 입력
|
||||
→ get_or_create_user → 같은 phone → user_id=100 반환
|
||||
→ 300P 적립 → balance: 800
|
||||
```
|
||||
|
||||
**결과**: 처음부터 phone + kakao가 연결되어 있으므로 머지 불필요.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 4: 이미 카카오 연동된 유저가 다시 카카오 로그인
|
||||
|
||||
```
|
||||
[카카오 로그인 (마이페이지)]
|
||||
→ find_user_by_kakao_id(kakao_id) → user_id=100 발견
|
||||
→ 이미 연결됨 → 마이페이지로 리다이렉트
|
||||
```
|
||||
|
||||
**결과**: 중복 연동 방지. `link_kakao_identity()`도 내부적으로 중복 INSERT 방지.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 5: 카카오에 전화번호가 없는 경우
|
||||
|
||||
```
|
||||
[카카오 로그인 (마이페이지)]
|
||||
→ find_user_by_kakao_id → 없음
|
||||
→ kakao_phone = None (카카오 설정에서 전화번호 미등록)
|
||||
→ 에러: "카카오 계정에 전화번호 정보가 없습니다"
|
||||
```
|
||||
|
||||
**결과**: 연동 불가. 카카오 설정에서 전화번호 등록 안내.
|
||||
|
||||
---
|
||||
|
||||
### 시나리오 6: 알림톡에서 바로 my-page 접근 (카카오 로그인 없이)
|
||||
|
||||
```
|
||||
[알림톡 수신] → "적립 내역 확인" 버튼
|
||||
→ https://mile.0bin.in/my-page?phone=01012345678
|
||||
→ 전화번호로 직접 조회 → 마이페이지 표시
|
||||
→ 카카오 연동 없이 내역만 확인
|
||||
```
|
||||
|
||||
**결과**: 열람만 가능. 카카오 연동은 카카오 로그인 버튼을 눌러야 진행.
|
||||
|
||||
---
|
||||
|
||||
## 연동 시점 정리
|
||||
|
||||
| 진입 경로 | link_kakao_identity 호출 | 비고 |
|
||||
|-----------|-------------------------|------|
|
||||
| QR 스캔 → 카카오 로그인 → 적립 | O | claim 플로우에서 자동 연동 |
|
||||
| 키오스크 → 번호 입력 → 적립 | X | 전화번호만 있음 |
|
||||
| 마이페이지 → 카카오 로그인 | O (신규!) | `_handle_mypage_kakao_callback`에서 머지 |
|
||||
| 알림톡 → 마이페이지 (번호 직접) | X | 카카오 로그인 안 함 |
|
||||
|
||||
---
|
||||
|
||||
## 이름 업데이트 규칙
|
||||
|
||||
| 현재 이름 | 카카오 이름 | 결과 |
|
||||
|-----------|-------------|------|
|
||||
| "고객" | "김철수" | → "김철수" (업데이트) |
|
||||
| "고객" | "고객" or 없음 | → "고객" (유지) |
|
||||
| "김철수" (기존 실명) | "김영희" | → "김철수" (기존 유지, 덮어쓰지 않음) |
|
||||
|
||||
**원칙**: "고객"(키오스크 기본값)인 경우에만 카카오 실명으로 업데이트. 이미 실명이 있으면 유지.
|
||||
|
||||
---
|
||||
|
||||
## 관련 코드
|
||||
|
||||
| 함수 | 파일:라인 | 역할 |
|
||||
|------|-----------|------|
|
||||
| `get_or_create_user()` | app.py:372 | 전화번호로 유저 조회/생성 |
|
||||
| `link_kakao_identity()` | app.py:485 | 카카오 계정을 유저에 연결 |
|
||||
| `find_user_by_kakao_id()` | app.py:519 | 카카오 ID로 유저 조회 |
|
||||
| `_handle_mypage_kakao_callback()` | app.py:793 | 마이페이지 카카오 콜백 (머지 포함) |
|
||||
| `api_kiosk_claim()` | app.py:1986 | 키오스크 적립 + 알림톡 발송 |
|
||||
74
docs/windows-utf8-encoding.md
Normal file
74
docs/windows-utf8-encoding.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Windows 콘솔 한글 인코딩 (UTF-8) 가이드
|
||||
|
||||
## 문제
|
||||
Windows 콘솔 기본 인코딩이 `cp949`여서 Python에서 한글 출력 시 깨짐 발생.
|
||||
Claude Code bash 터미널, cmd, PowerShell 모두 동일 증상.
|
||||
|
||||
```
|
||||
# 깨진 출력 예시
|
||||
{"product": "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>", "message": "<22>迵<EFBFBD><E8BFB5><EFBFBD>, ..."}
|
||||
```
|
||||
|
||||
## 해결: 3단계 방어
|
||||
|
||||
### 1단계: Python 파일 상단 — sys.stdout UTF-8 래핑
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
|
||||
```
|
||||
|
||||
**적용 위치**: `app.py`, `clawdbot_client.py` 등 진입점 파일 맨 위 (import 전)
|
||||
|
||||
> 모듈로 import되는 파일은 `hasattr(sys.stdout, 'buffer')` 체크 추가:
|
||||
> ```python
|
||||
> if sys.platform == 'win32':
|
||||
> import io
|
||||
> if hasattr(sys.stdout, 'buffer'):
|
||||
> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
> sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
> ```
|
||||
|
||||
### 2단계: 환경변수 — PYTHONIOENCODING
|
||||
```bash
|
||||
# ~/.bashrc (Claude Code bash 세션)
|
||||
export PYTHONIOENCODING=utf-8
|
||||
```
|
||||
|
||||
또는 실행 시:
|
||||
```bash
|
||||
PYTHONIOENCODING=utf-8 python backend/app.py
|
||||
```
|
||||
|
||||
### 3단계: json.dumps — ensure_ascii=False
|
||||
```python
|
||||
import json
|
||||
data = {"product": "비타민C", "message": "추천드려요"}
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
```
|
||||
`ensure_ascii=False` 없으면 `\uBE44\uD0C0\uBBFCC` 같은 유니코드 이스케이프로 출력됨.
|
||||
|
||||
## 프로젝트 내 적용 현황
|
||||
|
||||
| 파일 | 방식 |
|
||||
|------|------|
|
||||
| `backend/app.py` | sys.stdout 래핑 + PYTHONIOENCODING |
|
||||
| `backend/services/clawdbot_client.py` | sys.stdout 래핑 (buffer 체크) |
|
||||
| `backend/ai_tag_products.py` | sys.stdout 래핑 |
|
||||
| `backend/view_products.py` | sys.stdout 래핑 |
|
||||
| `backend/import_il1beta_foods.py` | sys.stdout 래핑 |
|
||||
| `backend/import_products_from_mssql.py` | sys.stdout 래핑 |
|
||||
| `backend/update_product_category.py` | sys.stdout 래핑 |
|
||||
| `backend/gui/check_cash.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `backend/gui/check_sunab.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
|
||||
| `~/.bashrc` | `export PYTHONIOENCODING=utf-8` |
|
||||
|
||||
## 주의사항
|
||||
- Flask 로거(`logging.info()` 등)도 stderr로 출력하므로 **stderr도 반드시 래핑**
|
||||
- `io.TextIOWrapper`는 이미 래핑된 스트림에 중복 적용하면 에러남 → `hasattr(sys.stdout, 'buffer')` 체크
|
||||
- PyQt GUI에서는 stdout이 다를 수 있음 → `hasattr` 가드 필수
|
||||
308
docs/결제수납구조.md
Normal file
308
docs/결제수납구조.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# PIT3000 판매/조제/수납 데이터 구조
|
||||
|
||||
## 핵심 테이블 관계
|
||||
|
||||
```
|
||||
CD_SUNAB (수납/결제) ─── 모든 거래의 결제 기록 (130건/일 기준)
|
||||
│
|
||||
├── PS_main (처방접수) ─── 조제 건만 (89건/일 기준)
|
||||
│ │ 조인: PS_main.PreSerial = CD_SUNAB.PRESERIAL
|
||||
│ │ 조인: PS_main.Indate = CD_SUNAB.INDATE
|
||||
│ │
|
||||
│ ├── PS_sub_hosp (처방 의약품 상세)
|
||||
│ └── PS_sub_pharm (조제 의약품 상세)
|
||||
│
|
||||
└── SALE_MAIN (OTC 판매) ─── OTC 직접 판매만 (39건/일 기준)
|
||||
│ 조인: SALE_MAIN.SL_NO_order = CD_SUNAB.PRESERIAL
|
||||
│
|
||||
└── SALE_SUB (판매 품목 상세) ─── SL_NO_order로 조인
|
||||
```
|
||||
|
||||
## 테이블별 역할
|
||||
|
||||
### 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개 컬럼** | |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름 정리
|
||||
|
||||
### 조제 (처방전 기반)
|
||||
```
|
||||
처방전 접수 → 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 금액 컬럼 상세
|
||||
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `SL_MY_total` | 원가 (할인 전) | 60,000 |
|
||||
| `SL_MY_discount` | 할인 금액 | 6,000 |
|
||||
| `SL_MY_sale` | **실판매가** (= total - discount) | 54,000 |
|
||||
| `SL_MY_recive` | 수납금액 (부가세 제외 추정) | 49,091 |
|
||||
| `SL_MY_credit` | 외상 금액 | 0 |
|
||||
| `SL_MY_dis_ratio` | 할인율 | 0 (미사용) |
|
||||
|
||||
### 금액 관계
|
||||
```
|
||||
SL_MY_sale = SL_MY_total - SL_MY_discount
|
||||
SL_MY_recive ≈ SL_MY_sale / 1.1 (부가세 제외 금액 추정)
|
||||
```
|
||||
|
||||
### 할인 빈도
|
||||
- 대부분의 거래: discount = 0 (할인 없음)
|
||||
- 할인 적용 건: 하루 2~5건 정도 (직원 할인, 대량 구매 등)
|
||||
- 할인 규모: 1,000원 ~ 수십만원까지 다양
|
||||
|
||||
---
|
||||
|
||||
## CD_SUNAB 카드/현금 상세 컬럼
|
||||
|
||||
### 카드 상세 정보
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `pMCHDATA` | 카드사 이름 | 비씨카드사, NH농협카드 |
|
||||
| `PCardName` | 카드사 이름 (별도) | KB국민카드 |
|
||||
| `pAPPROVAL_NUM` | 카드 승인번호 | 72139919 |
|
||||
| `pCARDINMODE` | 카드 입력 방식 | 1 (IC칩) |
|
||||
| `pTRDTYPE` | 거래 유형 | D1 (일반승인) |
|
||||
| `Appr_Gubun` | 승인 구분 | 9 (정상승인) |
|
||||
| `pCANCEL_NUM` | 취소 승인번호 | (취소 시) |
|
||||
|
||||
### 현금 상세 정보
|
||||
| 컬럼 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `nCASHINMODE` | 현금영수증 입력 방식 | 1=실제발행, 2=카드거래 자동세팅 |
|
||||
| `nAPPROVAL_NUM` | 현금영수증 승인번호 | 116624870 |
|
||||
| `nCHK_GUBUN` | 현금 체크 구분 | KOV, TASA |
|
||||
|
||||
---
|
||||
|
||||
## SQL 쿼리 (현재 GUI에서 사용)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
M.SL_NO_order,
|
||||
M.InsertTime,
|
||||
M.SL_MY_sale,
|
||||
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name,
|
||||
ISNULL(S.card_total, 0) AS card_total,
|
||||
ISNULL(S.cash_total, 0) AS cash_total,
|
||||
ISNULL(M.SL_MY_total, 0) AS total_amount,
|
||||
ISNULL(M.SL_MY_discount, 0) AS discount,
|
||||
S.cash_receipt_mode,
|
||||
S.cash_receipt_num
|
||||
FROM SALE_MAIN M
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1
|
||||
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
|
||||
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
|
||||
nCASHINMODE AS cash_receipt_mode,
|
||||
nAPPROVAL_NUM AS cash_receipt_num
|
||||
FROM CD_SUNAB
|
||||
WHERE PRESERIAL = M.SL_NO_order
|
||||
) S
|
||||
WHERE M.SL_DT_appl = ?
|
||||
ORDER BY M.InsertTime DESC
|
||||
```
|
||||
|
||||
**한계**: SALE_MAIN 기준이므로 OTC 판매(39건)만 표시됨.
|
||||
조제건(~89건)은 표시되지 않음. 조제건까지 보려면 CD_SUNAB을
|
||||
기본 테이블로 사용하거나 PS_main과 조인하는 쿼리 재설계 필요.
|
||||
|
||||
---
|
||||
|
||||
## 카드사 분포 (전체 데이터 기준)
|
||||
|
||||
| 카드사 | 건수 |
|
||||
|--------|------|
|
||||
| KB국민카드 | 6,106 |
|
||||
| NH농협카드 | 5,172 |
|
||||
| 비씨카드사 | 4,900 |
|
||||
| 하나카드 | 4,880 |
|
||||
| 신한카드 | 3,210 |
|
||||
| 삼성카드사 | 2,100 |
|
||||
| 현대카드사 | 1,960 |
|
||||
| 우리카드 | 1,285 |
|
||||
| 롯데카드사 | 837 |
|
||||
| 카카오페이 | 57 |
|
||||
| 모바일상품권 | 11 |
|
||||
91
docs/실행구조.md
Normal file
91
docs/실행구조.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 청춘약국 마일리지 시스템 — 실행 구조
|
||||
|
||||
## 실행해야 할 프로그램 (2개)
|
||||
|
||||
### 1. Flask 서버 (`backend/app.py`)
|
||||
```bash
|
||||
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
|
||||
python backend/app.py
|
||||
```
|
||||
- **포트**: 7001 (0.0.0.0)
|
||||
- **외부 도메인**: `mile.0bin.in` (→ 내부 7001 포트로 프록시)
|
||||
- **역할**: 웹 서비스 전체 담당
|
||||
|
||||
#### 제공하는 페이지/API
|
||||
| 경로 | 설명 |
|
||||
|------|------|
|
||||
| `/` | 메인 페이지 |
|
||||
| `/signup` | 회원가입 |
|
||||
| `/claim` | QR 적립 (폰번호 방식) |
|
||||
| `/claim/kakao/start` | QR 적립 (카카오 로그인) |
|
||||
| `/my-page` | 마이페이지 |
|
||||
| `/kiosk` | **키오스크 대기 화면** (약국 내 태블릿) |
|
||||
| `/admin` | 관리자 페이지 |
|
||||
| `/admin/transaction/<id>` | 거래 상세 |
|
||||
| `/admin/user/<id>` | 회원 상세 |
|
||||
| `/admin/search/user` | 회원 검색 |
|
||||
| `/admin/search/product` | 상품 검색 |
|
||||
| `/api/kiosk/trigger` | 키오스크 QR 트리거 (POST) |
|
||||
| `/api/kiosk/current` | 키오스크 현재 상태 |
|
||||
| `/api/kiosk/claim` | 키오스크 적립 처리 (POST) |
|
||||
|
||||
#### 사용하는 DB
|
||||
- **SQLite** (`backend/db/mileage.db`) — 회원, 적립, QR 토큰
|
||||
- **MSSQL** (`192.168.0.4\PM2014`, DB: `PM_PRES`) — POS 판매 데이터 (읽기 전용)
|
||||
|
||||
---
|
||||
|
||||
### 2. Qt POS GUI (`backend/gui/pos_sales_gui.py`)
|
||||
```bash
|
||||
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
|
||||
python backend/gui/pos_sales_gui.py
|
||||
```
|
||||
- **역할**: POS 판매 내역 조회 + QR 라벨 발행
|
||||
- **PyQt5 기반** 데스크톱 앱
|
||||
- Flask 서버와 **독립적으로 실행** (별도 프로세스)
|
||||
|
||||
#### 주요 기능
|
||||
- 일자별 판매 내역 조회 (SALE_MAIN + CD_SUNAB)
|
||||
- 결제수단 표시 (카드/현금/현영)
|
||||
- 할인 표시
|
||||
- QR 라벨 프린터 출력 (Zebra / POS 프린터)
|
||||
- 적립자 클릭 → 회원 적립 내역 팝업
|
||||
|
||||
#### 사용하는 DB
|
||||
- **MSSQL** — SALE_MAIN, SALE_SUB, CD_SUNAB 조회
|
||||
- **SQLite** — claim_tokens, users 조회 (적립 정보)
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
1. Flask 서버 먼저 실행 (키오스크, 웹 서비스 제공)
|
||||
2. Qt POS GUI 실행 (판매 내역 조회, QR 발행)
|
||||
```
|
||||
|
||||
순서는 상관없으나, Flask가 먼저 떠 있어야 키오스크(`mile.0bin.in/kiosk`)와
|
||||
웹 서비스(`mile.0bin.in`)가 접속 가능.
|
||||
|
||||
---
|
||||
|
||||
## 프로세스 확인
|
||||
|
||||
```bash
|
||||
# 실행 중인 Python 프로세스 확인
|
||||
tasklist /FI "IMAGENAME eq python.exe"
|
||||
|
||||
# 정상 상태: Python 프로세스 3개
|
||||
# - Flask 서버 (메인)
|
||||
# - Flask 서버 (debug reloader 워커)
|
||||
# - Qt POS GUI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- `taskkill /F /IM python.exe` 사용 시 **Flask + GUI 모두 종료됨**
|
||||
- GUI만 재시작하려면 해당 PID만 종료할 것
|
||||
- Flask 서버는 `debug=True`로 실행되어 코드 변경 시 자동 리로드
|
||||
- Python 경로: `C:\Users\청춘약국\AppData\Local\Programs\Python\Python312\python.exe`
|
||||
98
docs/카드현금구분_.md
Normal file
98
docs/카드현금구분_.md
Normal file
@@ -0,0 +1,98 @@
|
||||
.# 팜IT3000 (PIT3000) DB 구조
|
||||
|
||||
## DB 접속 정보
|
||||
- **서버**: 192.168.0.101\PM2014 (MSSQL)
|
||||
- **계정**: sa / tmddls214!%(
|
||||
- **ODBC**: Driver 18 + `OPENSSL_CONF=/root/person-lookup-web-local/openssl_legacy.conf` 필수
|
||||
- **코드 위치**: /root/person-lookup-web-local/ (CT 200)
|
||||
|
||||
## 데이터베이스 목록
|
||||
| DB명 | 용도 |
|
||||
|------|------|
|
||||
| PM_BASE | 환자 정보, 개인정보, 판매마스터 |
|
||||
| PM_PRES | 처방전, 판매(SALE), 수납(CD_SUNAB), 키오스크 |
|
||||
| PM_DRUG | 약품 마스터(CD_GOODS), 창고 거래(WH_sub) |
|
||||
| PM_DUMS | 재고 관리(INVENTORY, NIMS_REALTIME_INVENTORY) |
|
||||
| PM_ALIMI | 알림톡, SMS |
|
||||
| PM_ALDB | 알림 DB |
|
||||
| PM_EDIRECE/PM_EDISEND | EDI 전자문서 |
|
||||
| PM_IMAGE | 약품 이미지 |
|
||||
| PM_JOBLOG | 작업/시스템 로그 |
|
||||
|
||||
## 결제(수납) 테이블 구조
|
||||
|
||||
### CD_SUNAB (PM_PRES) - 핵심 수납 테이블
|
||||
건별 결제 내역. PRESERIAL로 처방과 연결.
|
||||
|
||||
#### 결제 수단 구분 (금액 기반, 단일 구분 컬럼 없음)
|
||||
| 구분 | 카드결제 | 현금결제 | 외상/기타 |
|
||||
|------|---------|---------|----------|
|
||||
| 조제(ETC, 전문의약품) | `ETC_CARD` | `ETC_CASH` | `ETC_PAPER` |
|
||||
| OTC(일반의약품) | `OTC_CARD` | `OTC_CASH` | `OTC_PAPER` |
|
||||
|
||||
**판별법**: 금액이 0보다 크면 해당 결제수단 사용
|
||||
- `ETC_CARD=6100, ETC_CASH=0` → 카드결제
|
||||
- `ETC_CARD=0, ETC_CASH=5100` → 현금결제
|
||||
|
||||
#### 카드 관련 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `PCardName` | 카드사 이름 (KB국민카드, 신한카드 등) |
|
||||
| `pAPPROVAL_NUM` | 카드 승인번호 |
|
||||
| `pCARDINMODE` | 카드 입력 방식 |
|
||||
| `pTRDTYPE` | 거래 유형 (D1 등) |
|
||||
| `pCHK_GUBUN` | 체크 구분 (TASA=타사, KIC 등) |
|
||||
| `Appr_Gubun` | 승인 구분 (9=정상승인, A 등) |
|
||||
| `pCANCEL_NUM` | 취소 승인번호 |
|
||||
| `CANCEL_DATE` | 취소 일시 |
|
||||
|
||||
#### 현금 관련 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `nCASHINMODE` | 현금영수증 입력 방식 (1 등, 대부분 빈값=미발행) |
|
||||
| `nAPPROVAL_NUM` | 현금영수증 승인번호 |
|
||||
| `nCHK_GUBUN` | 현금 체크 구분 (TASA 등) |
|
||||
|
||||
#### 카드사 분포 (PCardName)
|
||||
| 카드사 | 건수 |
|
||||
|--------|------|
|
||||
| KB국민카드 | 6,106 |
|
||||
| NH농협카드 | 5,172 |
|
||||
| 비씨카드사 | 4,900 |
|
||||
| 하나카드 | 4,880 |
|
||||
| 신한카드 | 3,210 |
|
||||
| 삼성카드사 | 2,100 |
|
||||
| 현대카드사 | 1,960 |
|
||||
| 우리카드 | 1,285 |
|
||||
| 롯데카드사 | 837 |
|
||||
| 카카오페이 | 57 |
|
||||
| 모바일상품권 | 11 |
|
||||
|
||||
### CD_SELL_MASTE (PM_BASE) - 판매마스터
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `CARD_C` | 카드 결제금액 |
|
||||
| `CHASH_C` | 현금 결제금액 |
|
||||
| `PAPER_C` | 외상 금액 |
|
||||
| `P_GUBUN` | 처방 구분 |
|
||||
| `C_GUBUN` | 고객 구분 |
|
||||
|
||||
### SALE_main (PM_PRES) - 판매 메인
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| `SL_MY_sale` | 판매금액 |
|
||||
| `SL_MY_credit` | 외상금액 |
|
||||
| `SL_MY_recive` | 수납금액 |
|
||||
| `POS_GUBUN` | POS 구분 (빈값=일반, C=카드?, G=기타?) |
|
||||
| `PRESERIAL` | 처방번호 (CD_SUNAB과 조인 키) |
|
||||
|
||||
### KIOSK 테이블 (PM_PRES)
|
||||
- `KIOSK_MAIN`: 키오스크 처방 접수
|
||||
- `KIOSK_CARD`: 키오스크 카드결제 (CARD_NM, CARD_NO, APP_NUM 등)
|
||||
- `KIOSK_CARD_PRES`: 키오스크 카드-처방 연결
|
||||
- `KIOSK_SUB`: 키오스크 서브
|
||||
|
||||
## 주요 조인 관계
|
||||
- `CD_SUNAB.PRESERIAL` ↔ `SALE_main.PRESERIAL` (수납-판매 연결)
|
||||
- `CD_SUNAB.CUSCODE` ↔ `CD_PERSON.CUSCODE` (수납-환자 연결, PM_BASE)
|
||||
- `SALE_main.SL_NO_order` ↔ `SALE_sub.SL_NO_order` (판매 메인-서브)
|
||||
45
ecosystem.config.js
Normal file
45
ecosystem.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// PM2 설정 파일
|
||||
// 사용법:
|
||||
// npm install -g pm2
|
||||
// pm2 start ecosystem.config.js
|
||||
// pm2 restart pharmacy
|
||||
// pm2 stop pharmacy
|
||||
// pm2 logs pharmacy
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'pharmacy',
|
||||
script: 'backend/app.py',
|
||||
interpreter: 'python',
|
||||
cwd: __dirname,
|
||||
|
||||
// 환경 설정
|
||||
env: {
|
||||
FLASK_ENV: 'development',
|
||||
PYTHONIOENCODING: 'utf-8'
|
||||
},
|
||||
env_production: {
|
||||
FLASK_ENV: 'production'
|
||||
},
|
||||
|
||||
// 재시작 설정
|
||||
watch: false, // 파일 변경 감지 (개발 시 true)
|
||||
max_restarts: 10, // 최대 재시작 횟수
|
||||
restart_delay: 3000, // 재시작 딜레이 (3초)
|
||||
|
||||
// 로그 설정
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
error_file: 'logs/pm2-error.log',
|
||||
out_file: 'logs/pm2-out.log',
|
||||
merge_logs: true,
|
||||
|
||||
// 인스턴스 설정
|
||||
instances: 1, // 단일 인스턴스
|
||||
exec_mode: 'fork',
|
||||
|
||||
// 메모리 제한
|
||||
max_memory_restart: '500M'
|
||||
}
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user