Compare commits

...

47 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
thug0bin
f3fa4707ac docs: 알리미팜 세트상품 DB 구조 문서화
- CD_ITEM_UNIT_MEMBER 테이블 (세트 바코드)
- CD_item_set 테이블 (세트 구성품)
- 바코드 조회 쿼리 예시
- 마진 계산 시 구성품 분해 필요 안내
2026-02-27 12:36:17 +09:00
thug0bin
1b78704ca6 fix: 세트상품 바코드 조회 - CD_ITEM_UNIT_MEMBER 테이블 연동
- CD_GOODS.BARCODE 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용
- 알리미팜 세트상품/자체등록 바코드 지원
- 바코드 매핑률 89.8% → 99.8% 개선
2026-02-27 12:35:34 +09:00
thug0bin
2a090c9704 feat: Clawdbot Gateway 모니터링 페이지 + API 클라이언트
- /admin/ai-gw: 토큰 사용량/비용 실시간 모니터링 대시보드
- clawdbot_client.py: Gateway HTTP API 클라이언트 (세션 상태, 사용량 조회)
- 세션별 토큰/비용 통계, 모델별 breakdown
- API 문서 추가 (docs/clawdbot-gateway-api.md)
2026-02-27 12:22:05 +09:00
thug0bin
ccb0067a1c feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회
- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언)
- /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지
- 상품코드/바코드/표준코드 전환 버튼
- 바코드 시각화 + 매핑률 통계
- 대시보드 메뉴에 판매내역 링크 추가
2026-02-27 12:14:50 +09:00
thug0bin
da51f4bfd1 fix: 키오스크 세로 모니터 QR 코드 중앙 정렬
- portrait 모드 claim-left: row → column 레이아웃으로 변경
- QR 컨테이너, 결제 카드, 품목 카드 모두 중앙 정렬
- QR 이미지 크기 140px → 160px 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:37:24 +09:00
thug0bin
db5f6063ec fix: SQLite 싱글톤 연결 I/O 에러 수정 + clawdbot 모델 오버라이드
- dbsetup: get_sqlite_connection()에 SELECT 1 헬스체크 추가 (죽은 연결 자동 재생성)
- pos_sales_gui: 싱글톤 SQLite conn.close() 제거 (I/O closed file 에러 원인)
- qr_token_generator: DatabaseManager() 새 생성 → 전역 db_manager 싱글톤 사용
- clawdbot_client: model 파라미터 추가, 업셀링에 claude-sonnet-4-5 지정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:27:47 +09:00
thug0bin
4c3e1d08b2 feat: 실데이터 기반 AI 업셀링 추천 — 약국 보유 제품 목록에서 추천
- generate_upsell_real(): MSSQL 최근 30일 판매 TOP 40 제품 목록을 AI에 제공
- AI가 실제 약국 보유 제품 중에서만 선택하여 추천
- 실데이터 실패 시 기존 자유 생성(generate_upsell) fallback
- 기존 generate_upsell은 그대로 보존

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:21:48 +09:00
thug0bin
a2829436d1 feat: 바텀시트 '관심있어요' 버튼 분리 — interested 상태 DB 저장 + 어드민 표시
- "관심있어요!" 클릭 → status='interested' (기존: dismissed와 동일했음)
- "다음에요" / 드래그 닫기 → status='dismissed'
- dismiss API에 action 파라미터 추가
- AI CRM 대시보드: interested 배지(주황) + 통계 카드 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:47:20 +09:00
thug0bin
3e3934e2e5 fix: AI 업셀링 생성을 별도 스레드로 분리 — 키오스크 적립 응답 블로킹 방지
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:41:40 +09:00
thug0bin
5042cffb9f feat: AI CRM 어드민 대시보드 + 바텀시트 드래그 닫기 + UTF-8 인코딩 + 문서화
- /admin/ai-crm: AI 업셀링 추천 생성 현황 대시보드 (통계 카드 + 로그 테이블 + 아코디언 상세)
- 마이페이지 바텀시트: 터치 드래그로 닫기 기능 추가 (80px 임계값)
- Windows 콘솔 UTF-8 인코딩 강제 (app.py, clawdbot_client.py)
- admin.html 헤더에 AI CRM 네비 링크 추가
- docs: ai-upselling-crm.md, windows-utf8-encoding.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:38:04 +09:00
thug0bin
b5a99f7b3b feat: AI 업셀링 CRM - Clawdbot Gateway 기반 맞춤 추천 시스템
키오스크 적립 시 Clawdbot Gateway(Claude Max)를 통해 구매 이력 기반
맞춤 제품 추천을 생성하고, 마이페이지 방문 시 바텀시트 팝업으로 표시.

