Compare commits

...

111 Commits

Author SHA1 Message Date
thug0bin
f3b6496c91 docs: PAAI 시스템 아키텍처 문서
- 시스템 개요 및 데이터 흐름
- API 엔드포인트 정리
- Clawdbot 연동 방법
- 트러블슈팅 가이드
2026-03-05 09:30:39 +09:00
thug0bin
16c3881661 feat: PAAI 어드민 대시보드 페이지
- PAAI 분석 로그 목록 조회
- 필터링 (상태, 날짜, 심각도)
- 로그 상세 보기 모달
- 피드백 통계 (일별)
2026-03-05 09:30:34 +09:00
thug0bin
59a55d6b22 refactor: PAAI Clawdbot 호출 방식 개선
- HTTP API → WebSocket Gateway 방식으로 변경
- clawdbot_client.py의 ask_clawdbot() 함수 활용
- 시스템 프롬프트 분리
2026-03-05 09:30:22 +09:00
thug0bin
4275689c29 feat: 처방감지 WebSocket 클라이언트 통합 + days 버그 수정
- WebSocket 클라이언트 추가 (ws://localhost:8765)
- 처방 감지 시 자동 토스트 알림 (누적 표시)
- 연결 상태 표시 (자동감지 ON/OFF)
- fix: med.days → med.duration 필드명 수정 (복용일수 0 버그)
2026-03-05 09:30:07 +09:00
thug0bin
1b33f82fd4 feat: PAAI (Pharmacist Assistant AI) 기능 구현
- PAAI 로그 테이블 스키마 (paai_logs_schema.sql)
- PAAI 로거 모듈 (db/paai_logger.py)
- /pmr/api/paai/analyze API 엔드포인트
- KIMS API 연동 (KD코드 기반 상호작용 조회)
- Clawdbot AI 연동 (HTTP API)
- PMR 화면 PAAI 버튼 및 모달
- Admin 페이지 (/admin/paai)
- 피드백 수집 기능
2026-03-05 00:36:51 +09:00
thug0bin
141b211f07 fix: OTC 모달 이미지 placeholder 스타일 개선
- 그라데이션 배경 + 점선 테두리
- SVG 이미지 아이콘 사용
- admin_products.html 스타일 통일
2026-03-05 00:00:40 +09:00
thug0bin
088d88878a feat: OTC 모달에 제품 썸네일 이미지 표시
- product_images.db에서 thumbnail_base64 조회
- drug_code로 이미지 매칭
- 이미지 없으면 💊 placeholder
2026-03-04 23:59:13 +09:00
thug0bin
ebf2e8a016 feat: PMR OTC 구매 이력 기능
- /pmr/api/patient/<cus_code>/otc: OTC 구매 이력 API
- SALE_MAIN + SALE_SUB (PRESERIAL='V' = OTC)
- 💊 OTC 뱃지 클릭 → 모달로 구매 이력 표시
- 자주 구매하는 품목 요약
- 방문/금액 통계
2026-03-04 23:55:54 +09:00
thug0bin
41428646ab fix: 질병 뱃지 스타일 분리 2026-03-04 23:48:14 +09:00
thug0bin
4fc667b844 fix: 질병 뱃지 개별 표시 2026-03-04 23:47:11 +09:00
thug0bin
7928bbd55c feat: PMR 질병정보 표시
- PS_MAIN.St1, St2 + PM_BASE.CD_SANG JOIN
- 처방 헤더에 🩺 질병명 표시
- disease_info: {code_1, code_2, name_1, name_2}
2026-03-04 23:46:02 +09:00
thug0bin
d8aa073564 feat: PMR 처방 비교 기능
- 비교 모드 토글 체크박스 추가
- 상태 분류: 🆕추가 / 🔄변경 / 중단 / ✓동일
- 변경된 값: '1정 → 2정' 형태로 표시
- 색상 코딩: 녹색(추가), 노랑(변경), 빨강(중단)
- 이전 처방 < > 네비게이션 시 자동 비교
2026-03-04 23:40:09 +09:00
thug0bin
6192f635ca feat: PMR 이전 처방 비교 기능
- /pmr/api/patient/<cus_code>/history: 환자 이전 처방 이력 API
- 하단에 이전 처방 영역 + < > 네비게이션
- 현재 처방과 이전 처방 한눈에 비교 가능
2026-03-04 23:35:11 +09:00
thug0bin
fc2db78816 feat: PMR 효능효과(add_info) 추가
- CD_MC.PRINT_TYPE JOIN으로 효능 조회
- 약품 테이블에 효능 표시
- 라벨 미리보기에 효능 포함
2026-03-04 22:56:28 +09:00
thug0bin
c21aa956da feat: PMR 라벨 미리보기 기능
- /pmr/api/label/preview: PIL 렌더링 → Base64 이미지
- 미리보기 버튼 + 모달 추가
- 29mm 용지 기준 라벨 이미지 생성
2026-03-04 22:52:42 +09:00
thug0bin
8d025457c0 fix: PMR 약품명 조회 - PM_DRUG.CD_GOODS JOIN
- PS_sub_pharm.DrugCode → PM_DRUG.CD_GOODS.GoodsName
- 용량(QUAN), 횟수(QUAN_TIME), 일수(Days) 매핑
2026-03-04 22:48:35 +09:00
thug0bin
75448ffdc5 feat: PMR 조제관리 - MSSQL(PharmaIT3000) 연동
- pmr_api.py: 192.168.0.4 MSSQL 연결
- /pmr/api/prescriptions: 일별 처방전 목록
- /pmr/api/prescription/<id>: 처방전 상세
- /pmr/api/stats: 당일 통계
- /pmr/api/test: DB 연결 테스트
- pmr.html: API 엔드포인트 수정
2026-03-04 22:44:54 +09:00
thug0bin
1054a9ed17 feat: 용법용량 체중별 투여량 HTML 테이블 렌더링
- 표 형식(─ 문자) 감지 시 HTML <table> 변환
- 파란색 헤더, 굵은 숫자 스타일
- 미리보기 API에서 strip_html 개선 (표 형식 줄바꿈 유지)
2026-03-04 21:02:45 +09:00
thug0bin
71c35433fc fix: 프론트엔드 모달 순서도 변경 - 주의사항 마지막으로 2026-03-04 20:54:20 +09:00
thug0bin
836be958db fix: 인쇄 순서 변경 - 주의사항을 마지막으로
용법용량 → 투약주기 → 함께투약 권장 → 주의사항
2026-03-04 20:51:16 +09:00
thug0bin
f829276431 feat: 인쇄에 투약주기/병용약 추가 + 인쇄 피드백 개선
- 인쇄 API: component_guide JOIN 추가
- 영수증에 ★ 투약 주기 ★ / ★ 함께 투약 권장 ★ 섹션 추가
- 인쇄 버튼: 로딩 중 → 인쇄 완료! 피드백
- 이모지 대신 ★ 사용 (프린터 호환)
2026-03-04 20:48:47 +09:00
thug0bin
9ff25dcbce feat: Phase 2 - 성분코드 기반 투약주기/병용약 JOIN 구현
- component_guide 테이블 생성 (PostgreSQL)
- IC2030126 (메벤다졸+프라지퀸텔) 샘플 데이터 입력
- 미리보기 API: apc + component_guide LEFT JOIN
- 모달에 투약주기, 병용약 섹션 추가 (보라색/녹색 강조)
2026-03-04 20:44:37 +09:00
thug0bin
4352a8b9a8 fix: 투약지도서 글자수 제한 해제
- 효능효과/용법용량/주의사항 전체 출력
- 롤 용지라 길이 제한 없음
2026-03-04 20:08:25 +09:00
thug0bin
5a2ab044ba feat: 투약지도서 표 형식 출력 지원
- 체중/투여정수 표(─ 문자 포함) 감지
- 표 형식일 때 공백/정렬 유지
- 안텔민 킹정 등 체중별 투여량 표 정상 출력
2026-03-04 19:58:15 +09:00
thug0bin
a89dc9b354 fix: '애니팜 투약지도서' 중앙 정렬 수정 2026-03-04 19:38:41 +09:00
thug0bin
27da568a13 fix: 투약 지도서 인쇄 개선
- 이모지(🐾) 제거 → 프린터 호환성
- '동물약 안내서' → '투약 지도서'로 변경
- 청춘약국/전화번호 중앙 정렬 수정 (48자 기준)
2026-03-04 19:35:48 +09:00
thug0bin
abb8ad1325 feat: 동물약 안내서 항목별 줄바꿈 처리
- 가. 나. 다. 라. 등 항목 앞에 줄바꿈 추가
- 1) 2) 3) 등 번호 앞에 들여쓰기 + 줄바꿈
- 미리보기/인쇄 모두 적용
- white-space: pre-line으로 줄바꿈 표시
- 80mm 프린터 출력에 최적화
2026-03-04 19:33:00 +09:00
thug0bin
f374ca4fd1 fix: 동물약 안내서 모달 - 효능효과/용법용량/주의사항 배경색 추가
- 효능효과: 연녹색 (#f0fdf4)
- 용법용량: 연파랑 (#eff6ff)
- 주의사항: 연빨강 (#fef2f2)
- 각 섹션에 padding, border-radius 추가
2026-03-04 19:26:50 +09:00
thug0bin
e2d3ea032f fix: 동물약 안내서 모달 스타일 수정 - modal-box 클래스 사용 2026-03-04 19:23:42 +09:00
thug0bin
097bc4c84f fix: 동물약 안내서 인쇄를 네트워크 프린터로 변경
- USB 프린터에서 네트워크 프린터(192.168.0.174:9100)로 변경
- pos_printer.print_text() 함수 사용 (특이사항 인쇄와 동일 방식)
- 80mm 프린터 기준 48자 레이아웃
2026-03-04 19:20:26 +09:00
thug0bin
321fd0de1e feat: 동물약 안내서 기능 추가
- 동물약 뱃지 클릭 시 약품 정보 모달 표시
- APC 코드로 PostgreSQL 조회 (효능효과, 용법용량, 주의사항)
- HTML 태그 파싱하여 텍스트 표시
- ESC/POS 인쇄 API 준비 (프린터 연결 시 활성화)
- 미리보기 API: /api/animal-drug-info/preview
- 인쇄 API: /api/animal-drug-info/print
2026-03-04 19:18:10 +09:00
thug0bin
77c667e1f6 feat: 단위바코드 갯수 뱃지 + QR 바코드 우선순위 수정
화면 표시:
- 대표바코드 옆 빨간 뱃지로 단위바코드 갯수 표시 (카톡 스타일)
- APC 없어도 단위바코드 있으면 POS 판매 가능함을 표시

QR 인쇄 우선순위:
1. 대표바코드 (있으면)
2. 단위바코드 첫 번째 (대표 없으면)
3. drug_code (fallback)

쿼리 추가:
- UNIT_FIRST: 단위바코드 첫 번째 (조건 없이)
- UNIT_CNT: 단위바코드 갯수
2026-03-04 16:29:23 +09:00
thug0bin
1c2bfd473b fix: APC에 바코드 대체 표시 버그 수정
- apc_code는 02%로 시작하는 것만 표시
- 바코드를 APC로 대체하는 로직 제거
- PostgreSQL 조회용 _pg_apc 별도 유지
2026-03-04 16:02:55 +09:00
thug0bin
6bb86f8780 feat: 동물약 바코드/APC 2줄 표시
- 대표바코드(CD_GOODS.BARCODE)만 표시 (없으면 '없음')
- APC: 02로 시작하는 단위바코드 별도 표시
- APC 없으면 'APC미지정' 빨간 점선 뱃지
- 동물약만 체크 시에만 2줄 표시 (일반약품은 1줄)
- 헤더: '바코드/APC'
2026-03-04 15:55:04 +09:00
thug0bin
e95c08ef59 fix: 사용약품만 쿼리에서 단위바코드 조회 누락 수정
문제: 대표바코드(CD_GOODS.BARCODE)가 비어있으면 '없음'으로 표시
원인: in_stock_only 쿼리에 CD_ITEM_UNIT_MEMBER OUTER APPLY 누락

수정:
- in_stock_only 쿼리에 단위바코드 조회 추가
- animal_only 쿼리에도 동일하게 추가
- COALESCE(대표바코드, 단위바코드, '') 순서로 조회
2026-03-04 15:27:50 +09:00
thug0bin
27bb0b7b86 feat: 위치 편집 기능 추가
API:
- GET /api/locations - 모든 위치 목록 (461개)
- PUT /api/drugs/<code>/location - 위치 업데이트/삭제

UI:
- 위치 있음: 노란색 뱃지 (클릭 가능)
- 위치 없음: '미지정' 회색 점선 뱃지
- 클릭 시 위치 설정 모달 열림
- 드롭다운 선택 또는 직접 입력
- person-lookup-web-local 참고하여 구현
2026-03-04 14:42:47 +09:00
thug0bin
96a3df8470 feat: 제품 검색에 위치 컬럼 추가 (인코딩 수정)
- CD_item_position.CD_NM_sale 조회
- Edit 도구로만 수정하여 인코딩 유지
- 위치 뱃지 스타일 (노란색 배경)
2026-03-04 14:31:21 +09:00
thug0bin
e7096f7bed feat: 제품 검색에 위치 컬럼 추가
- CD_item_position.CD_NM_sale 조회 (person-lookup-web-local 참고)
- 3개 쿼리 모두 LEFT JOIN CD_item_position 추가
- 위치 뱃지 스타일 (노란색 배경)
2026-03-04 14:28:41 +09:00
thug0bin
01f0df9294 feat: 제품 검색 페이지에 제품 이미지 표시 및 등록 기능 추가
- API에 thumbnail 반환 추가 (product_images.db 조회)
- 테이블에 이미지 컬럼 추가 (40x40 썸네일)
- 이미지/플레이스홀더 클릭 → 등록 모달 (URL/촬영)
- 판매내역과 동일한 UX
2026-03-04 14:15:29 +09:00
thug0bin
2859dc43cc feat: 동물약만 체크 시 검색어 없이 전체 조회 가능
- 동물약은 39건뿐이라 전체 조회해도 빠름
- 동물약만 체크 + 검색어 없음 → 전체 동물약 리스트
- 쿼리 조건 동적 생성 (animal_condition, search_condition)
2026-03-04 14:02:47 +09:00
thug0bin
a0cbb984e5 perf: 제품 검색 최적화 - 사용약품만 옵션 추가
문제: 전체 CD_GOODS 검색 시 178,232건 스캔 + OUTER APPLY → 6-14초 소요

해결:
- '사용약품만' 체크박스 추가 (기본 활성화)
- IM_total INNER JOIN으로 재고 있는 2,810건만 검색
- OUTER APPLY 제거로 쿼리 단순화

성능: 6.5초 → 1.4초 (4.6배 향상)
2026-03-04 13:57:33 +09:00
thug0bin
5dd3489385 feat: 판매내역 페이지에서 제품 이미지 등록/교체 기능 추가
- 이미지/플레이스홀더 클릭시 이미지 등록 모달 열림
- URL 입력 탭: 이미지 URL로 등록
- 촬영 탭: 카메라로 직접 촬영 (1:1 크롭 가이드)
- 기존 /api/admin/product-images API 재활용
- 저장 후 자동 새로고침
2026-03-04 12:40:22 +09:00
thug0bin
b660f324ac fix: 제품 이미지 플레이스홀더 개선 (이모지 → SVG 아이콘)
- 📦 이모지 대신 일반적인 이미지 플레이스홀더 스타일 적용
- 회색 그라데이션 배경 + SVG 이미지 아이콘
- 실제 이미지와 동일한 36x36 크기
2026-03-04 12:23:36 +09:00
thug0bin
fa4e87b461 feat: 판매내역 페이지에 제품 썸네일 이미지 표시
- app.py: /api/sales-detail에서 product_images.db 조회하여 thumbnail 반환
- admin_sales_pos.html: 거래별/목록 뷰에 36x36 썸네일 표시
- 이미지 없는 제품은 📦 플레이스홀더 표시
- barcode 우선, drug_code 폴백으로 이미지 매칭
2026-03-04 12:19:06 +09:00
thug0bin
9ce7e884d7 feat: 특이사항(CUSETC) 영수증 인쇄 기능 추가
- pos_printer.py: print_cusetc() 함수 추가 (ESC/POS 영수증 출력)
- admin.html: 회원 상세 모달에 🖨️ 인쇄 버튼 추가
- 즉시 피드백 토스트 + API 응답 후 결과 표시
2026-03-04 12:10:00 +09:00
thug0bin
5074adce20 feat: ESC/POS 영수증 프린터로 특이사항 인쇄 기능
- pos_printer.py: ESC/POS 유틸리티 (192.168.0.174:9100)
- POST /api/print/cusetc API 추가
- admin.html: 특이사항 옆 [🖨️ 인쇄] 버튼 추가
- EUC-KR 인코딩으로 한글 지원
2026-03-04 11:46:46 +09:00
thug0bin
50825c597e feat: 특이(참고)사항 조회/수정 기능 구현
- 사용자 상세 모달에 특이사항 표시 (생일 옆 칸)
- 인라인 수정 UI (수정 버튼 → textarea → 저장/취소)
- PUT /api/members/{cuscode}/cusetc API 추가
- CD_PERSON.CUSETC 직접 UPDATE

Docs: MEMBER_MEMO_SYSTEM.md 문서 추가
- DB 구조, API 명세, 구현 현황 정리
2026-03-04 11:24:13 +09:00
thug0bin
acf8e44aa5 fix: 이미지 상태 필터와 통계 일관성 수정
- '실패' 필터 선택 시 failed + no_result 둘 다 검색되도록 수정
- 통계 라벨: '실패' → '실패/없음'
- 필터 옵션: '실패' → '실패/검색없음', 별도 'no_result' 옵션 제거
- 상단 통계와 필터 결과가 일치하도록 UX 개선
2026-03-04 10:25:53 +09:00
thug0bin
546a5e7ae6 feat: 제품 이미지 카메라 촬영 기능 추가
- HTML5 getUserMedia로 카메라 촬영 지원 (모바일 후면 카메라 기본)
- 1:1 가이드 박스 UI로 정사각형 크롭 안내
- 백엔드: PIL로 800x800 리사이즈 + 썸네일 생성
- 기존 URL 교체 기능과 탭 방식으로 통합

버그 수정:
- closeReplaceModal() 호출 전 변수 복사로 null 전송 문제 해결
- None 값 방어 코드 추가

Docs: TROUBLESHOOTING-CAMERA-UPLOAD.md 추가
2026-03-04 10:08:40 +09:00
thug0bin
30d95c8579 feat: 제품 이미지 크롤링에 날짜 선택 기능 추가 - 달력으로 날짜 선택 가능 - 해당 날짜 판매 제품 크롤링 2026-03-04 00:55:02 +09:00
thug0bin
51216c582f fix: 알림톡 실패 시 상세 에러 메시지 저장 - header.resultMessage 대신 sendResults[0].resultMessage 우선 저장 - 원인 파악이 가능하도록 개선 2026-03-04 00:36:25 +09:00
thug0bin
9ba2846820 fix: 알림톡 14자 제한 수정 - 특수문자 제거 로직 삭제 (불필요했음) - 14자 제한으로 자르기 (카카오 API 제한) 2026-03-04 00:33:25 +09:00
thug0bin
0aebdaea0c style: AI 추천 이미지 스타일 최종 조정
- flexbox 중앙 정렬
- 160px 너비, 높이 자동
- border/outline 명시적 제거
2026-03-03 00:48:24 +09:00
thug0bin
467c0e91aa fix: AI 추천 이미지 해상도 및 스타일 개선
- 썸네일(3KB) 대신 원본 이미지(33KB) 사용
- 테두리/배경 제거로 깔끔한 디자인
- text-align:center로 중앙 정렬
- 최대 크기 140px
2026-03-03 00:46:27 +09:00
thug0bin
0676c4f466 style: AI 추천 이미지 스타일 개선
- 중앙 정렬 (flexbox)
- 크기 100px → 120px
- 회색 배경 + 둥근 모서리 (16px)
- 패딩 추가로 여백 확보
2026-03-03 00:44:31 +09:00
thug0bin
79259d004b fix: AI 추천 이미지 조회 시 sqlite3 import 누락 수정
- sqlite3 모듈 import 추가
- 디버그 로그 추가
2026-03-03 00:31:42 +09:00
thug0bin
8aa43221d2 feat: 마이페이지 AI 추천에 제품 이미지 표시
- /api/recommendation API에서 product_images DB 조회
- 제품명 매칭으로 썸네일 이미지 반환
- 이미지 있으면 실제 사진, 없으면 💊 이모지 표시
2026-03-03 00:02:04 +09:00
thug0bin
95fdd23817 docs: 이미지 교체 바코드 전달 오류 트러블슈팅 문서 추가 2026-03-02 23:56:34 +09:00
thug0bin
65754f594b fix: 이미지 교체 시 바코드 검증 강화
- openReplaceModal에서 바코드 유효성 검사
- submitReplace에서 null/undefined 바코드 차단
- 디버깅 로그 추가
2026-03-02 23:52:31 +09:00
thug0bin
4a3ec38ba7 fix: 이미지 교체 시 바코드 전달 오류 수정
- data 속성으로 바코드/제품명 전달 (escapeHtml 문제 해결)
- 기존 레코드 유지하도록 UPDATE 쿼리 수정
2026-03-02 23:47:11 +09:00
thug0bin
4a06e60e29 feat: 이미지 교체 기능 추가
- URL 입력으로 이미지 수동 교체
- 다양한 User-Agent로 다운로드 시도 (차단 우회)
- base64 변환 + 썸네일 자동 생성
- status를 'manual'로 표시
2026-03-02 23:42:01 +09:00
thug0bin
ee28f97c11 feat: 제품 이미지 수동 크롤링 - MSSQL 검색 인터페이스 추가
- OTC 라벨처럼 제품명 검색 → 선택 → 크롤링
- 바코드 직접 입력 불필요
- MSSQL 검색 API 재사용
2026-03-02 23:37:31 +09:00
thug0bin
29648e3a7d feat: yakkok.com 제품 이미지 크롤러 + 어드민 페이지
크롤러 (utils/yakkok_crawler.py):
- yakkok.com에서 제품 검색 및 이미지 추출
- MSSQL 오늘 판매 품목 자동 조회
- base64 변환 후 SQLite 저장
- CLI 지원 (--today, --product)

DB (product_images.db):
- 바코드, 제품명, 이미지(base64), 상태 저장
- 크롤링 로그 테이블

어드민 페이지 (/admin/product-images):
- 이미지 목록/검색/필터
- 통계 (성공/실패/대기)
- 상세 보기/삭제
- 오늘 판매 제품 일괄 크롤링

API:
- GET /api/admin/product-images
- GET /api/admin/product-images/<barcode>
- POST /api/admin/product-images/crawl-today
- DELETE /api/admin/product-images/<barcode>
2026-03-02 23:19:52 +09:00
thug0bin
4713395557 fix: OTC 라벨 검색 시 CD_ITEM_UNIT_MEMBER 바코드 포함
- CD_GOODS.Barcode가 없어도 CD_ITEM_UNIT_MEMBER에서 바코드 조회
- 바코드 없는 제품도 검색 결과에 포함
2026-03-02 17:35:13 +09:00
thug0bin
007b37e6c6 fix: 품목 바코드 조회 개선
- CD_GOODS.Barcode가 없으면 CD_ITEM_UNIT_MEMBER에서 조회
- 중복 제거 로직 추가 (drug_code 기준)
2026-03-02 17:29:16 +09:00
thug0bin
0e954ac749 fix: OTC 라벨 프린터 IP 수정 (192.168.0.168) 2026-03-02 17:18:48 +09:00
thug0bin
887aba3a03 feat: 실시간 POS 품목별 OTC 용법 라벨 인쇄 버튼 추가
POS 상세 패널:
- 품목 목록에 💊 인쇄 버튼 추가
- 프리셋 있으면 → 바로 인쇄
- 프리셋 없으면 → 새 창으로 등록 페이지 열기

API:
- /api/admin/pos-live/detail에 barcode 필드 추가

OTC 라벨 관리 페이지:
- URL 파라미터(barcode, name) 자동 처리
- POS에서 넘어올 때 자동으로 해당 약품 로드
2026-03-02 17:14:45 +09:00
thug0bin
c154537c87 feat: OTC 라벨 프리셋 삭제 기능 + 디버깅 로그 추가 2026-03-02 17:08:48 +09:00
thug0bin
b71d511c7a fix: OTC 라벨 저장 시 display_name 자동 설정
- display_name 비어있으면 원본 약품명(currentDrugName) 사용
- 저장된 프리셋 목록에 바코드 대신 약품명 표시
2026-03-02 17:07:02 +09:00
thug0bin
ac0e1ced0e fix: OTC 라벨 약품 검색 API 오류 수정
- StockQty 컬럼 제거 (CD_GOODS 테이블에 없음)
2026-03-02 17:04:43 +09:00
thug0bin
76a4280ebd feat: OTC 용법 라벨 시스템 구현
DB:
- otc_label_presets 테이블 추가 (SQLite)
- 바코드 기준 오버라이드 데이터 저장

Backend:
- utils/otc_label_printer.py: 라벨 이미지 생성 + Brother QL-810W 출력
- API: CRUD + 미리보기 렌더링 + MSSQL 약품 검색

Frontend:
- /admin/otc-labels: 관리 페이지
- 실시간 미리보기
- 저장된 프리셋 목록
- 바코드/이름 검색 → 프리셋 편집 → 인쇄
2026-03-02 17:00:47 +09:00
thug0bin
c525632246 feat: 어드민 집계 페이지에 반려동물 통계 추가
통계 카드:
- 등록 반려동물 총 수
- 강아지/고양이 종류별 수
- 노란색 그라데이션 카드 스타일

최근 등록 반려동물 섹션:
- 최근 10마리 반려동물 카드
- 사진 + 이름 + 품종 + 보호자 정보
- 보호자 전화번호 마스킹 처리
2026-03-02 16:37:25 +09:00
thug0bin
a7b3d5b7e0 feat: 반려동물 정보 표시 기능 추가
API:
- /api/admin/pos-live에 pets 배열 추가
- 적립된 회원의 반려동물 정보 조회 (이름, 종류, 품종, 사진)

테이블 (바깥):
- 적립 열에 반려동물 아이콘 표시 (🐕🐈)

상세 패널:
- 반려동물 카드 섹션 추가
- 사진 + 이름 + 품종 표시
- 노란색 그라데이션 카드 스타일
2026-03-02 16:11:59 +09:00
thug0bin
695c1f707f feat: 상세 패널에 키오스크/라벨출력 액션 버튼 추가
- 상세 패널 상단에 2열 액션 버튼 배치
- 📺 키오스크: 해당 건 즉시 전송
- 🏷️ 라벨출력: QR 생성 + Brother QL 출력
- 버튼에 예상 적립 포인트 표시
- 호버 효과 + 로딩 상태 표시
- QR 발행 여부, 적립 완료 정보 표시
2026-03-02 15:59:47 +09:00
thug0bin
f1e609ba9f feat: 체크박스 선택 방식으로 UX 개선
- 테이블 헤더에 전체 선택 체크박스 추가
- 각 행에 개별 체크박스 추가
- 체크박스 클릭 = 선택만 (상세 패널 안 열림)
- 행 클릭 = 상세 패널 열기 (기존 동작 유지)
- 여러 건 선택 → 일괄 라벨 출력 가능
- 버튼에 선택 건수 표시: '📺 키오스크 (3건)'
2026-03-02 15:50:21 +09:00
thug0bin
e10b50e0c3 feat: 키오스크 전송 + 라벨 출력 버튼 추가 (UX 개선)
- 📺 키오스크 버튼: 기존 /api/kiosk/trigger API 활용
- 🏷️ 라벨출력 버튼: QR 생성 + Brother QL-810W 출력 (1클릭)
- 복잡한 QR 모달 제거 → 심플한 버튼 방식
- 토스트 메시지로 결과 표시
2026-03-02 15:44:50 +09:00
thug0bin
c279e53c3e feat: 2단계 - QR 생성 및 Brother QL-810W 라벨 출력 API
- POST /api/admin/qr/generate: QR 토큰 생성 + 미리보기
- POST /api/admin/qr/print: Brother QL / POS 프린터 출력
- 프론트: QR 발행 버튼, 프린터 선택 모달
- 기존 qr_token_generator, qr_label_printer 모듈 활용
2026-03-02 15:35:48 +09:00
thug0bin
e37659dc04 feat: POS 실시간 판매 조회 웹 페이지 (Qt GUI 웹 버전) 2026-03-02 15:26:51 +09:00
thug0bin
52a4f69abc feat: 관리자 대시보드 사용자 모달에 반려동물 탭 추가
- /admin/user/<id> API에 pets 데이터 추가
- 사용자 상세 모달에 🐾 반려동물 탭 추가
- 반려동물 사진, 이름, 종류, 품종, 성별, 등록일 표시
2026-03-02 14:51:46 +09:00
thug0bin
1cebb02ec6 feat: 반려동물 등록 기능 및 확장 마이페이지 추가
- pets 테이블 추가 (이름, 종류, 품종, 사진 등)
- 반려동물 CRUD API (/api/pets)
- 확장 마이페이지 (/mypage) - 카카오 로그인 기반
- 기존 마이페이지에 퀵 메뉴 추가 (반려동물/쿠폰/구매내역/내정보)
- 카카오 로그인 시 세션에 user_id 저장
- 동물약 APC 매핑 가이드 문서 추가
2026-03-02 13:56:22 +09:00
thug0bin
f102f6b42e feat: 대시보드 조제 모달에도 AI 상호작용 체크 버튼 추가 2026-02-28 13:50:02 +09:00
thug0bin
16adca3646 feat: KIMS 상호작용 로그 뷰어 페이지 추가 (/admin/kims-logs) 2026-02-28 13:38:47 +09:00
thug0bin
fbe7dde4ce feat: KIMS API 호출 SQLite 로깅 (AI 학습용 데이터 수집) 2026-02-28 13:32:53 +09:00
thug0bin
8c20c8b8db fix: KIMS 심각도 매핑 수정 (SeverityDesc 사용) + 상호작용 약품 pill 색상 강조 2026-02-28 13:29:53 +09:00
thug0bin
67e576736d fix: KIMS API에 DrugCode 직접 사용 (BASECODE 조인 제거) 2026-02-28 13:22:26 +09:00
thug0bin
4c0cd68267 fix: KIMS 코드 조회 쿼리 최적화 (중복 제거) 2026-02-28 13:20:31 +09:00
thug0bin
68dcb919e4 feat: KIMS 약물 상호작용 체크 기능 추가 (조제 탭 버튼 + 모달) 2026-02-28 13:15:31 +09:00
thug0bin
6a786ff042 feat: 제품 검색에 분류 뱃지 + 도매상 재고 추가 (PostgreSQL 방어적 lazy fetch) 2026-02-28 12:48:58 +09:00
thug0bin
4c93ee038a feat: 챗봇 관련 제품에 분류 뱃지 추가 (내부구충제, 심장사상충약 등) 2026-02-28 12:32:03 +09:00
thug0bin
a42af23038 feat: 도매상 재고 표시 추가 (약국 N / 도매 M) + 문서화 2026-02-28 12:19:34 +09:00
thug0bin
180393700b feat: 챗봇 관련 제품에 재고 표시 추가 2026-02-28 12:04:44 +09:00
thug0bin
21e07bcca9 fix: admin_products.html 인코딩 수정 + 재고 컬럼 추가 2026-02-28 12:01:32 +09:00
thug0bin
95d7ebab71 feat: 제품 검색 페이지에 재고 컬럼 추가 (초록/빨강 표시) 2026-02-28 11:59:49 +09:00
thug0bin
c1c38c68ac feat: 동물약 API에 재고 정보 추가 (IM_total.IM_QT_sale_debit) 2026-02-28 11:56:11 +09:00
thug0bin
fd77dcbef9 feat: 챗봇 업셀링 로직 추가 (항생제→정장제 추천) 2026-02-28 11:50:02 +09:00
thug0bin
912679b137 feat: PostgreSQL에서 image_url 직접 조회 (바코드=APC 케이스 지원) 2026-02-28 11:45:27 +09:00
thug0bin
f438f42d15 docs: APC 매핑 현황 및 바코드=APC 케이스 문서화 2026-02-28 11:43:55 +09:00
thug0bin
b1d5bcfc98 feat: APC 없을 때 바코드로 PostgreSQL RAG 조회 2026-02-28 11:43:18 +09:00
thug0bin
8b58ab0d3a feat: RAG에 component_name_ko 추가 (성분 정보 개선) 2026-02-28 11:35:21 +09:00
thug0bin
c022ee21d0 feat: RAG에 성분/용도 정보 추가 2026-02-28 11:33:38 +09:00
thug0bin
d612563580 fix: (판) 접두어 제품 매칭 수정 2026-02-28 11:27:17 +09:00
thug0bin
dfbc6e4761 feat: 동물약 APC 일괄 매핑 (7개 완료) 2026-02-28 11:24:16 +09:00
thug0bin
8ee148abe4 refactor: 안텔민 하드코딩 제거, PostgreSQL RAG만 사용 2026-02-28 11:15:10 +09:00
thug0bin
3c9739a92e fix: RAG 정보 우선 참조하도록 프롬프트 개선 2026-02-28 11:04:23 +09:00
thug0bin
73b8c8ec88 fix: 안텔민 개/고양이 공용 정보 수정 (ANIMAL_DRUG_KNOWLEDGE) 2026-02-28 11:02:56 +09:00
thug0bin
4254a0f7a2 fix: 챗봇 제품 매칭 개선 (안텔민→안텔민킹/뽀삐 매칭, APC 우선) 2026-02-28 11:01:01 +09:00
thug0bin
e12328ec17 feat: 동물약 챗봇 RAG 연동 (PostgreSQL llm_pharm) 2026-02-28 10:46:43 +09:00
thug0bin
009d133aef chore: 테스트 스크립트 gitignore 추가 및 추적 제거 2026-02-28 10:46:31 +09:00
thug0bin
9019347d48 chore: DB 분석 스크립트 추가 (APC/바코드 조사용) 2026-02-28 10:45:00 +09:00
thug0bin
b95e14419e feat: 동물약 APC 이미지 지원 (CD_ITEM_UNIT_MEMBER 연동) 2026-02-28 10:44:55 +09:00
thug0bin
dd28958a59 docs: 데이터베이스 구조 문서화 (APC, MSSQL, PostgreSQL) 2026-02-28 10:44:50 +09:00
69 changed files with 21479 additions and 135 deletions

7
.gitignore vendored
View File

@@ -87,5 +87,12 @@ tmp/
*.tmp
.claude/
# Test/Debug scripts (일회성 분석용)
backend/scripts/check_*.py
backend/scripts/find_*.py
backend/scripts/search_*.py
backend/scripts/compare_*.py
backend/scripts/analyze_*.py
# GUI settings (user-specific)
gui_settings.json

File diff suppressed because it is too large Load Diff

23
backend/check_pets.py Normal file
View File

@@ -0,0 +1,23 @@
import sqlite3
conn = sqlite3.connect('db/mileage.db')
c = conn.cursor()
# 테이블 구조
c.execute("SELECT sql FROM sqlite_master WHERE name='pets'")
print("=== PETS TABLE SCHEMA ===")
print(c.fetchone())
# 샘플 데이터
c.execute("SELECT * FROM pets LIMIT 5")
print("\n=== SAMPLE DATA ===")
for row in c.fetchall():
print(row)
# 컬럼명
c.execute("PRAGMA table_info(pets)")
print("\n=== COLUMNS ===")
for col in c.fetchall():
print(col)
conn.close()

View File

@@ -154,11 +154,46 @@ class DatabaseManager:
return self.engines[database]
def get_session(self, database='PM_BASE'):
"""특정 데이터베이스 세션 반환"""
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
if database not in self.sessions:
engine = self.get_engine(database)
Session = sessionmaker(bind=engine)
self.sessions[database] = Session()
else:
# 🔥 기존 세션 상태 체크 및 자동 복구
session = self.sessions[database]
try:
# 세션이 유효한지 간단한 쿼리로 테스트
session.execute(text("SELECT 1"))
except Exception as e:
error_msg = str(e).lower()
# 연결 끊김 또는 트랜잭션 에러 감지
if any(keyword in error_msg for keyword in [
'invalid transaction', 'rollback', 'connection',
'closed', 'lost', 'timeout', 'network', 'disconnect'
]):
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
try:
session.rollback()
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
except Exception as rollback_err:
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
try:
session.close()
except:
pass
del self.sessions[database]
# 새 세션 생성
engine = self.get_engine(database)
Session = sessionmaker(bind=engine)
self.sessions[database] = Session()
print(f"[DB Manager] {database} 새 세션 생성 완료")
else:
# 다른 종류의 에러면 롤백만 시도
try:
session.rollback()
except:
pass
return self.sessions[database]
def rollback_session(self, database='PM_BASE'):
@@ -237,7 +272,13 @@ class DatabaseManager:
self.init_sqlite_schema()
self.sqlite_conn = old_conn
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
else:
# 기존 DB: 마이그레이션 실행
old_conn = self.sqlite_conn
self.sqlite_conn = conn
self._migrate_sqlite()
self.sqlite_conn = old_conn
return conn
def init_sqlite_schema(self):
@@ -319,6 +360,67 @@ class DatabaseManager:
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
# customer_identities 토큰 저장 컬럼 추가
cursor.execute("PRAGMA table_info(customer_identities)")
ci_columns = [row[1] for row in cursor.fetchall()]
if 'access_token' not in ci_columns:
cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT")
cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT")
cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가")
# pets 테이블 생성 (반려동물)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
species VARCHAR(20) NOT NULL,
breed VARCHAR(50),
gender VARCHAR(10),
birth_date DATE,
age_months INTEGER,
weight DECIMAL(5,2),
photo_url TEXT,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성")
# otc_label_presets 테이블 생성 (OTC 용법 라벨)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='otc_label_presets'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode VARCHAR(20) NOT NULL UNIQUE,
drug_code VARCHAR(20),
display_name VARCHAR(100),
effect VARCHAR(100),
dosage_instruction TEXT,
usage_tip TEXT,
use_wide_format BOOLEAN DEFAULT TRUE,
print_count INTEGER DEFAULT 0,
last_printed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: otc_label_presets 테이블 생성")
def test_connection(self, database='PM_BASE'):
"""연결 테스트"""
try:

220
backend/db/kims_logger.py Normal file
View File

@@ -0,0 +1,220 @@
"""
KIMS API 로깅 모듈
- API 호출/응답 SQLite 저장
- AI 학습용 데이터 수집
"""
import sqlite3
import json
import os
from datetime import datetime
from pathlib import Path
# DB 파일 경로
DB_PATH = Path(__file__).parent / 'kims_logs.db'
def init_db():
"""DB 초기화 (테이블 생성)"""
schema_path = Path(__file__).parent / 'kims_logs_schema.sql'
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
with open(schema_path, 'r', encoding='utf-8') as f:
schema = f.read()
cursor.executescript(schema)
conn.commit()
conn.close()
print(f"KIMS 로그 DB 초기화 완료: {DB_PATH}")
def log_kims_call(
pre_serial: str = None,
user_id: int = None,
source: str = 'admin',
drug_codes: list = None,
drug_names: list = None,
api_status: str = 'SUCCESS',
http_status: int = 200,
response_time_ms: int = 0,
interactions: list = None,
response_raw: dict = None,
error_message: str = None
) -> int:
"""
KIMS API 호출 로그 저장
Returns:
log_id: 생성된 로그 ID
"""
# DB 없으면 초기화
if not DB_PATH.exists():
init_db()
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
interactions = interactions or []
drug_codes = drug_codes or []
drug_names = drug_names or []
# 심각한 상호작용 여부 (severity 1 또는 2)
has_severe = any(
str(i.get('severity', '5')) in ['1', '2']
for i in interactions
)
# 메인 로그 삽입
cursor.execute("""
INSERT INTO kims_api_logs (
pre_serial, user_id, source,
request_drug_codes, request_drug_names, request_drug_count,
api_status, http_status, response_time_ms,
interaction_count, has_severe_interaction,
interactions_json, response_raw, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
pre_serial,
user_id,
source,
json.dumps(drug_codes, ensure_ascii=False),
json.dumps(drug_names, ensure_ascii=False),
len(drug_codes),
api_status,
http_status,
response_time_ms,
len(interactions),
1 if has_severe else 0,
json.dumps(interactions, ensure_ascii=False),
json.dumps(response_raw, ensure_ascii=False) if response_raw else None,
error_message
))
log_id = cursor.lastrowid
# 상호작용 상세 삽입 (정규화)
for inter in interactions:
cursor.execute("""
INSERT INTO kims_interactions (
log_id,
drug1_code, drug1_name, drug1_generic,
drug2_code, drug2_name, drug2_generic,
severity_level, severity_desc,
likelihood_level, likelihood_desc,
observation, observation_generic,
clinical_management, action_to_take, reference
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
log_id,
inter.get('drug1_code'),
inter.get('drug1_name'),
inter.get('generic1'),
inter.get('drug2_code'),
inter.get('drug2_name'),
inter.get('generic2'),
int(inter.get('severity', 5)) if str(inter.get('severity', '')).isdigit() else None,
inter.get('severity_text'),
None, # likelihood_level
inter.get('likelihood'),
inter.get('description'),
None, # observation_generic
inter.get('management'),
inter.get('action'),
None # reference
))
conn.commit()
conn.close()
return log_id
def get_recent_logs(limit: int = 50):
"""최근 로그 조회"""
if not DB_PATH.exists():
return []
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM kims_api_logs
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def get_log_detail(log_id: int):
"""로그 상세 조회 (상호작용 포함)"""
if not DB_PATH.exists():
return None
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 메인 로그
cursor.execute("SELECT * FROM kims_api_logs WHERE id = ?", (log_id,))
log = cursor.fetchone()
if not log:
conn.close()
return None
# 상호작용 상세
cursor.execute("""
SELECT * FROM kims_interactions
WHERE log_id = ?
ORDER BY severity_level ASC
""", (log_id,))
interactions = cursor.fetchall()
conn.close()
result = dict(log)
result['interactions_detail'] = [dict(i) for i in interactions]
return result
def get_stats():
"""통계 조회"""
if not DB_PATH.exists():
return {}
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 전체 통계
cursor.execute("""
SELECT
COUNT(*) as total_calls,
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
AVG(response_time_ms) as avg_response_ms
FROM kims_api_logs
""")
stats = dict(cursor.fetchone())
# 최근 7일 일별 통계
cursor.execute("""
SELECT * FROM kims_stats
ORDER BY date DESC
LIMIT 7
""")
daily = [dict(row) for row in cursor.fetchall()]
conn.close()
stats['daily'] = daily
return stats
if __name__ == '__main__':
# DB 초기화 테스트
init_db()
print("KIMS 로그 DB 초기화 완료!")

View File

@@ -0,0 +1,86 @@
-- KIMS API 로그 테이블 스키마
-- AI 학습 데이터로 활용 예정
-- 1. API 호출 로그 (메인)
CREATE TABLE IF NOT EXISTS kims_api_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 호출 컨텍스트
pre_serial TEXT, -- 처방번호
user_id INTEGER, -- 마일리지 회원 ID (있으면)
source TEXT DEFAULT 'admin', -- 호출 소스 (admin, api, batch 등)
-- 요청 데이터
request_drug_codes TEXT NOT NULL, -- JSON: ["055101150", "622801610"]
request_drug_names TEXT, -- JSON: ["오메프투캡슐", "락소펜엠정"]
request_drug_count INTEGER, -- 요청 약품 수
-- 응답 데이터
api_status TEXT NOT NULL, -- SUCCESS, ERROR, TIMEOUT
http_status INTEGER, -- HTTP 상태 코드
response_time_ms INTEGER, -- 응답 시간 (밀리초)
-- 상호작용 결과
interaction_count INTEGER DEFAULT 0, -- 발견된 상호작용 수
has_severe_interaction INTEGER DEFAULT 0, -- 심각한 상호작용 여부 (1/2 등급)
-- 상세 데이터 (JSON)
interactions_json TEXT, -- 상호작용 상세 정보 JSON
response_raw TEXT, -- 전체 API 응답 (디버깅/학습용)
-- 에러 정보
error_message TEXT
);
-- 2. 상호작용 상세 (정규화, AI 학습용)
CREATE TABLE IF NOT EXISTS kims_interactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_id INTEGER NOT NULL, -- kims_api_logs.id FK
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 약품 1
drug1_code TEXT NOT NULL,
drug1_name TEXT,
drug1_generic TEXT, -- 성분명 (영문)
-- 약품 2
drug2_code TEXT NOT NULL,
drug2_name TEXT,
drug2_generic TEXT, -- 성분명 (영문)
-- 상호작용 정보
severity_level INTEGER, -- 1=심각, 2=중등도, 3=경미, 4=참고
severity_desc TEXT, -- 심각도 설명 (중증, 경미 등)
likelihood_level INTEGER, -- 발생 가능성
likelihood_desc TEXT,
-- 상세 설명 (AI 학습 핵심 데이터)
observation TEXT, -- 상호작용 설명 (한글)
observation_generic TEXT, -- 일반적 설명
clinical_management TEXT, -- 임상적 관리 방법
action_to_take TEXT, -- 권장 조치
reference TEXT, -- 참고문헌
FOREIGN KEY (log_id) REFERENCES kims_api_logs(id)
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_kims_logs_created ON kims_api_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_kims_logs_pre_serial ON kims_api_logs(pre_serial);
CREATE INDEX IF NOT EXISTS idx_kims_logs_status ON kims_api_logs(api_status);
CREATE INDEX IF NOT EXISTS idx_kims_interactions_log ON kims_interactions(log_id);
CREATE INDEX IF NOT EXISTS idx_kims_interactions_drugs ON kims_interactions(drug1_code, drug2_code);
CREATE INDEX IF NOT EXISTS idx_kims_interactions_severity ON kims_interactions(severity_level);
-- 통계 뷰
CREATE VIEW IF NOT EXISTS kims_stats AS
SELECT
DATE(created_at) as date,
COUNT(*) as total_calls,
SUM(CASE WHEN api_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN interaction_count > 0 THEN 1 ELSE 0 END) as with_interaction,
SUM(CASE WHEN has_severe_interaction = 1 THEN 1 ELSE 0 END) as with_severe,
AVG(response_time_ms) as avg_response_ms
FROM kims_api_logs
GROUP BY DATE(created_at);

View File

@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities (
provider VARCHAR(20) NOT NULL,
provider_user_id VARCHAR(100) NOT NULL,
provider_data TEXT,
access_token TEXT,
refresh_token TEXT,
token_expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(provider, provider_user_id)
@@ -120,3 +123,44 @@ CREATE TABLE IF NOT EXISTS ai_recommendations (
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);
-- 8. 반려동물 테이블
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비)
species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other
breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등)
gender VARCHAR(10), -- male, female, unknown
birth_date DATE, -- 생년월일 (나중에 사용)
age_months INTEGER, -- 월령 (나중에 사용)
weight DECIMAL(5,2), -- 체중 kg (나중에 사용)
photo_url TEXT, -- 사진 URL
notes TEXT, -- 특이사항/메모
is_active BOOLEAN DEFAULT TRUE, -- 활성 상태
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
-- 9. OTC 용법 라벨 테이블 (바코드 기준 오버라이드 데이터)
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode VARCHAR(20) NOT NULL UNIQUE, -- 바코드 (PK 역할)
drug_code VARCHAR(20), -- MSSQL DrugCode (참조용)
display_name VARCHAR(100), -- 표시 이름 (오버라이드, NULL이면 MSSQL 이름 사용)
effect VARCHAR(100), -- 효능 (예: "치통, 두통")
dosage_instruction TEXT, -- 용법 (예: "1일 3회, 1회 1정, 식후 30분")
usage_tip TEXT, -- 부가 설명 (예: "[통증 시에만 복용]")
use_wide_format BOOLEAN DEFAULT TRUE, -- 와이드 포맷 사용 여부
print_count INTEGER DEFAULT 0, -- 인쇄 횟수 (통계용)
last_printed_at DATETIME, -- 마지막 인쇄 시간
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);

