Compare commits
52 Commits
cb927d2207
...
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 |
1682
backend/app.py
1682
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,60 @@ 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._migrate_sqlite()
|
||||
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):
|
||||
"""
|
||||
@@ -237,7 +260,7 @@ class DatabaseManager:
|
||||
print(f"[DB Manager] SQLite 스키마 초기화 완료")
|
||||
|
||||
def _migrate_sqlite(self):
|
||||
"""기존 DB에 새 컬럼 추가 (마이그레이션)"""
|
||||
"""기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
@@ -246,6 +269,56 @@ class DatabaseManager:
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가")
|
||||
|
||||
# alimtalk_logs 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS alimtalk_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
recipient_no VARCHAR(20) NOT NULL,
|
||||
user_id INTEGER,
|
||||
trigger_source VARCHAR(20) NOT NULL,
|
||||
template_params TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
result_message TEXT,
|
||||
transaction_id VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성")
|
||||
|
||||
# ai_recommendations 테이블 생성
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'")
|
||||
if not cursor.fetchone():
|
||||
cursor.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
""")
|
||||
self.sqlite_conn.commit()
|
||||
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
|
||||
|
||||
def test_connection(self, database='PM_BASE'):
|
||||
"""연결 테스트"""
|
||||
try:
|
||||
|
||||
@@ -80,3 +80,43 @@ CREATE TABLE IF NOT EXISTS pos_customer_links (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
|
||||
|
||||
-- 6. 알림톡 발송 로그 테이블
|
||||
CREATE TABLE IF NOT EXISTS alimtalk_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_code VARCHAR(50) NOT NULL,
|
||||
recipient_no VARCHAR(20) NOT NULL,
|
||||
user_id INTEGER,
|
||||
trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등
|
||||
template_params TEXT, -- JSON 문자열
|
||||
success BOOLEAN NOT NULL,
|
||||
result_message TEXT,
|
||||
transaction_id VARCHAR(20),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
|
||||
|
||||
-- 7. AI 추천 테이블
|
||||
CREATE TABLE IF NOT EXISTS ai_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
transaction_id VARCHAR(20),
|
||||
recommended_product TEXT NOT NULL,
|
||||
recommendation_message TEXT NOT NULL,
|
||||
recommendation_reason TEXT,
|
||||
trigger_products TEXT,
|
||||
ai_raw_response TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
displayed_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
displayed_at DATETIME,
|
||||
dismissed_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
|
||||
|
||||
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()
|
||||
@@ -67,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
|
||||
"""
|
||||
@@ -84,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("""
|
||||
@@ -121,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,
|
||||
@@ -140,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):
|
||||
@@ -559,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):
|
||||
@@ -575,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'),
|
||||
@@ -708,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)
|
||||
@@ -806,11 +846,56 @@ class POSSalesGUI(QMainWindow):
|
||||
self.sales_table.setItem(row, COL['time'],
|
||||
QTableWidgetItem(sale['time']))
|
||||
|
||||
# 금액 (우측 정렬, 천단위 콤마)
|
||||
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원")
|
||||
# 금액 (우측 정렬, 천단위 콤마, 할인 표시)
|
||||
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']))
|
||||
@@ -882,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
|
||||
|
||||
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
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
NHN Cloud 알림톡 발송 서비스
|
||||
마일리지 적립 완료 등 알림톡 발송
|
||||
마일리지 적립 완료 등 알림톡 발송 + SQLite 로깅
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
@@ -22,6 +23,34 @@ API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _log_to_db(template_code, recipient_no, success, result_message,
|
||||
template_params=None, user_id=None, trigger_source='unknown',
|
||||
transaction_id=None):
|
||||
"""발송 결과를 SQLite에 저장"""
|
||||
try:
|
||||
from db.dbsetup import db_manager
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO alimtalk_logs
|
||||
(template_code, recipient_no, user_id, trigger_source,
|
||||
template_params, success, result_message, transaction_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
template_code,
|
||||
recipient_no,
|
||||
user_id,
|
||||
trigger_source,
|
||||
json.dumps(template_params, ensure_ascii=False) if template_params else None,
|
||||
success,
|
||||
result_message,
|
||||
transaction_id
|
||||
))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"알림톡 로그 DB 저장 실패: {e}")
|
||||
|
||||
|
||||
def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
"""
|
||||
알림톡 발송 공통 함수
|
||||
@@ -70,7 +99,21 @@ def _send_alimtalk(template_code, recipient_no, template_params):
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def send_mileage_claim_alimtalk(phone, name, points, balance):
|
||||
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):
|
||||
"""
|
||||
마일리지 적립 완료 알림톡 발송
|
||||
|
||||
@@ -79,16 +122,22 @@ def send_mileage_claim_alimtalk(phone, name, points, balance):
|
||||
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('%Y-%m-%d %H:%M')
|
||||
now_kst = datetime.now(KST).strftime('%m/%d %H:%M')
|
||||
item_summary = build_item_summary(items)
|
||||
|
||||
# MILEAGE_CLAIM_V2 (버튼 포함 버전) 우선 시도
|
||||
template_code = 'MILEAGE_CLAIM_V2'
|
||||
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도
|
||||
template_code = 'MILEAGE_CLAIM_V3'
|
||||
params = {
|
||||
'고객명': name,
|
||||
'구매품목': item_summary,
|
||||
'적립포인트': f'{points:,}',
|
||||
'총잔액': f'{balance:,}',
|
||||
'적립일시': now_kst,
|
||||
@@ -98,14 +147,56 @@ def send_mileage_claim_alimtalk(phone, name, points, balance):
|
||||
success, msg = _send_alimtalk(template_code, phone, params)
|
||||
|
||||
if not success:
|
||||
# V2 실패 시 V1 (버튼 없는 버전) 시도
|
||||
template_code = 'MILEAGE_CLAIM'
|
||||
params_v1 = {
|
||||
# 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
|
||||
'적립일시': now_kst,
|
||||
'전화번호': phone
|
||||
}
|
||||
success, msg = _send_alimtalk(template_code, phone, params_v1)
|
||||
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))
|
||||
@@ -393,9 +393,19 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">📊 관리자 대시보드</div>
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
<div class="header-content" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div class="header-title">📊 관리자 대시보드</div>
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/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>
|
||||
|
||||
@@ -462,6 +472,8 @@
|
||||
<th>전화번호</th>
|
||||
<th>포인트</th>
|
||||
<th>가입일</th>
|
||||
<th>카카오</th>
|
||||
<th>조제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -472,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>
|
||||
@@ -655,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';
|
||||
@@ -839,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>
|
||||
@@ -853,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;">
|
||||
@@ -867,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>
|
||||
|
||||
@@ -927,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>
|
||||
`;
|
||||
@@ -1293,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>
|
||||
@@ -59,7 +59,8 @@
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
position: relative;
|
||||
height: 380px;
|
||||
height: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide {
|
||||
position: absolute;
|
||||
@@ -98,31 +99,31 @@
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.slide-title {
|
||||
font-size: 30px;
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.slide-desc {
|
||||
font-size: 17px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
font-size: 23px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
line-height: 1.6;
|
||||
max-width: 500px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.slide-highlight {
|
||||
display: inline-block;
|
||||
padding: 10px 28px;
|
||||
padding: 12px 32px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 14px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 15px;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -395,16 +396,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.claim-left {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.claim-info-card { flex: 1; min-width: 200px; }
|
||||
.qr-container { flex-shrink: 0; }
|
||||
.items-card { width: 100%; max-height: 160px; }
|
||||
.qr-container img { width: 140px; height: 140px; }
|
||||
.claim-info-card { width: 100%; max-width: 480px; }
|
||||
.qr-container { align-self: center; }
|
||||
.items-card { width: 100%; max-width: 480px; max-height: 160px; }
|
||||
.qr-container img { width: 160px; height: 160px; }
|
||||
|
||||
.divider { flex-direction: row; }
|
||||
.divider-line { width: 60px; height: 2px; }
|
||||
|
||||
@@ -392,6 +392,156 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- AI 추천 바텀시트 -->
|
||||
<div id="rec-sheet" style="display:none;">
|
||||
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
|
||||
<!-- 드래그 핸들 영역 -->
|
||||
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
</div>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec('dismissed')" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec('interested')" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes recFadeIn { from{opacity:0} to{opacity:1} }
|
||||
@keyframes recSlideUp { from{transform:translate(-50%,100%)} to{transform:translate(-50%,0)} }
|
||||
@keyframes recSlideDown { from{transform:translate(-50%,0)} to{transform:translate(-50%,100%)} }
|
||||
</style>
|
||||
<script>
|
||||
let _recId = null;
|
||||
|
||||
// ── 드래그 닫기 ──
|
||||
(function() {
|
||||
let startY = 0, currentY = 0, isDragging = false;
|
||||
const DISMISS_THRESHOLD = 80;
|
||||
|
||||
function getContent() { return document.getElementById('rec-content'); }
|
||||
function getBackdrop() { return document.getElementById('rec-backdrop'); }
|
||||
|
||||
function onStart(y) {
|
||||
const c = getContent();
|
||||
if (!c) return;
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
currentY = 0;
|
||||
c.style.animation = 'none';
|
||||
c.style.transition = 'none';
|
||||
}
|
||||
function onMove(y) {
|
||||
if (!isDragging) return;
|
||||
const c = getContent();
|
||||
const b = getBackdrop();
|
||||
currentY = Math.max(0, y - startY); // 아래로만
|
||||
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
|
||||
// 배경 투명도도 같이
|
||||
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
|
||||
b.style.background = 'rgba(0,0,0,' + opacity + ')';
|
||||
}
|
||||
function onEnd() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const c = getContent();
|
||||
if (currentY > DISMISS_THRESHOLD) {
|
||||
// 충분히 내렸으면 닫기
|
||||
c.style.transition = 'transform .25s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
getBackdrop().style.transition = 'opacity .25s';
|
||||
getBackdrop().style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
document.getElementById('rec-sheet').style.display = 'none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 250);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
} else {
|
||||
// 복귀
|
||||
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
|
||||
c.style.transform = 'translate(-50%, 0)';
|
||||
getBackdrop().style.transition = 'background .25s';
|
||||
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
|
||||
setTimeout(function() { c.style.transition = ''; }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('rec-content');
|
||||
if (!el) return;
|
||||
|
||||
// 터치 (모바일)
|
||||
el.addEventListener('touchstart', function(e) {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, {passive: true});
|
||||
el.addEventListener('touchmove', function(e) {
|
||||
if (isDragging && currentY > 0) e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, {passive: false});
|
||||
el.addEventListener('touchend', onEnd);
|
||||
|
||||
// 마우스 (데스크톱 테스트용)
|
||||
el.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
onStart(e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) onMove(e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 추천 로드 ──
|
||||
window.addEventListener('load', function() {
|
||||
{% if user_id %}
|
||||
setTimeout(async function() {
|
||||
try {
|
||||
const res = await fetch('/api/recommendation/{{ user_id }}');
|
||||
const data = await res.json();
|
||||
if (data.success && data.has_recommendation) {
|
||||
_recId = data.recommendation.id;
|
||||
document.getElementById('rec-message').textContent = data.recommendation.message;
|
||||
document.getElementById('rec-product').textContent = data.recommendation.product;
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[AI추천] 에러:', e);
|
||||
}
|
||||
}, 1500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function dismissRec(action) {
|
||||
action = action || 'dismissed';
|
||||
const c = document.getElementById('rec-content');
|
||||
const b = document.getElementById('rec-backdrop');
|
||||
c.style.transition = 'transform .3s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
b.style.opacity = '0';
|
||||
b.style.transition = 'opacity .3s';
|
||||
setTimeout(function(){
|
||||
document.getElementById('rec-sheet').style.display='none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 300);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action: action})
|
||||
}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -115,8 +115,8 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
|
||||
- token_hash가 이미 존재하면 실패 (UNIQUE 제약)
|
||||
"""
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
conn = db_manager.get_sqlite_connection()
|
||||
from db.dbsetup import db_manager as _db_manager
|
||||
conn = _db_manager.get_sqlite_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 중복 체크 (transaction_id)
|
||||
|
||||
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
|
||||
|
||||
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