Compare commits

..

35 Commits

Author SHA1 Message Date
e499e19342 feat: 더미 POS GUI 및 영수증 프린터 설정 추가
- 바코드 스캔 → 제품 조회 → 장바구니 → 결제 흐름의 더미 POS GUI 추가
- ESC/POS 영수증 프린터 설정 다이얼로그 추가
- barcode_reader_gui.py dbsetup import 경로 수정
- POS 프린터 config.json 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:02:48 +09:00
thug0bin
68ad59285a fix: 동물약 뱃지 위치 제품명 뒤로 변경 2026-02-27 17:59:11 +09:00
thug0bin
d106db64f3 feat: 동물약만 보기 체크박스 필터 추가
- 검색창 옆에 '🐾 동물약만 보기' 체크박스
- animal_only 파라미터로 API 필터링
- POS_BOON='010103' 기준 필터
2026-02-27 17:58:08 +09:00
thug0bin
197ded3806 feat: 제품 검색 페이지에 동물약 뱃지 표시
- /api/products API에 is_animal_drug 필드 추가
- POS_BOON='010103' 기준으로 동물약 판별
- 🐾 동물약 뱃지 표시 (초록색)
2026-02-27 17:56:34 +09:00
thug0bin
431909e50b fix: 생일 표시 형식 수정 (MM-DD 지원) 2026-02-27 17:32:39 +09:00
thug0bin
8c127cfb95 feat: 사용자 상세 모달에 생일 표시
- /admin/user/<id> API에 birthday 필드 추가
- 카카오 인증 시 저장된 생일 정보 표시
- 🎂 MM월 DD일 형식으로 표시
2026-02-27 17:17:40 +09:00
thug0bin
8c366cc4db feat: 대시보드 모달에 관심상품 탭 추가
- /admin/user/<id> API에 interests 필드 추가
- ai_recommendations 테이블에서 status='interested' 조회
- 모달에 💝 관심 탭 추가
- 트리거 상품, 추천 이유 표시
2026-02-27 17:10:35 +09:00
thug0bin
3fc9bbaf8e feat: 대시보드 모달에 조제 이력 탭 추가
- /admin/user/<id> API에 prescriptions 필드 추가
- 전화번호 → CD_PERSON(CUSCODE) → PS_main 연동
- 모달에 💊 조제 탭 추가 (admin_members.html 스타일 적용)
- 병원명, 의사명, 투약일수, 처방품목 표시
2026-02-27 17:07:41 +09:00
thug0bin
c33d857fa6 fix: 조제 이력 조회 쿼리 개선 (기존 로직 참고)
- PM_BASE 세션과 PM_PRES 세션 분리
- 1단계: CD_PERSON에서 전화번호로 CUSCODE 조회 (PHONE/TEL_NO/PHONE2)
- 2단계: PS_main에서 CUSCODE로 조제 기록 확인
2026-02-27 16:44:37 +09:00
thug0bin
d0e7d6bbd2 feat: 대시보드에 조제 이력 뱃지 추가
- PM_BASE.CD_PERSON에서 전화번호로 CUSCODE 매칭
- PS_main에서 조제 기록 유무 확인
- 조제 기록 있으면 녹색 '💊 환자' 뱃지
- 조제 기록 없으면 회색 '일반' 뱃지
2026-02-27 16:42:14 +09:00
thug0bin
04b0f3a8ca feat: 카카오 인증일(kakao_verified_at) 필드 추가
- DB에 kakao_verified_at 컬럼 추가
- link_kakao_identity()에서 최초 연동 시 인증일 기록
- 대시보드 테이블에 실제 인증일 표시
- 기존 카카오 연동 사용자 마이그레이션 완료
2026-02-27 16:31:31 +09:00
thug0bin
159386942e feat: 대시보드에 인증일 컬럼 추가
- 테이블 헤더에 '인증' 컬럼 추가
- 카카오 인증자: 노란 뱃지 + 인증일 (updated_at)
- 미인증: 회색 '미인증' 뱃지
2026-02-27 16:26:35 +09:00
thug0bin
3467cacd2f feat: 대시보드 최근 가입자 테이블에 카카오 뱃지 추가 2026-02-27 16:25:29 +09:00
thug0bin
a3a0bc8868 feat: 카카오 인증 여부 뱃지 추가
- API에 is_kakao_verified 필드 추가 (nickname != '고객')
- 사용자 상세 모달에 카카오 노란 뱃지 표시
- 검색 결과 목록에도 뱃지 표시
- 미인증 회원은 회색 '미인증' 뱃지
2026-02-27 16:23:26 +09:00
thug0bin
bd30ece284 docs: SQLite 연결 에러 트러블슈팅 문서 추가 2026-02-27 16:17:20 +09:00
thug0bin
94a8df6653 fix: product_category_mapping 테이블 없을 때 에러 무시
- 카테고리 조회 시 테이블 없으면 건너뛰도록 try-except 추가
2026-02-27 16:16:00 +09:00
thug0bin
4691d65c14 fix: /admin/user/<id> SQLite 연결 에러 해결
- new_connection=True + finally close 적용
2026-02-27 16:11:44 +09:00
thug0bin
866d10fd92 fix: lottie CDN을 로컬 파일로 변경 (Tracking Prevention 차단 해결) 2026-02-27 16:10:28 +09:00
thug0bin
1414bb1432 fix: /admin 사이드바 검색 SQLite 연결 에러 해결
- /admin/search/user: new_connection=True + finally close
- /admin/search/product: new_connection=True + finally close
- 에러 로깅 강화 (traceback 포함)
2026-02-27 16:09:07 +09:00
thug0bin
87a56d0f6c debug: 에러 로깅 강화 (traceback 포함) 2026-02-27 16:02:22 +09:00
thug0bin
76da7d9cd1 fix: SQLite 멀티스레드 I/O 에러 해결
- 요청마다 새 SQLite 연결 생성 (new_connection=True)
- 사용 후 명시적 close
- 간헐적 'I/O operation on closed file' 에러 방지
2026-02-27 15:43:52 +09:00
thug0bin
870e40a6db fix: SQLite 연결 체크 강화
- 커서 생성/실행/close로 연결 상태 확인
- 연결 닫힐 때 명시적 close 호출
- I/O operation on closed file 에러 방지
2026-02-27 15:41:28 +09:00
thug0bin
d44aed16be fix: 회원 상세 조회 시 모든 전화번호 컬럼 시도
- phone, phone1, tel_no, phone2 순서로 시도
- 전화번호 없는 회원 에러 방지 강화
2026-02-27 15:40:06 +09:00
thug0bin
a1640f55f8 fix: 전화번호 없는 회원 상세 조회 시 에러 처리
- 전화번호가 없으면 API 호출 전 안내 메시지 표시
- I/O 에러 방지
2026-02-27 15:36:55 +09:00
thug0bin
753df2c13c feat: 회원 상세 - 관심 상품 탭 추가
- AI 업셀링에서 '관심있어요' 표시한 상품 조회
- status='interested'인 ai_recommendations 조회
- 상품명, 추천 메시지, 구매 상품(트리거) 표시
- 💝 관심 탭 UI 구현
2026-02-27 15:31:08 +09:00
thug0bin
79369d9a56 fix: 조제이력 투약정보 표시 개선
- 투약량 x 횟수 x 일수 형식으로 표시
- 예: 1정 × 3회 × 7일
2026-02-27 15:24:45 +09:00
thug0bin
02e56b9413 feat: 회원 상세 - 전체 구매이력 + 조제이력 탭 추가
- 전화번호 → CD_PERSON(CUSCODE) 매핑
- 구매 탭: SALE_MAIN/SALE_SUB (전체 POS 구매)
- 조제 탭: PS_main/PS_sub_pharm (처방전 조제)
- 병원명, 의사명, 투약일수, 처방 약품 표시
- POS 미등록 회원 안내 메시지 추가
2026-02-27 15:19:13 +09:00
thug0bin
8c3bcb525d fix: 회원 상세 - transaction_id로 POS 품목 조회 연동
- 마일리지 적립 시 저장된 transaction_id로 SALE_SUB 조회
- 적립 내역에 구매 품목 표시 (품명, 수량, 가격)
- 구매 이력 탭: QR 적립된 구매만 품목과 함께 표시
- 기존 전화번호→고객코드 매핑 로직 제거 (불필요)
2026-02-27 15:11:23 +09:00
thug0bin
7843ca8fcf feat: 회원 상세 모달 구현 (마일리지 + POS 이력)
- /api/members/history/<phone>: 통합 이력 조회 API
- 마일리지 적립/사용 내역 (SQLite)
- POS 구매 이력 (MSSQL - 전화번호→고객코드 매핑)
- 세련된 UI: 탭 전환, 거래 카드, 구매 카드
- 상세에서 바로 메시지 발송 가능
2026-02-27 15:08:09 +09:00
thug0bin
a7e96e5efa docs: 회원 상세 기능 구현 계획 문서
- 마일리지 내역 + POS 구매 이력 연동 계획
- 전화번호 기반 통합 조회 전략
- API/UI 설계 초안
2026-02-27 15:00:49 +09:00
thug0bin
625012f5ee feat: PM2 설정 파일 추가
- ecosystem.config.js: PM2 프로세스 매니저 설정
- 자동 재시작, 로그 관리, 메모리 제한 설정
- logs/ 폴더 생성
2026-02-27 14:56:08 +09:00
thug0bin
c4ab865c93 feat: 서버 시작/중지 스크립트 추가
- scripts/start_server.ps1: 기존 프로세스 종료 후 시작
- scripts/stop_server.ps1: 서버 중지
- scripts/*.bat: 더블클릭 실행용
2026-02-27 14:55:46 +09:00
thug0bin
6e23dc8b20 fix: 서버 시작 시 포트 충돌 자동 해결
- 포트 7001 사용 중이면 기존 프로세스 자동 종료
- Flask reloader 자식 프로세스 구분 처리
- check_port_available(), kill_process_on_port() 함수 추가
2026-02-27 14:55:07 +09:00
thug0bin
705696a7fb feat: 회원 검색 페이지 및 API 추가
- /admin/members: 회원 검색 페이지 (팜IT3000 CD_PERSON)
- /api/members/search: 이름/전화번호 검색 API (TEL_NO, PHONE, PHONE2)
- /api/members/<cuscode>: 회원 상세 + 메모 조회 API
- /api/message/send: 알림톡/SMS 발송 API (테스트 모드)
- 대시보드 헤더에 회원검색 탭 추가
- 다중 선택 + 일괄 발송 UI
2026-02-27 14:10:44 +09:00
thug0bin
9bd2174501 feat: 제품 검색 페이지 및 QR 라벨 인쇄 기능
- /admin/products: 전체 제품 검색 페이지 (OTC)
- /api/products: 제품 검색 API (세트상품 바코드 포함)
- qr_printer.py: Brother QL-710W 프린터 연동
- /api/qr-print, /api/qr-preview: QR 라벨 인쇄/미리보기 API
- 판매상세 페이지에 QR 인쇄 버튼 추가
- 수량 선택 UI (+/- 버튼, 최대 10장)
- 세트상품 제조사 표시 개선
- 대시보드 헤더에 제품검색/판매조회 탭 추가
2026-02-27 13:56:26 +09:00
27 changed files with 5714 additions and 75 deletions

File diff suppressed because it is too large Load Diff

7
backend/config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"pos_printer": {
"ip": "192.168.0.174",
"port": 9100,
"name": "메인 POS"
}
}

View File

@@ -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
View 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()

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

View File

@@ -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
# 바코드 라벨 출력

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

View File

@@ -0,0 +1,4 @@
@echo off
chcp 65001 >nul
powershell -ExecutionPolicy Bypass -File "%~dp0start_server.ps1"
pause

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

View File

@@ -0,0 +1,4 @@
@echo off
chcp 65001 >nul
powershell -ExecutionPolicy Bypass -File "%~dp0stop_server.ps1"
pause

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

View 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 연결 에러 해결
```

View 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 | 용림 🐉*

View File

@@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods;
4. **효과 추적**: 3개월 후 재검사 결과 비교
---
**작성자**: Claude Sonnet 4.5
**버전**: 1.0
**최종 수정**: 2026-02-04

View 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 | 계획 문서 작성 |

View 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
View 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'
}
]
};