351
backend/db/paai_logger.py Normal file
View File

@@ -0,0 +1,351 @@
"""
PAAI (Pharmacist Assistant AI) 로깅 모듈
- API 호출/응답 SQLite 저장
- 분석 결과 및 피드백 관리
"""
import sqlite3
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
# DB 파일 경로
DB_PATH = Path(__file__).parent / 'paai_logs.db'
def init_db():
"""DB 초기화 (테이블 생성)"""
schema_path = Path(__file__).parent / 'paai_logs_schema.sql'
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
with open(schema_path, 'r', encoding='utf-8') as f:
schema = f.read()
cursor.executescript(schema)
conn.commit()
conn.close()
print(f"PAAI 로그 DB 초기화 완료: {DB_PATH}")
def create_log(
pre_serial: str = None,
patient_code: str = None,
patient_name: str = None,
disease_code_1: str = None,
disease_name_1: str = None,
disease_code_2: str = None,
disease_name_2: str = None,
current_medications: list = None,
previous_serial: str = None,
previous_medications: list = None,
prescription_changes: dict = None,
otc_history: dict = None
) -> int:
"""
PAAI 분석 로그 생성 (초기 상태)
Returns:
log_id: 생성된 로그 ID
"""
if not DB_PATH.exists():
init_db()
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
current_medications = current_medications or []
previous_medications = previous_medications or []
otc_history = otc_history or {}
# 환자명 마스킹
masked_name = None
if patient_name:
masked_name = patient_name[0] + '*' * (len(patient_name) - 1) if len(patient_name) > 1 else patient_name
cursor.execute("""
INSERT INTO paai_logs (
pre_serial, patient_code, patient_name,
disease_code_1, disease_name_1, disease_code_2, disease_name_2,
current_medications, current_med_count,
previous_serial, previous_medications, prescription_changes,
otc_history, otc_visit_count,
status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
""", (
pre_serial,
patient_code,
masked_name,
disease_code_1,
disease_name_1,
disease_code_2,
disease_name_2,
json.dumps(current_medications, ensure_ascii=False),
len(current_medications),
previous_serial,
json.dumps(previous_medications, ensure_ascii=False),
json.dumps(prescription_changes, ensure_ascii=False) if prescription_changes else None,
json.dumps(otc_history, ensure_ascii=False),
otc_history.get('visit_count', 0)
))
log_id = cursor.lastrowid
conn.commit()
conn.close()
return log_id
def update_kims_result(
log_id: int,
kims_drug_codes: list = None,
kims_interactions: list = None,
kims_response_time_ms: int = 0
):
"""KIMS 상호작용 결과 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
kims_drug_codes = kims_drug_codes or []
kims_interactions = kims_interactions or []
# 심각한 상호작용 여부 (severity 1 또는 2)
has_severe = any(
str(i.get('severity', '5')) in ['1', '2']
for i in kims_interactions
)
cursor.execute("""
UPDATE paai_logs SET
kims_drug_codes = ?,
kims_drug_count = ?,
kims_interactions = ?,
kims_interaction_count = ?,
kims_has_severe = ?,
kims_response_time_ms = ?,
status = 'kims_done'
WHERE id = ?
""", (
json.dumps(kims_drug_codes, ensure_ascii=False),
len(kims_drug_codes),
json.dumps(kims_interactions, ensure_ascii=False),
len(kims_interactions),
1 if has_severe else 0,
kims_response_time_ms,
log_id
))
conn.commit()
conn.close()
def update_ai_result(
log_id: int,
ai_prompt: str = None,
ai_model: str = None,
ai_response: dict = None,
ai_response_time_ms: int = 0,
ai_token_count: int = None
):
"""AI 분석 결과 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
UPDATE paai_logs SET
ai_prompt = ?,
ai_model = ?,
ai_response = ?,
ai_response_time_ms = ?,
ai_token_count = ?,
status = 'success'
WHERE id = ?
""", (
ai_prompt,
ai_model,
json.dumps(ai_response, ensure_ascii=False) if ai_response else None,
ai_response_time_ms,
ai_token_count,
log_id
))
conn.commit()
conn.close()
def update_error(log_id: int, error_message: str):
"""에러 상태 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
UPDATE paai_logs SET
status = 'error',
error_message = ?
WHERE id = ?
""", (error_message, log_id))
conn.commit()
conn.close()
def update_feedback(log_id: int, useful: bool, comment: str = None):
"""피드백 업데이트"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
UPDATE paai_logs SET
feedback_useful = ?,
feedback_comment = ?
WHERE id = ?
""", (1 if useful else 0, comment, log_id))
conn.commit()
conn.close()
def get_recent_logs(
limit: int = 100,
status: str = None,
has_severe: bool = None,
date: str = None
) -> list:
"""최근 로그 조회"""
if not DB_PATH.exists():
return []
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM paai_logs WHERE 1=1"
params = []
if status:
query += " AND status = ?"
params.append(status)
if has_severe is not None:
query += " AND kims_has_severe = ?"
params.append(1 if has_severe else 0)
if date:
query += " AND DATE(created_at) = ?"
params.append(date)
query += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
cursor.execute(query, params)
rows = cursor.fetchall()
result = []
for row in rows:
log = dict(row)
# JSON 필드 파싱
for field in ['current_medications', 'previous_medications', 'prescription_changes',
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
if log.get(field):
try:
log[field] = json.loads(log[field])
except:
pass
result.append(log)
conn.close()
return result
def get_log_detail(log_id: int) -> dict:
"""로그 상세 조회"""
if not DB_PATH.exists():
return None
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM paai_logs WHERE id = ?", (log_id,))
row = cursor.fetchone()
if not row:
conn.close()
return None
log = dict(row)
# JSON 필드 파싱
for field in ['current_medications', 'previous_medications', 'prescription_changes',
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
if log.get(field):
try:
log[field] = json.loads(log[field])
except:
pass
conn.close()
return log
def get_stats() -> dict:
"""통계 조회"""
if not DB_PATH.exists():
return {
'total': 0,
'today': 0,
'success_rate': 0,
'avg_response_time': 0,
'severe_count': 0
}
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
today = datetime.now().strftime('%Y-%m-%d')
# 전체 건수
cursor.execute("SELECT COUNT(*) FROM paai_logs")
total = cursor.fetchone()[0]
# 오늘 건수
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE DATE(created_at) = ?", (today,))
today_count = cursor.fetchone()[0]
# 성공률
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE status = 'success'")
success_count = cursor.fetchone()[0]
success_rate = (success_count / total * 100) if total > 0 else 0
# 평균 응답시간
cursor.execute("SELECT AVG(ai_response_time_ms) FROM paai_logs WHERE ai_response_time_ms > 0")
avg_time = cursor.fetchone()[0] or 0
# 심각한 상호작용 건수 (오늘)
cursor.execute("""
SELECT COUNT(*) FROM paai_logs
WHERE DATE(created_at) = ? AND kims_has_severe = 1
""", (today,))
severe_count = cursor.fetchone()[0]
# 피드백 통계
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful = 1")
useful_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful IS NOT NULL")
feedback_total = cursor.fetchone()[0]
conn.close()
return {
'total': total,
'today': today_count,
'success_rate': round(success_rate, 1),
'avg_response_time': int(avg_time),
'severe_count': severe_count,
'feedback': {
'useful': useful_count,
'total': feedback_total,
'rate': round(useful_count / feedback_total * 100, 1) if feedback_total > 0 else 0
}
}

View File

@@ -0,0 +1,59 @@
-- PAAI (Pharmacist Assistant AI) 로그 스키마
-- 생성일: 2026-03-04
CREATE TABLE IF NOT EXISTS paai_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 요청 정보
pre_serial TEXT, -- 처방번호
patient_code TEXT, -- 환자코드 (CusCode)
patient_name TEXT, -- 환자명 (마스킹: 김**)
-- 질병 정보
disease_code_1 TEXT, -- St1 (상병코드1)
disease_name_1 TEXT, -- 상병명1
disease_code_2 TEXT, -- St2 (상병코드2)
disease_name_2 TEXT, -- 상병명2
-- 처방 정보
current_medications TEXT, -- JSON: 현재 처방 [{code, name, dosage, ...}]
current_med_count INTEGER, -- 현재 처방 약품 수
previous_serial TEXT, -- 이전 처방번호
previous_medications TEXT, -- JSON: 이전 처방
prescription_changes TEXT, -- JSON: {added, removed, changed}
-- OTC 이력
otc_history TEXT, -- JSON: {purchases, frequent_items}
otc_visit_count INTEGER, -- OTC 구매 횟수
-- KIMS 상호작용
kims_drug_codes TEXT, -- JSON: 검사한 KD코드 배열
kims_drug_count INTEGER, -- 검사한 약품 수
kims_interactions TEXT, -- JSON: 상호작용 결과
kims_interaction_count INTEGER, -- 상호작용 건수
kims_has_severe BOOLEAN DEFAULT 0, -- 심각한 상호작용 (severity 1,2)
kims_response_time_ms INTEGER, -- KIMS API 응답시간
-- AI 분석
ai_prompt TEXT, -- AI에 전달한 프롬프트
ai_model TEXT, -- 사용된 모델
ai_response TEXT, -- JSON: AI 분석 결과
ai_response_time_ms INTEGER, -- AI 응답 시간
ai_token_count INTEGER, -- 토큰 사용량
-- 상태
status TEXT DEFAULT 'pending', -- pending, kims_done, success, error
error_message TEXT,
-- 피드백
feedback_useful INTEGER, -- 1=유용, 0=아님, NULL=미응답
feedback_comment TEXT -- 약사 코멘트
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_paai_created ON paai_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_paai_patient ON paai_logs(patient_code);
CREATE INDEX IF NOT EXISTS idx_paai_status ON paai_logs(status);
CREATE INDEX IF NOT EXISTS idx_paai_serial ON paai_logs(pre_serial);
CREATE INDEX IF NOT EXISTS idx_paai_severe ON paai_logs(kims_has_severe);

View File

@@ -0,0 +1,38 @@
-- product_images.db 스키마
-- yakkok.com에서 크롤링한 제품 이미지 저장
CREATE TABLE IF NOT EXISTS product_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode TEXT UNIQUE NOT NULL, -- 바코드 (고유키)
drug_code TEXT, -- PIT3000 DrugCode
product_name TEXT NOT NULL, -- 제품명
search_name TEXT, -- 검색에 사용한 이름
image_base64 TEXT, -- 이미지 (base64)
image_url TEXT, -- 원본 URL
thumbnail_base64 TEXT, -- 썸네일 (base64, 작은 사이즈)
source TEXT DEFAULT 'yakkok', -- 출처
status TEXT DEFAULT 'pending', -- pending/success/failed/manual/no_result
error_message TEXT, -- 실패 시 에러 메시지
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_barcode ON product_images(barcode);
CREATE INDEX IF NOT EXISTS idx_status ON product_images(status);
CREATE INDEX IF NOT EXISTS idx_drug_code ON product_images(drug_code);
CREATE INDEX IF NOT EXISTS idx_created_at ON product_images(created_at);
-- 크롤링 로그 테이블
CREATE TABLE IF NOT EXISTS crawl_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id TEXT, -- 배치 ID
total_count INTEGER DEFAULT 0, -- 전체 개수
success_count INTEGER DEFAULT 0, -- 성공 개수
failed_count INTEGER DEFAULT 0, -- 실패 개수
skipped_count INTEGER DEFAULT 0, -- 스킵 개수 (이미 있음)
started_at DATETIME,
finished_at DATETIME,
status TEXT DEFAULT 'running', -- running/completed/failed
error_message TEXT
);

View File

@@ -0,0 +1,18 @@
module.exports = {
apps: [
{
name: 'pharmacy-flask',
script: 'python',
args: 'app.py',
cwd: 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend',
interpreter: 'none',
watch: false,
autorestart: true,
max_restarts: 10,
env: {
FLASK_ENV: 'production',
PYTHONIOENCODING: 'utf-8'
}
}
]
};

1320
backend/pmr_api.py Normal file

File diff suppressed because it is too large Load Diff

169
backend/pos_printer.py Normal file
View File

@@ -0,0 +1,169 @@
# pos_printer.py - ESC/POS 영수증 프린터 유틸리티
# 0bin-label-app/src/pos_settings_dialog.py 기반
import socket
import logging
from datetime import datetime
# 프린터 설정 (config에서 불러올 수도 있음)
POS_PRINTER_IP = "192.168.0.174"
POS_PRINTER_PORT = 9100
POS_PRINTER_NAME = "올댓포스 오른쪽"
# ESC/POS 명령어
ESC = b'\x1b'
GS = b'\x1d'
# 기본 명령
INIT = ESC + b'@' # 프린터 초기화
CUT = ESC + b'd\x03' # 피드 + 커트 (원본 방식)
FEED = b'\n\n\n' # 줄바꿈
# 정렬
ALIGN_LEFT = ESC + b'a\x00'
ALIGN_CENTER = ESC + b'a\x01'
ALIGN_RIGHT = ESC + b'a\x02'
# 폰트 스타일
BOLD_ON = ESC + b'E\x01'
BOLD_OFF = ESC + b'E\x00'
DOUBLE_HEIGHT = ESC + b'!\x10'
DOUBLE_WIDTH = ESC + b'!\x20'
DOUBLE_SIZE = ESC + b'!\x30' # 가로세로 2배
NORMAL_SIZE = ESC + b'!\x00'
# 로깅
logging.basicConfig(level=logging.INFO)
def print_raw(data: bytes, ip: str = None, port: int = None) -> bool:
"""
ESC/POS 바이트 데이터를 프린터로 전송
Args:
data: ESC/POS 명령어 + 텍스트 바이트
ip: 프린터 IP (기본값: POS_PRINTER_IP)
port: 프린터 포트 (기본값: POS_PRINTER_PORT)
Returns:
bool: 성공 여부
"""
ip = ip or POS_PRINTER_IP
port = port or POS_PRINTER_PORT
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((ip, port))
sock.sendall(data)
sock.close()
logging.info(f"[POS Printer] 전송 성공: {ip}:{port}")
return True
except socket.timeout:
logging.error(f"[POS Printer] 연결 시간 초과: {ip}:{port}")
return False
except ConnectionRefusedError:
logging.error(f"[POS Printer] 연결 거부됨: {ip}:{port}")
return False
except Exception as e:
logging.error(f"[POS Printer] 전송 실패: {e}")
return False
def print_text(text: str, cut: bool = True) -> bool:
"""
텍스트를 영수증 프린터로 출력
Args:
text: 출력할 텍스트 (한글 지원)
cut: 출력 후 용지 커트 여부
Returns:
bool: 성공 여부
"""
try:
# EUC-KR 인코딩 (한글 지원)
text_bytes = text.encode('euc-kr', errors='replace')
# 명령어 조합
command = INIT + text_bytes + b'\n\n\n'
if cut:
command += CUT
return print_raw(command)
except Exception as e:
logging.error(f"[POS Printer] 텍스트 인쇄 실패: {e}")
return False
def print_cusetc(customer_name: str, cusetc: str, phone: str = None) -> bool:
"""
특이(참고)사항 영수증 출력 (단순 텍스트 방식)
Args:
customer_name: 고객 이름
cusetc: 특이사항 내용
phone: 전화번호 (선택)
Returns:
bool: 성공 여부
"""
now = datetime.now().strftime('%Y-%m-%d %H:%M')
# 전화번호 포맷팅
phone_display = ""
if phone:
phone_clean = phone.replace("-", "").replace(" ", "")
if len(phone_clean) == 11:
phone_display = f"{phone_clean[:3]}-{phone_clean[3:7]}-{phone_clean[7:]}"
else:
phone_display = phone
# 80mm 프린터 = 48자 기준
LINE = "=" * 48
THIN = "-" * 48
message = f"""
{LINE}
[ 특이사항 ]
{LINE}
고객: {customer_name}
"""
if phone_display:
message += f"연락처: {phone_display}\n"
message += f"""출력: {now}
{THIN}
{cusetc}
{LINE}
청춘약국
"""
return print_text(message, cut=True)
def test_print() -> bool:
"""테스트 인쇄"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
test_message = f"""
================================
POS 프린터 테스트
================================
IP: {POS_PRINTER_IP}
Port: {POS_PRINTER_PORT}
Time: {now}
청춘약국 마일리지 시스템
ESC/POS 정상 작동!
================================
"""
return print_text(test_message, cut=True)
if __name__ == "__main__":
# 테스트
print("POS 프린터 테스트 인쇄...")
result = test_print()
print(f"결과: {'성공' if result else '실패'}")

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""
동물약 일괄 APC 매칭 - 후보 찾기
"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
# 1. MSSQL 동물약 (APC 없는 것만)
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
"""))
no_apc = []
for row in result:
if not row.APC_CODE:
no_apc.append({
'code': row.DrugCode,
'name': row.GoodsName,
'price': row.Saleprice
})
session.close()
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===\n')
# 2. PostgreSQL에서 매칭 후보 찾기
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
matches = []
for drug in no_apc:
name = drug['name']
# 제품명에서 검색 키워드 추출
# (판) 제거, 괄호 내용 제거
search_name = name.replace('(판)', '').split('(')[0].strip()
# PostgreSQL 검색
result = pg.execute(text("""
SELECT apc, product_name,
llm_pharm->>'사용가능 동물' as target,
llm_pharm->>'분류' as category
FROM apc
WHERE product_name ILIKE :pattern
ORDER BY LENGTH(product_name)
LIMIT 5
"""), {'pattern': f'%{search_name}%'})
candidates = list(result)
if candidates:
matches.append({
'mssql': drug,
'candidates': candidates
})
print(f'{name}')
for c in candidates[:2]:
print(f'{c.apc}: {c.product_name[:40]}... [{c.target or "?"}]')
else:
print(f'{name} - 매칭 없음')
pg.close()
print(f'\n=== 요약 ===')
print(f'APC 없는 제품: {len(no_apc)}')
print(f'매칭 후보 있음: {len(matches)}')
print(f'매칭 없음: {len(no_apc) - len(matches)}')

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""
확실한 매칭만 일괄 등록
"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
from datetime import datetime
# 확실한 매칭 목록 (MSSQL 제품명, DrugCode, APC)
MAPPINGS = [
# 파라캅
('파라캅L(5kg이상)', 'LB000003159', '0230338510101'), # 파라캅 L 정 10정
('파라캅S(5kg이하)', 'LB000003160', '0230347110106'), # 파라캅 에스 정 10정
# 세레니아
('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정
('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 4정
]
session = get_db_session('PM_DRUG')
today = datetime.now().strftime('%Y%m%d')
print('=== 일괄 APC 매핑 ===\n')
for name, drugcode, apc in MAPPINGS:
# 기존 가격 조회
existing = session.execute(text("""
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = :dc
ORDER BY SN DESC
"""), {'dc': drugcode}).fetchone()
if not existing:
print(f'{name}: 기존 레코드 없음')
continue
# 이미 APC 있는지 확인
check = session.execute(text("""
SELECT 1 FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = :dc AND CD_CD_BARCODE = :apc
"""), {'dc': drugcode, 'apc': apc}).fetchone()
if check:
print(f'⏭️ {name}: 이미 등록됨')
continue
# INSERT
try:
session.execute(text("""
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
) VALUES (
:drugcode, '015', 1.0, :my_unit, :in_unit,
:barcode, '', :change_date
)
"""), {
'drugcode': drugcode,
'my_unit': existing.CD_MY_UNIT,
'in_unit': existing.CD_IN_UNIT,
'barcode': apc,
'change_date': today
})
session.commit()
print(f'{name}{apc}')
except Exception as e:
session.rollback()
print(f'{name}: {e}')
session.close()
print('\n완료!')

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
# 테스트 AI 응답
ai_response = "개시딘은 피부염 치료에 사용하는 겔 형태의 외용약입니다."
drug_name = "(판)복합개시딘"
# 현재 매칭 로직
base_name = drug_name.split('(')[0].split('/')[0].strip()
print(f'제품명: {drug_name}')
print(f'괄호 앞: "{base_name}"')
# suffix 제거
for suffix in ['', '', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
if base_name.endswith(suffix):
base_name = base_name[:-len(suffix)]
base_name = base_name.strip()
print(f'suffix 제거 후: "{base_name}"')
# 매칭 테스트
ai_lower = ai_response.lower()
ai_nospace = ai_lower.replace(' ', '')
base_lower = base_name.lower()
base_nospace = base_lower.replace(' ', '')
print(f'\n매칭 테스트:')
print(f' "{base_lower}" in ai_response? {base_lower in ai_lower}')
print(f' "{base_nospace}" in ai_nospace? {base_nospace in ai_nospace}')
# 문제: (판)이 먼저 잘려서 빈 문자열이 됨!
print(f'\n문제: split("(")[0] = "{drug_name.split("(")[0]}"')
print('"(판)"에서 "("로 시작하니까 빈 문자열!')

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
# 테스트 AI 응답 (실제 응답 시뮬레이션)
ai_response = """
네, 안텔민은 개와 고양이 모두 사용 가능합니다!
**안텔민 킹** - 체중 5kg 이상 반려동물용
**안텔민 뽀삐** - 체중 5kg 이하 소형 반려동물용
두 제품 모두 개와 고양이의 내부 기생충 구제에 효과적입니다.
"""
animal_drugs = [
{'name': '안텔민킹(5kg이상)', 'code': 'LB000003157'},
{'name': '안텔민뽀삐(5kg이하)', 'code': 'LB000003158'},
{'name': '다이로하트정M(12~22kg)', 'code': 'LB000003151'},
]
print('=== 현재 매칭 로직 테스트 ===\n')
print(f'AI 응답:\n{ai_response}\n')
print('=' * 50)
ai_response_lower = ai_response.lower()
for drug in animal_drugs:
drug_name = drug['name']
base_name = drug_name.split('(')[0].split('/')[0].strip()
# suffix 제거
original_base = base_name
for suffix in ['', '', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
if base_name.endswith(suffix):
base_name = base_name[:-len(suffix)]
base_name = base_name.strip()
matched = base_name.lower() in ai_response_lower
print(f'\n제품: {drug_name}')
print(f' 괄호 앞: {original_base}')
print(f' suffix 제거 후: {base_name}')
print(f' 매칭 결과: {"✅ 매칭됨" if matched else "❌ 매칭 안됨"}')
if not matched:
# 왜 안 됐는지 확인
print(f'"{base_name.lower()}" in 응답? {base_name.lower() in ai_response_lower}')
# 띄어쓰기 변형 체크
spaced = base_name.replace('', '').replace('뽀삐', ' 뽀삐')
print(f' → 띄어쓰기 변형 "{spaced.lower()}" in 응답? {spaced.lower() in ai_response_lower}')

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
# 실제 AI 응답
ai_response = """안텔민은 개와 고양이 모두에게 사용할 수 있습니다만, 체중에 따라 복용할 용량이 다릅니다. 🐾
- **안텔민**: 5kg 이상 개와 고양이에게 복용 가능.
- **안텔민 뽀삐**: 5kg 미만 소형 반려동물에게 복용 가능.
따라서, 반려동물의 체중에 맞는 적절한 제품을 선택해야 해요! 🐶 체중을 알려주시면 더 구체적으로 안내해 드릴 수 있어요."""
animal_drugs = [
{'name': '안텔민', 'code': 'S0000001', 'apc': None},
{'name': '안텔민킹(5kg이상)', 'code': 'LB000003157', 'apc': '0230237810109'},
{'name': '안텔민뽀삐(5kg이하)', 'code': 'LB000003158', 'apc': '0230237010107'},
]
print('=== 매칭 테스트 ===\n')
print(f'AI 응답:\n{ai_response}\n')
print('=' * 50)
ai_response_lower = ai_response.lower()
ai_response_nospace = ai_response_lower.replace(' ', '')
for drug in animal_drugs:
drug_name = drug['name']
base_name = drug_name.split('(')[0].split('/')[0].strip()
for suffix in ['', '', 'L', 'M', 'S', 'XL', 'XS', 'SS', 'mini']:
if base_name.endswith(suffix):
base_name = base_name[:-len(suffix)]
base_name = base_name.strip()
base_lower = base_name.lower()
base_nospace = base_lower.replace(' ', '')
in_normal = base_lower in ai_response_lower
in_nospace = base_nospace in ai_response_nospace
matched = len(base_name) >= 2 and (in_normal or in_nospace)
print(f'\n제품: {drug_name}')
print(f' base_name: "{base_name}"')
print(f' base_nospace: "{base_nospace}"')
print(f' 일반매칭: {in_normal}')
print(f' 공백제거매칭: {in_nospace}')
print(f' 최종: {"" if matched else ""}')

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
# _get_animal_drugs 로직 복제
drug_session = get_db_session('PM_DRUG')
query = text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
ORDER BY U.CHANGE_DATE DESC
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
""")
rows = drug_session.execute(query).fetchall()
print('=== AI에 전달되는 보유 제품 목록 ===\n')
for r in rows:
apc = r.APC_CODE
rag_info = ""
if apc:
rag_info = f" [대상: 개, 고양이]" # RAG 정보 시뮬레이션
print(f"- {r.GoodsName} ({r.Saleprice:,.0f}원){rag_info}")
print('\n=== 안텔민 관련 제품만 ===')
for r in rows:
if '안텔민' in r.GoodsName:
print(f" {r.GoodsName} - APC: {r.APC_CODE}")

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
from sqlalchemy import create_engine, text
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 안텔민킹 RAG 정보
apc = '0230237810109'
print(f'=== 안텔민킹 ({apc}) RAG 정보 ===\n')
result = pg.execute(text(f"""
SELECT
product_name,
llm_pharm->>'사용가능 동물' as target_animals,
llm_pharm->>'분류' as category,
llm_pharm->>'체중/부위' as dosage_weight,
llm_pharm->>'월령금기' as age_restriction
FROM apc
WHERE apc = '{apc}'
"""))
row = result.fetchone()
if row:
print(f'제품명: {row.product_name}')
print(f'사용가능 동물: {row.target_animals}')
print(f'분류: {row.category}')
print(f'체중/용량: {row.dosage_weight}')
print(f'월령금기: {row.age_restriction}')
# efficacy_effect도 확인
result2 = pg.execute(text(f"""
SELECT efficacy_effect FROM apc WHERE apc = '{apc}'
"""))
row2 = result2.fetchone()
if row2 and row2.efficacy_effect:
print(f'\n효능/효과 (원문 일부):')
print(row2.efficacy_effect[:500])
pg.close()

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
# 1. _get_animal_drugs 시뮬레이션
drug_session = get_db_session('PM_DRUG')
query = text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
ORDER BY U.CHANGE_DATE DESC
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
""")
rows = drug_session.execute(query).fetchall()
animal_drugs = []
for r in rows:
animal_drugs.append({
'code': r.DrugCode,
'name': r.GoodsName,
'price': float(r.Saleprice) if r.Saleprice else 0,
'apc': r.APC_CODE
})
# 2. _get_animal_drug_rag 시뮬레이션
apc_codes = [d['apc'] for d in animal_drugs if d.get('apc')]
print(f'APC 코드 목록: {apc_codes}\n')
rag_data = {}
if apc_codes:
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
placeholders = ','.join([f"'{apc}'" for apc in apc_codes])
result = pg.execute(text(f"""
SELECT apc, product_name,
llm_pharm->>'사용가능 동물' as target_animals,
llm_pharm->>'분류' as category,
llm_pharm->>'체중/부위' as dosage_weight,
llm_pharm->>'기간/용법' as usage_period,
llm_pharm->>'월령금기' as age_restriction
FROM apc
WHERE apc IN ({placeholders})
"""))
for row in result:
rag_data[row.apc] = {
'target_animals': row.target_animals or '정보 없음',
'category': row.category or '',
'dosage_weight': row.dosage_weight or '',
'usage_period': row.usage_period or '',
'age_restriction': row.age_restriction or ''
}
pg.close()
print(f'RAG 데이터: {rag_data}\n')
# 3. available_products_text 생성
print('=== AI에 전달되는 제품 목록 (RAG 포함) ===\n')
for d in animal_drugs:
if '안텔민' in d['name']:
line = f"- {d['name']} ({d['price']:,.0f}원)"
if d.get('apc') and d['apc'] in rag_data:
info = rag_data[d['apc']]
details = []
if info.get('target_animals'):
details.append(f"대상: {info['target_animals']}")
if info.get('dosage_weight'):
details.append(f"용량: {info['dosage_weight']}")
if info.get('age_restriction'):
details.append(f"금기: {info['age_restriction']}")
if details:
line += f" [{', '.join(details)}]"
print(line)

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from sqlalchemy import create_engine, text
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 약국 제품 → PostgreSQL 매칭 (체중/용량 포함)
mappings = [
# (약국제품명, 검색키워드)
('제스타제(10정)', '제스타제', '10'),
('파라캅L(5kg이상)', '파라캅', 'L'),
('파라캅S(5kg이하)', '파라캅', 'S'),
('하트캅츄어블(11kg이하)', '하트캅', '11'),
('넥스가드L(15~30kg)', '넥스가드', '15'),
('넥스가드xs(2~3.5kg)', '넥스가드', '2'),
('다이로하트정M(12~22kg)', '다이로하트', '12'),
('다이로하트정S(5.6~11kg)', '다이로하트', '5.6'),
('다이로하트정SS(5.6kg이하)', '다이로하트', 'SS'),
('세레니아정16mg(개멀미약)', '세레니아', '16'),
('세레니아정24mg(개멀미약)', '세레니아', '24'),
('하트세이버츄어블M(12~22kg)', '하트세이버', '12'),
('하트세이버츄어블S(5.6~11kg)', '하트세이버', '5.6'),
('하트웜솔루션츄어블M(12~22kg)', '하트웜', '12'),
('하트웜솔루션츄어블S(11kg이하)', '하트웜', '11'),
]
print('=== 상세 매칭 검색 ===\n')
for pharm_name, keyword, size in mappings:
result = pg.execute(text("""
SELECT apc, product_name, packaging,
llm_pharm->>'사용가능 동물' as target
FROM apc
WHERE product_name ILIKE :kw
ORDER BY product_name
LIMIT 10
"""), {'kw': f'%{keyword}%'})
print(f'\n📦 {pharm_name} (검색: {keyword}, 사이즈: {size})')
for r in result:
mark = '' if size.lower() in r.product_name.lower() else ' '
print(f'{mark} {r.apc}: {r.product_name[:50]}')
pg.close()

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('업데이트 전:')
r = session.execute(text("SELECT GoodsName, POS_BOON FROM CD_GOODS WHERE DrugCode = 'LB000003140'")).fetchone()
print(f' {r.GoodsName}: POS_BOON = {r.POS_BOON}')
session.execute(text("UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = 'LB000003140'"))
session.commit()
print('\n업데이트 후:')
r2 = session.execute(text("SELECT GoodsName, POS_BOON FROM CD_GOODS WHERE DrugCode = 'LB000003140'")).fetchone()
print(f' {r2.GoodsName}: POS_BOON = {r2.POS_BOON}')
print(' ✅ 완료!')
session.close()

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
from datetime import datetime
session = get_db_session('PM_DRUG')
# 1. 기존 데이터에서 가격 정보 가져오기
print('1. 기존 레코드에서 가격 정보 조회...')
existing = session.execute(text("""
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003140'
ORDER BY SN DESC
""")).fetchone()
sale_price = existing.CD_MY_UNIT
purchase_price = existing.CD_IN_UNIT
print(f' 판매가: {sale_price:,.0f}')
print(f' 입고가: {purchase_price:,.0f}')
# 2. 오늘 날짜
today = datetime.now().strftime('%Y%m%d')
print(f'\n2. 날짜: {today}')
# 3. INSERT 실행
print('\n3. INSERT 실행...')
apc_code = '0231093520106' # 복합개시딘 10g
try:
session.execute(text("""
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
) VALUES (
:drugcode, :unit, :nm_unit, :my_unit, :in_unit,
:barcode, :pos, :change_date
)
"""), {
'drugcode': 'LB000003140',
'unit': '015',
'nm_unit': 1.0,
'my_unit': sale_price,
'in_unit': purchase_price,
'barcode': apc_code,
'pos': '',
'change_date': today
})
session.commit()
print(f' ✅ 성공! APC {apc_code} 추가됨')
# 4. 확인
print('\n4. 결과 확인...')
result = session.execute(text("""
SELECT DRUGCODE, CD_CD_BARCODE, CD_MY_UNIT, SN
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003140' AND CD_CD_BARCODE = :apc
"""), {'apc': apc_code})
row = result.fetchone()
if row:
print(f' DRUGCODE: {row.DRUGCODE}')
print(f' BARCODE: {row.CD_CD_BARCODE}')
print(f' SN: {row.SN}')
except Exception as e:
session.rollback()
print(f' ❌ 실패: {e}')
session.close()

View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""
안텔민뽀삐 APC 추가 실행 (SN 자동 생성)
"""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
from datetime import datetime
session = get_db_session('PM_DRUG')
# 1. 기존 데이터에서 가격 정보 가져오기
print('1. 기존 레코드에서 가격 정보 조회...')
existing = session.execute(text("""
SELECT TOP 1 CD_MY_UNIT, CD_IN_UNIT
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003158'
ORDER BY SN DESC
""")).fetchone()
sale_price = existing.CD_MY_UNIT
purchase_price = existing.CD_IN_UNIT
print(f' 판매가: {sale_price:,.0f}')
print(f' 입고가: {purchase_price:,.0f}')
# 2. 오늘 날짜
today = datetime.now().strftime('%Y%m%d')
print(f'\n2. 날짜: {today}')
# 3. INSERT 실행 (SN은 IDENTITY 자동 생성)
print('\n3. INSERT 실행...')
apc_code = '0230237010107' # 안텔민뽀삐 10정
try:
session.execute(text("""
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE,
CD_CD_UNIT,
CD_NM_UNIT,
CD_MY_UNIT,
CD_IN_UNIT,
CD_CD_BARCODE,
CD_CD_POS,
CHANGE_DATE
) VALUES (
:drugcode,
:unit,
:nm_unit,
:my_unit,
:in_unit,
:barcode,
:pos,
:change_date
)
"""), {
'drugcode': 'LB000003158',
'unit': '015',
'nm_unit': 1.0,
'my_unit': sale_price,
'in_unit': purchase_price,
'barcode': apc_code,
'pos': '',
'change_date': today
})
session.commit()
print(f' ✅ 성공! APC {apc_code} 추가됨')
# 4. 확인
print('\n4. 결과 확인...')
result = session.execute(text("""
SELECT * FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003158' AND CD_CD_BARCODE = :apc
"""), {'apc': apc_code})
row = result.fetchone()
if row:
print(' --- 추가된 레코드 ---')
for col in result.keys():
print(f' {col}: {getattr(row, col)}')
except Exception as e:
session.rollback()
print(f' ❌ 실패: {e}')
session.close()

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('=== 펫팜 공급 동물약 ===\n')
result = session.execute(text("""
SELECT
G.DrugCode,
G.GoodsName,
G.POS_BOON,
S.SplName,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
) AS APC_CODE
FROM CD_GOODS G
LEFT JOIN CD_SALEGOODS S ON G.DrugCode = S.DrugCode
WHERE S.SplName LIKE N'%펫팜%'
ORDER BY G.GoodsName
"""))
for row in result:
apc_status = f'{row.APC_CODE}' if row.APC_CODE else '❌ 없음'
boon_status = '🐾' if row.POS_BOON == '010103' else ' '
print(f'{boon_status} {row.GoodsName}')
print(f' APC: {apc_status}')
session.close()

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""APC 매칭 성능 측정"""
import sys, io, time
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
print('=== APC 매칭 성능 측정 ===\n')
# 1. MSSQL: 동물약 + APC 조회
start = time.time()
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT G.DrugCode, G.GoodsName,
(SELECT TOP 1 U.CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode AND U.CD_CD_BARCODE LIKE '023%') AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103' AND G.GoodsSelCode = 'B'
"""))
mssql_rows = list(result)
no_apc = [r for r in mssql_rows if not r.APC_CODE]
has_apc = [r for r in mssql_rows if r.APC_CODE]
mssql_time = time.time() - start
print(f'1. MSSQL 동물약 조회: {mssql_time:.3f}')
print(f' - 총 제품: {len(mssql_rows)}')
print(f' - APC 있음: {len(has_apc)}개 ✅')
print(f' - APC 없음: {len(no_apc)}개 ❌')
# 2. PostgreSQL 연결 + 매칭 검색
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 샘플 매칭 테스트
sample_count = min(5, len(no_apc))
start = time.time()
match_count = 0
for drug in no_apc[:sample_count]:
search_name = drug.GoodsName.replace('(판)', '').split('(')[0].strip()
res = pg.execute(text("""
SELECT apc, product_name FROM apc
WHERE product_name ILIKE :p LIMIT 5
"""), {'p': f'%{search_name}%'})
if list(res):
match_count += 1
pg_search_time = time.time() - start
per_search = pg_search_time / sample_count if sample_count > 0 else 0
print(f'\n2. PostgreSQL 매칭 검색: {pg_search_time:.3f}초 ({sample_count}개 샘플)')
print(f' - 건당 소요: {per_search*1000:.1f}ms')
print(f' - 매칭 성공: {match_count}/{sample_count}')
print(f' - 예상 전체: {per_search * len(no_apc):.1f}초 ({len(no_apc)}개)')
# 3. APC 테이블 통계
start = time.time()
total_apc = pg.execute(text("SELECT COUNT(*) FROM apc")).scalar()
with_image = pg.execute(text("SELECT COUNT(*) FROM apc WHERE image_url1 IS NOT NULL AND image_url1 != ''")).scalar()
pg.close()
print(f'\n3. APDB 통계:')
print(f' - 전체 APC: {total_apc:,}')
print(f' - 이미지 있음: {with_image:,}개 ({with_image/total_apc*100:.1f}%)')
# 4. CD_ITEM_UNIT_MEMBER 구조 확인
print(f'\n4. 현재 APC 매핑 상태:')
for r in has_apc[:5]:
print(f'{r.GoodsName[:25]:<25}{r.APC_CODE}')
session.close()
print('\n=== 측정 완료 ===')

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
안텔민뽀삐 APC 추가 준비 스크립트
- CD_ITEM_UNIT_MEMBER 구조 확인
- 안텔민킹 레코드 참고
- INSERT 쿼리 생성 (실행 안 함)
"""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('=' * 60)
print('1. CD_ITEM_UNIT_MEMBER 테이블 구조')
print('=' * 60)
result = session.execute(text("""
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'CD_ITEM_UNIT_MEMBER'
ORDER BY ORDINAL_POSITION
"""))
for r in result:
nullable = 'NULL' if r.IS_NULLABLE == 'YES' else 'NOT NULL'
length = f'({r.CHARACTER_MAXIMUM_LENGTH})' if r.CHARACTER_MAXIMUM_LENGTH else ''
print(f' {r.COLUMN_NAME}: {r.DATA_TYPE}{length} {nullable}')
print('\n' + '=' * 60)
print('2. 안텔민킹 APC 레코드 (참고용)')
print('=' * 60)
result = session.execute(text("""
SELECT * FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003157'
AND CD_CD_BARCODE LIKE '023%'
"""))
row = result.fetchone()
if row:
cols = result.keys()
for col in cols:
val = getattr(row, col)
print(f' {col}: {val}')
print('\n' + '=' * 60)
print('3. 안텔민뽀삐 현재 레코드')
print('=' * 60)
result2 = session.execute(text("""
SELECT * FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003158'
ORDER BY SN DESC
"""))
rows = list(result2)
print(f'{len(rows)}개 레코드')
for row in rows[:3]:
print(f'\n --- SN: {row.SN} ---')
cols = result2.keys()
for col in cols:
val = getattr(row, col)
print(f' {col}: {val}')
print('\n' + '=' * 60)
print('4. 다음 SN 값 확인')
print('=' * 60)
result3 = session.execute(text("SELECT MAX(SN) as max_sn FROM CD_ITEM_UNIT_MEMBER"))
max_sn = result3.fetchone().max_sn
print(f' 현재 MAX(SN): {max_sn}')
print(f' 다음 SN: {max_sn + 1}')
session.close()
print('\n' + '=' * 60)
print('5. PostgreSQL에서 안텔민뽀삐 APC 확인')
print('=' * 60)
from sqlalchemy import create_engine
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
result4 = pg.execute(text("""
SELECT apc, product_name
FROM apc
WHERE product_name ILIKE '%안텔민%뽀삐%' OR product_name ILIKE '%안텔민%5kg%이하%'
ORDER BY apc
"""))
for r in result4:
print(f' APC: {r.apc}')
print(f' 제품명: {r.product_name}')
print()
pg.close()

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""
애니팜 PostgreSQL 조회 스크립트
Usage:
python scripts/query_aniparm.py schema # 테이블 구조 확인
python scripts/query_aniparm.py search <제품명> # 제품 검색
python scripts/query_aniparm.py barcode <바코드> # 바코드로 검색
python scripts/query_aniparm.py sample # 샘플 데이터
python scripts/query_aniparm.py stats # 통계
"""
import sys
import io
import json
# ═══════════════════════════════════════════════════════════
# 인코딩 설정 (Windows CP949 문제 방지)
# ═══════════════════════════════════════════════════════════
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
from sqlalchemy import create_engine, text
# PostgreSQL 연결
DATABASE_URI = 'postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master'
def get_connection():
engine = create_engine(DATABASE_URI)
return engine.connect()
def cmd_schema():
"""apc 테이블 구조 확인"""
conn = get_connection()
result = conn.execute(text("""
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'apc'
ORDER BY ordinal_position
"""))
print('=== apc 테이블 컬럼 ===')
for row in result:
length = f'({row.character_maximum_length})' if row.character_maximum_length else ''
print(f' {row.column_name}: {row.data_type}{length}')
conn.close()
def cmd_search(keyword):
"""제품명으로 검색"""
conn = get_connection()
result = conn.execute(text("""
SELECT idx, apc, product_name, company_name,
image_url1, godoimage_url_f, for_pets
FROM apc
WHERE product_name ILIKE :keyword
LIMIT 20
"""), {'keyword': f'%{keyword}%'})
print(f'=== "{keyword}" 검색 결과 ===')
count = 0
for row in result:
count += 1
print(f'\n[{count}] {row.product_name}')
print(f' APC: {row.apc}')
print(f' 제조사: {row.company_name}')
print(f' 동물용: {row.for_pets}')
if row.image_url1:
print(f' 이미지1: {row.image_url1[:50]}...')
if row.godoimage_url_f:
print(f' 고도몰F: {row.godoimage_url_f[:50]}...')
if count == 0:
print('(결과 없음)')
conn.close()
def cmd_barcode(barcode):
"""바코드로 검색 - 바코드 컬럼이 있는지 먼저 확인"""
conn = get_connection()
# 바코드 관련 컬럼 찾기
result = conn.execute(text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'apc'
AND column_name ILIKE '%barcode%'
"""))
barcode_cols = [row.column_name for row in result]
if not barcode_cols:
print('apc 테이블에 barcode 관련 컬럼이 없습니다.')
print('다른 컬럼으로 검색해야 합니다.')
else:
print(f'바코드 컬럼 발견: {barcode_cols}')
# TODO: 바코드로 검색 구현
conn.close()
def cmd_sample():
"""샘플 데이터 (동물용 제품)"""
conn = get_connection()
result = conn.execute(text("""
SELECT idx, apc, product_name, company_name,
image_url1, godoimage_url_f, for_pets
FROM apc
WHERE for_pets = true
LIMIT 10
"""))
print('=== 동물용 제품 샘플 ===')
count = 0
for row in result:
count += 1
print(f'\n[{count}] {row.product_name}')
print(f' APC: {row.apc}')
print(f' 제조사: {row.company_name}')
img = row.image_url1 or row.godoimage_url_f or '(없음)'
if len(img) > 50:
img = img[:50] + '...'
print(f' 이미지: {img}')
if count == 0:
print('(동물용 제품 없음 - for_pets 필터 확인 필요)')
conn.close()
def cmd_stats():
"""통계"""
conn = get_connection()
result = conn.execute(text("""
SELECT
COUNT(*) as total,
SUM(CASE WHEN for_pets = true THEN 1 ELSE 0 END) as pet_count,
SUM(CASE WHEN image_url1 IS NOT NULL AND image_url1 != '' THEN 1 ELSE 0 END) as has_img1,
SUM(CASE WHEN godoimage_url_f IS NOT NULL AND godoimage_url_f != '' THEN 1 ELSE 0 END) as has_godo_f
FROM apc
"""))
row = result.fetchone()
print('=== apc 테이블 통계 ===')
print(f'전체 제품: {row.total:,}')
print(f'동물용(for_pets=true): {row.pet_count:,}')
print(f'image_url1 있음: {row.has_img1:,}')
print(f'godoimage_url_f 있음: {row.has_godo_f:,}')
conn.close()
def main():
if len(sys.argv) < 2:
print(__doc__)
return
cmd = sys.argv[1]
if cmd == 'schema':
cmd_schema()
elif cmd == 'search' and len(sys.argv) > 2:
cmd_search(sys.argv[2])
elif cmd == 'barcode' and len(sys.argv) > 2:
cmd_barcode(sys.argv[2])
elif cmd == 'sample':
cmd_sample()
elif cmd == 'stats':
cmd_stats()
else:
print(__doc__)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
import sys, io, json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from sqlalchemy import create_engine, text
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 안텔민킹 llm_pharm 전체 확인
result = pg.execute(text("""
SELECT product_name, llm_pharm FROM apc WHERE apc = '0230237810109'
"""))
row = result.fetchone()
print('=== 안텔민킹 llm_pharm 전체 키 ===\n')
data = row.llm_pharm
for k in sorted(data.keys()):
val = str(data[k])
if len(val) > 60:
val = val[:60] + '...'
print(f' {k}: {val}')
# 동물약 전체 개수
print('\n=== PostgreSQL 동물약 전체 개수 ===')
result2 = pg.execute(text("SELECT COUNT(*) FROM apc"))
print(f' 전체: {result2.fetchone()[0]}')
pg.close()

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from app import _get_animal_drugs
drugs = _get_animal_drugs()
gestage = [d for d in drugs if '제스타제' in d['name']]
print('=== 제스타제 API 결과 ===\n')
for d in gestage:
print(f"name: {d['name']}")
print(f"barcode: {d['barcode']}")
print(f"apc: {d['apc']}")
print(f"image_url: {d['image_url']}")

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""pets 테이블 마이그레이션 테스트"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import db_manager
# SQLite 연결 (마이그레이션 자동 실행)
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# pets 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
if cursor.fetchone():
print('✅ pets 테이블 생성 완료')
cursor.execute('PRAGMA table_info(pets)')
columns = cursor.fetchall()
print('\n컬럼 목록:')
for col in columns:
print(f' - {col[1]} ({col[2]})')
else:
print('❌ pets 테이블 없음')

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text
session = get_db_session('PM_DRUG')
print('1. 현재 상태 확인...')
result = session.execute(text("""
SELECT DrugCode, GoodsName, POS_BOON
FROM CD_GOODS
WHERE DrugCode = 'LB000003140'
"""))
row = result.fetchone()
print(f' {row.GoodsName}: POS_BOON = {row.POS_BOON}')
print('\n2. POS_BOON을 동물약(010103)으로 업데이트...')
try:
session.execute(text("""
UPDATE CD_GOODS
SET POS_BOON = '010103'
WHERE DrugCode = 'LB000003140'
"""))
session.commit()
print(' ✅ 성공!')
# 확인
result2 = session.execute(text("""
SELECT DrugCode, GoodsName, POS_BOON
FROM CD_GOODS
WHERE DrugCode = 'LB000003140'
"""))
row2 = result2.fetchone()
print(f' {row2.GoodsName}: POS_BOON = {row2.POS_BOON}')
except Exception as e:
session.rollback()
print(f' ❌ 실패: {e}')
session.close()

View File

@@ -98,6 +98,89 @@ class KakaoAPIClient:
'error_description': f'Invalid JSON response: {e}'
}
def refresh_access_token(self, refresh_token: str) -> Tuple[bool, Dict[str, Any]]:
"""Refresh Token으로 Access Token 갱신"""
url = f"{self.auth_base_url}/oauth/token"
data = {
'grant_type': 'refresh_token',
'client_id': self.client_id,
'refresh_token': refresh_token,
}
if self.client_secret:
data['client_secret'] = self.client_secret
try:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
response = self.session.post(url, data=data, headers=headers)
logger.info(f"카카오 토큰 갱신 응답 상태: {response.status_code}")
response.raise_for_status()
token_data = response.json()
if 'expires_in' in token_data:
expires_at = datetime.now() + timedelta(seconds=token_data['expires_in'])
token_data['expires_at'] = expires_at.isoformat()
return True, token_data
except requests.exceptions.RequestException as e:
logger.error(f"카카오 토큰 갱신 실패: {e}")
error_details = {
'error': 'token_refresh_failed',
'error_description': f'Failed to refresh access token: {e}'
}
try:
if hasattr(e, 'response') and e.response is not None:
kakao_error = e.response.json()
logger.error(f"카카오 API 오류: {kakao_error}")
error_details.update(kakao_error)
except Exception:
pass
return False, error_details
def get_user_info_with_refresh(
self,
access_token: str,
refresh_token: str,
token_expires_at: str = None
) -> Tuple[bool, Dict[str, Any], Dict[str, Any]]:
"""저장된 토큰으로 사용자 정보 조회 (만료 시 자동 갱신)
Returns:
(성공여부, 사용자정보/에러, 갱신된 토큰 데이터 또는 빈 dict)
"""
new_token_data = {}
# 만료 확인: 5분 이내면 미리 갱신
if token_expires_at:
try:
expires = datetime.fromisoformat(token_expires_at)
if datetime.now() >= expires - timedelta(minutes=5):
logger.info("Access token 만료 임박, 갱신 시도")
success, refreshed = self.refresh_access_token(refresh_token)
if success:
access_token = refreshed['access_token']
new_token_data = refreshed
else:
return False, refreshed, {}
except (ValueError, TypeError) as e:
logger.warning(f"token_expires_at 파싱 실패, 기존 토큰으로 시도: {e}")
# 사용자 정보 조회
success, user_info = self.get_user_info(access_token)
if not success and refresh_token:
# 실패 시 갱신 후 재시도
logger.info("사용자 정보 조회 실패, 토큰 갱신 후 재시도")
refresh_ok, refreshed = self.refresh_access_token(refresh_token)
if refresh_ok:
access_token = refreshed['access_token']
new_token_data = refreshed
success, user_info = self.get_user_info(access_token)
return success, user_info, new_token_data
def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]:
"""Access Token으로 사용자 정보 조회"""
url = f"{self.api_base_url}/v2/user/me"

View File

@@ -87,7 +87,16 @@ def _send_alimtalk(template_code, recipient_no, template_params):
logger.info(f"알림톡 발송 성공: {template_code}{recipient_no}")
return (True, "발송 성공")
else:
error_msg = result.get('header', {}).get('resultMessage', str(result))
# 상세 에러 추출: sendResults[0].resultMessage 우선, 없으면 header.resultMessage
header_msg = result.get('header', {}).get('resultMessage', '')
send_results = result.get('message', {}).get('sendResults', [])
detail_msg = send_results[0].get('resultMessage', '') if send_results else ''
# 상세 에러가 있으면 그걸 사용, 없으면 header 에러
error_msg = detail_msg if detail_msg and detail_msg != 'SUCCESS' else header_msg
if not error_msg:
error_msg = str(result)
logger.warning(f"알림톡 발송 실패: {template_code}{recipient_no}: {error_msg}")
return (False, error_msg)
@@ -100,15 +109,25 @@ def _send_alimtalk(template_code, recipient_no, template_params):
def build_item_summary(items):
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')"""
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')
Note: 카카오 알림톡 템플릿 변수는 14자 제한
(에러: "Blacklist can't use more than 14 characters in template value.")
특수문자(%, 괄호 등)는 문제없이 발송 가능!
"""
if not items:
return "약국 구매"
first = items[0]['name']
if len(first) > 20:
first = first[:18] + '..'
first = first.strip()
if len(items) == 1:
return first
return f"{first}{len(items) - 1}"
# 단일 품목: 14자 제한 (그냥 자름)
return first[:14]
# 복수 품목: "외 N건" 붙으므로 전체 14자 맞춤
suffix = f"{len(items) - 1}"
max_first = 14 - len(suffix)
return f"{first[:max_first]}{suffix}"
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
@@ -146,24 +165,7 @@ def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
success, msg = _send_alimtalk(template_code, phone, params)
if not success:
# V3 실패 로그
_log_to_db(template_code, phone, False, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
# V2 폴백
template_code = 'MILEAGE_CLAIM_V2'
params = {
'고객명': name,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst,
'전화번호': phone
}
success, msg = _send_alimtalk(template_code, phone, params)
# 최종 결과 로그
# 결과 로그 (V3만 사용, V2 폴백 제거 - V2 반려 상태)
_log_to_db(template_code, phone, success, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

View File

@@ -20,6 +20,41 @@
-webkit-font-smoothing: antialiased;
}
/* 토스트 알림 */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
padding: 14px 20px;
border-radius: 10px;
color: white;
font-weight: 500;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
display: flex;
align-items: center;
gap: 10px;
}
.toast.success { background: linear-gradient(135deg, #10b981 0%, #059669 100%); }
.toast.error { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.toast.info { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
.toast.printing { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
@keyframes toastIn {
from { opacity: 0; transform: translateX(100px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100px); }
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 32px 24px;
@@ -457,8 +492,44 @@
{% endif %}
</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #fef3c7, #fde68a);">
<div class="stat-label" style="color: #92400e;">🐾 등록 반려동물</div>
<div class="stat-value" style="color: #92400e;">
{{ pet_stats.total_pets or 0 }}마리
<span style="font-size: 14px; font-weight: 500; margin-left: 8px;">
(🐕 {{ pet_stats.dog_count or 0 }} / 🐈 {{ pet_stats.cat_count or 0 }})
</span>
</div>
</div>
</div>
<!-- 최근 등록 반려동물 -->
{% if recent_pets %}
<div class="section">
<div class="section-title">🐾 최근 등록 반려동물 (10마리)</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
{% for pet in recent_pets %}
<div style="display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: linear-gradient(135deg, #fef3c7, #fde68a); border-radius: 14px; min-width: 220px;">
{% if pet.photo_url %}
<img src="{{ pet.photo_url }}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.15);">
{% else %}
<div style="width: 48px; height: 48px; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);">
{% if pet.species == 'dog' %}🐕{% elif pet.species == 'cat' %}🐈{% else %}🐾{% endif %}
</div>
{% endif %}
<div>
<div style="font-weight: 700; font-size: 15px; color: #92400e;">
{% if pet.species == 'dog' %}🐕{% elif pet.species == 'cat' %}🐈{% else %}🐾{% endif %} {{ pet.name }}
</div>
<div style="font-size: 12px; color: #a16207;">{{ pet.breed or '품종 미등록' }}</div>
<div style="font-size: 11px; color: #b45309; margin-top: 2px;">{{ pet.owner_name }} ({{ pet.owner_phone[:3] }}-****-{{ pet.owner_phone[-4:] }})</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- 최근 가입 사용자 -->
<div class="section">
<div class="section-title">최근 가입 사용자 (20명)</div>
@@ -850,6 +921,113 @@
function closeUserModal() {
document.getElementById('userDetailModal').style.display = 'none';
}
// 특이사항 펼치기/접기 (클릭 시)
function toggleCusetc(el) {
if (el.style.maxHeight === 'none' || el.style.maxHeight === '') {
el.style.maxHeight = '40px';
el.style.overflow = 'hidden';
} else {
el.style.maxHeight = 'none';
el.style.overflow = 'visible';
}
}
// 특이사항 수정 모드
function editCusetc(cuscode, btn) {
document.getElementById('cusetc-view').style.display = 'none';
document.getElementById('cusetc-edit').style.display = 'block';
document.getElementById('cusetc-textarea').focus();
btn.style.display = 'none';
}
// 특이사항 저장
async function saveCusetc(cuscode) {
const textarea = document.getElementById('cusetc-textarea');
const newValue = textarea.value.trim();
try {
const res = await fetch(`/api/members/${cuscode}/cusetc`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cusetc: newValue })
});
const data = await res.json();
if (data.success) {
// 뷰 업데이트
const viewEl = document.getElementById('cusetc-view');
viewEl.innerHTML = newValue || '<span style="color: #9ca3af; font-weight: normal;">없음</span>';
viewEl.style.maxHeight = newValue.length > 30 ? '40px' : 'none';
cancelCusetc();
alert('✅ 저장되었습니다.');
} else {
alert('❌ ' + (data.error || '저장 실패'));
}
} catch (err) {
alert('❌ 오류: ' + err.message);
}
}
// 특이사항 수정 취소
function cancelCusetc() {
document.getElementById('cusetc-view').style.display = 'block';
document.getElementById('cusetc-edit').style.display = 'none';
// 수정 버튼 다시 표시
const editBtn = document.querySelector('#cusetc-view').parentElement.querySelector('button');
if (editBtn) editBtn.style.display = 'inline-block';
}
// 토스트 알림 함수
function showToast(message, type = 'info') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = { success: '✅', error: '❌', info: '', printing: '🖨️' };
toast.innerHTML = `<span>${icons[type] || ''}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 인쇄용 전역 변수
let printData = { name: '', cusetc: '', phone: '' };
// 특이사항 인쇄 실행
async function doPrintCusetc() {
// 즉시 피드백
showToast(`${printData.name}님 특이사항 인쇄 중...`, 'printing');
try {
const res = await fetch('/api/print/cusetc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_name: printData.name,
cusetc: printData.cusetc,
phone: printData.phone
})
});
const data = await res.json();
if (data.success) {
showToast(data.message, 'success');
} else {
showToast('인쇄 실패: ' + (data.error || '알 수 없는 오류'), 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
function renderUserDetail(data) {
// 전역 변수에 데이터 저장
@@ -888,6 +1066,26 @@
<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>
` : ''}
<!-- 특이(참고)사항 - 생일 옆 칸 -->
${data.pos_customer ? `
<div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
<span style="color: #d97706; font-size: 13px;">⚠️ 특이사항</span>
<button onclick="editCusetc('${data.pos_customer.cuscode}', this)" style="background: none; border: 1px solid #d97706; color: #d97706; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">✏️ 수정</button>
${data.pos_customer.cusetc ? `<button onclick="printData={name:'${data.pos_customer.name}',cusetc:decodeURIComponent('${encodeURIComponent(data.pos_customer.cusetc)}'),phone:'${user.phone||''}'};doPrintCusetc()" style="background: none; border: 1px solid #6b7280; color: #6b7280; font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer;">🖨️ 인쇄</button>` : ''}
</div>
<div id="cusetc-view" onclick="toggleCusetc(this)" style="color: #92400e; font-size: 14px; font-weight: 500; cursor: ${(data.pos_customer.cusetc || '').length > 30 ? 'pointer' : 'default'}; ${(data.pos_customer.cusetc || '').length > 30 ? 'max-height: 40px; overflow: hidden;' : ''}" title="${(data.pos_customer.cusetc || '').length > 30 ? '클릭하여 펼치기' : ''}">
${data.pos_customer.cusetc || '<span style="color: #9ca3af; font-weight: normal;">없음</span>'}
</div>
<div id="cusetc-edit" style="display: none;">
<textarea id="cusetc-textarea" style="width: 100%; min-height: 60px; padding: 8px; border: 1px solid #d97706; border-radius: 6px; font-size: 13px; resize: vertical;">${data.pos_customer.cusetc || ''}</textarea>
<div style="display: flex; gap: 6px; margin-top: 6px;">
<button onclick="saveCusetc('${data.pos_customer.cuscode}')" style="background: #d97706; color: white; border: none; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;">저장</button>
<button onclick="cancelCusetc()" style="background: #e5e7eb; color: #374151; border: none; padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;">취소</button>
</div>
</div>
</div>
` : ''}
</div>
<div style="text-align: right; display: flex; gap: 8px; justify-content: flex-end;">
<button onclick="showAIAnalysisModal(${user.id})" style="padding: 10px 24px; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
@@ -913,6 +1111,9 @@
<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 onclick="switchTab('pets')" id="tab-pets" 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.pets ? data.pets.length : 0})
</button>
</div>
<!-- 정렬 버튼 (구매 이력용) -->
@@ -999,6 +1200,10 @@
`;
}).join('');
// 약품 코드 배열 (상호작용 체크용)
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
const drugCodesJson = JSON.stringify(drugCodes).replace(/"/g, '&quot;');
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;">
@@ -1009,6 +1214,14 @@
🏥 ${rx.hospital || ''} · ${rx.doctor || ''}
</div>
${rx.items && rx.items.length > 0 ? `<div style="background: #f8f9fa; border-radius: 8px; padding: 12px;">${itemsHtml}</div>` : ''}
${drugCodes.length >= 2 ? `
<div style="margin-top: 12px; text-align: right;">
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
style="background: linear-gradient(135deg, #8b5cf6, #6366f1); color: #fff; border: none; padding: 8px 14px; border-radius: 8px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
🔬 AI 상호작용 체크
</button>
</div>
` : ''}
</div>
`;
});
@@ -1058,6 +1271,53 @@
html += '<p style="text-align: center; padding: 40px; color: #868e96;">💝 관심 상품이 없습니다<br><small>마일리지 적립 시 AI 추천에서 "관심있어요"를 누르면 여기에 표시됩니다</small></p>';
}
html += `
</div>
<!-- 반려동물 탭 -->
<div id="tab-content-pets" class="tab-content" style="display: none;">
`;
// 반려동물 렌더링
const pets = data.pets || [];
if (pets.length > 0) {
html += '<div style="display: grid; gap: 16px;">';
pets.forEach(pet => {
const photoHtml = pet.photo_url
? `<img src="${pet.photo_url}" alt="${pet.name}" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover;">`
: `<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #fbbf24, #f59e0b); display: flex; align-items: center; justify-content: center; font-size: 36px;">${pet.species === 'dog' ? '🐕' : (pet.species === 'cat' ? '🐈' : '🐾')}</div>`;
html += `
<div style="border: 1px solid #e9ecef; border-radius: 16px; padding: 20px; display: flex; gap: 20px; align-items: center; border-left: 4px solid #f59e0b;">
<div style="flex-shrink: 0;">
${photoHtml}
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<span style="font-size: 20px; font-weight: 700; color: #212529;">${pet.name}</span>
<span style="background: ${pet.species === 'dog' ? '#dbeafe' : '#fce7f3'}; color: ${pet.species === 'dog' ? '#1e40af' : '#9d174d'}; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px;">
${pet.species_label}
</span>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px; font-size: 14px; color: #6b7280;">
${pet.breed ? `<span>🏷️ ${pet.breed}</span>` : ''}
${pet.gender_label ? `<span>${pet.gender_label}</span>` : ''}
${pet.weight ? `<span>⚖️ ${pet.weight}kg</span>` : ''}
${pet.age_months ? `<span>🎂 ${pet.age_months}개월</span>` : ''}
</div>
${pet.notes ? `<div style="margin-top: 8px; font-size: 13px; color: #9ca3af; background: #f9fafb; padding: 8px 12px; border-radius: 8px;">📝 ${pet.notes}</div>` : ''}
<div style="margin-top: 10px; font-size: 12px; color: #d1d5db;">
등록일: ${pet.created_at}
</div>
</div>
</div>
`;
});
html += '</div>';
} else {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">🐾 등록된 반려동물이 없습니다<br><small>고객이 마이페이지에서 반려동물을 등록하면 여기에 표시됩니다</small></p>';
}
html += `
</div>
`;
@@ -1710,6 +1970,169 @@
closeAIAnalysisModal();
}
});
// ═══════════════════════════════════════════════════
// KIMS 약물 상호작용 체크
// ═══════════════════════════════════════════════════
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function checkDrugInteraction(drugCodes, preSerial) {
// drugCodes가 문자열로 넘어올 수 있음
if (typeof drugCodes === 'string') {
try { drugCodes = JSON.parse(drugCodes); } catch(e) { return; }
}
// 로딩 모달 표시
showInteractionModal('loading');
try {
const response = await fetch('/api/kims/interaction-check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_codes: drugCodes,
pre_serial: preSerial
})
});
const data = await response.json();
if (data.success) {
showInteractionModal('result', data);
} else {
showInteractionModal('error', data.error || '알 수 없는 오류');
}
} catch (err) {
showInteractionModal('error', '서버 연결 실패: ' + err.message);
}
}
function showInteractionModal(type, data) {
let modal = document.getElementById('interactionModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'interactionModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
let content = '';
if (type === 'loading') {
content = `
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
</div>
`;
} else if (type === 'error') {
content = `
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
<div style="text-align:center;margin-top:20px;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
} else if (type === 'result') {
const interactions = data.interactions || [];
const drugsChecked = data.drugs_checked || [];
// 약품 목록 (상호작용 여부에 따른 색상)
const drugsHtml = drugsChecked.map(d => {
const hasInteraction = d.has_interaction;
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9';
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
const textColor = hasInteraction ? '#dc2626' : '#334155';
const icon = hasInteraction ? '⚠️ ' : '';
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
}).join('');
// 상호작용 목록
let interactionsHtml = '';
if (interactions.length === 0) {
interactionsHtml = `
<div style="text-align:center;padding:30px;">
<div style="font-size:48px;margin-bottom:12px;">✅</div>
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
<div style="font-size:13px;color:#64748b;margin-top:8px;">
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
주의가 필요한 상호작용이 발견되지 않았습니다.
</div>
</div>
`;
} else {
interactionsHtml = interactions.map(item => `
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-weight:600;color:#334155;">
${escapeHtml(item.drug1_name?.slice(0,20) || '')}${escapeHtml(item.drug2_name?.slice(0,20) || '')}
</span>
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
${item.severity_text}
</span>
</div>
${item.description ? `
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
📋 ${escapeHtml(item.description)}
</div>
` : ''}
${item.management ? `
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
💡 ${escapeHtml(item.management.slice(0, 150))}...
</div>
` : ''}
</div>
`).join('');
}
content = `
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
🔬 약물 상호작용 분석
</div>
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
</div>
</div>
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
${drugsHtml}
</div>
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
${interactions.length > 0 ? `
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
⚠️ ${interactions.length}건의 상호작용 발견
</div>
` : ''}
${interactionsHtml}
</div>
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
}
modal.innerHTML = content;
}
</script>
<!-- Lottie 애니메이션 라이브러리 (로컬) -->

View File

@@ -0,0 +1,563 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KIMS 상호작용 로그 - 청춘약국</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, #dc2626 0%, #f59e0b 50%, #16a34a 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;
display: flex;
align-items: center;
gap: 10px;
}
.header p {
font-size: 14px;
opacity: 0.9;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 통계 카드 ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 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;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.default { color: #1e293b; }
.stat-value.green { color: #16a34a; }
.stat-value.orange { color: #f59e0b; }
.stat-value.red { color: #dc2626; }
.stat-value.blue { color: #3b82f6; }
.stat-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
/* ── 필터 ── */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-bar select, .filter-bar input {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.filter-bar button {
padding: 10px 20px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
/* ── 테이블 ── */
.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;
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: 4px 10px;
border-radius: 100px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-error { background: #fee2e2; color: #dc2626; }
.badge-timeout { background: #fef3c7; color: #d97706; }
.badge-severe { background: #dc2626; color: #fff; }
.badge-moderate { background: #f59e0b; color: #fff; }
.badge-mild { background: #3b82f6; color: #fff; }
.badge-drug {
background: #f1f5f9;
color: #475569;
margin: 2px;
font-size: 10px;
padding: 3px 8px;
}
.badge-drug.warning {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
/* ── 상호작용 카운트 ── */
.interaction-count {
font-weight: 700;
font-size: 16px;
}
.interaction-count.zero { color: #16a34a; }
.interaction-count.has { color: #dc2626; }
.interaction-count.severe {
color: #fff;
background: #dc2626;
padding: 4px 10px;
border-radius: 8px;
}
/* ── 아코디언 상세 ── */
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td {
padding: 0;
border-bottom: 1px solid #e2e8f0;
background: #fafbfd;
}
.detail-content {
padding: 20px 24px;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 13px;
font-weight: 700;
color: #64748b;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.drug-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* ── 상호작용 카드 ── */
.interaction-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-left: 4px solid #e2e8f0;
}
.interaction-card.severe { border-left-color: #dc2626; background: #fef2f2; }
.interaction-card.moderate { border-left-color: #f59e0b; background: #fffbeb; }
.interaction-card.mild { border-left-color: #3b82f6; background: #eff6ff; }
.interaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.interaction-drugs {
font-weight: 600;
color: #1e293b;
font-size: 14px;
}
.interaction-desc {
font-size: 13px;
color: #475569;
line-height: 1.6;
margin-bottom: 10px;
}
.interaction-mgmt {
font-size: 12px;
color: #059669;
background: #ecfdf5;
padding: 10px 12px;
border-radius: 8px;
line-height: 1.5;
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
/* ── 로딩 ── */
.loading {
text-align: center;
padding: 40px;
color: #64748b;
}
/* ── 반응형 ── */
@media (max-width: 900px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.stats-grid { grid-template-columns: 1fr; }
.filter-bar { flex-direction: column; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/members">회원 관리</a>
</div>
<h1>🔬 KIMS 상호작용 로그</h1>
<p>약물 상호작용 체크 API 호출 기록 · AI 학습용 데이터</p>
</div>
<div class="content">
<!-- 통계 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-label">총 호출</div>
<div class="stat-value default" id="statTotal">-</div>
</div>
<div class="stat-card">
<div class="stat-label">성공</div>
<div class="stat-value green" id="statSuccess">-</div>
</div>
<div class="stat-card">
<div class="stat-label">상호작용 발견</div>
<div class="stat-value orange" id="statInteraction">-</div>
</div>
<div class="stat-card">
<div class="stat-label">심각 경고</div>
<div class="stat-value red" id="statSevere">-</div>
</div>
<div class="stat-card">
<div class="stat-label">평균 응답</div>
<div class="stat-value blue" id="statAvgMs">-</div>
<div class="stat-sub">밀리초</div>
</div>
</div>
<!-- 필터 -->
<div class="filter-bar">
<select id="filterStatus">
<option value="">모든 상태</option>
<option value="SUCCESS">성공</option>
<option value="ERROR">에러</option>
<option value="TIMEOUT">타임아웃</option>
</select>
<select id="filterInteraction">
<option value="">모든 결과</option>
<option value="has">상호작용 있음</option>
<option value="severe">심각 상호작용</option>
<option value="none">상호작용 없음</option>
</select>
<input type="date" id="filterDate" />
<button onclick="loadLogs()">🔍 조회</button>
</div>
<!-- 테이블 -->
<div class="table-wrap">
<table>
<thead>
<tr>
<th>시간</th>
<th>처방번호</th>
<th>약품</th>
<th>상호작용</th>
<th>상태</th>
<th>응답</th>
</tr>
</thead>
<tbody id="logsBody">
<tr>
<td colspan="6" class="loading">로딩 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
let logsData = [];
let openRowId = null;
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDateTime(dt) {
if (!dt) return '-';
const d = new Date(dt);
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
async function loadStats() {
try {
const res = await fetch('/api/kims/logs/stats');
const data = await res.json();
if (data.success) {
document.getElementById('statTotal').textContent = data.stats.total_calls || 0;
document.getElementById('statSuccess').textContent = data.stats.success_count || 0;
document.getElementById('statInteraction').textContent = data.stats.with_interaction || 0;
document.getElementById('statSevere').textContent = data.stats.with_severe || 0;
document.getElementById('statAvgMs').textContent = Math.round(data.stats.avg_response_ms || 0);
}
} catch (e) {
console.error('통계 로드 실패:', e);
}
}
async function loadLogs() {
const tbody = document.getElementById('logsBody');
tbody.innerHTML = '<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
const status = document.getElementById('filterStatus').value;
const interaction = document.getElementById('filterInteraction').value;
const date = document.getElementById('filterDate').value;
try {
let url = '/api/kims/logs?limit=100';
if (status) url += `&status=${status}`;
if (interaction) url += `&interaction=${interaction}`;
if (date) url += `&date=${date}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success || !data.logs || data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="empty-icon">📭</div><div>로그가 없습니다</div></td></tr>';
return;
}
logsData = data.logs;
renderLogs();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">로드 실패</td></tr>';
}
}
function renderLogs() {
const tbody = document.getElementById('logsBody');
let html = '';
logsData.forEach((log, idx) => {
// 약품 배지
let drugs = [];
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
const drugBadges = drugs.slice(0, 3).map(d =>
`<span class="badge badge-drug">${escapeHtml(d.slice(0, 12))}</span>`
).join('') + (drugs.length > 3 ? `<span class="badge badge-drug">+${drugs.length - 3}</span>` : '');
// 상호작용 표시
let interactionHtml = '';
if (log.interaction_count > 0) {
if (log.has_severe_interaction) {
interactionHtml = `<span class="interaction-count severe">⚠️ ${log.interaction_count}</span>`;
} else {
interactionHtml = `<span class="interaction-count has">${log.interaction_count}건</span>`;
}
} else {
interactionHtml = `<span class="interaction-count zero">✓ 없음</span>`;
}
// 상태 배지
let statusBadge = '';
if (log.api_status === 'SUCCESS') {
statusBadge = '<span class="badge badge-success">성공</span>';
} else if (log.api_status === 'TIMEOUT') {
statusBadge = '<span class="badge badge-timeout">타임아웃</span>';
} else {
statusBadge = '<span class="badge badge-error">에러</span>';
}
html += `
<tr onclick="toggleDetail(${log.id}, ${idx})">
<td>${formatDateTime(log.created_at)}</td>
<td>${escapeHtml(log.pre_serial) || '-'}</td>
<td>${drugBadges}</td>
<td>${interactionHtml}</td>
<td>${statusBadge}</td>
<td>${log.response_time_ms || 0}ms</td>
</tr>
<tr class="detail-row" id="detail-${log.id}">
<td colspan="6">
<div class="detail-content" id="detail-content-${log.id}">
로딩 중...
</div>
</td>
</tr>
`;
});
tbody.innerHTML = html;
}
async function toggleDetail(logId, idx) {
const detailRow = document.getElementById(`detail-${logId}`);
if (openRowId === logId) {
detailRow.classList.remove('open');
openRowId = null;
return;
}
// 기존 열린 행 닫기
if (openRowId) {
document.getElementById(`detail-${openRowId}`)?.classList.remove('open');
}
openRowId = logId;
detailRow.classList.add('open');
// 상세 데이터 로드
const contentDiv = document.getElementById(`detail-content-${logId}`);
try {
const res = await fetch(`/api/kims/logs/${logId}`);
const data = await res.json();
if (!data.success) {
contentDiv.innerHTML = '<p>상세 정보 로드 실패</p>';
return;
}
const log = data.log;
let drugs = [];
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
// 상호작용 카드
let interactionsHtml = '';
const interactions = log.interactions_detail || [];
if (interactions.length === 0) {
interactionsHtml = '<p style="color:#16a34a;font-weight:600;">✅ 상호작용 없음</p>';
} else {
interactions.forEach(inter => {
const sevLevel = inter.severity_level || 5;
const sevClass = sevLevel == 1 ? 'severe' : sevLevel == 2 ? 'moderate' : 'mild';
interactionsHtml += `
<div class="interaction-card ${sevClass}">
<div class="interaction-header">
<span class="interaction-drugs">${escapeHtml(inter.drug1_name)}${escapeHtml(inter.drug2_name)}</span>
<span class="badge badge-${sevClass}">${escapeHtml(inter.severity_desc) || '알 수 없음'}</span>
</div>
${inter.observation ? `<div class="interaction-desc">${escapeHtml(inter.observation)}</div>` : ''}
${inter.clinical_management ? `<div class="interaction-mgmt">💡 ${escapeHtml(inter.clinical_management).slice(0, 200)}...</div>` : ''}
</div>
`;
});
}
contentDiv.innerHTML = `
<div class="detail-section">
<div class="detail-section-title">💊 분석 약품 (${drugs.length}개)</div>
<div class="drug-pills">
${drugs.map(d => `<span class="badge badge-drug">${escapeHtml(d)}</span>`).join('')}
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">⚠️ 상호작용 (${interactions.length}건)</div>
${interactionsHtml}
</div>
<div class="detail-section" style="font-size:12px;color:#94a3b8;">
응답시간: ${log.response_time_ms}ms ·
호출시간: ${log.created_at} ·
처방번호: ${escapeHtml(log.pre_serial) || '-'}
</div>
`;
} catch (e) {
contentDiv.innerHTML = '<p>로드 실패</p>';
}
}
// 초기 로드
loadStats();
loadLogs();
</script>
</body>
</html>

View File

@@ -1038,6 +1038,10 @@
`;
}).join('');
// 약품 코드 배열 (상호작용 체크용)
const drugCodes = (rx.items || []).map(item => item.drug_code).filter(c => c);
const drugCodesJson = JSON.stringify(drugCodes);
return `
<div class="purchase-card" style="border-left: 3px solid #6366f1;">
<div class="purchase-header">
@@ -1050,6 +1054,14 @@
${rx.items && rx.items.length > 0 ? `
<div class="purchase-items">${itemsHtml}</div>
` : ''}
${drugCodes.length >= 2 ? `
<div style="margin-top:10px;text-align:right;">
<button onclick='checkDrugInteraction(${drugCodesJson}, "${rx.pre_serial || ""}")'
style="background:linear-gradient(135deg,#8b5cf6,#6366f1);color:#fff;border:none;padding:8px 14px;border-radius:8px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
🔬 AI 상호작용 체크
</button>
</div>
` : ''}
</div>
`;
}).join('');
@@ -1111,6 +1123,158 @@
// 페이지 로드 시 검색창 포커스
document.getElementById('searchInput').focus();
// ═══════════════════════════════════════════════════
// KIMS 약물 상호작용 체크
// ═══════════════════════════════════════════════════
async function checkDrugInteraction(drugCodes, preSerial) {
// 로딩 모달 표시
showInteractionModal('loading');
try {
const response = await fetch('/api/kims/interaction-check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_codes: drugCodes,
pre_serial: preSerial
})
});
const data = await response.json();
if (data.success) {
showInteractionModal('result', data);
} else {
showInteractionModal('error', data.error || '알 수 없는 오류');
}
} catch (err) {
showInteractionModal('error', '서버 연결 실패: ' + err.message);
}
}
function showInteractionModal(type, data) {
let modal = document.getElementById('interactionModal');
if (!modal) {
// 모달 생성
modal = document.createElement('div');
modal.id = 'interactionModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
let content = '';
if (type === 'loading') {
content = `
<div style="background:#fff;border-radius:16px;padding:40px;text-align:center;max-width:400px;">
<div style="font-size:48px;margin-bottom:16px;">🔬</div>
<div style="font-size:18px;font-weight:600;color:#334155;">상호작용 분석 중...</div>
<div style="font-size:14px;color:#64748b;margin-top:8px;">KIMS 데이터베이스 조회 중</div>
</div>
`;
} else if (type === 'error') {
content = `
<div style="background:#fff;border-radius:16px;padding:30px;max-width:400px;">
<div style="font-size:40px;text-align:center;margin-bottom:16px;">⚠️</div>
<div style="font-size:16px;font-weight:600;color:#dc2626;text-align:center;">분석 실패</div>
<div style="font-size:14px;color:#64748b;margin-top:12px;text-align:center;">${escapeHtml(data)}</div>
<div style="text-align:center;margin-top:20px;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
} else if (type === 'result') {
const interactions = data.interactions || [];
const drugsChecked = data.drugs_checked || [];
// 약품 목록 (상호작용 있는 약품은 빨간색/주황색 배경)
const drugsHtml = drugsChecked.map(d => {
const hasInteraction = d.has_interaction;
const bgColor = hasInteraction ? '#fef2f2' : '#f1f5f9'; // 연한 빨강 vs 회색
const borderColor = hasInteraction ? '#fca5a5' : '#e2e8f0';
const textColor = hasInteraction ? '#dc2626' : '#334155';
const icon = hasInteraction ? '⚠️ ' : '';
return `<span style="display:inline-block;background:${bgColor};border:1px solid ${borderColor};color:${textColor};padding:4px 8px;border-radius:4px;margin:2px;font-size:12px;">${icon}${escapeHtml(d.name.slice(0,20))}</span>`;
}).join('');
// 상호작용 목록
let interactionsHtml = '';
if (interactions.length === 0) {
interactionsHtml = `
<div style="text-align:center;padding:30px;">
<div style="font-size:48px;margin-bottom:12px;">✅</div>
<div style="font-size:16px;font-weight:600;color:#10b981;">상호작용 없음</div>
<div style="font-size:13px;color:#64748b;margin-top:8px;">
${data.total_pairs}개 약품 조합을 검사했습니다.<br>
주의가 필요한 상호작용이 발견되지 않았습니다.
</div>
</div>
`;
} else {
interactionsHtml = interactions.map(item => `
<div style="background:#fff;border:1px solid ${item.severity_color};border-radius:12px;padding:16px;margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-weight:600;color:#334155;">
${escapeHtml(item.drug1_name?.slice(0,20) || '')}${escapeHtml(item.drug2_name?.slice(0,20) || '')}
</span>
<span style="background:${item.severity_color};color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:500;">
${item.severity_text}
</span>
</div>
${item.description ? `
<div style="font-size:13px;color:#475569;margin-bottom:8px;line-height:1.5;">
📋 ${escapeHtml(item.description)}
</div>
` : ''}
${item.management ? `
<div style="font-size:12px;color:#059669;background:#ecfdf5;padding:8px 12px;border-radius:6px;">
💡 ${escapeHtml(item.management)}
</div>
` : ''}
</div>
`).join('');
}
content = `
<div style="background:#f8fafc;border-radius:20px;max-width:500px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
<div style="background:linear-gradient(135deg,#8b5cf6,#6366f1);padding:20px 24px;color:#fff;">
<div style="font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;">
🔬 약물 상호작용 분석
</div>
<div style="font-size:13px;opacity:0.9;margin-top:6px;">
${drugsChecked.length}개 약품 · ${data.total_pairs}개 조합 검사
</div>
</div>
<div style="padding:16px 20px;border-bottom:1px solid #e2e8f0;">
<div style="font-size:12px;color:#64748b;margin-bottom:6px;">분석 약품</div>
${drugsHtml}
</div>
<div style="flex:1;overflow-y:auto;padding:16px 20px;">
${interactions.length > 0 ? `
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:12px;">
⚠️ ${interactions.length}건의 상호작용 발견
</div>
` : ''}
${interactionsHtml}
</div>
<div style="padding:16px 20px;border-top:1px solid #e2e8f0;text-align:center;">
<button onclick="document.getElementById('interactionModal').remove()"
style="background:#6366f1;color:#fff;border:none;padding:12px 32px;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;">
닫기
</button>
</div>
</div>
`;
}
modal.innerHTML = content;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,704 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OTC 용법 라벨 관리 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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', sans-serif;
background: #f5f7fa;
min-height: 100vh;
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
padding: 20px 24px;
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: 22px;
font-weight: 700;
}
.header-nav a {
color: white;
text-decoration: none;
margin-left: 16px;
opacity: 0.9;
}
.header-nav a:hover { opacity: 1; }
/* 컨테이너 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
display: grid;
grid-template-columns: 400px 1fr;
gap: 24px;
}
/* 패널 */
.panel {
background: white;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
overflow: hidden;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.panel-body {
padding: 20px;
}
/* 검색 */
.search-box {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.search-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
transition: border-color 0.2s;
}
.search-input:focus {
outline: none;
border-color: #f59e0b;
}
.search-btn {
padding: 12px 20px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s;
}
.search-btn:hover { transform: scale(1.02); }
/* 검색 결과 */
.search-results {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 20px;
}
.search-result-item {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
transition: background 0.1s;
}
.search-result-item:hover { background: #fef3c7; }
.search-result-item:last-child { border-bottom: none; }
.search-result-name {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.search-result-barcode {
font-size: 12px;
color: #64748b;
font-family: monospace;
}
/* 폼 */
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 6px;
}
.form-input, .form-textarea {
width: 100%;
padding: 12px 14px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
font-family: inherit;
transition: border-color 0.2s;
}
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: #f59e0b;
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.form-input[readonly] {
background: #f8fafc;
color: #64748b;
}
/* 버튼 */
.btn-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
flex: 1;
padding: 14px 20px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(245,158,11,0.3); }
.btn-secondary {
background: #e2e8f0;
color: #475569;
}
.btn-secondary:hover { background: #cbd5e1; }
.btn-print {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.btn-print:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99,102,241,0.3); }
.btn-delete {
background: #fee2e2;
color: #dc2626;
}
.btn-delete:hover { background: #fecaca; }
/* 미리보기 */
.preview-container {
text-align: center;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.preview-placeholder {
color: #94a3b8;
font-size: 14px;
}
/* 목록 테이블 */
.label-list {
max-height: 400px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
background: #f8fafc;
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
position: sticky;
top: 0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:hover { background: #fef3c7; cursor: pointer; }
.td-name {
font-weight: 600;
color: #1e293b;
}
.td-effect {
color: #d97706;
font-weight: 500;
}
.td-count {
font-family: monospace;
color: #64748b;
}
/* 토스트 */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 14px 28px;
border-radius: 12px;
font-weight: 600;
z-index: 9999;
animation: toastIn 0.3s ease;
}
.toast.success { background: #10b981; color: white; }
.toast.error { background: #ef4444; color: white; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* 반응형 */
@media (max-width: 900px) {
.container {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<div class="header-title">💊 OTC 용법 라벨 관리</div>
<nav class="header-nav">
<a href="/admin">📊 대시보드</a>
<a href="/admin/pos-live">📋 실시간 POS</a>
<a href="/admin/members">👥 회원</a>
</nav>
</div>
</header>
<div class="container">
<!-- 왼쪽: 편집 패널 -->
<div class="panel">
<div class="panel-header">✏️ 라벨 편집</div>
<div class="panel-body">
<!-- 약품 검색 -->
<div class="search-box">
<input type="text" class="search-input" id="searchInput" placeholder="바코드 또는 약품명 검색...">
<button class="search-btn" onclick="searchDrug()">검색</button>
</div>
<!-- 검색 결과 -->
<div class="search-results" id="searchResults" style="display:none;"></div>
<!-- 편집 폼 -->
<form id="labelForm">
<div class="form-group">
<label class="form-label">바코드</label>
<input type="text" class="form-input" id="barcode" readonly placeholder="약품을 검색하세요">
</div>
<div class="form-group">
<label class="form-label">약품명 (표시용)</label>
<input type="text" class="form-input" id="displayName" placeholder="오버라이드 이름 (비우면 원본 사용)">
</div>
<div class="form-group">
<label class="form-label">효능 ⭐</label>
<input type="text" class="form-input" id="effect" placeholder="예: 치통, 두통">
</div>
<div class="form-group">
<label class="form-label">용법</label>
<textarea class="form-textarea" id="dosageInstruction" placeholder="예: 1일 3회, 1회 1정, 식후 30분"></textarea>
</div>
<div class="form-group">
<label class="form-label">부가 설명</label>
<input type="text" class="form-input" id="usageTip" placeholder="예: [통증 시에만 복용]">
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" onclick="previewLabel()">👁️ 미리보기</button>
<button type="button" class="btn btn-primary" onclick="saveLabel()">💾 저장</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-print" onclick="printLabel()">🖨️ 인쇄</button>
<button type="button" class="btn btn-delete" onclick="deleteLabel()">🗑️ 삭제</button>
</div>
</form>
</div>
</div>
<!-- 오른쪽: 미리보기 + 목록 -->
<div style="display: flex; flex-direction: column; gap: 24px;">
<!-- 미리보기 -->
<div class="panel">
<div class="panel-header">👁️ 라벨 미리보기</div>
<div class="panel-body">
<div class="preview-container" id="previewContainer">
<div class="preview-placeholder">미리보기를 클릭하면 라벨이 표시됩니다</div>
</div>
</div>
</div>
<!-- 저장된 목록 -->
<div class="panel">
<div class="panel-header">📋 저장된 라벨 프리셋</div>
<div class="panel-body">
<div class="label-list" id="labelList">
<table>
<thead>
<tr>
<th>약품명</th>
<th>효능</th>
<th>인쇄</th>
</tr>
</thead>
<tbody id="labelListBody">
<tr><td colspan="3" style="text-align:center; color:#94a3b8;">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
let currentBarcode = '';
let currentDrugName = '';
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadLabelList();
// Enter 키로 검색
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchDrug();
});
// 입력 시 자동 미리보기 (디바운스)
let debounceTimer;
['effect', 'dosageInstruction', 'usageTip', 'displayName'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(previewLabel, 500);
});
});
// URL 파라미터로 바코드/이름 전달 시 자동 로드
const params = new URLSearchParams(window.location.search);
const urlBarcode = params.get('barcode');
const urlName = params.get('name');
if (urlBarcode) {
currentBarcode = urlBarcode;
currentDrugName = urlName || urlBarcode;
document.getElementById('barcode').value = urlBarcode;
document.getElementById('searchInput').value = urlName || urlBarcode;
// 기존 프리셋 확인
fetch(`/api/admin/otc-labels/${urlBarcode}`)
.then(res => res.json())
.then(data => {
if (data.exists) {
document.getElementById('displayName').value = data.label.display_name || '';
document.getElementById('effect').value = data.label.effect || '';
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
document.getElementById('usageTip').value = data.label.usage_tip || '';
}
previewLabel();
});
}
});
// 약품 검색 (MSSQL)
async function searchDrug() {
const query = document.getElementById('searchInput').value.trim();
if (!query) return;
try {
const res = await fetch(`/api/admin/otc-labels/search-mssql?q=${encodeURIComponent(query)}`);
const data = await res.json();
const resultsDiv = document.getElementById('searchResults');
if (data.success && data.drugs.length > 0) {
resultsDiv.innerHTML = data.drugs.map(drug => `
<div class="search-result-item" onclick="selectDrug('${drug.barcode}', '${escapeHtml(drug.goods_name)}', '${drug.drug_code}')">
<div class="search-result-name">${drug.goods_name}</div>
<div class="search-result-barcode">${drug.barcode}</div>
</div>
`).join('');
resultsDiv.style.display = 'block';
} else {
resultsDiv.innerHTML = '<div class="search-result-item" style="color:#94a3b8;">검색 결과 없음</div>';
resultsDiv.style.display = 'block';
}
} catch (err) {
showToast('검색 오류: ' + err.message, 'error');
}
}
// 약품 선택
async function selectDrug(barcode, goodsName, drugCode) {
document.getElementById('searchResults').style.display = 'none';
document.getElementById('searchInput').value = goodsName;
currentBarcode = barcode;
currentDrugName = goodsName;
document.getElementById('barcode').value = barcode;
// 기존 프리셋 확인
try {
const res = await fetch(`/api/admin/otc-labels/${barcode}`);
const data = await res.json();
if (data.exists) {
// 기존 데이터 로드
document.getElementById('displayName').value = data.label.display_name || '';
document.getElementById('effect').value = data.label.effect || '';
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
document.getElementById('usageTip').value = data.label.usage_tip || '';
showToast('기존 프리셋 로드됨', 'success');
} else {
// 새 프리셋 (MSSQL 이름 사용)
document.getElementById('displayName').value = '';
document.getElementById('effect').value = '';
document.getElementById('dosageInstruction').value = '';
document.getElementById('usageTip').value = '';
}
previewLabel();
} catch (err) {
console.error(err);
}
}
// 미리보기
async function previewLabel() {
const drugName = document.getElementById('displayName').value || currentDrugName || '약품명';
const effect = document.getElementById('effect').value;
const dosageInstruction = document.getElementById('dosageInstruction').value;
const usageTip = document.getElementById('usageTip').value;
try {
const res = await fetch('/api/admin/otc-labels/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ drug_name: drugName, effect, dosage_instruction: dosageInstruction, usage_tip: usageTip })
});
const data = await res.json();
if (data.success) {
document.getElementById('previewContainer').innerHTML =
`<img src="${data.preview_url}" class="preview-image" alt="라벨 미리보기">`;
}
} catch (err) {
console.error('미리보기 오류:', err);
}
}
// 저장
async function saveLabel() {
if (!currentBarcode) {
showToast('먼저 약품을 검색하세요', 'error');
return;
}
// display_name이 비어있으면 원본 약품명 사용
const displayName = document.getElementById('displayName').value || currentDrugName;
const payload = {
barcode: currentBarcode,
display_name: displayName,
effect: document.getElementById('effect').value,
dosage_instruction: document.getElementById('dosageInstruction').value,
usage_tip: document.getElementById('usageTip').value
};
console.log('저장 payload:', payload);
try {
const res = await fetch('/api/admin/otc-labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('저장 응답 status:', res.status);
const data = await res.json();
console.log('저장 응답 data:', data);
if (data.success) {
showToast('저장 완료!', 'success');
loadLabelList();
} else {
showToast(data.error || '알 수 없는 오류', 'error');
}
} catch (err) {
console.error('저장 오류:', err);
showToast('저장 오류: ' + err.message, 'error');
}
}
// 인쇄
async function printLabel() {
const drugName = document.getElementById('displayName').value || currentDrugName || '약품명';
const effect = document.getElementById('effect').value;
const dosageInstruction = document.getElementById('dosageInstruction').value;
const usageTip = document.getElementById('usageTip').value;
if (!effect && !dosageInstruction) {
showToast('효능 또는 용법을 입력하세요', 'error');
return;
}
try {
const res = await fetch('/api/admin/otc-labels/print', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
barcode: currentBarcode,
drug_name: drugName,
effect,
dosage_instruction: dosageInstruction,
usage_tip: usageTip
})
});
const data = await res.json();
if (data.success) {
showToast('🖨️ 인쇄 완료!', 'success');
loadLabelList();
} else {
showToast(data.error, 'error');
}
} catch (err) {
showToast('인쇄 오류: ' + err.message, 'error');
}
}
// 삭제
async function deleteLabel() {
if (!currentBarcode) {
showToast('삭제할 프리셋이 없습니다', 'error');
return;
}
if (!confirm(`"${currentDrugName}" 프리셋을 삭제하시겠습니까?`)) {
return;
}
try {
const res = await fetch(`/api/admin/otc-labels/${currentBarcode}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
showToast('삭제 완료!', 'success');
// 폼 초기화
currentBarcode = '';
currentDrugName = '';
document.getElementById('barcode').value = '';
document.getElementById('displayName').value = '';
document.getElementById('effect').value = '';
document.getElementById('dosageInstruction').value = '';
document.getElementById('usageTip').value = '';
document.getElementById('previewContainer').innerHTML = '<div class="preview-placeholder">미리보기를 클릭하면 라벨이 표시됩니다</div>';
loadLabelList();
} else {
showToast(data.error || '삭제 실패', 'error');
}
} catch (err) {
showToast('삭제 오류: ' + err.message, 'error');
}
}
// 목록 로드
async function loadLabelList() {
try {
const res = await fetch('/api/admin/otc-labels');
const data = await res.json();
if (data.success) {
const tbody = document.getElementById('labelListBody');
if (data.labels.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#94a3b8;">저장된 프리셋이 없습니다</td></tr>';
return;
}
tbody.innerHTML = data.labels.map(label => `
<tr onclick="loadLabel('${label.barcode}')">
<td class="td-name">${label.display_name || label.barcode}</td>
<td class="td-effect">${label.effect || '-'}</td>
<td class="td-count">${label.print_count || 0}회</td>
</tr>
`).join('');
}
} catch (err) {
console.error('목록 로드 오류:', err);
}
}
// 목록에서 로드
async function loadLabel(barcode) {
try {
const res = await fetch(`/api/admin/otc-labels/${barcode}`);
const data = await res.json();
if (data.exists) {
currentBarcode = barcode;
currentDrugName = data.label.display_name || barcode;
document.getElementById('barcode').value = barcode;
document.getElementById('displayName').value = data.label.display_name || '';
document.getElementById('effect').value = data.label.effect || '';
document.getElementById('dosageInstruction').value = data.label.dosage_instruction || '';
document.getElementById('usageTip').value = data.label.usage_tip || '';
previewLabel();
showToast('프리셋 로드됨', 'success');
}
} catch (err) {
showToast('로드 오류: ' + err.message, 'error');
}
}
// 유틸
function escapeHtml(str) {
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
}
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAAI 분석 로그 - 관리자</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.5rem; }
.header a { color: #fff; text-decoration: none; opacity: 0.8; }
.header a:hover { opacity: 1; }
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 25px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.stat-card .num { font-size: 2rem; font-weight: 700; color: #10b981; }
.stat-card .label { font-size: 0.85rem; color: #6b7280; margin-top: 5px; }
.stat-card.severe .num { color: #ef4444; }
/* 필터 */
.filters {
background: #fff;
padding: 15px 20px;
border-radius: 12px;
margin-bottom: 20px;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.filters input, .filters select {
padding: 8px 12px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 0.9rem;
}
.filters input:focus, .filters select:focus {
outline: none;
border-color: #10b981;
}
.filters button {
padding: 8px 20px;
background: #10b981;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.filters button:hover { background: #059669; }
/* 로그 테이블 */
.log-table {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.log-table table {
width: 100%;
border-collapse: collapse;
}
.log-table th {
background: #f9fafb;
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
}
.log-table td {
padding: 12px 15px;
border-bottom: 1px solid #f3f4f6;
font-size: 0.9rem;
}
.log-table tr:hover { background: #f9fafb; cursor: pointer; }
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-pending { background: #fef3c7; color: #92400e; }
.badge-severe { background: #fee2e2; color: #dc2626; }
.badge-caution { background: #fef3c7; color: #d97706; }
.feedback-icon { font-size: 1.1rem; }
/* 상세 모달 */
.modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 20px;
}
.modal.show { display: flex; }
.modal-content {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 900px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 20px 25px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
}
.modal-body {
padding: 25px;
overflow-y: auto;
flex: 1;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section h4 {
font-size: 0.95rem;
color: #374151;
margin-bottom: 10px;
border-bottom: 2px solid #10b981;
padding-bottom: 5px;
}
.detail-section pre {
background: #f9fafb;
padding: 15px;
border-radius: 8px;
font-size: 0.85rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.detail-item {
background: #f9fafb;
padding: 10px 15px;
border-radius: 8px;
}
.detail-item .label { font-size: 0.8rem; color: #6b7280; }
.detail-item .value { font-weight: 600; color: #111827; }
.loading {
text-align: center;
padding: 60px;
color: #9ca3af;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="header">
<h1>🤖 PAAI 분석 로그</h1>
<a href="/admin">← 관리자 홈</a>
</div>
<div class="container">
<!-- 통계 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="num" id="statTotal">-</div>
<div class="label">전체</div>
</div>
<div class="stat-card">
<div class="num" id="statToday">-</div>
<div class="label">오늘</div>
</div>
<div class="stat-card">
<div class="num" id="statSuccessRate">-</div>
<div class="label">성공률</div>
</div>
<div class="stat-card">
<div class="num" id="statAvgTime">-</div>
<div class="label">평균 응답(ms)</div>
</div>
<div class="stat-card severe">
<div class="num" id="statSevere">-</div>
<div class="label">심각 상호작용</div>
</div>
<div class="stat-card">
<div class="num" id="statFeedback">-</div>
<div class="label">유용 피드백</div>
</div>
</div>
<!-- 필터 -->
<div class="filters">
<input type="date" id="filterDate" placeholder="날짜">
<select id="filterStatus">
<option value="">상태: 전체</option>
<option value="success">성공</option>
<option value="error">오류</option>
<option value="pending">대기중</option>
</select>
<select id="filterSevere">
<option value="">상호작용: 전체</option>
<option value="true">심각 있음</option>
<option value="false">심각 없음</option>
</select>
<button onclick="loadLogs()">🔍 조회</button>
<button onclick="loadLogs()" style="background:#6b7280;">🔄 새로고침</button>
</div>
<!-- 로그 테이블 -->
<div class="log-table">
<table>
<thead>
<tr>
<th>시간</th>
<th>환자</th>
<th>처방번호</th>
<th>약품수</th>
<th>KIMS</th>
<th>상태</th>
<th>응답시간</th>
<th>피드백</th>
</tr>
</thead>
<tbody id="logTableBody">
<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h3>📋 분석 상세</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
</div>
<script>
// 페이지 로드
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadLogs();
});
// 통계 로드
async function loadStats() {
try {
const res = await fetch('/api/paai/logs/stats');
const data = await res.json();
if (data.success) {
const s = data.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statToday').textContent = s.today;
document.getElementById('statSuccessRate').textContent = s.success_rate + '%';
document.getElementById('statAvgTime').textContent = s.avg_response_time;
document.getElementById('statSevere').textContent = s.severe_count;
document.getElementById('statFeedback').textContent =
s.feedback ? `${s.feedback.useful}/${s.feedback.total}` : '0/0';
}
} catch (err) {
console.error('Stats error:', err);
}
}
// 로그 로드
async function loadLogs() {
const tbody = document.getElementById('logTableBody');
tbody.innerHTML = '<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>';
try {
const date = document.getElementById('filterDate').value;
const status = document.getElementById('filterStatus').value;
const severe = document.getElementById('filterSevere').value;
const params = new URLSearchParams();
if (date) params.append('date', date);
if (status) params.append('status', status);
if (severe) params.append('has_severe', severe);
params.append('limit', '100');
const res = await fetch(`/api/paai/logs?${params}`);
const data = await res.json();
if (data.success) {
if (data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:40px;">로그가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = data.logs.map(log => {
const time = new Date(log.created_at).toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const statusBadge = {
'success': '<span class="badge badge-success">성공</span>',
'error': '<span class="badge badge-error">오류</span>',
'pending': '<span class="badge badge-pending">대기</span>',
'kims_done': '<span class="badge badge-pending">AI대기</span>'
}[log.status] || log.status;
const kimsBadge = log.kims_has_severe
? `<span class="badge badge-severe">🔴 ${log.kims_interaction_count}건</span>`
: log.kims_interaction_count > 0
? `<span class="badge badge-caution">⚠️ ${log.kims_interaction_count}건</span>`
: '<span style="color:#9ca3af;">-</span>';
const feedback = log.feedback_useful === 1 ? '👍'
: log.feedback_useful === 0 ? '👎' : '-';
return `
<tr onclick="showDetail(${log.id})">
<td>${time}</td>
<td>${log.patient_name || '-'}</td>
<td>${log.pre_serial || '-'}</td>
<td>${log.current_med_count || 0}종</td>
<td>${kimsBadge}</td>
<td>${statusBadge}</td>
<td>${log.ai_response_time_ms || '-'}ms</td>
<td class="feedback-icon">${feedback}</td>
</tr>
`;
}).join('');
}
} catch (err) {
console.error('Logs error:', err);
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#ef4444;">로드 실패</td></tr>';
}
}
// 상세 보기
async function showDetail(logId) {
const modal = document.getElementById('detailModal');
const body = document.getElementById('modalBody');
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
modal.classList.add('show');
try {
const res = await fetch(`/api/paai/logs/${logId}`);
const data = await res.json();
if (data.success) {
const log = data.log;
body.innerHTML = `
<div class="detail-section">
<h4>📌 기본 정보</h4>
<div class="detail-grid">
<div class="detail-item">
<div class="label">환자</div>
<div class="value">${log.patient_name || '-'} (${log.patient_code || '-'})</div>
</div>
<div class="detail-item">
<div class="label">처방번호</div>
<div class="value">${log.pre_serial || '-'}</div>
</div>
<div class="detail-item">
<div class="label">질병1</div>
<div class="value">[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</div>
</div>
<div class="detail-item">
<div class="label">질병2</div>
<div class="value">[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h4>💊 현재 처방 (${log.current_med_count || 0}종)</h4>
<pre>${JSON.stringify(log.current_medications, null, 2)}</pre>
</div>
<div class="detail-section">
<h4>⚠️ KIMS 상호작용 (${log.kims_interaction_count || 0}건)</h4>
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
</div>
<div class="detail-section">
<h4>🤖 AI 분석 결과</h4>
<pre>${JSON.stringify(log.ai_response, null, 2)}</pre>
</div>
<div class="detail-section">
<h4>📊 성능</h4>
<div class="detail-grid">
<div class="detail-item">
<div class="label">KIMS 응답</div>
<div class="value">${log.kims_response_time_ms || '-'}ms</div>
</div>
<div class="detail-item">
<div class="label">AI 응답</div>
<div class="value">${log.ai_response_time_ms || '-'}ms</div>
</div>
<div class="detail-item">
<div class="label">상태</div>
<div class="value">${log.status}</div>
</div>
<div class="detail-item">
<div class="label">피드백</div>
<div class="value">${log.feedback_useful === 1 ? '👍 유용' : log.feedback_useful === 0 ? '👎 아님' : '미응답'}</div>
</div>
</div>
</div>
`;
}
} catch (err) {
console.error('Detail error:', err);
body.innerHTML = '<div style="text-align:center;color:#ef4444;">로드 실패</div>';
}
}
function closeModal() {
document.getElementById('detailModal').classList.remove('show');
}
// ESC로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -369,13 +369,170 @@
/* 제품 셀 */
.product-cell {
display: flex;
align-items: center;
gap: 10px;
}
.product-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 6px;
background: var(--bg-secondary);
flex-shrink: 0;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.product-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(139,92,246,0.3);
}
.product-thumb-placeholder {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2a2a3e 0%, #1e1e2e 100%);
border-radius: 6px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
}
.product-thumb-placeholder:hover {
transform: scale(1.1);
border-color: var(--accent-purple);
}
.product-thumb-placeholder svg {
width: 18px;
height: 18px;
opacity: 0.3;
fill: #888;
}
/* 이미지 교체 모달 */
.image-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 1000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.image-modal.show { display: flex; }
.image-modal-content {
background: #1a1a3e;
border-radius: 16px;
padding: 24px;
max-width: 450px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
border: 1px solid rgba(139,92,246,0.3);
}
.image-modal-content h3 {
margin: 0 0 16px 0;
color: var(--accent-purple);
font-size: 18px;
}
.image-modal-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab-btn {
flex: 1;
padding: 10px;
border: 1px solid rgba(255,255,255,0.1);
background: transparent;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--accent-purple);
color: white;
border-color: var(--accent-purple);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.image-input {
width: 100%;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: var(--text-primary);
margin-bottom: 12px;
}
.image-input:focus {
outline: none;
border-color: var(--accent-purple);
}
.camera-container {
position: relative;
width: 100%;
aspect-ratio: 1;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.camera-container video,
.camera-container canvas {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-guide {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.modal-btns {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-modal {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-modal.secondary {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn-modal.primary {
background: var(--accent-purple);
color: white;
}
.btn-modal:hover {
transform: translateY(-1px);
}
.product-info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 0;
}
.product-name {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-supplier {
font-size: 11px;
@@ -796,8 +953,14 @@
<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>` : ''}
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
</div>
</td>
<td>${renderCode(item)}</td>
@@ -826,8 +989,14 @@
<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>` : ''}
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
</div>
</div>
</td>
<td>${renderCode(item)}</td>
@@ -897,6 +1066,288 @@
// 초기 로드
loadSalesData();
// ──────────────── 이미지 교체 모달 ────────────────
let imgModalBarcode = null;
let imgModalDrugCode = null;
let imgModalName = null;
let cameraStream = null;
let capturedImageData = null;
function openImageModal(barcode, drugCode, productName) {
// 바코드나 drug_code 중 하나는 있어야 함
if (!barcode && !drugCode) {
showToast('제품 코드 정보가 없습니다', 'error');
return;
}
imgModalBarcode = barcode || null;
imgModalDrugCode = drugCode || null;
imgModalName = productName || (barcode || drugCode);
document.getElementById('imgModalProductName').textContent = imgModalName;
document.getElementById('imgModalCode').textContent = barcode || drugCode;
document.getElementById('imgUrlInput').value = '';
// URL 탭으로 초기화
switchImageTab('url');
document.getElementById('imageModal').classList.add('show');
document.getElementById('imgUrlInput').focus();
}
function closeImageModal() {
stopCamera();
document.getElementById('imageModal').classList.remove('show');
imgModalBarcode = null;
imgModalDrugCode = null;
imgModalName = null;
capturedImageData = null;
}
function switchImageTab(tab) {
document.querySelectorAll('.image-modal .tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.image-modal .tab-content').forEach(content => {
content.classList.toggle('active', content.id === 'tab' + tab.charAt(0).toUpperCase() + tab.slice(1));
});
if (tab === 'camera') {
startCamera();
} else {
stopCamera();
}
}
async function startCamera() {
try {
stopCamera();
const constraints = {
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1920 }
},
audio: false
};
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('cameraVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
document.getElementById('captureCanvas').style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'block';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
} catch (err) {
console.error('카메라 오류:', err);
showToast('카메라에 접근할 수 없습니다', 'error');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
const video = document.getElementById('cameraVideo');
if (video) video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
const ctx = canvas.getContext('2d');
const vw = video.videoWidth;
const vh = video.videoHeight;
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2;
const sy = (vh - cropSize) / 2;
canvas.width = 800;
canvas.height = 800;
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('cameraGuide').style.display = 'none';
document.getElementById('captureBtn').style.display = 'none';
document.getElementById('previewBtns').style.display = 'flex';
}
function retakePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
video.style.display = 'block';
canvas.style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'block';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
}
async function submitCapturedImage() {
if (!capturedImageData) {
showToast('촬영된 이미지가 없습니다', 'error');
return;
}
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
const imageData = capturedImageData;
closeImageModal();
showToast(`"${name}" 이미지 저장 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_data: imageData,
product_name: name,
drug_code: imgModalDrugCode
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 이미지 저장 완료!', 'success');
loadSalesData(); // 새로고침
} else {
showToast(data.error || '저장 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
async function submitImageUrl() {
const imageUrl = document.getElementById('imgUrlInput').value.trim();
if (!imageUrl) {
showToast('이미지 URL을 입력하세요', 'error');
return;
}
if (!imageUrl.startsWith('http')) {
showToast('올바른 URL을 입력하세요', 'error');
return;
}
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" 이미지 다운로드 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${code}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl,
product_name: name,
drug_code: imgModalDrugCode
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 이미지 등록 완료!', 'success');
loadSalesData(); // 새로고침
} else {
showToast(data.error || '등록 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#6366f1'};
color: white;
border-radius: 8px;
font-size: 14px;
z-index: 2000;
animation: fadeIn 0.3s;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 모달 외부 클릭시 닫기
document.getElementById('imageModal').addEventListener('click', e => {
if (e.target.id === 'imageModal') closeImageModal();
});
</script>
<!-- 이미지 교체 모달 -->
<div class="image-modal" id="imageModal">
<div class="image-modal-content">
<h3>📷 제품 이미지 등록</h3>
<div style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="font-weight: 600;" id="imgModalProductName">제품명</div>
<div style="font-size: 12px; color: var(--text-muted); font-family: monospace;" id="imgModalCode">코드</div>
</div>
<div class="image-modal-tabs">
<button class="tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
<button class="tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
</div>
<!-- URL 탭 -->
<div class="tab-content active" id="tabUrl">
<input type="text" class="image-input" id="imgUrlInput" placeholder="이미지 URL을 입력하세요...">
<div class="modal-btns">
<button class="btn-modal secondary" onclick="closeImageModal()">취소</button>
<button class="btn-modal primary" onclick="submitImageUrl()">등록하기</button>
</div>
</div>
<!-- 카메라 탭 -->
<div class="tab-content" id="tabCamera">
<div class="camera-container">
<video id="cameraVideo" autoplay playsinline></video>
<canvas id="captureCanvas" style="display:none;"></canvas>
<div class="camera-guide" id="cameraGuide">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="10" y="10" width="80" height="80" fill="none" stroke="rgba(139,92,246,0.5)" stroke-width="0.5" stroke-dasharray="2,2"/>
<path d="M10,10 L20,10 M10,10 L10,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,10 L80,10 M90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M10,90 L20,90 M10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,90 L80,90 M90,90 L90,80" fill="none" stroke="#a855f7" stroke-width="1"/>
</svg>
</div>
</div>
<div class="modal-btns" id="captureBtn">
<button class="btn-modal secondary" onclick="closeImageModal()">취소</button>
<button class="btn-modal primary" onclick="capturePhoto()">📸 촬영</button>
</div>
<div class="modal-btns" id="previewBtns" style="display:none;">
<button class="btn-modal secondary" onclick="retakePhoto()">다시 촬영</button>
<button class="btn-modal primary" onclick="submitCapturedImage()">저장하기</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -119,6 +119,49 @@
letter-spacing: -0.2px;
}
/* 퀵 메뉴 */
.quick-menu {
display: flex;
justify-content: space-around;
padding: 20px 16px;
background: #fff;
margin: 0 16px 16px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.quick-menu-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
padding: 8px 12px;
border-radius: 12px;
transition: background 0.2s;
}
.quick-menu-item:active {
background: #f5f5f5;
}
.quick-menu-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.quick-menu-item span {
font-size: 12px;
font-weight: 600;
color: #495057;
letter-spacing: -0.3px;
}
.section {
padding: 24px;
}
@@ -301,6 +344,26 @@
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
</div>
<!-- 퀵 메뉴 -->
<div class="quick-menu">
<a href="/mypage" class="quick-menu-item">
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
<span>반려동물</span>
</a>
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
<span>쿠폰함</span>
</a>
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
<span>구매내역</span>
</a>
<a href="/mypage" class="quick-menu-item">
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
<span>내정보</span>
</a>
</div>
<div class="section">
<div class="section-title">적립 내역</div>
@@ -403,7 +466,10 @@
</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-image-container" style="margin-bottom:20px;width:100%;display:flex;justify-content:center;">
<img id="rec-image" style="width:160px;height:auto;border:none;outline:none;display:none;" alt="추천 제품">
<div id="rec-emoji" style="font-size:56px;">💊</div>
</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>
@@ -512,6 +578,17 @@
_recId = data.recommendation.id;
document.getElementById('rec-message').textContent = data.recommendation.message;
document.getElementById('rec-product').textContent = data.recommendation.product;
// 제품 이미지 표시
if (data.recommendation.image) {
document.getElementById('rec-image').src = 'data:image/jpeg;base64,' + data.recommendation.image;
document.getElementById('rec-image').style.display = 'block';
document.getElementById('rec-emoji').style.display = 'none';
} else {
document.getElementById('rec-image').style.display = 'none';
document.getElementById('rec-emoji').style.display = 'block';
}
document.getElementById('rec-sheet').style.display = 'block';
document.getElementById('rec-backdrop').onclick = dismissRec;
}

View File

@@ -0,0 +1,891 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#6366f1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="청춘약국">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<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&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;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.app-container {
background: #ffffff;
min-height: 100vh;
max-width: 420px;
margin: 0 auto;
box-shadow: 0 0 20px rgba(0,0,0,0.05);
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 20px 24px 100px;
position: relative;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-title {
color: white;
font-size: 20px;
font-weight: 700;
}
.btn-logout {
color: rgba(255,255,255,0.8);
font-size: 14px;
text-decoration: none;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.2s;
}
.btn-logout:hover {
background: rgba(255,255,255,0.1);
}
/* 프로필 카드 */
.profile-card {
background: white;
border-radius: 20px;
margin: -80px 16px 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
position: relative;
z-index: 10;
}
.profile-info {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.profile-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: white;
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-details h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin-bottom: 4px;
}
.profile-details p {
color: #6b7280;
font-size: 14px;
}
/* 통계 그리드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding-top: 20px;
border-top: 1px solid #f3f4f6;
}
.stat-item {
text-align: center;
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
font-size: 20px;
}
.stat-icon.purple { background: #ede9fe; }
.stat-icon.blue { background: #dbeafe; }
.stat-icon.pink { background: #fce7f3; }
.stat-value {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
/* 섹션 */
.section {
background: white;
margin: 16px;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
display: flex;
align-items: center;
gap: 8px;
}
.section-action {
color: #6366f1;
font-size: 13px;
font-weight: 500;
text-decoration: none;
}
/* 반려동물 카드 */
.pet-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #f9fafb;
border-radius: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: background 0.2s;
}
.pet-card:hover {
background: #f3f4f6;
}
.pet-photo {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
overflow: hidden;
flex-shrink: 0;
}
.pet-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pet-info {
flex: 1;
}
.pet-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
}
.pet-details {
font-size: 13px;
color: #6b7280;
}
.pet-arrow {
color: #d1d5db;
font-size: 18px;
}
/* 반려동물 추가 버튼 */
.add-pet-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.add-pet-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
/* 메뉴 리스트 */
.menu-list {
list-style: none;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:hover {
background: #f9fafb;
margin: 0 -20px;
padding: 16px 20px;
}
.menu-left {
display: flex;
align-items: center;
gap: 12px;
}
.menu-icon {
font-size: 20px;
}
.menu-text {
font-size: 15px;
color: #374151;
}
.menu-badge {
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
}
.menu-arrow {
color: #d1d5db;
}
/* 모달 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 24px 24px 0 0;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f3f4f6;
border: none;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* 폼 스타일 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 15px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #6366f1;
}
/* 종류 선택 */
.species-options {
display: flex;
gap: 12px;
}
.species-option {
flex: 1;
padding: 20px;
border: 2px solid #e5e7eb;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.species-option:hover {
border-color: #c7d2fe;
}
.species-option.selected {
border-color: #6366f1;
background: #eef2ff;
}
.species-option .icon {
font-size: 40px;
margin-bottom: 8px;
}
.species-option .label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
/* 사진 업로드 */
.photo-upload {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.photo-preview {
width: 120px;
height: 120px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s;
}
.photo-preview:hover {
background: #e5e7eb;
}
.photo-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-hint {
font-size: 13px;
color: #9ca3af;
}
/* 제출 버튼 */
.submit-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 24px;
transition: transform 0.2s, box-shadow 0.2s;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 32px 16px;
color: #9ca3af;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state p {
font-size: 14px;
}
/* 로딩 */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="app-container">
<!-- 헤더 -->
<div class="header">
<div class="header-top">
<h1 class="header-title">마이페이지</h1>
<a href="/logout" class="btn-logout">로그아웃</a>
</div>
</div>
<!-- 프로필 카드 -->
<div class="profile-card">
<div class="profile-info">
<div class="profile-avatar">
{% if user.profile_image_url %}
<img src="{{ user.profile_image_url }}" alt="프로필">
{% else %}
😊
{% endif %}
</div>
<div class="profile-details">
<h2>{{ user.nickname or '회원' }}님</h2>
<p>{{ user.phone or '전화번호 미등록' }}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon purple">🎁</div>
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
<div class="stat-label">포인트</div>
</div>
<div class="stat-item">
<div class="stat-icon blue">📦</div>
<div class="stat-value">{{ purchase_count or 0 }}</div>
<div class="stat-label">구매</div>
</div>
<div class="stat-item">
<div class="stat-icon pink">🐾</div>
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
<div class="stat-label">반려동물</div>
</div>
</div>
</div>
<!-- 반려동물 섹션 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">🐾 내 반려동물</h3>
</div>
<div id="pet-list">
{% if pets %}
{% for pet in pets %}
<div class="pet-card" onclick="editPet({{ pet.id }})">
<div class="pet-photo">
{% if pet.photo_url %}
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
{% else %}
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
{% endif %}
</div>
<div class="pet-info">
<div class="pet-name">{{ pet.name }}</div>
<div class="pet-details">
{{ pet.species_label }}
{% if pet.breed %}· {{ pet.breed }}{% endif %}
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
</div>
</div>
<span class="pet-arrow"></span>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="icon">🐾</div>
<p>등록된 반려동물이 없습니다</p>
</div>
{% endif %}
</div>
<button class="add-pet-btn" onclick="openAddPetModal()">
<span>+</span> 반려동물 추가하기
</button>
</div>
<!-- 메뉴 섹션 -->
<div class="section">
<ul class="menu-list">
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
<div class="menu-left">
<span class="menu-icon">📋</span>
<span class="menu-text">적립 내역</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">📦</span>
<span class="menu-text">구매 내역</span>
<span class="menu-badge">준비중</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">🎟️</span>
<span class="menu-text">쿠폰함</span>
<span class="menu-badge">준비중</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">⚙️</span>
<span class="menu-text">내 정보 수정</span>
</div>
<span class="menu-arrow"></span>
</li>
</ul>
</div>
</div>
<!-- 반려동물 추가/수정 모달 -->
<div class="modal-overlay" id="petModal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<form id="petForm" onsubmit="submitPet(event)">
<input type="hidden" id="petId" value="">
<!-- 종류 선택 -->
<div class="form-group">
<label class="form-label">종류 *</label>
<div class="species-options">
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
<div class="icon">🐕</div>
<div class="label">강아지</div>
</div>
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
<div class="icon">🐈</div>
<div class="label">고양이</div>
</div>
</div>
</div>
<!-- 이름 -->
<div class="form-group">
<label class="form-label">이름 *</label>
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
</div>
<!-- 품종 -->
<div class="form-group">
<label class="form-label">품종</label>
<select class="form-input" id="petBreed">
<option value="">선택해주세요</option>
</select>
</div>
<!-- 성별 -->
<div class="form-group">
<label class="form-label">성별</label>
<select class="form-input" id="petGender">
<option value="">선택해주세요</option>
<option value="male">남아 ♂</option>
<option value="female">여아 ♀</option>
<option value="unknown">모름</option>
</select>
</div>
<!-- 사진 -->
<div class="form-group">
<label class="form-label">사진</label>
<div class="photo-upload">
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
📷
</div>
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
<span class="photo-hint">탭하여 사진 추가</span>
</div>
</div>
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
</form>
</div>
</div>
<script>
let selectedSpecies = '';
let currentPetId = null;
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
function selectSpecies(species) {
selectedSpecies = species;
document.querySelectorAll('.species-option').forEach(el => {
el.classList.toggle('selected', el.dataset.species === species);
});
// 품종 옵션 업데이트
const breedSelect = document.getElementById('petBreed');
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
}
function openAddPetModal() {
currentPetId = null;
document.getElementById('modalTitle').textContent = '반려동물 등록';
document.getElementById('petId').value = '';
document.getElementById('petForm').reset();
document.getElementById('photoPreview').innerHTML = '📷';
document.getElementById('submitBtn').textContent = '등록하기';
document.getElementById('deleteBtn').style.display = 'none';
selectedSpecies = '';
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
document.getElementById('petModal').classList.add('active');
}
function editPet(petId) {
// TODO: API에서 pet 정보 가져와서 폼에 채우기
currentPetId = petId;
document.getElementById('modalTitle').textContent = '반려동물 수정';
document.getElementById('submitBtn').textContent = '수정하기';
document.getElementById('deleteBtn').style.display = 'block';
document.getElementById('petModal').classList.add('active');
}
function closeModal() {
document.getElementById('petModal').classList.remove('active');
}
function previewPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('photoPreview').innerHTML =
`<img src="${e.target.result}" alt="미리보기">`;
};
reader.readAsDataURL(file);
}
}
async function submitPet(event) {
event.preventDefault();
if (!selectedSpecies) {
alert('종류를 선택해주세요.');
return;
}
const name = document.getElementById('petName').value.trim();
if (!name) {
alert('이름을 입력해주세요.');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '처리중...';
try {
const data = {
name: name,
species: selectedSpecies,
breed: document.getElementById('petBreed').value,
gender: document.getElementById('petGender').value
};
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
const method = currentPetId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// 사진 업로드
const photoInput = document.getElementById('photoInput');
if (photoInput.files.length > 0) {
const petId = result.pet_id || currentPetId;
const formData = new FormData();
formData.append('photo', photoInput.files[0]);
await fetch(`/api/pets/${petId}/photo`, {
method: 'POST',
body: formData
});
}
alert(result.message || '저장되었습니다!');
location.reload();
} else {
alert(result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다.');
} finally {
btn.disabled = false;
btn.textContent = currentPetId ? '수정하기' : '등록하기';
}
}
async function deletePet() {
if (!currentPetId) return;
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/pets/${currentPetId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
alert('삭제되었습니다.');
location.reload();
} else {
alert(result.error || '삭제 실패');
}
} catch (error) {
alert('서버 오류가 발생했습니다.');
}
}
// 모달 외부 클릭 시 닫기
document.getElementById('petModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
</script>
</body>
</html>

2275
backend/templates/pmr.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,869 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAAI 어드민 - 청춘약국</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.header h1 {
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 10px;
}
.header .nav-links {
display: flex;
gap: 15px;
}
.header .nav-links a {
color: rgba(255,255,255,0.9);
text-decoration: none;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.header .nav-links a:hover,
.header .nav-links a.active {
background: rgba(255,255,255,0.2);
color: #fff;
}
/* 메인 컨테이너 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
display: flex;
align-items: center;
gap: 15px;
}
.stat-card .icon {
width: 50px;
height: 50px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.stat-card .icon.blue { background: #dbeafe; }
.stat-card .icon.green { background: #d1fae5; }
.stat-card .icon.yellow { background: #fef3c7; }
.stat-card .icon.red { background: #fee2e2; }
.stat-card .icon.purple { background: #ede9fe; }
.stat-card .info { flex: 1; }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #1f2937; }
.stat-card .label { font-size: 0.85rem; color: #6b7280; }
/* 섹션 */
.section {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.section-header {
background: #f9fafb;
padding: 15px 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h2 {
font-size: 1.1rem;
color: #374151;
display: flex;
align-items: center;
gap: 8px;
}
.section-body {
padding: 20px;
}
/* 필터 */
.filters {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 0.85rem;
color: #6b7280;
}
.filter-group input,
.filter-group select {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #10b981;
color: #fff;
}
.btn-primary:hover { background: #059669; }
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover { background: #d1d5db; }
/* 로그 테이블 */
.log-table {
width: 100%;
border-collapse: collapse;
}
.log-table th {
background: #f9fafb;
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
font-size: 0.85rem;
}
.log-table td {
padding: 12px 15px;
border-bottom: 1px solid #e5e7eb;
font-size: 0.9rem;
color: #4b5563;
}
.log-table tr:hover { background: #f9fafb; }
.log-table .badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-pending { background: #fef3c7; color: #92400e; }
.badge-severe { background: #fee2e2; color: #dc2626; }
.badge-useful { background: #d1fae5; color: #065f46; }
.badge-not-useful { background: #fee2e2; color: #991b1b; }
.badge-no-feedback { background: #e5e7eb; color: #6b7280; }
.log-table .actions button {
padding: 6px 12px;
background: #ede9fe;
color: #7c3aed;
border: none;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.log-table .actions button:hover {
background: #ddd6fe;
}
/* 모달 */
.modal {
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: flex-start;
padding: 40px 20px;
overflow-y: auto;
}
.modal.show { display: flex; }
.modal-content {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 900px;
box-shadow: 0 25px 50px rgba(0,0,0,0.2);
}
.modal-header {
background: linear-gradient(135deg, #7c3aed, #a855f7);
color: #fff;
padding: 20px 25px;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 16px 16px 0 0;
}
.modal-header h3 { font-size: 1.2rem; }
.modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
}
.modal-close:hover { background: rgba(255,255,255,0.3); }
.modal-body {
padding: 25px;
max-height: 70vh;
overflow-y: auto;
}
/* 상세 로그 섹션 */
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 0.9rem;
font-weight: 700;
color: #374151;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.detail-section-title:hover { color: #10b981; }
.detail-section-content {
background: #f9fafb;
border-radius: 8px;
padding: 15px;
font-size: 0.85rem;
line-height: 1.6;
}
.detail-section-content.collapsed {
display: none;
}
.detail-section-content pre {
background: #1f2937;
color: #e5e7eb;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.detail-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 15px;
}
.detail-grid dt {
color: #6b7280;
font-weight: 500;
}
.detail-grid dd {
color: #1f2937;
}
/* 차트 영역 */
.chart-container {
height: 200px;
display: flex;
align-items: flex-end;
gap: 8px;
padding: 20px 0;
}
.chart-bar {
flex: 1;
background: linear-gradient(to top, #10b981, #34d399);
border-radius: 4px 4px 0 0;
min-height: 10px;
position: relative;
cursor: pointer;
transition: all 0.2s;
}
.chart-bar:hover {
transform: scaleY(1.05);
transform-origin: bottom;
}
.chart-bar .tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.chart-bar:hover .tooltip { opacity: 1; }
.chart-labels {
display: flex;
gap: 8px;
}
.chart-labels span {
flex: 1;
text-align: center;
font-size: 0.7rem;
color: #9ca3af;
}
/* 로딩 */
.loading {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #9ca3af;
}
.empty-state .icon { font-size: 3rem; margin-bottom: 15px; }
/* 반응형 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.filters {
flex-direction: column;
}
.log-table {
font-size: 0.8rem;
}
.log-table th, .log-table td {
padding: 8px 10px;
}
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header">
<h1>🤖 PAAI 어드민</h1>
<nav class="nav-links">
<a href="/pmr" class="active">← 조제관리</a>
<a href="#" onclick="refreshData()">🔄 새로고침</a>
</nav>
</header>
<!-- 메인 -->
<div class="container">
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="icon blue">📊</div>
<div class="info">
<div class="value" id="statTotal">-</div>
<div class="label">총 분석</div>
</div>
</div>
<div class="stat-card">
<div class="icon green">📅</div>
<div class="info">
<div class="value" id="statToday">-</div>
<div class="label">오늘</div>
</div>
</div>
<div class="stat-card">
<div class="icon purple">👍</div>
<div class="info">
<div class="value" id="statUseful">-</div>
<div class="label">유용 평가율</div>
</div>
</div>
<div class="stat-card">
<div class="icon yellow">⚠️</div>
<div class="info">
<div class="value" id="statSevere">-</div>
<div class="label">KIMS 경고 (오늘)</div>
</div>
</div>
<div class="stat-card">
<div class="icon blue">⏱️</div>
<div class="info">
<div class="value" id="statAvgTime">-</div>
<div class="label">평균 응답시간</div>
</div>
</div>
</div>
<!-- 일별 통계 차트 -->
<div class="section">
<div class="section-header">
<h2>📈 일별 분석 추이 (최근 14일)</h2>
</div>
<div class="section-body">
<div class="chart-container" id="dailyChart"></div>
<div class="chart-labels" id="chartLabels"></div>
</div>
</div>
<!-- 분석 이력 -->
<div class="section">
<div class="section-header">
<h2>📋 분석 이력</h2>
</div>
<div class="section-body">
<!-- 필터 -->
<div class="filters">
<div class="filter-group">
<label>날짜:</label>
<input type="date" id="filterDate">
</div>
<div class="filter-group">
<label>환자명:</label>
<input type="text" id="filterPatient" placeholder="검색...">
</div>
<div class="filter-group">
<label>상태:</label>
<select id="filterStatus">
<option value="">전체</option>
<option value="success">성공</option>
<option value="error">에러</option>
<option value="pending">대기중</option>
</select>
</div>
<div class="filter-group">
<label>KIMS 경고:</label>
<select id="filterSevere">
<option value="">전체</option>
<option value="true">있음</option>
<option value="false">없음</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadLogs()">검색</button>
<button class="btn btn-secondary" onclick="clearFilters()">초기화</button>
</div>
<!-- 테이블 -->
<div id="logsContainer">
<div class="loading">
<div class="spinner"></div>
<div>로딩 중...</div>
</div>
</div>
</div>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">📋 분석 상세</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<script>
// 초기화
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadDailyStats();
loadLogs();
});
// 통계 로드
async function loadStats() {
try {
const res = await fetch('/pmr/api/admin/stats');
const data = await res.json();
if (data.success) {
const s = data.stats;
document.getElementById('statTotal').textContent = s.total.toLocaleString();
document.getElementById('statToday').textContent = s.today;
document.getElementById('statSevere').textContent = s.severe_count;
document.getElementById('statAvgTime').textContent = (s.avg_response_time / 1000).toFixed(1) + '초';
if (s.feedback && s.feedback.total > 0) {
document.getElementById('statUseful').textContent = s.feedback.rate + '%';
} else {
document.getElementById('statUseful').textContent = '-';
}
}
} catch (err) {
console.error('Stats error:', err);
}
}
// 일별 통계 로드
async function loadDailyStats() {
try {
const res = await fetch('/pmr/api/admin/feedback-stats');
const data = await res.json();
if (data.success && data.stats.length > 0) {
renderChart(data.stats.slice(0, 14).reverse());
}
} catch (err) {
console.error('Daily stats error:', err);
}
}
// 차트 렌더링
function renderChart(stats) {
const container = document.getElementById('dailyChart');
const labels = document.getElementById('chartLabels');
const maxTotal = Math.max(...stats.map(s => s.total), 1);
container.innerHTML = stats.map(s => {
const height = Math.max((s.total / maxTotal) * 100, 5);
const usefulPct = s.total > 0 ? Math.round((s.useful / s.total) * 100) : 0;
return `
<div class="chart-bar" style="height: ${height}%">
<div class="tooltip">
${s.date.slice(5)}<br>
분석: ${s.total}건<br>
유용: ${usefulPct}%<br>
경고: ${s.severe}
</div>
</div>
`;
}).join('');
labels.innerHTML = stats.map(s => `<span>${s.date.slice(5)}</span>`).join('');
}
// 로그 로드
async function loadLogs() {
const container = document.getElementById('logsContainer');
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>로딩 중...</div></div>';
try {
const params = new URLSearchParams();
const date = document.getElementById('filterDate').value;
const patient = document.getElementById('filterPatient').value;
const status = document.getElementById('filterStatus').value;
const severe = document.getElementById('filterSevere').value;
if (date) params.append('date', date);
if (patient) params.append('patient_name', patient);
if (status) params.append('status', status);
if (severe) params.append('has_severe', severe);
params.append('limit', '100');
const res = await fetch('/pmr/api/admin/logs?' + params.toString());
const data = await res.json();
if (data.success) {
renderLogs(data.logs);
} else {
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>로드 실패</div></div>';
}
} catch (err) {
console.error('Logs error:', err);
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>오류 발생</div></div>';
}
}
// 로그 테이블 렌더링
function renderLogs(logs) {
const container = document.getElementById('logsContainer');
if (logs.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>분석 이력이 없습니다</div></div>';
return;
}
container.innerHTML = `
<table class="log-table">
<thead>
<tr>
<th>#</th>
<th>일시</th>
<th>환자</th>
<th>약품수</th>
<th>KIMS</th>
<th>상태</th>
<th>피드백</th>
<th>응답시간</th>
<th>상세</th>
</tr>
</thead>
<tbody>
${logs.map(log => {
const date = new Date(log.created_at);
const dateStr = date.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const statusBadge = {
'success': '<span class="badge badge-success">성공</span>',
'error': '<span class="badge badge-error">에러</span>',
'pending': '<span class="badge badge-pending">대기</span>',
'kims_done': '<span class="badge badge-pending">AI 대기</span>'
}[log.status] || log.status;
let feedbackBadge = '<span class="badge badge-no-feedback">-</span>';
if (log.feedback_useful === 1) {
feedbackBadge = '<span class="badge badge-useful">👍</span>';
} else if (log.feedback_useful === 0) {
feedbackBadge = '<span class="badge badge-not-useful">👎</span>';
}
const kimsInfo = log.kims_has_severe
? `<span class="badge badge-severe"> ${log.kims_interaction_count}</span>`
: (log.kims_interaction_count > 0 ? `${log.kims_interaction_count}` : '-');
const responseTime = log.ai_response_time_ms
? (log.ai_response_time_ms / 1000).toFixed(1) + '초'
: '-';
return `
<tr>
<td>${log.id}</td>
<td>${dateStr}</td>
<td>${log.patient_name || '-'}</td>
<td>${log.current_med_count || 0}</td>
<td>${kimsInfo}</td>
<td>${statusBadge}</td>
<td>${feedbackBadge}</td>
<td>${responseTime}</td>
<td class="actions">
<button onclick="showDetail(${log.id})">상세</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
// 필터 초기화
function clearFilters() {
document.getElementById('filterDate').value = '';
document.getElementById('filterPatient').value = '';
document.getElementById('filterStatus').value = '';
document.getElementById('filterSevere').value = '';
loadLogs();
}
// 상세 보기
async function showDetail(logId) {
const modal = document.getElementById('detailModal');
const body = document.getElementById('modalBody');
const title = document.getElementById('modalTitle');
modal.classList.add('show');
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const res = await fetch(`/pmr/api/admin/log/${logId}`);
const data = await res.json();
if (data.success) {
renderDetail(data.log);
} else {
body.innerHTML = '<div class="empty-state">로드 실패</div>';
}
} catch (err) {
console.error('Detail error:', err);
body.innerHTML = '<div class="empty-state">오류 발생</div>';
}
}
// 상세 렌더링
function renderDetail(log) {
const body = document.getElementById('modalBody');
const title = document.getElementById('modalTitle');
title.textContent = `📋 분석 상세 - ${log.patient_name || '환자'} (#${log.id})`;
// 약품 목록 포맷
let medsHtml = '-';
if (log.current_medications && log.current_medications.length > 0) {
medsHtml = log.current_medications.map(m =>
`${m.name || m.code} (${m.dosage || '-'} × ${m.frequency || '-'} × ${m.days || '-'})`
).join('<br>');
}
// 피드백 상태
let feedbackHtml = '<span class="badge badge-no-feedback">없음</span>';
if (log.feedback_useful === 1) {
feedbackHtml = '<span class="badge badge-useful">👍 유용해요</span>';
} else if (log.feedback_useful === 0) {
feedbackHtml = '<span class="badge badge-not-useful">👎 아니요</span>';
}
body.innerHTML = `
<!-- 기본 정보 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
환자/처방 정보
</div>
<div class="detail-section-content">
<dl class="detail-grid">
<dt>처방번호</dt><dd>${log.pre_serial || '-'}</dd>
<dt>환자코드</dt><dd>${log.patient_code || '-'}</dd>
<dt>환자명</dt><dd>${log.patient_name || '-'}</dd>
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
<dt>약품</dt><dd>${medsHtml}</dd>
<dt>분석일시</dt><dd>${log.created_at}</dd>
<dt>상태</dt><dd>${log.status}</dd>
<dt>피드백</dt><dd>${feedbackHtml}</dd>
</dl>
</div>
</div>
<!-- KIMS 결과 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
KIMS 상호작용 (${log.kims_response_time_ms || 0}ms)
</div>
<div class="detail-section-content">
<p><strong>조회 약품:</strong> ${(log.kims_drug_codes || []).join(', ') || '-'}</p>
<p><strong>상호작용:</strong> ${log.kims_interaction_count || 0} ${log.kims_has_severe ? ' ' : ''}</p>
${log.kims_interactions && log.kims_interactions.length > 0 ? `
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
` : ''}
</div>
</div>
<!-- AI 프롬프트 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
AI 프롬프트 (클릭하여 펼치기)
</div>
<div class="detail-section-content collapsed">
<pre>${escapeHtml(log.ai_prompt || '없음')}</pre>
</div>
</div>
<!-- AI 응답 -->
<div class="detail-section">
<div class="detail-section-title" onclick="toggleSection(this)">
AI 응답 (${log.ai_response_time_ms || 0}ms, ${log.ai_model || '-'})
</div>
<div class="detail-section-content">
<pre>${JSON.stringify(log.ai_response, null, 2) || '없음'}</pre>
</div>
</div>
${log.error_message ? `
<div class="detail-section">
<div class="detail-section-title" style="color: #dc2626;">
⚠️ 에러 메시지
</div>
<div class="detail-section-content" style="background: #fee2e2;">
${escapeHtml(log.error_message)}
</div>
</div>
` : ''}
`;
}
// 섹션 토글
function toggleSection(titleEl) {
const content = titleEl.nextElementSibling;
const isCollapsed = content.classList.contains('collapsed');
content.classList.toggle('collapsed');
titleEl.textContent = titleEl.textContent.replace(/^[▼▶]/, isCollapsed ? '▼' : '▶');
}
// 모달 닫기
function closeModal() {
document.getElementById('detailModal').classList.remove('show');
}
// 데이터 새로고침
function refreshData() {
loadStats();
loadDailyStats();
loadLogs();
}
// HTML 이스케이프
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 모달 외부 클릭 시 닫기
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>

8
backend/test_pg.py Normal file
View File

@@ -0,0 +1,8 @@
from sqlalchemy import create_engine, text
pg_engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
with pg_engine.connect() as conn:
result = conn.execute(text("SELECT apc, product_name, company_name, main_ingredient FROM apc WHERE product_name LIKE '%아시엔로%' LIMIT 20"))
print('아시엔로 검색 결과:')
for row in result:
print(f' APC: {row[0]} | {row[1]} | {row[2]} | {row[3]}')

View File

@@ -0,0 +1,283 @@
"""
OTC 용법 라벨 출력 모듈
Brother QL-810W 프린터용 가로형 와이드 라벨 생성 및 출력
기반: person-lookup-web-local/print_label.py
"""
from PIL import Image, ImageDraw, ImageFont
import logging
import re
from pathlib import Path
# 프린터 설정 (QL-810W)
PRINTER_IP = "192.168.0.168" # QR 라벨과 동일한 Brother QL-810W
PRINTER_MODEL = "QL-810W"
LABEL_TYPE = "29" # 29mm 연속 출력 용지
# 폰트 경로 (Windows/Linux 크로스 플랫폼)
FONT_PATHS = [
"C:/Windows/Fonts/malgunbd.ttf", # Windows
"/srv/person-lookup-web-local/pop_maker/fonts/malgunbd.ttf", # Linux
"/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux 대체
]
def get_font_path():
"""사용 가능한 폰트 경로 반환"""
for path in FONT_PATHS:
if Path(path).exists():
return path
return None
def create_otc_label_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
OTC 용법 라벨 이미지 생성 (800 x 306px)
레이아웃:
- 효능: 중앙 상단에 크게 강조 (72pt)
- 약품명: 오른쪽 중간 (36pt)
- 용법: 왼쪽 하단 체크박스 (40pt)
- 약국명: 오른쪽 하단 테두리 박스 (32pt)
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
PIL.Image: 가로형 와이드 라벨 이미지 (800 x 306px, mode='1')
"""
try:
# 1. 캔버스 생성 (가로로 긴 형태)
width = 800
height = 306 # Brother QL 29mm 용지 폭
img = Image.new('1', (width, height), 1) # 흰색 배경
draw = ImageDraw.Draw(img)
# 2. 폰트 로드
font_path = get_font_path()
try:
font_effect = ImageFont.truetype(font_path, 72) # 효능 (매우 크게!)
font_drugname = ImageFont.truetype(font_path, 36) # 약품명 (중간)
font_dosage = ImageFont.truetype(font_path, 40) # 용법 (크게)
font_pharmacy = ImageFont.truetype(font_path, 32) # 약국명 (크게)
font_small = ImageFont.truetype(font_path, 26) # 사용팁
except (IOError, TypeError):
font_effect = ImageFont.load_default()
font_drugname = ImageFont.load_default()
font_dosage = ImageFont.load_default()
font_pharmacy = ImageFont.load_default()
font_small = ImageFont.load_default()
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
# 3. 레이아웃
x_margin = 25
# 효능 - 중앙 상단에 크게 (매우 강조!)
if effect:
effect_bbox = draw.textbbox((0, 0), effect, font=font_effect)
effect_width = effect_bbox[2] - effect_bbox[0]
effect_x = (width - effect_width) // 2
# 굵게 표시 (offset)
for offset in [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]:
draw.text((effect_x + offset[0], 20 + offset[1]), effect, font=font_effect, fill=0)
# 약품명 - 오른쪽 중간 여백에 배치
drugname_bbox = draw.textbbox((0, 0), drug_name, font=font_drugname)
drugname_width = drugname_bbox[2] - drugname_bbox[0]
drugname_x = width - drugname_width - 30 # 오른쪽에서 30px 여백
drugname_y = 195
draw.text((drugname_x, drugname_y), drug_name, font=font_drugname, fill=0)
# 용법 - 왼쪽 하단에 크게 표시
y = 120 # 효능 아래부터 시작
# 사용팁이 없으면 복용방법을 더 크게
if not usage_tip:
try:
font_dosage_adjusted = ImageFont.truetype(font_path, 50)
except:
font_dosage_adjusted = font_dosage
else:
font_dosage_adjusted = font_dosage
if dosage_instruction:
# 대괄호로 묶인 부분을 별도 줄로 분리
dosage_text = re.sub(r'\s*(\[.*?\])\s*', r'\n\1\n', dosage_instruction)
# 여러 줄 처리
max_chars_per_line = 32
dosage_lines = []
text_parts = dosage_text.split('\n')
for part in text_parts:
part = part.strip()
if not part:
continue
if part.startswith('[') and part.endswith(']'):
dosage_lines.append(part)
elif len(part) > max_chars_per_line:
words = part.split()
current_line = ""
for word in words:
if len(current_line + word) <= max_chars_per_line:
current_line += word + " "
else:
if current_line:
dosage_lines.append(current_line.strip())
current_line = word + " "
if current_line:
dosage_lines.append(current_line.strip())
else:
dosage_lines.append(part)
# 첫 줄에 체크박스 추가
if dosage_lines:
first_line = f"{dosage_lines[0]}"
draw.text((x_margin, y), first_line, font=font_dosage_adjusted, fill=0)
line_spacing = 60 if not usage_tip else 50
y += line_spacing
for line in dosage_lines[1:]:
indent = 0 if (line.startswith('[') and line.endswith(']')) else 30
draw.text((x_margin + indent, y), line, font=font_dosage_adjusted, fill=0)
y += line_spacing + 2
# 사용팁 (체크박스 + 텍스트)
if usage_tip and y < height - 60:
tip_text = f"{usage_tip}"
if len(tip_text) > 55:
tip_text = tip_text[:52] + "..."
draw.text((x_margin, y), tip_text, font=font_small, fill=0)
# 약국명 - 오른쪽 하단에 크게 (테두리 박스)
sign_text = "청춘약국"
sign_bbox = draw.textbbox((0, 0), sign_text, font=font_pharmacy)
sign_width = sign_bbox[2] - sign_bbox[0]
sign_height = sign_bbox[3] - sign_bbox[1]
sign_padding_lr = 10
sign_padding_top = 5
sign_padding_bottom = 10
sign_x = width - sign_width - x_margin - 10 - sign_padding_lr
sign_y = height - 55
# 테두리 박스 그리기
box_x1 = sign_x - sign_padding_lr
box_y1 = sign_y - sign_padding_top
box_x2 = sign_x + sign_width + sign_padding_lr
box_y2 = sign_y + sign_height + sign_padding_bottom
draw.rectangle([box_x1, box_y1, box_x2, box_y2], outline=0, width=2)
# 약국명 텍스트 (굵게)
for offset in [(0, 0), (1, 0), (0, 1), (1, 1)]:
draw.text((sign_x + offset[0], sign_y + offset[1]), sign_text, font=font_pharmacy, fill=0)
# 5. 테두리 (가위선 스타일)
for i in range(3):
draw.rectangle([5 + i, 5 + i, width - 5 - i, height - 5 - i], outline=0)
logging.info(f"OTC 라벨 이미지 생성 성공: {drug_name}")
return img
except Exception as e:
logging.error(f"OTC 라벨 이미지 생성 실패: {e}")
raise
def print_otc_label(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
OTC 용법 라벨을 Brother QL-810W 프린터로 출력
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
bool: 성공 여부
"""
try:
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
# 1. 라벨 이미지 생성
label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip)
# 2. 이미지 90도 회전 (Brother QL이 세로 방향 기준이므로)
label_img_rotated = label_img.rotate(90, expand=True)
logging.info(f"이미지 회전 완료: {label_img_rotated.size}")
# 3. Brother QL 프린터로 전송
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[label_img_rotated],
label='29',
rotate='0',
threshold=70.0,
dither=False,
compress=False,
red=False,
dpi_600=False,
hq=True,
cut=True
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info(f"[SUCCESS] OTC 용법 라벨 인쇄 성공: {drug_name}")
return True
except ImportError:
logging.error("brother_ql 라이브러리가 설치되지 않았습니다.")
return False
except Exception as e:
logging.error(f"[ERROR] OTC 용법 라벨 인쇄 실패: {e}")
return False
def generate_preview_image(drug_name, effect="", dosage_instruction="", usage_tip=""):
"""
미리보기용 PNG 이미지 생성 (Base64 인코딩)
Args:
drug_name (str): 약품명
effect (str): 효능
dosage_instruction (str): 복용 방법
usage_tip (str): 사용 팁
Returns:
str: Base64 인코딩된 PNG 이미지 (data:image/png;base64,... 형태)
"""
import base64
from io import BytesIO
try:
# 라벨 이미지 생성
label_img = create_otc_label_image(drug_name, effect, dosage_instruction, usage_tip)
# RGB로 변환 (1-bit → RGB)
label_img_rgb = label_img.convert('RGB')
# PNG로 인코딩
buffer = BytesIO()
label_img_rgb.save(buffer, format='PNG')
buffer.seek(0)
# Base64 인코딩
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return f"data:image/png;base64,{img_base64}"
except Exception as e:
logging.error(f"미리보기 이미지 생성 실패: {e}")
return None

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
"""
yakkok.com 제품 이미지 크롤러
- 제품명으로 검색하여 이미지 URL 추출
- base64로 변환하여 SQLite에 저장
"""
import os
import sys
import sqlite3
import base64
import logging
import hashlib
import re
from datetime import datetime
from urllib.parse import quote
import requests
from PIL import Image
from io import BytesIO
# Playwright 동기 모드
from playwright.sync_api import sync_playwright
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'db', 'product_images.db')
# yakkok.com 설정
YAKKOK_BASE_URL = "https://yakkok.com"
YAKKOK_SEARCH_URL = "https://yakkok.com/search?q={query}"
def init_db():
"""DB 초기화"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 스키마 파일 실행
schema_path = os.path.join(os.path.dirname(__file__), '..', 'db', 'product_images_schema.sql')
if os.path.exists(schema_path):
with open(schema_path, 'r', encoding='utf-8') as f:
cursor.executescript(f.read())
conn.commit()
conn.close()
logger.info(f"[DB] 초기화 완료: {DB_PATH}")
def get_existing_barcodes():
"""이미 저장된 바코드 목록 조회"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT barcode FROM product_images WHERE status IN ('success', 'manual')")
barcodes = set(row[0] for row in cursor.fetchall())
conn.close()
return barcodes
def save_product_image(barcode, drug_code, product_name, search_name,
image_base64, image_url, thumbnail_base64=None,
status='success', error_message=None):
"""제품 이미지 저장"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO product_images
(barcode, drug_code, product_name, search_name, image_base64, image_url,
thumbnail_base64, status, error_message, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (barcode, drug_code, product_name, search_name, image_base64, image_url,
thumbnail_base64, status, error_message, datetime.now().isoformat()))
conn.commit()
conn.close()
logger.info(f"[DB] 저장 완료: {product_name} ({barcode}) - {status}")
def download_image_as_base64(url, max_size=500):
"""이미지 다운로드 후 base64 변환 (리사이즈 포함)"""
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
# PIL로 이미지 열기
img = Image.open(BytesIO(response.content))
# RGBA -> RGB 변환 (JPEG 저장용)
if img.mode == 'RGBA':
bg = Image.new('RGB', img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif img.mode != 'RGB':
img = img.convert('RGB')
# 리사이즈 (비율 유지)
if max(img.size) > max_size:
ratio = max_size / max(img.size)
new_size = tuple(int(dim * ratio) for dim in img.size)
img = img.resize(new_size, Image.LANCZOS)
# base64 변환
buffer = BytesIO()
img.save(buffer, format='JPEG', quality=85)
base64_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
return base64_str
except Exception as e:
logger.error(f"[ERROR] 이미지 다운로드 실패: {url} - {e}")
return None
def clean_product_name(name):
"""검색용 제품명 정리"""
# 괄호 안 내용 제거 (용량 등)
name = re.sub(r'\([^)]*\)', '', name)
# 숫자+단위 제거 (100ml, 500mg 등)
name = re.sub(r'\d+\s*(ml|mg|g|kg|정|캡슐|T|t|개|EA|ea)', '', name, flags=re.IGNORECASE)
# 특수문자 제거
name = re.sub(r'[_\-/\\]', ' ', name)
# 연속 공백 정리
name = re.sub(r'\s+', ' ', name).strip()
return name
def search_yakkok(page, product_name):
"""yakkok.com에서 제품 검색하여 이미지 URL 반환"""
try:
# 검색어 정리
search_name = clean_product_name(product_name)
if not search_name:
search_name = product_name
# 검색 페이지 접속
search_url = YAKKOK_SEARCH_URL.format(query=quote(search_name))
page.goto(search_url, wait_until='networkidle', timeout=15000)
# 잠시 대기
page.wait_for_timeout(1000)
# 첫 번째 검색 결과의 이미지 찾기
img_selector = 'img[alt]'
images = page.query_selector_all(img_selector)
for img in images:
src = img.get_attribute('src')
alt = img.get_attribute('alt') or ''
# 로고, 아이콘 등 제외
if not src or 'logo' in src.lower() or 'icon' in src.lower():
continue
# 검색 아이콘 등 제외
if alt in ['검색', '', '마이', '재고콕', '약콕인증', '뒤로가기']:
continue
# 제품 이미지로 보이는 것 반환
if src.startswith('http') or src.startswith('//'):
if src.startswith('//'):
src = 'https:' + src
return src, search_name
return None, search_name
except Exception as e:
logger.error(f"[ERROR] 검색 실패: {product_name} - {e}")
return None, search_name
def crawl_products(products, headless=True):
"""
제품 목록 크롤링
products: [(barcode, drug_code, product_name), ...]
"""
init_db()
existing = get_existing_barcodes()
# 새로 크롤링할 제품만 필터
to_crawl = [(b, d, n) for b, d, n in products if b not in existing]
if not to_crawl:
logger.info("[INFO] 크롤링할 새 제품이 없습니다.")
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': len(products)}
logger.info(f"[INFO] 크롤링 시작: {len(to_crawl)}개 (스킵: {len(products) - len(to_crawl)}개)")
results = {'total': len(to_crawl), 'success': 0, 'failed': 0, 'skipped': len(products) - len(to_crawl)}
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
context = browser.new_context(
viewport={'width': 390, 'height': 844}, # 모바일 뷰포트
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
)
page = context.new_page()
for barcode, drug_code, product_name in to_crawl:
try:
logger.info(f"[CRAWL] {product_name} ({barcode})")
# yakkok 검색
image_url, search_name = search_yakkok(page, product_name)
if image_url:
# 이미지 다운로드 & base64 변환
image_base64 = download_image_as_base64(image_url)
thumbnail_base64 = download_image_as_base64(image_url, max_size=100)
if image_base64:
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=search_name,
image_base64=image_base64,
image_url=image_url,
thumbnail_base64=thumbnail_base64,
status='success'
)
results['success'] += 1
else:
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=search_name,
image_base64=None,
image_url=image_url,
status='failed',
error_message='이미지 다운로드 실패'
)
results['failed'] += 1
else:
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=search_name,
image_base64=None,
image_url=None,
status='no_result',
error_message='검색 결과 없음'
)
results['failed'] += 1
# 요청 간 딜레이
page.wait_for_timeout(500)
except Exception as e:
logger.error(f"[ERROR] {product_name}: {e}")
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=product_name,
image_base64=None,
image_url=None,
status='failed',
error_message=str(e)
)
results['failed'] += 1
browser.close()
logger.info(f"[DONE] 완료 - 성공: {results['success']}, 실패: {results['failed']}, 스킵: {results['skipped']}")
return results
def get_sales_products(date_str=None):
"""특정 날짜 판매 제품 목록 조회 (MSSQL)
Args:
date_str: 날짜 문자열 (YYYYMMDD 또는 YYYY-MM-DD), None이면 오늘
"""
try:
# 상위 폴더의 db 모듈 import
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from db.dbsetup import db_manager
from sqlalchemy import text
session = db_manager.get_session('PM_PRES')
# 날짜 처리
if date_str:
# YYYY-MM-DD -> YYYYMMDD 변환
target_date = date_str.replace('-', '')
else:
target_date = datetime.now().strftime('%Y%m%d')
# 해당 날짜 판매된 품목 조회 (중복 제거)
query = text("""
SELECT DISTINCT
COALESCE(NULLIF(G.Barcode, ''),
(SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DrugCode = S.DrugCode)
) AS barcode,
S.DrugCode AS drug_code,
ISNULL(G.GoodsName, '알수없음') AS product_name
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order LIKE :date_pattern
AND S.DrugCode IS NOT NULL
""")
result = session.execute(query, {'date_pattern': f'{target_date}%'}).fetchall()
products = []
for row in result:
barcode = row[0]
if barcode: # 바코드 있는 것만
products.append((barcode, row[1], row[2]))
logger.info(f"[MSSQL] {target_date} 판매 품목: {len(products)}")
return products
except Exception as e:
logger.error(f"[ERROR] MSSQL 조회 실패: {e}")
return []
def get_today_sales_products():
"""오늘 판매된 제품 목록 조회 (하위호환)"""
return get_sales_products(None)
def crawl_sales_by_date(date_str=None, headless=True):
"""특정 날짜 판매 제품 이미지 크롤링
Args:
date_str: 날짜 문자열 (YYYYMMDD 또는 YYYY-MM-DD), None이면 오늘
"""
products = get_sales_products(date_str)
if not products:
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': 0, 'message': '해당일 판매 내역 없음'}
return crawl_products(products, headless=headless)
def crawl_today_sales(headless=True):
"""오늘 판매된 제품 이미지 크롤링 (하위호환)"""
return crawl_sales_by_date(None, headless=headless)
# CLI 실행
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='yakkok.com 제품 이미지 크롤러')
parser.add_argument('--today', action='store_true', help='오늘 판매 제품 크롤링')
parser.add_argument('--product', type=str, help='특정 제품명으로 테스트')
parser.add_argument('--visible', action='store_true', help='브라우저 표시')
args = parser.parse_args()
if args.today:
result = crawl_today_sales(headless=not args.visible)
print(f"\n결과: {result}")
elif args.product:
# 테스트용 단일 제품 크롤링
test_products = [('TEST001', 'TEST', args.product)]
result = crawl_products(test_products, headless=not args.visible)
print(f"\n결과: {result}")
else:
print("사용법:")
print(" python yakkok_crawler.py --today # 오늘 판매 제품 크롤링")
print(" python yakkok_crawler.py --product 타이레놀 # 특정 제품 테스트")
print(" python yakkok_crawler.py --visible # 브라우저 표시")

