Compare commits
35 Commits
f3fa4707ac
...
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 |
980
backend/app.py
980
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)
|
||||
|
||||
def get_sqlite_connection(self):
|
||||
def get_sqlite_connection(self, new_connection=False):
|
||||
"""
|
||||
SQLite mileage.db 연결 반환 (싱글톤 패턴)
|
||||
최초 호출 시 스키마 자동 초기화
|
||||
SQLite mileage.db 연결 반환
|
||||
|
||||
Args:
|
||||
new_connection: True면 항상 새 연결 생성 (멀티스레드 안전)
|
||||
|
||||
Returns:
|
||||
sqlite3.Connection: SQLite 연결 객체
|
||||
"""
|
||||
# 연결이 닫혀있으면 재생성
|
||||
# 새 연결 요청 시 항상 새로 생성
|
||||
if new_connection:
|
||||
return self._create_sqlite_connection()
|
||||
|
||||
# 기존 싱글톤 방식 (하위 호환)
|
||||
if self.sqlite_conn is not None:
|
||||
try:
|
||||
self.sqlite_conn.execute("SELECT 1")
|
||||
except Exception:
|
||||
cursor = self.sqlite_conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
print(f"[DB Manager] SQLite 연결 체크 실패, 재연결: {e}")
|
||||
try:
|
||||
self.sqlite_conn.close()
|
||||
except:
|
||||
pass
|
||||
self.sqlite_conn = None
|
||||
|
||||
if self.sqlite_conn is None:
|
||||
# 파일 존재 여부 확인
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
|
||||
# 연결 생성
|
||||
self.sqlite_conn = sqlite3.connect(
|
||||
str(self.sqlite_db_path),
|
||||
check_same_thread=False, # 멀티스레드 허용
|
||||
timeout=10.0 # 10초 대기
|
||||
)
|
||||
|
||||
# Row Factory 설정 (dict 형태로 결과 반환)
|
||||
self.sqlite_conn.row_factory = sqlite3.Row
|
||||
|
||||
# 신규 DB면 스키마 초기화
|
||||
if is_new_db:
|
||||
self.init_sqlite_schema()
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
else:
|
||||
print(f"[DB Manager] SQLite 기존 DB 연결: {self.sqlite_db_path}")
|
||||
self._migrate_sqlite()
|
||||
self.sqlite_conn = self._create_sqlite_connection()
|
||||
|
||||
return self.sqlite_conn
|
||||
|
||||
def _create_sqlite_connection(self):
|
||||
"""새 SQLite 연결 생성"""
|
||||
is_new_db = not self.sqlite_db_path.exists()
|
||||
|
||||
conn = sqlite3.connect(
|
||||
str(self.sqlite_db_path),
|
||||
check_same_thread=False,
|
||||
timeout=10.0
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
if is_new_db:
|
||||
# 스키마 초기화 (임시로 self.sqlite_conn 설정)
|
||||
old_conn = self.sqlite_conn
|
||||
self.sqlite_conn = conn
|
||||
self.init_sqlite_schema()
|
||||
self.sqlite_conn = old_conn
|
||||
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
|
||||
|
||||
return conn
|
||||
|
||||
def init_sqlite_schema(self):
|
||||
"""
|
||||
|
||||
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 os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
@@ -19,6 +20,8 @@ from sqlalchemy import text
|
||||
|
||||
# MSSQL 데이터베이스 연결
|
||||
sys.path.insert(0, '.')
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
# 바코드 라벨 출력
|
||||
|
||||
713
backend/samples/pos_dummy_gui.py
Normal file
713
backend/samples/pos_dummy_gui.py
Normal file
@@ -0,0 +1,713 @@
|
||||
"""
|
||||
더미 POS 시스템 GUI (PyQt5)
|
||||
바코드 스캐너로 제품을 추가하고 수량 조절, 할인 적용, 결제까지 지원
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from datetime import datetime
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QGroupBox, QComboBox, QSpinBox,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView, QFrame,
|
||||
QLineEdit, QDialog, QFormLayout, QDoubleSpinBox, QMessageBox,
|
||||
QAbstractItemView, QCheckBox, QSplitter
|
||||
)
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt5.QtGui import QFont, QColor, QBrush, QIcon
|
||||
from sqlalchemy import text
|
||||
|
||||
# DB 연결
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from dbsetup import DatabaseManager
|
||||
|
||||
|
||||
# ─── GS1 바코드 파싱 ───────────────────────────────────────────
|
||||
|
||||
def parse_gs1_barcode(barcode):
|
||||
candidates = [barcode]
|
||||
if barcode.startswith('01') and len(barcode) >= 16:
|
||||
gtin14 = barcode[2:16]
|
||||
candidates.append(gtin14)
|
||||
if gtin14.startswith('0'):
|
||||
candidates.append(gtin14[1:])
|
||||
elif barcode.startswith('01') and len(barcode) == 15:
|
||||
candidates.append(barcode[2:15])
|
||||
return candidates
|
||||
|
||||
|
||||
def search_drug_by_barcode(barcode):
|
||||
try:
|
||||
db_manager = DatabaseManager()
|
||||
engine = db_manager.get_engine('PM_DRUG')
|
||||
query = text('''
|
||||
SELECT TOP 1
|
||||
BARCODE, GoodsName, DrugCode, SplName,
|
||||
Price, Saleprice, SUNG_CODE, IsUSE
|
||||
FROM CD_GOODS
|
||||
WHERE BARCODE = :barcode
|
||||
AND (GoodsName NOT LIKE N'%(판매중지)%'
|
||||
AND GoodsName NOT LIKE N'%(판매중단)%')
|
||||
ORDER BY
|
||||
CASE WHEN IsUSE = '1' THEN 0 ELSE 1 END,
|
||||
CASE WHEN Price > 0 THEN 0 ELSE 1 END,
|
||||
CASE WHEN SplName IS NOT NULL AND SplName != '' THEN 0 ELSE 1 END,
|
||||
DrugCode DESC
|
||||
''')
|
||||
candidates = parse_gs1_barcode(barcode)
|
||||
with engine.connect() as conn:
|
||||
for candidate in candidates:
|
||||
result = conn.execute(query, {"barcode": candidate})
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return {
|
||||
'barcode': row.BARCODE,
|
||||
'goods_name': row.GoodsName,
|
||||
'drug_code': row.DrugCode,
|
||||
'manufacturer': row.SplName or '',
|
||||
'price': float(row.Price) if row.Price else 0,
|
||||
'sale_price': float(row.Saleprice) if row.Saleprice else 0,
|
||||
'sung_code': row.SUNG_CODE or ''
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f'[오류] 약품 조회 실패: {e}')
|
||||
return None
|
||||
|
||||
|
||||
# ─── 바코드 리더 스레드 ────────────────────────────────────────
|
||||
|
||||
class BarcodeReaderThread(QThread):
|
||||
barcode_received = pyqtSignal(str)
|
||||
connection_status = pyqtSignal(bool, str)
|
||||
|
||||
def __init__(self, port='COM3', baudrate=115200):
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.running = False
|
||||
self.serial_connection = None
|
||||
|
||||
def run(self):
|
||||
self.running = True
|
||||
try:
|
||||
self.serial_connection = serial.Serial(
|
||||
port=self.port, baudrate=self.baudrate,
|
||||
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE, timeout=1
|
||||
)
|
||||
self.connection_status.emit(True, f'{self.port} 연결됨 ({self.baudrate} bps)')
|
||||
while self.running:
|
||||
if self.serial_connection.in_waiting > 0:
|
||||
data = self.serial_connection.read(self.serial_connection.in_waiting)
|
||||
try:
|
||||
text_data = data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text_data = data.decode('ascii', errors='ignore')
|
||||
for line in text_data.strip().split('\n'):
|
||||
barcode = line.strip()
|
||||
if barcode and len(barcode) in [13, 15, 16]:
|
||||
self.barcode_received.emit(barcode)
|
||||
except serial.SerialException as e:
|
||||
self.connection_status.emit(False, f'연결 실패: {e}')
|
||||
except Exception as e:
|
||||
self.connection_status.emit(False, f'오류: {e}')
|
||||
finally:
|
||||
if self.serial_connection and self.serial_connection.is_open:
|
||||
self.serial_connection.close()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.serial_connection and self.serial_connection.is_open:
|
||||
self.serial_connection.close()
|
||||
|
||||
|
||||
class DrugSearchThread(QThread):
|
||||
search_complete = pyqtSignal(str, object)
|
||||
|
||||
def __init__(self, barcode):
|
||||
super().__init__()
|
||||
self.barcode = barcode
|
||||
|
||||
def run(self):
|
||||
info = search_drug_by_barcode(self.barcode)
|
||||
self.search_complete.emit(self.barcode, info)
|
||||
|
||||
|
||||
# ─── 할인 다이얼로그 ──────────────────────────────────────────
|
||||
|
||||
class DiscountDialog(QDialog):
|
||||
def __init__(self, item_name, current_price, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f'할인 적용 - {item_name}')
|
||||
self.setMinimumWidth(350)
|
||||
self.result_discount = 0
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
info = QLabel(f'제품: {item_name}\n판매가: {current_price:,.0f}원')
|
||||
info.setStyleSheet('font-size: 14px; padding: 10px;')
|
||||
layout.addWidget(info)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
self.discount_type = QComboBox()
|
||||
self.discount_type.addItems(['금액 할인 (원)', '비율 할인 (%)'])
|
||||
form.addRow('할인 방식:', self.discount_type)
|
||||
|
||||
self.discount_value = QDoubleSpinBox()
|
||||
self.discount_value.setMaximum(999999)
|
||||
self.discount_value.setDecimals(0)
|
||||
form.addRow('할인값:', self.discount_value)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
self.preview_label = QLabel('')
|
||||
self.preview_label.setStyleSheet('font-size: 13px; color: #E53935; padding: 10px; font-weight: bold;')
|
||||
layout.addWidget(self.preview_label)
|
||||
|
||||
self.discount_value.valueChanged.connect(
|
||||
lambda: self._update_preview(current_price))
|
||||
self.discount_type.currentIndexChanged.connect(
|
||||
lambda: self._update_preview(current_price))
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton('적용')
|
||||
ok_btn.setStyleSheet('background: #4CAF50; color: white; font-weight: bold; padding: 8px 24px;')
|
||||
ok_btn.clicked.connect(lambda: self._apply(current_price))
|
||||
cancel_btn = QPushButton('취소')
|
||||
cancel_btn.setStyleSheet('padding: 8px 24px;')
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
btn_layout.addWidget(ok_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _update_preview(self, price):
|
||||
val = self.discount_value.value()
|
||||
if self.discount_type.currentIndex() == 0:
|
||||
disc = val
|
||||
else:
|
||||
disc = price * val / 100
|
||||
final = max(0, price - disc)
|
||||
self.preview_label.setText(f'할인: -{disc:,.0f}원 → 최종가: {final:,.0f}원')
|
||||
|
||||
def _apply(self, price):
|
||||
val = self.discount_value.value()
|
||||
if self.discount_type.currentIndex() == 0:
|
||||
self.result_discount = val
|
||||
else:
|
||||
self.result_discount = price * val / 100
|
||||
self.accept()
|
||||
|
||||
|
||||
# ─── 메인 POS GUI ─────────────────────────────────────────────
|
||||
|
||||
class POSDummyGUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reader_thread = None
|
||||
self.search_threads = []
|
||||
self.cart_items = [] # [{barcode, goods_name, manufacturer, price, sale_price, qty, discount}]
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
self.setWindowTitle('청춘약국 POS')
|
||||
self.setGeometry(50, 50, 1200, 800)
|
||||
self.setStyleSheet('''
|
||||
QMainWindow { background: #F5F5F5; }
|
||||
QGroupBox {
|
||||
font-weight: bold; font-size: 13px;
|
||||
border: 1px solid #E0E0E0; border-radius: 6px;
|
||||
margin-top: 12px; padding-top: 18px;
|
||||
background: white;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px; padding: 0 6px;
|
||||
}
|
||||
''')
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root_layout = QVBoxLayout()
|
||||
root_layout.setContentsMargins(12, 8, 12, 8)
|
||||
central.setLayout(root_layout)
|
||||
|
||||
# ── 상단: 연결 설정 ──
|
||||
conn_group = QGroupBox('스캐너 연결')
|
||||
conn_layout = QHBoxLayout()
|
||||
conn_group.setLayout(conn_layout)
|
||||
|
||||
conn_layout.addWidget(QLabel('포트:'))
|
||||
self.port_combo = QComboBox()
|
||||
self.port_combo.setMinimumWidth(200)
|
||||
self._refresh_ports()
|
||||
conn_layout.addWidget(self.port_combo)
|
||||
|
||||
refresh_btn = QPushButton('⟳')
|
||||
refresh_btn.setFixedWidth(36)
|
||||
refresh_btn.clicked.connect(self._refresh_ports)
|
||||
conn_layout.addWidget(refresh_btn)
|
||||
|
||||
conn_layout.addWidget(QLabel('속도:'))
|
||||
self.baudrate_spin = QSpinBox()
|
||||
self.baudrate_spin.setRange(9600, 921600)
|
||||
self.baudrate_spin.setValue(115200)
|
||||
self.baudrate_spin.setSingleStep(9600)
|
||||
conn_layout.addWidget(self.baudrate_spin)
|
||||
|
||||
self.connect_btn = QPushButton('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
self.connect_btn.clicked.connect(self._toggle_connection)
|
||||
conn_layout.addWidget(self.connect_btn)
|
||||
|
||||
self.status_label = QLabel('대기 중')
|
||||
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||
conn_layout.addWidget(self.status_label)
|
||||
conn_layout.addStretch()
|
||||
|
||||
# 수동 바코드 입력
|
||||
conn_layout.addWidget(QLabel('수동입력:'))
|
||||
self.manual_input = QLineEdit()
|
||||
self.manual_input.setPlaceholderText('바코드 번호 입력 후 Enter')
|
||||
self.manual_input.setMinimumWidth(180)
|
||||
self.manual_input.returnPressed.connect(self._manual_barcode)
|
||||
conn_layout.addWidget(self.manual_input)
|
||||
|
||||
root_layout.addWidget(conn_group)
|
||||
|
||||
# ── 중앙: 장바구니 테이블 + 우측 요약 ──
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 장바구니 테이블
|
||||
cart_group = QGroupBox('장바구니')
|
||||
cart_layout = QVBoxLayout()
|
||||
cart_group.setLayout(cart_layout)
|
||||
|
||||
self.cart_table = QTableWidget()
|
||||
self.cart_table.setColumnCount(8)
|
||||
self.cart_table.setHorizontalHeaderLabels([
|
||||
'제품명', '제조사', '바코드', '입고가', '판매가', '수량', '할인', '소계'
|
||||
])
|
||||
header = self.cart_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
for i in [1]:
|
||||
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||
for i in [2, 3, 4, 5, 6, 7]:
|
||||
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||
|
||||
self.cart_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.cart_table.setAlternatingRowColors(True)
|
||||
self.cart_table.setStyleSheet('''
|
||||
QTableWidget {
|
||||
font-size: 13px; gridline-color: #E0E0E0;
|
||||
alternate-background-color: #FAFAFA;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #37474F; color: white;
|
||||
font-weight: bold; font-size: 12px;
|
||||
padding: 6px; border: none;
|
||||
}
|
||||
''')
|
||||
self.cart_table.verticalHeader().setVisible(False)
|
||||
cart_layout.addWidget(self.cart_table)
|
||||
|
||||
# 장바구니 아래 버튼들
|
||||
cart_btn_layout = QHBoxLayout()
|
||||
|
||||
qty_up_btn = QPushButton('+1')
|
||||
qty_up_btn.setStyleSheet(
|
||||
'background: #2196F3; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||
qty_up_btn.clicked.connect(lambda: self._change_qty(1))
|
||||
cart_btn_layout.addWidget(qty_up_btn)
|
||||
|
||||
qty_down_btn = QPushButton('-1')
|
||||
qty_down_btn.setStyleSheet(
|
||||
'background: #FF9800; color: white; font-weight: bold; font-size: 16px; padding: 8px 16px; border-radius: 4px;')
|
||||
qty_down_btn.clicked.connect(lambda: self._change_qty(-1))
|
||||
cart_btn_layout.addWidget(qty_down_btn)
|
||||
|
||||
discount_btn = QPushButton('할인')
|
||||
discount_btn.setStyleSheet(
|
||||
'background: #9C27B0; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||
discount_btn.clicked.connect(self._apply_discount)
|
||||
cart_btn_layout.addWidget(discount_btn)
|
||||
|
||||
remove_btn = QPushButton('삭제')
|
||||
remove_btn.setStyleSheet(
|
||||
'background: #F44336; color: white; font-weight: bold; font-size: 14px; padding: 8px 16px; border-radius: 4px;')
|
||||
remove_btn.clicked.connect(self._remove_selected)
|
||||
cart_btn_layout.addWidget(remove_btn)
|
||||
|
||||
cart_btn_layout.addStretch()
|
||||
|
||||
clear_btn = QPushButton('전체 삭제')
|
||||
clear_btn.setStyleSheet(
|
||||
'background: #757575; color: white; font-size: 13px; padding: 8px 16px; border-radius: 4px;')
|
||||
clear_btn.clicked.connect(self._clear_cart)
|
||||
cart_btn_layout.addWidget(clear_btn)
|
||||
|
||||
cart_layout.addLayout(cart_btn_layout)
|
||||
splitter.addWidget(cart_group)
|
||||
|
||||
# ── 우측 패널: 요약 + 결제 ──
|
||||
right_panel = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_panel.setLayout(right_layout)
|
||||
|
||||
# 최근 스캔
|
||||
scan_group = QGroupBox('최근 스캔')
|
||||
scan_layout = QVBoxLayout()
|
||||
scan_group.setLayout(scan_layout)
|
||||
|
||||
self.last_scan_label = QLabel('바코드를 스캔하세요')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||
self.last_scan_label.setWordWrap(True)
|
||||
self.last_scan_label.setMinimumHeight(80)
|
||||
scan_layout.addWidget(self.last_scan_label)
|
||||
|
||||
right_layout.addWidget(scan_group)
|
||||
|
||||
# 합계 요약
|
||||
summary_group = QGroupBox('합계')
|
||||
summary_layout = QVBoxLayout()
|
||||
summary_group.setLayout(summary_layout)
|
||||
|
||||
self.item_count_label = QLabel('품목: 0개 / 수량: 0개')
|
||||
self.item_count_label.setStyleSheet('font-size: 14px; color: #616161; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.item_count_label)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.HLine)
|
||||
sep1.setStyleSheet('color: #E0E0E0;')
|
||||
summary_layout.addWidget(sep1)
|
||||
|
||||
self.cost_label = QLabel('입고 합계: 0원')
|
||||
self.cost_label.setStyleSheet('font-size: 13px; color: #9E9E9E; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.cost_label)
|
||||
|
||||
self.subtotal_label = QLabel('판매 합계: 0원')
|
||||
self.subtotal_label.setStyleSheet('font-size: 14px; color: #424242; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.subtotal_label)
|
||||
|
||||
self.discount_total_label = QLabel('할인 합계: -0원')
|
||||
self.discount_total_label.setStyleSheet('font-size: 14px; color: #E53935; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.discount_total_label)
|
||||
|
||||
sep2 = QFrame()
|
||||
sep2.setFrameShape(QFrame.HLine)
|
||||
sep2.setStyleSheet('color: #37474F; border: 1px solid #37474F;')
|
||||
summary_layout.addWidget(sep2)
|
||||
|
||||
self.total_label = QLabel('총 결제금액: 0원')
|
||||
self.total_label.setStyleSheet(
|
||||
'font-size: 22px; font-weight: bold; color: #1B5E20; padding: 8px;')
|
||||
summary_layout.addWidget(self.total_label)
|
||||
|
||||
self.margin_label = QLabel('마진: 0원 (0%)')
|
||||
self.margin_label.setStyleSheet('font-size: 13px; color: #1565C0; padding: 4px 8px;')
|
||||
summary_layout.addWidget(self.margin_label)
|
||||
|
||||
right_layout.addWidget(summary_group)
|
||||
right_layout.addStretch()
|
||||
|
||||
# 결제 버튼
|
||||
pay_btn = QPushButton('결 제')
|
||||
pay_btn.setMinimumHeight(70)
|
||||
pay_btn.setStyleSheet('''
|
||||
QPushButton {
|
||||
background: #1B5E20; color: white;
|
||||
font-size: 26px; font-weight: bold;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton:hover { background: #2E7D32; }
|
||||
QPushButton:pressed { background: #1B5E20; }
|
||||
''')
|
||||
pay_btn.clicked.connect(self._pay)
|
||||
right_layout.addWidget(pay_btn)
|
||||
|
||||
splitter.addWidget(right_panel)
|
||||
splitter.setSizes([800, 350])
|
||||
|
||||
root_layout.addWidget(splitter, 1)
|
||||
|
||||
# ── 하단 상태바 ──
|
||||
self.statusBar().setStyleSheet('font-size: 12px; color: #757575;')
|
||||
self.statusBar().showMessage('청춘약국 POS | 바코드 스캐너를 연결하고 "연결" 버튼을 누르세요')
|
||||
|
||||
# ── 포트 관리 ──
|
||||
|
||||
def _refresh_ports(self):
|
||||
self.port_combo.clear()
|
||||
for port in serial.tools.list_ports.comports():
|
||||
self.port_combo.addItem(f'{port.device} - {port.description}', port.device)
|
||||
for i in range(self.port_combo.count()):
|
||||
if 'COM3' in (self.port_combo.itemData(i) or ''):
|
||||
self.port_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
def _toggle_connection(self):
|
||||
if self.reader_thread and self.reader_thread.isRunning():
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait()
|
||||
self.reader_thread = None
|
||||
self.connect_btn.setText('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
self.status_label.setText('연결 해제됨')
|
||||
self.status_label.setStyleSheet('color: #9E9E9E; font-size: 13px; margin-left: 12px;')
|
||||
self.statusBar().showMessage('스캐너 연결 해제')
|
||||
else:
|
||||
port = self.port_combo.currentData()
|
||||
if not port:
|
||||
self.status_label.setText('포트를 선택하세요')
|
||||
return
|
||||
self.reader_thread = BarcodeReaderThread(port, self.baudrate_spin.value())
|
||||
self.reader_thread.barcode_received.connect(self._on_barcode)
|
||||
self.reader_thread.connection_status.connect(self._on_connection)
|
||||
self.reader_thread.start()
|
||||
self.connect_btn.setText('연결 해제')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #F44336; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
|
||||
def _on_connection(self, ok, msg):
|
||||
if ok:
|
||||
self.status_label.setText(msg)
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #2E7D32; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||
self.statusBar().showMessage(f'스캐너 {msg} | 바코드를 스캔하세요')
|
||||
else:
|
||||
self.status_label.setText(msg)
|
||||
self.status_label.setStyleSheet(
|
||||
'color: #D32F2F; font-size: 13px; font-weight: bold; margin-left: 12px;')
|
||||
self.connect_btn.setText('연결')
|
||||
self.connect_btn.setStyleSheet(
|
||||
'background: #4CAF50; color: white; font-weight: bold; padding: 6px 20px; border-radius: 4px;')
|
||||
|
||||
# ── 바코드 수신 ──
|
||||
|
||||
def _manual_barcode(self):
|
||||
barcode = self.manual_input.text().strip()
|
||||
if barcode:
|
||||
self.manual_input.clear()
|
||||
self._on_barcode(barcode)
|
||||
|
||||
def _on_barcode(self, barcode):
|
||||
self.last_scan_label.setText(f'스캔: {barcode}\n조회 중...')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #FF6F00; padding: 12px;')
|
||||
self.statusBar().showMessage(f'바코드 {barcode} 조회 중...')
|
||||
|
||||
thread = DrugSearchThread(barcode)
|
||||
thread.search_complete.connect(self._on_search_done)
|
||||
thread.start()
|
||||
self.search_threads.append(thread)
|
||||
|
||||
def _on_search_done(self, barcode, info):
|
||||
sender = self.sender()
|
||||
if sender in self.search_threads:
|
||||
self.search_threads.remove(sender)
|
||||
|
||||
if not info:
|
||||
self.last_scan_label.setText(f'스캔: {barcode}\n제품을 찾을 수 없습니다')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #D32F2F; padding: 12px;')
|
||||
self.statusBar().showMessage(f'바코드 {barcode}: 데이터베이스에서 찾을 수 없음')
|
||||
return
|
||||
|
||||
# 이미 장바구니에 있으면 수량 +1
|
||||
for item in self.cart_items:
|
||||
if item['barcode'] == info['barcode']:
|
||||
item['qty'] += 1
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText(
|
||||
f'{info["goods_name"]}\n수량 → {item["qty"]}개')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #1565C0; padding: 12px;')
|
||||
self.statusBar().showMessage(f'{info["goods_name"]} 수량 +1 ({item["qty"]}개)')
|
||||
return
|
||||
|
||||
# 새 항목 추가
|
||||
self.cart_items.append({
|
||||
'barcode': info['barcode'],
|
||||
'goods_name': info['goods_name'],
|
||||
'manufacturer': info['manufacturer'],
|
||||
'price': info['price'],
|
||||
'sale_price': info['sale_price'],
|
||||
'qty': 1,
|
||||
'discount': 0,
|
||||
})
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText(
|
||||
f'{info["goods_name"]}\n{info["manufacturer"]} | {info["sale_price"]:,.0f}원')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #2E7D32; padding: 12px; font-weight: bold;')
|
||||
self.statusBar().showMessage(f'{info["goods_name"]} 추가됨 ({info["sale_price"]:,.0f}원)')
|
||||
|
||||
# ── 장바구니 조작 ──
|
||||
|
||||
def _selected_row(self):
|
||||
rows = self.cart_table.selectionModel().selectedRows()
|
||||
return rows[0].row() if rows else -1
|
||||
|
||||
def _change_qty(self, delta):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('제품을 선택하세요')
|
||||
return
|
||||
item = self.cart_items[row]
|
||||
item['qty'] = max(1, item['qty'] + delta)
|
||||
self._refresh_table()
|
||||
self.cart_table.selectRow(row)
|
||||
|
||||
def _apply_discount(self):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('할인할 제품을 선택하세요')
|
||||
return
|
||||
item = self.cart_items[row]
|
||||
dlg = DiscountDialog(item['goods_name'], item['sale_price'], self)
|
||||
if dlg.exec_() == QDialog.Accepted:
|
||||
item['discount'] = dlg.result_discount
|
||||
self._refresh_table()
|
||||
self.cart_table.selectRow(row)
|
||||
|
||||
def _remove_selected(self):
|
||||
row = self._selected_row()
|
||||
if row < 0:
|
||||
self.statusBar().showMessage('삭제할 제품을 선택하세요')
|
||||
return
|
||||
name = self.cart_items[row]['goods_name']
|
||||
del self.cart_items[row]
|
||||
self._refresh_table()
|
||||
self.statusBar().showMessage(f'{name} 삭제됨')
|
||||
|
||||
def _clear_cart(self):
|
||||
if not self.cart_items:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, '전체 삭제', '장바구니를 비우시겠습니까?',
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.cart_items.clear()
|
||||
self._refresh_table()
|
||||
self.statusBar().showMessage('장바구니 초기화')
|
||||
|
||||
# ── 테이블 갱신 ──
|
||||
|
||||
def _refresh_table(self):
|
||||
self.cart_table.setRowCount(len(self.cart_items))
|
||||
|
||||
total_cost = 0
|
||||
total_sale = 0
|
||||
total_discount = 0
|
||||
total_qty = 0
|
||||
|
||||
for i, item in enumerate(self.cart_items):
|
||||
subtotal = (item['sale_price'] - item['discount']) * item['qty']
|
||||
cost_total = item['price'] * item['qty']
|
||||
|
||||
cols = [
|
||||
item['goods_name'],
|
||||
item['manufacturer'],
|
||||
item['barcode'],
|
||||
f'{item["price"]:,.0f}',
|
||||
f'{item["sale_price"]:,.0f}',
|
||||
str(item['qty']),
|
||||
f'-{item["discount"]:,.0f}' if item['discount'] > 0 else '',
|
||||
f'{subtotal:,.0f}',
|
||||
]
|
||||
|
||||
for j, val in enumerate(cols):
|
||||
cell = QTableWidgetItem(val)
|
||||
cell.setFlags(cell.flags() & ~Qt.ItemIsEditable)
|
||||
# 숫자 컬럼 오른쪽 정렬
|
||||
if j >= 3:
|
||||
cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
# 할인 빨간색
|
||||
if j == 6 and item['discount'] > 0:
|
||||
cell.setForeground(QBrush(QColor('#E53935')))
|
||||
# 소계 볼드
|
||||
if j == 7:
|
||||
font = cell.font()
|
||||
font.setBold(True)
|
||||
cell.setFont(font)
|
||||
self.cart_table.setItem(i, j, cell)
|
||||
|
||||
total_cost += cost_total
|
||||
total_sale += item['sale_price'] * item['qty']
|
||||
total_discount += item['discount'] * item['qty']
|
||||
total_qty += item['qty']
|
||||
|
||||
final_total = total_sale - total_discount
|
||||
margin = final_total - total_cost
|
||||
margin_pct = (margin / final_total * 100) if final_total > 0 else 0
|
||||
|
||||
self.item_count_label.setText(f'품목: {len(self.cart_items)}개 / 수량: {total_qty}개')
|
||||
self.cost_label.setText(f'입고 합계: {total_cost:,.0f}원')
|
||||
self.subtotal_label.setText(f'판매 합계: {total_sale:,.0f}원')
|
||||
self.discount_total_label.setText(f'할인 합계: -{total_discount:,.0f}원')
|
||||
self.total_label.setText(f'총 결제금액: {final_total:,.0f}원')
|
||||
self.margin_label.setText(f'마진: {margin:,.0f}원 ({margin_pct:.1f}%)')
|
||||
|
||||
# ── 결제 ──
|
||||
|
||||
def _pay(self):
|
||||
if not self.cart_items:
|
||||
self.statusBar().showMessage('장바구니가 비어있습니다')
|
||||
return
|
||||
|
||||
total_sale = sum(it['sale_price'] * it['qty'] for it in self.cart_items)
|
||||
total_discount = sum(it['discount'] * it['qty'] for it in self.cart_items)
|
||||
final = total_sale - total_discount
|
||||
|
||||
items_text = '\n'.join(
|
||||
f' {it["goods_name"]} x{it["qty"]} {(it["sale_price"] - it["discount"]) * it["qty"]:,.0f}원'
|
||||
for it in self.cart_items
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, '결제 확인',
|
||||
f'총 결제금액: {final:,.0f}원\n\n{items_text}\n\n결제하시겠습니까?',
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
QMessageBox.information(
|
||||
self, '결제 완료',
|
||||
f'결제가 완료되었습니다.\n\n'
|
||||
f'시각: {now}\n'
|
||||
f'금액: {final:,.0f}원\n'
|
||||
f'품목: {len(self.cart_items)}개'
|
||||
)
|
||||
self.cart_items.clear()
|
||||
self._refresh_table()
|
||||
self.last_scan_label.setText('바코드를 스캔하세요')
|
||||
self.last_scan_label.setStyleSheet('font-size: 14px; color: #757575; padding: 12px;')
|
||||
self.statusBar().showMessage(f'결제 완료 ({final:,.0f}원) | {now}')
|
||||
|
||||
# ── 종료 ──
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.reader_thread:
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait()
|
||||
for t in self.search_threads:
|
||||
if t.isRunning():
|
||||
t.wait()
|
||||
event.accept()
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle('Fusion')
|
||||
window = POSDummyGUI()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
4
backend/scripts/start_server.bat
Normal file
4
backend/scripts/start_server.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0start_server.ps1"
|
||||
pause
|
||||
35
backend/scripts/start_server.ps1
Normal file
35
backend/scripts/start_server.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# start_server.ps1 - Flask 서버 시작 스크립트
|
||||
# 기존 프로세스 종료 후 새로 시작
|
||||
|
||||
$PORT = 7001
|
||||
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$BACKEND_DIR = Split-Path -Parent $SCRIPT_DIR
|
||||
|
||||
Write-Host "=== 청춘약국 마일리지 서버 시작 ===" -ForegroundColor Cyan
|
||||
|
||||
# 1. 기존 포트 사용 프로세스 종료
|
||||
Write-Host "1. 포트 $PORT 확인 중..." -ForegroundColor Yellow
|
||||
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($netstat) {
|
||||
$pid = ($netstat -split '\s+')[-1]
|
||||
Write-Host " 기존 프로세스 발견 (PID: $pid). 종료합니다..." -ForegroundColor Red
|
||||
taskkill /F /PID $pid 2>$null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 2. 서버 시작
|
||||
Write-Host "2. 서버 시작 중..." -ForegroundColor Yellow
|
||||
Set-Location $BACKEND_DIR
|
||||
Start-Process python -ArgumentList "app.py" -WindowStyle Hidden
|
||||
|
||||
# 3. 시작 확인
|
||||
Start-Sleep -Seconds 3
|
||||
$check = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($check) {
|
||||
Write-Host "=== 서버 시작 완료! ===" -ForegroundColor Green
|
||||
Write-Host "URL: http://localhost:$PORT" -ForegroundColor Cyan
|
||||
Write-Host "외부: http://192.168.0.14:$PORT" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "=== 서버 시작 실패 ===" -ForegroundColor Red
|
||||
Write-Host "로그를 확인하세요." -ForegroundColor Red
|
||||
}
|
||||
4
backend/scripts/stop_server.bat
Normal file
4
backend/scripts/stop_server.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0stop_server.ps1"
|
||||
pause
|
||||
15
backend/scripts/stop_server.ps1
Normal file
15
backend/scripts/stop_server.ps1
Normal file
@@ -0,0 +1,15 @@
|
||||
# stop_server.ps1 - Flask 서버 중지 스크립트
|
||||
|
||||
$PORT = 7001
|
||||
|
||||
Write-Host "=== 청춘약국 마일리지 서버 중지 ===" -ForegroundColor Cyan
|
||||
|
||||
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
|
||||
if ($netstat) {
|
||||
$pid = ($netstat -split '\s+')[-1]
|
||||
Write-Host "서버 프로세스 종료 중 (PID: $pid)..." -ForegroundColor Yellow
|
||||
taskkill /F /PID $pid 2>$null
|
||||
Write-Host "=== 서버 중지 완료 ===" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "실행 중인 서버가 없습니다." -ForegroundColor Yellow
|
||||
}
|
||||
175
backend/scripts/tag_animal_drugs.py
Normal file
175
backend/scripts/tag_animal_drugs.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
동물약 태깅 및 MSSQL 동기화
|
||||
1. 키워드로 CD_GOODS에서 동물약 검색
|
||||
2. SQLite drug_tags.db에 태깅
|
||||
3. MSSQL CD_GOODS.POS_BOON = '010103' 업데이트
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from db.dbsetup import db_manager
|
||||
from sqlalchemy import text
|
||||
|
||||
# SQLite DB 경로
|
||||
DB_PATH = Path(__file__).parent.parent / 'db' / 'drug_tags.db'
|
||||
|
||||
# 동물약 키워드
|
||||
ANIMAL_KEYWORDS = [
|
||||
'동물', '반려', '애견', '강아지', '고양이', '반려견',
|
||||
'넥스가드', '브라벡토', '심파리카', '크레델리오', '컴포티스',
|
||||
'하트세이버', '하트가드', '다이로하트', '하트웜', '하트캅',
|
||||
'안텔민', '파라캅', '제스타제',
|
||||
'캐치원', '셀라이트', '가드닐', '리펠로', '심피드독',
|
||||
'세레니아', '아포퀄', '갈리프란트', '클라펫',
|
||||
'펫팜', '동물약품', '애니팜'
|
||||
]
|
||||
|
||||
# 제외 키워드 (사람용 약)
|
||||
EXCLUDE_KEYWORDS = [
|
||||
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
|
||||
]
|
||||
|
||||
def init_sqlite_db():
|
||||
"""SQLite DB 초기화"""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drug_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drug_code TEXT NOT NULL,
|
||||
drug_name TEXT,
|
||||
barcode TEXT,
|
||||
tag_type TEXT NOT NULL,
|
||||
tag_value TEXT,
|
||||
note TEXT,
|
||||
source TEXT DEFAULT 'keyword',
|
||||
confidence REAL DEFAULT 0.8,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(drug_code, tag_type)
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"✅ SQLite DB 준비: {DB_PATH}")
|
||||
|
||||
def search_animal_drugs():
|
||||
"""MSSQL에서 동물약 키워드 검색"""
|
||||
print("🔍 CD_GOODS에서 동물약 검색 중...")
|
||||
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 키워드 조건 생성
|
||||
conditions = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
|
||||
|
||||
query = text(f"""
|
||||
SELECT DrugCode, GoodsName, BARCODE, POS_BOON
|
||||
FROM CD_GOODS
|
||||
WHERE ({conditions})
|
||||
AND GoodsSelCode = 'B'
|
||||
""")
|
||||
|
||||
result = session.execute(query)
|
||||
drugs = result.fetchall()
|
||||
print(f"✅ 발견: {len(drugs)}개")
|
||||
return drugs
|
||||
|
||||
def tag_to_sqlite(drugs):
|
||||
"""SQLite에 동물약 태깅"""
|
||||
print("\n📝 SQLite 태깅 중...")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
excluded = 0
|
||||
|
||||
for drug in drugs:
|
||||
drug_code = drug[0]
|
||||
drug_name = drug[1] or ''
|
||||
barcode = drug[2]
|
||||
|
||||
# 제외 키워드 체크
|
||||
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
|
||||
excluded += 1
|
||||
print(f" ⛔ 제외: {drug_code} - {drug_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note)
|
||||
VALUES (?, ?, ?, 'animal_drug', 'all', '키워드 자동 태깅')
|
||||
''', (drug_code, drug_name, barcode))
|
||||
added += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\n📊 태깅 결과: 추가 {added}개, 중복 {skipped}개, 제외 {excluded}개")
|
||||
return added
|
||||
|
||||
def sync_to_mssql():
|
||||
"""SQLite 태그를 MSSQL POS_BOON에 동기화"""
|
||||
print("\n🔄 MSSQL 동기화 중...")
|
||||
|
||||
# SQLite에서 동물약 목록 가져오기
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT drug_code, drug_name FROM drug_tags
|
||||
WHERE tag_type = 'animal_drug' AND is_active = 1
|
||||
''')
|
||||
animal_drugs = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
print(f" 동물약 {len(animal_drugs)}개 → POS_BOON='010103' 업데이트")
|
||||
|
||||
# MSSQL 업데이트
|
||||
session = db_manager.get_session('PM_DRUG')
|
||||
updated = 0
|
||||
|
||||
for drug_code, drug_name in animal_drugs:
|
||||
try:
|
||||
result = session.execute(text('''
|
||||
UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = :dc
|
||||
'''), {'dc': drug_code})
|
||||
session.commit()
|
||||
if result.rowcount > 0:
|
||||
updated += 1
|
||||
print(f" ✅ {drug_code}: {drug_name}")
|
||||
except Exception as e:
|
||||
print(f" ❌ {drug_code}: {e}")
|
||||
|
||||
print(f"\n🎉 완료! MSSQL 업데이트: {updated}개")
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("🐾 동물약 태깅 시스템")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. SQLite 초기화
|
||||
init_sqlite_db()
|
||||
|
||||
# 2. 동물약 검색
|
||||
drugs = search_animal_drugs()
|
||||
|
||||
# 3. SQLite 태깅
|
||||
tag_to_sqlite(drugs)
|
||||
|
||||
# 4. MSSQL 동기화
|
||||
sync_to_mssql()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("✅ 모든 작업 완료!")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
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>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/products" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🔍 제품검색</a>
|
||||
<a href="/admin/members" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">👥 회원검색</a>
|
||||
<a href="/admin/sales-detail" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📋 판매조회</a>
|
||||
<a href="/admin/sales" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🧾 판매내역</a>
|
||||
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡</a>
|
||||
@@ -469,6 +472,8 @@
|
||||
<th>전화번호</th>
|
||||
<th>포인트</th>
|
||||
<th>가입일</th>
|
||||
<th>카카오</th>
|
||||
<th>조제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
|
||||
<td>{{ user.created_at[:16].replace('T', ' ') }}</td>
|
||||
<td>
|
||||
{% if user.kakao_verified_at %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💬 {{ user.kakao_verified_at[:10] }}</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #868e96; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">미인증</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.has_prescription %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 3px; background: #d3f9d8; color: #2b8a3e; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💊 환자</span>
|
||||
{% else %}
|
||||
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #adb5bd; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">일반</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -662,7 +681,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script src="/static/js/lottie.min.js"></script>
|
||||
<script>
|
||||
function showTransactionDetail(transactionId) {
|
||||
document.getElementById('transactionModal').style.display = 'block';
|
||||
@@ -846,7 +865,10 @@
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">
|
||||
${user.name}
|
||||
${user.is_kakao_verified ? '<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; margin-left: 8px;"><span style="font-size: 13px;">💬</span>카카오</span>' : '<span style="display: inline-flex; align-items: center; gap: 3px; background: #e9ecef; color: #868e96; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; margin-left: 8px;">미인증</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
|
||||
@@ -860,6 +882,12 @@
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
|
||||
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
|
||||
</div>
|
||||
${user.birthday ? `
|
||||
<div>
|
||||
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">🎂 생일</div>
|
||||
<div style="color: #ec4899; font-size: 16px; font-weight: 600;">${user.birthday.includes('-') ? user.birthday.split('-')[0] + '월 ' + user.birthday.split('-')[1] + '일' : user.birthday.slice(0,2) + '월 ' + user.birthday.slice(2,4) + '일'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
|
||||
@@ -874,10 +902,16 @@
|
||||
<!-- 탭 메뉴 -->
|
||||
<div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;">
|
||||
<button onclick="switchTab('purchases')" id="tab-purchases" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid #6366f1; color: #6366f1;">
|
||||
구매 이력 (${purchases.length})
|
||||
🛒 구매 (${purchases.length})
|
||||
</button>
|
||||
<button onclick="switchTab('mileage')" id="tab-mileage" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
적립 이력 (${mileageHistory.length})
|
||||
💰 적립 (${mileageHistory.length})
|
||||
</button>
|
||||
<button onclick="switchTab('prescriptions')" id="tab-prescriptions" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
💊 조제 (${data.prescriptions ? data.prescriptions.length : 0})
|
||||
</button>
|
||||
<button onclick="switchTab('interests')" id="tab-interests" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
|
||||
💝 관심 (${data.interests ? data.interests.length : 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -934,6 +968,96 @@
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 조제 이력 탭 -->
|
||||
<div id="tab-content-prescriptions" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 조제 이력 렌더링
|
||||
const prescriptions = data.prescriptions || [];
|
||||
if (prescriptions.length > 0) {
|
||||
prescriptions.forEach(rx => {
|
||||
// 날짜 포맷
|
||||
const dateStr = rx.date || '';
|
||||
let formattedDate = dateStr;
|
||||
if (dateStr.length === 8) {
|
||||
formattedDate = `${dateStr.slice(0,4)}.${dateStr.slice(4,6)}.${dateStr.slice(6,8)}`;
|
||||
}
|
||||
|
||||
// 처방 품목
|
||||
const itemsHtml = (rx.items || []).map(item => {
|
||||
const dosage = item.quantity || 1;
|
||||
const freq = item.times_per_day || 1;
|
||||
const days = item.days || 0;
|
||||
return `
|
||||
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f3f5;">
|
||||
<span style="color: #495057; font-size: 14px;">${item.name}</span>
|
||||
<span style="color: #6366f1; font-size: 13px; font-weight: 600;">${dosage}정 × ${freq}회 × ${days}일</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 15px; font-weight: 600; color: #212529;">📅 ${formattedDate}</span>
|
||||
<span style="font-size: 13px; color: #6366f1; font-weight: 600;">${rx.total_days || ''}일분</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #64748b; margin-bottom: 12px;">
|
||||
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
|
||||
</div>
|
||||
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else if (!data.pos_customer) {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 POS 회원으로 등록되지 않았습니다<br><small>전화번호가 POS에 등록되면 조제 이력이 표시됩니다</small></p>';
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 조제 이력이 없습니다</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<!-- 관심상품 탭 -->
|
||||
<div id="tab-content-interests" class="tab-content" style="display: none;">
|
||||
`;
|
||||
|
||||
// 관심상품 렌더링
|
||||
const interests = data.interests || [];
|
||||
if (interests.length > 0) {
|
||||
interests.forEach(item => {
|
||||
// 날짜 포맷
|
||||
const date = item.created_at || '';
|
||||
|
||||
// 트리거 상품 파싱
|
||||
let triggerText = '';
|
||||
try {
|
||||
const triggers = JSON.parse(item.trigger_products || '[]');
|
||||
if (triggers.length > 0) {
|
||||
triggerText = triggers.join(', ');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
html += `
|
||||
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #ec4899;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span style="font-size: 15px; font-weight: 700; color: #ec4899;">💝 ${item.product}</span>
|
||||
<span style="font-size: 12px; color: #868e96;">${date}</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #64748b; margin-bottom: 8px;">
|
||||
${item.reason || ''}
|
||||
</div>
|
||||
${triggerText ? `<div style="font-size: 12px; color: #94a3b8; background: #f8f9fa; padding: 8px 12px; border-radius: 6px;">🛒 구매: ${triggerText}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
`;
|
||||
@@ -1300,9 +1424,12 @@
|
||||
`;
|
||||
|
||||
users.forEach(user => {
|
||||
const kakaoBadge = user.is_kakao_verified
|
||||
? '<span style="display: inline-flex; align-items: center; gap: 2px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">💬</span>'
|
||||
: '<span style="display: inline-flex; align-items: center; background: #e9ecef; color: #868e96; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">미인증</span>';
|
||||
html += `
|
||||
<tr style="border-bottom: 1px solid #f1f3f5;">
|
||||
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}</td>
|
||||
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}${kakaoBadge}</td>
|
||||
<td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${user.phone}</td>
|
||||
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td>
|
||||
<td style="padding: 14px; text-align: center;">
|
||||
|
||||
@@ -258,7 +258,10 @@
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<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>
|
||||
<h1>AI 업셀링 CRM</h1>
|
||||
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
|
||||
|
||||
@@ -299,6 +299,8 @@
|
||||
</div>
|
||||
<div class="header-nav">
|
||||
<a href="/admin">관리자 홈</a>
|
||||
<a href="/admin/ai-crm">AI 업셀링</a>
|
||||
<a href="/admin/ai-gw">Gateway 모니터</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── QR 인쇄 버튼 ── */
|
||||
.btn-qr {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-qr:hover { background: #7c3aed; }
|
||||
.btn-qr:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-qr.printing {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-preview {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.modal-preview img {
|
||||
max-width: 200px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── 수량 선택기 ── */
|
||||
.qty-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.qty-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
.qty-btn:first-child {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
.qty-btn:last-child {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
.qty-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
.qty-btn:active {
|
||||
transform: scale(0.95);
|
||||
background: #cbd5e1;
|
||||
}
|
||||
.qty-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.qty-value {
|
||||
width: 64px;
|
||||
height: 44px;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qty-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-btn.cancel {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
.modal-btn.cancel:hover { background: #e2e8f0; }
|
||||
.modal-btn.confirm {
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
}
|
||||
.modal-btn.confirm:hover { background: #7c3aed; }
|
||||
.modal-btn.confirm:active { transform: scale(0.98); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
@@ -355,10 +501,11 @@
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
<th>합계</th>
|
||||
<th>QR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesTableBody">
|
||||
<tr><td colspan="6" class="loading">로딩 중...</td></tr>
|
||||
<tr><td colspan="7" class="loading">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -415,11 +562,11 @@
|
||||
const tbody = document.getElementById('salesTableBody');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
tbody.innerHTML = salesData.map(item => `
|
||||
tbody.innerHTML = salesData.map((item, idx) => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
|
||||
<td>
|
||||
@@ -430,6 +577,11 @@
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}</td>
|
||||
<td class="price">${formatPrice(item.total_price)}</td>
|
||||
<td>
|
||||
<button class="btn-qr" onclick="printQR(${idx})" title="QR 라벨 인쇄">
|
||||
🏷️ QR
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
@@ -445,7 +597,7 @@
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
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}`)
|
||||
.then(res => res.json())
|
||||
@@ -463,17 +615,164 @@
|
||||
renderTable();
|
||||
} else {
|
||||
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 => {
|
||||
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();
|
||||
</script>
|
||||
|
||||
<!-- QR 인쇄 모달 -->
|
||||
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
|
||||
<div id="qrInfo" style="margin-bottom:12px;"></div>
|
||||
<div class="modal-preview" id="qrPreview">
|
||||
<p style="color:#64748b;">미리보기 로딩 중...</p>
|
||||
</div>
|
||||
<div class="qty-label">인쇄 매수</div>
|
||||
<div class="qty-selector">
|
||||
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus">−</button>
|
||||
<div class="qty-value" id="qtyValue">1</div>
|
||||
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
|
||||
</div>
|
||||
<div class="modal-btns">
|
||||
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
|
||||
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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 | 용림 🐉*
|
||||
@@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods;
|
||||
4. **효과 추적**: 3개월 후 재검사 결과 비교
|
||||
|
||||
---
|
||||
|
||||
**작성자**: Claude Sonnet 4.5
|
||||
**버전**: 1.0
|
||||
**최종 수정**: 2026-02-04
|
||||
|
||||
210
docs/member-detail-feature.md
Normal file
210
docs/member-detail-feature.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 회원 상세 기능 구현 계획
|
||||
|
||||
> 작성일: 2026-02-27
|
||||
> 상태: 계획 중
|
||||
|
||||
## 개요
|
||||
|
||||
회원 검색 페이지(`/admin/members`)에서 "상세" 버튼 클릭 시, 해당 회원의 구매 이력 및 QR 적립 내역을 조회하는 기능.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- ✅ 회원 검색 API (`/api/members/search`) - 완료
|
||||
- ✅ 회원 기본정보 조회 (`/api/members/<cuscode>`) - 완료
|
||||
- ❌ 회원 구매 이력 조회 - 미구현
|
||||
- ❌ QR 적립 내역 연동 - 미구현
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
### 1. 마일리지 DB (SQLite)
|
||||
```
|
||||
파일: backend/db/mileage.db
|
||||
테이블: transactions (적립/사용 내역)
|
||||
|
||||
주요 컬럼:
|
||||
- phone: 전화번호 (010XXXXXXXX)
|
||||
- points: 적립/사용 포인트
|
||||
- type: earn (적립) / use (사용)
|
||||
- amount: 구매금액
|
||||
- created_at: 거래일시
|
||||
- receipt_id: 영수증 ID (연동용)
|
||||
```
|
||||
|
||||
### 2. POS DB (MSSQL - PM_PRES)
|
||||
```
|
||||
테이블: SALE_MAIN (판매 메인)
|
||||
테이블: SALE_SUB (판매 상세 - 품목별)
|
||||
|
||||
SALE_MAIN:
|
||||
- SL_NO_order: 거래번호
|
||||
- SL_NM_custom: 고객명
|
||||
- SL_CD_custom: 고객코드
|
||||
- SL_MY_total: 총액
|
||||
- SL_DT_appl: 거래일자
|
||||
|
||||
SALE_SUB:
|
||||
- SL_NO_order: 거래번호 (FK)
|
||||
- DrugCode: 상품코드
|
||||
- QUAN: 수량
|
||||
- SL_TOTAL_PRICE: 금액
|
||||
```
|
||||
|
||||
### 3. 회원 DB (MSSQL - PM_BASE)
|
||||
```
|
||||
테이블: CD_PERSON
|
||||
|
||||
주요 컬럼:
|
||||
- CUSCODE: 고객코드
|
||||
- PANAME: 이름
|
||||
- PHONE, TEL_NO, PHONE2: 전화번호 3곳
|
||||
```
|
||||
|
||||
## 연동 전략
|
||||
|
||||
### 문제점
|
||||
- POS(SALE_MAIN)에는 `SL_CD_custom`(고객코드) 사용
|
||||
- 마일리지 DB에는 `phone`(전화번호) 사용
|
||||
- **전화번호 → 고객코드** 또는 **고객코드 → 전화번호** 매핑 필요
|
||||
|
||||
### 해결 방안
|
||||
|
||||
#### 방안 1: 전화번호 기반 통합 (권장)
|
||||
```
|
||||
1. CD_PERSON에서 전화번호로 CUSCODE 조회
|
||||
2. CUSCODE로 SALE_MAIN 조회
|
||||
3. 마일리지 DB에서 전화번호로 적립 내역 조회
|
||||
4. 두 결과 병합하여 표시
|
||||
```
|
||||
|
||||
#### 방안 2: 마일리지 테이블에 CUSCODE 추가
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN cuscode TEXT;
|
||||
```
|
||||
- QR 적립 시 POS 고객코드 연동
|
||||
|
||||
## API 설계
|
||||
|
||||
### GET /api/members/<cuscode>/history
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"member": {
|
||||
"cuscode": "0000012345",
|
||||
"name": "김영빈",
|
||||
"phone": "01027027390"
|
||||
},
|
||||
"mileage": {
|
||||
"balance": 19005,
|
||||
"total_earned": 25000,
|
||||
"total_used": 5995,
|
||||
"transactions": [
|
||||
{
|
||||
"date": "2026-02-27 01:29",
|
||||
"type": "earn",
|
||||
"points": 555,
|
||||
"amount": 18500,
|
||||
"products": ["투엑스벤포파워", "마데카솔"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchases": [
|
||||
{
|
||||
"date": "20260227",
|
||||
"order_no": "20260227001234",
|
||||
"total": 18500,
|
||||
"items": [
|
||||
{"name": "투엑스벤포파워", "qty": 1, "price": 9000},
|
||||
{"name": "마데카솔연고", "qty": 1, "price": 9500}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## UI 설계
|
||||
|
||||
### 회원 상세 모달
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 👤 김영빈 │
|
||||
│ 📱 010-2702-7390 │
|
||||
│ 💰 잔여 포인트: 19,005P │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [QR 적립 내역] [POS 구매 이력] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 📅 2026-02-27 01:29 │
|
||||
│ +555P (18,500원 구매) │
|
||||
│ └ 투엑스벤포파워, 마데카솔연고 │
|
||||
│ │
|
||||
│ 📅 2026-02-27 01:25 │
|
||||
│ +360P (12,000원 구매) │
|
||||
│ └ 벤포파워Z x2 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [ 메시지 발송 ] [ 닫기 ] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### Phase 1: 마일리지 내역 연동 (우선)
|
||||
1. [ ] `/api/members/<phone>/mileage` API 추가
|
||||
2. [ ] SQLite에서 전화번호로 적립/사용 내역 조회
|
||||
3. [ ] 회원 상세 모달 UI 구현
|
||||
|
||||
### Phase 2: POS 구매 이력 연동
|
||||
1. [ ] 전화번호 → CUSCODE 매핑 로직
|
||||
2. [ ] SALE_MAIN/SALE_SUB 조회 API
|
||||
3. [ ] 품목 상세 표시
|
||||
|
||||
### Phase 3: 통합 뷰
|
||||
1. [ ] 마일리지 + POS 데이터 병합
|
||||
2. [ ] 타임라인 형태로 통합 표시
|
||||
3. [ ] 상품 추천 (자주 구매 품목)
|
||||
|
||||
## 예상 쿼리
|
||||
|
||||
### 마일리지 내역 (SQLite)
|
||||
```sql
|
||||
SELECT
|
||||
t.created_at, t.type, t.points, t.amount,
|
||||
u.name, u.phone, u.balance
|
||||
FROM transactions t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE u.phone = '01027027390'
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
### POS 구매 이력 (MSSQL)
|
||||
```sql
|
||||
-- 1. 전화번호로 고객코드 조회
|
||||
SELECT CUSCODE FROM PM_BASE.dbo.CD_PERSON
|
||||
WHERE PHONE = '01027027390' OR TEL_NO = '01027027390';
|
||||
|
||||
-- 2. 고객코드로 구매 이력 조회
|
||||
SELECT
|
||||
M.SL_NO_order, M.SL_DT_appl, M.SL_MY_total,
|
||||
S.DrugCode, G.GoodsName, S.QUAN, S.SL_TOTAL_PRICE
|
||||
FROM PM_PRES.dbo.SALE_MAIN M
|
||||
JOIN PM_PRES.dbo.SALE_SUB S ON M.SL_NO_order = S.SL_NO_order
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE M.SL_CD_custom = '0000012345'
|
||||
ORDER BY M.SL_DT_appl DESC;
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- 마일리지 테이블 구조: `backend/db/dbsetup.py`
|
||||
- POS 테이블 가이드: `docs/alimipharm-set-product-structure.md`
|
||||
- 회원 검색 API: `backend/app.py` → `/api/members/search`
|
||||
|
||||
---
|
||||
|
||||
## 히스토리
|
||||
|
||||
| 날짜 | 내용 |
|
||||
|------|------|
|
||||
| 2026-02-27 | 계획 문서 작성 |
|
||||
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