- ai_recommendations SQLite 테이블 추가 (스키마 + 마이그레이션)
- clawdbot_client.py: Gateway WebSocket 프로토콜 v3 Python 클라이언트
- app.py: 추천 생성 + GET/POST API 엔드포인트
- my_page.html: 바텀시트 UI (슬라이드업 애니메이션, 1.5초 후 자동 표시)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:57:03 +09:00
thug0bin
a3ff69b67f feat: 알림톡 발송 로그 시스템 + 현영 표시 + 문서화
- 알림톡 발송 로그: alimtalk_logs SQLite 테이블 + DB 자동 기록
- /admin/alimtalk 페이지: 서버 로그, NHN Cloud 내역 조회, 수동 발송 테스트
- 적립일시 포맷 수정: %Y-%m-%d %H:%M (16자 초과) → %m/%d %H:%M (11자)
- POS GUI 현금영수증(현영) 표시: 청록색 볼드
- 결제수납구조.md: CD_SUNAB/PS_main/SALE_MAIN 3테이블 관계 문서
- 실행구조.md: Flask 서버 + Qt GUI 실행 가이드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:28:29 +09:00
42 changed files with 11179 additions and 163 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,38 +185,61 @@ class DatabaseManager:
# 새 세션 생성 # 새 세션 생성
return self.get_session(database) return self.get_session(database)
def get_sqlite_connection(self): def get_sqlite_connection(self, new_connection=False):
""" """
SQLite mileage.db 연결 반환 (싱글톤 패턴) SQLite mileage.db 연결 반환
최초 호출 시 스키마 자동 초기화
Args:
new_connection: True면 항상 새 연결 생성 (멀티스레드 안전)
Returns: Returns:
sqlite3.Connection: SQLite 연결 객체 sqlite3.Connection: SQLite 연결 객체
""" """
# 새 연결 요청 시 항상 새로 생성
if new_connection:
return self._create_sqlite_connection()
# 기존 싱글톤 방식 (하위 호환)
if self.sqlite_conn is not None:
try:
cursor = self.sqlite_conn.cursor()
cursor.execute("SELECT 1")
cursor.fetchone()
cursor.close()
except Exception as e:
print(f"[DB Manager] SQLite 연결 체크 실패, 재연결: {e}")
try:
self.sqlite_conn.close()
except:
pass
self.sqlite_conn = None
if self.sqlite_conn is None: if self.sqlite_conn is None:
# 파일 존재 여부 확인 self.sqlite_conn = self._create_sqlite_connection()
is_new_db = not self.sqlite_db_path.exists()
# 연결 생성
self.sqlite_conn = sqlite3.connect(
str(self.sqlite_db_path),
check_same_thread=False, # 멀티스레드 허용
timeout=10.0 # 10초 대기
)
# Row Factory 설정 (dict 형태로 결과 반환)
self.sqlite_conn.row_factory = sqlite3.Row
# 신규 DB면 스키마 초기화
if is_new_db:
self.init_sqlite_schema()
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
else:
print(f"[DB Manager] SQLite 기존 DB 연결: {self.sqlite_db_path}")
self._migrate_sqlite()
return self.sqlite_conn return self.sqlite_conn
def _create_sqlite_connection(self):
"""새 SQLite 연결 생성"""
is_new_db = not self.sqlite_db_path.exists()
conn = sqlite3.connect(
str(self.sqlite_db_path),
check_same_thread=False,
timeout=10.0
)
conn.row_factory = sqlite3.Row
if is_new_db:
# 스키마 초기화 (임시로 self.sqlite_conn 설정)
old_conn = self.sqlite_conn
self.sqlite_conn = conn
self.init_sqlite_schema()
self.sqlite_conn = old_conn
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
return conn
def init_sqlite_schema(self): def init_sqlite_schema(self):
""" """
mileage_schema.sql 실행하여 테이블 생성 mileage_schema.sql 실행하여 테이블 생성
@@ -237,7 +260,7 @@ class DatabaseManager:
print(f"[DB Manager] SQLite 스키마 초기화 완료") print(f"[DB Manager] SQLite 스키마 초기화 완료")
def _migrate_sqlite(self): def _migrate_sqlite(self):
"""기존 DB에 새 컬럼 추가 (마이그레이션)""" """기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
cursor = self.sqlite_conn.cursor() cursor = self.sqlite_conn.cursor()
cursor.execute("PRAGMA table_info(users)") cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
@@ -246,6 +269,56 @@ class DatabaseManager:
self.sqlite_conn.commit() self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가") print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가")
# alimtalk_logs 테이블 생성
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS alimtalk_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_code VARCHAR(50) NOT NULL,
recipient_no VARCHAR(20) NOT NULL,
user_id INTEGER,
trigger_source VARCHAR(20) NOT NULL,
template_params TEXT,
success BOOLEAN NOT NULL,
result_message TEXT,
transaction_id VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성")
# ai_recommendations 테이블 생성
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS ai_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
transaction_id VARCHAR(20),
recommended_product TEXT NOT NULL,
recommendation_message TEXT NOT NULL,
recommendation_reason TEXT,
trigger_products TEXT,
ai_raw_response TEXT,
status VARCHAR(20) DEFAULT 'active',
displayed_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
displayed_at DATETIME,
dismissed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
def test_connection(self, database='PM_BASE'): def test_connection(self, database='PM_BASE'):
"""연결 테스트""" """연결 테스트"""
try: try:

View File

@@ -80,3 +80,43 @@ CREATE TABLE IF NOT EXISTS pos_customer_links (
); );
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode); CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
-- 6. 알림톡 발송 로그 테이블
CREATE TABLE IF NOT EXISTS alimtalk_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_code VARCHAR(50) NOT NULL,
recipient_no VARCHAR(20) NOT NULL,
user_id INTEGER,
trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등
template_params TEXT, -- JSON 문자열
success BOOLEAN NOT NULL,
result_message TEXT,
transaction_id VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
-- 7. AI 추천 테이블
CREATE TABLE IF NOT EXISTS ai_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
transaction_id VARCHAR(20),
recommended_product TEXT NOT NULL,
recommendation_message TEXT NOT NULL,
recommendation_reason TEXT,
trigger_products TEXT,
ai_raw_response TEXT,
status VARCHAR(20) DEFAULT 'active',
displayed_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
displayed_at DATETIME,
dismissed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);

121
backend/gui/check_cash.py Normal file
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()

View File

@@ -78,12 +78,16 @@ class SalesQueryThread(QThread):
ISNULL(S.card_total, 0) AS card_total, ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total, ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount, ISNULL(M.SL_MY_total, 0) AS total_amount,
ISNULL(M.SL_MY_discount, 0) AS discount ISNULL(M.SL_MY_discount, 0) AS discount,
S.cash_receipt_mode,
S.cash_receipt_num
FROM SALE_MAIN M FROM SALE_MAIN M
OUTER APPLY ( OUTER APPLY (
SELECT TOP 1 SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
nCASHINMODE AS cash_receipt_mode,
nAPPROVAL_NUM AS cash_receipt_num
FROM CD_SUNAB FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order WHERE PRESERIAL = M.SL_NO_order
) S ) S
@@ -96,7 +100,7 @@ class SalesQueryThread(QThread):
sales_list = [] sales_list = []
for row in rows: for row in rows:
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount = row order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
# 품목 수 조회 (SALE_SUB) # 품목 수 조회 (SALE_SUB)
mssql_cursor.execute(""" mssql_cursor.execute("""
@@ -136,12 +140,17 @@ class SalesQueryThread(QThread):
# 결제수단 판별 # 결제수단 판별
card_amt = float(card_total) if card_total else 0.0 card_amt = float(card_total) if card_total else 0.0
cash_amt = float(cash_total) if cash_total else 0.0 cash_amt = float(cash_total) if cash_total else 0.0
# 현금영수증: nCASHINMODE='1' AND nAPPROVAL_NUM 존재 (mode=2는 카드거래 자동세팅)
has_cash_receipt = (
str(cash_receipt_mode or '').strip() == '1'
and str(cash_receipt_num or '').strip() != ''
)
if card_amt > 0 and cash_amt > 0: if card_amt > 0 and cash_amt > 0:
pay_method = '카드+현금' pay_method = '카드+현금'
elif card_amt > 0: elif card_amt > 0:
pay_method = '카드' pay_method = '카드'
elif cash_amt > 0: elif cash_amt > 0:
pay_method = '현금' pay_method = '' if has_cash_receipt else ''
else: else:
pay_method = '' pay_method = ''
paid = (card_amt + cash_amt) > 0 paid = (card_amt + cash_amt) > 0
@@ -172,8 +181,7 @@ class SalesQueryThread(QThread):
finally: finally:
if mssql_conn: if mssql_conn:
mssql_conn.close() mssql_conn.close()
if sqlite_conn: # sqlite_conn은 싱글톤이므로 닫지 않음 (닫으면 다른 곳에서 I/O 에러 발생)
sqlite_conn.close()
class QRGeneratorThread(QThread): class QRGeneratorThread(QThread):
@@ -591,9 +599,7 @@ class UserMileageDialog(QDialog):
except Exception as e: except Exception as e:
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}') QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
finally: # conn은 싱글톤이므로 닫지 않음
if conn:
conn.close()
class POSSalesGUI(QMainWindow): class POSSalesGUI(QMainWindow):
@@ -862,6 +868,11 @@ class POSSalesGUI(QMainWindow):
pay_item.setTextAlignment(Qt.AlignCenter) pay_item.setTextAlignment(Qt.AlignCenter)
if sale['pay_method'] == '카드': if sale['pay_method'] == '카드':
pay_item.setForeground(QColor('#1976D2')) pay_item.setForeground(QColor('#1976D2'))
elif sale['pay_method'] == '현영':
pay_item.setForeground(QColor('#00897B')) # 청록 (현금영수증)
f = QFont()
f.setBold(True)
pay_item.setFont(f)
elif sale['pay_method'] == '현금': elif sale['pay_method'] == '현금':
pay_item.setForeground(QColor('#E65100')) pay_item.setForeground(QColor('#E65100'))
elif sale['pay_method']: elif sale['pay_method']:

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 sys
import os
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
from datetime import datetime from datetime import datetime
@@ -19,6 +20,8 @@ from sqlalchemy import text
# MSSQL 데이터베이스 연결 # MSSQL 데이터베이스 연결
sys.path.insert(0, '.') sys.path.insert(0, '.')
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'db'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from dbsetup import DatabaseManager from dbsetup import DatabaseManager
# 바코드 라벨 출력 # 바코드 라벨 출력

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

View File

@@ -0,0 +1,469 @@
"""
Clawdbot Gateway Python 클라이언트
카카오톡 봇과 동일한 Gateway WebSocket API를 통해 Claude와 통신
추가 API 비용 없음 (Claude Max 구독 재활용)
"""
import sys
import os
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
if sys.platform == 'win32':
import io
if hasattr(sys.stdout, 'buffer'):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
import json
import uuid
import asyncio
import logging
from pathlib import Path
import websockets
logger = logging.getLogger(__name__)
# Gateway 설정 (clawdbot.json에서 읽기)
CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json'
def _load_gateway_config():
"""clawdbot.json에서 Gateway 설정 로드"""
try:
with open(CLAWDBOT_CONFIG_PATH, 'r', encoding='utf-8') as f:
config = json.load(f)
gw = config.get('gateway', {})
return {
'port': gw.get('port', 18789),
'token': gw.get('auth', {}).get('token', ''),
}
except Exception as e:
logger.warning(f"[Clawdbot] 설정 파일 로드 실패: {e}")
return {'port': 18789, 'token': ''}
async def _ask_gateway(message, session_id='pharmacy-upsell',
system_prompt=None, timeout=60, model=None):
"""
Clawdbot Gateway WebSocket API 호출
프로토콜:
1. WS 연결
2. 서버 → connect.challenge (nonce)
3. 클라이언트 → connect 요청 (token)
4. 서버 → connect 응답 (ok)
5. 클라이언트 → agent 요청
6. 서버 → accepted (ack) → 최종 응답
Returns:
str: AI 응답 텍스트 (실패 시 None)
"""
config = _load_gateway_config()
url = f"ws://127.0.0.1:{config['port']}"
token = config['token']
try:
async with websockets.connect(url, max_size=25 * 1024 * 1024,
close_timeout=5) as ws:
# 1. connect.challenge 대기
nonce = None
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
challenge = json.loads(challenge_msg)
if challenge.get('event') == 'connect.challenge':
nonce = challenge.get('payload', {}).get('nonce')
# 2. connect 요청
connect_id = str(uuid.uuid4())
connect_frame = {
'type': 'req',
'id': connect_id,
'method': 'connect',
'params': {
'minProtocol': 3,
'maxProtocol': 3,
'client': {
'id': 'gateway-client',
'displayName': 'Pharmacy Upsell',
'version': '1.0.0',
'platform': 'win32',
'mode': 'backend',
'instanceId': str(uuid.uuid4()),
},
'caps': [],
'auth': {
'token': token,
},
'role': 'operator',
'scopes': ['operator.admin'],
}
}
await ws.send(json.dumps(connect_frame))
# 3. connect 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
if data.get('id') == connect_id:
if not data.get('ok'):
error = data.get('error', {}).get('message', 'unknown')
logger.warning(f"[Clawdbot] connect 실패: {error}")
return None
break # 연결 성공
# 4. 모델 오버라이드 (sessions.patch)
if model:
patch_id = str(uuid.uuid4())
patch_frame = {
'type': 'req',
'id': patch_id,
'method': 'sessions.patch',
'params': {
'key': session_id,
'model': model,
}
}
await ws.send(json.dumps(patch_frame))
# patch 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
if data.get('id') == patch_id:
if not data.get('ok'):
logger.warning(f"[Clawdbot] sessions.patch 실패: {data.get('error', {}).get('message', 'unknown')}")
break
# 5. agent 요청
agent_id = str(uuid.uuid4())
agent_params = {
'message': message,
'sessionId': session_id,
'sessionKey': session_id,
'timeout': timeout,
'idempotencyKey': str(uuid.uuid4()),
}
if system_prompt:
agent_params['extraSystemPrompt'] = system_prompt
agent_frame = {
'type': 'req',
'id': agent_id,
'method': 'agent',
'params': agent_params,
}
await ws.send(json.dumps(agent_frame))
# 5. agent 응답 대기 (accepted → final)
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout + 30)
data = json.loads(msg)
# 이벤트 무시 (tick 등)
if data.get('event'):
continue
# 우리 요청에 대한 응답인지 확인
if data.get('id') != agent_id:
continue
payload = data.get('payload', {})
status = payload.get('status')
# accepted는 대기
if status == 'accepted':
continue
# 최종 응답
if data.get('ok'):
payloads = payload.get('result', {}).get('payloads', [])
text = '\n'.join(p.get('text', '') for p in payloads if p.get('text'))
return text or None
else:
error = data.get('error', {}).get('message', 'unknown')
logger.warning(f"[Clawdbot] agent 실패: {error}")
return None
except asyncio.TimeoutError:
logger.warning("[Clawdbot] Gateway 타임아웃")
return None
except (ConnectionRefusedError, OSError) as e:
logger.warning(f"[Clawdbot] Gateway 연결 실패 (꺼져있음?): {e}")
return None
except Exception as e:
logger.warning(f"[Clawdbot] Gateway 오류: {e}")
return None
def ask_clawdbot(message, session_id='pharmacy-upsell',
system_prompt=None, timeout=60, model=None):
"""
동기 래퍼: Flask에서 직접 호출 가능
Args:
message: 사용자 메시지
session_id: 세션 ID (대화 구분용)
system_prompt: 추가 시스템 프롬프트
timeout: 타임아웃 (초)
model: 모델 오버라이드 (예: 'anthropic/claude-sonnet-4-5')
Returns:
str: AI 응답 텍스트 (실패 시 None)
"""
try:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(
_ask_gateway(message, session_id, system_prompt, timeout, model=model)
)
loop.close()
return result
except Exception as e:
logger.warning(f"[Clawdbot] 호출 실패: {e}")
return None
# 업셀링 전용 ──────────────────────────────────────
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # 업셀링은 Sonnet (빠르고 충분)
UPSELL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
def generate_upsell(user_name, current_items, recent_products):
"""
업셀링 추천 생성
Args:
user_name: 고객명
current_items: 오늘 구매 품목 문자열 (예: "타이레놀, 챔프 시럽")
recent_products: 최근 구매 이력 문자열
Returns:
dict: {'product': '...', 'reason': '...', 'message': '...'} 또는 None
"""
prompt = f"""고객 이름: {user_name}
오늘 구매한 약: {current_items}
최근 구매 이력: {recent_products}
위 정보를 바탕으로 이 고객에게 추천할 약품 하나를 제안해주세요.
규칙:
1. 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
2. 실제 약국에서 판매하는 일반의약품/건강기능식품만 추천 (처방약 제외)
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
4. 구체적인 제품명 사용 (예: "비타민C 1000", "오메가3" 등)
응답은 반드시 아래 JSON 형식으로만:
{{"product": "추천 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [오늘 구매 품목]과 함께 [추천약]도 추천드려요. [간단한 이유]."}}"""
response_text = ask_clawdbot(
prompt,
session_id=f'upsell-{user_name}',
system_prompt=UPSELL_SYSTEM_PROMPT,
timeout=30,
model=UPSELL_MODEL
)
if not response_text:
return None
return _parse_upsell_response(response_text)
UPSELL_REAL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다.
반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요.
목록에 없는 제품은 절대 추천하지 마세요.
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
def generate_upsell_real(user_name, current_items, recent_products, available_products):
"""
실데이터 기반 업셀링 추천 생성
available_products: 약국 보유 제품 리스트 [{'name': ..., 'price': ..., 'sales': ...}, ...]
"""
product_list = '\n'.join(
f"- {p['name']} ({int(p['price'])}원, 최근 {p['sales']}건 판매)"
for p in available_products if p.get('name')
)
prompt = f"""고객 이름: {user_name}
오늘 구매한 약: {current_items}
최근 구매 이력: {recent_products}
[약국 보유 제품 목록 — 이 중에서만 추천하세요]
{product_list}
규칙:
1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
2. 오늘 이미 구매한 제품은 추천하지 마세요
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요
응답은 반드시 아래 JSON 형식으로만:
{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [추천 메시지 2문장 이내]"}}"""
response_text = ask_clawdbot(
prompt,
session_id=f'upsell-real-{user_name}',
system_prompt=UPSELL_REAL_SYSTEM_PROMPT,
timeout=30,
model=UPSELL_MODEL
)
if not response_text:
return None
return _parse_upsell_response(response_text)
# ===== Claude 상태 조회 =====
async def _get_gateway_status():
"""
Clawdbot Gateway에서 세션 목록 조회
토큰 차감 없음 (AI 호출 아님)
"""
config = _load_gateway_config()
url = f"ws://127.0.0.1:{config['port']}"
token = config['token']
try:
async with websockets.connect(url, max_size=25 * 1024 * 1024,
close_timeout=5) as ws:
# 1. connect.challenge 대기
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
challenge = json.loads(challenge_msg)
nonce = None
if challenge.get('event') == 'connect.challenge':
nonce = challenge.get('payload', {}).get('nonce')
# 2. connect 요청
connect_id = str(uuid.uuid4())
connect_frame = {
'type': 'req',
'id': connect_id,
'method': 'connect',
'params': {
'minProtocol': 3,
'maxProtocol': 3,
'client': {
'id': 'gateway-client',
'displayName': 'Pharmacy Status',
'version': '1.0.0',
'platform': 'win32',
'mode': 'backend',
'instanceId': str(uuid.uuid4()),
},
'caps': [],
'auth': {'token': token},
'role': 'operator',
'scopes': ['operator.read'],
}
}
await ws.send(json.dumps(connect_frame))
# 3. connect 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
if data.get('id') == connect_id:
if not data.get('ok'):
error = data.get('error', {}).get('message', 'connect failed')
logger.warning(f"[Clawdbot] connect 실패: {error}")
return {'error': error, 'connected': False}
break
# 4. sessions.list 요청
list_id = str(uuid.uuid4())
list_frame = {
'type': 'req',
'id': list_id,
'method': 'sessions.list',
'params': {
'limit': 10
}
}
await ws.send(json.dumps(list_frame))
# 5. 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
# 이벤트 무시
if data.get('event'):
continue
if data.get('id') == list_id:
if data.get('ok'):
return {
'connected': True,
'sessions': data.get('payload', {})
}
else:
error = data.get('error', {}).get('message', 'unknown')
return {'error': error, 'connected': True}
except asyncio.TimeoutError:
logger.warning("[Clawdbot] Gateway 타임아웃")
return {'error': 'timeout', 'connected': False}
except (ConnectionRefusedError, OSError) as e:
logger.warning(f"[Clawdbot] Gateway 연결 실패: {e}")
return {'error': str(e), 'connected': False}
except Exception as e:
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
return {'error': str(e), 'connected': False}
def get_claude_status():
"""
동기 래퍼: Claude 상태 조회
Returns:
dict: 상태 정보
"""
try:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(_get_gateway_status())
loop.close()
return result
except Exception as e:
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
return {'error': str(e), 'connected': False}
def _parse_upsell_response(text):
"""AI 응답에서 JSON 추출"""
import re
try:
# ```json ... ``` 블록 추출 시도
json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
else:
# 직접 JSON 파싱 시도
start = text.find('{')
end = text.rfind('}')
if start >= 0 and end > start:
json_str = text[start:end + 1]
else:
return None
data = json.loads(json_str)
if 'product' not in data or 'message' not in data:
return None
return {
'product': data['product'],
'reason': data.get('reason', ''),
'message': data['message'],
}
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"[Clawdbot] 업셀 응답 파싱 실패: {e}")
return None

View File

@@ -1,9 +1,10 @@
""" """
NHN Cloud 알림톡 발송 서비스 NHN Cloud 알림톡 발송 서비스
마일리지 적립 완료 등 알림톡 발송 마일리지 적립 완료 등 알림톡 발송 + SQLite 로깅
""" """
import os import os
import json
import logging import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -22,6 +23,34 @@ API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}
KST = timezone(timedelta(hours=9)) KST = timezone(timedelta(hours=9))
def _log_to_db(template_code, recipient_no, success, result_message,
template_params=None, user_id=None, trigger_source='unknown',
transaction_id=None):
"""발송 결과를 SQLite에 저장"""
try:
from db.dbsetup import db_manager
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO alimtalk_logs
(template_code, recipient_no, user_id, trigger_source,
template_params, success, result_message, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
template_code,
recipient_no,
user_id,
trigger_source,
json.dumps(template_params, ensure_ascii=False) if template_params else None,
success,
result_message,
transaction_id
))
conn.commit()
except Exception as e:
logger.warning(f"알림톡 로그 DB 저장 실패: {e}")
def _send_alimtalk(template_code, recipient_no, template_params): def _send_alimtalk(template_code, recipient_no, template_params):
""" """
알림톡 발송 공통 함수 알림톡 발송 공통 함수
@@ -82,7 +111,9 @@ def build_item_summary(items):
return f"{first}{len(items) - 1}" return f"{first}{len(items) - 1}"
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None): def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
user_id=None, trigger_source='kiosk',
transaction_id=None):
""" """
마일리지 적립 완료 알림톡 발송 마일리지 적립 완료 알림톡 발송
@@ -92,11 +123,14 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None):
points: 적립 포인트 points: 적립 포인트
balance: 적립 후 총 잔액 balance: 적립 후 총 잔액
items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...] items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...]
user_id: 사용자 ID (로그용)
trigger_source: 발송 주체 ('kiosk', 'admin', 'manual')
transaction_id: 거래 ID (로그용)
Returns: Returns:
tuple: (성공 여부, 메시지) tuple: (성공 여부, 메시지)
""" """
now_kst = datetime.now(KST).strftime('%Y-%m-%d %H:%M') now_kst = datetime.now(KST).strftime('%m/%d %H:%M')
item_summary = build_item_summary(items) item_summary = build_item_summary(items)
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도 # MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도
@@ -113,15 +147,56 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None):
success, msg = _send_alimtalk(template_code, phone, params) success, msg = _send_alimtalk(template_code, phone, params)
if not success: if not success:
# V3 실패 시 V2 폴백 (구매품목 변수 없는 버전) # V3 실패 로그
_log_to_db(template_code, phone, False, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
# V2 폴백
template_code = 'MILEAGE_CLAIM_V2' template_code = 'MILEAGE_CLAIM_V2'
params_v2 = { params = {
'고객명': name, '고객명': name,
'적립포인트': f'{points:,}', '적립포인트': f'{points:,}',
'총잔액': f'{balance:,}', '총잔액': f'{balance:,}',
'적립일시': now_kst, '적립일시': now_kst,
'전화번호': phone '전화번호': phone
} }
success, msg = _send_alimtalk(template_code, phone, params_v2) success, msg = _send_alimtalk(template_code, phone, params)
# 최종 결과 로그
_log_to_db(template_code, phone, success, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
return (success, msg) return (success, msg)
def get_nhn_send_history(start_date, end_date, page=1, page_size=15):
"""
NHN Cloud API에서 실제 발송 내역 조회
Args:
start_date: 시작일 (YYYY-MM-DD HH:mm)
end_date: 종료일 (YYYY-MM-DD HH:mm)
Returns:
list: 발송 메시지 목록
"""
url = (f'{API_BASE}/messages'
f'?startRequestDate={start_date}'
f'&endRequestDate={end_date}'
f'&pageNum={page}&pageSize={page_size}')
headers = {
'Content-Type': 'application/json;charset=UTF-8',
'X-Secret-Key': SECRET_KEY
}
try:
resp = requests.get(url, headers=headers, timeout=10)
data = resp.json()
if data.get('messageSearchResultResponse'):
return data['messageSearchResultResponse'].get('messages', [])
return []
except Exception as e:
logger.warning(f"NHN 발송내역 조회 실패: {e}")
return []

147
backend/sms_client.py Normal file
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

@@ -393,9 +393,19 @@
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<div class="header-content"> <div class="header-content" style="display:flex;justify-content:space-between;align-items:center;">
<div class="header-title">📊 관리자 대시보드</div> <div>
<div class="header-subtitle">청춘약국 마일리지 관리</div> <div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div>
</div>
<div style="display:flex;gap:8px;">
<a href="/admin/products" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🔍 제품검색</a>
<a href="/admin/members" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">👥 회원검색</a>
<a href="/admin/sales-detail" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📋 판매조회</a>
<a href="/admin/sales" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🧾 판매내역</a>
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡</a>
</div>
</div> </div>
</div> </div>
@@ -462,6 +472,8 @@
<th>전화번호</th> <th>전화번호</th>
<th>포인트</th> <th>포인트</th>
<th>가입일</th> <th>가입일</th>
<th>카카오</th>
<th>조제</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -472,6 +484,20 @@
<td class="phone-masked">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</td> <td class="phone-masked">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</td>
<td class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td> <td class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
<td>{{ user.created_at[:16].replace('T', ' ') }}</td> <td>{{ user.created_at[:16].replace('T', ' ') }}</td>
<td>
{% if user.kakao_verified_at %}
<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💬 {{ user.kakao_verified_at[:10] }}</span>
{% else %}
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #868e96; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">미인증</span>
{% endif %}
</td>
<td>
{% if user.has_prescription %}
<span style="display: inline-flex; align-items: center; gap: 3px; background: #d3f9d8; color: #2b8a3e; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;">💊 환자</span>
{% else %}
<span style="display: inline-flex; align-items: center; background: #f1f3f5; color: #adb5bd; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 10px;">일반</span>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -655,7 +681,7 @@
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script> <script src="/static/js/lottie.min.js"></script>
<script> <script>
function showTransactionDetail(transactionId) { function showTransactionDetail(transactionId) {
document.getElementById('transactionModal').style.display = 'block'; document.getElementById('transactionModal').style.display = 'block';
@@ -839,7 +865,10 @@
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;"> <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 16px;">
<div> <div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div> <div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div> <div style="color: #212529; font-size: 16px; font-weight: 600;">
${user.name}
${user.is_kakao_verified ? '<span style="display: inline-flex; align-items: center; gap: 3px; background: #FEE500; color: #3C1E1E; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; margin-left: 8px;"><span style="font-size: 13px;">💬</span>카카오</span>' : '<span style="display: inline-flex; align-items: center; gap: 3px; background: #e9ecef; color: #868e96; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; margin-left: 8px;">미인증</span>'}
</div>
</div> </div>
<div> <div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div> <div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
@@ -853,6 +882,12 @@
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div> <div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div> <div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
</div> </div>
${user.birthday ? `
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">🎂 생일</div>
<div style="color: #ec4899; font-size: 16px; font-weight: 600;">${user.birthday.includes('-') ? user.birthday.split('-')[0] + '월 ' + user.birthday.split('-')[1] + '일' : user.birthday.slice(0,2) + '월 ' + user.birthday.slice(2,4) + '일'}</div>
</div>
` : ''}
</div> </div>
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;"> <div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;"> <button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
@@ -867,10 +902,16 @@
<!-- 탭 메뉴 --> <!-- 탭 메뉴 -->
<div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;"> <div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;">
<button onclick="switchTab('purchases')" id="tab-purchases" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid #6366f1; color: #6366f1;"> <button onclick="switchTab('purchases')" id="tab-purchases" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid #6366f1; color: #6366f1;">
구매 이력 (${purchases.length}) 🛒 구매 (${purchases.length})
</button> </button>
<button onclick="switchTab('mileage')" id="tab-mileage" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;"> <button onclick="switchTab('mileage')" id="tab-mileage" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
적립 이력 (${mileageHistory.length}) 💰 적립 (${mileageHistory.length})
</button>
<button onclick="switchTab('prescriptions')" id="tab-prescriptions" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
💊 조제 (${data.prescriptions ? data.prescriptions.length : 0})
</button>
<button onclick="switchTab('interests')" id="tab-interests" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
💝 관심 (${data.interests ? data.interests.length : 0})
</button> </button>
</div> </div>
@@ -927,6 +968,96 @@
html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>'; html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>';
} }
html += `
</div>
<!-- 조제 이력 탭 -->
<div id="tab-content-prescriptions" class="tab-content" style="display: none;">
`;
// 조제 이력 렌더링
const prescriptions = data.prescriptions || [];
if (prescriptions.length > 0) {
prescriptions.forEach(rx => {
// 날짜 포맷
const dateStr = rx.date || '';
let formattedDate = dateStr;
if (dateStr.length === 8) {
formattedDate = `${dateStr.slice(0,4)}.${dateStr.slice(4,6)}.${dateStr.slice(6,8)}`;
}
// 처방 품목
const itemsHtml = (rx.items || []).map(item => {
const dosage = item.quantity || 1;
const freq = item.times_per_day || 1;
const days = item.days || 0;
return `
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f3f5;">
<span style="color: #495057; font-size: 14px;">${item.name}</span>
<span style="color: #6366f1; font-size: 13px; font-weight: 600;">${dosage}× ${freq}× ${days}일</span>
</div>
`;
}).join('');
html += `
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #6366f1;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="font-size: 15px; font-weight: 600; color: #212529;">📅 ${formattedDate}</span>
<span style="font-size: 13px; color: #6366f1; font-weight: 600;">${rx.total_days || ''}일분</span>
</div>
<div style="font-size: 13px; color: #64748b; margin-bottom: 12px;">
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
</div>
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
</div>
`;
});
} else if (!data.pos_customer) {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 POS 회원으로 등록되지 않았습니다<br><small>전화번호가 POS에 등록되면 조제 이력이 표시됩니다</small></p>';
} else {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">📭 조제 이력이 없습니다</p>';
}
html += `
</div>
<!-- 관심상품 탭 -->
<div id="tab-content-interests" class="tab-content" style="display: none;">
`;
// 관심상품 렌더링
const interests = data.interests || [];
if (interests.length > 0) {
interests.forEach(item => {
// 날짜 포맷
const date = item.created_at || '';
// 트리거 상품 파싱
let triggerText = '';
try {
const triggers = JSON.parse(item.trigger_products || '[]');
if (triggers.length > 0) {
triggerText = triggers.join(', ');
}
} catch(e) {}
html += `
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; padding: 16px; border-left: 4px solid #ec4899;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="font-size: 15px; font-weight: 700; color: #ec4899;">💝 ${item.product}</span>
<span style="font-size: 12px; color: #868e96;">${date}</span>
</div>
<div style="font-size: 13px; color: #64748b; margin-bottom: 8px;">
${item.reason || ''}
</div>
${triggerText ? `<div style="font-size: 12px; color: #94a3b8; background: #f8f9fa; padding: 8px 12px; border-radius: 6px;">🛒 구매: ${triggerText}</div>` : ''}
</div>
`;
});
} else {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
}
html += ` html += `
</div> </div>
`; `;
@@ -1293,9 +1424,12 @@
`; `;
users.forEach(user => { users.forEach(user => {
const kakaoBadge = user.is_kakao_verified
? '<span style="display: inline-flex; align-items: center; gap: 2px; background: #FEE500; color: #3C1E1E; font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">💬</span>'
: '<span style="display: inline-flex; align-items: center; background: #e9ecef; color: #868e96; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 8px; margin-left: 6px;">미인증</span>';
html += ` html += `
<tr style="border-bottom: 1px solid #f1f3f5;"> <tr style="border-bottom: 1px solid #f1f3f5;">
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}</td> <td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}${kakaoBadge}</td>
<td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${user.phone}</td> <td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${user.phone}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td> <td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td>
<td style="padding: 14px; text-align: center;"> <td style="padding: 14px; text-align: center;">

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 업셀링 CRM - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
-webkit-font-smoothing: antialiased;
color: #1e293b;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #7c3aed 0%, #6366f1 50%, #8b5cf6 100%);
padding: 28px 32px 24px;
color: #fff;
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.header p {
font-size: 14px;
opacity: 0.85;
font-weight: 400;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 통계 카드 ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 28px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 20px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
letter-spacing: -0.2px;
margin-bottom: 8px;
text-transform: uppercase;
}
.stat-value {
font-size: 32px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.default { color: #1e293b; }
.stat-value.green { color: #16a34a; }
.stat-value.orange { color: #d97706; }
.stat-value.indigo { color: #6366f1; }
/* ── 테이블 섹션 ── */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.section-title {
font-size: 17px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.3px;
}
.section-sub {
font-size: 13px;
color: #94a3b8;
font-weight: 500;
}
.table-wrap {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #f8fafc;
padding: 12px 14px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 1px solid #e2e8f0;
letter-spacing: -0.2px;
white-space: nowrap;
}
tbody td {
padding: 14px;
font-size: 13px;
font-weight: 500;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tbody tr { cursor: pointer; transition: background .15s; }
tbody tr:hover { background: #f8fafc; }
tbody tr:last-child td { border-bottom: none; }
/* ── 배지 ── */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 100px;
font-size: 11px;
font-weight: 600;
letter-spacing: -0.2px;
}
.badge-active { background: #dcfce7; color: #16a34a; }
.badge-interested { background: #fef3c7; color: #d97706; }
.badge-dismissed { background: #f1f5f9; color: #64748b; }
.badge-expired { background: #fee2e2; color: #dc2626; }
.badge-trigger {
background: #dbeafe;
color: #2563eb;
margin: 1px 2px;
font-size: 11px;
padding: 2px 8px;
}
.badge-product {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
padding: 4px 12px;
font-size: 12px;
}
/* ── 메시지 말줄임 ── */
.msg-ellipsis {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #64748b;
font-size: 12px;
}
/* ── 노출 횟수 ── */
.display-count {
text-align: center;
font-weight: 700;
color: #6366f1;
font-size: 14px;
}
.display-count.zero { color: #cbd5e1; }
/* ── 아코디언 상세 ── */
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td {
padding: 0;
border-bottom: 1px solid #e2e8f0;
}
.detail-content {
padding: 20px 24px;
background: #fafbfd;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.detail-field {
margin-bottom: 2px;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
margin-bottom: 4px;
letter-spacing: 0.3px;
}
.detail-value {
font-size: 13px;
font-weight: 500;
color: #334155;
line-height: 1.5;
}
.detail-raw {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.detail-raw pre {
background: #1e293b;
color: #e2e8f0;
padding: 14px 16px;
border-radius: 10px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
word-break: break-all;
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
.empty-text { font-size: 14px; font-weight: 500; }
/* ── 반응형 ── */
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.detail-grid { grid-template-columns: 1fr; }
.header { padding: 20px 16px 18px; }
.content { padding: 16px 12px 40px; }
.table-wrap { overflow-x: auto; }
table { min-width: 700px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<div>
<a href="/admin/ai-gw" style="margin-right: 16px;">Gateway 모니터</a>
<a href="/admin/alimtalk">알림톡 로그 →</a>
</div>
</div>
<h1>AI 업셀링 CRM</h1>
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
</div>
<div class="content">
<!-- 통계 카드 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">전체 생성</div>
<div class="stat-value default">{{ stats.total or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Active</div>
<div class="stat-value green">{{ stats.active_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">관심있어요</div>
<div class="stat-value orange">{{ stats.interested_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">오늘 생성</div>
<div class="stat-value indigo">{{ stats.today_count or 0 }}</div>
</div>
</div>
<!-- 추천 목록 -->
<div class="section-header">
<div class="section-title">추천 생성 로그</div>
<div class="section-sub">최근 50건 · 클릭하여 상세 보기</div>
</div>
{% if recs %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>생성일시</th>
<th>고객</th>
<th>트리거 품목</th>
<th>추천 제품</th>
<th>AI 메시지</th>
<th>상태</th>
<th style="text-align:center">노출</th>
</tr>
</thead>
<tbody>
{% for rec in recs %}
<tr onclick="toggleDetail({{ rec.id }})">
<td style="white-space:nowrap;font-size:12px;color:#64748b;">
{{ rec.created_at[5:16] if rec.created_at else '-' }}
</td>
<td>
<div style="font-weight:600;font-size:13px;">{{ rec.nickname or '알 수 없음' }}</div>
{% if rec.user_phone %}
<div style="font-size:11px;color:#94a3b8;">{{ rec.user_phone[:3] }}-****-{{ rec.user_phone[-4:] }}</div>
{% endif %}
</td>
<td>
{% if rec.trigger_list %}
{% for item in rec.trigger_list %}
<span class="badge badge-trigger">{{ item }}</span>
{% endfor %}
{% else %}
<span style="color:#cbd5e1;">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-product">{{ rec.recommended_product }}</span>
</td>
<td>
<div class="msg-ellipsis" title="{{ rec.recommendation_message }}">{{ rec.recommendation_message }}</div>
</td>
<td>
{% if rec.status == 'interested' %}
<span class="badge badge-interested">관심있어요</span>
{% elif rec.status == 'active' and (not rec.expires_at or rec.expires_at > now) %}
<span class="badge badge-active">Active</span>
{% elif rec.status == 'dismissed' %}
<span class="badge badge-dismissed">Dismissed</span>
{% else %}
<span class="badge badge-expired">Expired</span>
{% endif %}
</td>
<td class="display-count {{ 'zero' if not rec.displayed_count else '' }}">
{{ rec.displayed_count or 0 }}
</td>
</tr>
<!-- 상세 아코디언 -->
<tr class="detail-row" id="detail-{{ rec.id }}">
<td colspan="7">
<div class="detail-content">
<div class="detail-grid">
<div class="detail-field">
<div class="detail-label">추천 이유</div>
<div class="detail-value">{{ rec.recommendation_reason or '-' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">거래 ID</div>
<div class="detail-value">{{ rec.transaction_id or '-' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">노출 일시</div>
<div class="detail-value">{{ rec.displayed_at or '미노출' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">닫기 일시</div>
<div class="detail-value">{{ rec.dismissed_at or '-' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">만료 일시</div>
<div class="detail-value">{{ rec.expires_at or '없음' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">노출 횟수</div>
<div class="detail-value">{{ rec.displayed_count or 0 }}회</div>
</div>
</div>
{% if rec.ai_raw_response %}
<div class="detail-raw">
<div class="detail-label">AI 원본 응답</div>
<pre>{{ rec.ai_raw_response }}</pre>
</div>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="table-wrap">
<div class="empty-state">
<div class="empty-icon">🤖</div>
<div class="empty-text">아직 생성된 AI 추천이 없습니다</div>
</div>
</div>
{% endif %}
</div>
<script>
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
if (!row) return;
// 다른 열린 것 닫기
document.querySelectorAll('.detail-row.open').forEach(function(el) {
if (el.id !== 'detail-' + id) el.classList.remove('open');
});
row.classList.toggle('open');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,559 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Gateway 모니터 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #0f172a;
-webkit-font-smoothing: antialiased;
color: #e2e8f0;
min-height: 100vh;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
padding: 28px 32px 24px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.6);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.header h1 .live-dot {
width: 10px;
height: 10px;
background: #22c55e;
border-radius: 50%;
animation: pulse 2s infinite;
}
.header h1 .live-dot.offline { background: #ef4444; animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { opacity: 0.8; box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
}
.header p {
font-size: 14px;
color: rgba(255,255,255,0.5);
font-weight: 400;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 메인 카드 ── */
.main-card {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-radius: 20px;
padding: 32px;
margin-bottom: 24px;
border: 1px solid rgba(255,255,255,0.05);
}
.main-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.main-title {
font-size: 14px;
font-weight: 600;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.main-model {
font-size: 20px;
font-weight: 700;
color: #a78bfa;
}
.refresh-btn {
background: rgba(255,255,255,0.1);
border: none;
color: rgba(255,255,255,0.7);
padding: 10px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.refresh-btn:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
.refresh-btn.loading { opacity: 0.6; pointer-events: none; }
/* 컨텍스트 표시 */
.context-display {
margin-bottom: 24px;
}
.context-numbers {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
}
.context-used {
font-size: 64px;
font-weight: 700;
letter-spacing: -3px;
line-height: 1;
}
.context-max {
font-size: 24px;
font-weight: 500;
color: rgba(255,255,255,0.3);
}
.context-percent {
font-size: 24px;
font-weight: 600;
color: #22c55e;
margin-left: 16px;
}
.context-percent.warning { color: #fbbf24; }
.context-percent.danger { color: #ef4444; }
/* 프로그레스 바 */
.progress-wrap {
background: rgba(255,255,255,0.1);
border-radius: 100px;
height: 12px;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 100px;
background: linear-gradient(90deg, #22c55e, #84cc16);
transition: width 0.6s ease;
}
.progress-bar.warning { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.progress-bar.danger { background: linear-gradient(90deg, #ef4444, #f97316); }
/* 통계 그리드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 28px;
}
.stat-item {
background: rgba(0,0,0,0.2);
border-radius: 14px;
padding: 18px 20px;
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: rgba(255,255,255,0.4);
margin-bottom: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
}
.stat-value.purple { color: #a78bfa; }
.stat-value.blue { color: #38bdf8; }
.stat-value.yellow { color: #fbbf24; }
.stat-value.green { color: #34d399; }
/* ── 세션 목록 ── */
.sessions-card {
background: #1e293b;
border-radius: 20px;
border: 1px solid rgba(255,255,255,0.05);
overflow: hidden;
}
.sessions-header {
padding: 20px 24px;
border-bottom: 1px solid rgba(255,255,255,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.sessions-title {
font-size: 16px;
font-weight: 600;
}
.sessions-count {
font-size: 13px;
color: rgba(255,255,255,0.4);
}
.sessions-list {
max-height: 500px;
overflow-y: auto;
}
.session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid rgba(255,255,255,0.03);
transition: background 0.15s;
}
.session-item:hover { background: rgba(255,255,255,0.02); }
.session-item:last-child { border-bottom: none; }
.session-info { flex: 1; }
.session-name {
font-size: 14px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 4px;
}
.session-meta {
font-size: 12px;
color: rgba(255,255,255,0.4);
display: flex;
gap: 12px;
}
.session-model {
color: #a78bfa;
}
.session-usage {
text-align: right;
}
.session-percent {
font-size: 18px;
font-weight: 700;
margin-bottom: 2px;
}
.session-percent.low { color: #22c55e; }
.session-percent.mid { color: #fbbf24; }
.session-percent.high { color: #ef4444; }
.session-tokens {
font-size: 12px;
color: rgba(255,255,255,0.4);
}
.session-bar-wrap {
width: 100px;
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 100px;
margin-top: 6px;
}
.session-bar {
height: 100%;
border-radius: 100px;
background: #22c55e;
}
.session-bar.mid { background: #fbbf24; }
.session-bar.high { background: #ef4444; }
/* ── 모델별 통계 ── */
.model-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.model-stat-card {
background: #1e293b;
border-radius: 14px;
padding: 18px 20px;
border: 1px solid rgba(255,255,255,0.05);
}
.model-stat-name {
font-size: 14px;
font-weight: 600;
color: #a78bfa;
margin-bottom: 10px;
}
.model-stat-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: rgba(255,255,255,0.6);
margin-bottom: 4px;
}
.model-stat-row span:last-child {
font-weight: 600;
color: #e2e8f0;
}
/* ── 에러 상태 ── */
.error-state {
text-align: center;
padding: 60px 20px;
color: #f87171;
}
.error-icon { font-size: 48px; margin-bottom: 12px; }
.error-text { font-size: 16px; font-weight: 500; margin-bottom: 8px; }
.error-sub { font-size: 13px; color: rgba(255,255,255,0.4); }
/* ── 타임스탬프 ── */
.timestamp {
text-align: center;
padding: 16px;
font-size: 12px;
color: rgba(255,255,255,0.3);
}
/* ── 반응형 ── */
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.context-used { font-size: 48px; }
.context-max { font-size: 18px; }
.context-percent { font-size: 18px; }
.header { padding: 20px 16px 18px; }
.content { padding: 16px 12px 40px; }
.main-card { padding: 24px 20px; }
.session-bar-wrap { display: none; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<div>
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
<a href="/admin/alimtalk">알림톡 로그</a>
</div>
</div>
<h1>
<span class="live-dot" id="statusDot"></span>
AI Gateway 모니터
</h1>
<p>Clawdbot Gateway 실시간 상태 · Claude / GPT 토큰 사용량</p>
</div>
<div class="content">
<div id="mainContent">
<!-- 메인 카드 -->
<div class="main-card">
<div class="main-header">
<div>
<div class="main-title">현재 모델</div>
<div class="main-model" id="currentModel">로딩중...</div>
</div>
<button class="refresh-btn" id="refreshBtn" onclick="refresh()">
<span></span> 새로고침
</button>
</div>
<div class="context-display">
<div class="context-numbers">
<span class="context-used" id="contextUsed">--</span>
<span class="context-max" id="contextMax">/ 200k</span>
<span class="context-percent" id="contextPercent">0%</span>
</div>
<div class="progress-wrap">
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">입력 토큰</div>
<div class="stat-value purple" id="inputTokens">-</div>
</div>
<div class="stat-item">
<div class="stat-label">출력 토큰</div>
<div class="stat-value blue" id="outputTokens">-</div>
</div>
<div class="stat-item">
<div class="stat-label">전체 토큰 (모든 세션)</div>
<div class="stat-value yellow" id="totalTokens">-</div>
</div>
<div class="stat-item">
<div class="stat-label">활성 세션</div>
<div class="stat-value green" id="sessionCount">-</div>
</div>
</div>
</div>
<!-- 모델별 통계 -->
<div class="model-stats" id="modelStats"></div>
<!-- 세션 목록 -->
<div class="sessions-card">
<div class="sessions-header">
<div class="sessions-title">세션별 상세</div>
<div class="sessions-count" id="sessionsCount">-</div>
</div>
<div class="sessions-list" id="sessionsList"></div>
</div>
<div class="timestamp" id="timestamp">-</div>
</div>
</div>
<script>
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return Math.round(num / 1000) + 'k';
return num.toString();
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function getPercentClass(percent) {
if (percent >= 70) return 'high';
if (percent >= 40) return 'mid';
return 'low';
}
function refresh() {
const btn = document.getElementById('refreshBtn');
btn.classList.add('loading');
btn.innerHTML = '<span>⟳</span> 로딩중...';
fetch('/api/claude-status?detail=true')
.then(res => res.json())
.then(data => {
btn.classList.remove('loading');
btn.innerHTML = '<span>↻</span> 새로고침';
if (!data.ok || !data.connected) {
document.getElementById('statusDot').classList.add('offline');
document.getElementById('mainContent').innerHTML = `
<div class="main-card">
<div class="error-state">
<div class="error-icon">⚠️</div>
<div class="error-text">Gateway 연결 실패</div>
<div class="error-sub">${data.error || 'Clawdbot이 실행 중인지 확인하세요'}</div>
</div>
</div>
`;
return;
}
document.getElementById('statusDot').classList.remove('offline');
updateUI(data);
})
.catch(err => {
btn.classList.remove('loading');
btn.innerHTML = '<span>↻</span> 새로고침';
console.error(err);
});
}
function updateUI(data) {
const ctx = data.context;
const main = data.mainSession || {};
const summary = data.summary;
// 모델
document.getElementById('currentModel').textContent = data.model;
// 컨텍스트
document.getElementById('contextUsed').textContent = formatNumber(ctx.used);
document.getElementById('contextMax').textContent = '/ ' + formatNumber(ctx.max);
const percentEl = document.getElementById('contextPercent');
percentEl.textContent = ctx.percent + '%';
percentEl.className = 'context-percent';
if (ctx.percent >= 70) percentEl.classList.add('danger');
else if (ctx.percent >= 40) percentEl.classList.add('warning');
// 프로그레스 바
const bar = document.getElementById('progressBar');
bar.style.width = ctx.percent + '%';
bar.className = 'progress-bar';
if (ctx.percent >= 70) bar.classList.add('danger');
else if (ctx.percent >= 40) bar.classList.add('warning');
// 통계
document.getElementById('inputTokens').textContent = formatNumber(main.inputTokens || 0);
document.getElementById('outputTokens').textContent = formatNumber(main.outputTokens || 0);
document.getElementById('totalTokens').textContent = formatNumber(summary.totalTokens);
document.getElementById('sessionCount').textContent = summary.totalSessions + '개';
// 모델별 통계
if (data.modelStats) {
const statsHtml = Object.entries(data.modelStats).map(([model, stat]) => `
<div class="model-stat-card">
<div class="model-stat-name">${escapeHtml(model)}</div>
<div class="model-stat-row">
<span>세션 수</span>
<span>${stat.sessions}개</span>
</div>
<div class="model-stat-row">
<span>총 토큰</span>
<span>${formatNumber(stat.tokens)}</span>
</div>
</div>
`).join('');
document.getElementById('modelStats').innerHTML = statsHtml;
}
// 세션 목록
if (data.sessions) {
document.getElementById('sessionsCount').textContent =
`토큰 사용량 순 · ${data.sessions.length}`;
const sessionsHtml = data.sessions.map(s => {
const pct = s.tokens.contextPercent;
const pctClass = getPercentClass(pct);
return `
<div class="session-item">
<div class="session-info">
<div class="session-name">${escapeHtml(s.displayName || s.name)}</div>
<div class="session-meta">
<span class="session-model">${escapeHtml(s.model)}</span>
<span>${s.channel || '-'}</span>
<span>${s.updatedAt || '-'}</span>
</div>
</div>
<div class="session-usage">
<div class="session-percent ${pctClass}">${pct}%</div>
<div class="session-tokens">${s.tokens.display}</div>
<div class="session-bar-wrap">
<div class="session-bar ${pctClass}" style="width: ${pct}%"></div>
</div>
</div>
</div>
`;
}).join('');
document.getElementById('sessionsList').innerHTML = sessionsHtml;
}
// 타임스탬프
const ts = new Date(data.timestamp);
document.getElementById('timestamp').textContent =
`마지막 업데이트: ${ts.toLocaleTimeString('ko-KR')}`;
}
// 초기 로드 & 30초 자동 갱신
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,554 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>알림톡 발송 로그 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f7fa;
-webkit-font-smoothing: antialiased;
}
.header {
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
padding: 28px 24px;
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
.header-subtitle { font-size: 14px; opacity: 0.85; margin-top: 4px; }
.header-nav a {
color: rgba(255,255,255,0.85);
text-decoration: none;
font-size: 14px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.header-nav a:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.stat-label { font-size: 13px; color: #64748b; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
.stat-value.success { color: #10b981; }
.stat-value.fail { color: #ef4444; }
.stat-value.today { color: #6366f1; }
/* Tabs */
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: #fff;
padding: 4px;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.tab {
padding: 10px 24px;
border: none;
background: none;
font-size: 14px;
font-weight: 500;
color: #64748b;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.tab.active {
background: #6366f1;
color: #fff;
}
.tab:hover:not(.active) { background: #f1f5f9; }
/* Tab Panels */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Table */
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title { font-size: 16px; font-weight: 600; color: #1e293b; }
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 12px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 12px 16px;
font-size: 13px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:hover td { background: #f8fafc; }
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-fail { background: #fee2e2; color: #dc2626; }
.badge-kiosk { background: #dbeafe; color: #2563eb; }
.badge-admin { background: #f3e8ff; color: #7c3aed; }
.badge-manual { background: #fef3c7; color: #d97706; }
.badge-completed { background: #dcfce7; color: #16a34a; }
.badge-sending { background: #fef3c7; color: #d97706; }
.badge-failed { background: #fee2e2; color: #dc2626; }
.phone-mask { font-family: 'Courier New', monospace; font-size: 13px; }
.param-toggle {
font-size: 12px;
color: #6366f1;
cursor: pointer;
text-decoration: underline;
}
.param-detail {
display: none;
margin-top: 8px;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #475569;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
}
.param-detail.show { display: block; }
/* NHN Tab */
.date-picker-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.date-picker-row input {
padding: 8px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.btn-primary { background: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-teal { background: #0d9488; color: #fff; }
.btn-teal:hover { background: #0f766e; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.loading {
text-align: center;
padding: 40px;
color: #94a3b8;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.empty-state .text { font-size: 15px; }
/* Test Send */
.test-form {
display: flex;
gap: 12px;
align-items: flex-end;
padding: 16px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; font-weight: 500; color: #64748b; }
.form-group input {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
}
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 14px 20px;
border-radius: 10px;
color: #fff;
font-size: 14px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 1000;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s ease;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.test-form { flex-wrap: wrap; }
.header-nav { display: none; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div>
<div class="header-title">알림톡 발송 로그</div>
<div class="header-subtitle">NHN Cloud 카카오 알림톡 발송 기록 및 상태 모니터링</div>
</div>
<div class="header-nav">
<a href="/admin">관리자 홈</a>
<a href="/admin/ai-crm">AI 업셀링</a>
<a href="/admin/ai-gw">Gateway 모니터</a>
</div>
</div>
</div>
<div class="container">
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">전체 발송</div>
<div class="stat-value">{{ stats.total or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">성공</div>
<div class="stat-value success">{{ stats.success_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">실패</div>
<div class="stat-value fail">{{ stats.fail_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">오늘 발송</div>
<div class="stat-value today">{{ stats.today_total or 0 }}</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab('local')">발송 로그 (서버)</button>
<button class="tab" onclick="switchTab('nhn')">NHN Cloud 내역</button>
<button class="tab" onclick="switchTab('test')">수동 발송</button>
</div>
<!-- Tab 1: Local Logs -->
<div id="panel-local" class="tab-panel active">
<div class="card">
<div class="card-header">
<div class="card-title">서버 발송 로그 (최근 50건)</div>
</div>
{% if local_logs %}
<table>
<thead>
<tr>
<th>시간</th>
<th>수신번호</th>
<th>고객</th>
<th>템플릿</th>
<th>발송 주체</th>
<th>결과</th>
<th>상세</th>
</tr>
</thead>
<tbody>
{% for log in local_logs %}
<tr>
<td>{{ log.created_at[:16] if log.created_at else '-' }}</td>
<td class="phone-mask">{{ log.recipient_no[:3] + '-' + log.recipient_no[3:7] + '-' + log.recipient_no[7:] if log.recipient_no|length >= 11 else log.recipient_no }}</td>
<td>{{ log.nickname or '-' }}</td>
<td><code>{{ log.template_code }}</code></td>
<td>
{% if log.trigger_source == 'kiosk' %}
<span class="badge badge-kiosk">키오스크</span>
{% elif log.trigger_source == 'admin_test' %}
<span class="badge badge-admin">관리자</span>
{% else %}
<span class="badge badge-manual">{{ log.trigger_source }}</span>
{% endif %}
</td>
<td>
{% if log.success %}
<span class="badge badge-success">성공</span>
{% else %}
<span class="badge badge-fail">실패</span>
{% endif %}
</td>
<td>
{% if log.template_params %}
<span class="param-toggle" onclick="toggleParam(this)">변수 보기</span>
<div class="param-detail">{{ log.template_params }}</div>
{% endif %}
{% if not log.success and log.result_message %}
<div style="color: #ef4444; font-size: 12px; margin-top: 4px;">{{ log.result_message }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="icon">📭</div>
<div class="text">아직 발송 기록이 없습니다</div>
</div>
{% endif %}
</div>
</div>
<!-- Tab 2: NHN Cloud -->
<div id="panel-nhn" class="tab-panel">
<div class="card">
<div class="card-header">
<div class="card-title">NHN Cloud 발송 내역</div>
</div>
<div style="padding: 16px 20px;">
<div class="date-picker-row">
<input type="date" id="nhn-date" value="{{ now_date }}" />
<button class="btn btn-primary" onclick="loadNhnHistory()">조회</button>
</div>
</div>
<div id="nhn-table-area">
<div class="empty-state">
<div class="icon">🔍</div>
<div class="text">날짜를 선택하고 조회를 눌러주세요</div>
</div>
</div>
</div>
</div>
<!-- Tab 3: Test Send -->
<div id="panel-test" class="tab-panel">
<div class="card">
<div class="card-header">
<div class="card-title">수동 알림톡 발송 테스트</div>
</div>
<div class="test-form">
<div class="form-group">
<label>전화번호</label>
<input type="tel" id="test-phone" placeholder="01012345678" style="width: 160px;" />
</div>
<div class="form-group">
<label>고객명</label>
<input type="text" id="test-name" placeholder="테스트" value="테스트" style="width: 120px;" />
</div>
<button class="btn btn-teal" onclick="sendTest()">테스트 발송</button>
</div>
<div style="padding: 20px; color: #64748b; font-size: 13px; line-height: 1.8;">
<strong>안내</strong><br>
- MILEAGE_CLAIM_V3 템플릿으로 테스트 메시지를 발송합니다.<br>
- 테스트 값: 적립 100P, 잔액 500P, 품목 "테스트 발송"<br>
- 발송 결과는 "발송 로그 (서버)" 탭에서 확인 가능합니다.
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('panel-' + tabName).classList.add('active');
if (tabName === 'nhn' && !document.getElementById('nhn-table-area').dataset.loaded) {
loadNhnHistory();
}
}
// Toggle param detail
function toggleParam(el) {
const detail = el.nextElementSibling;
detail.classList.toggle('show');
el.textContent = detail.classList.contains('show') ? '접기' : '변수 보기';
}
// Toast notification
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast ' + type + ' show';
setTimeout(() => toast.classList.remove('show'), 3000);
}
// Load NHN history
async function loadNhnHistory() {
const date = document.getElementById('nhn-date').value;
const area = document.getElementById('nhn-table-area');
area.innerHTML = '<div class="loading">조회 중...</div>';
try {
const resp = await fetch('/api/admin/alimtalk/nhn-history?date=' + date);
const data = await resp.json();
area.dataset.loaded = '1';
if (!data.messages || data.messages.length === 0) {
area.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div class="text">' + date + ' 발송 내역이 없습니다</div></div>';
return;
}
let html = '<table><thead><tr><th>요청 시간</th><th>수신번호</th><th>템플릿</th><th>상태</th><th>결과코드</th></tr></thead><tbody>';
data.messages.forEach(m => {
const time = m.requestDate ? m.requestDate.substring(0, 19) : '-';
const phone = m.recipientNo || '-';
const tpl = m.templateCode || '-';
let statusBadge = '';
const st = (m.messageStatus || '').toUpperCase();
if (st === 'COMPLETED') {
statusBadge = '<span class="badge badge-completed">전송완료</span>';
} else if (st === 'SENDING' || st === 'READY') {
statusBadge = '<span class="badge badge-sending">발송중</span>';
} else {
statusBadge = '<span class="badge badge-failed">' + (m.messageStatus || '알수없음') + '</span>';
}
const code = m.resultCode || '-';
html += '<tr><td>' + time + '</td><td class="phone-mask">' + phone + '</td><td><code>' + tpl + '</code></td><td>' + statusBadge + '</td><td>' + code + '</td></tr>';
});
html += '</tbody></table>';
area.innerHTML = html;
} catch(e) {
area.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div class="text">조회 실패: ' + e.message + '</div></div>';
}
}
// Test send
async function sendTest() {
const phone = document.getElementById('test-phone').value.trim();
const name = document.getElementById('test-name').value.trim() || '테스트';
if (phone.length < 10) {
showToast('전화번호를 입력해주세요', 'error');
return;
}
try {
const resp = await fetch('/api/admin/alimtalk/test-send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, name })
});
const data = await resp.json();
if (data.success) {
showToast('발송 성공!', 'success');
} else {
showToast('발송 실패: ' + data.message, 'error');
}
} catch(e) {
showToast('오류: ' + e.message, 'error');
}
}
// Set today's date
document.getElementById('nhn-date').value = new Date().toISOString().split('T')[0];
</script>
</body>
</html>

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

@@ -0,0 +1,778 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>판매 상세 조회 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
-webkit-font-smoothing: antialiased;
color: #1e293b;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
padding: 28px 32px 24px;
color: #fff;
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.header p {
font-size: 14px;
opacity: 0.85;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 검색/필터 영역 ── */
.search-section {
background: #fff;
border-radius: 14px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid #e2e8f0;
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.search-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.search-group label {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.search-group input, .search-group select {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
min-width: 150px;
}
.search-group input:focus, .search-group select:focus {
outline: none;
border-color: #0d9488;
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.1);
}
.search-btn {
background: #0d9488;
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.search-btn:hover { background: #0f766e; }
/* ── 통계 카드 ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 18px 20px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 6px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.teal { color: #0d9488; }
.stat-value.blue { color: #3b82f6; }
.stat-value.purple { color: #8b5cf6; }
.stat-value.orange { color: #f59e0b; }
/* ── 테이블 ── */
.table-wrap {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 16px;
font-weight: 700;
}
.table-count {
font-size: 13px;
color: #64748b;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #f8fafc;
padding: 12px 14px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 14px;
font-size: 13px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tbody tr:hover { background: #f8fafc; }
tbody tr:last-child td { border-bottom: none; }
/* ── 코드 스타일 ── */
.code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
display: inline-block;
}
.code-drug {
background: #dbeafe;
color: #1e40af;
}
.code-barcode {
background: #d1fae5;
color: #065f46;
}
.code-standard {
background: #fef3c7;
color: #92400e;
}
.code-na {
background: #f1f5f9;
color: #94a3b8;
}
/* ── 제품명 ── */
.product-name {
font-weight: 600;
color: #1e293b;
}
.product-category {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
/* ── 금액 ── */
.price {
font-weight: 600;
text-align: right;
}
.qty {
text-align: center;
font-weight: 500;
}
/* ── 코드 전환 버튼 ── */
.code-toggle {
display: flex;
gap: 4px;
margin-bottom: 12px;
}
.code-toggle button {
padding: 6px 12px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.code-toggle button.active {
background: #0d9488;
color: #fff;
border-color: #0d9488;
}
.code-toggle button:hover:not(.active) {
background: #f8fafc;
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
/* ── 로딩 ── */
.loading {
text-align: center;
padding: 40px;
color: #64748b;
}
/* ── QR 인쇄 버튼 ── */
.btn-qr {
background: #8b5cf6;
color: #fff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-qr:hover { background: #7c3aed; }
.btn-qr:disabled {
background: #cbd5e1;
cursor: not-allowed;
}
.btn-qr.printing {
background: #f59e0b;
}
/* ── 모달 ── */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-box {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 90%;
text-align: center;
}
.modal-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
.modal-preview {
margin: 16px 0;
}
.modal-preview img {
max-width: 200px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
/* ── 수량 선택기 ── */
.qty-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin: 20px 0;
}
.qty-btn {
width: 44px;
height: 44px;
border: none;
background: #f1f5f9;
font-size: 24px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.qty-btn:first-child {
border-radius: 12px 0 0 12px;
}
.qty-btn:last-child {
border-radius: 0 12px 12px 0;
}
.qty-btn:hover {
background: #e2e8f0;
color: #334155;
}
.qty-btn:active {
transform: scale(0.95);
background: #cbd5e1;
}
.qty-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.qty-value {
width: 64px;
height: 44px;
background: #fff;
border: 2px solid #e2e8f0;
border-left: none;
border-right: none;
font-size: 20px;
font-weight: 700;
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
}
.qty-label {
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.modal-btns {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
.modal-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.modal-btn.cancel {
background: #f1f5f9;
color: #64748b;
}
.modal-btn.cancel:hover { background: #e2e8f0; }
.modal-btn.confirm {
background: #8b5cf6;
color: #fff;
}
.modal-btn.confirm:hover { background: #7c3aed; }
.modal-btn.confirm:active { transform: scale(0.98); }
/* ── 반응형 ── */
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.search-section { flex-direction: column; }
.search-group { width: 100%; }
.search-group input, .search-group select { width: 100%; }
.table-wrap { overflow-x: auto; }
table { min-width: 900px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<div>
<a href="/admin/sales" style="margin-right: 16px;">판매 내역</a>
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
<a href="/admin/ai-gw">Gateway 모니터</a>
</div>
</div>
<h1>판매 상세 조회</h1>
<p>상품코드 · 바코드 · 표준코드 매핑 조회</p>
</div>
<div class="content">
<!-- 검색/필터 -->
<div class="search-section">
<div class="search-group">
<label>조회 기간</label>
<select id="periodSelect">
<option value="1">오늘</option>
<option value="7" selected>최근 7일</option>
<option value="30">최근 30일</option>
</select>
</div>
<div class="search-group">
<label>검색 (상품명/코드)</label>
<input type="text" id="searchInput" placeholder="타이레놀, LB000...">
</div>
<div class="search-group">
<label>바코드 필터</label>
<select id="barcodeFilter">
<option value="all">전체</option>
<option value="has">바코드 있음</option>
<option value="none">바코드 없음</option>
</select>
</div>
<button class="search-btn" onclick="loadSalesData()">조회</button>
</div>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">총 판매 건수</div>
<div class="stat-value teal" id="statTotal">-</div>
</div>
<div class="stat-card">
<div class="stat-label">총 매출액</div>
<div class="stat-value blue" id="statAmount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">바코드 매핑률</div>
<div class="stat-value purple" id="statBarcode">-</div>
</div>
<div class="stat-card">
<div class="stat-label">고유 상품 수</div>
<div class="stat-value orange" id="statProducts">-</div>
</div>
</div>
<!-- 코드 표시 토글 -->
<div class="code-toggle">
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
<button data-code="all" onclick="setCodeView('all')">전체 표시</button>
</div>
<!-- 테이블 -->
<div class="table-wrap">
<div class="table-header">
<div class="table-title">판매 내역</div>
<div class="table-count" id="tableCount">-</div>
</div>
<table>
<thead>
<tr>
<th>판매일시</th>
<th>상품명</th>
<th id="codeHeader">상품코드</th>
<th>수량</th>
<th>단가</th>
<th>합계</th>
<th>QR</th>
</tr>
</thead>
<tbody id="salesTableBody">
<tr><td colspan="7" class="loading">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
let salesData = [];
let currentCodeView = 'drug';
function setCodeView(view) {
currentCodeView = view;
document.querySelectorAll('.code-toggle button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.code === view);
});
const header = document.getElementById('codeHeader');
if (view === 'drug') header.textContent = '상품코드';
else if (view === 'barcode') header.textContent = '바코드';
else if (view === 'standard') header.textContent = '표준코드';
else header.textContent = '코드 (상품/바코드/표준)';
renderTable();
}
function formatPrice(num) {
return new Intl.NumberFormat('ko-KR').format(num) + '원';
}
function renderCodeCell(item) {
if (currentCodeView === 'drug') {
return `<span class="code code-drug">${item.drug_code}</span>`;
} else if (currentCodeView === 'barcode') {
return item.barcode
? `<span class="code code-barcode">${item.barcode}</span>`
: `<span class="code code-na">N/A</span>`;
} else if (currentCodeView === 'standard') {
return item.standard_code
? `<span class="code code-standard">${item.standard_code}</span>`
: `<span class="code code-na">N/A</span>`;
} else {
// 전체 표시
let html = `<span class="code code-drug">${item.drug_code}</span><br>`;
html += item.barcode
? `<span class="code code-barcode">${item.barcode}</span><br>`
: `<span class="code code-na">바코드 없음</span><br>`;
html += item.standard_code
? `<span class="code code-standard">${item.standard_code}</span>`
: `<span class="code code-na">표준코드 없음</span>`;
return html;
}
}
function renderTable() {
const tbody = document.getElementById('salesTableBody');
if (salesData.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">판매 내역이 없습니다</td></tr>';
return;
}
tbody.innerHTML = salesData.map((item, idx) => `
<tr>
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
<td>
<div class="product-name">${escapeHtml(item.product_name)}</div>
${item.supplier ? `<div class="product-category">${escapeHtml(item.supplier)}</div>` : ''}
</td>
<td>${renderCodeCell(item)}</td>
<td class="qty">${item.quantity}</td>
<td class="price">${formatPrice(item.unit_price)}</td>
<td class="price">${formatPrice(item.total_price)}</td>
<td>
<button class="btn-qr" onclick="printQR(${idx})" title="QR 라벨 인쇄">
🏷️ QR
</button>
</td>
</tr>
`).join('');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function loadSalesData() {
const period = document.getElementById('periodSelect').value;
const search = document.getElementById('searchInput').value;
const barcodeFilter = document.getElementById('barcodeFilter').value;
document.getElementById('salesTableBody').innerHTML =
'<tr><td colspan="7" class="loading">로딩 중...</td></tr>';
fetch(`/api/sales-detail?days=${period}&search=${encodeURIComponent(search)}&barcode=${barcodeFilter}`)
.then(res => res.json())
.then(data => {
if (data.success) {
salesData = data.items;
// 통계 업데이트
document.getElementById('statTotal').textContent = data.stats.total_count.toLocaleString();
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
document.getElementById('tableCount').textContent = `${salesData.length}`;
renderTable();
} else {
document.getElementById('salesTableBody').innerHTML =
`<tr><td colspan="7" class="empty-state">오류: ${data.error}</td></tr>`;
}
})
.catch(err => {
document.getElementById('salesTableBody').innerHTML =
`<tr><td colspan="7" class="empty-state">데이터 로드 실패</td></tr>`;
});
}
// QR 인쇄 관련
let selectedItem = null;
let printQty = 1;
const MAX_QTY = 10;
const MIN_QTY = 1;
function adjustQty(delta) {
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
updateQtyUI();
}
function updateQtyUI() {
document.getElementById('qtyValue').textContent = printQty;
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
const btn = document.getElementById('printBtn');
btn.textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
}
function printQR(idx) {
selectedItem = salesData[idx];
printQty = 1;
// 미리보기 요청
const modal = document.getElementById('qrModal');
const preview = document.getElementById('qrPreview');
const info = document.getElementById('qrInfo');
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
info.innerHTML = `
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
<span style="color:#64748b;font-size:13px;">
바코드: ${selectedItem.barcode || selectedItem.drug_code || 'N/A'}<br>
가격: ${formatPrice(selectedItem.unit_price)}
</span>
`;
updateQtyUI();
modal.classList.add('active');
// 미리보기 이미지 로드
fetch('/api/qr-preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: selectedItem.barcode || '',
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.unit_price || 0
})
})
.then(res => res.json())
.then(data => {
if (data.success && data.image) {
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
} else {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
}
})
.catch(err => {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
});
}
function closeQRModal() {
document.getElementById('qrModal').classList.remove('active');
selectedItem = null;
printQty = 1;
}
async function confirmPrintQR() {
if (!selectedItem) return;
const btn = document.getElementById('printBtn');
const totalQty = printQty;
btn.disabled = true;
let successCount = 0;
let errorMsg = '';
for (let i = 0; i < totalQty; i++) {
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
try {
const res = await fetch('/api/qr-print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: selectedItem.barcode || '',
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.unit_price || 0
})
});
const data = await res.json();
if (data.success) {
successCount++;
} else {
errorMsg = data.error || '알 수 없는 오류';
break;
}
// 연속 인쇄 시 약간의 딜레이
if (i < totalQty - 1) {
await new Promise(r => setTimeout(r, 500));
}
} catch (err) {
errorMsg = err.message;
break;
}
}
btn.disabled = false;
updateQtyUI();
if (successCount === totalQty) {
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
closeQRModal();
} else if (successCount > 0) {
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
} else {
alert(`❌ 인쇄 실패: ${errorMsg}`);
}
}
// 초기 로드
loadSalesData();
</script>
<!-- QR 인쇄 모달 -->
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
<div class="modal-box">
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
<div id="qrInfo" style="margin-bottom:12px;"></div>
<div class="modal-preview" id="qrPreview">
<p style="color:#64748b;">미리보기 로딩 중...</p>
</div>
<div class="qty-label">인쇄 매수</div>
<div class="qty-selector">
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus"></button>
<div class="qty-value" id="qtyValue">1</div>
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
</div>
<div class="modal-btns">
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,902 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>판매 내역 - 청춘약국 POS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--border: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-teal: #14b8a6;
--accent-blue: #3b82f6;
--accent-purple: #a855f7;
--accent-amber: #f59e0b;
--accent-emerald: #10b981;
--accent-rose: #f43f5e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
padding: 20px 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.header-inner {
max-width: 1600px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.header-left p {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.header-nav {
display: flex;
gap: 8px;
}
.header-nav a {
color: rgba(255,255,255,0.85);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
background: rgba(255,255,255,0.1);
transition: all 0.2s;
}
.header-nav a:hover {
background: rgba(255,255,255,0.2);
color: #fff;
}
/* ══════════════════ 컨텐츠 ══════════════════ */
.content {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
}
/* ══════════════════ 검색 영역 ══════════════════ */
.search-bar {
background: var(--bg-card);
border-radius: 16px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.search-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.search-group label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-group input, .search-group select {
padding: 10px 14px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
color: var(--text-primary);
min-width: 140px;
transition: all 0.2s;
}
.search-group input:focus, .search-group select:focus {
outline: none;
border-color: var(--accent-teal);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
}
.search-group input::placeholder { color: var(--text-muted); }
.search-btn {
background: linear-gradient(135deg, var(--accent-teal), var(--accent-emerald));
color: #fff;
border: none;
padding: 10px 28px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.4);
}
/* ══════════════════ 통계 카드 ══════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: 14px;
padding: 20px;
border: 1px solid var(--border);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.teal::before { background: var(--accent-teal); }
.stat-card.blue::before { background: var(--accent-blue); }
.stat-card.purple::before { background: var(--accent-purple); }
.stat-card.amber::before { background: var(--accent-amber); }
.stat-card.emerald::before { background: var(--accent-emerald); }
.stat-icon {
font-size: 24px;
margin-bottom: 12px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 4px;
}
.stat-card.teal .stat-value { color: var(--accent-teal); }
.stat-card.blue .stat-value { color: var(--accent-blue); }
.stat-card.purple .stat-value { color: var(--accent-purple); }
.stat-card.amber .stat-value { color: var(--accent-amber); }
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
.stat-label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ══════════════════ 뷰 토글 ══════════════════ */
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.code-toggle {
display: flex;
gap: 4px;
background: var(--bg-secondary);
padding: 4px;
border-radius: 10px;
}
.code-toggle button {
padding: 8px 16px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.code-toggle button.active {
background: var(--accent-teal);
color: #fff;
}
.code-toggle button:hover:not(.active) {
color: var(--text-primary);
}
.view-mode {
display: flex;
gap: 8px;
}
.view-btn {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.view-btn.active {
border-color: var(--accent-teal);
color: var(--accent-teal);
}
/* ══════════════════ 거래 카드 (그룹별) ══════════════════ */
.transactions-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.tx-card {
background: var(--bg-card);
border-radius: 16px;
border: 1px solid var(--border);
overflow: hidden;
transition: all 0.2s;
}
.tx-card:hover {
border-color: var(--accent-teal);
}
.tx-header {
padding: 16px 20px;
background: var(--bg-secondary);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.tx-header:hover {
background: var(--bg-card-hover);
}
.tx-info {
display: flex;
align-items: center;
gap: 20px;
}
.tx-id {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-teal);
}
.tx-time {
font-size: 13px;
color: var(--text-secondary);
}
.tx-customer {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
background: var(--bg-primary);
padding: 4px 12px;
border-radius: 20px;
}
.tx-summary {
display: flex;
align-items: center;
gap: 16px;
}
.tx-count {
font-size: 13px;
color: var(--text-muted);
}
.tx-amount {
font-size: 18px;
font-weight: 700;
color: var(--accent-emerald);
}
.tx-toggle {
font-size: 16px;
color: var(--text-muted);
transition: transform 0.3s;
}
.tx-card.open .tx-toggle {
transform: rotate(180deg);
}
/* 품목 테이블 */
.tx-items {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.tx-card.open .tx-items {
max-height: 2000px;
}
.items-table {
width: 100%;
border-collapse: collapse;
}
.items-table th {
padding: 12px 16px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: left;
background: rgba(0,0,0,0.2);
border-bottom: 1px solid var(--border);
}
.items-table th:nth-child(4),
.items-table th:nth-child(5),
.items-table th:nth-child(6) {
text-align: right;
}
.items-table td {
padding: 14px 16px;
font-size: 13px;
border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: middle;
}
.items-table tr:last-child td {
border-bottom: none;
}
.items-table tr:hover {
background: rgba(255,255,255,0.02);
}
/* 제품 셀 */
.product-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.product-name {
font-weight: 600;
color: var(--text-primary);
}
.product-supplier {
font-size: 11px;
color: var(--text-muted);
}
/* 코드 뱃지 */
.code-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
padding: 4px 10px;
border-radius: 6px;
display: inline-block;
}
.code-drug {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.code-barcode {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.code-standard {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.code-na {
background: rgba(148, 163, 184, 0.1);
color: var(--text-muted);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.code-stack {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 바코드 시각화 */
.barcode-visual {
display: flex;
align-items: center;
gap: 8px;
}
.barcode-bars {
display: flex;
gap: 1px;
align-items: flex-end;
height: 20px;
}
.barcode-bars span {
width: 2px;
background: var(--accent-emerald);
opacity: 0.7;
}
/* 숫자 정렬 */
.items-table td.qty,
.items-table td.price {
text-align: right;
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
.items-table td.price.total {
color: var(--accent-teal);
font-weight: 600;
}
/* ══════════════════ 리스트 뷰 ══════════════════ */
.list-view {
display: none;
}
.list-view.active {
display: block;
}
.list-table-wrap {
background: var(--bg-card);
border-radius: 16px;
border: 1px solid var(--border);
overflow: hidden;
}
.list-table {
width: 100%;
border-collapse: collapse;
}
.list-table th {
padding: 14px 16px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: left;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
}
.list-table td {
padding: 14px 16px;
font-size: 13px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.list-table tr:hover {
background: rgba(255,255,255,0.02);
}
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
.loading-state, .empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-muted);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent-teal);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* ══════════════════ 반응형 ══════════════════ */
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.header-nav { display: none; }
.search-bar { flex-direction: column; }
.search-group { width: 100%; }
.search-group input, .search-group select { width: 100%; }
.tx-info { flex-wrap: wrap; gap: 8px; }
.view-controls { flex-direction: column; gap: 12px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-inner">
<div class="header-left">
<h1>🧾 판매 내역</h1>
<p>POS 판매 데이터 · 바코드 · 표준코드 조회</p>
</div>
<nav class="header-nav">
<a href="/admin">📊 대시보드</a>
<a href="/admin/ai-crm">🤖 AI CRM</a>
<a href="/admin/alimtalk">📨 알림톡</a>
</nav>
</div>
</div>
<div class="content">
<!-- 검색 -->
<div class="search-bar">
<div class="search-group">
<label>조회 기간</label>
<select id="periodSelect">
<option value="1">오늘</option>
<option value="3" selected>최근 3일</option>
<option value="7">최근 7일</option>
<option value="30">최근 30일</option>
</select>
</div>
<div class="search-group">
<label>검색어</label>
<input type="text" id="searchInput" placeholder="상품명, 코드, 바코드...">
</div>
<div class="search-group">
<label>바코드</label>
<select id="barcodeFilter">
<option value="all">전체</option>
<option value="has">있음</option>
<option value="none">없음</option>
</select>
</div>
<button class="search-btn" onclick="loadSalesData()">🔍 조회</button>
</div>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card teal">
<div class="stat-icon">📅</div>
<div class="stat-value" id="statTxCount">-</div>
<div class="stat-label">조회 일수</div>
</div>
<div class="stat-card blue">
<div class="stat-icon">📦</div>
<div class="stat-value" id="statItemCount">-</div>
<div class="stat-label">총 판매 품목</div>
</div>
<div class="stat-card emerald">
<div class="stat-icon">💰</div>
<div class="stat-value" id="statAmount">-</div>
<div class="stat-label">총 매출액</div>
</div>
<div class="stat-card purple">
<div class="stat-icon">📊</div>
<div class="stat-value" id="statBarcode">-</div>
<div class="stat-label">바코드 매핑률</div>
</div>
<div class="stat-card amber">
<div class="stat-icon">🏷️</div>
<div class="stat-value" id="statProducts">-</div>
<div class="stat-label">고유 상품</div>
</div>
</div>
<!-- 뷰 컨트롤 -->
<div class="view-controls">
<div class="code-toggle">
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
<button data-code="all" onclick="setCodeView('all')">전체</button>
</div>
<div class="view-mode">
<button class="view-btn active" data-view="group" onclick="setViewMode('group')">📁 거래별</button>
<button class="view-btn" data-view="list" onclick="setViewMode('list')">📋 목록</button>
</div>
</div>
<!-- 거래별 뷰 -->
<div id="groupView" class="transactions-container">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
</div>
</div>
<!-- 리스트 뷰 -->
<div id="listView" class="list-view">
<div class="list-table-wrap">
<table class="list-table">
<thead>
<tr>
<th>판매일</th>
<th>상품명</th>
<th id="listCodeHeader">상품코드</th>
<th style="text-align:center">수량</th>
<th style="text-align:right">단가</th>
<th style="text-align:right">합계</th>
</tr>
</thead>
<tbody id="listTableBody"></tbody>
</table>
</div>
</div>
</div>
<script>
let rawData = []; // API에서 받은 원본 데이터
let groupedData = []; // 거래별 그룹화된 데이터
let currentCodeView = 'drug';
let currentViewMode = 'group';
// ──────────────── 코드 뷰 전환 ────────────────
function setCodeView(view) {
currentCodeView = view;
document.querySelectorAll('.code-toggle button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.code === view);
});
const headers = {
'drug': '상품코드',
'barcode': '바코드',
'standard': '표준코드',
'all': '코드 정보'
};
document.querySelectorAll('#codeHeader, #listCodeHeader').forEach(el => {
if (el) el.textContent = headers[view];
});
render();
}
// ──────────────── 뷰 모드 전환 ────────────────
function setViewMode(mode) {
currentViewMode = mode;
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode);
});
document.getElementById('groupView').style.display = mode === 'group' ? 'flex' : 'none';
document.getElementById('listView').classList.toggle('active', mode === 'list');
}
// ──────────────── 코드 렌더링 ────────────────
function renderCode(item) {
if (currentCodeView === 'drug') {
return `<span class="code-badge code-drug">${item.drug_code}</span>`;
} else if (currentCodeView === 'barcode') {
if (item.barcode) {
return `
<div class="barcode-visual">
<span class="code-badge code-barcode">${item.barcode}</span>
${renderBarcodeBars(item.barcode)}
</div>`;
}
return `<span class="code-badge code-na">—</span>`;
} else if (currentCodeView === 'standard') {
return item.standard_code
? `<span class="code-badge code-standard">${item.standard_code}</span>`
: `<span class="code-badge code-na">—</span>`;
} else {
return `
<div class="code-stack">
<span class="code-badge code-drug">${item.drug_code}</span>
${item.barcode
? `<span class="code-badge code-barcode">${item.barcode}</span>`
: `<span class="code-badge code-na">바코드 없음</span>`}
${item.standard_code
? `<span class="code-badge code-standard">${item.standard_code}</span>`
: ''}
</div>`;
}
}
// 바코드 시각화 바
function renderBarcodeBars(barcode) {
const bars = barcode.split('').map(c => {
const h = 8 + (parseInt(c) || c.charCodeAt(0) % 10) * 1.2;
return `<span style="height:${h}px"></span>`;
}).join('');
return `<div class="barcode-bars">${bars}</div>`;
}
// ──────────────── 포맷 ────────────────
function formatPrice(num) {
return new Intl.NumberFormat('ko-KR').format(num);
}
function formatDateTime(dt) {
if (!dt) return '-';
const d = new Date(dt);
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
// ──────────────── 데이터 그룹화 (날짜별) ────────────────
function groupByDate(items) {
const map = new Map();
items.forEach(item => {
const key = item.sale_date;
if (!map.has(key)) {
map.set(key, {
date: item.sale_date,
items: [],
total: 0
});
}
const group = map.get(key);
group.items.push(item);
group.total += item.total_price || 0;
});
return Array.from(map.values()).sort((a, b) =>
b.date.localeCompare(a.date)
);
}
// ──────────────── 렌더링 ────────────────
function render() {
renderGroupView();
renderListView();
}
function renderGroupView() {
const container = document.getElementById('groupView');
if (groupedData.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📭</div>
<div>판매 내역이 없습니다</div>
</div>`;
return;
}
container.innerHTML = groupedData.map((tx, idx) => `
<div class="tx-card" id="tx-${idx}">
<div class="tx-header" onclick="toggleTransaction(${idx})">
<div class="tx-info">
<span class="tx-id">📅 ${tx.date}</span>
</div>
<div class="tx-summary">
<span class="tx-count">${tx.items.length}개 품목</span>
<span class="tx-amount">${formatPrice(tx.total)}원</span>
<span class="tx-toggle">▼</span>
</div>
</div>
<div class="tx-items">
<table class="items-table">
<thead>
<tr>
<th style="width:40%">상품명</th>
<th id="codeHeader-${idx}">상품코드</th>
<th style="text-align:right;width:8%">수량</th>
<th style="text-align:right;width:12%">단가</th>
<th style="text-align:right;width:12%">합계</th>
</tr>
</thead>
<tbody>
${tx.items.map(item => `
<tr>
<td>
<div class="product-cell">
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
</td>
<td>${renderCode(item)}</td>
<td class="qty">${item.quantity}</td>
<td class="price">${formatPrice(item.unit_price)}원</td>
<td class="price total">${formatPrice(item.total_price)}원</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`).join('');
}
function renderListView() {
const tbody = document.getElementById('listTableBody');
if (rawData.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>`;
return;
}
tbody.innerHTML = rawData.map(item => `
<tr>
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
<td>
<div class="product-cell">
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
</td>
<td>${renderCode(item)}</td>
<td style="text-align:center">${item.quantity}</td>
<td style="text-align:right;font-family:'JetBrains Mono',monospace;">${formatPrice(item.unit_price)}원</td>
<td style="text-align:right;font-family:'JetBrains Mono',monospace;color:var(--accent-teal);font-weight:600;">${formatPrice(item.total_price)}원</td>
</tr>
`).join('');
}
function toggleTransaction(idx) {
const card = document.getElementById(`tx-${idx}`);
card.classList.toggle('open');
}
// ──────────────── 데이터 로드 ────────────────
function loadSalesData() {
const period = document.getElementById('periodSelect').value;
const search = document.getElementById('searchInput').value;
const barcodeFilter = document.getElementById('barcodeFilter').value;
document.getElementById('groupView').innerHTML = `
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
</div>`;
let url = `/api/sales-detail?days=${period}&barcode=${barcodeFilter}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
fetch(url)
.then(res => res.json())
.then(data => {
if (data.success) {
rawData = data.items;
groupedData = groupByDate(rawData);
// 통계 업데이트
document.getElementById('statTxCount').textContent = groupedData.length.toLocaleString();
document.getElementById('statItemCount').textContent = data.stats.total_count.toLocaleString();
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
render();
} else {
document.getElementById('groupView').innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${data.error}</div>
</div>`;
}
})
.catch(err => {
document.getElementById('groupView').innerHTML = `
<div class="empty-state">
<div class="empty-icon">❌</div>
<div>데이터 로드 실패</div>
</div>`;
});
}
// 엔터키 검색
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') loadSalesData();
});
// 초기 로드
loadSalesData();
</script>
</body>
</html>

View File

@@ -59,7 +59,8 @@
width: 100%; width: 100%;
max-width: 780px; max-width: 780px;
position: relative; position: relative;
height: 380px; height: 450px;
overflow: hidden;
} }
.slide { .slide {
position: absolute; position: absolute;
@@ -98,31 +99,31 @@
display: inline-block; display: inline-block;
padding: 6px 16px; padding: 6px 16px;
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.slide-title { .slide-title {
font-size: 30px; font-size: 42px;
font-weight: 900; font-weight: 900;
color: #fff; color: #fff;
letter-spacing: -0.8px; letter-spacing: -0.8px;
line-height: 1.3; line-height: 1.3;
} }
.slide-desc { .slide-desc {
font-size: 17px; font-size: 23px;
color: rgba(255,255,255,0.65); color: rgba(255,255,255,0.7);
line-height: 1.6; line-height: 1.6;
max-width: 500px; max-width: 520px;
} }
.slide-highlight { .slide-highlight {
display: inline-block; display: inline-block;
padding: 10px 28px; padding: 12px 32px;
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.15);
border-radius: 14px; border-radius: 14px;
color: rgba(255,255,255,0.9); color: rgba(255,255,255,0.9);
font-size: 15px; font-size: 19px;
font-weight: 600; font-weight: 600;
margin-top: 4px; margin-top: 4px;
} }
@@ -395,16 +396,15 @@
justify-content: center; justify-content: center;
} }
.claim-left { .claim-left {
flex-direction: row; flex-direction: column;
flex-wrap: wrap;
gap: 16px; gap: 16px;
width: 100%; width: 100%;
align-items: flex-start; align-items: center;
} }
.claim-info-card { flex: 1; min-width: 200px; } .claim-info-card { width: 100%; max-width: 480px; }
.qr-container { flex-shrink: 0; } .qr-container { align-self: center; }
.items-card { width: 100%; max-height: 160px; } .items-card { width: 100%; max-width: 480px; max-height: 160px; }
.qr-container img { width: 140px; height: 140px; } .qr-container img { width: 160px; height: 160px; }
.divider { flex-direction: row; } .divider { flex-direction: row; }
.divider-line { width: 60px; height: 2px; } .divider-line { width: 60px; height: 2px; }

View File

@@ -392,6 +392,156 @@
} }
} }
</script> </script>
<!-- AI 추천 바텀시트 -->
<div id="rec-sheet" style="display:none;">
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
<!-- 드래그 핸들 영역 -->
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
</div>
<div style="padding:0 24px 32px;">
<div style="text-align:center;padding:8px 0 20px;">
<div style="font-size:48px;margin-bottom:16px;">💊</div>
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
</div>
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
<button onclick="dismissRec('dismissed')" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
<button onclick="dismissRec('interested')" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
</div>
</div>
</div>
</div>
<style>
@keyframes recFadeIn { from{opacity:0} to{opacity:1} }
@keyframes recSlideUp { from{transform:translate(-50%,100%)} to{transform:translate(-50%,0)} }
@keyframes recSlideDown { from{transform:translate(-50%,0)} to{transform:translate(-50%,100%)} }
</style>
<script>
let _recId = null;
// ── 드래그 닫기 ──
(function() {
let startY = 0, currentY = 0, isDragging = false;
const DISMISS_THRESHOLD = 80;
function getContent() { return document.getElementById('rec-content'); }
function getBackdrop() { return document.getElementById('rec-backdrop'); }
function onStart(y) {
const c = getContent();
if (!c) return;
isDragging = true;
startY = y;
currentY = 0;
c.style.animation = 'none';
c.style.transition = 'none';
}
function onMove(y) {
if (!isDragging) return;
const c = getContent();
const b = getBackdrop();
currentY = Math.max(0, y - startY); // 아래로만
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
// 배경 투명도도 같이
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
b.style.background = 'rgba(0,0,0,' + opacity + ')';
}
function onEnd() {
if (!isDragging) return;
isDragging = false;
const c = getContent();
if (currentY > DISMISS_THRESHOLD) {
// 충분히 내렸으면 닫기
c.style.transition = 'transform .25s ease';
c.style.transform = 'translate(-50%, 100%)';
getBackdrop().style.transition = 'opacity .25s';
getBackdrop().style.opacity = '0';
setTimeout(function() {
document.getElementById('rec-sheet').style.display = 'none';
c.style.transition = '';
c.style.transform = '';
}, 250);
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
} else {
// 복귀
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
c.style.transform = 'translate(-50%, 0)';
getBackdrop().style.transition = 'background .25s';
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
setTimeout(function() { c.style.transition = ''; }, 250);
}
}
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('rec-content');
if (!el) return;
// 터치 (모바일)
el.addEventListener('touchstart', function(e) {
onStart(e.touches[0].clientY);
}, {passive: true});
el.addEventListener('touchmove', function(e) {
if (isDragging && currentY > 0) e.preventDefault();
onMove(e.touches[0].clientY);
}, {passive: false});
el.addEventListener('touchend', onEnd);
// 마우스 (데스크톱 테스트용)
el.addEventListener('mousedown', function(e) {
if (e.target.tagName === 'BUTTON') return;
onStart(e.clientY);
});
document.addEventListener('mousemove', function(e) {
if (isDragging) onMove(e.clientY);
});
document.addEventListener('mouseup', onEnd);
});
})();
// ── 추천 로드 ──
window.addEventListener('load', function() {
{% if user_id %}
setTimeout(async function() {
try {
const res = await fetch('/api/recommendation/{{ user_id }}');
const data = await res.json();
if (data.success && data.has_recommendation) {
_recId = data.recommendation.id;
document.getElementById('rec-message').textContent = data.recommendation.message;
document.getElementById('rec-product').textContent = data.recommendation.product;
document.getElementById('rec-sheet').style.display = 'block';
document.getElementById('rec-backdrop').onclick = dismissRec;
}
} catch(e) {
console.error('[AI추천] 에러:', e);
}
}, 1500);
{% endif %}
});
function dismissRec(action) {
action = action || 'dismissed';
const c = document.getElementById('rec-content');
const b = document.getElementById('rec-backdrop');
c.style.transition = 'transform .3s ease';
c.style.transform = 'translate(-50%, 100%)';
b.style.opacity = '0';
b.style.transition = 'opacity .3s';
setTimeout(function(){
document.getElementById('rec-sheet').style.display='none';
c.style.transition = '';
c.style.transform = '';
}, 300);
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action: action})
}).catch(function(){});
}
</script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script> <script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{});}</script>
</body> </body>
</html> </html>

View File

@@ -115,8 +115,8 @@ def save_token_to_db(transaction_id, token_hash, total_amount, claimable_points,
- token_hash가 이미 존재하면 실패 (UNIQUE 제약) - token_hash가 이미 존재하면 실패 (UNIQUE 제약)
""" """
try: try:
db_manager = DatabaseManager() from db.dbsetup import db_manager as _db_manager
conn = db_manager.get_sqlite_connection() conn = _db_manager.get_sqlite_connection()
cursor = conn.cursor() cursor = conn.cursor()
# 중복 체크 (transaction_id) # 중복 체크 (transaction_id)

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

173
docs/ai-upselling-crm.md Normal file
View File

@@ -0,0 +1,173 @@
# AI 업셀링 CRM — 마이페이지 맞춤 추천 시스템
## 개요
키오스크 적립 시 고객 구매이력을 AI가 분석하여 맞춤 제품을 추천.
고객이 알림톡 → 마이페이지 접속 시 바텀시트 팝업으로 자연스럽게 표시.
## 기술 스택
- **AI 엔진**: Clawdbot Gateway (Claude Max 구독 재활용, 추가 비용 없음)
- **통신**: WebSocket (`ws://127.0.0.1:18789`) — JSON-RPC 프로토콜
- **저장소**: SQLite `ai_recommendations` 테이블
- **프론트**: 바텀시트 UI (드래그 닫기 지원)
## 전체 흐름
```
키오스크 적립 (POST /api/kiosk/claim)
├─ 1. 적립 처리 (기존)
├─ 2. 알림톡 발송 (기존)
└─ 3. AI 추천 생성 (fire-and-forget)
├─ 최근 구매 이력 수집 (SQLite + MSSQL SALE_SUB)
├─ Clawdbot Gateway → Claude 호출
├─ 추천 결과 → ai_recommendations 저장
└─ 실패 시 무시 (추천은 부가 기능)
고객: 알림톡 버튼 클릭 → /my-page
├─ 1.5초 후 GET /api/recommendation/{user_id}
├─ 추천 있음 → 바텀시트 슬라이드업
│ ├─ 아래로 드래그 → 닫기
│ ├─ "다음에요" → dismiss
│ └─ "관심있어요!" → dismiss + 기록
└─ 추천 없음 → 아무것도 안 뜸
```
## 핵심 파일
### `backend/services/clawdbot_client.py`
Clawdbot Gateway Python 클라이언트.
**Gateway WebSocket 프로토콜 (v3):**
1. WS 연결 → `ws://127.0.0.1:{port}`
2. 서버 → `connect.challenge` 이벤트 (nonce 전달)
3. 클라이언트 → `connect` 요청 (token + client info)
4. 서버 → connect 응답 (ok)
5. 클라이언트 → `agent` 요청 (message + systemPrompt)
6. 서버 → `accepted` ack → 최종 응답 (`payloads[].text`)
**주요 함수:**
| 함수 | 설명 |
|------|------|
| `_load_gateway_config()` | `~/.clawdbot/clawdbot.json`에서 port, token 읽기 |
| `_ask_gateway(message, ...)` | async WebSocket 통신 |
| `ask_clawdbot(message, ...)` | 동기 래퍼 (Flask에서 호출) |
| `generate_upsell(user_name, current_items, recent_products)` | 업셀 프롬프트 구성 + 호출 + JSON 파싱 |
| `_parse_upsell_response(text)` | AI 응답에서 JSON 추출 |
**Gateway 설정:**
- 설정 파일: `~/.clawdbot/clawdbot.json`
- Client ID: `gateway-client` (허용된 상수 중 하나)
- Protocol: v3 (minProtocol=3, maxProtocol=3)
### `backend/db/mileage_schema.sql` — ai_recommendations 테이블
```sql
CREATE TABLE IF NOT EXISTS ai_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
transaction_id VARCHAR(20),
recommended_product TEXT NOT NULL, -- "고려은단 비타민C 1000"
recommendation_message TEXT NOT NULL, -- 고객에게 보여줄 메시지
recommendation_reason TEXT, -- 내부용 추천 이유
trigger_products TEXT, -- JSON: 트리거된 구매 품목
ai_raw_response TEXT, -- AI 원본 응답
status VARCHAR(20) DEFAULT 'active', -- active/dismissed
displayed_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME, -- 7일 후 만료
displayed_at DATETIME,
dismissed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
### `backend/app.py` — API 엔드포인트
| 엔드포인트 | 메서드 | 설명 |
|-----------|--------|------|
| `/api/recommendation/<user_id>` | GET | 최신 active 추천 조회 (마이페이지용) |
| `/api/recommendation/<rec_id>/dismiss` | POST | 추천 닫기 (status→dismissed) |
**추천 생성 위치**: `api_kiosk_claim()` 함수 끝부분, `_generate_upsell_recommendation()` 호출
### `backend/templates/my_page.html` — 바텀시트 UI
**기능:**
- 페이지 로드 1.5초 후 추천 API fetch
- 💊 아이콘 + AI 메시지 + 제품명 배지 (보라색 그라디언트)
- **터치 드래그 닫기**: 아래로 80px 이상 드래그하면 dismiss
- 배경 탭 닫기, "다음에요"/"관심있어요!" 버튼
- 슬라이드업/다운 CSS 애니메이션
## AI 프롬프트
**시스템 프롬프트:**
```
당신은 동네 약국(청춘약국)의 친절한 약사입니다.
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
반드시 JSON 형식으로만 응답하세요.
```
**유저 프롬프트 구조:**
```
고객 이름: {name}
오늘 구매한 약: {current_items}
최근 구매 이력: {recent_products}
규칙:
1. 함께 먹으면 좋은 약 1가지만 추천 (일반의약품/건강기능식품)
2. 메시지 2문장 이내, 따뜻한 톤
3. JSON: {"product": "...", "reason": "...", "message": "..."}
```
**응답 예시:**
```json
{
"product": "고려은단 비타민C 1000",
"reason": "감기약 구매로 면역력 보충 필요",
"message": "김영빈님, 감기약 드시는 동안 비타민C도 함께 챙겨드시면 회복에 도움이 돼요."
}
```
## Fallback 정책
| 상황 | 동작 |
|------|------|
| Gateway 꺼져있음 | 추천 생성 스킵, 로그만 남김 |
| AI 응답 파싱 실패 | 저장 안 함 |
| 추천 없을 때 마이페이지 방문 | 바텀시트 안 뜸 |
| 7일 경과 | `expires_at` 만료, 조회 안 됨 |
| dismiss 후 재방문 | 같은 추천 안 뜸 (새 적립 시 새 추천 생성) |
## 테스트
```bash
# 1. Gateway 연결 테스트
PYTHONIOENCODING=utf-8 python -c "
from services.clawdbot_client import ask_clawdbot
print(ask_clawdbot('안녕'))
"
# 2. 업셀 생성 테스트
PYTHONIOENCODING=utf-8 python -c "
import json
from services.clawdbot_client import generate_upsell
result = generate_upsell('홍길동', '타이레놀, 챔프시럽', '비타민C, 소화제')
print(json.dumps(result, ensure_ascii=False, indent=2))
"
# 3. API 테스트
curl https://mile.0bin.in/api/recommendation/1
# 4. DB 확인
python -c "
import sqlite3, json
conn = sqlite3.connect('db/mileage.db')
conn.row_factory = sqlite3.Row
for r in conn.execute('SELECT * FROM ai_recommendations ORDER BY id DESC LIMIT 5'):
print(json.dumps(dict(r), ensure_ascii=False))
"
```

View File

@@ -0,0 +1,186 @@
# 알리미팜 세트 상품 구조 (PIT3000)
> 작성일: 2026-02-27
> 약국: 양구청춘약국
## 개요
PIT3000(팜잇3000) DB는 세트 상품을 기본적으로 잘 처리하지 못하게 설계되어 있다.
하지만 알리미팜에서는 세트 상품을 등록하고 **자체 바코드**를 생성하여 사용한다.
이 문서는 세트 상품의 DB 구조와 바코드 조회 방법을 정리한 참고 문서이다.
---
## 테이블 구조
### 1. CD_GOODS (기본 상품 테이블)
```
Database: PM_DRUG
Table: CD_GOODS
```
| 컬럼 | 설명 |
|------|------|
| DrugCode | 상품 코드 (PK) |
| GoodsName | 상품명 |
| BARCODE | **바코드** (세트상품은 대부분 비어있음!) |
| SplName | 공급업체 |
| Saleprice | 판매가 |
| Price | 매입가 |
⚠️ **주의**: 세트 상품의 경우 `BARCODE` 컬럼이 비어있는 경우가 많음!
---
### 2. CD_ITEM_UNIT_MEMBER (단위/바코드 확장 테이블) ⭐
```
Database: PM_DRUG
Table: CD_ITEM_UNIT_MEMBER
```
| 컬럼 | 설명 |
|------|------|
| DRUGCODE | 상품 코드 (FK → CD_GOODS.DrugCode) |
| CD_CD_UNIT | 단위 코드 |
| CD_NM_UNIT | 단위 수량 |
| CD_MY_UNIT | 판매가 |
| CD_IN_UNIT | 매입가 |
| **CD_CD_BARCODE** | **세트상품 바코드** ⭐ |
| CD_CD_POS | POS 코드 |
| CHANGE_DATE | 변경일 |
**핵심**: 세트 상품/자체 등록 상품의 바코드는 이 테이블의 `CD_CD_BARCODE`에 저장됨!
---
### 3. CD_item_set (세트 구성품 테이블)
```
Database: PM_DRUG
Table: CD_item_set
```
| 컬럼 | 설명 |
|------|------|
| SetCode | 세트 상품 코드 (FK → CD_GOODS.DrugCode) |
| DrugCode | 구성품 코드 ('SET0000' = 헤더, 그 외 = 구성품) |
| CD_NM_UNIT | 구성품 수량 |
**구조 예시 (투엑스벤포파워 LB000003181):**
```
SetCode | DrugCode | CD_NM_UNIT
--------------|---------------|------------
LB000003181 | SET0000 | NULL ← 세트 헤더
LB000003181 | LB000003324 | 1.0 ← 구성품 1
LB000003181 | LB000001423 | 1.0 ← 구성품 2 (벤포파워Z)
LB000003181 | LB000001412 | 1.0 ← 구성품 3
```
---
### 4. CD_item_pack / CD_ITEM_PACK_UNIT
```
Database: PM_DRUG
```
포장 단위 관련 테이블. 굿팜/알리미팜 처리 방식이 다를 수 있음.
---
## 바코드 조회 쿼리
### 세트 상품 바코드까지 포함한 조회
```sql
SELECT
S.DrugCode,
G.GoodsName,
-- CD_GOODS.BARCODE가 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
OUTER APPLY (
SELECT TOP 1 CD_CD_BARCODE
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = S.DrugCode
AND CD_CD_BARCODE IS NOT NULL
AND CD_CD_BARCODE != ''
) U
```
---
## 세트 상품 바코드 패턴
| 패턴 | 설명 |
|------|------|
| `999XXXXXXXXX` | 알리미팜 자체 생성 바코드 (세트/자체등록) |
| `880XXXXXXXXX` | 일반 제조사 바코드 |
예시:
- `9990000001101` - 투엑스벤포파워 (세트상품)
- `8806418067510` - 벤포파워Z (일반상품)
---
## ⚠️ 마진 계산 시 주의사항
### 세트 상품 마진 계산의 복잡성
세트 상품의 **실제 마진**을 계산하려면 **구성품을 물고 들어가서** 각 구성품의 매입가를 합산해야 한다!
```
세트 판매가: 9,000원
세트 매입가(CD_GOODS.Price): 3,300원 ← 이건 정확하지 않을 수 있음!
실제 계산 필요:
├── 구성품1 매입가: 1,500원
├── 구성품2 매입가: 1,200원
└── 구성품3 매입가: 800원
────────────────────
실제 매입가 합계: 3,500원
실제 마진: 9,000 - 3,500 = 5,500원
```
### 마진 계산 쿼리 예시 (향후 개발용)
```sql
-- 세트 상품의 실제 매입가 계산
SELECT
S.SetCode,
G1.GoodsName as set_name,
G1.Saleprice as set_sale_price,
SUM(G2.Price * S.CD_NM_UNIT) as actual_cost
FROM CD_item_set S
JOIN CD_GOODS G1 ON S.SetCode = G1.DrugCode
JOIN CD_GOODS G2 ON S.DrugCode = G2.DrugCode
WHERE S.DrugCode != 'SET0000' -- 헤더 제외
GROUP BY S.SetCode, G1.GoodsName, G1.Saleprice
```
---
## 관련 테이블 요약
| 테이블 | 데이터베이스 | 용도 |
|--------|-------------|------|
| CD_GOODS | PM_DRUG | 기본 상품 정보 |
| CD_ITEM_UNIT_MEMBER | PM_DRUG | 단위별 바코드 (세트 바코드 저장) |
| CD_item_set | PM_DRUG | 세트 구성품 매핑 |
| CD_item_pack | PM_DRUG | 포장 단위 |
| CD_BARCODE | PM_DRUG | 표준코드 매핑 |
| SALE_SUB | PM_PRES | 판매 상세 |
| SALE_MAIN | PM_PRES | 판매 헤더 |
---
## 히스토리
| 날짜 | 내용 |
|------|------|
| 2026-02-27 | 세트상품 바코드 조회 문제 해결 (`CD_ITEM_UNIT_MEMBER` 연동) |
| 2026-02-27 | 바코드 매핑률 89.8% → 99.8% 개선 |
---
## 참고
- PIT3000 DB 서버: `192.168.0.4\PM2014`
- 굿팜 vs 알리미팜: 세트 처리 방식이 다를 수 있음 (확인 필요)

View File

@@ -0,0 +1,342 @@
# Clawdbot Gateway WebSocket API 가이드
> 외부 애플리케이션에서 Clawdbot Gateway에 연결하여 AI 호출 또는 상태 조회하는 방법
## 개요
Clawdbot Gateway는 WebSocket API를 제공합니다. 이를 통해:
- **AI 호출** (`agent` 메서드) — Claude/GPT 등 모델에 질문 (토큰 소비)
- **상태 조회** (`sessions.list` 등) — 세션 정보 조회 (토큰 무소비)
- **세션 설정** (`sessions.patch`) — 모델 오버라이드 등
## 아키텍처
```
┌─────────────────┐ WebSocket ┌─────────────────┐
│ Flask 서버 │ ◄─────────────────► │ Clawdbot Gateway│
│ (pharmacy-pos) │ Port 18789 │ (localhost) │
└─────────────────┘ └────────┬────────┘
┌────────▼────────┐
│ Claude / GPT │
│ (Providers) │
└─────────────────┘
```
## 설정 파일 위치
Gateway 설정은 `~/.clawdbot/clawdbot.json`에 있음:
```json
{
"gateway": {
"port": 18789,
"auth": {
"mode": "token",
"token": "your-gateway-token"
}
}
}
```
---
## 연결 프로토콜 (Python)
### 1. 기본 연결 흐름
```python
import asyncio
import json
import uuid
import websockets
async def connect_to_gateway():
config = load_gateway_config() # ~/.clawdbot/clawdbot.json 읽기
url = f"ws://127.0.0.1:{config['port']}"
token = config['token']
async with websockets.connect(url) as ws:
# 1단계: challenge 수신
challenge = json.loads(await ws.recv())
# {'event': 'connect.challenge', 'payload': {'nonce': '...'}}
# 2단계: connect 요청
connect_frame = {
'type': 'req',
'id': str(uuid.uuid4()),
'method': 'connect',
'params': {
'minProtocol': 3,
'maxProtocol': 3,
'client': {
'id': 'gateway-client', # 고정값
'displayName': 'My App',
'version': '1.0.0',
'platform': 'win32',
'mode': 'backend', # 고정값
'instanceId': str(uuid.uuid4()),
},
'caps': [],
'auth': {'token': token},
'role': 'operator',
'scopes': ['operator.admin'], # 또는 ['operator.read']
}
}
await ws.send(json.dumps(connect_frame))
# 3단계: connect 응답 대기
while True:
msg = json.loads(await ws.recv())
if msg.get('id') == connect_frame['id']:
if msg.get('ok'):
print("연결 성공!")
break
else:
print(f"연결 실패: {msg.get('error')}")
return
# 이제 다른 메서드 호출 가능
# ...
```
### 2. 주의사항: client 파라미터
⚠️ **중요**: `client.id``client.mode`는 Gateway 스키마에 정의된 값만 허용됨
| 필드 | 허용되는 값 | 설명 |
|------|-------------|------|
| `client.id` | `'gateway-client'` | 백엔드 클라이언트용 |
| `client.mode` | `'backend'` | 백엔드 모드 |
| `role` | `'operator'` | 제어 클라이언트 |
| `scopes` | `['operator.admin']` 또는 `['operator.read']` | 권한 범위 |
잘못된 값 사용 시 에러:
```
invalid connect params: at /client/id: must be equal to constant
```
---
## 메서드 종류
### 토큰 소비 없는 메서드 (관리용)
| 메서드 | 용도 | 파라미터 |
|--------|------|----------|
| `sessions.list` | 세션 목록 조회 | `{limit: 10}` |
| `sessions.patch` | 세션 설정 변경 | `{key: '...', model: '...'}` |
### 토큰 소비하는 메서드 (AI 호출)
| 메서드 | 용도 | 파라미터 |
|--------|------|----------|
| `agent` | AI에게 질문 | `{message: '...', sessionId: '...'}` |
---
## 실제 구현 예제
### 예제 1: 상태 조회 (토큰 0)
```python
# services/clawdbot_client.py 참고
async def _get_gateway_status():
"""세션 목록 조회 — 토큰 소비 없음"""
# ... (연결 코드 생략)
# sessions.list 요청
list_frame = {
'type': 'req',
'id': str(uuid.uuid4()),
'method': 'sessions.list',
'params': {'limit': 10}
}
await ws.send(json.dumps(list_frame))
# 응답 대기
while True:
msg = json.loads(await ws.recv())
if msg.get('event'): # 이벤트는 무시
continue
if msg.get('id') == list_frame['id']:
return msg.get('payload', {})
```
**응답 예시:**
```json
{
"sessions": [
{
"key": "agent:main:main",
"totalTokens": 30072,
"contextTokens": 200000,
"model": "claude-opus-4-5"
}
],
"defaults": {
"model": "claude-opus-4-5",
"contextTokens": 200000
}
}
```
### 예제 2: AI 호출 (토큰 소비)
```python
async def ask_ai(message, session_id='my-session', model=None):
"""AI에게 질문 — 토큰 소비함"""
# ... (연결 코드)
# 모델 오버라이드 (선택)
if model:
patch_frame = {
'type': 'req',
'id': str(uuid.uuid4()),
'method': 'sessions.patch',
'params': {'key': session_id, 'model': model}
}
await ws.send(json.dumps(patch_frame))
# 응답 대기...
# agent 요청
agent_frame = {
'type': 'req',
'id': str(uuid.uuid4()),
'method': 'agent',
'params': {
'message': message,
'sessionId': session_id,
'sessionKey': session_id,
'timeout': 60,
}
}
await ws.send(json.dumps(agent_frame))
# 응답 대기 (accepted → final)
while True:
msg = json.loads(await ws.recv())
if msg.get('event'):
continue
if msg.get('id') == agent_frame['id']:
if msg.get('payload', {}).get('status') == 'accepted':
continue # 아직 처리 중
# 최종 응답
payloads = msg.get('payload', {}).get('result', {}).get('payloads', [])
return '\n'.join(p.get('text', '') for p in payloads)
```
### 예제 3: 모델 오버라이드
비싼 Opus 대신 저렴한 Sonnet 사용:
```python
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5'
response = await ask_ai(
message="추천 멘트 만들어줘",
session_id='upsell-customer1',
model=UPSELL_MODEL # Sonnet으로 오버라이드
)
```
---
## Flask API 엔드포인트 예제
```python
# app.py
@app.route('/api/claude-status')
def api_claude_status():
"""토큰 차감 없이 상태 조회"""
from services.clawdbot_client import get_claude_status
status = get_claude_status()
if not status.get('connected'):
return jsonify({'ok': False, 'error': status.get('error')}), 503
sessions = status.get('sessions', {})
# ... 데이터 가공
return jsonify({
'ok': True,
'context': {'used': 30000, 'max': 200000, 'percent': 15},
'model': 'claude-opus-4-5'
})
```
---
## 토큰 관리 전략
### 모델별 용도 분리
| 용도 | 모델 | 이유 |
|------|------|------|
| 메인 컨트롤러 | Claude Opus | 복잡한 추론, 도구 사용 |
| 단순 생성 (업셀링 등) | Claude Sonnet | 빠르고 저렴 |
| 코딩 작업 | GPT-5 Codex | 정식 지원, 안정적 |
### 세션 분리
```python
# 용도별 세션 ID 분리
ask_ai("...", session_id='upsell-고객명') # 업셀링 전용
ask_ai("...", session_id='analysis-daily') # 분석 전용
ask_ai("...", session_id='chat-main') # 일반 대화
```
---
## 트러블슈팅
### 1. "invalid connect params" 에러
```
at /client/id: must be equal to constant
at /client/mode: must be equal to constant
```
**해결**: `client.id``'gateway-client'`, `client.mode``'backend'` 사용
### 2. Gateway 연결 실패
```python
ConnectionRefusedError: [WinError 10061]
```
**해결**: Clawdbot Gateway가 실행 중인지 확인
```bash
clawdbot gateway status
```
### 3. CLI 명령어가 hang됨
Clawdbot 내부(agent 세션)에서 `clawdbot status` 같은 CLI 호출하면 충돌.
→ WebSocket API 직접 사용할 것
---
## 파일 위치
```
pharmacy-pos-qr-system/
└── backend/
└── services/
└── clawdbot_client.py # Gateway 클라이언트 구현
└── app.py # Flask API (/api/claude-status)
```
---
## 참고 자료
- Clawdbot 문서: `C:\Users\청춘약국\AppData\Roaming\npm\node_modules\clawdbot\docs\`
- Gateway 프로토콜: `docs/gateway/protocol.md`
- 설정 예제: `docs/gateway/configuration-examples.md`
---
*작성: 2026-02-27 | 용림 🐉*

View File

@@ -556,7 +556,3 @@ SELECT * FROM v_il1beta_increasing_foods;
4. **효과 추적**: 3개월 후 재검사 결과 비교 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,74 @@
# Windows 콘솔 한글 인코딩 (UTF-8) 가이드
## 문제
Windows 콘솔 기본 인코딩이 `cp949`여서 Python에서 한글 출력 시 깨짐 발생.
Claude Code bash 터미널, cmd, PowerShell 모두 동일 증상.
```
# 깨진 출력 예시
{"product": "<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD>", "message": "<22><EFBFBD><E8BFB5><EFBFBD>, ..."}
```
## 해결: 3단계 방어
### 1단계: Python 파일 상단 — sys.stdout UTF-8 래핑
```python
import sys
import os
if sys.platform == 'win32':
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
```
**적용 위치**: `app.py`, `clawdbot_client.py` 등 진입점 파일 맨 위 (import 전)
> 모듈로 import되는 파일은 `hasattr(sys.stdout, 'buffer')` 체크 추가:
> ```python
> if sys.platform == 'win32':
> import io
> if hasattr(sys.stdout, 'buffer'):
> sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
> sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
> ```
### 2단계: 환경변수 — PYTHONIOENCODING
```bash
# ~/.bashrc (Claude Code bash 세션)
export PYTHONIOENCODING=utf-8
```
또는 실행 시:
```bash
PYTHONIOENCODING=utf-8 python backend/app.py
```
### 3단계: json.dumps — ensure_ascii=False
```python
import json
data = {"product": "비타민C", "message": "추천드려요"}
print(json.dumps(data, ensure_ascii=False, indent=2))
```
`ensure_ascii=False` 없으면 `\uBE44\uD0C0\uBBFCC` 같은 유니코드 이스케이프로 출력됨.
## 프로젝트 내 적용 현황
| 파일 | 방식 |
|------|------|
| `backend/app.py` | sys.stdout 래핑 + PYTHONIOENCODING |
| `backend/services/clawdbot_client.py` | sys.stdout 래핑 (buffer 체크) |
| `backend/ai_tag_products.py` | sys.stdout 래핑 |
| `backend/view_products.py` | sys.stdout 래핑 |
| `backend/import_il1beta_foods.py` | sys.stdout 래핑 |
| `backend/import_products_from_mssql.py` | sys.stdout 래핑 |
| `backend/update_product_category.py` | sys.stdout 래핑 |
| `backend/gui/check_cash.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
| `backend/gui/check_sunab.py` | `sys.stdout.reconfigure(encoding='utf-8')` |
| `~/.bashrc` | `export PYTHONIOENCODING=utf-8` |
## 주의사항
- Flask 로거(`logging.info()` 등)도 stderr로 출력하므로 **stderr도 반드시 래핑**
- `io.TextIOWrapper`는 이미 래핑된 스트림에 중복 적용하면 에러남 → `hasattr(sys.stdout, 'buffer')` 체크
- PyQt GUI에서는 stdout이 다를 수 있음 → `hasattr` 가드 필수

View File

@@ -1,22 +1,217 @@
# PIT3000 제/수납/할인 데이터 구조 # PIT3000 판매/조제/수납 데이터 구조
## 핵심 테이블 관계 ## 핵심 테이블 관계
``` ```
SALE_MAIN (판매) CD_SUNAB (수납/결제) ─── 모든 거래의 결제 기록 (130건/일 기준)
└── SL_NO_order (PK, 주문번호)
├── PS_main (처방접수) ─── 조제 건만 (89건/일 기준)
│ │ 조인: PS_main.PreSerial = CD_SUNAB.PRESERIAL
│ │ 조인: PS_main.Indate = CD_SUNAB.INDATE
│ │
│ ├── PS_sub_hosp (처방 의약품 상세)
│ └── PS_sub_pharm (조제 의약품 상세)
└── SALE_MAIN (OTC 판매) ─── OTC 직접 판매만 (39건/일 기준)
│ 조인: SALE_MAIN.SL_NO_order = CD_SUNAB.PRESERIAL
── SALE_SUB (품목 상세) SL_NO_order로 조인 ── SALE_SUB (판매 품목 상세) ─── SL_NO_order로 조인
└── CD_SUNAB (수납/결제) — CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order
``` ```
**주의**: `CD_SUNAB.PRESERIAL``SALE_MAIN.SL_NO_order`(주문번호)와 매칭됨. ## 테이블별 역할
`SALE_MAIN.PRESERIAL`(처방번호)과는 다른 키임.
### 1. CD_SUNAB — 수납/결제 (모든 거래 포함)
- **역할**: 조제 + OTC 모든 거래의 결제/수납 기록
- **1주문 = 1행** (복수행 없음)
- **키**: `PRESERIAL` (주문번호), `INDATE` (수납일)
- **건수**: 하루 약 130건 (조제 91 + OTC 39)
| 컬럼 | 설명 |
|------|------|
| `PRESERIAL` | 주문번호 (PS_main.PreSerial 또는 SALE_MAIN.SL_NO_order와 매칭) |
| `INDATE` | 수납일 (YYYYMMDD) |
| `DAY_SERIAL` | 일련번호 |
| `CUSCODE` | 고객코드 |
| `ETC_CARD` | 조제 카드결제 금액 |
| `ETC_CASH` | 조제 현금결제 금액 |
| `ETC_PAPER` | 조제 외상 금액 |
| `OTC_CARD` | 일반약 카드결제 금액 |
| `OTC_CASH` | 일반약 현금결제 금액 |
| `OTC_PAPER` | 일반약 외상 금액 |
| `pAPPROVAL_NUM` | 카드 승인번호 |
| `pMCHDATA` | 카드사 이름 |
| `pCARDINMODE` | 카드 입력방식 (1=IC칩) |
| `pTRDTYPE` | 거래유형 (D1=일반승인) |
| `nCASHINMODE` | 현금영수증 모드 (1=발행, 2=카드거래 자동세팅) |
| `nAPPROVAL_NUM` | 현금영수증 승인번호 |
| `Appr_Gubun` | 승인구분 (1, 2, 9 등) |
| `APPR_DATE` | 승인일시 (YYYYMMDDHHmmss) |
| `DaeRiSunab` | 대리수납 여부 |
| `YOHUDATE` | 요후일 |
| 총 **54개 컬럼** | |
### 2. PS_main — 처방전 접수 (조제 전용)
- **역할**: 처방전 기반 조제 접수 기록
- **키**: `PreSerial` (처방번호 = CD_SUNAB.PRESERIAL)
- **건수**: 하루 약 89건
- **SALE_MAIN에는 없음** — 조제건은 SALE_MAIN을 거치지 않음
| 컬럼 | 설명 |
|------|------|
| `PreSerial` | 처방번호 (= CD_SUNAB.PRESERIAL) |
| `Day_Serial` | 일일 접수 순번 (1~89) |
| `Indate` | 접수일 (YYYYMMDD) |
| `CusCode` | 환자 코드 |
| `Paname` | 환자명 |
| `PaNum` | 주민번호 |
| `InsName` | 보험구분 (건강보험, 의료급여 등) |
| `OrderName` | 의료기관명 |
| `Drname` | 처방의사명 |
| `PresTime` | 접수 시간 |
| `PRICE_T` | 총금액 |
| `PRICE_P` | 본인부담금 |
| `PRICE_C` | 보험자부담금 |
| `Pre_State` | 처방 상태 |
| `InsertTime` | 입력 시간 |
| 총 **58개 컬럼** | |
### 3. SALE_MAIN — OTC 직접 판매
- **역할**: 일반의약품(OTC) 직접 판매 기록
- **키**: `SL_NO_order` (주문번호 = CD_SUNAB.PRESERIAL)
- **건수**: 하루 약 39건
- **조제건은 포함되지 않음**
| 컬럼 | 설명 |
|------|------|
| `SL_NO_order` | 주문번호 (= CD_SUNAB.PRESERIAL) |
| `SL_DT_appl` | 판매일 (YYYYMMDD) |
| `SL_NM_custom` | 고객명 (대부분 빈값 → `[비고객]`) |
| `SL_MY_total` | 원가 (할인 전) |
| `SL_MY_discount` | 할인 금액 |
| `SL_MY_sale` | 실판매가 (= total - discount) |
| `InsertTime` | 입력 시간 |
| `PRESERIAL` | 처방번호 (OTC는 'V' 고정, 의미 없음) |
| 총 **30개 컬럼** | |
--- ---
## SALE_MAIN 금액 컬럼 ## 데이터 흐름 정리
### 조제 (처방전 기반)
```
처방전 접수 → PS_main 생성 → 조제 → CD_SUNAB 수납 기록
(ETC_CARD/ETC_CASH에 금액)
```
- SALE_MAIN에는 **기록되지 않음**
- SALE_SUB에도 품목이 **들어가지 않음**
- 환자명은 PS_main.Paname에 있음
### OTC 판매 (직접 판매)
```
POS에서 품목 선택 → SALE_MAIN + SALE_SUB 생성 → CD_SUNAB 수납 기록
(OTC_CARD/OTC_CASH에 금액)
```
- PS_main에는 **기록되지 않음**
- 고객명은 보통 빈값 (`[비고객]`)
### 조제 + OTC 동시 (하루 약 10건)
```
처방전 조제 + 일반약 동시 구매
→ PS_main (조제 부분)
→ SALE_MAIN + SALE_SUB (OTC 부분)
→ CD_SUNAB 1행에 ETC + OTC 금액 모두 기록
```
---
## 조인 키 관계
```
CD_SUNAB.PRESERIAL = PS_main.PreSerial (조제건)
CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (OTC건)
```
**주의**: `SALE_MAIN.PRESERIAL`은 OTC에서 항상 `'V'`로, 조인키가 아님.
실제 조인키는 `SALE_MAIN.SL_NO_order`임.
---
## 건수 관계 (2025-02-25 기준)
| 구분 | 건수 | 설명 |
|------|------|------|
| CD_SUNAB | 130 | 모든 수납 기록 |
| PS_main | 89 | 처방전 접수 (= 조제) |
| SALE_MAIN | 39 | OTC 직접 판매 |
| CD_SUNAB에만 존재 | 91 | 조제건 (SALE_MAIN 없음) |
| PS_main 매칭 | 89 | 91건 중 PS_main과 매칭 |
| 미매칭 | 2 | PS_main 없이 수납만 존재 (미수금 수납 등 특수 케이스) |
### 130건 = 39 (OTC) + 89 (조제) + 2 (특수)
---
## 조제/OTC 구분 방법
CD_SUNAB의 ETC/OTC 금액으로 판별:
```python
etc_total = ETC_CARD + ETC_CASH # 조제 금액
otc_total = OTC_CARD + OTC_CASH # 일반약 금액
if etc_total > 0 and otc_total > 0:
구분 = "조제+판매"
elif etc_total > 0:
구분 = "조제"
elif otc_total > 0:
구분 = "판매(OTC)"
else:
구분 = "본인부담금 없음" # 건강보험 전액 부담
```
---
## 결제수단 판별
```python
card_total = ETC_CARD + OTC_CARD
cash_total = ETC_CASH + OTC_CASH
# 현금영수증 판별 (nCASHINMODE=2는 카드거래 자동세팅이므로 제외)
has_cash_receipt = (nCASHINMODE == '1' and nAPPROVAL_NUM != '')
if card_total > 0 and cash_total > 0:
결제 = "카드+현금"
elif card_total > 0:
결제 = "카드"
elif cash_total > 0:
결제 = "현영" if has_cash_receipt else "현금"
else:
결제 = "-"
```
---
## GUI 표시 색상
### 결제 컬럼
- **카드**: 파란색 (#1976D2)
- **현영**: 청록색 볼드 (#00897B) — 현금영수증 발행
- **현금**: 주황색 (#E65100) — 현금영수증 미발행
- **카드+현금**: 보라색 (#7B1FA2)
- **-**: 회색 (수납 없음)
### 수납 컬럼
- **✓**: 녹색 (#4CAF50)
- **-**: 회색 (미수납)
### 할인 표시
- 할인 없음: `12,000원`
- 할인 있음: `54,000원 (-6,000)` 주황색 볼드 + 툴팁
---
## SALE_MAIN 금액 컬럼 상세
| 컬럼 | 설명 | 예시 | | 컬럼 | 설명 | 예시 |
|------|------|------| |------|------|------|
@@ -40,30 +235,7 @@ SL_MY_recive ≈ SL_MY_sale / 1.1 (부가세 제외 금액 추정)
--- ---
## CD_SUNAB 결제수단 컬럼 ## CD_SUNAB 카드/현금 상세 컬럼
### 금액 기반 결제수단 구분
단일 구분 컬럼이 없음. **금액이 0보다 크면 해당 결제수단 사용**.
| 구분 | 카드 | 현금 | 외상 |
|------|------|------|------|
| 조제(ETC, 전문의약품) | `ETC_CARD` | `ETC_CASH` | `ETC_PAPER` |
| OTC(일반의약품) | `OTC_CARD` | `OTC_CASH` | `OTC_PAPER` |
### 결제수단 판별 로직
```python
card_total = ETC_CARD + OTC_CARD
cash_total = ETC_CASH + OTC_CASH
if card_total > 0 and cash_total > 0:
결제수단 = "카드+현금"
elif card_total > 0:
결제수단 = "카드"
elif cash_total > 0:
결제수단 = "현금"
else:
결제수단 = "-" (미수납 또는 외상)
```
### 카드 상세 정보 ### 카드 상세 정보
| 컬럼 | 설명 | 예시 | | 컬럼 | 설명 | 예시 |
@@ -79,32 +251,13 @@ else:
### 현금 상세 정보 ### 현금 상세 정보
| 컬럼 | 설명 | 예시 | | 컬럼 | 설명 | 예시 |
|------|------|------| |------|------|------|
| `nCASHINMODE` | 현금영수증 입력 방식 | 1, 2 (빈값=미발행) | | `nCASHINMODE` | 현금영수증 입력 방식 | 1=실제발행, 2=카드거래 자동세팅 |
| `nAPPROVAL_NUM` | 현금영수증 승인번호 | | | `nAPPROVAL_NUM` | 현금영수증 승인번호 | 116624870 |
| `nCHK_GUBUN` | 현금 체크 구분 | TASA | | `nCHK_GUBUN` | 현금 체크 구분 | KOV, TASA |
--- ---
## GUI 표시 방식 ## SQL 쿼리 (현재 GUI에서 사용)
### 결제 컬럼
- **카드**: 파란색 (#1976D2)
- **현금**: 주황색 (#E65100)
- **카드+현금**: 보라색 (#7B1FA2)
- **-**: 회색 (수납 정보 없음)
### 수납 컬럼
- **✓**: 녹색 (card + cash > 0)
- **-**: 회색 (미수납)
### 할인 표시
- 할인 없는 건: `12,000원` (기본)
- 할인 있는 건: `54,000원 (-6,000)` 주황색 볼드
- 마우스 툴팁: 원가 / 할인 / 결제 상세
---
## SQL 쿼리 (GUI에서 사용)
```sql ```sql
SELECT SELECT
@@ -115,12 +268,16 @@ SELECT
ISNULL(S.card_total, 0) AS card_total, ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total, ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount, ISNULL(M.SL_MY_total, 0) AS total_amount,
ISNULL(M.SL_MY_discount, 0) AS discount ISNULL(M.SL_MY_discount, 0) AS discount,
S.cash_receipt_mode,
S.cash_receipt_num
FROM SALE_MAIN M FROM SALE_MAIN M
OUTER APPLY ( OUTER APPLY (
SELECT TOP 1 SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total, ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
nCASHINMODE AS cash_receipt_mode,
nAPPROVAL_NUM AS cash_receipt_num
FROM CD_SUNAB FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order WHERE PRESERIAL = M.SL_NO_order
) S ) S
@@ -128,6 +285,10 @@ WHERE M.SL_DT_appl = ?
ORDER BY M.InsertTime DESC ORDER BY M.InsertTime DESC
``` ```
**한계**: SALE_MAIN 기준이므로 OTC 판매(39건)만 표시됨.
조제건(~89건)은 표시되지 않음. 조제건까지 보려면 CD_SUNAB을
기본 테이블로 사용하거나 PS_main과 조인하는 쿼리 재설계 필요.
--- ---
## 카드사 분포 (전체 데이터 기준) ## 카드사 분포 (전체 데이터 기준)

91
docs/실행구조.md Normal file
View File

@@ -0,0 +1,91 @@
# 청춘약국 마일리지 시스템 — 실행 구조
## 실행해야 할 프로그램 (2개)
### 1. Flask 서버 (`backend/app.py`)
```bash
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
python backend/app.py
```
- **포트**: 7001 (0.0.0.0)
- **외부 도메인**: `mile.0bin.in` (→ 내부 7001 포트로 프록시)
- **역할**: 웹 서비스 전체 담당
#### 제공하는 페이지/API
| 경로 | 설명 |
|------|------|
| `/` | 메인 페이지 |
| `/signup` | 회원가입 |
| `/claim` | QR 적립 (폰번호 방식) |
| `/claim/kakao/start` | QR 적립 (카카오 로그인) |
| `/my-page` | 마이페이지 |
| `/kiosk` | **키오스크 대기 화면** (약국 내 태블릿) |
| `/admin` | 관리자 페이지 |
| `/admin/transaction/<id>` | 거래 상세 |
| `/admin/user/<id>` | 회원 상세 |
| `/admin/search/user` | 회원 검색 |
| `/admin/search/product` | 상품 검색 |
| `/api/kiosk/trigger` | 키오스크 QR 트리거 (POST) |
| `/api/kiosk/current` | 키오스크 현재 상태 |
| `/api/kiosk/claim` | 키오스크 적립 처리 (POST) |
#### 사용하는 DB
- **SQLite** (`backend/db/mileage.db`) — 회원, 적립, QR 토큰
- **MSSQL** (`192.168.0.4\PM2014`, DB: `PM_PRES`) — POS 판매 데이터 (읽기 전용)
---
### 2. Qt POS GUI (`backend/gui/pos_sales_gui.py`)
```bash
cd c:\Users\청춘약국\source\pharmacy-pos-qr-system
python backend/gui/pos_sales_gui.py
```
- **역할**: POS 판매 내역 조회 + QR 라벨 발행
- **PyQt5 기반** 데스크톱 앱
- Flask 서버와 **독립적으로 실행** (별도 프로세스)
#### 주요 기능
- 일자별 판매 내역 조회 (SALE_MAIN + CD_SUNAB)
- 결제수단 표시 (카드/현금/현영)
- 할인 표시
- QR 라벨 프린터 출력 (Zebra / POS 프린터)
- 적립자 클릭 → 회원 적립 내역 팝업
#### 사용하는 DB
- **MSSQL** — SALE_MAIN, SALE_SUB, CD_SUNAB 조회
- **SQLite** — claim_tokens, users 조회 (적립 정보)
---
## 실행 순서
```
1. Flask 서버 먼저 실행 (키오스크, 웹 서비스 제공)
2. Qt POS GUI 실행 (판매 내역 조회, QR 발행)
```
순서는 상관없으나, Flask가 먼저 떠 있어야 키오스크(`mile.0bin.in/kiosk`)와
웹 서비스(`mile.0bin.in`)가 접속 가능.
---
## 프로세스 확인
```bash
# 실행 중인 Python 프로세스 확인
tasklist /FI "IMAGENAME eq python.exe"
# 정상 상태: Python 프로세스 3개
# - Flask 서버 (메인)
# - Flask 서버 (debug reloader 워커)
# - Qt POS GUI
```
---
## 주의사항
- `taskkill /F /IM python.exe` 사용 시 **Flask + GUI 모두 종료됨**
- GUI만 재시작하려면 해당 PID만 종료할 것
- Flask 서버는 `debug=True`로 실행되어 코드 변경 시 자동 리로드
- Python 경로: `C:\Users\청춘약국\AppData\Local\Programs\Python\Python312\python.exe`

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