26
check_images_db.py Normal file
View File

@@ -0,0 +1,26 @@
import sqlite3
conn = sqlite3.connect(r'C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\product_images.db')
cursor = conn.cursor()
# 테이블 목록
cursor.execute('SELECT name FROM sqlite_master WHERE type="table"')
tables = [r[0] for r in cursor.fetchall()]
print("테이블:", tables)
# 각 테이블 스키마
for table in tables:
cursor.execute(f'PRAGMA table_info({table})')
cols = [r[1] for r in cursor.fetchall()]
print(f"\n{table} 컬럼: {cols}")
# 샘플 데이터
cursor.execute(f'SELECT * FROM {table} LIMIT 2')
rows = cursor.fetchall()
for r in rows:
print(f" 샘플: {r[:3]}..." if len(r) > 3 else f" 샘플: {r}")
# 총 개수
for table in tables:
cursor.execute(f'SELECT COUNT(*) FROM {table}')
print(f"\n{table}{cursor.fetchone()[0]}")

View File

@@ -0,0 +1,515 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>스마트헬스케어 사업제안서</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.8;
color: #1e293b;
max-width: 210mm;
margin: 0 auto;
padding: 20mm;
background: #fff;
}
h1 {
font-size: 28px;
font-weight: 700;
color: #6366f1;
margin: 40px 0 20px;
padding-bottom: 10px;
border-bottom: 3px solid #6366f1;
}
h2 {
font-size: 22px;
font-weight: 700;
color: #334155;
margin: 35px 0 15px;
padding-bottom: 8px;
border-bottom: 2px solid #e2e8f0;
}
h3 {
font-size: 18px;
font-weight: 600;
color: #475569;
margin: 25px 0 12px;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #64748b;
margin: 20px 0 10px;
}
p {
margin: 12px 0;
text-align: justify;
}
blockquote {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-left: 4px solid #6366f1;
padding: 16px 20px;
margin: 20px 0;
border-radius: 0 8px 8px 0;
font-style: italic;
color: #475569;
}
code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: #dc2626;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 20px;
border-radius: 12px;
overflow-x: auto;
margin: 20px 0;
font-size: 12px;
line-height: 1.6;
}
pre code {
background: none;
color: inherit;
padding: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
}
th {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
padding: 12px 16px;
text-align: left;
font-weight: 600;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
tr:nth-child(even) {
background: #f8fafc;
}
ul, ol {
margin: 15px 0;
padding-left: 25px;
}
li {
margin: 8px 0;
}
hr {
border: none;
height: 2px;
background: linear-gradient(90deg, #6366f1, #8b5cf6, #ec4899);
margin: 40px 0;
border-radius: 2px;
}
strong {
color: #334155;
font-weight: 600;
}
em {
color: #64748b;
}
/* 첫 페이지 타이틀 */
h1:first-of-type {
font-size: 32px;
text-align: center;
border-bottom: none;
margin-top: 60px;
margin-bottom: 10px;
}
h1:first-of-type + blockquote {
text-align: center;
border-left: none;
background: none;
font-size: 18px;
margin-bottom: 60px;
}
/* 프린트 스타일 */
@media print {
body {
padding: 15mm;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
h1, h2, h3 {
page-break-after: avoid;
}
table, pre, blockquote {
page-break-inside: avoid;
}
}
/* 페이지 구분 */
.page-break {
page-break-before: always;
}
</style>
</head>
<body>
<h1 id="apc">동물약 APC 매핑 가이드</h1>
<blockquote>
<p>최종 업데이트: 2026-03-02</p>
</blockquote>
<h2 id="_1">개요</h2>
<p>POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드.</p>
<hr />
<h2 id="_2">현재 상태</h2>
<h3 id="_3">매핑 현황</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>개수</th>
<th>비율</th>
</tr>
</thead>
<tbody>
<tr>
<td>동물약 총</td>
<td>39개</td>
<td>100%</td>
</tr>
<tr>
<td>APC 매핑됨</td>
<td>7개</td>
<td>18%</td>
</tr>
<tr>
<td><strong>APC 미매핑</strong></td>
<td><strong>32개</strong></td>
<td><strong>82%</strong></td>
</tr>
</tbody>
</table>
<h3 id="_4">매핑 완료 제품</h3>
<table>
<thead>
<tr>
<th>POS 제품명</th>
<th>DrugCode</th>
<th>APC</th>
</tr>
</thead>
<tbody>
<tr>
<td>(판)복합개시딘</td>
<td>LB000003140</td>
<td>0231093520106</td>
</tr>
<tr>
<td>안텔민킹(5kg이상)</td>
<td>LB000003158</td>
<td>0230237810109</td>
</tr>
<tr>
<td>안텔민뽀삐(5kg이하)</td>
<td>LB000003157</td>
<td>0230237010107</td>
</tr>
<tr>
<td>파라캅L(5kg이상)</td>
<td>LB000003159</td>
<td>0230338510101</td>
</tr>
<tr>
<td>파라캅S(5kg이하)</td>
<td>LB000003160</td>
<td>0230347110106</td>
</tr>
<tr>
<td>세레니아정16mg(개멀미약)</td>
<td>LB000003353</td>
<td>0231884610109</td>
</tr>
<tr>
<td>세레니아정24mg(개멀미약)</td>
<td>LB000003354</td>
<td>0231884620107</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="_5">매핑 구조</h2>
<h3 id="_6">데이터베이스 연결</h3>
<pre><code>MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432)
┌─────────────────────────┐ ┌─────────────────────────┐
│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │
│ - DrugCode │ │ - apc (PK) │
│ - GoodsName │ │ - product_name │
│ - BARCODE │ │ - image_url1 │
│ │ │ - llm_pharm (JSONB) │
├─────────────────────────┤ └─────────────────────────┘
│ PM_DRUG.CD_ITEM_UNIT_ │
│ MEMBER │
│ - DRUGCODE (FK) │
│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작)
│ - CHANGE_DATE │
└─────────────────────────┘
</code></pre>
<h3 id="apc_1">APC 매핑 방식</h3>
<ol>
<li><code>CD_ITEM_UNIT_MEMBER</code> 테이블에 <strong>추가 바코드</strong>로 APC 등록</li>
<li>기존 바코드는 유지, APC를 별도 레코드로 INSERT</li>
<li>APC 코드는 <code>023%</code>로 시작 (식별자)</li>
</ol>
<hr />
<h2 id="11">1:1 매핑 가능 후보</h2>
<h3 id="1">✅ 확실한 매핑 (1개)</h3>
<table>
<thead>
<tr>
<th>POS 제품명</th>
<th>DrugCode</th>
<th>APC</th>
<th>APDB 제품명</th>
<th>이미지</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>제스타제(10정)</strong></td>
<td>LB000003146</td>
<td>8809720800455</td>
<td>제스타제</td>
<td>✅ 있음</td>
</tr>
</tbody>
</table>
<h3 id="1_1">⚠️ 검토 필요 (1개)</h3>
<table>
<thead>
<tr>
<th>POS 제품명</th>
<th>DrugCode</th>
<th>APC 후보</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr>
<td>안텔민</td>
<td>S0000001</td>
<td>0230237800003</td>
<td>"안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요</td>
</tr>
</tbody>
</table>
<h3 id="apdb-3">❌ APDB에 없음 (3개)</h3>
<table>
<thead>
<tr>
<th>POS 제품명</th>
<th>사유</th>
</tr>
</thead>
<tbody>
<tr>
<td>(판)클라펫정50(100정)</td>
<td>APDB엔 "클라펫 정"만 있음 (함량 불일치)</td>
</tr>
<tr>
<td>넥스가드xs(2~3.5kg)</td>
<td>사이즈별 APC 없음</td>
</tr>
<tr>
<td>캐치원캣(2.5~7.5kg)/고양이</td>
<td>APDB에 캐치원 자체가 없음</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="1n-27">1:N 매핑 필요 제품 (27개)</h2>
<p>사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함.</p>
<h3 id="_7">브랜드별 현황</h3>
<table>
<thead>
<tr>
<th>브랜드</th>
<th>POS 제품 수</th>
<th>APDB 존재</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<tr>
<td>다이로하트</td>
<td>3개 (SS/S/M)</td>
<td></td>
<td>다이로하트 츄어블 정</td>
</tr>
<tr>
<td>하트세이버</td>
<td>4개 (mini/S/M/L)</td>
<td></td>
<td>하트세이버 플러스 츄어블</td>
</tr>
<tr>
<td>하트웜솔루션</td>
<td>2개 (S/M)</td>
<td></td>
<td>APDB에 없음</td>
</tr>
<tr>
<td>리펠로</td>
<td>2개 (S/M)</td>
<td></td>
<td>리펠로액 (이미지 있음!)</td>
</tr>
<tr>
<td>캐치원</td>
<td>5개 (SS/S/M/L/캣)</td>
<td></td>
<td>APDB에 없음</td>
</tr>
<tr>
<td>셀라이트</td>
<td>5개 (SS/S/M/L/XL)</td>
<td></td>
<td>셀라이트 액</td>
</tr>
<tr>
<td>넥스가드</td>
<td>2개 (xs/L)</td>
<td></td>
<td>넥스가드 스펙트라</td>
</tr>
<tr>
<td>가드닐</td>
<td>3개 (S/M/L)</td>
<td></td>
<td>가드닐 액</td>
</tr>
<tr>
<td>심피드</td>
<td>2개 (M/L)</td>
<td></td>
<td>APDB에 없음</td>
</tr>
<tr>
<td>하트캅</td>
<td>1개</td>
<td></td>
<td>하트캅-츄어블 정</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="apdb">APDB 통계</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>수치</th>
</tr>
</thead>
<tbody>
<tr>
<td>전체 APC</td>
<td>16,326개</td>
</tr>
<tr>
<td>이미지 있음</td>
<td>73개 (0.4%)</td>
</tr>
<tr>
<td>LLM 정보 있음</td>
<td>81개 (0.5%)</td>
</tr>
<tr>
<td>동물 관련 키워드</td>
<td>~200개</td>
</tr>
</tbody>
</table>
<p>⚠️ <strong>주의:</strong> APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요.</p>
<hr />
<h2 id="_8">매핑 스크립트</h2>
<h3 id="_9">매핑 후보 찾기</h3>
<pre><code class="language-bash">python backend/scripts/batch_apc_matching.py
</code></pre>
<h3 id="11_1">1:1 매핑 가능 후보 추출</h3>
<pre><code class="language-bash">python backend/scripts/find_1to1_candidates.py
</code></pre>
<h3 id="_10">매핑 실행 (수동)</h3>
<pre><code class="language-python"># backend/scripts/batch_insert_apc.py 참고
MAPPINGS = [
('제스타제(10정)', 'LB000003146', '8809720800455'),
]
</code></pre>
<h3 id="insert">INSERT 쿼리 예시</h3>
<pre><code class="language-sql">INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
) VALUES (
'LB000003146', -- DrugCode
'015', -- 단위코드
1.0, -- 단위명
&lt;기존값&gt;, -- CD_MY_UNIT (기존 레코드에서 복사)
&lt;기존값&gt;, -- CD_IN_UNIT (기존 레코드에서 복사)
'8809720800455', -- APC 바코드
'',
'20260302' -- 변경일자
)
</code></pre>
<hr />
<h2 id="_11">다음 단계</h2>
<ol>
<li><strong>제스타제</strong> 1:1 매핑 실행</li>
<li><strong>안텔민(S0000001)</strong> 제품 확인 후 결정</li>
<li>1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?)</li>
<li>이미지 소스 대안 검토 (필요시)</li>
</ol>
<hr />
<h2 id="_12">관련 파일</h2>
<ul>
<li><code>backend/db/dbsetup.py</code> - DB 연결 설정</li>
<li><code>backend/scripts/batch_apc_matching.py</code> - 매칭 후보 찾기</li>
<li><code>backend/scripts/batch_insert_apc.py</code> - 매핑 실행</li>
<li><code>backend/scripts/find_1to1_candidates.py</code> - 1:1 후보 추출</li>
<li><code>backend/app.py</code> - <code>_get_animal_drugs()</code>, <code>_get_animal_drug_rag()</code></li>
</ul>
</body>
</html>

