Compare commits

...

186 Commits

Author SHA1 Message Date
thug0bin
a7bcf46aaa feat(반품관리): 위치 지정 기능 추가
- 위치 뱃지 클릭 시 위치 수정 모달 표시
- '미지정' 뱃지 스타일 (점선 테두리, 클릭 유도)
- 기존 위치 선택 드롭다운 + 직접 입력 가능
- 위치 삭제 기능
- products 페이지와 동일한 API 재활용 (/api/locations, /api/drugs/.../location)
- 다크 테마에 맞는 모달 스타일
- Edit 툴로 부분 수정하여 인코딩 유지
2026-03-08 12:45:06 +09:00
thug0bin
e82f4be4af feat(반품관리): 위치 컬럼 추가
- CSS: .location-badge (노란 배지 스타일)
- 테이블 헤더에 위치 컬럼 추가
- API의 location 필드 활용 (CD_item_position 조인)
- Edit으로 부분 수정하여 인코딩 유지
2026-03-08 11:16:13 +09:00
thug0bin
eda0429a85 fix(반품관리): 인코딩 수정 (UTF-8)
- admin_return_management.html 한글 깨짐 수정
- Python으로 UTF-8 인코딩으로 전체 파일 재작성
- 모든 기능 유지 (입고이력, 위치 컬럼 등)
2026-03-08 11:08:02 +09:00
thug0bin
71d1916efb feat(반품관리): 약품 위치 컬럼 추가
- API: return-candidates에서 CD_item_position 조인하여 위치 정보 반환
- 테이블에 '위치' 컬럼 추가 (노란색 뱃지 스타일)
- 위치 미지정 약품은 '-' 표시
2026-03-08 10:46:43 +09:00
thug0bin
c71c9ad678 feat(반품관리): 약품 더블클릭 시 입고이력 모달 추가
- admin_return_management.html 업데이트:
  - 입고이력 모달 스타일/HTML 추가 (다크테마 적용)
  - tr ondblclick → openPurchaseModal()
  - 도매상 전화번호 클릭 시 복사 기능
  - 테이블 위에 더블클릭 힌트 추가
  - 상태변경 버튼에 event.stopPropagation() 추가
2026-03-08 10:35:48 +09:00
thug0bin
91f8dea5b4 feat(재고): 약품 더블클릭 시 입고이력 모달 추가
- 새 API: GET /api/drugs/<drug_code>/purchase-history
  - WH_sub + WH_main + PM_BASE.CD_custom 조인
  - 도매상명, 입고일, 수량, 단가, 전화번호 반환
- admin_products.html 업데이트:
  - tr ondblclick → openPurchaseModal()
  - 입고이력 모달 UI/스타일 추가
  - 도매상 전화번호 클릭 시 복사 기능
  - 결과 카운트 옆에 더블클릭 힌트 추가
- 기타 onclick에 event.stopPropagation() 추가 (충돌 방지)
2026-03-08 10:33:21 +09:00
thug0bin
d6cf4c2cc1 feat: 반품관리 페이지 추가 2026-03-08 10:03:42 +09:00
thug0bin
09948c234f feat(rx-usage): 선호 도매상 컬럼 추가
- 테이블에 '선호도매상' 컬럼 추가
- 입고장 기반 최다/최근 주문 도매상 표시
- API: /api/order/drugs/preferred-vendors 연동
- Python 스크립트로 안전하게 수정
2026-03-07 23:12:42 +09:00
thug0bin
a23e4bad43 feat: 약품별 선호 도매상 API + delivery_schedules 테이블
- GET /api/order/drug/{code}/preferred-vendor: 약품별 선호 도매상 조회
- POST /api/order/drugs/preferred-vendors: 일괄 조회
- MSSQL 입고장 데이터 활용 (WH_main, WH_sub)
- 최근 주문 도매상 + 최다 주문 도매상 반환

DB:
- delivery_schedules 테이블 생성 (orders.db)
- 도매상별 주문 마감시간/배송 도착시간 관리
2026-03-07 22:49:12 +09:00
thug0bin
1088720081 fix(baekje): /orders/summary-by-kd에서 새 API 사용
- get_order_list(include_details=True)로 한 번에 조회
- 접수 상태(확정 전)도 집계에 포함
- pending_count, approved_count 응답에 추가
2026-03-07 22:20:15 +09:00
thug0bin
497aeee75f feat(baekje): order_api에서 선별 주문 사용
- submit_order_selective로 기존 장바구니 보존
- 지오영/수인과 동일한 방식 적용
2026-03-07 21:42:15 +09:00
thug0bin
0ae4ae66f0 fix(baekje): 장바구니 담기 시 internal_code 사용하도록 수정
- kd_code 대신 internal_code로 장바구니 추가
- internal_code 없으면 검색 후 규격 매칭으로 찾기
- 백제 장바구니 담기 정상 작동 확인
2026-03-07 21:29:00 +09:00
thug0bin
232a77006a fix: 지오영 주문량 집계 시 취소/삭제 상태 제외
- status에 '취소' 또는 '삭제' 포함 시 집계 제외
- 예: '취소(삭제)' 상태
2026-03-07 18:14:00 +09:00
thug0bin
20fc528c2b fix: 조회 버튼 클릭 시 주문량도 갱신 2026-03-07 18:10:11 +09:00
thug0bin
0f69b50c49 fix: D(도즈) 단위는 boxes=units로 처리 (나잘스프레이 등) 2026-03-07 17:49:03 +09:00
thug0bin
dc2a992c12 fix: 규격 파싱 - T/C/P/D 수량 단위 우선, mg/ml 용량 단위 무시
- parse_spec 함수 개선: product_name에서도 수량 단위 추출
- 예: '스틸녹스정10mg(PTP) 14T' → spec='10mg'이어도 14T에서 14 추출
2026-03-07 17:31:18 +09:00
thug0bin
21c8124811 fix: 지오영 summary-by-kd에 KD코드 enrich 추가 2026-03-07 17:08:18 +09:00
thug0bin
33c6cd2d5c feat: 처방약품 사용량 페이지 - 주문량 지오영+수인 합산
- GET /api/geoyoung/orders/summary-by-kd 추가
- admin_rx_usage.html: 두 도매상 병렬 조회 후 합산 표시
- 콘솔에 도매상별 주문량 로깅
2026-03-07 17:07:25 +09:00
thug0bin
e5744e4f0f feat(geoyoung): 주문 조회 API 엔드포인트 추가
- GET /api/geoyoung/order-list: 기간별 주문 목록
- GET /api/geoyoung/order-detail/<order_num>: 주문 상세
- GET /api/geoyoung/order-today: 오늘 주문 요약

수인약품 API와 동일한 엔드포인트 구조
2026-03-07 17:01:22 +09:00
thug0bin
1720c108b5 fix: 상세 모달 날짜도 UTC → KST 변환 2026-03-07 11:40:05 +09:00
thug0bin
d842c776c9 fix: 날짜 표시 UTC → KST 변환 (admin 페이지들) 2026-03-07 11:38:37 +09:00
thug0bin
c1fae04344 docs: PAAI 트러블슈팅 기록 (2026-03-07) 2026-03-07 11:06:07 +09:00
thug0bin
b6d0fadb3c fix: OTC 라벨 모듈 import 경로 수정 (PM2 환경 호환) 2026-03-07 10:44:44 +09:00
thug0bin
ee300f80ca feat: 소수 환자 약품 뱃지 표시
- 1년간 3명 이하 환자만 사용하는 약품에 환자 이름 뱃지 표시
- 조회 기간 내 사용한 환자는 핑크색으로 강조
- 매출액 컬럼명 변경 (약가 → 매출액)
- SUM(DRUPRICE)로 매출액 계산
2026-03-07 00:43:02 +09:00
thug0bin
846883cbfa feat: 주문 모달 한도/매출 표시 및 UI 개선
- 도매상 한도 API 추가 (wholesaler_limits 테이블)
- 다중 도매상 모달: 월 한도 + 실제 월 매출 표시
- 주문 후 예상 사용량 계산 및 경고 표시
- 이모지 대신 로고 이미지 사용
- 약가 → 매출액 헤더 변경
- 매출액 계산: SUM(DRUPRICE)
2026-03-07 00:24:32 +09:00
thug0bin
29597d55fa feat: 주문 모달에 금액 표시 추가 2026-03-07 00:01:02 +09:00
thug0bin
442815b65e feat(rx-usage): 주문 모달 개선 - 버튼 3분할 + 총액 표시
- 🧪 테스트 / 🛒 장바구니만 / 🚀 즉시주문 버튼 분리
- cart_only 파라미터로 장바구니만 담기 기능 지원
- 주문 확인 모달에 총액 표시 추가
- 모달 너비 확장 (600px/650px)
2026-03-06 23:47:25 +09:00
thug0bin
a672c7a2a0 feat(order): 지오영/수인 선택적 주문 + 장바구니 보존 기능
- internal_code DB 저장 → 프론트에서 선택한 제품 그대로 주문
- 기존 장바구니 백업/복구로 사용자 장바구니 보존
- 수인약품 submit_order() 수정 (체크박스 제외 방식)
- 테스트 파일 정리 및 문서 추가
2026-03-06 23:26:44 +09:00
thug0bin
f48e657e12 fix(order): 지오영 internal_code 있으면 검색 스킵
- 프론트에서 이미 선택한 품목의 internal_code 사용
- 검색 없이 바로 add_to_cart 호출
- 재고 불일치 문제 해결
2026-03-06 22:19:48 +09:00
thug0bin
268f5bce8f feat(order): 지오영 선별 주문 구현 완료
- full_order(auto_confirm=False)로 장바구니에 담기
- internal_code 사용 (product_code 아님)
- submit_order_selective()로 선별 주문
- 기존 장바구니 품목 보존, 복원 완료
2026-03-06 22:15:09 +09:00
thug0bin
ad58cde952 fix(order): 지오영 quick_order 파라미터명 수정 (specification → spec) 2026-03-06 22:03:58 +09:00
thug0bin
760aea6f89 feat(order): 지오영 주문도 선별 주문 적용
- quick_order로 장바구니 담기 (auto_confirm 제거)
- submit_order_selective로 선별 주문
- 기존 품목 보존, 이번에 담은 것만 주문
2026-03-06 22:00:50 +09:00
thug0bin
be95f8b3d1 feat(order): 수인약품 선별 주문 및 rx-usage 주문 전송 개선
- order_api.py: 수인 주문 시 submit_order_selective() 사용 (기존 품목 보존)
- admin_rx_usage.html: cart_only=false로 변경 (장바구니+주문확정)
- 버튼 텍스트 변경: 장바구니 담기 → 주문 전송
2026-03-06 21:52:40 +09:00
thug0bin
5519f5ae62 feat: 도매상 잔고 모달에 월간 매출 추가
- 백제/지오영/수인 월간매출 API 라우트 추가
- 모달 UI: 잔고 + 월간 매출 동시 표시
- 총 주문액 / 총 미수금 요약 표시
2026-03-06 18:01:37 +09:00
thug0bin
4b2d934839 feat: 백제약품 월간 매출 API 라우트 추가 2026-03-06 17:57:06 +09:00
thug0bin
06c975ce34 fix: 백제약품 로고를 공식 SVG로 교체 2026-03-06 17:22:08 +09:00
thug0bin
ad0b55ee2d feat: 도매상 설정 중앙 관리 시스템
- config/wholesalers.json: 도매상 정보 중앙 관리 (ID, 이름, 로고, 색상, API)
- config/__init__.py: Python 헬퍼 (get_wholesalers, get_wholesaler)
- wholesaler_config_api.py: /api/config/wholesalers 엔드포인트
- 백제약품 로고(favicon) 추가: logo_baekje.ico
- 잔고 모달에 로고 표시 기능 추가
2026-03-06 17:18:40 +09:00
thug0bin
2d09f139ca feat: Rx 사용량 페이지에 도매상 잔고 조회 버튼 추가
- 💰 도매상 잔고 버튼 (검색 바 옆)
- 모달로 백제/지오영/수인 3개 도매상 잔고 표시
- 총 미수금 합계 표시
- 새로고침 기능
2026-03-06 16:26:26 +09:00
thug0bin
1829c3efa7 feat: 지오영/수인 잔고 API 엔드포인트 추가
- GET /api/geoyoung/balance
- GET /api/sooin/balance
2026-03-06 16:15:23 +09:00
thug0bin
241e65aaf1 feat: 백제약품 잔고 API 엔드포인트 추가
GET /api/baekje/balance - 잔고액 조회
2026-03-06 15:00:15 +09:00
thug0bin
ddba17ae08 feat: 처방 사용량 페이지에 약품 위치 표시
- rx-usage API에 CD_item_position.CD_NM_sale 조인 추가
- 제품코드 옆에 위치 배지 표시 (📍A-1 형태)
- 인디고 색상 작은 배지로 UI 해치지 않게
2026-03-06 13:41:39 +09:00
thug0bin
055fad574d feat: 백제약품 프론트엔드 완전 통합
- 도매상 재고 조회에 백제 추가
- searchBaekjeAPI 함수 추가
- renderWholesaleResults에 백제 섹션 추가
- addToCartFromWholesale에 백제 처리 추가
- executeOrder에 백제 도매상 필터 추가
- CSS 스타일 추가 (주황색 테마)
2026-03-06 13:18:29 +09:00
thug0bin
857a058691 feat: 백제약품 API 통합
- baekje_api.py: Flask Blueprint 추가
- order_api.py: submit_baekje_order 함수 추가
- admin_rx_usage.html: WHOLESALERS에 baekje 추가
- 환경변수: BAEKJE_USER_ID, BAEKJE_PASSWORD

URL: https://ibjp.co.kr (약국용 웹 주문 시스템)
2026-03-06 13:04:07 +09:00
thug0bin
78f6f21228 fix: 주문 확인 모달 도매상명 동적 변경
- orderConfirmWholesaler span 추가
- '지오영에 주문합니다' → '{도매상명}에 주문합니다' 동적 변경
2026-03-06 12:35:30 +09:00
thug0bin
f625a08091 refactor: 도매상 설정 중앙화 (WHOLESALERS 객체)
- WHOLESALERS 객체로 도매상 정보 일원화
- 향후 도매상 추가 시 객체에만 추가하면 됨
- 결과 모달에 도매상명, 아이콘, 색상 적용
- 단가 정보 결과에 표시
2026-03-06 12:29:11 +09:00
thug0bin
50455e63c7 feat: 수인약품 주문 dry-run 지원
- order_api.py: submit_sooin_order() 함수 추가
- admin_rx_usage.html: 도매상별 주문 분기 처리
- 수인/지오영 모두 dry-run 테스트 가능
- 여러 도매상 품목 있을 때 선택 모달
2026-03-06 12:24:15 +09:00
thug0bin
7dda385b7f feat: 지오영 재고 조회 시 단가 표시
- geoyoung_api.py: include_price=True로 검색
- admin_rx_usage.html: 지오영 섹션에 단가 컬럼 추가
2026-03-06 12:18:38 +09:00
thug0bin
101dda2e41 feat: 도매상 로고 아이콘 적용 (이모지 → 실제 로고)
- 지오영: favicon.ico 사용
- 수인: 커스텀 SVG (보라색 '수' 아이콘)
- static/img/ 폴더에 로고 저장
2026-03-06 12:05:13 +09:00
thug0bin
19c70e42fb feat: 더블클릭 시 지오영+수인 동시 재고 조회
- openWholesaleModal: 두 도매상 동시 API 호출
- Promise.all로 병렬 조회 (빠른 응답)
- 도매상별 섹션 구분 UI (지오영: 청록, 수인: 보라)
- 각 도매상별 담기 버튼
- 수인은 단가 정보도 표시
2026-03-06 11:59:13 +09:00
thug0bin
90d993156e docs: 지오영 cancel/restore 문서 업데이트 2026-03-06 11:56:41 +09:00
thug0bin
ba38c05b93 feat: 지오영 cart/cancel, cart/restore 엔드포인트 추가
- POST /api/geoyoung/cart/cancel - 개별 삭제
- POST /api/geoyoung/cart/restore - NOT_SUPPORTED 응답
2026-03-06 11:56:17 +09:00
thug0bin
c1596a6d35 feat: 도매상 API 통합 및 스키마 업데이트
- wholesale 패키지 연동 (SooinSession, GeoYoungSession)
- Flask Blueprint 분리 (sooin_api.py, geoyoung_api.py)
- order_context 스키마 확장 (wholesaler_id, internal_code 등)
- 수인약품 개별 취소 기능 (cancel_item, restore_item)
- 문서 추가: WHOLESALE_API_INTEGRATION.md
- 테스트 스크립트들
2026-03-06 11:50:46 +09:00
thug0bin
e84eda928a fix: dry run에서 재고 있는 제품 우선 선택하도록 수정
- 동일 보험코드에 여러 제품 있을 때 첫 번째 매칭 선택하던 버그
- 재고 0인 제품 선택되어 dry run 실패하던 문제 해결
- matched_with_stock 우선, 없으면 matched_any 사용
2026-03-06 08:57:02 +09:00
thug0bin
0460085791 feat: 전문의약품(Rx) 사용량 조회 페이지 + OTC 사용량 페이지 추가
- /admin/rx-usage: 전문의약품 사용량 조회 (현재고 포함)
- /admin/usage: OTC 일반약 사용량 조회
- /api/rx-usage: 처방전 데이터 기반 품목별 집계 API
- /api/usage: POS 판매 데이터 기반 품목별 집계 API
- 현재고: IM_total.IM_QT_sale_debit 사용
- 장바구니 + 주문서 생성 기능
- 세로모니터 대응 미디어쿼리 포함
2026-03-06 01:07:04 +09:00
thug0bin
0d9f4c9a23 fix: 이전 처방에서도 대체조제 원처방(PS_Type=9) 제외
- 현재 처방과 동일하게 PS_Type=9는 목록에서 제외
- 중복 처방처럼 보이는 문제 해결
2026-03-05 20:44:04 +09:00
thug0bin
3527cc9777 feat: PAAI 약품 정보 Enrichment 구현
- enrich_medications() 함수 추가
- CD_SUNG: 성분 정보 (복합제 포함)
- CD_MC: 분류(PRINT_TYPE), 상호작용(INTERACTION), 금기(CONTRA)
- 프롬프트에 성분/분류 정보 포함
- AI가 성분 기반으로 정확한 판단 가능
2026-03-05 20:38:46 +09:00
thug0bin
636fd66f9e perf: OTC API 최적화 및 뱃지 안정화
- N+1 쿼리 → JOIN 한방 쿼리로 개선
  - 21번 쿼리 → 1번 쿼리
  - 2.4초 → 37ms (20건 조회 기준)
