Compare commits
38 Commits
ccb0067a1c
...
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 |
988
backend/app.py
988
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,44 +185,60 @@ class DatabaseManager:
|
|||||||
# 새 세션 생성
|
# 새 세션 생성
|
||||||
return self.get_session(database)
|
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:
|
Returns:
|
||||||
sqlite3.Connection: SQLite 연결 객체
|
sqlite3.Connection: SQLite 연결 객체
|
||||||
"""
|
"""
|
||||||
# 연결이 닫혀있으면 재생성
|
# 새 연결 요청 시 항상 새로 생성
|
||||||
|
if new_connection:
|
||||||
|
return self._create_sqlite_connection()
|
||||||
|
|
||||||
|
# 기존 싱글톤 방식 (하위 호환)
|
||||||
if self.sqlite_conn is not None:
|
if self.sqlite_conn is not None:
|
||||||
try:
|
try:
|
||||||
self.sqlite_conn.execute("SELECT 1")
|
cursor = self.sqlite_conn.cursor()
|
||||||
except Exception:
|
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
|
self.sqlite_conn = None
|
||||||
|
|
||||||
if self.sqlite_conn is None:
|
if self.sqlite_conn is None:
|
||||||
# 파일 존재 여부 확인
|
self.sqlite_conn = self._create_sqlite_connection()
|
||||||
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()
|
|
||||||
|
|
||||||
return self.sqlite_conn
|
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):
|
def init_sqlite_schema(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
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()
|
||||||
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 sys
|
||||||
|
import os
|
||||||
import serial
|
import serial
|
||||||
import serial.tools.list_ports
|
import serial.tools.list_ports
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -19,6 +20,8 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
# MSSQL 데이터베이스 연결
|
# MSSQL 데이터베이스 연결
|
||||||
sys.path.insert(0, '.')
|
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
|
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()
|
||||||
@@ -321,6 +321,122 @@ def generate_upsell_real(user_name, current_items, recent_products, available_pr
|
|||||||
return _parse_upsell_response(response_text)
|
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):
|
def _parse_upsell_response(text):
|
||||||
"""AI 응답에서 JSON 추출"""
|
"""AI 응답에서 JSON 추출"""
|
||||||
import re
|
import re
|
||||||
|
|||||||
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))
|
||||||
@@ -399,6 +399,9 @@
|
|||||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;">
|
<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/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/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>
|
<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>
|
||||||
@@ -469,6 +472,8 @@
|
|||||||
<th>전화번호</th>
|
<th>전화번호</th>
|
||||||
<th>포인트</th>
|
<th>포인트</th>
|
||||||
<th>가입일</th>
|
<th>가입일</th>
|
||||||
|
<th>카카오</th>
|
||||||
|
<th>조제</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -479,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="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 class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
|
||||||
<td>{{ user.created_at[:16].replace('T', ' ') }}</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -662,7 +681,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
function showTransactionDetail(transactionId) {
|
function showTransactionDetail(transactionId) {
|
||||||
document.getElementById('transactionModal').style.display = 'block';
|
document.getElementById('transactionModal').style.display = 'block';
|
||||||
@@ -846,7 +865,10 @@
|
|||||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
|
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
|
||||||
<div>
|
<div>
|
||||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</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>
|
<div>
|
||||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
|
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
|
||||||
@@ -860,6 +882,12 @@
|
|||||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
|
<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 style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
|
||||||
</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>
|
||||||
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
|
<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;">
|
<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;">
|
||||||
@@ -874,10 +902,16 @@
|
|||||||
<!-- 탭 메뉴 -->
|
<!-- 탭 메뉴 -->
|
||||||
<div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;">
|
<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;">
|
<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>
|
||||||
<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;">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -934,6 +968,96 @@
|
|||||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>';
|
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 += `
|
html += `
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1300,9 +1424,12 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
users.forEach(user => {
|
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 += `
|
html += `
|
||||||
<tr style="border-bottom: 1px solid #f1f3f5;">
|
<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; 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: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td>
|
||||||
<td style="padding: 14px; text-align: center;">
|
<td style="padding: 14px; text-align: center;">
|
||||||
|
|||||||
@@ -258,7 +258,10 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-nav">
|
<div class="header-nav">
|
||||||
<a href="/admin">← 관리자 홈</a>
|
<a href="/admin">← 관리자 홈</a>
|
||||||
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
<div>
|
||||||
|
<a href="/admin/ai-gw" style="margin-right: 16px;">Gateway 모니터</a>
|
||||||
|
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1>AI 업셀링 CRM</h1>
|
<h1>AI 업셀링 CRM</h1>
|
||||||
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
|
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
|
||||||
|
|||||||
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>
|
||||||
@@ -299,6 +299,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-nav">
|
<div class="header-nav">
|
||||||
<a href="/admin">관리자 홈</a>
|
<a href="/admin">관리자 홈</a>
|
||||||
|
<a href="/admin/ai-crm">AI 업셀링</a>
|
||||||
|
<a href="/admin/ai-gw">Gateway 모니터</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
@@ -261,6 +261,152 @@
|
|||||||
color: #64748b;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
@@ -355,10 +501,11 @@
|
|||||||
<th>수량</th>
|
<th>수량</th>
|
||||||
<th>단가</th>
|
<th>단가</th>
|
||||||
<th>합계</th>
|
<th>합계</th>
|
||||||
|
<th>QR</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="salesTableBody">
|
<tbody id="salesTableBody">
|
||||||
<tr><td colspan="6" class="loading">로딩 중...</td></tr>
|
<tr><td colspan="7" class="loading">로딩 중...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,11 +562,11 @@
|
|||||||
const tbody = document.getElementById('salesTableBody');
|
const tbody = document.getElementById('salesTableBody');
|
||||||
|
|
||||||
if (salesData.length === 0) {
|
if (salesData.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">판매 내역이 없습니다</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = salesData.map(item => `
|
tbody.innerHTML = salesData.map((item, idx) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
|
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -430,6 +577,11 @@
|
|||||||
<td class="qty">${item.quantity}</td>
|
<td class="qty">${item.quantity}</td>
|
||||||
<td class="price">${formatPrice(item.unit_price)}</td>
|
<td class="price">${formatPrice(item.unit_price)}</td>
|
||||||
<td class="price">${formatPrice(item.total_price)}</td>
|
<td class="price">${formatPrice(item.total_price)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-qr" onclick="printQR(${idx})" title="QR 라벨 인쇄">
|
||||||
|
🏷️ QR
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -445,7 +597,7 @@
|
|||||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||||
|
|
||||||
document.getElementById('salesTableBody').innerHTML =
|
document.getElementById('salesTableBody').innerHTML =
|
||||||
'<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
|
'<tr><td colspan="7" class="loading">로딩 중...</td></tr>';
|
||||||
|
|
||||||
fetch(`/api/sales-detail?days=${period}&search=${encodeURIComponent(search)}&barcode=${barcodeFilter}`)
|
fetch(`/api/sales-detail?days=${period}&search=${encodeURIComponent(search)}&barcode=${barcodeFilter}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -463,17 +615,164 @@
|
|||||||
renderTable();
|
renderTable();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('salesTableBody').innerHTML =
|
document.getElementById('salesTableBody').innerHTML =
|
||||||
`<tr><td colspan="6" class="empty-state">오류: ${data.error}</td></tr>`;
|
`<tr><td colspan="7" class="empty-state">오류: ${data.error}</td></tr>`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
document.getElementById('salesTableBody').innerHTML =
|
document.getElementById('salesTableBody').innerHTML =
|
||||||
`<tr><td colspan="6" class="empty-state">데이터 로드 실패</td></tr>`;
|
`<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();
|
loadSalesData();
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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 | 용림 🐉*
|
||||||
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개월 후 재검사 결과 비교
|
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 | 계획 문서 작성 |
|
||||||
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