View File

@@ -0,0 +1,176 @@
# 동물약 APC 매핑 가이드
> 최종 업데이트: 2026-03-02
## 개요
POS(PIT3000)의 동물약 제품을 APDB의 APC 코드와 매핑하여 제품 정보(용법, 용량, 주의사항) 및 이미지를 표시하기 위한 작업 가이드.
---
## 현재 상태
### 매핑 현황
| 구분 | 개수 | 비율 |
|------|------|------|
| 동물약 총 | 39개 | 100% |
| APC 매핑됨 | 7개 | 18% |
| **APC 미매핑** | **32개** | **82%** |
### 매핑 완료 제품
| POS 제품명 | DrugCode | APC |
|------------|----------|-----|
| (판)복합개시딘 | LB000003140 | 0231093520106 |
| 안텔민킹(5kg이상) | LB000003158 | 0230237810109 |
| 안텔민뽀삐(5kg이하) | LB000003157 | 0230237010107 |
| 파라캅L(5kg이상) | LB000003159 | 0230338510101 |
| 파라캅S(5kg이하) | LB000003160 | 0230347110106 |
| 세레니아정16mg(개멀미약) | LB000003353 | 0231884610109 |
| 세레니아정24mg(개멀미약) | LB000003354 | 0231884620107 |
---
## 매핑 구조
### 데이터베이스 연결
```
MSSQL (192.168.0.4\PM2014) PostgreSQL (192.168.0.87:5432)
┌─────────────────────────┐ ┌─────────────────────────┐
│ PM_DRUG.CD_GOODS │ │ apdb_master.apc │
│ - DrugCode │ │ - apc (PK) │
│ - GoodsName │ │ - product_name │
│ - BARCODE │ │ - image_url1 │
│ │ │ - llm_pharm (JSONB) │
├─────────────────────────┤ └─────────────────────────┘
│ PM_DRUG.CD_ITEM_UNIT_ │
│ MEMBER │
│ - DRUGCODE (FK) │
│ - CD_CD_BARCODE ◀───────┼── APC 코드 저장 (023%로 시작)
│ - CHANGE_DATE │
└─────────────────────────┘
```
### APC 매핑 방식
1. `CD_ITEM_UNIT_MEMBER` 테이블에 **추가 바코드**로 APC 등록
2. 기존 바코드는 유지, APC를 별도 레코드로 INSERT
3. APC 코드는 `023%`로 시작 (식별자)
---
## 1:1 매핑 가능 후보
### ✅ 확실한 매핑 (1개)
| POS 제품명 | DrugCode | APC | APDB 제품명 | 이미지 |
|------------|----------|-----|-------------|--------|
| **제스타제(10정)** | LB000003146 | 8809720800455 | 제스타제 | ✅ 있음 |
### ⚠️ 검토 필요 (1개)
| POS 제품명 | DrugCode | APC 후보 | 비고 |
|------------|----------|----------|------|
| 안텔민 | S0000001 | 0230237800003 | "안텔민킹"과 "안텔민뽀삐"는 이미 별도 매핑됨. 이 제품이 무엇인지 확인 필요 |
### ❌ APDB에 없음 (3개)
| POS 제품명 | 사유 |
|------------|------|
| (판)클라펫정50(100정) | APDB엔 "클라펫 정"만 있음 (함량 불일치) |
| 넥스가드xs(2~3.5kg) | 사이즈별 APC 없음 |
| 캐치원캣(2.5~7.5kg)/고양이 | APDB에 캐치원 자체가 없음 |
---
## 1:N 매핑 필요 제품 (27개)
사이즈별로 세분화된 제품들. 하나의 APDB APC에 여러 POS 제품을 매핑해야 함.
### 브랜드별 현황
| 브랜드 | POS 제품 수 | APDB 존재 | 비고 |
|--------|-------------|-----------|------|
| 다이로하트 | 3개 (SS/S/M) | ✅ | 다이로하트 츄어블 정 |
| 하트세이버 | 4개 (mini/S/M/L) | ✅ | 하트세이버 플러스 츄어블 |
| 하트웜솔루션 | 2개 (S/M) | ❌ | APDB에 없음 |
| 리펠로 | 2개 (S/M) | ✅ | 리펠로액 (이미지 있음!) |
| 캐치원 | 5개 (SS/S/M/L/캣) | ❌ | APDB에 없음 |
| 셀라이트 | 5개 (SS/S/M/L/XL) | ✅ | 셀라이트 액 |
| 넥스가드 | 2개 (xs/L) | ✅ | 넥스가드 스펙트라 |
| 가드닐 | 3개 (S/M/L) | ✅ | 가드닐 액 |
| 심피드 | 2개 (M/L) | ❌ | APDB에 없음 |
| 하트캅 | 1개 | ✅ | 하트캅-츄어블 정 |
---
## APDB 통계
| 항목 | 수치 |
|------|------|
| 전체 APC | 16,326개 |
| 이미지 있음 | 73개 (0.4%) |
| LLM 정보 있음 | 81개 (0.5%) |
| 동물 관련 키워드 | ~200개 |
⚠️ **주의:** APDB에 이미지가 거의 없음. 이미지 표시가 목적이라면 다른 소스 필요.
---
## 매핑 스크립트
### 매핑 후보 찾기
```bash
python backend/scripts/batch_apc_matching.py
```
### 1:1 매핑 가능 후보 추출
```bash
python backend/scripts/find_1to1_candidates.py
```
### 매핑 실행 (수동)
```python
# backend/scripts/batch_insert_apc.py 참고
MAPPINGS = [
('제스타제(10정)', 'LB000003146', '8809720800455'),
]
```
### INSERT 쿼리 예시
```sql
INSERT INTO CD_ITEM_UNIT_MEMBER (
DRUGCODE, CD_CD_UNIT, CD_NM_UNIT, CD_MY_UNIT, CD_IN_UNIT,
CD_CD_BARCODE, CD_CD_POS, CHANGE_DATE
) VALUES (
'LB000003146', -- DrugCode
'015', -- 단위코드
1.0, -- 단위명
<>, -- CD_MY_UNIT (기존 레코드에서 복사)
<>, -- CD_IN_UNIT (기존 레코드에서 복사)
'8809720800455', -- APC 바코드
'',
'20260302' -- 변경일자
)
```
---
## 다음 단계
1. **제스타제** 1:1 매핑 실행
2. **안텔민(S0000001)** 제품 확인 후 결정
3. 1:N 매핑 정책 결정 (사이즈별 제품 → 동일 APC?)
4. 이미지 소스 대안 검토 (필요시)
---
## 관련 파일
- `backend/db/dbsetup.py` - DB 연결 설정
- `backend/scripts/batch_apc_matching.py` - 매칭 후보 찾기
- `backend/scripts/batch_insert_apc.py` - 매핑 실행
- `backend/scripts/find_1to1_candidates.py` - 1:1 후보 추출
- `backend/app.py` - `_get_animal_drugs()`, `_get_animal_drug_rag()`