- DB warmup 추가 (앱 시작 시 연결 미리 생성)
- OTC 뱃지 insertBefore 방식으로 변경 (레이아웃 안정화)
- 중복 뱃지 방지 로직 추가
2026-03-05 20:32:00 +09:00
thug0bin
69b75d6724 feat: PAAI 피드백 루프 시스템 구현 (1-2단계)
- paai_feedback.py: 피드백 API + SQLite 저장
- PMR 화면: 👎 클릭 시 코멘트 입력 모달
- 카테고리: 약물상호작용/적응증/용법용량/기타
- 피드백 규칙 자동 정제 (기본 버전)
2026-03-05 15:37:06 +09:00
thug0bin
3ce44019bf feat: 본인부담률 표시 통일 (30/50/80/90/100)
- 색상 보라색 통일
- 숫자만 표시: 100), 80), 50), 30), 90)
2026-03-05 14:15:24 +09:00
thug0bin
d820d13af9 feat: 비급여/100% 표시 - UnitCode 기반
- UnitCode 컬럼 추가 (같은 테이블, 쿼리 부담 없음)
- UnitCode=2: 비) 비급여 (빨간색)
- UnitCode=3: 100) 전액본인부담 (보라색)
- 기존 대체조제 배지와 함께 표시 가능
2026-03-05 14:14:16 +09:00
thug0bin
771d247163 docs: PS_Type 의미 정정 - 일반대체 vs 저가대체 2026-03-05 13:55:03 +09:00
thug0bin
daa697fff9 fix: 대체조제 표시 수정 - PS_Type 의미 반영
- PS_Type=1: 대) 일반 대체조제 (주황색)
- PS_Type=4+9 쌍: 저) 저가대체 인센티브 (초록색)
- 비급여 표시는 추후 별도 구현
2026-03-05 13:54:51 +09:00
thug0bin
8a86a120d8 feat: 비급여 표시 '비)' 배지 추가 (붉은색)
- PS_Type=1 비급여 약품에 '비)' 배지 표시
- 붉은 그라데이션 (#ef4444 → #dc2626)
- 대체조제 + 비급여 동시 표시 가능
2026-03-05 13:46:09 +09:00
thug0bin
513c082cc6 docs: PS_sub_pharm 테이블 및 PS_Type 대체조제 구분 문서화
- PS_sub_pharm 테이블 컬럼 설명
- PS_Type 값별 의미 (0,1=일반, 4=대체실제, 9=대체원본)
- 대체조제 데이터 패턴 (4→9 순서)
- 쿼리 예시 추가
2026-03-05 13:45:03 +09:00
thug0bin
4968735a80 feat: 대체조제 표시 기능 추가
- PS_Type=9 (원본 처방) 숨김
- PS_Type=4 (대체조제) '대)' 배지 표시
- 원처방 펼쳐보기 (details/summary)
- 순서: 4(대체) → 9(원본) 패턴 매칭

관련: 3월5일 개선지시서.md
2026-03-05 13:43:33 +09:00
thug0bin
a144a091b9 docs: PAAI 시스템 아키텍처 문서 추가
- PAAI 전체 흐름 다이어그램
- 구성 요소 (Trigger, WebSocket, API, 프린터)
- WebSocket 이벤트 목록
- 자동인쇄/중복방지/재시도 로직 설명
- 로그 파일 위치
2026-03-05 13:24:03 +09:00
thug0bin
ebd4669d24 fix: 중복 인쇄 race condition 수정
문제: Set에 추가하는 타이밍이 인쇄 완료 후라서
      비동기로 2개 요청이 거의 동시에 들어오면 둘 다 통과

해결: 요청 전에 먼저 Set에 추가
      실패/오류 시에만 Set에서 제거 (재시도 가능)
2026-03-05 13:09:02 +09:00
thug0bin
c7169e6679 fix: 자동인쇄 중복 방지 + 인쇄 로깅 추가
문제: 같은 처방이 2번 인쇄됨
원인: printPaaiResult가 2곳에서 호출 (API 응답 + WebSocket 이벤트)

해결:
- window.printedSerials (Set) 으로 중복 인쇄 방지
- 인쇄 성공 시 Set에 추가
- 5분 후 자동 제거 (메모리 관리)

로깅 추가:
- 프론트: 콘솔에 시간 + 환자명 + 상태
- 백엔드: logs/print_history.log 파일에 기록
2026-03-05 12:56:39 +09:00
thug0bin
2eb92daf3e docs: PAAI 자동인쇄 트러블슈팅 가이드 작성
- 자동인쇄 토글 문제 해결법
- Flask/브라우저 캐시 문제
- 프린터 출력 안 될 때
- 한글 깨짐 해결 (EUC-KR)
- WebSocket 연결 실패
- 디버깅 체크리스트
- 전체 플로우 요약
2026-03-05 12:49:30 +09:00
thug0bin
b4e4a44981 fix: 자동인쇄 전역 변수/함수 완전 수정
모든 변수와 함수를 window. 접두사로 전역 노출:
- window.autoPrintEnabled
- window.showToast
- window.updateAutoPrintIndicator
- window.printPaaiResult

이제 토글 클릭 정상 작동!
2026-03-05 12:46:53 +09:00
thug0bin
e33204f265 fix: printPaaiResult 전역 함수로 변경
- async function → window.printPaaiResult = async function
- WebSocket 이벤트에서 호출 가능하도록 전역 노출
2026-03-05 12:42:58 +09:00
thug0bin
0bbc8a56f7 fix: 자동인쇄 토글 수정 - showToast 함수 추가
문제:
- onclick 이벤트에서 showToast 함수가 정의되지 않아 에러 발생
- JavaScript 함수들이 전역 스코프에 없어서 접근 불가

해결:
- window.showToast 함수 추가 (전역)
- 즉시 실행 함수(IIFE)로 이벤트 바인딩
- HTML onclick 속성 제거, JS에서만 처리

테스트 완료:
- 토글 클릭 시 ON/OFF 전환 확인
- localStorage에 상태 저장 확인
- 콘솔에 [AutoPrint] 로그 출력 확인
2026-03-05 12:37:54 +09:00
thug0bin
0b17139daa feat: PAAI 자동인쇄 기능 완성 (EUC-KR 텍스트 방식)
추가:
- 자동인쇄 ON/OFF 토글 (헤더)
- ESC/POS 영수증 인쇄 (EUC-KR 인코딩)
- ESCPOS_TROUBLESHOOTING.md 트러블슈팅 문서

핵심 변경:
- 이미지 방식 -> 텍스트 방식 (socket 직접 전송)
- UTF-8 -> EUC-KR 인코딩
- 이모지 제거 ([V], [!], >> 사용)
- 48자 기준 줄바꿈

인쇄 흐름:
1. PAAI 분석 완료
2. 자동인쇄 ON이면 /pmr/api/paai/print 호출
3. _format_paai_receipt()로 텍스트 생성
4. _print_escpos_text()로 프린터 전송

참고: docs/ESCPOS_TROUBLESHOOTING.md
2026-03-05 12:19:56 +09:00
thug0bin
7ac3f7a8b4 feat: PAAI 분석에 환자 특이사항(CUSETC) 포함
추가:
- AI 프롬프트에 '환자 특이사항' 섹션 추가
- 알러지, 기저질환, 주의사항 등 약사가 입력한 메모 활용
- 예: '투석실 환자' → AI가 신장약물 주의사항 안내

변경 파일:
- pmr_api.py: patient_note 파라미터 추가, build_paai_prompt 수정
- pmr.html: requestSnapshot에 patient_note 포함
- prescription_trigger.py: cusetc → patient_note 전달

효과:
- 환자별 맞춤 복약 안내 품질 향상
- 알러지/금기 정보 반영으로 안전성 강화
2026-03-05 11:04:03 +09:00
thug0bin
cb90d4a7a6 fix: 처방목록 조회 기준을 발행일에서 조제일로 변경
문제:
- PMR 처방 목록이 PassDay(처방전 발행일) 기준으로 조회되어
  발행일과 조제일이 다른 처방(예: 3일 전 발행, 오늘 조제)이
  오늘 목록에 표시되지 않는 버그

해결:
- PS_MAIN 테이블 조회 시 PassDay 대신 Indate(조제일) 기준으로 변경
- issue_date(발행일), dispense_date(조제일) 필드 추가로 구분 명확화

추가 변경:
- WebSocket 연결/해제 시 토스트 알림 추가
- WebSocket 프록시 트러블슈팅 문서 추가 (NPM 설정 가이드)
2026-03-05 10:56:24 +09:00
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
169 changed files with 46220 additions and 1288 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

View File

@@ -0,0 +1,277 @@
# 수인약품 API 리버스 엔지니어링 문서
## 개요
수인약품 웹 주문 시스템의 API 구조를 분석한 문서입니다.
지오영 API와 같은 하이브리드 방식 (Playwright 로그인 → requests 직접 호출)으로 구현합니다.
## 기본 정보
- **Base URL**: `http://sooinpharm.co.kr`
- **인코딩**: EUC-KR (한글 파라미터 인코딩 시 주의)
- **거래처 코드**: `50911` (청춘약국)
- **세션 관리**: 쿠키 기반 (ASP 세션)
---
## 1. 로그인
### 로그인 페이지
- **URL**: `/Homepage/intro.asp`
- **Method**: POST (JavaScript 함수 `chkLogin()` 호출)
### 필드
| 필드명 | 설명 | 예시 |
|--------|------|------|
| tx_id | 아이디 | thug0bin |
| tx_pw | 비밀번호 | @Trajet6640 |
### 인증 쿠키
로그인 성공 시 ASP 세션 쿠키가 발급됨:
- `ASPSESSIONID*` (세션 ID)
### 로그인 성공 확인
- 로그인 후 페이지에 "로그아웃" 링크 존재 여부로 확인
- 로그인 후 자동으로 `/Service/Order/Order.asp`로 리다이렉트
---
## 2. 제품 검색 API
### URL
```
GET /Service/Order/Order.asp
```
### 파라미터
| 파라미터 | 필수 | 설명 | 값 예시 |
|----------|------|------|---------|
| so | N | 제품분류 | 0=전체, 1=전문, 2=일반 |
| so2 | N | 주문분류 | 0=전체, 1=다빈도, 2=관심, 3=재주문 |
| so3 | N | 검색타입 | **1=제품명, 2=KD코드, 3=표준코드** |
| tx_maker | N | 제조사 | 한독 |
| tx_physic | N | 검색어 | 073100220 (KD코드) |
| tx_ven | Y | 거래처코드 | 50911 |
| currVenNm | Y | 약국명 | 청춘약국 (URL인코딩) |
| sDate | N | 시작일 | 20260306 |
| eDate | N | 종료일 | 20260306 |
| sa | N | 정렬 | phy=제품명순, ven=제조사순 |
| Page | N | 페이지번호 | 1 |
| tx_StockLoc | N | 재고위치 | '00001' |
| df | N | 기간필터 | t=3개월 |
### KD코드 검색 예시 URL
```
/Service/Order/Order.asp?so=0&so2=0&so3=2&tx_physic=073100220&tx_ven=50911&currVenNm=%EC%B2%AD%EC%B6%98%EC%95%BD%EA%B5%AD&sDate=20260306&eDate=20260306&df=t
```
### 응답 (HTML)
HTML 테이블 형식으로 반환. BeautifulSoup로 파싱 필요.
#### 테이블 구조
```html
<tr class="ln_physic">
<td>073100220</td> <!-- KD코드 -->
<td>한국오가논</td> <!-- 제조사 -->
<td>
<a href="./PhysicInfo.asp?pc=32495&...">
(오가논)코자정 50mg(PTP)
</a>
</td> <!-- 제품명 (pc=내부코드) -->
<td>30T</td> <!-- 규격 -->
<td>보험전문</td> <!-- 구분 -->
<td>14,220</td> <!-- 단가 -->
<td>238</td> <!-- 재고 -->
<td>
<input name="qty_0"> <!-- 수량입력 -->
<input type="hidden" name="pc_0" value="32495"> <!-- 내부코드 -->
<input type="hidden" name="stock_0" value="238">
<input type="hidden" name="price_0" value="14220">
</td>
</tr>
```
#### 핵심 필드 추출
- **KD코드**: 첫 번째 td
- **제조사**: 두 번째 td
- **제품명**: 세 번째 td의 a 태그 텍스트
- **내부코드(pc)**: a 태그 href에서 `pc=xxxxx` 추출
- **규격**: 네 번째 td
- **단가**: 여섯 번째 td (콤마 제거 후 int)
- **재고**: 일곱 번째 td
---
## 3. 장바구니 추가 API
### URL
```
POST /Service/Order/BagOrder.asp
```
### Content-Type
```
application/x-www-form-urlencoded
```
### 파라미터 (각 제품당)
| 파라미터 | 설명 | 예시 |
|----------|------|------|
| qty_N | 수량 | 1 |
| pc_N | 내부 제품코드 | 32495 |
| stock_N | 현재 재고 | 238 |
| saleqty_N | 판매수량 | 0 |
| price_N | 단가 | 14220 |
| soldout_N | 품절여부 | N |
| ordunitqty_N | 주문단위수량 | 1 |
| bidqty_N | 입찰수량 | 0 |
| outqty_N | 출고수량 | 0 |
| overqty_N | 초과수량 | 0 |
| manage_N | 관리여부 | N |
| prodno_N | 제품번호 | (빈값) |
| termdt_N | 종료일자 | (빈값) |
> N은 0부터 시작하는 행 인덱스
### 요청 예시
```
qty_0=1&pc_0=32495&stock_0=238&saleqty_0=0&price_0=14220&soldout_0=N&ordunitqty_0=1&bidqty_0=0&outqty_0=0&overqty_0=0&manage_0=N&prodno_0=&termdt_0=
```
### 응답
HTML (장바구니 iframe 내용)
---
## 4. 장바구니 비우기 API
### URL
```
GET /Service/Order/BagOrder.asp?kind=del&currVenCd=50911&currMkind=&currRealVenCd=
```
### 파라미터
| 파라미터 | 설명 | 값 |
|----------|------|-----|
| kind | 동작 | del |
| currVenCd | 거래처코드 | 50911 |
| currMkind | 종류 | (빈값) |
| currRealVenCd | 실제거래처코드 | (빈값) |
---
## 5. 장바구니 조회 API
### URL
```
GET /Service/Order/BagOrder.asp?currVenCd=50911
```
### 응답 (HTML)
```html
<table class="tbl_list">
<tr>
<td>건별취소</td>
<td>제품명</td>
<td>수량</td>
<td>금액</td>
</tr>
<tr>
<td><a href="...">X</a></td>
<td>(오가논)코자정 50mg(PTP)</td>
<td>1</td>
<td>14,220</td>
</tr>
</table>
<div>
<dt>주문품목</dt><dd>1개</dd>
<dt>주문금액</dt><dd>14,220원</dd>
</div>
```
---
## 6. 주문 전송 API
### URL (추정)
```
POST /Service/Order/BagOrder.asp
```
### 파라미터
| 파라미터 | 설명 |
|----------|------|
| kind | order (추정) |
| memo | 주문메모 |
| currVenCd | 거래처코드 |
> 실제 주문 전송은 iframe 내 버튼 클릭으로 수행됨
> 정확한 API 파라미터는 추가 분석 필요
---
## 7. 제품 상세 정보 API
### URL
```
GET /Service/Order/PhysicInfo.asp
```
### 파라미터
| 파라미터 | 설명 | 예시 |
|----------|------|------|
| pc | 내부제품코드 | 32495 |
| ln | 행번호 | 0 |
| currVenCd | 거래처코드 | 50911 |
| currLoc | 재고위치 | '00001' |
---
## 구현 전략
### 지오영 API 패턴 적용
1. **Playwright 로그인**
- 초기 로그인만 Playwright 사용
- 쿠키 획득 후 requests 세션에 복사
- 세션 30분 유효 (재로그인 필요 시 자동 갱신)
2. **requests 직접 호출**
- 검색: GET /Service/Order/Order.asp
- 장바구니 추가: POST /Service/Order/BagOrder.asp
- 장바구니 비우기: GET /Service/Order/BagOrder.asp?kind=del
- 장바구니 조회: GET /Service/Order/BagOrder.asp
3. **HTML 파싱**
- BeautifulSoup 사용
- 테이블 행에서 제품 정보 추출
- 내부코드(pc) 추출 (장바구니 추가용)
### 예상 성능
- 기존 Playwright: ~30초/주문
- requests 직접 호출: **~1초/주문**
---
## 주의사항
1. **EUC-KR 인코딩**
- 한글 파라미터는 EUC-KR로 인코딩
- `urllib.parse.quote(text.encode('euc-kr'))`
2. **세션 관리**
- ASP 세션 쿠키 유지 필수
- 장시간 미사용 시 세션 만료
3. **동시 접속**
- 동일 계정 동시 접속 시 세션 충돌 가능
4. **재고 실시간성**
- 검색 시점의 재고 정보
- 주문 전 재고 재확인 권장
---
## 작성일
- 2026-03-06
- 리버스 엔지니어링 by Claude

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""백제약품 주문 원장 페이지 분석"""
import asyncio
import json
import os
from dotenv import load_dotenv
load_dotenv()
async def analyze_order_ledger():
from playwright.async_api import async_playwright
username = os.getenv('BAEKJE_USER_ID')
password = os.getenv('BAEKJE_PASSWORD')
print(f'Username: {username}')
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context()
page = await context.new_page()
# 로그인 페이지
await page.goto('https://ibjp.co.kr/dist/login', timeout=15000)
await page.wait_for_load_state('networkidle', timeout=10000)
# 로그인 폼 입력
inputs = await page.locator('input[type="text"], input[type="password"]').all()
if len(inputs) >= 2:
await inputs[0].fill(username)
await inputs[1].fill(password)
# 로그인 버튼 클릭
buttons = await page.locator('button').all()
for btn in buttons:
text = await btn.text_content()
if '로그인' in (text or ''):
await btn.click()
break
# 로그인 완료 대기
try:
await page.wait_for_url('**/comOrd**', timeout=15000)
print('Login successful, redirected to comOrd')
except Exception as e:
print(f'URL wait failed: {e}')
await asyncio.sleep(3)
print(f'Current URL: {page.url}')
# 주문 원장 페이지로 이동
await page.goto('https://ibjp.co.kr/dist/ordLedger', timeout=15000)
await page.wait_for_load_state('networkidle', timeout=15000)
print(f'Order Ledger URL: {page.url}')
# 페이지 HTML 저장
html = await page.content()
with open('ordLedger_page.html', 'w', encoding='utf-8') as f:
f.write(html)
print('Page HTML saved to ordLedger_page.html')
# 스크린샷 저장
await page.screenshot(path='ordLedger_screenshot.png', full_page=True)
print('Screenshot saved')
# 테이블 데이터 분석
tables = await page.locator('table').all()
print(f'Found {len(tables)} tables')
for i, table in enumerate(tables):
headers = await table.locator('th').all()
header_texts = [await h.text_content() for h in headers]
print(f'Table {i} headers: {header_texts}')
# 페이지 텍스트 출력 (분석용)
body_text = await page.locator('body').text_content()
print('\n=== Page Text Preview ===')
print(body_text[:3000] if body_text else 'No text')
await asyncio.sleep(30) # 페이지 확인 시간
await browser.close()
if __name__ == '__main__':
asyncio.run(analyze_order_ledger())

16
backend/analyze_bag.py Normal file
View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
s = SooinSession()
s.login()
# Bag.asp HTML 가져오기
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
# 파일로 저장
with open('bag_page.html', 'w', encoding='utf-8') as f:
f.write(resp.text)
print('bag_page.html 저장됨')
print(f'응답 길이: {len(resp.text)}')

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""지오영 API 엔드포인트 분석 - 간단 버전"""
import asyncio
from playwright.async_api import async_playwright
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 모든 요청 로깅
all_requests = []
def log_request(request):
all_requests.append({
'url': request.url,
'method': request.method,
'data': request.post_data
})
page.on('request', log_request)
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지 HTML 분석
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_load_state('networkidle')
# JavaScript에서 API 엔드포인트 찾기
js_content = await page.content()
await browser.close()
# POST 요청만 필터
print("="*60)
print("POST 요청들:")
print("="*60)
for r in all_requests:
if r['method'] == 'POST':
print(f"URL: {r['url']}")
if r['data']:
print(f"Data: {r['data'][:300]}")
print()
# HTML에서 API 힌트 찾기
print("="*60)
print("HTML에서 발견된 API 관련 패턴:")
print("="*60)
import re
# ajax, fetch, url 패턴 찾기
patterns = [
r'url:\s*[\'"]([^"\']+)[\'"]',
r'action=[\'"]([^"\']+)[\'"]',
r'\.post\([\'"]([^"\']+)[\'"]',
r'\.get\([\'"]([^"\']+)[\'"]',
r'fetch\([\'"]([^"\']+)[\'"]',
]
found_urls = set()
for pattern in patterns:
matches = re.findall(pattern, js_content)
for m in matches:
if 'Order' in m or 'Cart' in m or 'Add' in m or 'Product' in m:
found_urls.add(m)
for url in sorted(found_urls):
print(url)
if __name__ == "__main__":
asyncio.run(analyze())

File diff suppressed because it is too large Load Diff

447
backend/baekje_api.py Normal file
View File

@@ -0,0 +1,447 @@
# -*- coding: utf-8 -*-
"""
백제약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import time
import logging
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import BaekjeSession
logger = logging.getLogger(__name__)
# Blueprint 생성
baekje_bp = Blueprint('baekje', __name__, url_prefix='/api/baekje')
# ========== 세션 관리 ==========
_baekje_session = None
_init_started = False
def get_baekje_session():
global _baekje_session
if _baekje_session is None:
_baekje_session = BaekjeSession()
return _baekje_session
def init_baekje_session():
"""앱 시작 시 백그라운드에서 로그인 시작"""
global _init_started
if _init_started:
return
_init_started = True
session = get_baekje_session()
# 저장된 토큰이 있으면 즉시 사용 가능
if session._logged_in:
logger.info(f"백제약품: 저장된 토큰 사용 중")
return
# 백그라운드 로그인 시작
session.start_background_login()
logger.info(f"백제약품: 백그라운드 로그인 시작됨")
# 모듈 로드 시 자동 시작
try:
init_baekje_session()
except Exception as e:
logger.warning(f"백제약품 초기화 오류: {e}")
def search_baekje_stock(keyword: str):
"""백제약품 재고 검색"""
try:
session = get_baekje_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'count': result['total'],
'items': result['items']
}
else:
return result
except Exception as e:
logger.error(f"백제약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@baekje_bp.route('/stock', methods=['GET'])
def api_baekje_stock():
"""
백제약품 재고 조회 API
GET /api/baekje/stock?kd_code=672300240
GET /api/baekje/stock?keyword=타이레놀
"""
kd_code = flask_request.args.get('kd_code', '').strip()
keyword = flask_request.args.get('keyword', '').strip()
search_term = kd_code or keyword
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_baekje_stock(search_term)
return jsonify(result)
except Exception as e:
logger.error(f"백제약품 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@baekje_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_baekje_session()
return jsonify({
'success': True,
'wholesaler': 'baekje',
'name': '백제약품',
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_timeout': session.SESSION_TIMEOUT
})
@baekje_bp.route('/login', methods=['POST'])
def api_login():
"""수동 로그인"""
session = get_baekje_session()
success = session.login()
return jsonify({
'success': success,
'message': '로그인 성공' if success else '로그인 실패'
})
@baekje_bp.route('/cart', methods=['GET'])
def api_get_cart():
"""장바구니 조회"""
session = get_baekje_session()
result = session.get_cart()
return jsonify(result)
@baekje_bp.route('/cart', methods=['POST'])
def api_add_to_cart():
"""
장바구니 추가
POST /api/baekje/cart
{
"product_code": "672300240",
"quantity": 2
}
"""
data = flask_request.get_json() or {}
product_code = data.get('product_code', '').strip()
quantity = int(data.get('quantity', 1))
if not product_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'product_code 필요'
}), 400
session = get_baekje_session()
result = session.add_to_cart(product_code, quantity)
return jsonify(result)
@baekje_bp.route('/order', methods=['POST'])
def api_submit_order():
"""
주문 등록
POST /api/baekje/order
{
"memo": "긴급 요청"
}
"""
data = flask_request.get_json() or {}
memo = data.get('memo', '')
session = get_baekje_session()
result = session.submit_order(memo)
return jsonify(result)
# ========== 프론트엔드 통합용 ==========
@baekje_bp.route('/search-for-order', methods=['POST'])
def api_search_for_order():
"""
발주용 재고 검색 (프론트엔드 통합용)
POST /api/baekje/search-for-order
{
"kd_code": "672300240",
"product_name": "타이레놀",
"specification": "500T"
}
"""
data = flask_request.get_json() or {}
kd_code = data.get('kd_code', '').strip()
product_name = data.get('product_name', '').strip()
specification = data.get('specification', '').strip()
search_term = kd_code or product_name
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM'
}), 400
result = search_baekje_stock(search_term)
if result.get('success') and specification:
# 규격 필터링
filtered = [
item for item in result.get('items', [])
if specification.lower() in item.get('spec', '').lower()
]
result['items'] = filtered
result['count'] = len(filtered)
return jsonify(result)
# ========== 잔고 조회 ==========
@baekje_bp.route('/balance', methods=['GET'])
def api_get_balance():
"""
잔고액 조회
GET /api/baekje/balance
GET /api/baekje/balance?year=2026
Returns:
{
"success": true,
"balance": 14193234,
"monthly": [
{"month": "2026-03", "sales": 6935133, "balance": 14193234, ...},
{"month": "2026-02", "sales": 18600692, "balance": 7258101, ...}
]
}
"""
year = flask_request.args.get('year', '').strip()
session = get_baekje_session()
result = session.get_balance(year if year else None)
return jsonify(result)
@baekje_bp.route('/orders/summary-by-kd', methods=['GET'])
def api_baekje_orders_by_kd():
"""
백제약품 주문량 KD코드별 집계 API
GET /api/baekje/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
Returns:
{
"success": true,
"order_count": 4,
"by_kd_code": {
"670400830": {
"product_name": "레바미피드정",
"spec": "100T",
"boxes": 2,
"units": 200
}
},
"total_products": 15
}
"""
import re
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
start_date = flask_request.args.get('start_date', today).strip()
end_date = flask_request.args.get('end_date', today).strip()
def parse_spec(spec: str, product_name: str = '') -> int:
"""
규격에서 수량 추출 (30T → 30, 100C → 100)
"""
combined = f"{spec} {product_name}"
# D(도즈) 단위는 박스 단위로 계산 (140D → 1)
if re.search(r'\d+\s*D\b', combined, re.IGNORECASE):
return 1
# T/C/P 단위가 붙은 숫자 추출 (예: 14T, 100C, 30P)
qty_match = re.search(r'(\d+)\s*[TCP]\b', combined, re.IGNORECASE)
if qty_match:
return int(qty_match.group(1))
# 없으면 spec의 첫 번째 숫자
if spec:
num_match = re.search(r'(\d+)', spec)
if num_match:
val = int(num_match.group(1))
# mg, ml 같은 용량 단위면 수량 1로 처리
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
return 1
return val
return 1
try:
session = get_baekje_session()
# 주문 목록 + 상세를 한 번에 조회 (include_details=True)
# 접수 상태(확정 전)도 포함됨!
orders_result = session.get_order_list(start_date, end_date, include_details=True)
if not orders_result.get('success'):
return jsonify({
'success': False,
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
'by_kd_code': {},
'order_count': 0
})
orders = orders_result.get('orders', [])
if not orders:
return jsonify({
'success': True,
'order_count': 0,
'period': {'start': start_date, 'end': end_date},
'by_kd_code': {},
'total_products': 0,
'pending_count': 0,
'approved_count': 0
})
# KD코드별 집계 (items가 이미 각 order에 포함됨)
kd_summary = {}
for order in orders:
for item in order.get('items', []):
# 취소 상태 제외
status = item.get('status', '').strip()
if '취소' in status or '삭제' in status:
continue
# 백제는 kd_code가 insurance_code(BOHUM_CD)에 있음
kd_code = item.get('kd_code', '') or item.get('insurance_code', '')
if not kd_code:
continue
product_name = item.get('product_name', '')
spec = item.get('spec', '')
quantity = item.get('quantity', 0) or item.get('order_qty', 0)
per_unit = parse_spec(spec, product_name)
total_units = quantity * per_unit
if kd_code not in kd_summary:
kd_summary[kd_code] = {
'product_name': product_name,
'spec': spec,
'boxes': 0,
'units': 0
}
kd_summary[kd_code]['boxes'] += quantity
kd_summary[kd_code]['units'] += total_units
pending_count = orders_result.get('pending_count', 0)
approved_count = orders_result.get('approved_count', 0)
logger.info(f"백제 주문량 집계: {start_date}~{end_date}, {len(orders)}건 (접수:{pending_count}, 승인:{approved_count}), {len(kd_summary)}개 품목")
return jsonify({
'success': True,
'order_count': len(orders),
'pending_count': pending_count, # 접수 상태 (확정 전)
'approved_count': approved_count, # 승인 상태 (확정됨)
'period': {'start': start_date, 'end': end_date},
'by_kd_code': kd_summary,
'total_products': len(kd_summary)
})
except Exception as e:
logger.error(f"백제 주문량 집계 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e),
'by_kd_code': {},
'order_count': 0
}), 500
@baekje_bp.route('/monthly-sales', methods=['GET'])
def api_get_monthly_sales():
"""
월간 매출(주문) 합계 조회
GET /api/baekje/monthly-sales?year=2026&month=3
Returns:
{
"success": true,
"total_amount": 7305877, // 월간 매출 합계
"total_returns": 0, // 월간 반품 합계
"net_amount": 7305877, // 순매출 (매출 - 반품)
"total_paid": 0, // 월간 입금 합계
"ending_balance": 14563978, // 월말 잔액
"prev_balance": 14565453, // 전월이월금
"from_date": "2026-03-01",
"to_date": "2026-03-31",
"rotate_days": 58.4 // 회전일수
}
"""
from datetime import datetime
year = flask_request.args.get('year', '').strip()
month = flask_request.args.get('month', '').strip()
# 기본값: 현재 연월
now = datetime.now()
if not year:
year = now.year
else:
year = int(year)
if not month:
month = now.month
else:
month = int(month)
session = get_baekje_session()
result = session.get_monthly_sales(year, month)
return jsonify(result)

150
backend/bag_page.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge"/>
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/Reset.css" type="text/css" media="screen" />
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/Bag.css?v=260116" type="text/css" media="screen" />
<link rel="stylesheet" href="http://sooinpharm.co.kr/Common/Css/jquery-ui.css" type="text/css" />
<title>수인약품(주) :: 장바구니</title>
</head>
<body oncontextmenu="return false" >
<input type="hidden" id="domainNm" name="domainNm" value="sooinpharm.co.kr" />
<input type="hidden" id="aggqty" value="N">
<input type='hidden' id='hardcoding' value='sooinpharm'>
<input type='hidden' id='bidding' value=''>
<input type='hidden' id='gumaeKind' value='U'>
<input type="hidden" id="min_order_qty" value=""><!--최소주문수량-->
<input type="hidden" id="BigWideFlag" value="">
<input type="hidden" id="DayOrdAmt" value="0">
<input type='hidden' id='baekjestockcd' value=''>
<div id="msg_order" style="margin: 0px 0 2px 0px;padding: 0px 7px 2px 0;background-position-y: 50%;position:relative;">
<div style="padding-left: 19px;line-height:13px;min-height:37px;display:table;"><span style="display: table-cell;text-align: left;vertical-align: middle;">17시 이후 주문은 다음근무일로 주문됩니다</span></div>
</div>
<h1 id="bag_title">장바구니</h1>
<div id="bag"
style='height:518px;'
>
<form name="frmBag" id="frmBag" method="post" action="./OrderEnd.asp" autocomplete=off>
<fieldset class="info">
<legend>주문관련 버튼 및 메모</legend>
<ul class="btn">
<li><a href="./BagOrder.asp?kind=del&amp;currVenCd=50911&amp;currMkind=&amp;currRealVenCd=" title="장바구니 비우기" id="btn_cancel_order">장바구니 비우기</a></li>
<input type="hidden" name="hostuser" id="hostuser" />
<li><input type="image" src="http://sooinpharm.co.kr/Images/Btn/btn_order_v2.gif" alt="주문전송" title="주문전송" /></li>
<input type='hidden' id='btnState' value='true'>
</ul>
<p class="memo">
<label for="tx_memo" >주문전송 메모</label><input type="text" name="tx_memo" id="tx_memo" maxlength="150" class="setInput_h20" title="메모" value="" />
</p>
<input type="hidden" name="pDate" id="pDate" value=""/>
</fieldset>
<fieldset class="list">
<legend>장바구니</legend>
<table class="bag_list" summary="스크롤링을 위해 고정시킬 테이블 제목">
<caption>장바구니 리스트</caption>
<colgroup>
<col width="30" />
<col width="*" />
<col width="35" />
<col width="77" />
</colgroup>
<thead>
<tr>
<th scope="col" class="title1 first">건별취소</th>
<th scope="col" class="title2">제품명</th>
<th scope="col" class="title3">수량</th>
<th scope="col" class="title4">금액</th>
</tr>
</thead>

View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""지오영 API 엔드포인트 분석"""
import asyncio
from playwright.async_api import async_playwright
async def capture_network():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 네트워크 요청 캡처
requests_log = []
def log_request(request):
if 'geoweb' in request.url:
requests_log.append({
'url': request.url,
'method': request.method,
'post_data': request.post_data
})
page.on('request', log_request)
# 로그인
print("로그인 중...")
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
print("로그인 완료")
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_load_state('networkidle')
# 검색
print("검색 중...")
search_input = await page.query_selector('input#srchText, input[name="srchText"]')
if search_input:
await search_input.fill('643104281')
# 검색 버튼
search_btn = await page.query_selector('button:has-text("검색"), input[type="submit"]')
if search_btn:
await search_btn.click()
else:
await page.keyboard.press('Enter')
await page.wait_for_timeout(3000)
# 제품 행 클릭
print("제품 선택 중...")
rows = await page.query_selector_all('table tbody tr')
if rows:
await rows[0].click()
await page.wait_for_timeout(2000)
# 담기 버튼
print("담기 버튼 클릭...")
add_btn = await page.query_selector('button:has-text("담기")')
if add_btn:
await add_btn.click()
await page.wait_for_timeout(3000)
await browser.close()
print("\n" + "="*60)
print("캡처된 요청들:")
print("="*60)
for r in requests_log:
if r['method'] == 'POST' or 'cart' in r['url'].lower() or 'order' in r['url'].lower():
print(f"\n[{r['method']}] {r['url']}")
if r['post_data']:
print(f" Data: {r['post_data'][:200]}")
if __name__ == "__main__":
asyncio.run(capture_network())

82
backend/capture_order.py Normal file
View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""
네트워크 캡처용 - 약사님이 직접 주문 버튼 클릭
"""
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
from playwright.sync_api import sync_playwright
import time
s = SooinSession()
print('로그인...')
s.login()
# 장바구니에 코자정 담기
print('\n코자정 검색...')
result = s.search_products('코자정 50mg PTP')
product = None
for item in result.get('items', []):
if 'PTP' in item['name']:
product = item
break
if product:
print(f"제품: {product['name']} - {product['price']:,}")
s.add_to_cart(product['internal_code'], qty=1,
price=product['price'], stock=product['stock'])
print('장바구니에 담음!')
else:
print('제품 못 찾음')
# 장바구니 확인
cart = s.get_cart()
print(f"\n장바구니: {cart['total_items']}개, {cart['total_amount']:,}")
print('\n' + '='*50)
print('브라우저 열기 + 네트워크 캡처 시작')
print('='*50)
with sync_playwright() as p:
browser = p.chromium.launch(headless=False) # 브라우저 보임
context = browser.new_context()
# 세션 쿠키 복사
for c in s.session.cookies:
context.add_cookies([{
'name': c.name,
'value': c.value,
'domain': c.domain or 'sooinpharm.co.kr',
'path': c.path or '/'
}])
page = context.new_page()
# 네트워크 요청 캡처
def on_request(request):
if 'BagOrder' in request.url and request.method == 'POST':
print('\n' + '='*50)
print('🎯 POST 요청 캡처!')
print('='*50)
print(f'URL: {request.url}')
print(f'\nPOST 데이터:')
data = request.post_data or ''
# 파라미터별로 출력
for param in data.split('&'):
if '=' in param:
key, val = param.split('=', 1)
print(f' {key}: {val[:50]}')
print('='*50)
page.on('request', on_request)
# 주문 페이지로 이동
page.goto('http://sooinpharm.co.kr/Service/Order/Order.asp')
print('\n✅ 브라우저 준비 완료!')
print('👆 주문전송 버튼을 클릭해주세요!')
print('\n(Enter 누르면 브라우저 닫힘)')
input()
browser.close()
print('\n완료!')

79
backend/capture_order2.py Normal file
View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""
네트워크 캡처 v2 - 새로고침 후에도 캡처
"""
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
from playwright.sync_api import sync_playwright
import time
s = SooinSession()
print('로그인...')
s.login()
# 먼저 장바구니 비우기
s.clear_cart()
# 코자정 담기
print('코자정 검색...')
result = s.search_products('코자정')
product = result['items'][0] if result.get('items') else None
if product:
print(f"제품: {product['name']} - {product['price']:,}")
s.add_to_cart(product['internal_code'], qty=1,
price=product['price'], stock=product['stock'])
print('장바구니에 담음!')
cart = s.get_cart()
print(f"장바구니: {cart['total_items']}")
print('\n브라우저 열기...')
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
# 쿠키 복사
for c in s.session.cookies:
context.add_cookies([{
'name': c.name,
'value': c.value,
'domain': c.domain or 'sooinpharm.co.kr',
'path': c.path or '/'
}])
page = context.new_page()
# 모든 요청 캡처 (지속적)
captured = []
def capture(request):
if 'BagOrder' in request.url and request.method == 'POST':
data = request.post_data or ''
captured.append(data)
print('\n' + '='*60)
print('🎯 POST 캡처!')
print('='*60)
for param in data.split('&')[:30]: # 주요 파라미터만
if '=' in param:
k, v = param.split('=', 1)
if v: # 값이 있는 것만
print(f' {k}: {v[:60]}')
print('='*60)
context.on('request', capture) # context 레벨에서 캡처
page.goto('http://sooinpharm.co.kr/Service/Order/Order.asp')
print('\n✅ 준비 완료!')
print('👆 F5로 새로고침 후 주문전송 버튼 클릭!')
print('\n(Enter 누르면 종료)')
input()
# 캡처된 데이터 파일로 저장
if captured:
with open('captured_post.txt', 'w', encoding='utf-8') as f:
f.write(captured[0])
print('\n📁 captured_post.txt 저장됨')
browser.close()

View File

@@ -1,70 +0,0 @@
"""
바코드가 있는 제품 샘플 조회
"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from db.dbsetup import DatabaseManager
from sqlalchemy import text
def check_barcode_samples():
"""바코드가 있는 제품 샘플 조회"""
db_manager = DatabaseManager()
try:
session = db_manager.get_session('PM_PRES')
# 바코드가 있는 제품 샘플 조회
query = text("""
SELECT TOP 10
S.DrugCode,
S.BARCODE,
G.GoodsName,
S.SL_NM_cost_a as price
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.BARCODE IS NOT NULL AND S.BARCODE != ''
ORDER BY S.SL_NO_order DESC
""")
results = session.execute(query).fetchall()
print('=' * 100)
print('바코드가 있는 제품 샘플 (최근 10개)')
print('=' * 100)
for r in results:
barcode = r.BARCODE if r.BARCODE else '(없음)'
goods_name = r.GoodsName if r.GoodsName else '(약품명 없음)'
print(f'DrugCode: {r.DrugCode:20} | BARCODE: {barcode:20} | 제품명: {goods_name}')
print('=' * 100)
# 바코드 통계
stats_query = text("""
SELECT
COUNT(DISTINCT DrugCode) as total_drugs,
COUNT(DISTINCT BARCODE) as total_barcodes,
SUM(CASE WHEN BARCODE IS NOT NULL AND BARCODE != '' THEN 1 ELSE 0 END) as with_barcode,
COUNT(*) as total_sales
FROM SALE_SUB
""")
stats = session.execute(stats_query).fetchone()
print('\n바코드 통계')
print('=' * 100)
print(f'전체 제품 수 (DrugCode): {stats.total_drugs:,}')
print(f'바코드 종류 수: {stats.total_barcodes:,}')
print(f'바코드가 있는 판매 건수: {stats.with_barcode:,}')
print(f'전체 판매 건수: {stats.total_sales:,}')
print(f'바코드 보유율: {stats.with_barcode / stats.total_sales * 100:.2f}%')
print('=' * 100)
except Exception as e:
print(f"오류 발생: {e}")
finally:
db_manager.close_all()
if __name__ == '__main__':
check_barcode_samples()

View File

@@ -1,83 +0,0 @@
"""
특정 거래의 SALE_SUB 데이터 확인
"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from db.dbsetup import DatabaseManager
from sqlalchemy import text
def check_sale_sub_data(transaction_id):
"""특정 거래의 판매 상세 데이터 확인"""
db_manager = DatabaseManager()
try:
session = db_manager.get_session('PM_PRES')
# SALE_SUB 모든 컬럼 조회
query = text("""
SELECT *
FROM SALE_SUB
WHERE SL_NO_order = :transaction_id
""")
result = session.execute(query, {'transaction_id': transaction_id}).fetchone()
if result:
print("=" * 80)
print(f"거래번호 {transaction_id}의 SALE_SUB 데이터")
print("=" * 80)
# 모든 컬럼 출력
for key in result._mapping.keys():
value = result._mapping[key]
print(f"{key:30} = {value}")
print("=" * 80)
else:
print(f"거래번호 {transaction_id}를 찾을 수 없습니다.")
except Exception as e:
print(f"오류 발생: {e}")
finally:
db_manager.close_all()
def check_sale_main_data(transaction_id):
"""특정 거래의 SALE_MAIN 데이터 확인"""
db_manager = DatabaseManager()
try:
session = db_manager.get_session('PM_PRES')
query = text("""
SELECT *
FROM SALE_MAIN
WHERE SL_NO_order = :transaction_id
""")
result = session.execute(query, {'transaction_id': transaction_id}).fetchone()
if result:
print("\n" + "=" * 80)
print(f"거래번호 {transaction_id}의 SALE_MAIN 데이터")
print("=" * 80)
for key in result._mapping.keys():
value = result._mapping[key]
print(f"{key:30} = {value}")
print("=" * 80)
else:
print(f"거래번호 {transaction_id}를 찾을 수 없습니다.")
except Exception as e:
print(f"오류 발생: {e}")
finally:
db_manager.close_all()
if __name__ == '__main__':
# 스크린샷의 거래번호
check_sale_sub_data('20260123000261')
check_sale_main_data('20260123000261')

View File

@@ -1,54 +0,0 @@
"""
SALE_MAIN 테이블 컬럼 확인 스크립트
"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from db.dbsetup import DatabaseManager
from sqlalchemy import text
def check_sale_table_columns(table_name):
"""테이블의 모든 컬럼 확인"""
db_manager = DatabaseManager()
try:
session = db_manager.get_session('PM_PRES')
# SQL Server에서 테이블 컬럼 정보 조회
query = text(f"""
SELECT
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = '{table_name}'
ORDER BY ORDINAL_POSITION
""")
columns = session.execute(query).fetchall()
print("=" * 80)
print(f"{table_name} 테이블 컬럼 목록")
print("=" * 80)
for col in columns:
nullable = "NULL" if col.IS_NULLABLE == 'YES' else "NOT NULL"
max_len = f"({col.CHARACTER_MAXIMUM_LENGTH})" if col.CHARACTER_MAXIMUM_LENGTH else ""
print(f"{col.COLUMN_NAME:30} {col.DATA_TYPE}{max_len:20} {nullable}")
print("=" * 80)
print(f"{len(columns)}개 컬럼")
print("=" * 80)
except Exception as e:
print(f"오류 발생: {e}")
finally:
db_manager.close_all()
if __name__ == '__main__':
check_sale_table_columns('SALE_MAIN')
print("\n\n")
check_sale_table_columns('SALE_SUB')

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
도매상 설정 중앙 관리
사용법:
from config import get_wholesalers, get_wholesaler
# 전체 도매상 목록
wholesalers = get_wholesalers()
# 특정 도매상 정보
geo = get_wholesaler('geoyoung')
print(geo['name']) # 지오영
print(geo['logo']) # /static/img/logo_geoyoung.ico
"""
import json
from pathlib import Path
_config = None
_config_path = Path(__file__).parent / 'wholesalers.json'
def _load_config():
global _config
if _config is None:
with open(_config_path, 'r', encoding='utf-8') as f:
_config = json.load(f)
return _config
def get_wholesalers():
"""전체 도매상 목록 반환 (순서대로)"""
config = _load_config()
order = config.get('order', [])
wholesalers = config.get('wholesalers', {})
return [wholesalers[key] for key in order if key in wholesalers]
def get_wholesaler(wholesaler_id: str):
"""특정 도매상 정보 반환"""
config = _load_config()
return config.get('wholesalers', {}).get(wholesaler_id)
def get_all_wholesalers_dict():
"""전체 도매상 딕셔너리 반환"""
config = _load_config()
return config.get('wholesalers', {})
def get_config():
"""전체 설정 반환"""
return _load_config()

View File

@@ -0,0 +1,65 @@
{
"wholesalers": {
"geoyoung": {
"id": "geoyoung",
"name": "지오영",
"shortName": "지오영",
"icon": "🏭",
"logo": "/static/img/logo_geoyoung.ico",
"color": "#06b6d4",
"gradient": "linear-gradient(135deg, #0891b2, #06b6d4)",
"bgColor": "rgba(6, 182, 212, 0.1)",
"api": {
"balance": "/api/geoyoung/balance",
"stock": "/api/geoyoung/stock",
"order": "/api/geoyoung/order"
},
"env": {
"userId": "GEOYOUNG_USER_ID",
"password": "GEOYOUNG_PASSWORD"
}
},
"sooin": {
"id": "sooin",
"name": "수인약품",
"shortName": "수인",
"icon": "💊",
"logo": "/static/img/logo_sooin.svg",
"color": "#a855f7",
"gradient": "linear-gradient(135deg, #7c3aed, #a855f7)",
"bgColor": "rgba(168, 85, 247, 0.1)",
"api": {
"balance": "/api/sooin/balance",
"stock": "/api/sooin/stock",
"order": "/api/sooin/order"
},
"env": {
"userId": "SOOIN_USER_ID",
"password": "SOOIN_PASSWORD",
"vendorCode": "SOOIN_VENDOR_CODE"
}
},
"baekje": {
"id": "baekje",
"name": "백제약품",
"shortName": "백제",
"icon": "💉",
"logo": "/static/img/logo_baekje.svg",
"color": "#f59e0b",
"gradient": "linear-gradient(135deg, #d97706, #f59e0b)",
"bgColor": "rgba(245, 158, 11, 0.1)",
"api": {
"balance": "/api/baekje/balance",
"stock": "/api/baekje/stock",
"order": "/api/baekje/order"
},
"env": {
"userId": "BAEKJE_USER_ID",
"password": "BAEKJE_PASSWORD"
}
}
},
"order": ["baekje", "geoyoung", "sooin"],
"version": "1.0.0",
"lastUpdated": "2026-03-06"
}

View File

@@ -0,0 +1,50 @@
import sqlite3
conn = sqlite3.connect('db/orders.db')
cur = conn.cursor()
# wholesaler_limits 테이블 생성
cur.execute('''
CREATE TABLE IF NOT EXISTS wholesaler_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wholesaler_id TEXT NOT NULL UNIQUE,
-- 한도 설정
monthly_limit INTEGER DEFAULT 0, -- 월 한도 (원)
warning_threshold REAL DEFAULT 0.9, -- 경고 임계값 (90%)
-- 우선순위
priority INTEGER DEFAULT 1, -- 1이 최우선
-- 상태
is_active INTEGER DEFAULT 1,
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''')
# 기본 데이터 삽입 (각 2000만원)
wholesalers = [
('geoyoung', 20000000, 0.9, 1),
('sooin', 20000000, 0.9, 2),
('baekje', 20000000, 0.9, 3),
]
for ws_id, limit, threshold, priority in wholesalers:
cur.execute('''
INSERT OR REPLACE INTO wholesaler_limits
(wholesaler_id, monthly_limit, warning_threshold, priority)
VALUES (?, ?, ?, ?)
''', (ws_id, limit, threshold, priority))
conn.commit()
# 확인
cur.execute('SELECT * FROM wholesaler_limits')
print('=== wholesaler_limits 생성 완료 ===')
for row in cur.fetchall():
print(row)
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
);