299
docs/APC_MAPPING_PLAN.md Normal file
View File

@@ -0,0 +1,299 @@
# 🎯 APC 기반 동물약 매핑 기획서
> ⚠️ **주의**: 기획 단계에서는 MSSQL **READ ONLY**. 절대 데이터 입력/수정 금지.
---
## 📋 현상황 분석
### 1. 동물약 바코드 문제
#### 문제 1: 바코드 없음
- 동물약은 **수의사 소분 판매 방지** 목적으로 공산품이지만 바코드가 없는 경우 많음
- "판매 최소포장단위"별 바코드가 부여되지 않음
- 예: 다이로하트, 넥스가드 등 → 바코드 없음
#### 문제 2: 바코드 중복
- 바코드가 있어도 **여러 사이즈 제품이 동일 바코드** 사용
- 예: 다이로하트정 SS/S/M/L → 모두 동일 바코드
- 바코드만으로 사이즈/체중 구분 불가
#### 문제 3: 약국별 자체 바코드
- 약국은 POS 재고관리를 위해 **자체 바코드 생성**하여 사용
- 원래 바코드 무시하고 새로 지정
- 이유: POS에서 스캔 시 제품별 즉시 구분 + 재고 차감 필요
#### 결과: 중앙 매핑 불가
```
약국 A: "안텔민사사" → 바코드 "A001"
약국 B: "안텔민사사" → 바코드 "B999"
약국 C: "안텔민사사" → 바코드 없음 (수기 입력)
↓ 중앙 시스템 입장
바코드 "A001" = ??? (알 수 없음)
바코드 "B999" = ??? (알 수 없음)
```
---
### 2. 현재 데이터 구조 (2025-06-30 최종 확인)
```
┌─────────────────────────────────────────────────────────────┐
│ MSSQL (팜IT3000 - 약국 POS) │
├─────────────────────────────────────────────────────────────┤
│ │
│ CD_GOODS (제품 마스터) - 178,182개 │
│ ├── DrugCode: LB000003157 (PK) │
│ ├── GoodsName: "안텔민킹(5kg이상)" │
│ └── 팜IT3000 전체 제품 DB │
│ │ │
│ ├────────────────┬─────────────────────────────┐ │
│ ▼ ▼ ▼ │
│ CD_SALEGOODS CD_ITEM_UNIT_MEMBER CD_BARCODE│
│ (대표 바코드 1개) (바코드 N개!) ★ (인체용) │
│ 3,053개 ├ CD_CD_BARCODE 306,565개│
│ BARCODE: │ 0230237810109 (APC!) 동물약X │
│ 9990000001134 │ 9990000001134 (자체) │
│ └ DRUGCODE → CD_GOODS.DrugCode │
│ │
└─────────────────────────────────────────────────────────────┘
★ 핵심: 한 제품에 여러 바코드 가능! → CD_ITEM_UNIT_MEMBER
★ APC 저장 위치: CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE
★ APC로 이미지 조회: https://ani.0bin.in/img/{APC}_F.jpg
```
│ 매핑 필요
┌─────────────────────────────────────────────────────────────┐
│ PostgreSQL (애니팜 - 동물약 마스터) │
├─────────────────────────────────────────────────────────────┤
│ apc 테이블 │
│ ├── apc: "0230237010107" (고유!) │
│ ├── product_name: "대성 안텔민 사사 정 100mg/25mg/10정" │
│ ├── company_name: "(주)대성미생물연구소" │
│ ├── for_pets: true │
│ ├── image_url1: "https://ani.0bin.in/img/..." │
│ └── godoimage_url_f: "https://cdn.../..." │
└─────────────────────────────────────────────────────────────┘
```
---
## 💡 해결책: APC 기반 매핑
### 핵심 아이디어
**APC(Animal Product Code)를 고유 매핑 키로 사용**
```
CD_GOODS.DrugCode ←→ CD_BARCODE.DRUGCODE ←→ APC(새로추가) ←→ PostgreSQL.apc
```
### 왜 APC인가?
| 키 | 고유성 | 중앙관리 | 이미지 | 현황 |
|----|--------|----------|--------|------|
| 바코드 | ❌ 중복/없음 | ❌ 약국별 다름 | ❌ | 사용 불가 |
| DrugCode | ⚠️ 약국내 고유 | ❌ 약국별 다름 | ❌ | 내부용 |
| **APC** | ✅ 전국 고유 | ✅ 애니팜 관리 | ✅ | **사용 가능** |
---
## 🔄 구현 계획
### Phase 1: 동물약 태깅 (완료 ✅)
```
CD_GOODS에서 POS_BOON='010103' 추출 → 38개 동물약 식별
```
### Phase 2: AI 기반 APC 매핑
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MSSQL 동물약 │ │ AI 분석 │ │ PostgreSQL │
│ 38개 제품 │────►│ 제품명 매칭 │────►│ apc 후보 추천 │
│ │ │ 성분/체중 분석 │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ 관리자 확인 │
│ 매핑 승인/수정 │
└─────────────────┘
```
**AI 매칭 로직:**
```python
# MSSQL 제품
mssql_product = "안텔민사사(5kg이하)"
# PostgreSQL 검색
pgsql_candidates = search_apc("안텔민 사사")
# → [
# "대성 안텔민 사사 정 100mg/25mg/10정" (APC: 0230237010107),
# "대성 안텔민 사사 정 100mg/25mg/50정" (APC: 0230237010205),
# ...
# ]
# AI 추천: 체중 범위, 포장단위 분석
recommended_apc = "0230237010107" # 10정 (최소 판매단위)
```
### Phase 3: APC 바코드 등록 방법
**옵션 A: CD_SALEGOODS.BARCODE 업데이트 (현재 구조 활용)**
```sql
-- CD_SALEGOODS에서 바코드를 APC로 변경
UPDATE CD_SALEGOODS
SET BARCODE = '0230237010107' -- APC 코드
WHERE DrugCode = 'LB000003158'; -- 안텔민뽀삐
```
또는 POS에서 직접:
1. 제품 선택 → 바코드 수정 → APC 입력 → 저장
**옵션 B: 별도 매핑 테이블 (SQLite) - MSSQL 수정 최소화**
```sql
-- SQLite에 매핑 테이블 생성
CREATE TABLE animal_drug_apc_mapping (
id INTEGER PRIMARY KEY,
mssql_drug_code TEXT NOT NULL, -- CD_GOODS.DrugCode
mssql_barcode TEXT, -- CD_SALEGOODS.BARCODE (현재값)
apc_code TEXT NOT NULL, -- PostgreSQL apc
product_name TEXT, -- 확인용
verified BOOLEAN DEFAULT 0, -- 관리자 검증 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**현재 동물약 바코드 현황:**
| 제품 | DrugCode | CD_SALEGOODS.BARCODE |
|------|----------|---------------------|
| 안텔민뽀삐 | LB000003158 | 9990000001133 |
| 안텔민킹 | LB000003157 | 9990000001134 |
| 다이로하트S | LB000003150 | 9990000001131 |
| 다이로하트M | LB000003151 | 9990000001132 |
### Phase 4: QR 라벨 출력 연동
```
┌─────────────────────────────────────────────────────────────┐
│ 프론트엔드: 제품 검색 → QR 라벨 출력 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 제품 선택 (MSSQL) │
│ └── DrugCode: LB000003158 │
│ │
│ 2. APC 매핑 확인 │
│ └── APC: 0230237010107 (매핑됨 ✅) │
│ │
│ 3. QR 라벨 생성 │
│ ├── QR 내용: APC 코드 │
│ ├── 라벨 텍스트: 제품명 + 가격 │
│ └── [인쇄] 버튼 활성화 │
│ │
│ ※ APC 미매핑 제품 → [인쇄] 버튼 비활성화 또는 경고 │
└─────────────────────────────────────────────────────────────┘
```
### Phase 5: 이미지 표시 연동
```
챗봇 응답: "안텔민을 추천드려요"
├── MSSQL: "안텔민사사(5kg이하)" 재고 확인
├── APC 매핑: LB000003158 → 0230237010107
├── PostgreSQL: 이미지 URL 조회
│ └── https://ani.0bin.in/img/0230237010107_F.jpg
└── 프론트: 제품 칩 + 이미지 썸네일 표시
┌──────────────────────────┐
│ 📦 안텔민사사 (5,000원) │
│ [썸네일 이미지] │
└──────────────────────────┘
```
---
## 📊 예상 데이터 흐름
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 전체 데이터 흐름 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ [약국 POS] │
│ │ │
│ ▼ │
│ CD_GOODS ──────► CD_BARCODE (APC 추가) │
│ │ │ │
│ │ │ APC = "0230237010107" │
│ │ │ │
│ ▼ ▼ │
│ 제품 판매 ◄──── QR 스캔 (APC 인식) │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ [챗봇/이미지] [애니팜 연동] │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ PostgreSQL ◄─────────────┘ │
│ (이미지, 상세정보, 용법용량) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## ✅ TODO 체크리스트
### 1단계: 분석 (READ ONLY)
- [x] MSSQL 동물약 38개 추출
- [x] CD_BARCODE 구조 분석
- [x] PostgreSQL apc 테이블 구조 분석
- [x] 매핑 가능 제품 샘플 확인 (안텔민, 하트가드 등)
- [ ] 전체 38개 제품 APC 후보 목록 생성
### 2단계: 기획
- [x] 매핑 전략 수립 (APC 기반)
- [ ] CD_BARCODE 활용 vs SQLite 매핑 테이블 결정
- [ ] 관리자 매핑 UI 설계
- [ ] QR 라벨 출력 연동 설계
### 3단계: 구현 (약사님 승인 후)
- [ ] 매핑 테이블 생성
- [ ] AI 매핑 추천 기능
- [ ] 관리자 매핑 확인/수정 UI
- [ ] QR 라벨 출력 (APC 기반)
- [ ] 챗봇 이미지 연동
---
## ⚠️ 주의사항
1. **MSSQL 수정 금지** (기획 단계)
- READ ONLY 유지
- 테스트도 SELECT만
2. **APC 신뢰성**
- PostgreSQL apc 테이블이 마스터
- 애니팜에서 관리하는 공식 코드
3. **약국별 차이**
- 자체 바코드 사용 중인 약국 고려
- 기존 워크플로우 방해하지 않도록
4. **단계적 적용**
- 매핑 확인된 제품만 QR 출력 허용
- 미매핑 제품은 기존 방식 유지
---
*작성일: 2025-06-30*
*작성자: 용림 (Clawdbot)*
*상태: 기획 중*

433
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,433 @@
# 🏗️ 약국 통합 솔루션 아키텍처
## 📋 개요
본 시스템은 **동물약 도매상(애니팜)**, **개별 약국 POS**, **마일리지 솔루션**을 통합하는 멀티 데이터베이스 아키텍처입니다.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🏢 애니팜 (동물약 도매상) │
│ PostgreSQL Database │
│ 제품 마스터, 재고, 주문, 거래처 │
└─────────────────────────────────────────────────────────────────────────────┘
│ 제품 정보 / 발주
┌─────────────────────────────────────────────────────────────────────────────┐
│ 💊 개별 약국 (청춘약국 등) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ MSSQL (팜IT3000) │ │ SQLite (솔루션) │ │
│ │ - 제품 마스터 │ │ - 마일리지 │ │
│ │ - 판매 내역 │◄──►│ - AI 추천 │ │
│ │ - 조제 이력 │ │ - 알림톡 로그 │ │
│ │ - 회원 정보 │ │ - 동물약 태그 │ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ API / 웹 인터페이스
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🌐 Flask 웹 서버 (7001) │
│ QR 적립 | AI 챗봇 | 관리자 | 회원 조회 | 알림톡 │
└─────────────────────────────────────────────────────────────────────────────┘
│ 외부 서비스
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🔌 외부 API 연동 │
│ - OpenAI GPT (동물약 챗봇, AI 업셀링) │
│ - 카카오 OAuth (로그인) │
│ - NHN Cloud 알림톡 │
│ - Clawdbot Gateway (AI 에이전트) │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 🗄️ 데이터베이스 구조
### 1⃣ PostgreSQL (애니팜 - 동물약 도매상)
> **역할**: 동물약 도매 사업의 핵심 DB. 제품 마스터, 거래처(약국), 주문/발주 관리
| 테이블 | 설명 | 주요 컬럼 |
|--------|------|-----------|
| `products` | 제품 마스터 | id, name, barcode, price, category |
| `customers` | 거래처 (약국) | id, pharmacy_name, owner, phone |
| `orders` | 주문 내역 | id, customer_id, order_date, status |
| `order_items` | 주문 상세 | order_id, product_id, qty, price |
| `inventory` | 재고 현황 | product_id, stock_qty, location |
```sql
-- 예시: 인기 동물약 TOP 10 조회
SELECT p.name, SUM(oi.qty) as total_sold
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.created_at >= NOW() - INTERVAL '30 days'
GROUP BY p.name
ORDER BY total_sold DESC
LIMIT 10;
```
---
### 2⃣ MSSQL (팜IT3000 - 약국 POS)
> **역할**: 약국 청구/POS 프로그램의 DB. 제품, 판매, 조제, 회원 정보
#### 주요 데이터베이스
| DB명 | 설명 |
|------|------|
| `PM_DRUG` | 제품 마스터 (의약품/건기식) |
| `PM_PRES` | 판매/조제 내역 |
| `PM_BASE` | 회원/거래처 기본 정보 |
#### 핵심 테이블
**PM_DRUG.dbo.CD_GOODS** - 제품 마스터
| 컬럼 | 설명 |
|------|------|
| `DrugCode` | 제품 코드 (PK) |
| `GoodsName` | 제품명 |
| `BARCODE` | 바코드 |
| `Saleprice` | 판매가 |
| `Price` | 원가 |
| `POS_BOON` | 분류코드 (010103 = 동물약) |
| `GoodsSelCode` | 판매상태 (B = 판매중) |
**PM_PRES.dbo.SALE_MAIN** - 판매 헤더
| 컬럼 | 설명 |
|------|------|
| `SL_NO_order` | 거래번호 (PK) |
| `InsertTime` | 거래 일시 |
| `SL_MY_total` | 총 금액 |
| `SL_CD_custom` | 고객 코드 |
**PM_PRES.dbo.SALE_SUB** - 판매 상세
| 컬럼 | 설명 |
|------|------|
| `SL_NO_order` | 거래번호 (FK) |
| `DrugCode` | 제품 코드 |
| `SL_NM_item` | 수량 |
| `SL_TOTAL_PRICE` | 금액 |
**PM_BASE.dbo.CD_PERSON** - 회원 정보
| 컬럼 | 설명 |
|------|------|
| `CUSCODE` | 고객 코드 (PK) |
| `PANAME` | 이름 |
| `PHONE` | 전화번호 |
| `PANUM` | 주민번호 |
```sql
-- 예시: 오늘 판매 내역 + 제품명 조회
SELECT
M.SL_NO_order AS ,
M.InsertTime AS ,
G.GoodsName AS ,
S.SL_NM_item AS ,
S.SL_TOTAL_PRICE AS
FROM PM_PRES.dbo.SALE_MAIN M
JOIN PM_PRES.dbo.SALE_SUB S ON M.SL_NO_order = S.SL_NO_order
JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE CONVERT(DATE, M.InsertTime) = CONVERT(DATE, GETDATE())
ORDER BY M.InsertTime DESC;
```
```sql
-- 예시: 동물약 목록 조회 (POS_BOON = '010103')
SELECT DrugCode, GoodsName, Saleprice, BARCODE
FROM PM_DRUG.dbo.CD_GOODS
WHERE POS_BOON = '010103' AND GoodsSelCode = 'B'
ORDER BY GoodsName;
```
---
### 3⃣ SQLite (마일리지 솔루션)
> **역할**: 약국별 마일리지 적립, AI 추천, 알림톡 로그 등 부가 기능
**경로**: `backend/db/mileage.db`
#### 핵심 테이블
**users** - 마일리지 회원
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | INTEGER | PK |
| `nickname` | TEXT | 이름 |
| `phone` | TEXT | 전화번호 (UNIQUE) |
| `mileage_balance` | INTEGER | 포인트 잔액 |
| `birthday` | TEXT | 생년월일 |
| `created_at` | TIMESTAMP | 가입일 |
**claim_tokens** - QR 적립 토큰
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | INTEGER | PK |
| `transaction_id` | TEXT | POS 거래번호 (UNIQUE) |
| `token_hash` | TEXT | 토큰 해시 |
| `total_amount` | REAL | 구매 금액 |
| `claimable_points` | INTEGER | 적립 가능 포인트 |
| `claimed_at` | TIMESTAMP | 적립 완료 시각 |
| `claimed_by_user_id` | INTEGER | 적립한 회원 ID |
**mileage_ledger** - 포인트 원장
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | INTEGER | PK |
| `user_id` | INTEGER | 회원 ID |
| `transaction_id` | TEXT | 거래번호 |
| `points` | INTEGER | 적립/차감 포인트 |
| `balance_after` | INTEGER | 변동 후 잔액 |
| `reason` | TEXT | CLAIM / USE / ADMIN |
**ai_recommendations** - AI 업셀링 추천
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `id` | INTEGER | PK |
| `user_id` | INTEGER | 회원 ID |
| `recommended_product` | TEXT | 추천 제품 |
| `recommendation_message` | TEXT | 추천 메시지 |
| `status` | TEXT | active / interested / dismissed |
**drug_tags** - 동물약 태그 (별도 DB: `drug_tags.db`)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| `drug_code` | TEXT | 제품 코드 |
| `drug_name` | TEXT | 제품명 |
| `tag_type` | TEXT | animal_drug 등 |
| `tag_value` | TEXT | all / dog / cat |
```sql
-- 예시: 회원별 적립 내역 조회
SELECT
u.nickname, u.phone, u.mileage_balance,
ml.points, ml.reason, ml.created_at
FROM users u
JOIN mileage_ledger ml ON u.id = ml.user_id
WHERE u.phone = '01012345678'
ORDER BY ml.created_at DESC;
```
---
## 🔄 데이터 흐름 예시
### 📱 시나리오 1: QR 마일리지 적립
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ POS 결제 │────►│ QR 발행 │────►│ 고객 스캔 │────►│ 적립 완료 │
│ (MSSQL) │ │ (SQLite) │ │ (Flask) │ │ (SQLite) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
│ │ │ │
▼ ▼ ▼ ▼
SALE_MAIN claim_tokens users 조회 mileage_ledger
SALE_SUB 생성 & 저장 /생성 적립 기록
```
**쿼리 흐름:**
```sql
-- 1. POS 판매 완료 시 (MSSQL)
INSERT INTO SALE_MAIN (SL_NO_order, SL_MY_total, ...) VALUES (...)
-- 2. QR 토큰 생성 (SQLite)
INSERT INTO claim_tokens (transaction_id, total_amount, claimable_points, ...)
VALUES ('20260228001234', 50000, 1500, ...)
-- 3. 고객 QR 스캔 → 회원 조회/생성 (SQLite)
SELECT * FROM users WHERE phone = '01012345678'
-- 없으면:
INSERT INTO users (nickname, phone, mileage_balance) VALUES ('홍길동', '01012345678', 0)
-- 4. 적립 처리 (SQLite)
UPDATE users SET mileage_balance = mileage_balance + 1500 WHERE id = 1
INSERT INTO mileage_ledger (user_id, transaction_id, points, balance_after, reason)
VALUES (1, '20260228001234', 1500, 1500, 'CLAIM')
-- 5. 토큰 사용 완료 표시 (SQLite)
UPDATE claim_tokens SET claimed_at = datetime('now'), claimed_by_user_id = 1
WHERE transaction_id = '20260228001234'
```
---
### 🐾 시나리오 2: 동물약 AI 챗봇
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 사용자 질문 │────►│ 동물약 조회 │────►│ OpenAI API │────►│ 응답 생성 │
│ "구충제 추천" │ │ (MSSQL) │ │ (RAG) │ │ + 제품 매칭 │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │
│ │
▼ ▼
CD_GOODS에서 지식 베이스 +
동물약 38개 제품 목록 전달
가격 포함 조회
```
**쿼리 흐름:**
```sql
-- 1. 동물약 목록 조회 (MSSQL → RAG 컨텍스트)
SELECT DrugCode, GoodsName, Saleprice, BARCODE
FROM PM_DRUG.dbo.CD_GOODS
WHERE POS_BOON = '010103' AND GoodsSelCode = 'B'
ORDER BY GoodsName;
-- 결과: 안텔민(5000원), 넥스가드L(84000원), ... 38개
-- 2. OpenAI API 호출 (Python)
# System Prompt에 :
# - (, , )
# - +
# User: "구충제 추천해줘"
# AI : "구충제로는 **안텔민**을 추천드려요! 프라지콴텔+피란텔 성분으로..."
-- 3. 응답에서 제품명 매칭 (Python)
# AI "안텔민" 5000
```
---
### 👤 시나리오 3: 회원 상세 조회 (통합)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 전화번호 │────►│ DB 3곳 │────►│ 통합 응답 │
│ 입력 │ │ 동시 조회 │ │ 반환 │
└─────────────┘ └─────────────┘ └─────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ SQLite │ │ MSSQL │ │ MSSQL │
│ users │ │PM_BASE │ │PM_PRES │
│마일리지 │ │회원정보 │ │조제이력 │
└─────────┘ └─────────┘ └─────────┘
```
**쿼리 흐름:**
```sql
-- 1. 마일리지 회원 조회 (SQLite)
SELECT id, nickname, phone, mileage_balance, created_at
FROM users WHERE phone = '01012345678'
-- 2. 적립 이력 조회 (SQLite)
SELECT points, balance_after, reason, created_at, transaction_id
FROM mileage_ledger WHERE user_id = 1
ORDER BY created_at DESC LIMIT 50
-- 3. POS 고객 코드 조회 (MSSQL PM_BASE)
SELECT CUSCODE, PANAME FROM CD_PERSON
WHERE REPLACE(PHONE, '-', '') = '01012345678'
-- 4. 조제 이력 조회 (MSSQL PM_PRES)
SELECT P.PreSerial, P.Indate, P.Drname, P.OrderName
FROM PS_main P
WHERE P.CusCode = 'C00001234'
ORDER BY P.Indate DESC
-- 5. 구매 상세 조회 (MSSQL PM_PRES + PM_DRUG)
SELECT G.GoodsName, S.SL_NM_item, S.SL_TOTAL_PRICE
FROM SALE_SUB S
JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order = '20260228001234'
```
---
## 🛠️ 기술 스택
| 계층 | 기술 | 용도 |
|------|------|------|
| **Frontend** | HTML/CSS/JS | 관리자 페이지, 키오스크, 마이페이지 |
| **Backend** | Flask (Python) | REST API, 템플릿 렌더링 |
| **Database** | PostgreSQL | 애니팜 (도매상) |
| | MSSQL | 팜IT3000 (약국 POS) |
| | SQLite | 마일리지 솔루션 |
| **AI** | OpenAI GPT-4o-mini | 동물약 챗봇, 업셀링 추천 |
| **인증** | 카카오 OAuth | 소셜 로그인 |
| **알림** | NHN Cloud | 알림톡/SMS |
| **프로세스** | PM2 | 서버 관리 |
| **도메인** | Cloudflare | SSL, 프록시 |
---
## 📁 프로젝트 구조
```
pharmacy-pos-qr-system/
├── backend/
│ ├── app.py # Flask 메인 앱
│ ├── db/
│ │ ├── dbsetup.py # DB 연결 관리
│ │ ├── mileage.db # SQLite (마일리지)
│ │ └── drug_tags.db # SQLite (동물약 태그)
│ ├── templates/ # HTML 템플릿
│ │ ├── admin.html
│ │ ├── admin_products.html # 제품 검색 + AI 챗봇
│ │ ├── admin_members.html
│ │ ├── kiosk.html
│ │ └── my_page.html
│ ├── services/
│ │ ├── kakao_client.py # 카카오 OAuth
│ │ ├── nhn_alimtalk.py # 알림톡
│ │ └── clawdbot_client.py # AI 에이전트
│ ├── utils/
│ │ └── qr_token_generator.py
│ └── .env # 환경 변수
├── docs/
│ └── ARCHITECTURE.md # 이 문서
├── logs/
└── ecosystem.config.js # PM2 설정
```
---
## 🔐 환경 변수 (.env)
```env
# 카카오 OAuth
KAKAO_CLIENT_ID=xxx
KAKAO_CLIENT_SECRET=xxx
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
# OpenAI API
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-4o-mini
# MSSQL 연결 (dbsetup.py에서 설정)
# SQLite 경로 (backend/db/)
```
---
## 📊 주요 API 엔드포인트
| 경로 | 메서드 | 설명 | DB |
|------|--------|------|-----|
| `/api/products` | GET | 제품 검색 | MSSQL |
| `/api/animal-chat` | POST | 동물약 AI 챗봇 | MSSQL + OpenAI |
| `/api/animal-drugs` | GET | 동물약 목록 | MSSQL |
| `/api/claim` | POST | 마일리지 적립 | SQLite |
| `/api/members/search` | GET | 회원 검색 | MSSQL |
| `/api/members/history/:phone` | GET | 회원 이력 통합 | 전체 |
| `/admin/user/:id` | GET | 회원 상세 (적립+구매+조제) | 전체 |
---
## 📝 버전 이력
| 날짜 | 버전 | 변경 내용 |
|------|------|----------|
| 2026-02-28 | 1.0 | 초기 아키텍처 문서 작성 |
| | | 동물약 AI 챗봇 추가 |
| | | 플로팅 챗봇 UI 구현 |
---
*작성: Clawdbot AI | 청춘약국 통합 솔루션*

309
docs/DATABASE_STRUCTURE.md Normal file
View File

@@ -0,0 +1,309 @@
# 데이터베이스 구조 (2025-06-30 정리)
## 개요
양구청춘약국 시스템은 3개의 데이터베이스를 사용합니다:
| DB | 용도 | 위치 |
|----|------|------|
| **MSSQL (PM_DRUG)** | POS 제품/재고/판매 | localhost (팜IT3000) |
| **MSSQL (PM_PRES)** | 처방전/조제 | localhost (팜IT3000) |
| **PostgreSQL** | 동물약 상세 정보 (RAG) | 192.168.0.87:5432 |
| **SQLite** | 마일리지 시스템 | backend/db/mileage.db |
---
## MSSQL 테이블 구조 (PM_DRUG)
### 핵심 테이블 관계
```
┌─────────────────────────────────────────────────────────────┐
│ CD_GOODS (제품 마스터) - 178,182개 │
│ └── DrugCode (PK): LB000003157 │
│ │ │
│ ┌─────────┴─────────────┬──────────────────────────┐ │
│ ▼ ▼ ▼ │
│ CD_SALEGOODS CD_ITEM_UNIT_MEMBER CD_BARCODE│
│ (대표 바코드) (바코드 N개) ★ (인체용) │
│ 3,053개 N:1 관계 306,565개│
└─────────────────────────────────────────────────────────────┘
```
### CD_GOODS (제품 마스터)
팜IT3000 전체 제품 DB. 약국이 개별 등록한 제품은 `LB`, `S`로 시작.
| 컬럼 | 타입 | 설명 |
|------|------|------|
| DrugCode | nvarchar | PK. `LB000003157` (약국등록), `050000010` (표준) |
| GoodsName | nvarchar | 제품명 |
| Saleprice | decimal | 판매가 |
| BARCODE | nvarchar | (보통 비어있음 - CD_SALEGOODS 사용) |
| POS_BOON | nvarchar | 분류코드. `010103` = 동물약 |
| GoodsSelCode | nvarchar | `B` = 판매용 |
### CD_SALEGOODS (판매용 제품)
약국에서 실제 판매하는 제품. **대표 바코드 1개** 저장.
| 컬럼 | 타입 | 설명 |
|------|------|------|
| DrugCode | nvarchar | FK → CD_GOODS |
| GoodsName | nvarchar | 제품명 |
| BARCODE | nvarchar | **대표 바코드** (자체생성: `999000000xxxx`) |
| SplCode | nvarchar | 공급처 코드 |
| SplName | nvarchar | 공급처명 |
### CD_ITEM_UNIT_MEMBER (바코드 N개) ★
**한 제품에 여러 바코드** 저장. APC 코드는 여기에 저장됨!
| 컬럼 | 타입 | 설명 |
|------|------|------|
| DRUGCODE | nvarchar | FK → CD_GOODS.DrugCode |
| CD_CD_BARCODE | nvarchar | **바코드** (APC: `0230237810109`) |
| CD_CD_UNIT | nvarchar | 단위코드 (13, 015 등) |
| CD_MY_UNIT | decimal | 판매가 |
| CD_IN_UNIT | decimal | 입고가 |
| CHANGE_DATE | nvarchar | 변경일 (YYYYMMDD) |
| SN | bigint | 일련번호 |
### CD_BARCODE (인체용 표준)
식약처 인체용 의약품 표준 바코드. **동물약은 없음!**
| 컬럼 | 타입 | 설명 |
|------|------|------|
| DRUGCODE | nvarchar | 제품코드 |
| BARCODE | nvarchar | 표준 바코드 |
| BASECODE | nvarchar | 표준코드 |
| ETCNAME | nvarchar | 제품명 |
| CL_GUBUN | nvarchar | 구분 (전문의약품 등) |
---
## PostgreSQL 구조 (apdb_master)
동물약품 상세 정보. 농림축산검역본부 데이터 + LLM 가공.
### apc 테이블 (핵심)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| apc | varchar | **PK**. `0230237810109` |
| product_name | varchar | 제품명 |
| company_name | varchar | 제조사 |
| main_ingredient | varchar | 주성분 |
| efficacy_effect | text | 효능/효과 (HTML) |
| dosage_instructions | text | 용법/용량 (HTML) |
| precautions | text | 주의사항 (HTML) |
| **llm_pharm** | jsonb | **LLM 가공 정보** ★ |
| image_url1 | varchar | 앞면 이미지 |
| image_url2 | varchar | 뒷면 이미지 |
| weight_min_kg | float | 최소 체중 |
| weight_max_kg | float | 최대 체중 |
### llm_pharm JSON 구조 (핵심!)
```json
{
"사용가능 동물": "개, 고양이",
"분류": "내부구충제",
"성분1": "메벤다졸",
"성분2": "프라지콴텔",
"체중/부위": "체중 5~9kg: 1정, 10~19kg: 2정...",
"기간/용법": "1일 1회, 1~2일간 경구투여",
"월령금기": "생후 1주 미만 사용 금지",
"반려인주의": "사람이 복용 시 즉시 의사의 조치 필요",
"앞이미지": "https://...",
"뒤이미지": "https://..."
}
```
---
## 바코드 체계
| 패턴 | 설명 | 예시 |
|------|------|------|
| `023xxxxxxxx` | **APC (동물약 표준)** | `0230237810109` |
| `999000000xxxx` | 약국 자체 생성 | `9990000001134` |
| `880xxxxxxxxx` | 일반 GS1 바코드 | `8809989000009` |
---
## 연결 예시
**안텔민킹(5kg이상) 조회:**
```sql
-- MSSQL: 바코드 조회
SELECT CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = 'LB000003157'
AND CD_CD_BARCODE LIKE '023%';
-- → 0230237810109
-- PostgreSQL: 상세 정보 조회
SELECT llm_pharm->>'사용가능 동물', efficacy_effect
FROM apc
WHERE apc = '0230237810109';
-- → 개, 고양이
```
---
## 이미지 URL 규칙
```
https://ani.0bin.in/img/{APC}_F.jpg # 앞면
https://ani.0bin.in/img/{APC}_B.jpg # 뒷면
https://ani.0bin.in/img/{APC}_D.jpg # 상세
```
예: `https://ani.0bin.in/img/0230237810109_F.jpg`
---
## APC 매핑 현황 (2025-06-30)
### 매핑 완료 (8개)
| 제품 | APC | 이미지 | 비고 |
|------|-----|--------|------|
| 안텔민킹(5kg이상) | 0230237810109 | ✅ | |
| 안텔민뽀삐(5kg이하) | 0230237010107 | ✅ | |
| (판)복합개시딘 | 0231093520106 | ✅ | |
| 파라캅L(5kg이상) | 0230338510101 | ✅ | |
| 파라캅S(5kg이하) | 0230347110106 | ✅ | |
| 세레니아정16mg | 0231884610109 | ✅ | |
| 세레니아정24mg | 0231884620107 | ✅ | |
| 제스타제(10정) | 8809720800455 | ❌ | 바코드=APC |
### 바코드=APC 케이스
PostgreSQL에서 일부 제품은 APC 대신 **바코드**로 등록됨:
```
제스타제:
- 약국 바코드: 8809720800455
- PostgreSQL apc: 8809720800455 (동일!)
- RAG 데이터 있음 ✅
- 이미지 URL: ❌ (023으로 시작 안 함)
```
**시스템 처리 로직:**
1. CD_ITEM_UNIT_MEMBER에서 `023%` APC 검색
2. 없으면 기존 바코드를 APC로 사용
3. PostgreSQL에서 해당 코드로 RAG 조회
### 매핑 대기 (주요 펫팜 공급)
| 제품 | 상태 |
|------|------|
| 가드닐 L/M/S | PostgreSQL 용량별 APC 없음 |
| 다이로하트정 M/S/SS | 매칭 필요 |
| 리펠로 M/S | 부모 APC만 있음 |
| 셀라이트액 L/M/S/SS/XL | 매칭 필요 |
| 캐치원 SS/S/M/L/캣 | PostgreSQL에 없음 |
| 하트세이버 L/M/mini/S | 매칭 필요 |
| 하트웜솔루션 M/S | 매칭 필요 |
---
## 재고 시스템 (2025-06-30)
### 이중 재고 구조
| 위치 | 테이블 | 용도 | 조회 방식 |
|------|--------|------|-----------|
| **MSSQL (PM_DRUG)** | `IM_total` | 약국 재고 | `IM_QT_sale_debit` |
| **PostgreSQL** | `inventory` | 도매상 재고 | `SUM(quantity)` |
### 약국 재고 (MSSQL)
```sql
-- IM_total 테이블
SELECT DrugCode, IM_QT_sale_debit as stock
FROM IM_total
WHERE DrugCode = 'LB000003157';
-- → 8 (현재 약국 보유 수량)
```
### 도매상 재고 (PostgreSQL)
도매상 재고는 **입출고 이력**으로 관리됩니다.
```sql
-- inventory 테이블 (입출고 이력)
-- quantity: +입고(INBOUND), -출고(OUTBOUND)
SELECT A.apc, A.product_name, SUM(I.quantity) as wholesaler_stock
FROM inventory I
JOIN apc A ON I.apdb_id = A.idx
WHERE A.for_pets = true
GROUP BY A.apc, A.product_name
HAVING SUM(I.quantity) > 0;
-- 안텔민뽀삐: 38개
-- 복합개시딘: 6개
-- 세레니아16mg: 4개
```
### inventory 테이블 주요 컬럼
| 컬럼 | 타입 | 설명 |
|------|------|------|
| apdb_id | integer | apc.idx FK |
| quantity | integer | 수량 (+입고/-출고) |
| transaction_type | varchar | INBOUND/OUTBOUND |
| transaction_date | timestamp | 거래일시 |
| wholesaler_price | numeric | 도매가 |
| retail_price | numeric | 소매가 |
| expiration_date | date | 유효기간 |
### API 응답 예시
```json
{
"name": "안텔민뽀삐(5kg이하)",
"price": 5000,
"stock": 8, // 약국 재고
"wholesaler_stock": 38 // 도매상 재고
}
```
### 프론트엔드 표시
```
┌────────────────────────────────┐
│ 💊 안텔민뽀삐(5kg이하) │
│ ₩5,000 약국 8 / 도매 38 │
└────────────────────────────────┘
```
- **약국 재고 있음**: 초록색 `약국 8`
- **약국 품절**: 빨간색 `품절`
- **도매상 재고**: 파란색 `도매 38` (발주 가능)
---
## 향후 계획: 연관 제품 추천
약국에 없지만 도매상에 있는 제품 추천 로직:
1. **카테고리 기반**: 같은 efficacy_effect (심장사상충, 외부기생충 등)
2. **신제품**: PostgreSQL `created_at` 최신순
3. **인기 제품**: 도매상 출고량 기준 (`transaction_type = 'OUTBOUND'` 집계)
→ 클릭 시 발주 연결 (미구현)
---
## 관련 파일
- `backend/app.py`: `_get_animal_drugs()`, `_get_animal_drug_rag()`
- `backend/scripts/insert_apc_*.py`: APC INSERT 스크립트
- `backend/scripts/check_pgsql_stock_sum.py`: 도매상 재고 확인
- `docs/APC_MAPPING_PLAN.md`: APC 매핑 기획