85
backend/download_js.py Normal file
View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""지오영 JS 파일 다운로드 및 분석"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def download_and_analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
# 세션 설정
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# JS 파일 다운로드
js_urls = [
'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1'
]
for url in js_urls:
print(f"\n{'='*60}")
print(f"분석: {url.split('/')[-1].split('?')[0]}")
print('='*60)
resp = session.get(url)
content = resp.text
# 장바구니/주문 관련 함수 찾기
patterns = [
(r'function\s+(fn\w*Cart\w*|add\w*Cart\w*|insert\w*Order\w*)\s*\([^)]*\)', 'function'),
(r'(fn\w*Cart\w*|add\w*Cart\w*)\s*=\s*function', 'var function'),
(r'url\s*:\s*["\']([^"\']*(?:Cart|Order|Add)[^"\']*)["\']', 'ajax url'),
(r'\$\.(?:ajax|post|get)\s*\(\s*["\']([^"\']+)["\']', 'ajax call'),
]
found = {}
for pattern, name in patterns:
matches = re.findall(pattern, content, re.IGNORECASE)
if matches:
for m in matches:
if m not in found:
found[m] = name
for item, ptype in found.items():
print(f"[{ptype}] {item}")
# InsertOrder 함수 찾기
if 'InsertOrder' in content or 'insertOrder' in content:
print("\n--- InsertOrder 함수 발견! ---")
# 해당 부분 추출
idx = content.lower().find('insertorder')
if idx > 0:
snippet = content[max(0, idx-100):idx+500]
print(snippet[:600])
# AddCart 패턴 찾기
add_patterns = re.findall(r'.{50}AddCart.{100}|.{50}addCart.{100}', content, re.IGNORECASE)
if add_patterns:
print("\n--- AddCart 관련 ---")
for p in add_patterns[:3]:
print(p)
# ajax 호출 상세
ajax_pattern = r'\$\.ajax\s*\(\s*\{[^}]{50,500}(Cart|Order)[^}]{0,200}\}'
ajax_matches = re.findall(ajax_pattern, content, re.IGNORECASE | re.DOTALL)
if ajax_matches:
print(f"\n--- AJAX 호출 {len(ajax_matches)}개 발견 ---")
if __name__ == "__main__":
asyncio.run(download_and_analyze())

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

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""AddCart 함수 전체 추출"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def extract():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# AddCart 함수 전체 찾기
# function AddCart(n,t,i){ ... }
start = content.find('function AddCart')
if start > 0:
# 중괄호 매칭으로 함수 끝 찾기
depth = 0
end = start
in_func = False
for i in range(start, min(start + 5000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end = i + 1
break
func_content = content[start:end]
print("="*60)
print("AddCart 함수 전체:")
print("="*60)
print(func_content)
# ajax 호출 찾기
ajax_match = re.search(r'\$\.ajax\s*\(\s*\{[^}]+\}', func_content, re.DOTALL)
if ajax_match:
print("\n" + "="*60)
print("AJAX 호출:")
print("="*60)
print(ajax_match.group())
# InsertOrder 함수도 찾기
start2 = content.find('function InsertOrder')
if start2 > 0:
depth = 0
end2 = start2
in_func = False
for i in range(start2, min(start2 + 3000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end2 = i + 1
break
print("\n" + "="*60)
print("InsertOrder 함수:")
print("="*60)
print(content[start2:end2][:1500])
if __name__ == "__main__":
asyncio.run(extract())

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""ProcessCart 함수 추출"""
import requests
import asyncio
from playwright.async_api import async_playwright
async def extract():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# ProcessCart 함수 찾기
start = content.find('function ProcessCart')
if start > 0:
depth = 0
end = start
in_func = False
for i in range(start, min(start + 5000, len(content))):
if content[i] == '{':
depth += 1
in_func = True
elif content[i] == '}':
depth -= 1
if in_func and depth == 0:
end = i + 1
break
func_content = content[start:end]
print("="*60)
print("ProcessCart 함수:")
print("="*60)
print(func_content)
else:
# 다른 패턴으로 찾기
print("ProcessCart를 변수로 찾기...")
start = content.find('ProcessCart=function')
if start > 0:
print(content[start:start+2000])
else:
# ajax 호출 찾기
import re
ajax_calls = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{100,1000}(Cart|Order)[^}]{0,500}\}', content, re.IGNORECASE | re.DOTALL)
print(f"\nAJAX 호출 {len(ajax_calls)}개 발견")
# url 패턴 찾기
urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content)
print("\n모든 URL:")
for url in set(urls):
if 'Cart' in url or 'Order' in url or 'Add' in url or 'Insert' in url:
print(f" {url}")
if __name__ == "__main__":
asyncio.run(extract())

90
backend/find_cart_js.py Normal file
View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""지오영 JavaScript에서 장바구니 추가 함수 찾기"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze_js():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 로그인
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(3000)
# 모든 스크립트 태그 내용 가져오기
scripts = await page.evaluate('''() => {
var result = [];
var scripts = document.querySelectorAll('script');
scripts.forEach(s => {
if (s.src) {
result.push({type: 'src', url: s.src});
}
if (s.textContent && s.textContent.length > 100) {
result.push({type: 'inline', content: s.textContent});
}
});
return result;
}''')
print(f"스크립트 {len(scripts)}개 발견")
# 장바구니 관련 함수 찾기
for s in scripts:
if s['type'] == 'inline':
content = s['content']
# 담기, Cart, Add 관련 찾기
if '담기' in content or 'AddCart' in content or 'addCart' in content or 'InsertOrder' in content:
print("\n" + "="*60)
print("장바구니 관련 스크립트 발견!")
print("="*60)
# 함수 정의 찾기
func_patterns = [
r'function\s+(\w*[Cc]art\w*)\s*\([^)]*\)\s*{[^}]+}',
r'function\s+(\w*[Aa]dd\w*)\s*\([^)]*\)\s*{[^}]+}',
r'(\w+)\s*=\s*function\s*\([^)]*\)\s*{[^}]*[Cc]art[^}]*}',
]
for pattern in func_patterns:
matches = re.findall(pattern, content, re.DOTALL)
for m in matches:
print(f"함수 발견: {m}")
# ajax 호출 찾기
ajax_pattern = r'\$\.ajax\s*\(\s*{[^}]+url[^}]+}'
ajax_matches = re.findall(ajax_pattern, content, re.DOTALL)
for m in ajax_matches:
if 'cart' in m.lower() or 'order' in m.lower() or 'add' in m.lower():
print(f"\nAJAX 호출:\n{m[:500]}")
# 일부 내용 출력
lines = content.split('\n')
for i, line in enumerate(lines):
if '담기' in line or 'addCart' in line.lower() or 'insertorder' in line.lower():
print(f"\n관련 라인 {i}:")
print('\n'.join(lines[max(0,i-3):min(len(lines),i+10)]))
# 외부 JS 파일 확인
print("\n" + "="*60)
print("외부 스크립트 파일:")
print("="*60)
for s in scripts:
if s['type'] == 'src':
print(s['url'])
await browser.close()
if __name__ == "__main__":
asyncio.run(analyze_js())

82
backend/find_frmsave.py Normal file
View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""frmSave 폼과 주문 저장 로직 찾기"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 네트워크 요청 캡처
requests_log = []
def log_req(req):
if req.method == 'POST':
requests_log.append({'url': req.url, 'data': req.post_data})
page.on('request', log_req)
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
# 메인 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(2000)
# 페이지 HTML에서 frmSave 폼 찾기
html = await page.content()
print("="*60)
print("frmSave 폼 찾기:")
print("="*60)
soup = BeautifulSoup(html, 'html.parser')
# 모든 form 찾기
forms = soup.find_all('form')
for form in forms:
form_id = form.get('id', '')
form_action = form.get('action', '')
print(f"폼: id={form_id}, action={form_action}")
if 'save' in form_id.lower() or 'order' in form_id.lower():
print(f" >>> 주문 관련 폼 발견!")
inputs = form.find_all('input')
for inp in inputs[:10]:
print(f" - {inp.get('name')}: {inp.get('value', '')[:30]}")
# 주문저장 버튼 찾기
print("\n" + "="*60)
print("주문저장 버튼:")
print("="*60)
buttons = soup.find_all(['button', 'input'], type=['button', 'submit'])
for btn in buttons:
text = btn.get_text(strip=True) or btn.get('value', '')
onclick = btn.get('onclick', '')
if '저장' in text or '주문' in text:
print(f"버튼: {text}")
print(f" onclick: {onclick[:100]}")
# JavaScript에서 폼 action 찾기
scripts = soup.find_all('script')
for script in scripts:
text = script.get_text() or ''
if 'frmSave' in text:
print("\n" + "="*60)
print("frmSave 관련 스크립트:")
print("="*60)
# frmSave 근처 코드 출력
idx = text.find('frmSave')
print(text[max(0,idx-100):idx+300])
await browser.close()
if __name__ == "__main__":
asyncio.run(analyze())

70
backend/find_order_api.py Normal file
View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""주문 확정 API 찾기"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def find_order_api():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# order.js 다운로드
resp = session.get('https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1')
content = resp.text
# InsertOrder, ConfirmOrder, SubmitOrder 등 찾기
print("="*60)
print("주문 관련 함수 찾기")
print("="*60)
# 함수 찾기
funcs = ['InsertOrder', 'ConfirmOrder', 'SubmitOrder', 'SaveOrder', 'ProcessOrder', 'DataOrder']
for func in funcs:
start = content.find(f'function {func}')
if start < 0:
start = content.find(f'{func}=function')
if start < 0:
start = content.find(f'{func}(')
if start > 0:
print(f"\n{func} 발견!")
# 함수 내용 출력
snippet = content[max(0, start-20):start+800]
print(snippet[:600])
# DataOrder URL 찾기
print("\n" + "="*60)
print("DataOrder 관련")
print("="*60)
dataorder_pattern = re.findall(r'.{30}DataOrder.{100}', content)
for p in dataorder_pattern[:5]:
print(p)
# 모든 ajax URL 찾기
print("\n" + "="*60)
print("주문 관련 URL")
print("="*60)
urls = re.findall(r'url\s*:\s*["\']([^"\']*(?:Order|Submit|Confirm|Save)[^"\']*)["\']', content, re.IGNORECASE)
for url in set(urls):
print(url)
if __name__ == "__main__":
asyncio.run(find_order_api())

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""주문 확정 API 찾기 - 전체 검색"""
import requests
import asyncio
from playwright.async_api import async_playwright
import re
async def analyze():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://gwn.geoweb.kr/Member/Login')
await page.fill('input[type="text"]', '7390')
await page.fill('input[type="password"]', 'trajet6640')
await page.click('button, input[type="submit"]')
await page.wait_for_load_state('networkidle')
cookies = await page.context.cookies()
await browser.close()
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
# 모든 JS 번들 다운로드
js_urls = [
'https://gwn.geoweb.kr/bundles/order_product_cart?v=JPwFQ8DWaNMW1VmbtWYKTJqxT-5255z351W5iZE1qew1',
'https://gwn.geoweb.kr/bundles/order?v=PGhSOAjQ9z6ruAJgJUFuhW9tGQSiJeX6ek-ky3E-tOk1',
'https://gwn.geoweb.kr/bundles/javascript?v=Tn_AqbA-PX_uu3d0zjfQOYS6NPSDLtOVqjW95a949Ow1'
]
all_content = ""
for url in js_urls:
resp = session.get(url)
all_content += resp.text + "\n"
print(f"총 JS 길이: {len(all_content)}")
# 모든 ajax POST URL 찾기
print("\n" + "="*60)
print("모든 POST URL:")
print("="*60)
# $.ajax 패턴
ajax_patterns = re.findall(r'\$\.ajax\s*\(\s*\{[^}]*url\s*:\s*["\']([^"\']+)["\'][^}]*type\s*:\s*["\']POST["\']', all_content, re.IGNORECASE | re.DOTALL)
ajax_patterns += re.findall(r'\$\.ajax\s*\(\s*\{[^}]*type\s*:\s*["\']POST["\'][^}]*url\s*:\s*["\']([^"\']+)["\']', all_content, re.IGNORECASE | re.DOTALL)
for url in set(ajax_patterns):
print(url)
# 주문저장, 저장 관련
print("\n" + "="*60)
print("저장/주문 관련 키워드:")
print("="*60)
keywords = ['주문저장', '저장', 'save', 'submit', 'confirm', 'order', 'insert']
for kw in keywords:
matches = re.findall(rf'.{{50}}{kw}.{{50}}', all_content, re.IGNORECASE)
if matches:
print(f"\n--- {kw} ---")
for m in matches[:3]:
print(m.replace('\n', ' ')[:100])
# 버튼 onclick 찾기
print("\n" + "="*60)
print("주문저장 버튼:")
print("="*60)
save_btn = re.findall(r'주문저장.{0,200}', all_content)
for s in save_btn[:5]:
print(s[:150])
if __name__ == "__main__":
asyncio.run(analyze())

BIN
backend/geo_cart_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

742
backend/geoyoung_api.py Normal file
View File

@@ -0,0 +1,742 @@
# -*- coding: utf-8 -*-
"""
지오영 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import re
import time
import logging
from flask import Blueprint, jsonify, request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import GeoYoungSession
logger = logging.getLogger(__name__)
# Blueprint 생성
geoyoung_bp = Blueprint('geoyoung', __name__, url_prefix='/api/geoyoung')
# ========== 세션 관리 ==========
_geo_session = None
def get_geo_session():
global _geo_session
if _geo_session is None:
_geo_session = GeoYoungSession()
return _geo_session
def search_geoyoung_stock(keyword: str, include_price: bool = True):
"""지오영 재고 검색 (동기, 단가 포함)"""
try:
session = get_geo_session()
# 새 API 사용 (단가 포함)
result = session.search_products(keyword, include_price=include_price)
if result.get('success'):
# 기존 형식으로 변환
items = [{
'insurance_code': item['code'],
'internal_code': item.get('internal_code'),
'manufacturer': item['manufacturer'],
'product_name': item['name'],
'specification': item['spec'],
'stock': item['stock'],
'price': item.get('price', 0), # 단가 추가!
'box_qty': item.get('box_qty'),
'case_qty': item.get('case_qty')
} for item in result['items']]
return {
'success': True,
'keyword': keyword,
'count': len(items),
'items': items
}
else:
return {'success': False, 'error': result.get('error'), 'message': '검색 실패'}
except Exception as e:
logger.error(f"지오영 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@geoyoung_bp.route('/stock', methods=['GET'])
def api_geoyoung_stock():
"""
지오영 재고 조회 API (빠름)
GET /api/geoyoung/stock?kd_code=670400830
GET /api/geoyoung/stock?keyword=레바미피드
"""
kd_code = request.args.get('kd_code', '').strip()
keyword = request.args.get('keyword', '').strip()
search_term = kd_code or keyword
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_geoyoung_stock(search_term)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/stock-by-name', methods=['GET'])
def api_geoyoung_stock_by_name():
"""
제품명에서 성분명 추출 후 지오영 검색
GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1정)
"""
product_name = request.args.get('product_name', '').strip()
if not product_name:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'product_name 파라미터가 필요합니다'
}), 400
# 성분명 추출
prefixes = ['휴니즈', '휴온스', '대웅', '한미', '종근당', '유한', '녹십자', '동아', '일동', '광동',
'삼성', '안국', '보령', '광동', '경동', '현대', '일양', '태극', '환인', '에스케이']
ingredient = product_name
for prefix in prefixes:
if ingredient.startswith(prefix):
ingredient = ingredient[len(prefix):]
break
match = re.match(r'^([가-힣a-zA-Z]+)', ingredient)
if match:
ingredient = match.group(1)
if ingredient.endswith(''):
ingredient = ingredient[:-1]
elif ingredient.endswith('캡슐'):
ingredient = ingredient[:-2]
if not ingredient:
ingredient = product_name[:10]
try:
result = search_geoyoung_stock(ingredient)
result['extracted_ingredient'] = ingredient
result['original_product_name'] = product_name
return jsonify(result)
except Exception as e:
logger.error(f"지오영 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_geo_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@geoyoung_bp.route('/cart', methods=['GET'])
def api_geoyoung_cart():
"""장바구니 조회 API"""
try:
session = get_geo_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@geoyoung_bp.route('/cart/clear', methods=['POST'])
def api_geoyoung_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_geo_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/cart/cancel', methods=['POST'])
def api_geoyoung_cart_cancel():
"""
장바구니 개별 항목 삭제 API (Hard delete)
POST /api/geoyoung/cart/cancel
{
"row_index": 0, // 또는
"product_code": "008709"
}
⚠️ 지오영은 완전 삭제됨 (복원 불가, 다시 추가해야 함)
"""
data = request.get_json() or {}
row_index = data.get('row_index')
product_code = data.get('product_code')
if row_index is None and not product_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'row_index 또는 product_code 필요'
}), 400
try:
session = get_geo_session()
result = session.cancel_item(row_index=row_index, product_code=product_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/cart/restore', methods=['POST'])
def api_geoyoung_cart_restore():
"""
삭제된 항목 복원 API - 지오영은 Hard delete이므로 지원 안 함
Returns:
항상 {'success': False, 'error': 'NOT_SUPPORTED'}
"""
return jsonify({
'success': False,
'error': 'NOT_SUPPORTED',
'message': '지오영은 삭제 후 복원 불가 (다시 추가 필요)'
}), 400
@geoyoung_bp.route('/confirm', methods=['POST'])
def api_geoyoung_confirm():
"""주문 확정 API"""
data = request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_geo_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/full-order', methods=['POST'])
def api_geoyoung_full_order():
"""전체 주문 API (검색 → 장바구니 → 확정)"""
data = request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_geo_session()
result = session.full_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
specification=data.get('specification'),
check_stock=data.get('check_stock', True),
auto_confirm=data.get('auto_confirm', True),
memo=data.get('memo', '')
)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@geoyoung_bp.route('/order', methods=['POST'])
def api_geoyoung_order():
"""지오영 주문 API (장바구니 추가)"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_geo_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@geoyoung_bp.route('/order-batch', methods=['POST'])
def api_geoyoung_order_batch():
"""지오영 일괄 주문 API"""
data = request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_geo_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})
# ========== 잔고 탐색 (임시) ==========
@geoyoung_bp.route('/explore-balance', methods=['GET'])
def api_explore_balance():
"""잔고 페이지 탐색 (임시 디버그용)"""
from bs4 import BeautifulSoup
session = get_geo_session()
if not session._logged_in:
session.login()
results = {
'logged_in': session._logged_in,
'cookies': len(session.session.cookies),
'pages_found': [],
'balance_pages': []
}
# Order 페이지에서 메뉴 링크 수집
try:
# 먼저 Order 페이지 접근
resp = session.session.get(f"{session.BASE_URL}/Home/Order", timeout=10)
results['order_page'] = {
'status': resp.status_code,
'url': resp.url,
'is_error': 'Error' in resp.url
}
if resp.status_code == 200 and 'Error' not in resp.url:
soup = BeautifulSoup(resp.text, 'html.parser')
# 모든 링크 추출
for link in soup.find_all('a', href=True):
href = link.get('href', '')
text = link.get_text(strip=True)[:50]
if href.startswith('/') and href not in [l['href'] for l in results['pages_found']]:
entry = {'href': href, 'text': text}
results['pages_found'].append(entry)
# 잔고 관련 키워드
keywords = ['account', 'balance', 'trans', 'state', 'history', 'ledger', '잔고', '잔액', '거래', '명세', '내역']
if any(kw in href.lower() or kw in text for kw in keywords):
results['balance_pages'].append(entry)
except Exception as e:
results['error'] = str(e)
return jsonify(results)
@geoyoung_bp.route('/balance', methods=['GET'])
def api_get_balance():
"""
잔고액 조회
GET /api/geoyoung/balance
"""
session = get_geo_session()
# get_balance 메서드가 있으면 호출
if hasattr(session, 'get_balance'):
result = session.get_balance()
return jsonify(result)
else:
return jsonify({
'success': False,
'error': 'NOT_IMPLEMENTED',
'message': '지오영 잔고 조회 미구현'
}), 501
@geoyoung_bp.route('/monthly-sales', methods=['GET'])
def api_get_monthly_sales():
"""
월간 매출 조회
GET /api/geoyoung/monthly-sales?year=2026&month=3
"""
from datetime import datetime
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
# 기본값: 현재 월
if not year or not month:
now = datetime.now()
year = year or now.year
month = month or now.month
session = get_geo_session()
if hasattr(session, 'get_monthly_sales'):
result = session.get_monthly_sales(year, month)
return jsonify(result)
else:
return jsonify({
'success': False,
'error': 'NOT_IMPLEMENTED',
'message': '지오영 월간 매출 조회 미구현'
}), 501
# ========== 주문 조회 API ==========
@geoyoung_bp.route('/order-list', methods=['GET'])
def api_geoyoung_order_list():
"""
지오영 주문 목록 조회 API
GET /api/geoyoung/order-list?start_date=2026-03-01&end_date=2026-03-07
Query Parameters:
start_date: 시작일 (YYYY-MM-DD), 기본값 30일 전
end_date: 종료일 (YYYY-MM-DD), 기본값 오늘
Returns:
{
"success": true,
"orders": [{
"order_num": "DA2603-0006409",
"order_date": "2026-03-07",
"order_time": "09:08:55",
"total_amount": 132020,
"item_count": 3,
"status": "출고확정"
}, ...],
"total_count": 5,
"start_date": "2026-03-01",
"end_date": "2026-03-07"
}
"""
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
try:
session = get_geo_session()
result = session.get_order_list(start_date or None, end_date or None)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 주문 목록 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e),
'orders': [],
'total_count': 0
}), 500
@geoyoung_bp.route('/order-detail/<order_num>', methods=['GET'])
def api_geoyoung_order_detail(order_num):
"""
지오영 주문 상세 조회 API
GET /api/geoyoung/order-detail/DA2603-0006409
Returns:
{
"success": true,
"order_num": "DA2603-0006409",
"order_date": "2026-03-07",
"order_time": "09:08:55",
"items": [{
"product_code": "008709",
"kd_code": "670400830",
"product_name": "레바미피드정100mg",
"spec": "100mg",
"quantity": 10,
"unit_price": 500,
"amount": 5000
}, ...],
"total_amount": 132020,
"item_count": 3
}
"""
try:
session = get_geo_session()
result = session.get_order_detail(order_num)
return jsonify(result)
except Exception as e:
logger.error(f"지오영 주문 상세 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e),
'order_num': order_num,
'items': [],
'total_amount': 0
}), 500
@geoyoung_bp.route('/orders/summary-by-kd', methods=['GET'])
def api_geoyoung_orders_by_kd():
"""
지오영 주문량 KD코드별 집계 API
GET /api/geoyoung/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
Returns:
{
"success": true,
"order_count": 4,
"by_kd_code": {
"670400830": {
"product_name": "레바미피드정",
"spec": "100T",
"boxes": 2,
"units": 200
}
},
"total_products": 15
}
"""
import re
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
start_date = request.args.get('start_date', today).strip()
end_date = request.args.get('end_date', today).strip()
def parse_spec(spec: str, product_name: str = '') -> int:
"""
규격에서 수량 추출 (30T → 30, 100C → 100)
단위 처리:
- T/C/P: 정/캡슐/포 → 숫자 그대로 (30T → 30)
- D: 도즈/분사 → 1로 처리 (140D → 1, 박스 단위)
- mg/ml/g: 용량 → 1로 처리
"""
combined = f"{spec} {product_name}"
# D(도즈) 단위는 박스 단위로 계산 (140D → 1)
if re.search(r'\d+\s*D\b', combined, re.IGNORECASE):
return 1
# T/C/P 단위가 붙은 숫자 추출 (예: 14T, 100C, 30P)
qty_match = re.search(r'(\d+)\s*[TCP]\b', combined, re.IGNORECASE)
if qty_match:
return int(qty_match.group(1))
# 없으면 spec의 첫 번째 숫자 (mg, ml 등 용량일 수 있음 - 기본값 1)
if spec:
num_match = re.search(r'(\d+)', spec)
if num_match:
val = int(num_match.group(1))
# mg, ml 같은 용량 단위면 수량 1로 처리
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
return 1
return val
return 1
try:
session = get_geo_session()
# 주문 목록 조회 (items 포함)
orders_result = session.get_order_list(start_date, end_date)
if not orders_result.get('success'):
return jsonify({
'success': False,
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
'by_kd_code': {},
'order_count': 0
})
orders = orders_result.get('orders', [])
# 각 주문의 items에 KD코드 추가 (enrich)
for order in orders:
items = order.get('items', [])
if items:
session._enrich_kd_codes(items)
# KD코드별 집계
kd_summary = {}
for order in orders:
# 지오영은 get_order_list에서 items도 같이 반환
for item in order.get('items', []):
# 취소/삭제 상태 제외
status = item.get('status', '').strip()
if '취소' in status or '삭제' in status:
continue
kd_code = item.get('kd_code', '')
if not kd_code:
continue
product_name = item.get('product_name', '')
spec = item.get('spec', '')
quantity = item.get('quantity', 0) or item.get('order_qty', 0)
per_unit = parse_spec(spec, product_name)
total_units = quantity * per_unit
if kd_code not in kd_summary:
kd_summary[kd_code] = {
'product_name': product_name,
'spec': spec,
'boxes': 0,
'units': 0
}
kd_summary[kd_code]['boxes'] += quantity
kd_summary[kd_code]['units'] += total_units
logger.info(f"지오영 주문량 집계: {start_date}~{end_date}, {len(orders)}건 주문, {len(kd_summary)}개 품목")
return jsonify({
'success': True,
'order_count': len(orders),
'period': {'start': start_date, 'end': end_date},
'by_kd_code': kd_summary,
'total_products': len(kd_summary)
})
except Exception as e:
logger.error(f"지오영 주문량 집계 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e),
'by_kd_code': {},
'order_count': 0
}), 500
@geoyoung_bp.route('/order-today', methods=['GET'])
def api_geoyoung_order_today():
"""
지오영 오늘 주문 요약 API
GET /api/geoyoung/order-today
Returns:
{
"success": true,
"date": "2026-03-07",
"order_count": 3,
"total_amount": 450000,
"item_count": 15,
"orders": [...]
}
"""
try:
session = get_geo_session()
result = session.get_today_order_summary()
return jsonify(result)
except Exception as e:
logger.error(f"지오영 오늘 주문 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e),
'date': '',
'order_count': 0,
'total_amount': 0
}), 500
# ========== 하위 호환성 ==========
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
GeoyoungSession = GeoYoungSession

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