167
docs/ENCODING_GUIDE.md Normal file
View File

@@ -0,0 +1,167 @@
# 🔤 인코딩 가이드 (필독!)
> ⚠️ **중요**: 한글 데이터 처리 시 반드시 이 가이드를 따를 것
---
## ✅ 현재 설정 (2025-06-30 적용됨)
```
환경변수: PYTHONIOENCODING=utf-8 (User 레벨)
```
이 설정으로 모든 Python 스크립트에서 UTF-8 출력이 기본 적용됩니다.
---
## 📋 문제 상황
### 증상
```
DB 실제 값: "안텔민뽀삐"
콘솔 출력: "안텔민사사" ← 깨져서 다른 글자로 보임!
```
### 원인
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ DB (UTF-8) │ ──► │ Python │ ──► │ Windows 콘솔 │
│ "뽀삐" │ │ stdout │ │ (CP949) │
│ U+BF40 │ │ │ │ "사사" │
└──────────────┘ └──────────────┘ └──────────────┘
인코딩 변환 실패!
```
- Windows 콘솔 기본 인코딩: **CP949** (한국어 완성형)
- CP949에서 지원하지 않거나 다르게 매핑되는 유니코드 문자 존재
- "뽀삐" 같은 글자가 "사사"로 잘못 표시됨
---
## ✅ 해결책
### 1. 스크립트 상단에 인코딩 설정 추가 (필수!)
```python
# -*- coding: utf-8 -*-
import sys
import io
# stdout을 UTF-8로 강제 설정
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
```
### 2. 환경변수 설정 (권장)
```powershell
# PowerShell에서 실행 전 설정
$env:PYTHONIOENCODING = "utf-8"
# 또는 시스템 환경변수로 영구 설정
[Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8", "User")
```
### 3. Windows Terminal UTF-8 모드
```powershell
# 콘솔 코드페이지를 UTF-8로 변경
chcp 65001
```
### 4. JSON 출력 사용 (가장 안전)
```python
import json
# 콘솔 출력 대신 JSON으로 반환
result = {
"product_name": "안텔민뽀삐",
"apc": "0230237010107"
}
print(json.dumps(result, ensure_ascii=False, indent=2))
```
---
## 📝 스크립트 템플릿
모든 DB 조회 스크립트는 이 템플릿을 사용할 것:
```python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
스크립트 설명
"""
import sys
import io
import json
# ═══════════════════════════════════════════════════════════
# 인코딩 설정 (Windows CP949 문제 방지)
# ═══════════════════════════════════════════════════════════
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
# ═══════════════════════════════════════════════════════════
# 메인 로직
# ═══════════════════════════════════════════════════════════
def main():
# ... 로직 ...
# 결과는 JSON으로 출력 (가장 안전)
result = {"data": [...]}
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
```
---
## 🔧 기존 스크립트 수정 목록
| 스크립트 | 상태 | 수정 필요 |
|----------|------|-----------|
| `scripts/query_mileage.py` | ⚠️ | 인코딩 설정 추가 |
| `scripts/query_sales.py` | ⚠️ | 인코딩 설정 추가 |
| `scripts/query_aniparm.py` | ⚠️ | 인코딩 설정 추가 |
| `scripts/search_mssql.py` | ⚠️ | 인코딩 설정 추가 |
| `scripts/check_*.py` | ⚠️ | 인코딩 설정 추가 |
---
## 🧪 테스트 방법
```python
# 인코딩 테스트 스크립트
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
test_words = ["뽀삐", "", "안텔민뽀삐(5kg이하)", "다이로하트정M(12~22kg)"]
for word in test_words:
print(f"원본: {word}")
print(f"유니코드: {[f'U+{ord(c):04X}' for c in word]}")
print()
```
---
## ⚠️ 주의사항
1. **절대 CP949 출력을 믿지 말 것** - 깨진 글자가 다른 글자로 보일 수 있음
2. **DB 데이터 확인 시** - 직접 DB 툴로 확인하거나 JSON 출력 사용
3. **AI 분석 시** - 유니코드 코드포인트로 확인 (U+XXXX)
4. **매핑 작업 시** - 반드시 양쪽 DB 직접 확인 후 진행
---
*작성일: 2025-06-30*
*사유: "뽀삐"가 "사사"로 잘못 표시되는 인코딩 문제 발생*

238
docs/IMAGE_MAPPING_PLAN.md Normal file
View File

@@ -0,0 +1,238 @@
# 🖼️ 동물약 이미지 매핑 계획
## 📋 목표
챗봇에서 동물약 추천 시 **제품 이미지**를 함께 표시
---
## 🗄️ 데이터 현황
### MSSQL (약국 POS - PM_DRUG.CD_GOODS)
| 컬럼 | 설명 | 현황 |
|------|------|------|
| `DrugCode` | 제품코드 | ✅ 전체 있음 (예: LB000003151) |
| `GoodsName` | 제품명 | ✅ 전체 있음 |
| `BARCODE` | 바코드 | ⚠️ **14/38개만 있음 (37%)** |
| `BaseCode` | 표준코드 | ❌ **0개** (사용 불가) |
### PostgreSQL (애니팜 - apc 테이블)
| 컬럼 | 설명 |
|------|------|
| `idx` | 고유 ID |
| `apc` | APC 코드 (고유) |
| `product_name` | 제품명 |
| `image_url1` ~ `image_url3` | 이미지 URL |
| `godoimage_url_f` | 고도몰 CDN - 앞 이미지 |
| `godoimage_url_b` | 고도몰 CDN - 뒤 이미지 |
| `godoimage_url_d` | 고도몰 CDN - 상세 이미지 |
**확인 필요**: `apc` 테이블에 바코드 컬럼이 있는지?
---
## 🔗 매핑 전략
### 옵션 1: 바코드 매핑 (37% 커버)
```
MSSQL.BARCODE ↔ PostgreSQL.barcode(?)
```
- 장점: 정확한 매칭
- 단점: 14/38개만 매핑 가능
### 옵션 2: 제품명 유사도 매핑 (Fuzzy Matching)
```python
from fuzzywuzzy import fuzz
# MSSQL: "다이로하트정M(12~22kg)"
# PostgreSQL: "다이로하트 정M 12~22kg" 등 유사 이름 매칭
score = fuzz.partial_ratio(mssql_name, pgsql_name)
if score > 80:
matched = True
```
- 장점: 100% 커버 가능
- 단점: 오매칭 위험
### 옵션 3: 매핑 테이블 생성 (권장) ✅
```sql
-- SQLite에 매핑 테이블 생성
CREATE TABLE drug_image_mapping (
id INTEGER PRIMARY KEY,
mssql_drug_code TEXT UNIQUE, -- MSSQL DrugCode
pgsql_apc TEXT, -- PostgreSQL apc 코드
image_url TEXT, -- 확정된 이미지 URL
verified BOOLEAN DEFAULT 0, -- 수동 검증 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**작업 흐름:**
1. 바코드 매칭 (자동) → 14개 즉시 매핑
2. 제품명 유사도로 후보 추천 → 관리자 확인
3. 수동 매핑 → 나머지 제품
---
## 📊 MSSQL 동물약 바코드 현황 (38개)
### ✅ 바코드 있음 (14개)
| 제품명 | DrugCode | 바코드 |
|--------|----------|--------|
| 가드L(20~40kg) | LB000003570 | 8801244508268 |
| 가드M(10~20kg) | LB000003569 | 8801244508237 |
| 가드S(2~10kg) | LB000003568 | 8801244508220 |
| 하트가드정L(10~20kg) | LB000003564 | 8801244508343 |
| 하트가드정M(5~10kg) | LB000003453 | 8801244508329 |
| 하트가드정S(2.5~5kg) | LB000003452 | 8801244508312 |
| 하트가드정SS(2.5kg이하) | LB000003451 | 8801244508305 |
| 심파리카L(10~25kg) | LB000003634 | 8801244508534 |
| 심파리카M(4~10kg) | LB000003635 | 8801244508435 |
| 안텔민 | S0000001 | 8809989000009 |
| 세레타정(10정) | LB000003146 | 8809720800455 |
| 파라칸L(5kg이상) | LB000003159 | 8809625390914 |
| 파라칸S(5kg이하) | LB000003160 | 8809625390655 |
| 하트칸츄어블(11kg이하) | LB000003696 | 8809625390563 |
### ❌ 바코드 없음 (24개)
| 제품명 | DrugCode |
|--------|----------|
| (동)클리어민50(100정) | LB000003504 |
| 넥스가드L(15~30kg) | LB000003531 |
| 넥스가드xs(2~3.5kg) | LB000003530 |
| 다이로하트정M(12~22kg) | LB000003151 |
| 다이로하트정S(5.6~11kg) | LB000003150 |
| 다이로하트정SS(5.6kg이하) | LB000003149 |
| 레보M(10~20kg) | LB000003161 |
| 레보S(2~10kg) | LB000003162 |
| 밀베마이신A정16mg(대동미어) | LB000003353 |
| 밀베마이신A정24mg(대동미어) | LB000003354 |
| 하트가드정XL(20~40kg) | LB000003545 |
| 안텔민사사(5kg이하) | LB000003158 |
| 안텔민킹(5kg이상) | LB000003157 |
| 캐치펫캅(2.5~7.5kg)/고양이 | LB000003167 |
| 캐치펫L(10~20kg)/개 | LB000003166 |
| 캐치펫M(5~10kg)/개 | LB000003165 |
| 캐치펫S(2.5~5kg)/개 | LB000003164 |
| 캐치펫SS(2.5kg이하/개,고양이가능) | LB000003163 |
| 하트플레이버블정L(23~45kg) | LB000003544 |
| 하트플레이버블정M(12~22kg) | LB000003152 |
| 하트플레이버블정mini(5.6kg이하) | LB000003154 |
| 하트플레이버블정S(5.6~11kg) | LB000003153 |
| 하트플라블러스정M(12~22kg) | LB000003155 |
| 하트플라블러스정S(11kg이하) | LB000003156 |
---
## 🛠️ 구현 단계
### Phase 1: PostgreSQL 조사
- [ ] apc 테이블에 바코드 컬럼 확인
- [ ] 이미지 URL 실제 데이터 샘플 확인
- [ ] 동물약 제품 필터링 방법 확인 (`for_pets = true`?)
### Phase 2: 매핑 테이블 생성
- [ ] SQLite에 `drug_image_mapping` 테이블 생성
- [ ] 바코드 있는 14개 자동 매핑 시도
- [ ] 관리자 페이지에 매핑 UI 추가
### Phase 3: 챗봇 연동
- [ ] AI 응답에서 제품 매칭 시 이미지 URL 포함
- [ ] 프론트엔드에 이미지 표시 (썸네일)
- [ ] 클릭 시 큰 이미지 또는 상세 페이지
---
## 📝 다음 작업
1. **PostgreSQL apc 테이블 샘플 조회**
```sql
SELECT product_name, image_url1, godoimage_url_f
FROM apc
WHERE for_pets = true
LIMIT 10;
```
2. **바코드 컬럼 존재 여부 확인**
```sql
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'apc' AND column_name LIKE '%barcode%';
```
---
## 🗃️ MSSQL 테이블 구조 상세
### CD_GOODS vs CD_BARCODE 관계
```
CD_GOODS (제품 마스터) CD_BARCODE (바코드 마스터)
├── DrugCode (PK) ──────────► DRUGCODE (FK)
├── GoodsName ├── BARCODE (개별 바코드)
├── BARCODE (대표 바코드) ├── TITLECODE (대표 바코드)
├── BaseCode (❌ 비어있음) ├── BASECODE ✅ (100% 있음!)
├── SUNG_CODE ├── SUNG_CODE (성분코드)
└── Saleprice ├── DIK_CODE (의약품통합코드)
├── ETCNAME (제품명)
└── SPLNAME (제조사)
```
**핵심 포인트:**
- `CD_GOODS.BaseCode`는 비어있음 (사용 안 함)
- `CD_BARCODE.BASECODE`에 표준코드 100% 있음!
- 1개 제품(DrugCode)에 여러 바코드 가능 (낱개, 박스 등)
### CD_BARCODE 매핑 키 통계
| 컬럼 | 보유율 | 설명 | 외부 매핑 |
|------|--------|------|-----------|
| `BARCODE` | 100% | 개별 바코드 | ⭐ PostgreSQL 매핑 가능 |
| `BASECODE` | 100% | 표준코드 (식약처) | 인체용만 |
| `TITLECODE` | 100% | 대표 바코드 | |
| `DIK_CODE` | 68.2% | 의약품통합코드 | |
| `SUNG_CODE` | 44.5% | 성분코드 | |
### 동물약 특이사항
```
동물약 38개
├── CD_GOODS에 있음 ✅ (POS_BOON = '010103')
├── CD_BARCODE에 없음 ❌ (인체용 아님)
├── DrugCode가 "LB"로 시작 (로컬/자체 등록)
└── BASECODE 매핑 불가 → PostgreSQL(애니팜) 필요
```
---
## 🔗 PostgreSQL(애니팜) 매핑 전략
### 확인 필요 사항
PostgreSQL `apc` 테이블에서 확인할 컬럼:
```sql
-- 바코드 관련 컬럼 확인
SELECT column_name FROM information_schema.columns
WHERE table_name = 'apc'
AND column_name ILIKE '%barcode%';
-- 코드 관련 컬럼 확인
SELECT column_name FROM information_schema.columns
WHERE table_name = 'apc'
AND (column_name ILIKE '%code%' OR column_name ILIKE '%apc%');
```
### 예상 매핑 키
| MSSQL (CD_GOODS/BARCODE) | PostgreSQL (apc) | 매핑 방식 |
|--------------------------|------------------|-----------|
| `BARCODE` | `barcode`? | 직접 매핑 |
| `GoodsName` | `product_name` | 유사도 매칭 |
| `제조사` | `company_name` | 보조 키 |
---
*작성일: 2025-06-30*
*업데이트: 2025-06-30 - CD_BARCODE 구조 분석 추가*
*프로젝트: pharmacy-pos-qr-system*

272
docs/MEMBER_MEMO_SYSTEM.md Normal file
View File

@@ -0,0 +1,272 @@
# 환자 메모/특이사항 시스템 설계 문서
## 📅 작성일: 2026-03-04
---
## 1. DB 접속 정보
| 항목 | 값 |
|------|-----|
| 서버 | `192.168.0.4\PM2014` |
| 드라이버 | ODBC Driver 17 for SQL Server |
| 인증 | Windows 인증 (Trusted_Connection) |
| 데이터베이스 | PM_BASE (환자정보), PM_PRES (처방), PM_DRUG (약품) |
### 접속 코드 (pharmacy-pos-qr-system)
```python
from db.dbsetup import DatabaseManager
db = DatabaseManager()
session = db.get_session('PM_BASE')
```
---
## 2. 테이블 구조
### 2.1 CD_PERSON.CUSETC (특이참고사항)
**용도:** 단일 필드, 간단한 메모 (덮어쓰기 방식)
| 칼럼 | 타입 | 설명 |
|------|------|------|
| CUSETC | VARCHAR(2000) | 특이/참고사항 텍스트 |
**특징:**
- 한 환자당 하나의 값만 저장
- 새로 입력하면 기존 값 덮어씀
- 주로 미수금, 간단한 주의사항 등 기록
---
### 2.2 CD_PERSON_MEMO (메모 - 날짜별 누적)
**용도:** 별도 테이블, 상세 메모 이력 관리
| 칼럼 | 타입 | 설명 |
|------|------|------|
| CUSCODE | VARCHAR(10) | 고객코드 (PK) |
| MEMO_CODE | VARCHAR(5) | 메모코드 (PK) - 00001, 00002... |
| PHARMA_ID | VARCHAR(10) | 작성자명 (약사 이름 직접 저장) |
| MEMO_DATE | VARCHAR(8) | 작성일 (YYYYMMDD) |
| MEMO_TITLE | VARCHAR(40) | 메모 제목 |
| MEMO_Item | TEXT | 메모 내용 |
**특징:**
- 한 환자당 여러 메모 가능 (날짜별 누적)
- 복합 PK: CUSCODE + MEMO_CODE
- 작성자/날짜 추적 가능
---
## 3. PHARMA_ID 분석
### 현재 저장된 값 (2026-03-04 기준)
```
[김영빈] - 2448건
[박혜령] - 63건
[이충섭] - 4건
[시스템] - 2건
[이수지] - 1건
[지민구] - 1건
[PHARM001] - 1건
```
### 결론
- **직접 이름 저장 방식** (마스터 테이블 조인 불필요)
- 대부분 한글 이름, 일부 코드 형태 존재
- 별도 약사 마스터 테이블 연결 없이 독립적으로 저장
---
## 4. 실제 데이터 예시
### 예시 1: 김미성 (0000014615)
```
[특이사항 - CD_PERSON.CUSETC]
25/1 미수금:200
[메모 - CD_PERSON_MEMO]
메모코드: 00001
작성자: 김영빈
날짜: 20260304
제목: (없음)
내용: 신장투석.이식 가족력
```
### 예시 2: 박상호 (0000024142)
```
[특이사항 - CD_PERSON.CUSETC]
25/1 미수금:1400
[메모 - CD_PERSON_MEMO]
메모코드: 00003
작성자: 김영빈
날짜: 20260303
제목: 가루약
내용: 사미온만 아침,저녁
나머지 저녁으로
카나브는 알약으로 포장
```
### 예시 3: 안동옥 (0000001030)
```
[특이사항 - CD_PERSON.CUSETC]
25/1 미수금:200
[메모 - CD_PERSON_MEMO]
메모코드: 00001
작성자: 김영빈
날짜: 20260224
제목: (없음)
내용: 26.2.23-에터미 해모임과 고지혀약 피타로우에프 먹은지 한달 만에
간수피가 20대에서 120대로 수치가 오름.
고덱스 처방과 약 끊고 변화 확인 요망(010-6209-0796)
```
---
## 5. 기존 API 현황
### GET /api/members/search?q={검색어}
- 회원 검색 (이름 2자 이상, 전화번호)
- 응답에 `memo` (CUSETC 100자 미리보기) 포함
### GET /api/members/{cuscode}
- 회원 상세 조회
- `member.memo`: CUSETC 전체
- `memos[]`: CD_PERSON_MEMO 배열 (author, date, title, content)
---
## 6. 구현 계획
### 6.1 약사/직원 테이블 (신규 - SQLite)
**위치:** `backend/db/pharmacy_staff.db`
```sql
CREATE TABLE staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(20) NOT NULL, -- 이름 (PHARMA_ID에 저장될 값)
role VARCHAR(20), -- 역할 (약사, 직원 등)
is_active BOOLEAN DEFAULT 1, -- 활성 여부
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 초기 데이터
INSERT INTO staff (name, role) VALUES ('김영빈', '약사');
INSERT INTO staff (name, role) VALUES ('박혜령', '약사');
```
**용도:**
- 메모 작성 시 드롭다운 목록 제공
- 향후 로그인 시스템 확장 대비
- 기본 작성자 설정 가능
### 6.2 UI 구현 방향
```
[회원 상세 페이지]
├── 기본 정보 (이름, 전화번호, 주민번호 등)
├── 특이(참고)사항
│ └── [단일 텍스트 영역] - 저장 시 덮어쓰기
└── 메모 (날짜별 누적)
├── [메모 목록] - 날짜순 정렬
│ └── 각 메모: 날짜, 작성자, 제목, 내용 미리보기
├── [새 메모 추가]
│ ├── 작성자 드롭다운 (staff 테이블에서)
│ ├── 제목 입력
│ └── 내용 입력
└── [메모 수정/삭제]
```
---
## 7. 현재 구현 현황 (2026-03-04)
### 7.1 특이(참고)사항 - 구현 완료 ✅
**위치:** https://mile.0bin.in/admin → 사용자 클릭 → 상세 모달
#### UI
```
┌──────────────────┬──────────────────┐
│ 🎂 생일 │ ⚠️ 특이사항 [✏️ 수정]│
│ 07월 12일 │ 개발약사2 │
└──────────────────┴──────────────────┘
```
- 생일 옆 칸에 표시 (공간 효율적 활용)
- 30자 초과 시 truncate, 클릭하면 펼침
- [✏️ 수정] 버튼 → 인라인 textarea → [저장] / [취소]
#### API
**조회:** `GET /admin/user/{userId}`
```json
{
"pos_customer": {
"cuscode": "0000000004",
"name": "김영빈",
"cusetc": "개발약사2"
}
}
```
**수정:** `PUT /api/members/{cuscode}/cusetc`
```json
// Request
{ "cusetc": "새로운 특이사항" }
// Response
{
"success": true,
"message": "특이사항이 저장되었습니다.",
"cusetc": "새로운 특이사항"
}
```
#### E2E 테스트 완료
```bash
# 검색
GET /api/members/search?q=김영빈 → cuscode: 0000000004
# 수정
PUT /api/members/0000000004/cusetc
Body: { "cusetc": "개발약사2 - 테스트 수정" }
→ 성공 ✅
# 확인
GET /api/members/search?q=김영빈
→ memo: "개발약사2 - 테스트 수정"
```
---
### 7.2 메모 (날짜별 누적) - 미구현 ⏳
**다음 단계:**
1. staff 테이블 생성 (SQLite)
2. 메모 CRUD API 구현
3. UI 구현 (메모 목록, 추가, 수정, 삭제)
---
## 8. 관련 파일
| 파일 | 설명 |
|------|------|
| `backend/app.py` | Flask API (3740행~ 회원 관련, CUSETC 수정 API 포함) |
| `backend/db/dbsetup.py` | DB 연결 설정 |
| `backend/templates/admin.html` | 어드민 대시보드 (사용자 상세 모달, 특이사항 UI) |
| `backend/templates/admin_members.html` | 회원 관리 페이지 |
| `person-lookup-web-local/models.py` | SQLAlchemy 모델 정의 |
---
## 9. 참고사항
- PIT3000 원본 테이블은 직접 수정 (INSERT/UPDATE)
- 마일리지 시스템(SQLite)과 별개로 MSSQL에 저장
- CD_PERSON_MEMO는 복합키(CUSCODE + MEMO_CODE) 주의

359
docs/PAAI-SYSTEM.md Normal file
View File

@@ -0,0 +1,359 @@
# PAAI (Pharmacist Assistant AI) 시스템
> 약사를 위한 AI 기반 처방 분석 및 복약지도 보조 시스템
## 📋 목차
1. [시스템 개요](#시스템-개요)
2. [구현 현황](#구현-현황)
3. [아키텍처](#아키텍처)
4. [데이터베이스](#데이터베이스)
5. [API 엔드포인트](#api-엔드포인트)
6. [어드민 페이지](#어드민-페이지)
7. [향후 계획](#향후-계획)
---
## 시스템 개요
### 목적
- 처방전 분석 시 KIMS 약물 상호작용 자동 확인
- AI 기반 복약지도 포인트 추천
- OTC 구매 이력 기반 맞춤 상담 제안
- 처방 변화 감지 및 분석
### 핵심 기능
1. **KIMS 상호작용 조회** - 처방 약품 간 상호작용 자동 체크
2. **AI 분석** - Clawdbot(Claude) 기반 처방 인사이트 생성
3. **처방 비교** - 이전 처방과 현재 처방 변화 분석
4. **OTC 연계** - 환자 OTC 구매 이력 기반 추천
---
## 구현 현황
### ✅ 완료된 기능
#### PMR (조제관리) 페이지
- [x] 환자 목록 / 처방 상세 조회
- [x] 이전 처방 비교 모드 (추가/변경/중단/동일 표시)
- [x] OTC 구매 이력 모달
- [x] PAAI 분석 버튼
#### PAAI 분석 기능
- [x] KIMS API 연동 (약물 상호작용 조회)
- [x] Clawdbot Gateway 연동 (AI 분석)
- [x] 비동기 토스트 알림 (다른 환자 보면서도 알림 수신)
- [x] 분석 결과 캐싱 (환자별)
- [x] 피드백 수집 (유용/비유용)
#### 토스트 알림 시스템
- [x] 우상단 오버레이 토스트
- [x] A환자 분석 중 → B환자 조회 가능
- [x] 토스트 클릭 시 해당 환자 결과 모달
### 🚧 진행 중
#### 어드민 페이지
- [ ] 피드백 통계 대시보드
- [ ] 분석 이력 검색
- [ ] KIMS 호출 로그
- [ ] AI 요청/응답 로그
---
## 아키텍처
```
┌─────────────────────────────────────────────────────────────────┐
│ PMR 페이지 (pmr.html) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ 환자 목록 │ │ 처방 상세 │ │ PAAI 토스트/모달 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Flask API (pmr_api.py) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ /pmr/api/ │ │ /pmr/api/ │ │ /pmr/api/paai/ │ │
│ │ prescriptions│ │ patient/ │ │ analyze, feedback │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ PIT3000 DB │ │ KIMS API │ │ Clawdbot Gateway│
│ (MSSQL) │ │ (상호작용) │ │ (Claude AI) │
│ │ │ │ │ │
│ - PM_PRES │ │ - 약품 검색 │ │ - WebSocket │
│ - PM_DRUG │ │ - 상호작용 조회 │ │ - 세션 관리 │
│ - PM_CUS │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ SQLite DB │
│ (paai_logs) │
│ │
│ - 분석 로그 │
│ - 피드백 │
│ - KIMS 로그 │
└─────────────────┘
```
---
## 데이터베이스
### paai_logs 테이블 (SQLite)
```sql
CREATE TABLE IF NOT EXISTS paai_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 환자/처방 정보
pre_serial TEXT, -- 처방전 번호
cus_code TEXT, -- 환자 코드
patient_name TEXT, -- 환자명
-- 질병 정보
disease_codes TEXT, -- JSON: ["M750", "K299"]
disease_names TEXT, -- JSON: ["어깨 유착성 관절낭염", "위십이지장염"]
-- 처방 정보
medication_count INTEGER, -- 약품 수
medications_json TEXT, -- JSON: 전체 약품 리스트
-- KIMS 결과
kims_called BOOLEAN, -- KIMS 호출 여부
kims_request_json TEXT, -- KIMS 요청 데이터
kims_response_json TEXT, -- KIMS 응답 원본
kims_interaction_count INTEGER, -- 상호작용 건수
kims_has_severe BOOLEAN, -- 중증 상호작용 여부
kims_duration_ms INTEGER, -- KIMS 응답 시간
-- AI 분석 결과
ai_called BOOLEAN, -- AI 호출 여부
ai_prompt_json TEXT, -- AI에게 전달한 프롬프트
ai_response_json TEXT, -- AI 응답 원본
ai_parsed_json TEXT, -- 파싱된 분석 결과
ai_duration_ms INTEGER, -- AI 응답 시간
ai_model TEXT, -- 사용 모델 (claude-opus-4-5 등)
-- 피드백
feedback_useful BOOLEAN, -- 유용했는지
feedback_at TIMESTAMP, -- 피드백 시간
feedback_comment TEXT, -- 추가 코멘트 (향후)
-- 메타
total_duration_ms INTEGER, -- 전체 처리 시간
error_message TEXT, -- 에러 발생 시
client_ip TEXT -- 요청 IP
);
-- 인덱스
CREATE INDEX idx_paai_created ON paai_logs(created_at);
CREATE INDEX idx_paai_patient ON paai_logs(cus_code);
CREATE INDEX idx_paai_feedback ON paai_logs(feedback_useful);
```
---
## API 엔드포인트
### PAAI 분석
#### POST `/pmr/api/paai/analyze`
**요청:**
```json
{
"pre_serial": "20260305001",
"cus_code": "C00123",
"patient_name": "김미성",
"disease_info": {
"code_1": "M750",
"name_1": "어깨의 유착성 관절낭염",
"code_2": "K299",
"name_2": "상세불명의 위십이지장염"
},
"current_medications": [
{"code": "641500020", "name": "아세탑정", "dosage": "1", "frequency": "2", "days": "5"}
],
"previous_medications": [],
"otc_history": {
"visit_count": 5,
"frequent_items": [{"name": "신신파스", "count": 3}]
}
}
```
**응답:**
```json
{
"success": true,
"log_id": 42,
"kims_summary": {
"interaction_count": 2,
"has_severe": false,
"interactions": [...]
},
"analysis": {
"prescription_insight": "소염진통제와 위장약 병용 처방...",
"kims_analysis": "아세클로페낙과 레바미피드 병용은...",
"cautions": ["식후 30분 복용", "위장장애 주의"],
"otc_recommendations": [
{"product": "신신파스", "reason": "근골격계 통증 보조"}
],
"counseling_points": ["충분한 수분 섭취", "알코올 자제"]
},
"timing": {
"kims_ms": 234,
"ai_ms": 2891,
"total_ms": 3125
}
}
```
### 피드백
#### POST `/pmr/api/paai/feedback`
**요청:**
```json
{
"log_id": 42,
"useful": true
}
```
---
## 어드민 페이지
### 📊 대시보드 (`/pmr/admin`)
#### 1. 개요 통계
```
┌─────────────────────────────────────────────────────────────┐
│ 📊 PAAI 어드민 대시보드 [날짜 선택] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 127 │ │ 89% │ │ 15건 │ │ 2.3초 │ │
│ │ 총 분석 │ │ 유용 평가│ │KIMS 경고 │ │ 평균응답 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
#### 2. 피드백 통계
- 일별/주별/월별 유용/비유용 비율 차트
- 비유용 피드백 많은 케이스 분석
#### 3. 분석 이력 검색
```
┌─────────────────────────────────────────────────────────────┐
│ 🔍 분석 이력 │
├─────────────────────────────────────────────────────────────┤
│ 환자명: [_________] 기간: [____] ~ [____] [검색] │
├─────────────────────────────────────────────────────────────┤
│ # │ 일시 │ 환자 │ 약품수│ KIMS │ 피드백│ 상세 │
│ ───┼────────────┼─────────┼───────┼──────┼───────┼─────── │
│ 1 │ 03-05 14:32│ 김미성 │ 4 │ 2건 │ 👍 │ [보기] │
│ 2 │ 03-05 14:28│ 박철수 │ 6 │ 0건 │ 👎 │ [보기] │
│ 3 │ 03-05 14:15│ 이영희 │ 3 │ 1건 │ - │ [보기] │
└─────────────────────────────────────────────────────────────┘
```
#### 4. 상세 로그 보기 (모달)
```
┌─────────────────────────────────────────────────────────────┐
│ 📋 분석 상세 - 김미성님 (2026-03-05 14:32) [닫기] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ▼ 환자/처방 정보 │
│ 처방번호: 20260305001 │
│ 질병: [M750] 어깨 유착성 관절낭염, [K299] 위십이지장염 │
│ 약품: 아세탑정, 에페솔정, 레바미피드정, 브로나제정 │
│ │
│ ▼ KIMS 호출 (234ms) │
│ 요청: {"medications": ["641500020", "645678901", ...]} │
│ 응답: {"interactions": [...], "count": 2} │
│ │
│ ▼ AI 프롬프트 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 당신은 약사를 보조하는 AI입니다... │ │
│ │ ## 환자 질병 │ │
│ │ [M750] 어깨의 유착성 관절낭염... │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ▼ AI 응답 (2891ms) │
│ 모델: claude-opus-4-5 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "prescription_insight": "소염진통제와...", │ │
│ │ "kims_analysis": "아세클로페낙과...", │ │
│ │ "cautions": ["식후 30분 복용", ...], │ │
│ │ ... │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ▼ 피드백 │
│ 평가: 👍 유용해요 │
│ 시간: 2026-03-05 14:35:21 │
│ │
└─────────────────────────────────────────────────────────────┘
```
#### 5. KIMS 호출 로그
- 일별 KIMS API 호출 횟수
- 상호작용 감지율
- 중증 경고 발생 케이스
#### 6. AI 성능 모니터링
- 평균 응답 시간 추이
- 에러율
- 모델별 사용량
---
## 향후 계획
### Phase 1 (현재)
- [x] 기본 PAAI 분석 기능
- [x] 비동기 토스트 알림
- [ ] 어드민 페이지 기본
### Phase 2
- [ ] 피드백 기반 프롬프트 개선
- [ ] 자주 나오는 상담 포인트 학습
- [ ] 약국별 맞춤 설정
### Phase 3
- [ ] 다중 약국 지원
- [ ] 분석 결과 PDF 출력
- [ ] 환자용 복약지도 문자 발송 연동
---
## 파일 구조
```
backend/
├── pmr_api.py # Flask API 서버
├── services/
│ ├── kims_service.py # KIMS API 연동
│ └── clawdbot_client.py # Clawdbot Gateway 연동
├── templates/
│ ├── pmr.html # 조제관리 페이지
│ └── pmr_admin.html # 어드민 페이지 (예정)
└── db/
└── paai_logs.db # PAAI 로그 SQLite
```
---
*최종 업데이트: 2026-03-05*

View File

@@ -0,0 +1,74 @@
# 트러블슈팅: 카메라 촬영 이미지 업로드 실패
## 📅 발생일: 2026-03-04
## 🔴 증상
- 제품 이미지 관리 페이지에서 "촬영" 기능으로 이미지 교체 시 저장 실패
- 에러 메시지: `NoneType object has no attribute 'strip'`
- API 호출은 성공하나 이미지 데이터가 `null`로 전송됨
## 🔍 원인 분석
### 1차 원인: None 값 처리 누락 (백엔드)
```python
# 문제 코드
image_data = data.get('image_data', '').strip() # None이면 에러
# 수정 코드
image_data = (data.get('image_data') or '').strip()
```
### 2차 원인: 변수 리셋 타이밍 문제 (프론트엔드) ⭐ 핵심
```javascript
// 문제 코드
async function submitCapture() {
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
closeReplaceModal(); // ← 여기서 capturedImageData = null 로 리셋됨!
// API 호출 시 capturedImageData가 이미 null
body: JSON.stringify({
image_data: capturedImageData, // null!
...
})
}
// 수정 코드
async function submitCapture() {
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
const imageData = capturedImageData; // ← 미리 복사!
closeReplaceModal();
body: JSON.stringify({
image_data: imageData, // 복사된 값 사용
...
})
}
```
### 3차 원인: 브라우저 캐시
- 코드 수정 후에도 브라우저가 이전 JS를 캐시
- **Ctrl+Shift+R** (강력 새로고침) 필요
## ✅ 해결 방법
1. **백엔드**: `None` 값에 대한 방어 코드 추가
2. **프론트엔드**: `closeReplaceModal()` 호출 전에 필요한 변수들을 로컬 변수로 복사
3. **테스트 시**: 강력 새로고침 (Ctrl+Shift+R) 또는 시크릿 모드 사용
## 📝 교훈
1. **모달 닫기 함수에서 상태 리셋 주의**
- 모달을 닫으면서 관련 변수를 초기화하는 경우, async 함수에서 순서에 주의
2. **프론트엔드 디버깅 시 브라우저 캐시 확인**
- 코드 수정 후에도 동작이 같다면 캐시 문제 의심
3. **API 직접 테스트로 문제 범위 좁히기**
- `requests` 또는 `curl`로 API만 테스트하면 프론트/백엔드 문제 구분 가능
## 🔧 관련 파일
- `backend/app.py` - `/api/admin/product-images/<barcode>/upload` 엔드포인트
- `backend/templates/admin_product_images.html` - 카메라 촬영 UI 및 JS

View File

@@ -0,0 +1,109 @@
# 트러블슈팅: 이미지 교체 시 바코드 전달 오류
**날짜:** 2026-03-02
**해결 상태:** ✅ 해결됨
---
## 증상
제품 이미지 관리 페이지에서 "교체" 버튼을 눌러 이미지 URL을 입력하면:
- 기존 제품의 이미지가 교체되지 않음
- 대신 새로운 레코드가 생성됨
- 새 레코드의 barcode, product_name이 `null`로 저장됨
## 원인
### 1차 원인: JavaScript 템플릿 리터럴에서 escapeHtml 문제
```javascript
// 문제 코드
onclick="openReplaceModal('${item.barcode}', '${escapeHtml(item.product_name)}')"
```
제품명에 특수문자(따옴표 등)가 포함되면 `escapeHtml` 함수가 문자열을 변환하면서 onclick 속성이 깨짐.
### 2차 원인: 전역 변수 참조 문제
`replaceTargetBarcode` 변수가 모달 제출 시 `undefined` 또는 `"null"` 문자열로 전달됨.
---
## 해결 방법
### 1. data 속성으로 바코드 전달 (HTML)
```javascript
// 수정 후
<button class="btn btn-primary btn-sm"
data-barcode="${item.barcode}"
data-name="${item.product_name || ''}"
onclick="openReplaceModal(this.dataset.barcode, this.dataset.name)">교체</button>
```
`data-*` 속성을 사용하여 값을 안전하게 저장하고, `this.dataset`으로 접근.
### 2. 바코드 유효성 검증 추가 (JavaScript)
```javascript
function openReplaceModal(barcode, productName) {
console.log('openReplaceModal called with:', barcode, productName);
// 바코드 검증
if (!barcode || barcode === 'null' || barcode === 'undefined') {
showToast('바코드 정보가 없습니다', 'error');
return;
}
replaceTargetBarcode = barcode;
// ...
}
async function submitReplace() {
// 바코드 검증
if (!replaceTargetBarcode || replaceTargetBarcode === 'null' || replaceTargetBarcode === 'undefined') {
showToast('바코드 정보가 없습니다. 다시 시도해주세요.', 'error');
return;
}
const barcode = replaceTargetBarcode; // 로컬 변수에 복사
// ...
}
```
### 3. 백엔드 UPDATE 쿼리 수정 (Python)
```python
# 기존 레코드 확인 후 이미지만 업데이트 (product_name, drug_code 유지)
cursor.execute("SELECT product_name, drug_code FROM product_images WHERE barcode = ?", (barcode,))
existing = cursor.fetchone()
if existing:
# 기존 레코드 있으면 이미지만 업데이트
cursor.execute("""
UPDATE product_images
SET image_base64 = ?, thumbnail_base64 = ?, image_url = ?,
status = 'manual', error_message = NULL, updated_at = datetime('now')
WHERE barcode = ?
""", (image_base64, thumbnail_base64, image_url, barcode))
```
---
## 관련 커밋
- `4a06e60` - feat: 이미지 교체 기능 추가
- `4a3ec38` - fix: 이미지 교체 시 바코드 전달 오류 수정
- `65754f5` - fix: 이미지 교체 시 바코드 검증 강화
---
## 교훈
1. **onclick에 동적 값 전달 시 주의**: 템플릿 리터럴 + escapeHtml 조합은 예상치 못한 오류 발생 가능. `data-*` 속성 사용 권장.
2. **전역 변수 의존 최소화**: 모달 등 비동기 흐름에서 전역 변수 사용 시 값이 바뀔 수 있음. 로컬 변수에 복사 후 사용.
3. **입력 검증은 프론트/백 양쪽에서**: null, undefined, "null" 문자열 등 예외 케이스 모두 검증.
4. **디버깅 로그 활용**: `console.log`로 실제 전달되는 값 확인하면 원인 파악이 빠름.

56
package-lock.json generated Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "pharmacy-pos-qr-system",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.58.2"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"playwright": "^1.58.2"
}
}

39
test_print_cusetc.js Normal file
View File

@@ -0,0 +1,39 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// API 응답 캡처
let apiResponse = null;
page.on('response', async (response) => {
if (response.url().includes('/api/print/cusetc')) {
apiResponse = await response.json();
}
});
await page.goto('http://localhost:7001/admin');
console.log('✅ 관리자 페이지');
await page.click('td:has-text("김영빈")');
await page.waitForSelector('text=특이사항', { timeout: 5000 });
console.log('✅ 모달 열림');
// 인쇄 버튼 클릭
const printBtn = await page.$('button[onclick*="doPrintCusetc"]');
if (printBtn) {
await printBtn.click();
console.log('✅ 인쇄 버튼 클릭');
// 즉시 토스트 확인 (API 응답 전)
await page.waitForTimeout(100);
const toast = await page.$eval('.toast', el => el?.textContent).catch(() => null);
console.log('📢 즉시 피드백:', toast || '(토스트 없음)');
// API 응답 대기
await page.waitForTimeout(2000);
console.log('📡 API 응답:', apiResponse?.success ? '✅ ' + apiResponse.message : '❌ ' + (apiResponse?.error || 'no response'));
}
await browser.close();
})();