1389
backend/order_api.py Normal file

File diff suppressed because it is too large Load Diff

861
backend/order_db.py Normal file
View File

@@ -0,0 +1,861 @@
# -*- coding: utf-8 -*-
"""
주문 관리 DB (SQLite)
- 다중 도매상 지원 (지오영, 수인, 백제 등)
- 주문 상태 추적
- 품목별 결과 관리
- 자동화 ERP 확장 대비
"""
import sqlite3
import os
from datetime import datetime
from typing import Optional, List, Dict
import json
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'orders.db')
def get_connection():
"""DB 연결"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""DB 초기화 - 테이블 생성"""
conn = get_connection()
cursor = conn.cursor()
# ─────────────────────────────────────────────
# 도매상 마스터
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS wholesalers (
id TEXT PRIMARY KEY, -- 'geoyoung', 'sooin', 'baekje'
name TEXT NOT NULL, -- '지오영', '수인', '백제'
api_type TEXT, -- 'playwright', 'api', 'manual'
base_url TEXT,
is_active INTEGER DEFAULT 1,
config_json TEXT, -- 로그인 정보 등 (암호화 권장)
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''')
# 기본 도매상 등록
cursor.execute('''
INSERT OR IGNORE INTO wholesalers (id, name, api_type, base_url)
VALUES
('geoyoung', '지오영', 'playwright', 'https://gwn.geoweb.kr'),
('sooin', '수인', 'manual', NULL),
('baekje', '백제', 'manual', NULL)
''')
# ─────────────────────────────────────────────
# 주문 헤더
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT UNIQUE, -- 주문번호 (ORD-20260306-001)
wholesaler_id TEXT NOT NULL, -- 도매상 ID
-- 주문 정보
order_date TEXT NOT NULL, -- 주문일 (YYYY-MM-DD)
order_time TEXT, -- 주문시간 (HH:MM:SS)
order_type TEXT DEFAULT 'manual', -- 'manual', 'auto', 'scheduled'
order_session TEXT, -- 'morning', 'afternoon', 'evening'
-- 상태
status TEXT DEFAULT 'draft', -- draft, pending, submitted, partial, completed, failed, cancelled
-- 집계
total_items INTEGER DEFAULT 0,
total_qty INTEGER DEFAULT 0,
success_items INTEGER DEFAULT 0,
failed_items INTEGER DEFAULT 0,
-- 참조
parent_order_id INTEGER, -- 재주문 시 원주문 참조
reference_period TEXT, -- 사용량 조회 기간 (2026-03-01~2026-03-06)
-- 메타
note TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
submitted_at TEXT, -- 실제 제출 시간
completed_at TEXT,
FOREIGN KEY (wholesaler_id) REFERENCES wholesalers(id),
FOREIGN KEY (parent_order_id) REFERENCES orders(id)
)
''')
# ─────────────────────────────────────────────
# 주문 품목 상세
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
-- 약품 정보
drug_code TEXT NOT NULL, -- PIT3000 약품코드
kd_code TEXT, -- 보험코드 (지오영 검색용)
internal_code TEXT, -- 🔧 도매상 내부 코드 (장바구니 직접 추가용!)
product_name TEXT NOT NULL,
manufacturer TEXT,
-- 규격/수량
specification TEXT, -- '30T', '300T', '500T'
unit_qty INTEGER, -- 규격당 수량 (30, 300, 500)
order_qty INTEGER NOT NULL, -- 주문 수량 (단위 개수)
total_dose INTEGER, -- 총 정제수 (order_qty * unit_qty)
-- 주문 근거
usage_qty INTEGER, -- 사용량 (조회 기간)
current_stock INTEGER, -- 주문 시점 재고
-- 가격 (선택)
unit_price INTEGER,
total_price INTEGER,
-- 상태
status TEXT DEFAULT 'pending', -- pending, submitted, success, failed, cancelled
-- 결과
result_code TEXT, -- 'OK', 'OUT_OF_STOCK', 'NOT_FOUND', 'ERROR'
result_message TEXT,
wholesaler_order_no TEXT, -- 도매상 측 주문번호
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 주문 로그 (상태 변경 이력)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
order_item_id INTEGER, -- NULL이면 주문 전체 로그
action TEXT NOT NULL, -- 'created', 'submitted', 'success', 'failed', 'cancelled'
old_status TEXT,
new_status TEXT,
message TEXT,
detail_json TEXT, -- API 응답 등 상세 정보
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 주문 컨텍스트 (AI 학습용 스냅샷)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_context (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_item_id INTEGER NOT NULL,
-- 약품 정보
drug_code TEXT NOT NULL,
product_name TEXT,
-- 주문 시점 재고
stock_at_order INTEGER, -- 주문 시점 현재고
-- 사용량 분석
usage_1d INTEGER, -- 최근 1일 사용량
usage_7d INTEGER, -- 최근 7일 사용량
usage_30d INTEGER, -- 최근 30일 사용량
avg_daily_usage REAL, -- 일평균 사용량 (30일 기준)
-- 주문 패턴
ordered_spec TEXT, -- 주문한 규격 (30T, 300T)
ordered_qty INTEGER, -- 주문 수량 (단위 개수)
ordered_dose INTEGER, -- 주문 총 정제수
-- 규격 선택 이유 (AI 분석용)
available_specs TEXT, -- 가능한 규격들 JSON ["30T", "300T"]
spec_stocks TEXT, -- 규격별 도매상 재고 JSON {"30T": 50, "300T": 0}
selection_reason TEXT, -- 'stock_available', 'best_fit', 'only_option', 'user_choice'
-- 예측 vs 실제 (나중에 업데이트)
days_until_stockout REAL, -- 주문 시점 예상 재고 소진일
actual_reorder_days INTEGER, -- 실제 재주문까지 일수 (나중에 업데이트)
-- 메타
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
)
''')
# ─────────────────────────────────────────────
# 일별 사용량 추적 (시계열 데이터)
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS daily_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
usage_date TEXT NOT NULL, -- YYYY-MM-DD
-- 처방 데이터
rx_count INTEGER DEFAULT 0, -- 처방 건수
rx_qty INTEGER DEFAULT 0, -- 처방 수량 (정제수)
-- POS 데이터 (일반약)
pos_count INTEGER DEFAULT 0,
pos_qty INTEGER DEFAULT 0,
-- 집계
total_qty INTEGER DEFAULT 0,
-- 재고 스냅샷
stock_start INTEGER, -- 시작 재고
stock_end INTEGER, -- 종료 재고
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, usage_date)
)
''')
# ─────────────────────────────────────────────
# AI 분석 결과/패턴
# ─────────────────────────────────────────────
cursor.execute('''
CREATE TABLE IF NOT EXISTS order_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
-- 분석 기간
analysis_date TEXT NOT NULL,
analysis_period_days INTEGER,
-- 사용 패턴
avg_daily_usage REAL,
usage_stddev REAL, -- 사용량 표준편차 (변동성)
peak_usage INTEGER, -- 최대 사용량
-- 주문 패턴
typical_order_spec TEXT, -- 주로 주문하는 규격
typical_order_qty INTEGER, -- 주로 주문하는 수량
order_frequency_days REAL, -- 평균 주문 주기 (일)
-- AI 추천
recommended_spec TEXT, -- 추천 규격
recommended_qty INTEGER, -- 추천 수량
recommended_reorder_point INTEGER,-- 추천 재주문점 (재고가 이 이하면 주문)
confidence_score REAL, -- 추천 신뢰도 (0-1)
-- 모델 정보
model_version TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, analysis_date)
)
''')
# ─────────────────────────────────────────────
# 인덱스
# ─────────────────────────────────────────────
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_orders_wholesaler ON orders(wholesaler_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_drug ON order_items(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_items_status ON order_items(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_context_drug ON order_context(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_drug ON daily_usage(drug_code)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(usage_date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_order_patterns_drug ON order_patterns(drug_code)')
conn.commit()
conn.close()
return True
def generate_order_no(wholesaler_id: str) -> str:
"""주문번호 생성 (ORD-GEO-20260306-001)"""
prefix_map = {
'geoyoung': 'GEO',
'sooin': 'SOO',
'baekje': 'BAK'
}
prefix = prefix_map.get(wholesaler_id, 'ORD')
date_str = datetime.now().strftime('%Y%m%d')
conn = get_connection()
cursor = conn.cursor()
# 오늘 해당 도매상 주문 수 카운트
cursor.execute('''
SELECT COUNT(*) FROM orders
WHERE wholesaler_id = ? AND order_date = ?
''', (wholesaler_id, datetime.now().strftime('%Y-%m-%d')))
count = cursor.fetchone()[0] + 1
conn.close()
return f"ORD-{prefix}-{date_str}-{count:03d}"
def create_order(wholesaler_id: str, items: List[Dict],
order_type: str = 'manual',
order_session: str = None,
reference_period: str = None,
note: str = None) -> Dict:
"""
주문 생성 (draft 상태)
items: [
{
'drug_code': '670400830',
'kd_code': '670400830',
'product_name': '레바미피드정 30T',
'manufacturer': '휴온스',
'specification': '30T',
'unit_qty': 30,
'order_qty': 10,
'usage_qty': 280,
'current_stock': 50
}
]
"""
conn = get_connection()
cursor = conn.cursor()
try:
order_no = generate_order_no(wholesaler_id)
now = datetime.now()
# 주문 헤더 생성
cursor.execute('''
INSERT INTO orders (
order_no, wholesaler_id, order_date, order_time,
order_type, order_session, reference_period, note,
total_items, total_qty, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
''', (
order_no,
wholesaler_id,
now.strftime('%Y-%m-%d'),
now.strftime('%H:%M:%S'),
order_type,
order_session,
reference_period,
note,
len(items),
sum(item.get('order_qty', 0) for item in items)
))
order_id = cursor.lastrowid
# 주문 품목 생성
for item in items:
unit_qty = item.get('unit_qty', 1)
order_qty = item.get('order_qty', 0)
cursor.execute('''
INSERT INTO order_items (
order_id, drug_code, kd_code, internal_code, product_name, manufacturer,
specification, unit_qty, order_qty, total_dose,
usage_qty, current_stock, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
''', (
order_id,
item.get('drug_code'),
item.get('kd_code'),
item.get('internal_code'), # 🔧 도매상 내부 코드 저장!
item.get('product_name'),
item.get('manufacturer'),
item.get('specification'),
unit_qty,
order_qty,
order_qty * unit_qty,
item.get('usage_qty'),
item.get('current_stock')
))
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, new_status, message)
VALUES (?, 'created', 'draft', ?)
''', (order_id, f'{len(items)}개 품목 주문 생성'))
conn.commit()
return {
'success': True,
'order_id': order_id,
'order_no': order_no,
'total_items': len(items)
}
except Exception as e:
conn.rollback()
return {'success': False, 'error': str(e)}
finally:
conn.close()
def get_order(order_id: int) -> Optional[Dict]:
"""주문 조회 (품목 포함)"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM orders WHERE id = ?', (order_id,))
order = cursor.fetchone()
if not order:
conn.close()
return None
cursor.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,))
items = cursor.fetchall()
conn.close()
return {
**dict(order),
'items': [dict(item) for item in items]
}
def update_order_status(order_id: int, status: str, message: str = None) -> bool:
"""주문 상태 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
# 현재 상태 조회
cursor.execute('SELECT status FROM orders WHERE id = ?', (order_id,))
row = cursor.fetchone()
if not row:
return False
old_status = row['status']
# 상태 업데이트
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
update_fields = ['status = ?', 'updated_at = ?']
params = [status, now]
if status == 'submitted':
update_fields.append('submitted_at = ?')
params.append(now)
elif status in ('completed', 'failed'):
update_fields.append('completed_at = ?')
params.append(now)
params.append(order_id)
cursor.execute(f'''
UPDATE orders SET {', '.join(update_fields)} WHERE id = ?
''', params)
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, old_status, new_status, message)
VALUES (?, ?, ?, ?, ?)
''', (order_id, status, old_status, status, message))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_item_result(item_id: int, status: str, result_code: str = None,
result_message: str = None, wholesaler_order_no: str = None) -> bool:
"""품목 결과 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE order_items SET
status = ?,
result_code = ?,
result_message = ?,
wholesaler_order_no = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (status, result_code, result_message, wholesaler_order_no, item_id))
# 주문 집계 업데이트
cursor.execute('SELECT order_id FROM order_items WHERE id = ?', (item_id,))
order_id = cursor.fetchone()['order_id']
cursor.execute('''
UPDATE orders SET
success_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'success'),
failed_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'failed'),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (order_id, order_id, order_id))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_order_history(wholesaler_id: str = None,
start_date: str = None,
end_date: str = None,
status: str = None,
limit: int = 50) -> List[Dict]:
"""주문 이력 조회"""
conn = get_connection()
cursor = conn.cursor()
query = 'SELECT * FROM orders WHERE 1=1'
params = []
if wholesaler_id:
query += ' AND wholesaler_id = ?'
params.append(wholesaler_id)
if start_date:
query += ' AND order_date >= ?'
params.append(start_date)
if end_date:
query += ' AND order_date <= ?'
params.append(end_date)
if status:
query += ' AND status = ?'
params.append(status)
query += ' ORDER BY created_at DESC LIMIT ?'
params.append(limit)
cursor.execute(query, params)
orders = [dict(row) for row in cursor.fetchall()]
conn.close()
return orders
# ─────────────────────────────────────────────
# AI 학습용 함수들
# ─────────────────────────────────────────────
def save_order_context(order_item_id: int, context: Dict) -> bool:
"""
주문 시점 컨텍스트 저장 (AI 학습용)
context: {
'drug_code': '670400830',
'product_name': '레바미피드정',
'stock_at_order': 50,
'usage_1d': 30,
'usage_7d': 180,
'usage_30d': 800,
'ordered_spec': '30T',
'ordered_qty': 10,
'available_specs': ['30T', '300T'],
'spec_stocks': {'30T': 50, '300T': 0},
'selection_reason': 'stock_available'
}
"""
conn = get_connection()
cursor = conn.cursor()
try:
# 일평균 사용량 계산
usage_30d = context.get('usage_30d', 0)
avg_daily = usage_30d / 30.0 if usage_30d else 0
# 재고 소진 예상일 계산
stock = context.get('stock_at_order', 0)
days_until_stockout = stock / avg_daily if avg_daily > 0 else None
# 주문 총 정제수
ordered_qty = context.get('ordered_qty', 0)
spec = context.get('ordered_spec', '')
unit_qty = int(''.join(filter(str.isdigit, spec))) if spec else 1
ordered_dose = ordered_qty * unit_qty
cursor.execute('''
INSERT INTO order_context (
order_item_id, drug_code, product_name,
stock_at_order, usage_1d, usage_7d, usage_30d, avg_daily_usage,
ordered_spec, ordered_qty, ordered_dose,
available_specs, spec_stocks, selection_reason,
days_until_stockout
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
order_item_id,
context.get('drug_code'),
context.get('product_name'),
context.get('stock_at_order'),
context.get('usage_1d'),
context.get('usage_7d'),
context.get('usage_30d'),
avg_daily,
context.get('ordered_spec'),
ordered_qty,
ordered_dose,
json.dumps(context.get('available_specs', []), ensure_ascii=False),
json.dumps(context.get('spec_stocks', {}), ensure_ascii=False),
context.get('selection_reason'),
days_until_stockout
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_daily_usage(drug_code: str, usage_date: str,
rx_count: int = 0, rx_qty: int = 0,
pos_count: int = 0, pos_qty: int = 0,
stock_end: int = None) -> bool:
"""일별 사용량 업데이트 (UPSERT)"""
conn = get_connection()
cursor = conn.cursor()
try:
total_qty = rx_qty + pos_qty
cursor.execute('''
INSERT INTO daily_usage (
drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, usage_date) DO UPDATE SET
rx_count = rx_count + excluded.rx_count,
rx_qty = rx_qty + excluded.rx_qty,
pos_count = pos_count + excluded.pos_count,
pos_qty = pos_qty + excluded.pos_qty,
total_qty = total_qty + excluded.total_qty,
stock_end = COALESCE(excluded.stock_end, stock_end)
''', (drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_usage_stats(drug_code: str, days: int = 30) -> Dict:
"""약품 사용량 통계 조회 (AI 분석용)"""
conn = get_connection()
cursor = conn.cursor()
from datetime import datetime, timedelta
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cursor.execute('''
SELECT
COUNT(*) as days_with_data,
SUM(total_qty) as total_usage,
AVG(total_qty) as avg_daily,
MAX(total_qty) as max_daily,
MIN(total_qty) as min_daily
FROM daily_usage
WHERE drug_code = ? AND usage_date BETWEEN ? AND ?
''', (drug_code, start_date, end_date))
row = cursor.fetchone()
conn.close()
if row and row['total_usage']:
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': row['days_with_data'],
'total_usage': row['total_usage'],
'avg_daily': round(row['avg_daily'], 2) if row['avg_daily'] else 0,
'max_daily': row['max_daily'],
'min_daily': row['min_daily']
}
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': 0,
'total_usage': 0,
'avg_daily': 0,
'max_daily': 0,
'min_daily': 0
}
def get_order_pattern(drug_code: str) -> Optional[Dict]:
"""약품 주문 패턴 조회"""
conn = get_connection()
cursor = conn.cursor()
# 최근 주문 이력 분석
cursor.execute('''
SELECT
oi.specification,
oi.order_qty,
oi.total_dose,
o.order_date
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE oi.drug_code = ? AND oi.status = 'success'
ORDER BY o.order_date DESC
LIMIT 10
''', (drug_code,))
orders = [dict(row) for row in cursor.fetchall()]
if not orders:
conn.close()
return None
# 가장 많이 사용된 규격
spec_counts = {}
for o in orders:
spec = o['specification']
spec_counts[spec] = spec_counts.get(spec, 0) + 1
typical_spec = max(spec_counts, key=spec_counts.get)
# 평균 주문 수량
typical_qty = sum(o['order_qty'] for o in orders) // len(orders)
# 주문 주기 계산
if len(orders) >= 2:
dates = [datetime.strptime(o['order_date'], '%Y-%m-%d') for o in orders]
intervals = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
avg_interval = sum(intervals) / len(intervals) if intervals else 0
else:
avg_interval = 0
conn.close()
return {
'drug_code': drug_code,
'order_count': len(orders),
'typical_spec': typical_spec,
'typical_qty': typical_qty,
'avg_order_interval_days': round(avg_interval, 1),
'recent_orders': orders[:5]
}
def get_ai_training_data(limit: int = 1000) -> List[Dict]:
"""AI 학습용 데이터 추출"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
oc.*,
oi.status as order_status,
oi.result_code,
o.order_date,
o.wholesaler_id
FROM order_context oc
JOIN order_items oi ON oc.order_item_id = oi.id
JOIN orders o ON oi.order_id = o.id
ORDER BY oc.created_at DESC
LIMIT ?
''', (limit,))
data = []
for row in cursor.fetchall():
item = dict(row)
# JSON 필드 파싱
if item.get('available_specs'):
item['available_specs'] = json.loads(item['available_specs'])
if item.get('spec_stocks'):
item['spec_stocks'] = json.loads(item['spec_stocks'])
data.append(item)
conn.close()
return data
def save_ai_pattern(drug_code: str, pattern: Dict) -> bool:
"""AI 분석 결과 저장"""
conn = get_connection()
cursor = conn.cursor()
try:
today = datetime.now().strftime('%Y-%m-%d')
cursor.execute('''
INSERT INTO order_patterns (
drug_code, analysis_date, analysis_period_days,
avg_daily_usage, usage_stddev, peak_usage,
typical_order_spec, typical_order_qty, order_frequency_days,
recommended_spec, recommended_qty, recommended_reorder_point,
confidence_score, model_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, analysis_date) DO UPDATE SET
avg_daily_usage = excluded.avg_daily_usage,
recommended_spec = excluded.recommended_spec,
recommended_qty = excluded.recommended_qty,
confidence_score = excluded.confidence_score
''', (
drug_code,
today,
pattern.get('period_days', 30),
pattern.get('avg_daily_usage'),
pattern.get('usage_stddev'),
pattern.get('peak_usage'),
pattern.get('typical_order_spec'),
pattern.get('typical_order_qty'),
pattern.get('order_frequency_days'),
pattern.get('recommended_spec'),
pattern.get('recommended_qty'),
pattern.get('recommended_reorder_point'),
pattern.get('confidence_score'),
pattern.get('model_version', 'v1')
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
# 초기화 실행
init_db()

225
backend/paai_feedback.py Normal file
View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""
PAAI 피드백 루프 시스템
- 피드백 수집, AI 정제, 프롬프트 인젝션
"""
import sqlite3
import os
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
paai_feedback_bp = Blueprint('paai_feedback', __name__)
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'paai_feedback.db')
def get_db():
"""DB 연결"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""테이블 초기화"""
conn = get_db()
conn.execute('''
CREATE TABLE IF NOT EXISTS paai_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 컨텍스트
prescription_id TEXT,
patient_name TEXT,
patient_context TEXT,
-- PAAI 응답
paai_request TEXT,
paai_response TEXT,
-- 피드백
rating TEXT,
category TEXT,
pharmacist_comment TEXT,
-- AI 정제 결과
refined_rule TEXT,
confidence REAL,
-- 적용 상태
applied_to_prompt INTEGER DEFAULT 0,
applied_to_training INTEGER DEFAULT 0
)
''')
conn.commit()
conn.close()
# 앱 시작 시 테이블 생성
init_db()
@paai_feedback_bp.route('/api/paai/feedback', methods=['POST'])
def submit_feedback():
"""피드백 제출"""
try:
data = request.json
conn = get_db()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO paai_feedback (
prescription_id, patient_name, patient_context,
paai_request, paai_response,
rating, category, pharmacist_comment
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
data.get('prescription_id'),
data.get('patient_name'),
json.dumps(data.get('patient_context', {}), ensure_ascii=False),
data.get('paai_request'),
data.get('paai_response'),
data.get('rating'), # 'good' or 'bad'
data.get('category'), # 'interaction', 'indication', 'dosage', 'other'
data.get('pharmacist_comment')
))
feedback_id = cursor.lastrowid
conn.commit()
# bad 피드백이고 코멘트가 있으면 AI 정제 시도
if data.get('rating') == 'bad' and data.get('pharmacist_comment'):
refined = refine_feedback_async(feedback_id, data)
if refined:
cursor.execute('''
UPDATE paai_feedback
SET refined_rule = ?, confidence = ?
WHERE id = ?
''', (refined['rule'], refined['confidence'], feedback_id))
conn.commit()
conn.close()
return jsonify({
'success': True,
'feedback_id': feedback_id,
'message': '피드백이 저장되었습니다.'
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
def refine_feedback_async(feedback_id, data):
"""피드백을 규칙으로 정제 (동기 버전 - 나중에 비동기로 변경 가능)"""
try:
# TODO: AI 호출로 정제
# 지금은 간단히 코멘트를 규칙 형태로 저장
comment = data.get('pharmacist_comment', '')
if not comment:
return None
# 간단한 규칙 형태로 변환
category = data.get('category', 'other')
rule = f"[{category}] {comment}"
return {
'rule': rule,
'confidence': 0.8 # 기본 신뢰도
}
except:
return None
@paai_feedback_bp.route('/api/paai/feedback/rules', methods=['GET'])
def get_feedback_rules():
"""축적된 피드백 규칙 조회 (프롬프트 인젝션용)"""
try:
conn = get_db()
cursor = conn.cursor()
# bad 피드백 중 정제된 규칙만
cursor.execute('''
SELECT refined_rule, category, created_at
FROM paai_feedback
WHERE rating = 'bad'
AND refined_rule IS NOT NULL
ORDER BY created_at DESC
LIMIT 20
''')
rules = []
for row in cursor.fetchall():
rules.append({
'rule': row['refined_rule'],
'category': row['category'],
'created_at': row['created_at']
})
conn.close()
return jsonify({
'success': True,
'rules': rules,
'count': len(rules)
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@paai_feedback_bp.route('/api/paai/feedback/stats', methods=['GET'])
def get_feedback_stats():
"""피드백 통계"""
try:
conn = get_db()
cursor = conn.cursor()
cursor.execute('''
SELECT
COUNT(*) as total,
SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) as good,
SUM(CASE WHEN rating = 'bad' THEN 1 ELSE 0 END) as bad
FROM paai_feedback
''')
row = cursor.fetchone()
conn.close()
return jsonify({
'success': True,
'stats': {
'total': row['total'] or 0,
'good': row['good'] or 0,
'bad': row['bad'] or 0
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
def get_rules_for_prompt(patient_context=None):
"""프롬프트에 주입할 규칙 목록 반환"""
try:
conn = get_db()
cursor = conn.cursor()
# 최근 규칙 20개
cursor.execute('''
SELECT refined_rule
FROM paai_feedback
WHERE rating = 'bad'
AND refined_rule IS NOT NULL
ORDER BY created_at DESC
LIMIT 20
''')
rules = [row['refined_rule'] for row in cursor.fetchall()]
conn.close()
return rules
except:
return []

159
backend/paai_printer.py Normal file
View File

@@ -0,0 +1,159 @@
"""PAAI ESC/POS 프린터 모듈"""
import json
from datetime import datetime
from escpos.printer import Network
from PIL import Image, ImageDraw, ImageFont
# 프린터 설정
PRINTER_IP = "192.168.0.174"
PRINTER_PORT = 9100
THERMAL_WIDTH = 576
def print_paai_result(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> dict:
"""PAAI 분석 결과 인쇄"""
try:
# 이미지 생성
img = create_receipt_image(pre_serial, patient_name, analysis, kims_summary)
# 프린터 연결 및 출력
p = Network(PRINTER_IP, port=PRINTER_PORT, timeout=15)
p.image(img)
p.text('\n\n\n')
p.cut()
return {'success': True, 'message': '인쇄 완료'}
except Exception as e:
return {'success': False, 'error': str(e)}
def create_receipt_image(pre_serial: str, patient_name: str, analysis: dict, kims_summary: dict) -> Image:
"""영수증 이미지 생성"""
# 폰트
try:
font_title = ImageFont.truetype('malgun.ttf', 28)
font_section = ImageFont.truetype('malgunbd.ttf', 20)
font_normal = ImageFont.truetype('malgun.ttf', 18)
font_small = ImageFont.truetype('malgun.ttf', 15)
except:
font_title = ImageFont.load_default()
font_section = font_title
font_normal = font_title
font_small = font_title
width = THERMAL_WIDTH
padding = 20
y = padding
# 이미지 생성
img = Image.new('RGB', (width, 1000), 'white')
draw = ImageDraw.Draw(img)
# 헤더
draw.text((width//2, y), 'PAAI 복약안내', font=font_title, fill='black', anchor='mt')
y += 40
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
y += 15
# 환자 정보
draw.text((padding, y), f'환자: {patient_name}', font=font_normal, fill='black')
y += 25
draw.text((padding, y), f'처방번호: {pre_serial}', font=font_small, fill='black')
y += 20
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
draw.text((padding, y), f'출력: {now_str}', font=font_small, fill='black')
y += 25
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
y += 15
# 상호작용
interaction_count = kims_summary.get('interaction_count', 0)
has_severe = kims_summary.get('has_severe', False)
if has_severe:
draw.text((padding, y), '[주의] 중증 상호작용 있음!', font=font_section, fill='black')
elif interaction_count > 0:
draw.text((padding, y), f'약물 상호작용: {interaction_count}', font=font_normal, fill='black')
else:
draw.text((padding, y), '상호작용 없음', font=font_normal, fill='black')
y += 30
# 처방 해석
insight = analysis.get('prescription_insight', '')
if insight:
draw.text((padding, y), '[처방 해석]', font=font_section, fill='black')
y += 28
for line in wrap_text(insight, 40)[:3]:
draw.text((padding, y), line, font=font_small, fill='black')
y += 20
y += 10
# 주의사항
cautions = analysis.get('cautions', [])
if cautions:
draw.text((padding, y), '[복용 주의사항]', font=font_section, fill='black')
y += 28
for i, c in enumerate(cautions[:3], 1):
for line in wrap_text(f'{i}. {c}', 40)[:2]:
draw.text((padding, y), line, font=font_small, fill='black')
y += 20
y += 10
# 상담 포인트
counseling = analysis.get('counseling_points', [])
if counseling:
draw.text((padding, y), '[상담 포인트]', font=font_section, fill='black')
y += 28
for i, c in enumerate(counseling[:2], 1):
for line in wrap_text(f'{i}. {c}', 40)[:2]:
draw.text((padding, y), line, font=font_small, fill='black')
y += 20
y += 10
# 푸터
y += 10
draw.line([(padding, y), (width-padding, y)], fill='black', width=1)
y += 15
draw.text((width//2, y), '양구청춘약국 PAAI', font=font_small, fill='black', anchor='mt')
return img.crop((0, 0, width, y + 30))
def wrap_text(text: str, max_chars: int = 40) -> list:
"""텍스트 줄바꿈"""
lines = []
words = text.split()
current = ""
for word in words:
if len(current) + len(word) + 1 <= max_chars:
current = current + " " + word if current else word
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines if lines else [text[:max_chars]]
if __name__ == '__main__':
# CLI 테스트
import sys
if len(sys.argv) > 1:
pre_serial = sys.argv[1]
else:
pre_serial = '20260305000075'
# 테스트 데이터
analysis = {
'prescription_insight': '테스트 처방입니다.',
'cautions': ['주의사항 1', '주의사항 2'],
'counseling_points': ['상담 포인트 1']
}
kims_summary = {'interaction_count': 0, 'has_severe': False}
result = print_paai_result(pre_serial, '테스트환자', analysis, kims_summary)
print(result)

201
backend/paai_printer_cli.py Normal file
View File

@@ -0,0 +1,201 @@
"""PAAI ESC/POS 프린터 CLI - EUC-KR 텍스트 방식"""
import sys
import json
import socket
from datetime import datetime
# 프린터 설정
PRINTER_IP = "192.168.0.174"
PRINTER_PORT = 9100
# ESC/POS 명령어
ESC = b'\x1b'
INIT = ESC + b'@' # 프린터 초기화
CUT = ESC + b'd\x03' # 피드 + 커트
def print_raw(data: bytes) -> bool:
"""바이트 데이터를 프린터로 전송"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((PRINTER_IP, PRINTER_PORT))
sock.sendall(data)
sock.close()
return True
except Exception as e:
print(f"프린터 오류: {e}", file=sys.stderr)
return False
def wrap_text(text: str, width: int = 44) -> list:
"""텍스트 줄바꿈 (44자 기준, 들여쓰기 고려)"""
if not text:
return []
lines = []
words = text.split()
current = ""
for word in words:
if len(current) + len(word) + 1 <= width:
current = current + " " + word if current else word
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines if lines else [text[:width]]
def center_text(text: str, width: int = 48) -> str:
"""중앙 정렬"""
text_len = len(text)
if text_len >= width:
return text
spaces = (width - text_len) // 2
return " " * spaces + text
def format_paai_receipt(pre_serial: str, patient_name: str,
analysis: dict, kims_summary: dict) -> str:
"""PAAI 복약안내 영수증 텍스트 생성 (48자 기준)"""
LINE = "=" * 48
THIN = "-" * 48
now = datetime.now().strftime("%Y-%m-%d %H:%M")
# 헤더
msg = f"\n{LINE}\n"
msg += center_text("[ PAAI 복약안내 ]") + "\n"
msg += f"{LINE}\n"
# 환자 정보
msg += f"환자: {patient_name}\n"
msg += f"처방번호: {pre_serial}\n"
msg += f"출력: {now}\n"
msg += f"{THIN}\n"
# 상호작용 요약
interaction_count = kims_summary.get('interaction_count', 0)
has_severe = kims_summary.get('has_severe', False)
if has_severe:
msg += "[!!] 중증 상호작용 있음!\n"
elif interaction_count > 0:
msg += f"[!] 약물 상호작용: {interaction_count}\n"
else:
msg += "[V] 상호작용 없음\n"
msg += "\n"
# 처방 해석
insight = analysis.get('prescription_insight', '')
if insight:
msg += f"{THIN}\n"
msg += ">> 처방 해석\n"
for line in wrap_text(insight, 44):
msg += f" {line}\n"
msg += "\n"
# 복용 주의사항
cautions = analysis.get('cautions', [])
if cautions:
msg += f"{THIN}\n"
msg += ">> 복용 주의사항\n"
for i, caution in enumerate(cautions[:4], 1):
# 첫 줄
first_line = True
for line in wrap_text(f"{i}. {caution}", 44):
if first_line:
msg += f" {line}\n"
first_line = False
else:
msg += f" {line}\n"
msg += "\n"
# 상담 포인트
counseling = analysis.get('counseling_points', [])
if counseling:
msg += f"{THIN}\n"
msg += ">> 상담 포인트\n"
for i, point in enumerate(counseling[:3], 1):
first_line = True
for line in wrap_text(f"{i}. {point}", 44):
if first_line:
msg += f" {line}\n"
first_line = False
else:
msg += f" {line}\n"
msg += "\n"
# OTC 추천
otc_recs = analysis.get('otc_recommendations', [])
if otc_recs:
msg += f"{THIN}\n"
msg += ">> OTC 추천\n"
for rec in otc_recs[:2]:
product = rec.get('product', '')
reason = rec.get('reason', '')
msg += f" - {product}\n"
for line in wrap_text(reason, 42):
msg += f" {line}\n"
msg += "\n"
# 푸터
msg += f"{LINE}\n"
msg += center_text("양구청춘약국 PAAI") + "\n"
msg += center_text("Tel: 033-481-5222") + "\n"
msg += "\n"
return msg
def print_paai_receipt(data: dict) -> bool:
"""PAAI 영수증 인쇄"""
try:
pre_serial = data.get('pre_serial', '')
patient_name = data.get('patient_name', '')
analysis = data.get('analysis', {})
kims_summary = data.get('kims_summary', {})
# 텍스트 생성
message = format_paai_receipt(pre_serial, patient_name, analysis, kims_summary)
# EUC-KR 인코딩 (한글 지원)
text_bytes = message.encode('euc-kr', errors='replace')
# 명령어 조합
command = INIT + text_bytes + b'\n\n\n' + CUT
return print_raw(command)
except Exception as e:
print(f"인쇄 오류: {e}", file=sys.stderr)
return False
def main():
if len(sys.argv) < 2:
print("사용법: python paai_printer_cli.py <json_file>", file=sys.stderr)
sys.exit(1)
json_path = sys.argv[1]
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if print_paai_receipt(data):
print("인쇄 완료")
sys.exit(0)
else:
sys.exit(1)
except Exception as e:
print(f"오류: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

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

672
backend/sooin_api.py Normal file
View File

@@ -0,0 +1,672 @@
# -*- coding: utf-8 -*-
"""
수인약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import time
import logging
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import SooinSession
logger = logging.getLogger(__name__)
# Blueprint 생성
sooin_bp = Blueprint('sooin', __name__, url_prefix='/api/sooin')
# ========== 세션 관리 ==========
_sooin_session = None
def get_sooin_session():
global _sooin_session
if _sooin_session is None:
_sooin_session = SooinSession()
return _sooin_session
def search_sooin_stock(keyword: str, search_type: str = 'kd_code'):
"""수인약품 재고 검색 (동기, 빠름)"""
try:
session = get_sooin_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'search_type': search_type,
'count': result['total'],
'items': result['items']
}
else:
return result
except Exception as e:
logger.error(f"수인약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@sooin_bp.route('/stock', methods=['GET'])
def api_sooin_stock():
"""
수인약품 재고 조회 API
GET /api/sooin/stock?kd_code=073100220
GET /api/sooin/stock?keyword=코자정&type=name
"""
kd_code = flask_request.args.get('kd_code', '').strip()
keyword = flask_request.args.get('keyword', '').strip()
search_type = flask_request.args.get('type', 'kd_code').strip()
search_term = kd_code or keyword
if kd_code:
search_type = 'kd_code'
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_sooin_stock(search_term, search_type)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_sooin_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@sooin_bp.route('/balance', methods=['GET'])
def api_sooin_balance():
"""
수인약품 잔고(미수금) 조회 API
GET /api/sooin/balance
Returns:
{
"success": true,
"balance": 14293001, // 현재 잔고 (누계합)
"prev_balance": 10592762, // 전일잔액
"monthly_sales": 3700239, // 월 매출
"yearly_sales": 34380314 // 연 누계 매출
}
"""
try:
session = get_sooin_session()
result = session.get_balance()
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 잔고 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'BALANCE_ERROR',
'message': str(e),
'balance': 0
}), 500
@sooin_bp.route('/monthly-sales', methods=['GET'])
def api_sooin_monthly_sales():
"""
수인약품 월간 매출 조회 API
GET /api/sooin/monthly-sales?year=2026&month=3
Returns:
{
"success": true,
"total_amount": 3700239, // 월간 매출 합계
"total_paid": 0, // 월간 입금 합계
"ending_balance": 14293001, // 월말 잔액
"opening_balance": 10592762, // 전일(기초) 잔액
"from_date": "2026-03-01",
"to_date": "2026-03-31"
}
"""
from datetime import datetime
year = flask_request.args.get('year', type=int)
month = flask_request.args.get('month', type=int)
# 기본값: 현재 월
if not year or not month:
now = datetime.now()
year = year or now.year
month = month or now.month
try:
session = get_sooin_session()
if hasattr(session, 'get_monthly_sales'):
result = session.get_monthly_sales(year, month)
return jsonify(result)
else:
return jsonify({
'success': False,
'error': 'NOT_IMPLEMENTED',
'message': '수인약품 월간 매출 조회 미구현'
}), 501
except Exception as e:
logger.error(f"수인약품 월간 매출 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'MONTHLY_SALES_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/cart', methods=['GET'])
def api_sooin_cart():
"""장바구니 조회 API"""
try:
session = get_sooin_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@sooin_bp.route('/cart/clear', methods=['POST'])
def api_sooin_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_sooin_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/cancel', methods=['POST'])
def api_sooin_cart_cancel():
"""
장바구니 항목 취소 API
POST /api/sooin/cart/cancel
{ "row_index": 0 }
또는
{ "internal_code": "32495" }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
if row_index is None and not internal_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'row_index 또는 internal_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.cancel_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/restore', methods=['POST'])
def api_sooin_cart_restore():
"""
취소된 항목 복원 API
POST /api/sooin/cart/restore
{ "row_index": 0 }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
try:
session = get_sooin_session()
result = session.restore_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order', methods=['POST'])
def api_sooin_order():
"""
수인약품 주문 API (장바구니 추가)
POST /api/sooin/order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"check_stock": true
}
"""
data = flask_request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/confirm', methods=['POST'])
def api_sooin_confirm():
"""주문 확정 API"""
data = flask_request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_sooin_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/full-order', methods=['POST'])
def api_sooin_full_order():
"""
전체 주문 API (검색 → 장바구니 → 확정)
POST /api/sooin/full-order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"auto_confirm": true,
"memo": "자동주문"
}
"""
data = flask_request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_sooin_session()
# 장바구니에 담기
cart_result = session.quick_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
spec=data.get('specification'),
check_stock=data.get('check_stock', True)
)
if not cart_result.get('success'):
return jsonify(cart_result)
if not data.get('auto_confirm', True):
return jsonify(cart_result)
# 주문 확정
confirm_result = session.submit_order(data.get('memo', ''))
if confirm_result.get('success'):
return jsonify({
'success': True,
'message': f"{cart_result['product']['name']} {cart_result['quantity']}개 주문 완료",
'product': cart_result['product'],
'quantity': cart_result['quantity'],
'confirmed': True
})
else:
return jsonify({
'success': False,
'error': confirm_result.get('error', 'CONFIRM_FAILED'),
'message': f"장바구니 담기 성공, 주문 확정 실패",
'product': cart_result['product'],
'cart_added': True
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order-batch', methods=['POST'])
def api_sooin_order_batch():
"""수인약품 일괄 주문 API"""
data = flask_request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_sooin_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})
# ========== 주문 조회 API ==========
@sooin_bp.route('/orders', methods=['GET'])
def api_sooin_orders():
"""
수인약품 주문 목록 조회 API
GET /api/sooin/orders?start_date=2026-03-01&end_date=2026-03-07
파라미터:
start_date: 시작일 (YYYY-MM-DD), 기본값: 오늘
end_date: 종료일 (YYYY-MM-DD), 기본값: 오늘
Returns:
{
"success": true,
"orders": [
{
"order_num": "202603095091177",
"order_date": "2026-03-09",
"order_time": "14:30:25",
"total_amount": 125000,
"item_count": 5,
"status": "완료"
}
],
"total_count": 10
}
"""
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
start_date = flask_request.args.get('start_date', today).strip()
end_date = flask_request.args.get('end_date', today).strip()
try:
session = get_sooin_session()
result = session.get_order_list(start_date, end_date)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 주문 목록 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDERS_ERROR',
'message': str(e),
'orders': [],
'total_count': 0
}), 500
@sooin_bp.route('/orders/today-summary', methods=['GET'])
def api_sooin_today_summary():
"""
수인약품 오늘 주문 집계 API
GET /api/sooin/orders/today-summary
Returns:
{
"success": true,
"date": "2026-03-09",
"summary": [
{
"kd_code": "073100220",
"product_name": "코자정50mg",
"total_quantity": 10,
"total_amount": 150000,
"order_count": 3
}
],
"grand_total_amount": 500000,
"grand_total_items": 25,
"order_count": 5
}
"""
try:
session = get_sooin_session()
result = session.get_today_order_summary()
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 오늘 주문 집계 오류: {e}")
return jsonify({
'success': False,
'error': 'TODAY_SUMMARY_ERROR',
'message': str(e),
'summary': [],
'grand_total_amount': 0,
'grand_total_items': 0,
'order_count': 0
}), 500
@sooin_bp.route('/orders/<order_num>', methods=['GET'])
def api_sooin_order_detail(order_num):
"""
수인약품 주문 상세 조회 API
GET /api/sooin/orders/202603095091177
Returns:
{
"success": true,
"order_num": "202603095091177",
"order_date": "2026-03-09",
"items": [
{
"product_code": "32495",
"kd_code": "073100220",
"product_name": "코자정50mg",
"spec": "30T",
"quantity": 2,
"unit_price": 15000,
"amount": 30000
}
],
"total_amount": 125000,
"item_count": 5
}
"""
if not order_num or not order_num.isdigit():
return jsonify({
'success': False,
'error': 'INVALID_ORDER_NUM',
'message': '유효한 주문번호를 입력하세요'
}), 400
try:
session = get_sooin_session()
result = session.get_order_detail(order_num)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 주문 상세 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_DETAIL_ERROR',
'message': str(e),
'order_num': order_num,
'items': [],
'total_amount': 0
}), 500
@sooin_bp.route('/orders/summary-by-kd', methods=['GET'])
def api_sooin_orders_by_kd():
"""
수인약품 주문량 KD코드별 집계 API (병렬 처리)
GET /api/sooin/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
"""
import re
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
start_date = flask_request.args.get('start_date', today).strip()
end_date = flask_request.args.get('end_date', today).strip()
def parse_spec(spec: str) -> int:
if not spec:
return 1
match = re.search(r'(\d+)', spec)
return int(match.group(1)) if match else 1
try:
session = get_sooin_session()
# 주문 목록 조회
orders_result = session.get_order_list(start_date, end_date)
if not orders_result.get('success'):
return jsonify({
'success': False,
'error': orders_result.get('error', 'ORDERS_FETCH_FAILED'),
'by_kd_code': {}
})
orders = orders_result.get('orders', [])
order_nums = [o.get('order_num') for o in orders if o.get('order_num')]
# 순차 처리 + 캐시 (캐시 효과 극대화)
all_details = []
for order_num in order_nums:
try:
detail = session.get_order_detail(order_num)
if detail.get('success'):
all_details.append(detail)
except Exception as e:
logger.warning(f"주문 상세 조회 실패: {e}")
# KD코드별 집계
kd_summary = {}
for detail in all_details:
for item in detail.get('items', []):
kd_code = item.get('kd_code', '')
if not kd_code:
continue
product_name = item.get('product_name', '')
spec = item.get('spec', '')
quantity = item.get('quantity', 0)
per_unit = parse_spec(spec)
total_units = quantity * per_unit
if kd_code not in kd_summary:
kd_summary[kd_code] = {
'product_name': product_name,
'spec': spec,
'boxes': 0,
'units': 0
}
kd_summary[kd_code]['boxes'] += quantity
kd_summary[kd_code]['units'] += total_units
return jsonify({
'success': True,
'order_count': len(order_nums),
'period': {'start': start_date, 'end': end_date},
'by_kd_code': kd_summary,
'total_products': len(kd_summary)
})
except Exception as e:
logger.error(f"수인약품 KD코드별 집계 오류: {e}")
return jsonify({
'success': False,
'error': 'SUMMARY_ERROR',
'message': str(e),
'by_kd_code': {}
}), 500

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,16 @@
<svg id="logo_foot" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64.36" height="32" viewBox="0 0 64.36 32">
<defs>
<clipPath id="clip-path">
<rect id="Rectangle_4823" data-name="Rectangle 4823" width="64.36" height="32" fill="#999"/>
</clipPath>
</defs>
<g id="Group_14242" data-name="Group 14242" clip-path="url(#clip-path)">
<path id="Path_14020" data-name="Path 14020" d="M29.966,33.717c.348,0,.524.154.524.463v2.732h1.539V34.207a.437.437,0,0,1,.5-.49.449.449,0,0,1,.511.5v5.058a.566.566,0,0,1-.169.413.5.5,0,0,1-.676.017.634.634,0,0,1-.166-.43V37.842H30.49v1.437a.566.566,0,0,1-.169.413.474.474,0,0,1-.355.141.464.464,0,0,1-.335-.124.631.631,0,0,1-.162-.43v-5.1q0-.463.5-.463m13.23,0a.465.465,0,0,1,.345.114.4.4,0,0,1,.169.349V44.189a.476.476,0,0,1-.149.369.511.511,0,0,1-.375.161.484.484,0,0,1-.361-.141.532.532,0,0,1-.149-.389V39.1H41.088a.477.477,0,0,1-.375-.144.412.412,0,0,1-.149-.322.419.419,0,0,1,.146-.329.492.492,0,0,1,.365-.138h1.6V34.207a.434.434,0,0,1,.149-.362.533.533,0,0,1,.371-.128m2.384,0a.5.5,0,0,1,.342.128.4.4,0,0,1,.169.349v10a.467.467,0,0,1-.153.369.481.481,0,0,1-.358.161.469.469,0,0,1-.348-.141.535.535,0,0,1-.153-.389V34.207a.476.476,0,0,1,.159-.369.5.5,0,0,1,.342-.121m-21.463.235a.5.5,0,0,1,.348.124.527.527,0,0,1,.176.4V35.64h2.484v-1.2a.446.446,0,0,1,.169-.376.475.475,0,0,1,.332-.114.53.53,0,0,1,.355.128.416.416,0,0,1,.169.362v4.152a.975.975,0,0,1-.421.806,1.489,1.489,0,0,1-.942.289H24.83a1.219,1.219,0,0,1-.849-.279,1,1,0,0,1-.361-.816v-4.1a.545.545,0,0,1,.149-.4.48.48,0,0,1,.348-.141m13.041.158h4.214c.3,0,.444.131.444.4-.013.346-.159.52-.444.52H39.765v2.115a9.559,9.559,0,0,0,.623,3.581,7.858,7.858,0,0,0,1.641,2.2.452.452,0,0,1,.179.393.5.5,0,0,1-.172.332.616.616,0,0,1-.351.161.534.534,0,0,1-.4-.151,7.155,7.155,0,0,1-1.316-1.792,5.441,5.441,0,0,1-.713-2.256,5.5,5.5,0,0,1-.726,2.259A6.6,6.6,0,0,1,37.1,43.686a.658.658,0,0,1-.4.138.477.477,0,0,1-.315-.158.47.47,0,0,1-.119-.356.572.572,0,0,1,.225-.386,6.677,6.677,0,0,0,1.476-1.99,9.345,9.345,0,0,0,.773-3.779V35.026H37.145c-.325,0-.481-.158-.471-.47s.159-.446.484-.446M24.641,36.57v1.906a.324.324,0,0,0,.073.248.456.456,0,0,0,.305.074h1.628A.592.592,0,0,0,27,38.718a.286.286,0,0,0,.126-.242V36.57Zm-.2,4.323h7.255a1.425,1.425,0,0,1,.965.316,1.112,1.112,0,0,1,.381.869V44.2a.517.517,0,0,1-.176.393.477.477,0,0,1-.335.124.489.489,0,0,1-.342-.121.54.54,0,0,1-.159-.4V42.118a.305.305,0,0,0-.06-.218.494.494,0,0,0-.3-.077H24.439q-.507,0-.507-.463c0-.312.169-.467.507-.467" transform="translate(-15.788 -22.399)" fill="#999"/>
<path id="Path_14021" data-name="Path 14021" d="M45.059,44.463a.73.73,0,0,1-.531-.217.8.8,0,0,1-.228-.575V33.689a.735.735,0,0,1,.244-.563.786.786,0,0,1,1.026,0,.647.647,0,0,1,.258.546v10a.731.731,0,0,1-.241.567.723.723,0,0,1-.528.225m0-11a.246.246,0,0,0-.173.058.211.211,0,0,0-.069.171v9.982a.273.273,0,0,0,.077.2.215.215,0,0,0,.165.064.224.224,0,0,0,.172-.082.218.218,0,0,0,.08-.187v-10a.138.138,0,0,0-.06-.135.268.268,0,0,0-.192-.08m-2.394,11a.737.737,0,0,1-.544-.217.8.8,0,0,1-.225-.575v-.642a.8.8,0,0,1-.208.3.876.876,0,0,1-.5.227.8.8,0,0,1-.6-.216,7.454,7.454,0,0,1-1.371-1.862,6.958,6.958,0,0,1-.489-1.107,6.8,6.8,0,0,1-.5,1.113,6.894,6.894,0,0,1-1.484,1.887.925.925,0,0,1-.566.2.743.743,0,0,1-.5-.242.728.728,0,0,1-.188-.546.835.835,0,0,1,.318-.575,6.445,6.445,0,0,0,1.41-1.906,9.118,9.118,0,0,0,.745-3.662V34.77H36.624a.732.732,0,0,1-.552-.2.715.715,0,0,1-.178-.538.665.665,0,0,1,.743-.7h4.214a.626.626,0,0,1,.7.658c-.027.707-.5.782-.7.782H39.5v1.853a9.335,9.335,0,0,0,.6,3.483,7.625,7.625,0,0,0,1.574,2.106.731.731,0,0,1,.215.288V38.844h-1.33A.722.722,0,0,1,40,38.613a.649.649,0,0,1-.215-.5.68.68,0,0,1,.236-.527.735.735,0,0,1,.534-.2H41.9v-3.7a.681.681,0,0,1,.251-.57.765.765,0,0,1,.519-.182.724.724,0,0,1,.52.175.65.65,0,0,1,.262.55V43.671a.738.738,0,0,1-.235.564.758.758,0,0,1-.547.228m-2.112-6.552a.229.229,0,0,0-.182.061.172.172,0,0,0-.07.144.149.149,0,0,0,.06.125.263.263,0,0,0,.205.08h1.588a.26.26,0,0,1,.259.262v5.088a.267.267,0,0,0,.074.2.226.226,0,0,0,.178.064.257.257,0,0,0,.192-.085.218.218,0,0,0,.074-.184V33.662a.141.141,0,0,0-.059-.135.23.23,0,0,0-.176-.067.363.363,0,0,0-.218.061.2.2,0,0,0-.063.167V37.65a.26.26,0,0,1-.259.262Zm-3.916-4.058c-.225,0-.225.074-.225.185a.251.251,0,0,0,.032.165.279.279,0,0,0,.18.043h1.595a.26.26,0,0,1,.259.262v2.128a9.648,9.648,0,0,1-.795,3.885A6.98,6.98,0,0,1,36.144,42.6a.324.324,0,0,0-.142.217.194.194,0,0,0,.052.153.22.22,0,0,0,.152.076.412.412,0,0,0,.219-.089,6.378,6.378,0,0,0,1.353-1.733,5.267,5.267,0,0,0,.694-2.149.26.26,0,0,1,.258-.243h0a.26.26,0,0,1,.256.246,5.206,5.206,0,0,0,.681,2.145,6.934,6.934,0,0,0,1.268,1.728.269.269,0,0,0,.209.08.357.357,0,0,0,.2-.1.226.226,0,0,0,.085-.156.188.188,0,0,0-.085-.17,8.153,8.153,0,0,1-1.706-2.282,9.855,9.855,0,0,1-.654-3.7V34.508a.26.26,0,0,1,.259-.262h1.608c.086,0,.176,0,.186-.269,0-.077,0-.124-.186-.124Zm-4.629,10.61a.744.744,0,0,1-.513-.187.808.808,0,0,1-.246-.592V41.6c0-.009,0-.017,0-.025a.477.477,0,0,0-.1-.009H23.918a.728.728,0,1,1,0-1.453h7.255a1.679,1.679,0,0,1,1.133.378,1.374,1.374,0,0,1,.472,1.068v2.125a.783.783,0,0,1-.26.586.737.737,0,0,1-.51.193m-8.091-3.826c-.237,0-.249.078-.249.2s.012.2.249.2h7.228a.738.738,0,0,1,.457.13.185.185,0,0,1,.027.024.545.545,0,0,1,.136.4v2.084a.279.279,0,0,0,.084.211.235.235,0,0,0,.159.044.223.223,0,0,0,.163-.058.252.252,0,0,0,.089-.2V41.56a.855.855,0,0,0-.294-.673,1.169,1.169,0,0,0-.794-.25Zm8.091-1.061a.72.72,0,0,1-.514-.2.914.914,0,0,1-.245-.619V37.586H30.228v1.175a.835.835,0,0,1-.245.6.729.729,0,0,1-.538.217.72.72,0,0,1-.514-.2.9.9,0,0,1-.242-.619v-5.1a.681.681,0,0,1,.756-.725.694.694,0,0,1,.783.725v2.47h1.021V33.689a.694.694,0,0,1,.759-.752.7.7,0,0,1,.769.765v5.058a.835.835,0,0,1-.245.6.713.713,0,0,1-.525.217m-.142-.56a.226.226,0,0,0,.142.037.2.2,0,0,0,.159-.065.3.3,0,0,0,.093-.228V33.7c0-.171-.035-.242-.252-.242s-.242.057-.242.228v2.705a.26.26,0,0,1-.259.262H29.969a.26.26,0,0,1-.259-.262V33.662c0-.108,0-.2-.265-.2-.227,0-.239.077-.239.2v5.1a.367.367,0,0,0,.1.255.226.226,0,0,0,.142.037.217.217,0,0,0,.172-.065.3.3,0,0,0,.093-.228V37.324a.26.26,0,0,1,.259-.262h1.539a.26.26,0,0,1,.259.262v1.437A.374.374,0,0,0,31.866,39.016Zm-5.6.416H24.309a1.473,1.473,0,0,1-1.021-.345,1.255,1.255,0,0,1-.448-1.011v-4.1a.811.811,0,0,1,.225-.584.784.784,0,0,1,1.051-.027.794.794,0,0,1,.262.6v.9h1.966v-.936a.705.705,0,0,1,.271-.584.715.715,0,0,1,.489-.168.786.786,0,0,1,.52.188.662.662,0,0,1,.262.564v4.152a1.243,1.243,0,0,1-.525,1.015,1.74,1.74,0,0,1-1.1.341M23.6,33.7a.225.225,0,0,0-.168.068.277.277,0,0,0-.07.211v4.1a.74.74,0,0,0,.267.614.975.975,0,0,0,.685.219h1.956a1.244,1.244,0,0,0,.784-.234.72.72,0,0,0,.32-.6V33.924c0-.106-.039-.134-.06-.149a.3.3,0,0,0-.206-.08.216.216,0,0,0-.16.049.193.193,0,0,0-.082.18v1.2a.26.26,0,0,1-.259.262H24.12a.26.26,0,0,1-.259-.262V33.961a.265.265,0,0,0-.092-.209A.242.242,0,0,0,23.6,33.7m2.53,4.847H24.5a.668.668,0,0,1-.482-.144.563.563,0,0,1-.155-.44V36.052a.26.26,0,0,1,.259-.262H26.6a.26.26,0,0,1,.259.262v1.906a.549.549,0,0,1-.234.454.811.811,0,0,1-.5.13m-1.745-.531a.733.733,0,0,0,.117.008h1.628a.423.423,0,0,0,.2-.028c.023-.016.023-.02.023-.032V36.314H24.379v1.645a.5.5,0,0,0,0,.053" transform="translate(-15.267 -21.881)" fill="#999"/>
<path id="Path_14022" data-name="Path 14022" d="M110.236,33.72c.348,0,.524.158.524.473v1.413h1.283a.4.4,0,0,1,.451.45c0,.312-.149.467-.451.467H110.76v1.494h1.283a.4.4,0,0,1,.451.45c0,.312-.149.467-.451.467H110.76v.4a.459.459,0,0,1-.166.373.487.487,0,0,1-.358.141.5.5,0,0,1-.361-.151.476.476,0,0,1-.136-.366V34.2c0-.319.166-.477.5-.477m-5.279.222a2.7,2.7,0,0,1,2.135.809,2.62,2.62,0,0,1,.885,1.98,2.545,2.545,0,0,1-.892,1.943,2.665,2.665,0,0,1-2.129.809,2.99,2.99,0,0,1-2.324-.977,2.249,2.249,0,0,1-.723-1.776,2.675,2.675,0,0,1,.759-1.94,3.09,3.09,0,0,1,2.288-.849m0,.93a2.038,2.038,0,0,0-1.535.564,1.978,1.978,0,0,0,0,2.581,2.006,2.006,0,0,0,1.532.574,2.123,2.123,0,0,0,1.479-.463,2.1,2.1,0,0,0,.534-1.4,1.848,1.848,0,0,0-.4-1.255,2.258,2.258,0,0,0-1.611-.6m-2.122,5.79h6.529a1.553,1.553,0,0,1,1.081.379,1.145,1.145,0,0,1,.315.879v2.286a.517.517,0,0,1-.176.393.488.488,0,0,1-.348.124.5.5,0,0,1-.342-.121.551.551,0,0,1-.156-.4V41.923a.316.316,0,0,0-.073-.248.438.438,0,0,0-.305-.084h-6.526a.411.411,0,0,1-.471-.46c-.01-.312.146-.47.471-.47" transform="translate(-68.119 -22.402)" fill="#999"/>
<path id="Path_14023" data-name="Path 14023" d="M109.715,44.466a.753.753,0,0,1-.51-.184.818.818,0,0,1-.246-.595V41.4a.463.463,0,0,0,0-.057.387.387,0,0,0-.116-.013h-6.525a.667.667,0,0,1-.73-.722.708.708,0,0,1,.178-.529.731.731,0,0,1,.552-.2h6.529a1.8,1.8,0,0,1,1.253.446,1.4,1.4,0,0,1,.4,1.074v2.286a.781.781,0,0,1-.26.586.739.739,0,0,1-.523.193m-7.4-4.061a.277.277,0,0,0-.18.043.238.238,0,0,0-.032.157c0,.159.03.206.213.206h6.525a.668.668,0,0,1,.483.155.567.567,0,0,1,.154.439v2.282a.287.287,0,0,0,.083.214.252.252,0,0,0,.156.042.231.231,0,0,0,.173-.055.255.255,0,0,0,.093-.2V41.4a.892.892,0,0,0-.232-.687,1.29,1.29,0,0,0-.906-.31Zm6.666.954,0,0,0,0m.734-1.766a.751.751,0,0,1-.541-.225.729.729,0,0,1-.215-.554V33.678a.687.687,0,0,1,.756-.738.7.7,0,0,1,.783.735v1.151h1.025a.653.653,0,0,1,.71.712.661.661,0,0,1-.71.728H110.5v.97h1.025a.653.653,0,0,1,.71.711.661.661,0,0,1-.71.728H110.5v.141a.784.784,0,0,1-.783.775m0-6.129c-.215,0-.239.069-.239.215v5.135c0,.124.04.161.053.174a.247.247,0,0,0,.364.014.21.21,0,0,0,.087-.184v-.4a.26.26,0,0,1,.259-.261h1.283c.149,0,.192-.027.192-.205,0-.145-.025-.188-.192-.188h-1.283a.26.26,0,0,1-.259-.262V36a.26.26,0,0,1,.259-.262h1.283c.149,0,.192-.027.192-.2,0-.148-.024-.188-.192-.188h-1.283a.26.26,0,0,1-.259-.262V33.675c0-.113,0-.211-.265-.211m-5.279,5.763a3.249,3.249,0,0,1-2.515-1.061,2.494,2.494,0,0,1-.791-1.953,2.945,2.945,0,0,1,.834-2.123,3.352,3.352,0,0,1,2.472-.928,2.955,2.955,0,0,1,2.328.9,2.868,2.868,0,0,1,.952,2.155,2.814,2.814,0,0,1-.978,2.137,2.9,2.9,0,0,1-2.3.877m0-5.541a2.822,2.822,0,0,0-2.106.774,2.4,2.4,0,0,0-.682,1.754,1.983,1.983,0,0,0,.634,1.579,2.74,2.74,0,0,0,2.154.912,2.433,2.433,0,0,0,1.934-.72,2.307,2.307,0,0,0,.828-1.771,2.35,2.35,0,0,0-.8-1.788,2.45,2.45,0,0,0-1.96-.739m0,4.649a2.262,2.262,0,0,1-1.719-.654,2.241,2.241,0,0,1,0-2.945,2.3,2.3,0,0,1,1.721-.643,2.518,2.518,0,0,1,1.788.674,2.112,2.112,0,0,1,.484,1.447,2,2,0,0,1-2.272,2.121m0-3.719a1.781,1.781,0,0,0-1.351.486,1.716,1.716,0,0,0,0,2.216,1.749,1.749,0,0,0,1.346.494,1.889,1.889,0,0,0,1.306-.4,1.816,1.816,0,0,0,.448-1.2,1.6,1.6,0,0,0-.337-1.082,1.988,1.988,0,0,0-1.417-.515" transform="translate(-67.597 -21.884)" fill="#999"/>
<path id="Path_14024" data-name="Path 14024" d="M141.333,34.3h8.14a.538.538,0,0,1,.365.128.459.459,0,0,1,.146.326.5.5,0,0,1-.109.312.49.49,0,0,1-.365.151h-1.273l.076.047a.317.317,0,0,1,.07.064.465.465,0,0,1,.1.4l-.385,1.628h1.416a.414.414,0,0,1,.471.44c.017.319-.116.48-.408.48H141.28c-.288,0-.428-.158-.418-.47a.4.4,0,0,1,.458-.45h1.429l-.371-1.668a.492.492,0,0,1,.093-.409l.06-.057h-1.2a.41.41,0,0,1-.471-.46c-.01-.3.146-.456.471-.456m1.854.916.063.057a.527.527,0,0,1,.133.3l.365,1.776H147.1l.365-1.755a.428.428,0,0,1,.249-.366l.023-.013Zm-2.7,3.877h9.888a.443.443,0,0,1,.484.49q0,.4-.458.4h-4.473v.91l0,.06h2.656a1.362,1.362,0,0,1,.905.3.884.884,0,0,1,.351.738v1.678a1.056,1.056,0,0,1-.318.8,1.125,1.125,0,0,1-.839.322h-6.5a1.183,1.183,0,0,1-.859-.285A1.041,1.041,0,0,1,141,43.7V42.1a1.119,1.119,0,0,1,.325-.842,1.226,1.226,0,0,1,.872-.3h2.729l0-.034v-.936h-4.447q-.5,0-.5-.453c0-.292.169-.44.511-.44m1.936,2.779a.551.551,0,0,0-.312.057.418.418,0,0,0-.076.285v1.332c0,.134.017.218.056.252a.623.623,0,0,0,.371.077h5.922a.645.645,0,0,0,.332-.064.254.254,0,0,0,.086-.215V42.174c0-.111-.023-.181-.063-.208a.631.631,0,0,0-.378-.094Z" transform="translate(-93.565 -22.788)" fill="#999"/>
<path id="Path_14025" data-name="Path 14025" d="M148.163,44.535h-6.5a1.431,1.431,0,0,1-1.039-.358,1.294,1.294,0,0,1-.4-.994v-1.6a1.374,1.374,0,0,1,.405-1.032,1.48,1.48,0,0,1,1.05-.371h2.468v-.447h-4.188a.71.71,0,1,1,.013-1.416h9.888a.7.7,0,0,1,.742.741.638.638,0,0,1-.716.675h-4.214v.447h2.394a1.612,1.612,0,0,1,1.071.362,1.143,1.143,0,0,1,.444.94v1.678a1.315,1.315,0,0,1-.4.985,1.375,1.375,0,0,1-1.017.394M141.677,40.7a.973.973,0,0,0-.7.229.855.855,0,0,0-.242.65v1.6a.782.782,0,0,0,.24.612.944.944,0,0,0,.685.217h6.5a.872.872,0,0,0,.656-.246.8.8,0,0,0,.243-.61V41.478a.625.625,0,0,0-.249-.53,1.122,1.122,0,0,0-.749-.249h-2.656a.257.257,0,0,1-.188-.082.264.264,0,0,1-.071-.194l0-.061v-.9a.26.26,0,0,1,.259-.261h4.473c.2,0,.2-.053.2-.141-.008-.194-.076-.228-.226-.228h-9.888c-.252,0-.252.083-.252.178s0,.191.239.191H144.4a.26.26,0,0,1,.259.261l0,.945a.263.263,0,0,1-.066.2.257.257,0,0,1-.192.086Zm6.191,2.92h-5.922a.813.813,0,0,1-.536-.138.565.565,0,0,1-.15-.453V41.7a.654.654,0,0,1,.147-.465.7.7,0,0,1,.5-.139h5.939a.866.866,0,0,1,.531.144.477.477,0,0,1,.169.419v1.423a.505.505,0,0,1-.187.422.862.862,0,0,1-.489.118m-6.09-.535a1.09,1.09,0,0,0,.168.011h5.922a.766.766,0,0,0,.157-.013l0-1.427c0-.006,0-.012,0-.017a.571.571,0,0,0-.182-.023h-5.939a1.052,1.052,0,0,0-.125.006.574.574,0,0,0,0,.075v1.332c0,.022,0,.041,0,.056m6.3-1.423,0,0,0,0m.978-3.648h-8.293a.66.66,0,0,1-.5-.189.732.732,0,0,1-.177-.551.653.653,0,0,1,.716-.7H141.9l-.3-1.349a.971.971,0,0,1-.01-.262h-.783a.667.667,0,0,1-.729-.722.686.686,0,0,1,.174-.515.735.735,0,0,1,.555-.2h8.14a.8.8,0,0,1,.534.191.73.73,0,0,1,.236.524.77.77,0,0,1-.164.474.758.758,0,0,1-.569.251h-.777a.8.8,0,0,1,0,.291l-.311,1.32h1.088a.672.672,0,0,1,.729.689.739.739,0,0,1-.169.564.651.651,0,0,1-.5.19m-8.253-.919c-.172,0-.2.045-.2.188a.28.28,0,0,0,.032.178.2.2,0,0,0,.127.03h8.293a.186.186,0,0,0,.121-.027.283.283,0,0,0,.028-.178c-.007-.144-.038-.191-.213-.191h-1.416a.258.258,0,0,1-.2-.1.264.264,0,0,1-.048-.223l.384-1.628a.191.191,0,0,0-.042-.166l-.083-.056a.127.127,0,1,0-.24,0l-.031.018c-.1.053-.109.1-.112.151a.242.242,0,0,1,0,.037l-.365,1.755a.26.26,0,0,1-.253.208h-3.349a.259.259,0,0,1-.253-.209l-.365-1.776a.3.3,0,0,0-.067-.175l-.048-.043a.264.264,0,0,1-.069-.288.259.259,0,0,1,.242-.168h4.546a.259.259,0,0,1,.25.194l0,.009s0-.008,0-.012a.259.259,0,0,1,.249-.191h1.273a.235.235,0,0,0,.182-.075.253.253,0,0,0,.033-.127.191.191,0,0,0-.07-.141.289.289,0,0,0-.182-.051h-8.14a.284.284,0,0,0-.183.043.217.217,0,0,0-.03.143c0,.162.028.207.213.207h1.2a.258.258,0,0,1,.24.165.264.264,0,0,1-.063.287l-.059.057a.264.264,0,0,0-.016.177l.368,1.653a.265.265,0,0,1-.05.221.258.258,0,0,1-.2.1Zm2.638-.524h2.929l.318-1.531a.745.745,0,0,1,.012-.08h-3.588c0,.02.008.041.01.062Z" transform="translate(-93.044 -22.269)" fill="#999"/>
<path id="Path_14026" data-name="Path 14026" d="M32.18,32c-8.505,0-16.514-1.612-22.55-4.54C3.42,24.448,0,20.378,0,16S3.42,7.552,9.63,4.54C15.666,1.612,23.674,0,32.18,0S48.694,1.612,54.73,4.54c6.21,3.012,9.63,7.082,9.63,11.46s-3.42,8.448-9.63,11.46C48.694,30.388,40.685,32,32.18,32m0-30.49C23.893,1.51,16.114,3.07,10.275,5.9,4.611,8.649,1.492,12.235,1.492,16s3.119,7.351,8.783,10.1C16.114,28.93,23.893,30.49,32.18,30.49S48.246,28.93,54.085,26.1c5.664-2.747,8.783-6.333,8.783-10.1S59.749,8.649,54.085,5.9C48.246,3.07,40.467,1.51,32.18,1.51" transform="translate(0 0)" fill="#999"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<script type="text/javascript">
location.href = "./homepage/intro.asp";
</script>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#7c3aed"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-family="Arial" font-weight="bold" font-size="16"></text>
</svg>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 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

@@ -924,7 +924,8 @@
const txs = detailData.mileage.transactions;
container.innerHTML = txs.map(tx => {
const isPositive = tx.points > 0;
const date = tx.created_at ? new Date(tx.created_at).toLocaleString('ko-KR', {
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
const date = tx.created_at ? new Date(tx.created_at + 'Z').toLocaleString('ko-KR', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
}) : '';
@@ -1038,6 +1039,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 +1055,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('');
@@ -1063,8 +1076,8 @@
}
container.innerHTML = detailData.interests.map(item => {
// 날짜 포맷
const date = item.created_at ? new Date(item.created_at).toLocaleString('ko-KR', {
// 날짜 포맷 (DB는 UTC → KST 변환)
const date = item.created_at ? new Date(item.created_at + 'Z').toLocaleString('ko-KR', {
month: 'short', day: 'numeric'
}) : '';
@@ -1111,6 +1124,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,495 @@
<!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 => {
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
const time = new Date(log.created_at + 'Z').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

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>

File diff suppressed because it is too large Load Diff

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>

3073
backend/templates/pmr.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,870 @@
<!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 => {
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
const date = new Date(log.created_at + 'Z');
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>${new Date(log.created_at + 'Z').toLocaleString('ko-KR')}</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>

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
import requests
print('=== 주문량 API 테스트 (지오영 + 수인 + 백제) ===')
date = '2026-03-07'
# 지오영
geo = requests.get(f'http://localhost:7001/api/geoyoung/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
geo_count = len(geo.get('by_kd_code', {}))
print(f'지오영: {"OK" if geo.get("success") else "FAIL"} - {geo_count}개 품목')
# 수인
sooin = requests.get(f'http://localhost:7001/api/sooin/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
sooin_count = len(sooin.get('by_kd_code', {}))
print(f'수인: {"OK" if sooin.get("success") else "FAIL"} - {sooin_count}개 품목')
# 백제
baekje = requests.get(f'http://localhost:7001/api/baekje/orders/summary-by-kd?start_date={date}&end_date={date}', timeout=60).json()
baekje_count = len(baekje.get('by_kd_code', {}))
print(f'백제: {"OK" if baekje.get("success") else "FAIL"} - {baekje_count}개 품목')
if baekje.get('message'):
print(f' 메시지: {baekje.get("message")}')
print()
print(f'총 품목: {geo_count + sooin_count + baekje_count}')

60
backend/test_api_debug.py Normal file
View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""API 직접 테스트 - 디버그용"""
import requests
import json
# 지오영에서 실제 품목 검색해서 internal_code 얻기
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import GeoYoungSession
g = GeoYoungSession()
g.login()
# 재고 있는 품목 검색
r = g.search_products('라식스')
if r.get('items'):
item = r['items'][0]
print("="*60)
print("검색된 품목:")
print(f" name: {item['name']}")
print(f" internal_code: {item['internal_code']}")
print(f" stock: {item['stock']}")
print(f" price: {item['price']}")
print("="*60)
# API 호출
payload = {
"wholesaler_id": "geoyoung",
"items": [{
"drug_code": "652100200",
"kd_code": "라식스",
"internal_code": item['internal_code'], # 검색된 internal_code 사용
"product_name": item['name'],
"manufacturer": "한독",
"specification": item.get('spec', ''),
"order_qty": 1,
"usage_qty": 100,
"current_stock": 0
}],
"reference_period": "2026-02-01~2026-03-07",
"dry_run": False,
"cart_only": False
}
print("\n" + "="*60)
print("API 요청:")
print(json.dumps(payload, ensure_ascii=False, indent=2))
print("="*60)
response = requests.post(
'http://localhost:7001/api/order/quick-submit',
json=payload,
timeout=60
)
print("\n" + "="*60)
print(f"응답 (status: {response.status_code}):")
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
print("="*60)
else:
print("품목을 찾을 수 없습니다")

52
backend/test_checkbox.py Normal file
View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
from bs4 import BeautifulSoup
import re
s = SooinSession()
s.login()
s.clear_cart()
result = s.search_products('코자정')
product = result['items'][0]
s.add_to_cart(product['internal_code'], qty=1, price=product['price'], stock=product['stock'])
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
soup = BeautifulSoup(resp.content, 'html.parser')
form = soup.find('form', {'id': 'frmBag'})
form_data = {}
for inp in form.find_all('input'):
name = inp.get('name', '')
if not name: continue
inp_type = inp.get('type', '').lower()
if inp_type == 'checkbox':
# 체크박스는 'on' 값으로 전송!
form_data[name] = 'on'
else:
form_data[name] = inp.get('value', '')
form_data['kind'] = 'order'
form_data['x'] = '10'
form_data['y'] = '10'
print('체크박스 포함된 form_data:')
print(f" chk_0: {form_data.get('chk_0')}")
resp = s.session.post(s.BAG_URL, data=form_data, timeout=30)
alert_match = re.search(r'alert\("([^"]*)"\)', resp.text)
alert_msg = alert_match.group(1) if alert_match else 'N/A'
print(f'alert 메시지: {alert_msg}')
# 장바구니 확인
resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
soup2 = BeautifulSoup(resp2.content, 'html.parser')
int_array = soup2.find('input', {'name': 'intArray'})
val = int_array.get('value') if int_array else '없음'
print(f'주문 후 intArray: {val}')
if val == '-1':
print('\n🎉 주문 성공!')
else:
print('\n❌ 주문 실패')

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""체크박스 HTML 상태 확인"""
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
from bs4 import BeautifulSoup
import re
s = SooinSession()
s.login()
s.clear_cart()
# 품목 담기
r1 = s.search_products('코자정')
p1 = r1['items'][0]
s.add_to_cart(p1['internal_code'], qty=1, price=p1['price'], stock=p1['stock'])
# 취소하기 전 HTML
print('=== 취소 전 HTML ===')
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
soup = BeautifulSoup(resp.content, 'html.parser')
for cb in soup.find_all('input', {'type': 'checkbox'}):
name = cb.get('name', '')
checked = cb.get('checked')
print(f"체크박스 {name}: checked={checked}")
# 취소
print('\n=== 취소 실행 ===')
s.cancel_item(row_index=0)
# 취소 후 HTML
print('\n=== 취소 후 HTML ===')
resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
soup2 = BeautifulSoup(resp2.content, 'html.parser')
for cb in soup2.find_all('input', {'type': 'checkbox'}):
name = cb.get('name', '')
checked = cb.get('checked')
print(f"체크박스 {name}: checked={checked}")
# 체크박스 HTML 전체 출력
cb = soup2.find('input', {'type': 'checkbox'})
if cb:
print(f"\n전체 HTML: {cb}")

Some files were not shown because too many files have changed in this diff Show More