Compare commits

..

85 Commits

Author SHA1 Message Date
thug0bin
e7daadb316 fix: QR 품목은 MSSQL 수납완료 데이터에서만 조회 2026-03-29 12:58:45 +09:00
thug0bin
8bcea3040f feat: QR 토큰 API에서 클라이언트 items 우선 처리
- /api/admin/qr/generate에서 client_items 파라미터 추가
- 클라이언트 전달 items 우선, 없으면 MSSQL 조회
- get_sale_items 쿼리 컬럼명 수정 (DrugCode, GoodsName 등)
2026-03-29 12:54:20 +09:00
thug0bin
21e1c3adfa feat: QR 토큰 품목 상세 전송 지원 (items 파라미터) 2026-03-29 12:37:36 +09:00
thug0bin
3871154509 fix: PAAI OpenClaw 호출 방식 변경 (WebSocket -> CLI)
- OpenClaw 업데이트로 device identity 필수화됨
- WebSocket 대신 Node.js 직접 호출로 변경
- 특수문자/줄바꿈 문제 해결 (shell=True 제거)
- subprocess array 방식으로 안전한 인자 전달
2026-03-28 12:42:01 +09:00
thug0bin
f855fc5916 feat: OTC 라벨 프리셋 확인 API + 인쇄 조건 강화
- GET /api/otc-label-check - 바코드 배열로 프리셋 존재 여부 일괄 확인
- 인쇄 API: 프리셋 없으면 인쇄 안 함 (404 반환)
- POS 장바구니에서 인쇄 버튼 활성화 판단용
2026-03-14 00:36:57 +09:00
thug0bin
cb7450f654 feat: OTC 라벨 바로 인쇄 API 추가 (CORS 지원)
- GET /api/otc-label-print/{barcode} - 바로 인쇄
- 프리셋 있으면 해당 데이터로, 없으면 약품명만으로 인쇄
- 인쇄 횟수 자동 카운트
2026-03-14 00:18:58 +09:00
thug0bin
67fb7bf937 feat: 동물약 안내서/제품 이미지 외부 API 추가 (CORS 지원)
- GET /api/animal-drug-print/{apc|barcode} - 바로 인쇄
- GET /api/product-image/{barcode} - 제품 이미지 반환
- GET /api/product-image-info/{barcode} - 이미지 메타데이터
- 바코드→APC 자동 변환 지원
2026-03-14 00:18:04 +09:00
thug0bin
aed0c314b7 docs: DB 구조 분석 - 제품/입고/판매/마진 흐름 문서화
- 핵심 테이블 구조 (CD_GOODS, SALE_SUB, WH_sub 등)
- 바코드 매핑 구조 (대표바코드, 단위바코드)
- 마진 계산 로직 분석
- 마진 0 문제 원인 파악: 입고 없이 판매 시 INPRICE=0
2026-03-13 14:57:54 +09:00
thug0bin
2ad4ad05f3 docs: 재고 분석 통계 최적화 노트 추가
- 추세 분석 로직 변천사 기록
- 롤백 포인트 (커밋 hash) 정리
- 대안 로직들 (전월대비, 이동평균, 선형회귀) 정리
- TODO 리스트
2026-03-13 09:23:00 +09:00
thug0bin
0b81999cb4 feat: 재고량 vs 처방 사용량 이중 Y축 비교 그래프 추가
- /api/stock-analytics/stock-with-usage API 추가
- 일별/주별/월별 분석 기간 선택
- 재고 추세 + 사용량 추세 동시 분석
- 추세 해석 카드 (과잉재고/부족 위험 등 자동 진단)
2026-03-13 08:07:12 +09:00
thug0bin
2ca35cdc82 feat: 재고 분석 페이지 - 재고 변화 추이 그래프 추가 2026-03-13 08:02:44 +09:00
thug0bin
c9f89cb9b0 fix(stock-analytics): 입출고 컬럼 의미 수정
- credit = 출고 (환자에게 판매)
- debit = 입고 (도매상에서 들어옴)
- 약국 재고 관점에서 올바르게 표시
2026-03-13 00:47:02 +09:00
thug0bin
591af31da9 fix(stock-analytics): 동시 요청 시 DB 연결 충돌 해결
- daily-trend, stock-level API에서 각 요청마다 새로운 pyodbc 연결 사용
- SQLAlchemy 세션 공유로 인한 'concurrent operations' 에러 해결
- 연결 종료 처리 추가 (정상/에러 모두)
2026-03-13 00:35:29 +09:00
thug0bin
93c643cb8e fix(rx-usage): GROUP BY 구조 개선으로 중복 버그 해결
## 문제
- ORDER BY를 INV_QUAN으로 변경 시 같은 약품이 중복 표시됨
- 예: 수인 30개 주문 → '수인 30, 수인 30' (60개로 잘못 표시)

## 근본 원인
- GROUP BY에 JOIN된 테이블 컬럼(IT.IM_QT_sale_debit, POS.CD_NM_sale) 포함
- IM_total, CD_item_position이 1:N 관계일 때 같은 DrugCode가 여러 행으로 팽창
- GROUP BY가 이 값들을 포함하여 중복 행 유지

## 해결
- GROUP BY를 P.DrugCode 만으로 축소 (진짜 그룹핑 기준만)
- 나머지 컬럼은 MAX() 집계함수 사용
- DrugCode당 정확히 1행 보장

## 변경 내용
- GROUP BY: 6개 컬럼 → 1개 (P.DrugCode)
- SELECT: ISNULL() → MAX(ISNULL()) 래핑
- ORDER BY: INV_QUAN 기준 정렬 (투약량순)
2026-03-13 00:19:34 +09:00
thug0bin
94dea2ab3a fix(rx-usage): 투약량 계산 수정 (INV_QUAN 사용)
- total_dose를 QUAN×Days → INV_QUAN으로 변경
- 투약량 = 1회복용량 × 복용횟수 × 일수 (정확한 계산)
- ORDER BY는 기존 유지 (GROUP BY 구조 문제로 임시)
2026-03-13 00:10:17 +09:00
thug0bin
d901c67125 fix(pmr): 라벨 박스 내 텍스트 레이아웃 일관성 개선
- 고정 라인 높이(32px) 기준 중앙 정렬 (글자 내용과 무관하게 동일 레이아웃)
- 1회복용량 ↔ 복용횟수 간격 조정 (6px)
- 박스 내 텍스트 전체 5px 위로 조정
2026-03-12 22:45:29 +09:00
thug0bin
80f7f0ac80 style(pmr): 라벨 레이아웃 미세 조정
- 상단 지그재그 ↔ 이름 간격 축소 (15 → 8)
- 보관조건 폰트 크기 증가 (22 → 28)
2026-03-12 17:31:57 +09:00
thug0bin
4944669470 feat(pmr): 라벨에 보관조건 표시 추가
- get_conversion_factor()에서 storage_conditions 함께 반환
- PostgreSQL 조회 결과 없으면 '실온보관' 기본값
- create_label_image()에 storage_conditions 파라미터 추가
- 용법 박스 아래, 조제일 위 여백에 보관조건 표시
- 모든 약품에 보관조건 표시 (실온보관 포함)
2026-03-12 17:26:05 +09:00
thug0bin
2cc9ec6bb1 feat(admin): 건조시럽 환산계수 관리 페이지 추가
- /admin/drysyrup 페이지 구현
- GET /api/drug-info/drysyrup 전체 목록 API
- DELETE /api/drug-info/drysyrup/<sung_code> 삭제 API
- 검색, CRUD, 통계 카드 기능
2026-03-12 17:15:28 +09:00
thug0bin
58408c9f5c fix(pmr): 라벨 인쇄 개선
- 가로/세로 모드 분기 추가 (orientation 파라미터)
- 기본값 portrait (세로 모드, QR 라벨과 동일 방향)
- 환자 이름 selector 수정 (#detailName)
2026-03-12 14:19:59 +09:00
thug0bin
17a29f05b8 feat(pmr): 라벨 인쇄 기능 구현 및 환산계수 개선
- Brother QL 라벨 인쇄 API 추가 (POST /pmr/api/label/print)
- PMR 라벨 인쇄 버튼 동작 구현 (QL-810W)
- 환산계수 sung_code 프론트→백엔드 전달 추가
- 환산계수 모달 제품명 readonly 처리 (MSSQL 원본 보호)
- Pillow 10+ 호환성 패치 (ANTIALIAS → LANCZOS)
2026-03-12 13:41:16 +09:00
thug0bin
98d370104b feat: 건조시럽 환산계수 모달 구현 - GET/POST/PUT API 추가 - PMR 약품명 더블클릭 → 모달 오픈 - 신규 등록/수정 기능 2026-03-12 10:17:39 +09:00
thug0bin
9531b74d0e feat: 환산계수 모달 구현 전 백업 2026-03-12 10:14:17 +09:00
thug0bin
e254c5c23d feat(pmr): 환자 전화번호 표시/수정 기능 추가
- API: 처방 조회 시 CD_PERSON.PHONE 반환
- API: PUT /api/members/{code}/phone - 전화번호 저장
- UI: 나이/성별 옆에 전화번호 뱃지 표시
- UI: 전화번호 없으면 '전화번호 추가' 클릭 가능
- UI: 클릭 시 모달에서 전화번호 입력/저장
2026-03-11 23:42:13 +09:00
thug0bin
4c033b0584 feat(admin): 구매 이력에 POS/QR 소스 뱃지 추가
- QR 적립: 초록색 'QR' 뱃지 + 포인트 적립 표시
- POS 매핑: 회색 'POS' 뱃지 + '적립 안됨' 표시
- 구매 소스 시각적으로 구분 가능
2026-03-11 23:33:11 +09:00
thug0bin
4a529fc891 feat(admin): 사용자 구매 이력에 MSSQL 직접 구매 통합
## 배경
- 기존: QR 적립된 구매만 표시 (SQLite claim_tokens)
- 문제: pos-live에서 고객 매핑한 구매가 안 보임

## 변경 사항
- admin_user_detail API에 MSSQL SALE_MAIN 조회 추가
- 전화번호 → CD_PERSON.CUSCODE → SALE_MAIN.SL_CD_custom 매칭
- QR 적립 구매와 POS 직접 구매 통합 표시
- 중복 제거: 이미 QR 적립된 건은 스킵
- 최근 30일, 최대 20건 조회

## 구매 소스 구분
- QR 적립: points > 0
- POS 직접 매핑: points = 0, source = 'pos'
2026-03-11 23:26:22 +09:00
thug0bin
9f10f8fdbb feat(pos-live): 고객 검색/매핑 + 비동기 마일리지 표시
- GET /api/customers/search: CD_PERSON 검색 (최근 활동순)
- PUT /api/pos-live/{order}/customer: SALE_MAIN 고객 업데이트
- GET /api/customers/{code}/mileage: 비동기 마일리지 조회
- UI: 고객 뱃지 클릭 → 검색 모달 → 선택 → 업데이트
- 마일리지: 이름+전화뒤4자리 매칭, 비동기 표시
2026-03-11 23:22:57 +09:00
thug0bin
1deba9e631 fix(pmr): 용량 컬럼 줄바꿈 방지 (white-space: nowrap) 2026-03-11 22:45:04 +09:00
thug0bin
e6f4d5b1e7 feat(pmr): 라벨 동적 폰트 + 한글 줄바꿈 + 파싱 개선
- 약품명 동적 폰트 크기 (32px→18px 자동 축소)
- wrap_text_korean(): 글자 단위 줄바꿈 (한글 지원)
- (2.5mg)노바스크정 → 앞의 괄호 제거 후 처리
- 파일 상단 함수 가이드 문서화 추가
2026-03-11 22:31:43 +09:00
thug0bin
7b71ea0179 feat(pmr): 라벨 디자인 개선 + 약품명 정규화
라벨 미리보기:
- 지그재그 테두리 (가위로 자른 느낌)
- 환자명 공백 + 폰트 확대 (44px)
- 복용량 박스 + 총량 표시
- 시그니처 박스 (청 춘 약 국)
- 조제일 표시

약품명 정규화:
- 밀리그램/밀리그람 → mg
- 마이크로그램 → μg
- 그램/그람 → g
- 밀리리터 → mL
- 언더스코어(_) 뒤 내용 제거
- 대괄호 내용 제거

프론트엔드:
- data-med-name 속성으로 순수 약품명 전달
- 번호/뱃지 제외된 이름 사용
2026-03-11 22:13:08 +09:00
thug0bin
849ce4c3c0 feat(pmr): 라벨 단위 정규화 + 제형 컬럼 제거
- SUNG_CODE 기반 단위 자동 판별 (정/캡슐/mL/포 등)
- 제형 컬럼 제거, 용량에 단위 직접 표시 (예: 1정, 0.5mL)
- previewLabels() 셀 인덱스 수정
2026-03-11 22:00:45 +09:00
thug0bin
80b3919ac9 feat(drug-usage): 단위 마스터 + 총사용량 표시 + 순차 API 호출
- drug_unit.py: SUNG_CODE 기반 단위 판별 함수 추가
- 조제 상세에 총사용량 + 단위 표시 (예: 1,230정)
- API 순차 호출로 DB 세션 충돌 방지
2026-03-11 21:47:53 +09:00
thug0bin
91f36273e9 fix(drug-usage): 이중 스크롤 제거
- detail-left/right에서 max-height, overflow-y 제거
- 테이블 wrapper만 스크롤 유지 (300px)
- 바깥 패널은 자연스럽게 확장
2026-03-11 21:04:47 +09:00
thug0bin
f46071132c feat(drug-usage): 음수재고 빨간색 + 테이블 스크롤 고정헤더
- 음수 현재고 빨간색 강조 (.negative-stock)
- 입고/조제 테이블 sticky header (max-height: 300px)
- 테이블 wrapper 추가 (border-radius)
2026-03-11 20:53:02 +09:00
thug0bin
88a23c26c1 feat(drug-usage): 환자 뱃지 + limit 5000 + 현재고 IM_total
- 조제목록에 환자 뱃지 표시 (3명 이하: 전체, 3명 초과: 최근 3명 + 외 N명)
- API에 unique_patients, recent_patients 필드 추가
- limit 1000 → 5000 증가
- 현재고: 계산 방식 → IM_total.IM_QT_sale_debit (실제 DB 값)
2026-03-11 20:25:42 +09:00
thug0bin
6db31785fa feat(admin): 기간별 사용약품 조회 페이지 완성
- /admin/drug-usage 페이지 + API 3개 구현
- GET /api/drug-usage: 기간별 약품 통계 (조제건수, 입고건수)
- GET /api/drug-usage/<code>/imports: 약품별 입고 상세
- GET /api/drug-usage/<code>/prescriptions: 약품별 조제 상세

UX 개선:
- 약품 클릭 시 입고/조제 상세 펼침 패널
- table-layout:fixed + colgroup으로 컬럼 너비 고정
- white-space:nowrap으로 날짜/숫자 줄바꿈 방지
- 금액/거래처 사이 border로 구분선 추가
- 발행기관 OrderName으로 수정 (InsName 오류 수정)

QT_GUI 데이터와 100% 일치 검증 (살라겐정)
2026-03-11 19:38:06 +09:00
thug0bin
04bf7a8535 feat(api): 기간별 사용약품 조회 API
- GET /api/drug-usage 엔드포인트 추가
- 조제건수/입고건수 통합 조회
- 조제일/소진일(expiry) 기준 선택 가능
- 약품명 검색, 약품코드 필터 지원
- QT_GUI 데이터와 100% 일치 검증됨 (살라겐정)
2026-03-11 17:16:34 +09:00
thug0bin
688bdb40f2 feat(admin): 제품 사용이력 + 환자 최근처방 모달 기능
- GET /api/products/<drug_code>/usage-history
  - 제품별 처방 이력 조회 (환자명, 수량, 횟수, 일수, 총투약량)
  - 페이지네이션 + 기간 필터 지원

- GET /api/patients/<cus_code>/recent-prescriptions
  - 환자 최근 6개월 처방 내역
  - 약품별 분류(CD_MC.PRINT_TYPE) 표시 (당뇨치료, 고지혈치료 등)

- admin_products.html 모달 2개 추가
  - 제품명 클릭 → 사용이력 모달 (횟수 포함 정확한 총투약량)
  - 환자명 클릭 → 최근처방 모달 (z-index 2100으로 위에 표시)

DB 조인:
- PS_main.PreSerial = PS_sub_pharm.PreSerial
- CD_GOODS + CD_MC (PRINT_TYPE 분류)
2026-03-11 16:50:48 +09:00
thug0bin
83ecf88bd4 feat(animal-chat): APC 코드 2024년 체계 지원 및 피부약 2단계 추천
## APC 코드 체계 확장
- 기존: 023%만 검색 (~2023년 제품만)
- 변경: 02% OR 92% + 13자리 검증
  - 02%: 2023년 이전 item_seq (9자리) 기반 APC
  - 92%: 2024년 이후 item_seq (10자리) 기반 APC
- 999% 등 청구프로그램 임의코드는 제외

## 동물약 챗봇 피부약 추천 개선
- 피부약 2단계 추천 구조 추가
  - 1차(치료): 의약품 (개시딘겔, 테르비덤 등)
  - 2차(보조케어): 의약외품 (스킨카솔 - 회복기 피부보호)
- 스킨카솔은 의약외품임을 명시하여 치료제로 오인 방지

## 기타
- RAG 테스트 스크립트 추가
- 수인약품 API 문서화
2026-03-11 14:20:44 +09:00
thug0bin
e470deaefc fix: rx-usage 쿼리에 PS_Type!=9 조건 추가 (실제 조제된 약만 집계)
- patient_query: 대체조제 원본 처방 제외
- rx_query: 대체조제 원본 처방 제외
- PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨)
- 기타 배치 스크립트 및 문서 추가
2026-03-09 21:54:32 +09:00
thug0bin
f92abf94c8 fix(scripts): SQLAlchemy 트랜잭션 에러 수정
autobegin 상태에서 begin() 재호출 에러 → engine.begin() 컨텍스트 매니저로 변경.
189건 PostgreSQL weight_min_kg/weight_max_kg 업데이트 완료.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:14:39 +09:00
thug0bin
2ef418ed7c fix(scripts): 'Xkg 초과-Ykg 이하' 체중 패턴 처리
지마스터 캣 등에서 "4kg 초과-8kg 이하" 형태의 체중 구간이
(0, 8)로 잘못 파싱되던 문제 수정.
- 패턴8a: "Xkg 초과-Ykg" 전용 패턴 추가
- 패턴8c: "초과" 컨텍스트 중복 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:12:53 +09:00
thug0bin
0bcae4ec72 feat(scripts): dosage 순서 매칭으로 체중구간 자동 매핑
제품명에 사이즈 라벨이 없지만 dosage 컬럼으로 구분 가능한 제품
(하트웜 솔루션, 지마스터, 넥스포인트 등) 처리 추가.
- 고유 dosage 수 == 체중구간 수 일 때 오름차순 매칭
- 작은 용량 = 작은 체중 원칙 적용
- 결과: 146건 → 189건으로 커버리지 증가 (+43건)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:08:19 +09:00
thug0bin
90f88450be feat(scripts): APDB weight_min_kg/max_kg 일괄 채우기 스크립트
dosage_instructions에서 체중 구간을 파싱하여 weight 컬럼 업데이트.
- 제품명 사이즈 라벨(소형견/중형견 등)로 체중구간 매칭
- 단일 체중구간 제품은 전체 APC에 적용
- 통합 제품(SS,S,M,L)은 안전하게 SKIP
- 축산용(>60kg) 자동 제외
- dry-run 기본, --commit으로 실행

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:06:49 +09:00
thug0bin
7e7d06f32e feat(animal-chat): 프롬프트 개선 - 질문 유형별 응답 구분
1. 일반/정보 질문: RAG 활용해 시중 제품 설명
2. 추천/구매 질문: 보유 제품만 추천
3. 비교/상세 질문: RAG + 보유 목록 둘 다 활용
2026-03-08 15:42:42 +09:00
thug0bin
03481dadae feat(animal-chat): max_tokens 동적 조정
- 상세 질문 시: max_tokens=1500 (~2000자)
- 기본 질문 시: max_tokens=500 (~750자)
- is_detail_request 변수 스코프 수정
2026-03-08 15:34:06 +09:00
thug0bin
e1711d9176 fix(animal-chat): 프롬프트 + RAG 최적화
1. 프롬프트 개선:
   - 상세 요청 감지 ('자세히', '설명해줘' 등)
   - 상세 요청 시 10-15문장 응답
   - RAG 검색 결과 적극 활용 지시

2. 벡터 검색 수정:
   - L2 거리 → 유사도 변환: 1/(1+distance)
   - 음수 유사도 문제 해결
   - 임계값 0.3 적용 (30% 미만 제외)

3. 컨텍스트 주입 개선:
   - 상세 질문 시 n_results=5로 증가
   - RAG 활용 지시 추가
2026-03-08 15:28:58 +09:00
thug0bin
5d7a8fc3f4 feat(animal-chat): 로깅 시스템 구축
- SQLite DB: animal_chat_logs.db
- 로거 모듈: utils/animal_chat_logger.py
- 단계별 로깅:
  - MSSQL (보유 동물약): 개수, 소요시간
  - PostgreSQL (RAG): 개수, 소요시간
  - LanceDB (벡터 검색): 상위 N개, 유사도, 소스, 소요시간
  - OpenAI: 모델, 토큰(입력/출력), 비용, 소요시간
- Admin 페이지: /admin/animal-chat-logs
- API: /api/animal-chat-logs
- 통계: 총 대화, 평균 응답시간, 총 토큰, 총 비용
2026-03-08 15:17:11 +09:00
thug0bin
be1e6c2bb7 feat(animal-chat): LanceDB 벡터 검색 RAG 통합
- LanceDB로 MD 문서 252개 청크 인덱싱
- /api/animal-chat에 벡터 검색 컨텍스트 주입
- 마지막 사용자 메시지로 관련 문서 검색 (top 3)
- ChromaDB Windows crash로 LanceDB 채택
2026-03-08 15:00:39 +09:00
thug0bin
3631da2953 fix(products): PC 촬영 시 이미지 데이터 null 버그 수정
- closeImageModal() 호출 전 imageData 로컬 변수에 저장
- closeImageModal()에서 capturedImageData = null 초기화로 인한 버그
2026-03-08 14:06:09 +09:00
thug0bin
3507d17dc5 fix(upload): 모바일 QR 업로드 이미지 처리 수정
- PIL 이미지 처리 추가 (product-images API와 동일)
- 800x800 리사이즈
- 200x200 썸네일 생성
- RGBA->RGB 변환
- 1:1 중앙 크롭
2026-03-08 13:57:32 +09:00
thug0bin
90cb91d644 fix(upload): product_name NOT NULL 에러 수정
- 세션 생성 시 product_name 저장
- 업로드 API에서 product_name INSERT
- 프론트엔드에서 product_name 전달
2026-03-08 13:49:23 +09:00
thug0bin
aef867645e fix(upload): render_template_string import 추가
- 모바일 업로드 페이지 500 에러 수정
- Flask import에 render_template_string 추가
2026-03-08 13:45:50 +09:00
thug0bin
4614fc4c0d feat(products): 모바일 이미지 업로드 QR 시스템 추가
- API 3개 추가:
  - POST /api/upload-session (세션 생성)
  - GET /api/upload-session/{id} (상태 확인/폴링)
  - POST /api/upload-session/{id}/image (이미지 업로드)
- 모바일 업로드 페이지 (/upload/{session_id})
- 이미지 등록 모달에 '📱 모바일' 탭 추가
- QR 스캔 → 모바일 촬영 → PC 실시간 반영
- 2초 폴링으로 업로드 완료 감지
- 세션 10분 만료, 메모리 기반 관리
- Edit 툴로 부분 수정하여 인코딩 유지
2026-03-08 13:40:12 +09:00
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
173 changed files with 27409 additions and 3346 deletions

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)}')

File diff suppressed because it is too large Load Diff

View File

@@ -262,6 +262,148 @@ def api_get_balance():
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():
"""

View File

@@ -99,63 +99,7 @@
<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>
</table>
<div id="bag_view"
style='height:375px;'
> <!--닫는 태그-->
</fieldset>
@@ -168,7 +112,7 @@
<caption>장바구니 리스트</caption>
<col width="*" />
<colgroup>
@@ -177,15 +121,15 @@
<col width="*" />
<col width="35" />
<thead style="display:none;">
<col width="77" />
</colgroup>
<thead>
<tr>

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

32
backend/check_2024_apc.py Normal file
View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
import pyodbc
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
)
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
# 2024년 이후 APC (9xx로 시작) 확인
cursor.execute('''
SELECT G.GoodsName, U.CD_CD_BARCODE
FROM CD_GOODS G
JOIN CD_ITEM_UNIT_MEMBER U ON G.DrugCode = U.DRUGCODE
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
AND U.CD_CD_BARCODE LIKE '9%'
AND LEN(U.CD_CD_BARCODE) = 13
ORDER BY G.GoodsName
''')
rows = cursor.fetchall()
print(f'=== 2024년 이후 APC 제품: {len(rows)}건 ===')
for row in rows:
print(f' {row.GoodsName} | APC: {row.CD_CD_BARCODE}')
conn.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()

18
backend/check_chunks.py Normal file
View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from utils.animal_rag import get_animal_rag
rag = get_animal_rag()
rag._init_db()
df = rag.table.to_pandas()
# 개시딘 청크들 확인
gaesidin = df[df['source'] == 'gaesidin_gel_pyoderma_fusidic_acid.md']
print(f'개시딘 청크 수: {len(gaesidin)}')
print('=' * 60)
for i, row in gaesidin.head(5).iterrows():
section = row['section']
text = row['text'][:200].replace('\n', ' ')
print(f'\n[섹션] {section}')
print(f'{text}...')

View File

@@ -1,11 +0,0 @@
import sqlite3
conn = sqlite3.connect('db/orders.db')
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [r[0] for r in cursor.fetchall()]
print('Tables:', tables)
for t in tables:
cursor.execute(f"PRAGMA table_info({t})")
cols = [r[1] for r in cursor.fetchall()]
print(f" {t}: {cols}")
conn.close()

View File

@@ -1,13 +0,0 @@
# -*- coding: utf-8 -*-
import sqlite3
conn = sqlite3.connect('db/orders.db')
# 테이블 목록
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
print('=== orders.db 테이블 ===')
for t in tables:
count = conn.execute(f'SELECT COUNT(*) FROM {t[0]}').fetchone()[0]
print(f' {t[0]}: {count}개 레코드')
conn.close()

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from sqlalchemy import create_engine, text
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
with engine.connect() as conn:
# 오리더밀 검색
result = conn.execute(text("""
SELECT apc, product_name, item_seq,
llm_pharm->>'분류' as category,
llm_pharm->>'간이분류' as easy_category,
image_url1
FROM apc
WHERE product_name ILIKE '%오리더밀%'
ORDER BY apc
"""))
print('=== PostgreSQL 오리더밀 검색 결과 ===')
for row in result:
print(f'APC: {row.apc}')
print(f' 제품명: {row.product_name}')
print(f' item_seq: {row.item_seq}')
print(f' 분류: {row.category}')
print(f' 간이분류: {row.easy_category}')
print(f' 이미지: {row.image_url1}')
print()

View File

@@ -1,28 +0,0 @@
# -*- coding: utf-8 -*-
import sqlite3
conn = sqlite3.connect('db/paai_logs.db')
# 테이블 목록
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print('테이블 목록:', [t[0] for t in tables])
# 로그 개수
count = conn.execute('SELECT COUNT(*) FROM paai_logs').fetchone()[0]
print(f'PAAI 로그 수: {count}')
# 최근 로그
print('\n최근 로그 3개:')
recent = conn.execute('SELECT id, created_at, patient_name, status FROM paai_logs ORDER BY id DESC LIMIT 3').fetchall()
for r in recent:
print(f' #{r[0]} | {r[1]} | {r[2]} | {r[3]}')
# 피드백 통계
feedback = conn.execute('SELECT feedback_useful, COUNT(*) FROM paai_logs GROUP BY feedback_useful').fetchall()
print('\n피드백 통계:')
for f in feedback:
label = '유용' if f[0] == 1 else ('아님' if f[0] == 0 else '미응답')
print(f' {label}: {f[1]}')
conn.close()

View File

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

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
import pyodbc
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
)
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
# 정식 2024년 APC (92%로 시작) 확인
cursor.execute('''
SELECT G.GoodsName, U.CD_CD_BARCODE
FROM CD_GOODS G
JOIN CD_ITEM_UNIT_MEMBER U ON G.DrugCode = U.DRUGCODE
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
AND U.CD_CD_BARCODE LIKE '92%'
AND LEN(U.CD_CD_BARCODE) = 13
ORDER BY G.GoodsName
''')
rows = cursor.fetchall()
print(f'=== 정식 2024년 APC (92%) 제품: {len(rows)}건 ===')
for row in rows:
print(f' {row.GoodsName} | APC: {row.CD_CD_BARCODE}')
if len(rows) == 0:
print(' (없음 - 아직 2024년 이후 허가 제품이 등록 안 됨)')
conn.close()

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

28
backend/check_tiergard.py Normal file
View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import pyodbc
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
)
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
cursor.execute('''
SELECT G.GoodsName, G.Saleprice, ISNULL(IT.IM_QT_sale_debit, 0) AS Stock
FROM CD_GOODS G
LEFT JOIN IM_total IT ON G.DrugCode = IT.DrugCode
WHERE G.GoodsName LIKE '%티어가드%'
ORDER BY G.GoodsName
''')
rows = cursor.fetchall()
print('=== 티어가드 보유 현황 ===')
for row in rows:
print(f'{row.GoodsName} | {row.Saleprice:,.0f}원 | 재고: {int(row.Stock)}')
conn.close()

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from sqlalchemy import create_engine, text
import json
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
with engine.connect() as conn:
result = conn.execute(text("""
SELECT apc, product_name, llm_pharm, main_ingredient, component_name_ko
FROM apc
WHERE product_name ILIKE '%티어가드%60mg%'
ORDER BY apc
LIMIT 3
"""))
print('=== 티어가드 60mg 허가사항 상세 ===')
for row in result:
print(f'APC: {row.apc}')
print(f'제품명: {row.product_name}')
print(f'main_ingredient: {row.main_ingredient}')
print(f'component_name_ko: {row.component_name_ko}')
if row.llm_pharm:
llm = row.llm_pharm if isinstance(row.llm_pharm, dict) else json.loads(row.llm_pharm)
print('llm_pharm 내용:')
for k, v in llm.items():
print(f' {k}: {v}')
print()

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from sqlalchemy import create_engine, text
import json
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
with engine.connect() as conn:
# llm_pharm이 있는 티어가드 확인
result = conn.execute(text("""
SELECT apc, product_name, llm_pharm
FROM apc
WHERE product_name ILIKE '%티어가드%'
AND llm_pharm IS NOT NULL
AND llm_pharm::text != '{}'
ORDER BY apc
"""))
print('=== 티어가드 llm_pharm 있는 항목 ===')
for row in result:
print(f'APC: {row.apc}')
print(f'제품명: {row.product_name}')
if row.llm_pharm:
llm = row.llm_pharm if isinstance(row.llm_pharm, dict) else json.loads(row.llm_pharm)
print('llm_pharm:')
for k, v in llm.items():
if v:
print(f' {k}: {v}')
print()

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from sqlalchemy import create_engine, text
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
with engine.connect() as conn:
result = conn.execute(text("""
SELECT apc, product_name,
llm_pharm->>'체중/부위' as dosage,
llm_pharm->>'주성분' as ingredient
FROM apc
WHERE product_name ILIKE '%티어가드%'
ORDER BY apc
"""))
print('=== PostgreSQL 티어가드 전체 규격 ===')
for row in result:
print(f'APC: {row.apc}')
print(f' 제품명: {row.product_name}')
print(f' 용량: {row.dosage}')
print(f' 성분: {row.ingredient}')
print()

11
backend/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"pharmacy_code": "P0001",
"pharmacy_name": "청춘약국",
"cloud_api_url": "https://pos.pharmq.kr",
"pos_printer": {
"ip": "192.168.0.174",
"port": 9100
},
"pharmacist_name": "김영빈",
"license_number": "72672"
}

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

@@ -0,0 +1,47 @@
-- 동물약 챗봇 로그 스키마
-- 생성일: 2026-03-08
CREATE TABLE IF NOT EXISTS chat_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
-- 입력
user_message TEXT,
history_length INTEGER,
-- MSSQL (보유 동물약)
mssql_drug_count INTEGER,
mssql_duration_ms INTEGER,
-- PostgreSQL (RAG)
pgsql_rag_count INTEGER,
pgsql_duration_ms INTEGER,
-- LanceDB (벡터 검색)
vector_results_count INTEGER,
vector_top_scores TEXT, -- JSON: [0.92, 0.85, 0.78]
vector_sources TEXT, -- JSON: ["file1.md#section", ...]
vector_duration_ms INTEGER,
-- OpenAI
openai_model TEXT,
openai_prompt_tokens INTEGER,
openai_completion_tokens INTEGER,
openai_total_tokens INTEGER,
openai_cost_usd REAL,
openai_duration_ms INTEGER,
-- 출력
assistant_response TEXT,
products_mentioned TEXT, -- JSON array
-- 메타
total_duration_ms INTEGER,
error TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_chat_session ON chat_logs(session_id);
CREATE INDEX IF NOT EXISTS idx_chat_error ON chat_logs(error);

View File

@@ -2,6 +2,8 @@
PIT3000 Database Setup
SQLAlchemy 기반 데이터베이스 연결 및 스키마 정의
Windows/Linux 크로스 플랫폼 지원
PostgreSQL 지원 추가: 건조시럽 환산계수 조회 (drysyrup 테이블)
"""
from sqlalchemy import create_engine, MetaData, text
@@ -75,7 +77,7 @@ def get_available_odbc_driver():
class DatabaseConfig:
"""PIT3000 데이터베이스 연결 설정"""
SERVER = "192.168.0.4\\PM2014"
SERVER = "192.168.0.69\\PM2014"
USERNAME = "sa"
PASSWORD = "tmddls214!%(" # 원본 비밀번호
@@ -87,6 +89,9 @@ class DatabaseConfig:
# URL 인코딩된 드라이버
DRIVER_ENCODED = urllib.parse.quote_plus(DRIVER)
# PostgreSQL 연결 정보 (건조시럽 환산계수 DB)
POSTGRES_URL = "postgresql+psycopg2://admin:trajet6640@192.168.0.39:5432/label10"
# 데이터베이스별 연결 문자열 (동적 드라이버 사용)
@classmethod
@@ -135,6 +140,10 @@ class DatabaseManager:
# SQLite 연결 추가
self.sqlite_conn = None
self.sqlite_db_path = Path(__file__).parent / 'mileage.db'
# PostgreSQL 연결 (건조시럽 환산계수)
self.postgres_engine = None
self.postgres_session = None
def get_engine(self, database='PM_BASE'):
"""특정 데이터베이스 엔진 반환"""
@@ -220,6 +229,132 @@ class DatabaseManager:
# 새 세션 생성
return self.get_session(database)
# ─────────────────────────────────────────────────────────────
# PostgreSQL 연결 (건조시럽 환산계수)
# ─────────────────────────────────────────────────────────────
def get_postgres_engine(self):
"""
PostgreSQL 엔진 반환 (건조시럽 환산계수 DB)
Returns:
Engine 또는 None (연결 실패 시)
"""
if self.postgres_engine is not None:
return self.postgres_engine
try:
self.postgres_engine = create_engine(
DatabaseConfig.POSTGRES_URL,
pool_size=5,
max_overflow=5,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
echo=False
)
# 연결 테스트
with self.postgres_engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("[DB Manager] PostgreSQL 연결 성공")
return self.postgres_engine
except Exception as e:
print(f"[DB Manager] PostgreSQL 연결 실패 (무시됨): {e}")
self.postgres_engine = None
return None
def get_postgres_session(self):
"""
PostgreSQL 세션 반환 (건조시럽 환산계수 조회용)
Returns:
Session 또는 None (연결 실패 시)
"""
engine = self.get_postgres_engine()
if engine is None:
return None
if self.postgres_session is None:
try:
Session = sessionmaker(bind=engine)
self.postgres_session = Session()
except Exception as e:
print(f"[DB Manager] PostgreSQL 세션 생성 실패: {e}")
return None
else:
# 세션 상태 체크
try:
self.postgres_session.execute(text("SELECT 1"))
except Exception as e:
print(f"[DB Manager] PostgreSQL 세션 복구 시도: {e}")
try:
self.postgres_session.rollback()
except:
pass
try:
self.postgres_session.close()
except:
pass
try:
Session = sessionmaker(bind=engine)
self.postgres_session = Session()
except:
self.postgres_session = None
return None
return self.postgres_session
def get_conversion_factor(self, sung_code):
"""
건조시럽 환산계수 및 보관조건 조회
Args:
sung_code: SUNG_CODE (예: "535000ASY")
Returns:
dict: {
'conversion_factor': float 또는 None,
'ingredient_name': str 또는 None,
'product_name': str 또는 None,
'storage_conditions': str (기본값 '실온보관')
}
"""
result = {
'conversion_factor': None,
'ingredient_name': None,
'product_name': None,
'storage_conditions': '실온보관' # 기본값
}
session = self.get_postgres_session()
if session is None:
return result
try:
query = text("""
SELECT conversion_factor, ingredient_name, product_name, storage_conditions
FROM drysyrup
WHERE ingredient_code = :sung_code
LIMIT 1
""")
row = session.execute(query, {'sung_code': sung_code}).fetchone()
if row:
result['conversion_factor'] = float(row[0]) if row[0] is not None else None
result['ingredient_name'] = row[1]
result['product_name'] = row[2]
# storage_conditions: 값이 있으면 사용, 없으면 기본값 '실온보관' 유지
if row[3]:
result['storage_conditions'] = row[3]
except Exception as e:
print(f"[DB Manager] 환산계수 조회 실패 (SUNG_CODE={sung_code}): {e}")
# 세션 롤백
try:
session.rollback()
except:
pass
return result
def get_sqlite_connection(self, new_connection=False):
"""
SQLite mileage.db 연결 반환
@@ -442,6 +577,20 @@ class DatabaseManager:
if self.sqlite_conn:
self.sqlite_conn.close()
self.sqlite_conn = None
# PostgreSQL 연결 종료
if self.postgres_session:
try:
self.postgres_session.close()
except:
pass
self.postgres_session = None
if self.postgres_engine:
try:
self.postgres_engine.dispose()
except:
pass
self.postgres_engine = None
# 전역 데이터베이스 매니저 인스턴스
db_manager = DatabaseManager()

View File

@@ -289,6 +289,43 @@ def get_log_detail(log_id: int) -> dict:
return log
def get_cached_result(pre_serial: str) -> dict:
"""처방번호로 캐시된 PAAI 결과 조회 (재인쇄용)"""
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 pre_serial = ? AND status = 'success'
ORDER BY created_at DESC
LIMIT 1
''', (pre_serial,))
row = cursor.fetchone()
conn.close()
if not row:
return None
result = dict(row)
# JSON 파싱
import json
for field in ['analysis', 'kims_summary', 'raw_response']:
if result.get(field):
try:
result[field] = json.loads(result[field])
except:
pass
return result
def get_stats() -> dict:
"""통계 조회"""
if not DB_PATH.exists():

267
backend/dongwon_api.py Normal file
View File

@@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""
동원약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
이 파일은 Flask 웹 API 연동만 담당
"""
import time
import logging
from datetime import datetime
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import DongwonSession
logger = logging.getLogger(__name__)
# Blueprint 생성
dongwon_bp = Blueprint('dongwon', __name__, url_prefix='/api/dongwon')
# ========== 세션 관리 ==========
_dongwon_session = None
def get_dongwon_session():
global _dongwon_session
if _dongwon_session is None:
_dongwon_session = DongwonSession()
return _dongwon_session
def search_dongwon_stock(keyword: str, search_type: str = 'name'):
"""동원약품 재고 검색"""
try:
session = get_dongwon_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'search_type': search_type,
'count': result.get('total', 0),
'items': result.get('items', [])
}
else:
return result
except Exception as e:
logger.error(f"동원약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@dongwon_bp.route('/stock', methods=['GET'])
def api_dongwon_stock():
"""
동원약품 재고 조회 API
GET /api/dongwon/stock?keyword=타이레놀
"""
keyword = flask_request.args.get('keyword', '').strip()
if not keyword:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'keyword 파라미터가 필요합니다.'
}), 400
result = search_dongwon_stock(keyword)
return jsonify(result)
@dongwon_bp.route('/session', methods=['GET'])
def api_dongwon_session():
"""동원약품 세션 상태 확인"""
session = get_dongwon_session()
return jsonify({
'logged_in': getattr(session, '_logged_in', False),
'last_login': getattr(session, '_last_login', 0),
'session_age_sec': int(time.time() - session._last_login) if getattr(session, '_last_login', 0) else None
})
@dongwon_bp.route('/balance', methods=['GET'])
def api_dongwon_balance():
"""
동원약품 잔고 조회 API
GET /api/dongwon/balance
Returns:
{
"success": true,
"balance": 7080018, // 당월 잔고
"prev_balance": 5407528, // 전월 잔고
"trade_amount": 1672490, // 거래 금액
"payment_amount": 0 // 결제 금액
}
"""
try:
session = get_dongwon_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
@dongwon_bp.route('/monthly-orders', methods=['GET'])
def api_dongwon_monthly_orders():
"""
동원약품 월간 주문 조회 API
GET /api/dongwon/monthly-orders?year=2026&month=3
Returns:
{
"success": true,
"year": 2026,
"month": 3,
"total_amount": 1815115, // 주문 총액
"approved_amount": 1672490, // 승인 금액
"order_count": 23 // 주문 건수
}
"""
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_dongwon_session()
result = session.get_monthly_orders(year, month)
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 월간 주문 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'MONTHLY_ORDERS_ERROR',
'message': str(e)
}), 500
@dongwon_bp.route('/cart', methods=['GET'])
def api_dongwon_cart():
"""
동원약품 장바구니 조회 API
GET /api/dongwon/cart
"""
try:
session = get_dongwon_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 장바구니 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'CART_ERROR',
'message': str(e)
}), 500
@dongwon_bp.route('/cart/add', methods=['POST'])
def api_dongwon_cart_add():
"""
동원약품 장바구니 추가 API
POST /api/dongwon/cart/add
{
"item_code": "A4394",
"quantity": 2
}
"""
data = flask_request.get_json() or {}
item_code = data.get('item_code', '').strip()
quantity = data.get('quantity', 1)
if not item_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'item_code가 필요합니다.'
}), 400
try:
session = get_dongwon_session()
result = session.add_to_cart(item_code, quantity)
return jsonify(result)
except Exception as e:
logger.error(f"동원약품 장바구니 추가 오류: {e}")
return jsonify({
'success': False,
'error': 'CART_ADD_ERROR',
'message': str(e)
}), 500
@dongwon_bp.route('/orders/summary-by-kd', methods=['GET'])
def api_dongwon_orders_by_kd():
"""
동원약품 주문량 KD코드별 집계 API
GET /api/dongwon/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-07
흐름:
1. 주문 목록 API → 주문번호 목록
2. 각 주문번호 → HTML 파싱 → ItemCode 목록
3. 각 ItemCode → itemInfoAx → KD코드, 규격, 수량
4. KD코드별 집계
Returns:
{
"success": true,
"order_count": 4,
"by_kd_code": {
"642900680": {
"product_name": "사미온정10mg",
"spec": "30정(병)",
"boxes": 3,
"units": 90
}
},
"total_products": 15
}
"""
from datetime import datetime
today = datetime.now()
start_date = flask_request.args.get('start_date', today.strftime("%Y-%m-%d")).strip()
end_date = flask_request.args.get('end_date', today.strftime("%Y-%m-%d")).strip()
try:
session = get_dongwon_session()
# 새로운 get_orders_by_kd_code 메서드 사용
result = session.get_orders_by_kd_code(start_date, end_date)
return jsonify(result)
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

BIN
backend/geo_cart_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -478,6 +478,264 @@ def api_get_monthly_sales():
}), 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
# ========== 하위 호환성 ==========
# 기존 코드에서 직접 클래스 참조하는 경우를 위해

View File

@@ -143,6 +143,8 @@ def api_submit_order():
# 도매상별 주문 처리
if wholesaler_id == 'geoyoung':
result = submit_geoyoung_order(order, dry_run)
elif wholesaler_id == 'dongwon':
result = submit_dongwon_order(order, dry_run)
else:
result = {
'success': False,
@@ -281,26 +283,45 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) ->
else:
# ─────────────────────────────────────────
# 실제 주문 (빠른 API - ~1초/품목)
# 실제 주문 (선별 주문 - 기존 장바구니 보존)
# ─────────────────────────────────────────
from geoyoung_api import get_geo_session
geo_session = get_geo_session()
# 1단계: 모든 품목을 장바구니에 담기 (auto_confirm=False)
# 1단계: 모든 품목을 장바구니에 담기
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
order_qty = item['order_qty']
spec = item.get('specification', '')
item_internal_code = item.get('internal_code') # 프론트에서 이미 선택한 품목
result = {}
# 🔍 디버그 로그
logger.info(f"[GEO DEBUG] item keys: {list(item.keys())}")
logger.info(f"[GEO DEBUG] kd_code={kd_code}, internal_code={item_internal_code}, qty={order_qty}, spec={spec}")
logger.info(f"[GEO DEBUG] full item: {item}")
try:
# 장바구니에만 담기 (주문 확정은 나중에 한번에)
result = geo_session.quick_order(
kd_code=kd_code,
quantity=order_qty,
specification=spec if spec else None,
check_stock=True
)
if item_internal_code:
# internal_code가 있으면 검색 없이 바로 장바구니 추가!
logger.info(f"[GEO DEBUG] Using internal_code directly: {item_internal_code}")
result = geo_session.add_to_cart(item_internal_code, order_qty)
logger.info(f"[GEO DEBUG] add_to_cart result: {result}")
if result.get('success'):
result['product'] = {'internal_code': item_internal_code, 'name': item.get('product_name', '')}
else:
# internal_code 없으면 검색 후 장바구니 추가
logger.info(f"[GEO DEBUG] No internal_code, using full_order with kd_code={kd_code}")
result = geo_session.full_order(
kd_code=kd_code,
quantity=order_qty,
specification=spec if spec else None,
check_stock=True,
auto_confirm=False,
memo=f"자동주문 - {item.get('product_name', '')}"
)
logger.info(f"[GEO DEBUG] full_order result: {result}")
if result.get('success'):
status = 'success'
@@ -318,12 +339,11 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) ->
result_code = 'ERROR'
result_message = str(e)
failed_count += 1
result = {}
update_item_result(item['id'], status, result_code, result_message)
# quick_order 결과에서 product_code 가져오기
product_code = result.get('product', {}).get('product_code') if result.get('success') else None
# full_order 결과에서 internal_code 가져오기 (지오영은 internal_code 사용!)
internal_code = result.get('product', {}).get('internal_code') if result.get('success') else None
# AI 학습용 컨텍스트 저장
save_order_context(item['id'], {
@@ -335,7 +355,7 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) ->
'ordered_qty': order_qty,
'selection_reason': 'user_order',
'wholesaler_id': 'geoyoung',
'product_code': product_code
'internal_code': internal_code
})
results.append({
@@ -347,15 +367,20 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) ->
'status': status,
'result_code': result_code,
'result_message': result_message,
'product_code': product_code # 선별 주문용
'internal_code': internal_code # 선별 주문용
})
# 2단계: cart_only=False면 선별 주문 (기존 품목 보존)
if not cart_only and success_count > 0:
try:
# 이번에 담은 품목의 product_code만 수집
ordered_codes = [r['product_code'] for r in results
if r['status'] == 'success' and r.get('product_code')]
# 이번에 담은 품목의 internal_code만 수집
ordered_codes = [r['internal_code'] for r in results
if r['status'] == 'success' and r.get('internal_code')]
# 🔧 디버그: 선별 주문 전 상세 로그
logger.info(f"[GEO DEBUG] 선별 주문 시작")
logger.info(f"[GEO DEBUG] ordered_codes: {ordered_codes}")
logger.info(f"[GEO DEBUG] results: {[(r.get('product_name', '')[:20], r.get('internal_code')) for r in results if r['status'] == 'success']}")
if ordered_codes:
# 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문
@@ -375,7 +400,7 @@ def submit_geoyoung_order(order: dict, dry_run: bool, cart_only: bool = True) ->
f'지오영 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
else:
update_order_status(order_id, 'partial',
f'지오영 장바구니 담김, product_code 없음')
f'지오영 장바구니 담김, internal_code 없음')
except Exception as e:
logger.error(f"지오영 주문 확정 오류: {e}")
update_order_status(order_id, 'partial',
@@ -494,6 +519,8 @@ def api_quick_submit():
submit_result = submit_sooin_order(order, dry_run, cart_only=cart_only)
elif order['wholesaler_id'] == 'baekje':
submit_result = submit_baekje_order(order, dry_run, cart_only=cart_only)
elif order['wholesaler_id'] == 'dongwon':
submit_result = submit_dongwon_order(order, dry_run, cart_only=cart_only)
else:
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
@@ -875,12 +902,45 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
internal_code = item.get('internal_code') # 프론트엔드에서 전달된 internal_code
order_qty = item['order_qty']
spec = item.get('specification', '')
cart_result = {}
# 🔍 디버그: 백제 주문 파라미터 확인
logger.info(f"[BAEKJE DEBUG] kd_code={kd_code}, internal_code={internal_code}, qty={order_qty}, spec={spec}")
logger.info(f"[BAEKJE DEBUG] full item: {item}")
try:
# 장바구니 추가
cart_result = baekje_session.add_to_cart(kd_code, order_qty)
if internal_code:
# internal_code가 있으면 바로 장바구니 추가!
logger.info(f"[BAEKJE DEBUG] Using internal_code directly: {internal_code}")
cart_result = baekje_session.add_to_cart(internal_code, order_qty)
logger.info(f"[BAEKJE DEBUG] add_to_cart result: {cart_result}")
else:
# internal_code가 없으면 검색 후 장바구니 추가
logger.info(f"[BAEKJE DEBUG] No internal_code, searching by kd_code={kd_code}")
search_result = baekje_session.search_products(kd_code)
if search_result.get('success') and search_result.get('items'):
# 규격 매칭 (재고 있는 것 우선)
matched_item = None
for baekje_item in search_result.get('items', []):
item_spec = baekje_item.get('spec', '')
# 규격이 지정되어 있으면 매칭, 없으면 첫번째 재고 있는 것
if not spec or spec in item_spec or item_spec in spec:
if matched_item is None or baekje_item.get('stock', 0) > matched_item.get('stock', 0):
matched_item = baekje_item
if matched_item:
found_internal_code = matched_item.get('internal_code')
logger.info(f"[BAEKJE DEBUG] Found internal_code via search: {found_internal_code}")
cart_result = baekje_session.add_to_cart(found_internal_code, order_qty)
internal_code = found_internal_code # 컨텍스트 저장용
else:
cart_result = {'success': False, 'error': 'NO_MATCHING_SPEC', 'message': f'규격 {spec} 미발견'}
else:
cart_result = {'success': False, 'error': 'PRODUCT_NOT_FOUND', 'message': '제품 검색 결과 없음'}
if cart_result.get('success'):
status = 'success'
@@ -898,6 +958,7 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
result_code = 'ERROR'
result_message = str(e)
failed_count += 1
logger.error(f"[BAEKJE DEBUG] Exception: {e}")
update_item_result(item['id'], status, result_code, result_message)
@@ -909,7 +970,8 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
'ordered_spec': spec,
'ordered_qty': order_qty,
'selection_reason': 'user_order',
'wholesaler_id': 'baekje'
'wholesaler_id': 'baekje',
'internal_code': internal_code
})
results.append({
@@ -920,24 +982,38 @@ def submit_baekje_order(order: dict, dry_run: bool, cart_only: bool = True) -> d
'order_qty': order_qty,
'status': status,
'result_code': result_code,
'result_message': result_message
'result_message': result_message,
'internal_code': internal_code
})
# cart_only=False면 주문 확정까지 진행
# cart_only=False면 주문 확정까지 진행 (선별 주문!)
if not cart_only and success_count > 0:
try:
confirm_result = baekje_session.submit_order()
if confirm_result.get('success'):
update_order_status(order_id, 'submitted',
f'백제 주문 확정 완료: {success_count}')
# 결과 메시지 업데이트
for r in results:
if r['status'] == 'success':
r['result_code'] = 'OK'
r['result_message'] = '주문 확정 완료'
# 이번에 담은 품목의 internal_code만 수집
ordered_codes = [r['internal_code'] for r in results
if r['status'] == 'success' and r.get('internal_code')]
logger.info(f"[BAEKJE DEBUG] 선별 주문 시작, ordered_codes: {ordered_codes}")
if ordered_codes:
# 선별 주문: 기존 품목은 건드리지 않고, 이번에 담은 것만 주문
confirm_result = baekje_session.submit_order_selective(ordered_codes)
if confirm_result.get('success'):
restored_info = f", 기존 {confirm_result.get('restored_count', 0)}개 복원" if confirm_result.get('restored_count', 0) > 0 else ""
update_order_status(order_id, 'submitted',
f'백제 주문 확정 완료: {success_count}{restored_info}')
# 결과 메시지 업데이트
for r in results:
if r['status'] == 'success':
r['result_code'] = 'OK'
r['result_message'] = '주문 확정 완료'
else:
update_order_status(order_id, 'partial',
f'백제 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
else:
update_order_status(order_id, 'partial',
f'백제 장바구니 담김, 확정 실패: {confirm_result.get("error", "알 수 없는 오류")}')
f'백제 장바구니 담김, internal_code 없음')
except Exception as e:
logger.error(f"백제 주문 확정 오류: {e}")
update_order_status(order_id, 'partial',
@@ -1033,3 +1109,508 @@ def api_ai_order_pattern(drug_code):
'pattern': None,
'message': '주문 이력이 없습니다'
})
# ─────────────────────────────────────────────
# 도매상 한도 관리 API
# ─────────────────────────────────────────────
@order_bp.route('/wholesaler/limits', methods=['GET'])
def api_wholesaler_limits():
"""
전체 도매상 한도 조회 (현재 월 사용량 포함)
GET /api/order/wholesaler/limits
"""
import sqlite3
from datetime import datetime
# 절대 경로 사용
db_path = r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\orders.db'
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 현재 월
year_month = datetime.now().strftime('%Y-%m')
# 한도 정보 조회
cur.execute('SELECT * FROM wholesaler_limits WHERE is_active = 1 ORDER BY priority')
limits = cur.fetchall()
result = []
for row in limits:
ws_id = row['wholesaler_id']
monthly_limit = row['monthly_limit']
# 이번 달 실제 주문 금액 조회 (성공한 것만)
cur.execute('''
SELECT COALESCE(SUM(oi.unit_price * oi.order_qty), 0) as total_amount
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.wholesaler_id = ?
AND strftime('%Y-%m', o.order_date) = ?
AND o.status IN ('submitted', 'success', 'confirmed')
''', (ws_id, year_month))
usage_row = cur.fetchone()
current_usage = usage_row['total_amount'] if usage_row else 0
usage_percent = (current_usage / monthly_limit * 100) if monthly_limit > 0 else 0
remaining = monthly_limit - current_usage
result.append({
'wholesaler_id': ws_id,
'monthly_limit': monthly_limit,
'current_usage': current_usage,
'remaining': remaining,
'usage_percent': round(usage_percent, 1),
'warning_threshold': row['warning_threshold'],
'is_warning': usage_percent >= (row['warning_threshold'] * 100),
'priority': row['priority']
})
conn.close()
return jsonify({
'success': True,
'year_month': year_month,
'limits': result
})
@order_bp.route('/wholesaler/limits/<wholesaler_id>', methods=['PUT'])
def api_update_wholesaler_limit(wholesaler_id):
"""
도매상 한도 수정
PUT /api/order/wholesaler/limits/geoyoung
{
"monthly_limit": 30000000,
"warning_threshold": 0.85
}
"""
import sqlite3
data = request.get_json()
db_path = r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend\db\orders.db'
conn = sqlite3.connect(db_path)
cur = conn.cursor()
updates = []
params = []
if 'monthly_limit' in data:
updates.append('monthly_limit = ?')
params.append(data['monthly_limit'])
if 'warning_threshold' in data:
updates.append('warning_threshold = ?')
params.append(data['warning_threshold'])
if 'priority' in data:
updates.append('priority = ?')
params.append(data['priority'])
if updates:
updates.append("updated_at = datetime('now')")
params.append(wholesaler_id)
cur.execute(f'''
UPDATE wholesaler_limits
SET {', '.join(updates)}
WHERE wholesaler_id = ?
''', params)
conn.commit()
conn.close()
return jsonify({
'success': True,
'message': f'{wholesaler_id} 한도 업데이트 완료'
})
# ========== 약품별 선호 도매상 API ==========
def get_drug_preferred_vendor(drug_code: str, period_days: int = 365):
"""
약품코드 기준 선호 도매상 조회 (MSSQL 입고장 데이터)
"""
import pyodbc
CONN_STR = (
'DRIVER={ODBC Driver 17 for SQL Server};'
'SERVER=192.168.0.4\\PM2014;'
'DATABASE=PM_DRUG;'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes;'
'Connection Timeout=10'
)
try:
conn = pyodbc.connect(CONN_STR, timeout=10)
cursor = conn.cursor()
# 약품명 조회
cursor.execute("SELECT GoodsName FROM CD_GOODS WHERE DrugCode = ?", drug_code)
row = cursor.fetchone()
drug_name = row[0] if row else ''
# 도매상별 입고 통계
query = """
SELECT
c.CD_NM_custom AS vendor_name,
c.CD_CD_custom AS vendor_code,
COUNT(*) AS order_count,
SUM(ws.WH_NM_item_a) AS total_qty,
SUM(ws.WH_MY_amount_a) AS total_amount,
AVG(ws.WH_MY_unit_a) AS avg_unit_price,
MAX(wm.WH_DT_appl) AS last_order_date
FROM WH_sub ws
JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
WHERE ws.DrugCode = ?
AND wm.WH_DT_appl >= CONVERT(varchar(8), DATEADD(day, ?, GETDATE()), 112)
GROUP BY c.CD_NM_custom, c.CD_CD_custom
ORDER BY COUNT(*) DESC
"""
cursor.execute(query, (drug_code, -period_days))
rows = cursor.fetchall()
if not rows:
conn.close()
return {
'success': True,
'drug_code': drug_code,
'drug_name': drug_name,
'recent_vendor': None,
'most_frequent_vendor': None,
'vendors': [],
'message': '입고 이력 없음'
}
vendors = []
for r in rows:
vendors.append({
'vendor_name': r[0] or '알수없음',
'vendor_code': r[1] or '',
'order_count': r[2],
'total_qty': float(r[3] or 0),
'total_amount': float(r[4] or 0),
'avg_unit_price': float(r[5] or 0),
'last_order_date': r[6]
})
most_frequent = vendors[0] if vendors else None
# 최근 주문 도매상
recent_query = """
SELECT TOP 1
c.CD_NM_custom AS vendor_name,
c.CD_CD_custom AS vendor_code,
wm.WH_DT_appl AS order_date,
ws.WH_NM_item_a AS qty,
ws.WH_MY_unit_a AS unit_price
FROM WH_sub ws
JOIN WH_main wm ON ws.WH_SR_stock = wm.WH_NO_stock
LEFT JOIN PM_BASE.dbo.CD_custom c ON wm.WH_CD_cust_sale = c.CD_CD_custom
WHERE ws.DrugCode = ?
ORDER BY wm.WH_DT_appl DESC
"""
cursor.execute(recent_query, drug_code)
recent_row = cursor.fetchone()
recent_vendor = None
if recent_row:
recent_vendor = {
'vendor_name': recent_row[0] or '알수없음',
'vendor_code': recent_row[1] or '',
'order_date': recent_row[2],
'qty': float(recent_row[3] or 0),
'unit_price': float(recent_row[4] or 0)
}
conn.close()
return {
'success': True,
'drug_code': drug_code,
'drug_name': drug_name,
'recent_vendor': recent_vendor,
'most_frequent_vendor': most_frequent,
'vendors': vendors
}
except Exception as e:
return {
'success': False,
'error': str(e),
'drug_code': drug_code
}
@order_bp.route('/drug/<drug_code>/preferred-vendor', methods=['GET'])
def api_drug_preferred_vendor(drug_code):
"""
약품별 선호 도매상 조회 API
GET /api/order/drug/670400830/preferred-vendor
GET /api/order/drug/670400830/preferred-vendor?period=180
"""
period = request.args.get('period', 365, type=int)
result = get_drug_preferred_vendor(drug_code, period)
return jsonify(result)
@order_bp.route('/drugs/preferred-vendors', methods=['POST'])
def api_drugs_preferred_vendors():
"""
여러 약품의 선호 도매상 일괄 조회
POST /api/order/drugs/preferred-vendors
{"drug_codes": ["670400830", "654301800"], "period": 365}
"""
data = request.get_json() or {}
drug_codes = data.get('drug_codes', [])
period = data.get('period', 365)
if not drug_codes:
return jsonify({'success': False, 'error': 'drug_codes required'})
results = {}
for code in drug_codes:
results[code] = get_drug_preferred_vendor(code, period)
return jsonify({
'success': True,
'count': len(results),
'results': results
})
def submit_dongwon_order(order: dict, dry_run: bool, cart_only: bool = True) -> dict:
"""
동원약품 주문 제출
Args:
order: 주문 정보
dry_run: True=시뮬레이션만, False=실제 주문
cart_only: True=장바구니만, False=주문 확정까지
"""
order_id = order['id']
items = order['items']
# 상태 업데이트
update_order_status(order_id, 'pending',
f'동원 주문 시작 (dry_run={dry_run}, cart_only={cart_only})')
results = []
success_count = 0
failed_count = 0
try:
from dongwon_api import get_dongwon_session
dongwon_session = get_dongwon_session()
if dry_run:
# ─────────────────────────────────────────
# DRY RUN: 재고 확인만
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
spec = item.get('specification', '')
# 재고 검색
search_result = dongwon_session.search_products(kd_code)
matched = None
available_specs = []
spec_stocks = {}
if search_result.get('success'):
for dongwon_item in search_result.get('items', []):
s = dongwon_item.get('spec', '')
available_specs.append(s)
spec_stocks[s] = dongwon_item.get('stock', 0)
# 규격 매칭
if spec in s or s in spec:
if matched is None or dongwon_item.get('stock', 0) > matched.get('stock', 0):
matched = dongwon_item
if matched:
stock = matched.get('stock', 0)
if stock >= item['order_qty']:
status = 'success'
result_code = 'OK'
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}"
success_count += 1
elif stock > 0:
status = 'failed'
result_code = 'LOW_STOCK'
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
failed_count += 1
else:
status = 'failed'
result_code = 'OUT_OF_STOCK'
result_message = f"[DRY RUN] 재고 없음"
failed_count += 1
else:
status = 'failed'
result_code = 'NOT_FOUND'
result_message = f"[DRY RUN] 동원에서 규격 {spec} 미발견"
failed_count += 1
update_item_result(item['id'], status, result_code, result_message)
results.append({
'item_id': item['id'],
'drug_code': item.get('drug_code') or item.get('kd_code'),
'product_name': item.get('product_name') or item.get('drug_name', ''),
'specification': spec,
'order_qty': item['order_qty'],
'status': status,
'result_code': result_code,
'result_message': result_message,
'matched_spec': matched.get('spec') if matched else None,
'stock': matched.get('stock') if matched else 0,
'price': matched.get('price') if matched else 0
})
update_order_status(order_id, 'dry_run_complete',
f'[DRY RUN] 완료: 성공 {success_count}, 실패 {failed_count}')
return {
'success': True,
'dry_run': dry_run,
'cart_only': cart_only,
'order_id': order_id,
'order_no': order['order_no'],
'wholesaler': 'dongwon',
'total_items': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
}
else:
# ─────────────────────────────────────────
# 실제 주문: 장바구니 담기 (또는 주문 확정)
# ─────────────────────────────────────────
cart_items = []
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
internal_code = item.get('dongwon_code') or item.get('internal_code')
spec = item.get('specification', '')
order_qty = item['order_qty']
# internal_code가 없으면 검색해서 찾기
if not internal_code:
search_result = dongwon_session.search_products(kd_code)
if search_result.get('success') and search_result.get('items'):
for dongwon_item in search_result['items']:
s = dongwon_item.get('spec', '')
if spec in s or s in spec:
internal_code = dongwon_item.get('internal_code')
break
# 규격 매칭 안 되면 첫 번째 결과 사용
if not internal_code and search_result['items']:
internal_code = search_result['items'][0].get('internal_code')
product_name = item.get('product_name') or item.get('drug_name', '')
if internal_code:
cart_items.append({
'internal_code': internal_code,
'quantity': order_qty
})
update_item_result(item['id'], 'success', 'CART_READY',
f'장바구니 준비 완료: {internal_code}')
results.append({
'item_id': item['id'],
'drug_code': kd_code,
'product_name': product_name,
'specification': spec,
'order_qty': order_qty,
'status': 'success',
'result_code': 'CART_READY',
'result_message': f'장바구니 준비 완료: {internal_code}',
'internal_code': internal_code
})
success_count += 1
else:
update_item_result(item['id'], 'failed', 'NOT_FOUND',
f'동원에서 제품 미발견: {kd_code}')
results.append({
'item_id': item['id'],
'drug_code': kd_code,
'product_name': product_name,
'specification': spec,
'order_qty': order_qty,
'status': 'failed',
'result_code': 'NOT_FOUND',
'result_message': f'동원에서 제품 미발견'
})
failed_count += 1
# safe_order 사용 (장바구니 백업/복구)
if cart_items:
if cart_only:
# 장바구니만 담기
for cart_item in cart_items:
dongwon_session.add_to_cart(
cart_item['internal_code'],
cart_item['quantity']
)
update_order_status(order_id, 'cart_added',
f'동원 장바구니 담기 완료: {len(cart_items)}개 품목')
else:
# safe_order로 주문 (기존 장바구니 백업/복구)
order_result = dongwon_session.safe_order(
items_to_order=cart_items,
memo=order.get('memo', ''),
dry_run=False
)
if order_result.get('success'):
update_order_status(order_id, 'completed',
f'동원 주문 완료: {order_result.get("ordered_count", 0)}개 품목')
else:
update_order_status(order_id, 'failed',
f'동원 주문 실패: {order_result.get("error", "unknown")}')
# 응답 생성
if cart_only:
note = '동원약품 장바구니에 담김. 동원몰에서 최종 확정 필요.'
else:
note = None
return {
'success': True,
'dry_run': dry_run,
'cart_only': cart_only,
'order_id': order_id,
'order_no': order['order_no'],
'wholesaler': 'dongwon',
'total_items': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results,
'note': note
}
except Exception as e:
logger.error(f"동원 주문 오류: {e}", exc_info=True)
update_order_status(order_id, 'error', f'동원 주문 오류: {str(e)}')
return {
'success': False,
'order_id': order_id,
'wholesaler': 'dongwon',
'error': str(e)
}

View File

@@ -105,6 +105,7 @@ def init_db():
-- 약품 정보
drug_code TEXT NOT NULL, -- PIT3000 약품코드
kd_code TEXT, -- 보험코드 (지오영 검색용)
internal_code TEXT, -- 🔧 도매상 내부 코드 (장바구니 직접 추가용!)
product_name TEXT NOT NULL,
manufacturer TEXT,
@@ -372,14 +373,15 @@ def create_order(wholesaler_id: str, items: List[Dict],
cursor.execute('''
INSERT INTO order_items (
order_id, drug_code, kd_code, product_name, manufacturer,
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')
) 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'),

View File

@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
"""
주문 추천 API v2
- 의약품 도메인 지식 반영
- 처방 빈도 기반 차등 추천
- 저빈도 약품: 나간 만큼만 보충
- 고빈도 약품: 일평균 기반 주문
"""
import pyodbc
import logging
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
order_recommendation_bp = Blueprint('order_recommendation', __name__)
def get_mssql_connection(db_name='PM_DRUG'):
conn_str = (
'DRIVER={ODBC Driver 17 for SQL Server};'
f'SERVER=192.168.0.4\\PM2014;'
f'DATABASE={db_name};'
'UID=sa;'
'PWD=tmddls214!%(;'
'TrustServerCertificate=yes'
)
return pyodbc.connect(conn_str, timeout=10)
@order_recommendation_bp.route('/api/order-recommendation')
def api_order_recommendation():
"""
주문 추천 목록 API v2
의약품 도메인 지식 반영:
1. 고빈도 약품 (7일 이상 데이터, 3건 이상 처방): 일평균 × N일분
2. 저빈도 약품 (가끔 사용): 나간 만큼만 보충
3. 유통기한/폐기 위험 고려하여 과잉 주문 방지
GET /api/order-recommendation?days_threshold=7&order_days=14&limit=50
"""
try:
days_threshold = int(request.args.get('days_threshold', 7)) # N일 이내 소진
order_days = int(request.args.get('order_days', 14)) # 고빈도 약품 주문 기준 일수
limit = int(request.args.get('limit', 50))
min_data_days = int(request.args.get('min_data_days', 3)) # 최소 데이터 일수
conn = get_mssql_connection('PM_DRUG')
cursor = conn.cursor()
today = datetime.now().date()
thirty_days_ago = today - timedelta(days=30)
# 1단계: 재고 있는 품목 + 최근 30일 출고/입고 + 처방 건수 조회
cursor.execute("""
WITH StockItems AS (
SELECT
G.DrugCode,
G.GoodsName,
G.BARCODE,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
FROM CD_GOODS G
INNER JOIN IM_total IT ON G.DrugCode = IT.DrugCode
WHERE ISNULL(IT.IM_QT_sale_debit, 0) > 0
),
Outbound AS (
SELECT
DrugCode,
SUM(ISNULL(IM_QT_sale_credit, 0)) as total_outbound,
SUM(ISNULL(IM_QT_sale_debit, 0)) as total_inbound,
COUNT(DISTINCT IM_DT_appl) as data_days,
MAX(IM_DT_appl) as last_outbound_date
FROM IM_date_total
WHERE IM_DT_appl >= ?
AND IM_DT_appl <= ?
GROUP BY DrugCode
)
SELECT
S.DrugCode,
S.GoodsName,
S.BARCODE,
S.current_stock,
ISNULL(O.total_outbound, 0) as total_outbound,
ISNULL(O.total_inbound, 0) as total_inbound,
ISNULL(O.data_days, 0) as data_days,
O.last_outbound_date
FROM StockItems S
LEFT JOIN Outbound O ON S.DrugCode = O.DrugCode
WHERE ISNULL(O.total_outbound, 0) > 0
""", (thirty_days_ago.strftime('%Y%m%d'), today.strftime('%Y%m%d')))
rows = cursor.fetchall()
# 2단계: 처방 건수 조회 (PM_PRES)
drug_codes = [row.DrugCode for row in rows]
rx_counts = {}
if drug_codes:
conn_pres = get_mssql_connection('PM_PRES')
cursor_pres = conn_pres.cursor()
# 최근 30일 처방 건수
placeholders = ','.join(['?' for _ in drug_codes])
cursor_pres.execute(f"""
SELECT DrugCode, COUNT(DISTINCT PreSerial) as rx_count
FROM PS_sub_pharm
WHERE DrugCode IN ({placeholders})
AND PreSerial >= ?
GROUP BY DrugCode
""", drug_codes + [thirty_days_ago.strftime('%Y%m%d')])
for row in cursor_pres.fetchall():
rx_counts[row.DrugCode] = row.rx_count
conn_pres.close()
conn.close()
# 3단계: 추천 로직 (도메인 지식 반영)
recommendations = []
for row in rows:
drug_code = row.DrugCode
goods_name = row.GoodsName
barcode = row.BARCODE or ''
current_stock = int(row.current_stock)
total_outbound = int(row.total_outbound)
total_inbound = int(row.total_inbound)
data_days = int(row.data_days)
rx_count = rx_counts.get(drug_code, 0)
# === 약품 분류 ===
# 고빈도: 7일 이상 데이터 AND 3건 이상 처방
# 저빈도: 그 외
is_high_frequency = data_days >= 7 and rx_count >= 3
if is_high_frequency:
# === 고빈도 약품: 나간 만큼 + 약간 버퍼 ===
avg_daily = total_outbound / data_days
days_until_empty = current_stock / avg_daily if avg_daily > 0 else 999
if days_until_empty > days_threshold:
continue # 아직 여유 있음
# 기본: 나간 만큼 주문 + 10% 버퍼
recommended_qty = int(total_outbound * 1.1)
# 현재 재고 고려 (이미 있는 건 빼기)
recommended_qty = max(0, recommended_qty - current_stock)
# 최소 주문량 (나간 양의 50% 이상)
min_qty = int(total_outbound * 0.5)
if recommended_qty < min_qty:
recommended_qty = min_qty
calc_method = 'high_freq'
else:
# === 저빈도 약품: 나간 만큼만 보충 ===
# 원래 재고 수준으로 복구
original_stock = current_stock + total_outbound - total_inbound
# 나간 만큼만 주문 (과잉 주문 방지)
recommended_qty = int(total_outbound)
# 현재 재고가 이미 충분하면 스킵
if current_stock >= original_stock * 0.5:
continue
# 일평균 개념 없음, 대략적인 소진일
if total_outbound > 0 and data_days > 0:
# 한 달에 total_outbound 나갔으니, 하루 평균
rough_daily = total_outbound / 30
days_until_empty = current_stock / rough_daily if rough_daily > 0 else 999
else:
days_until_empty = 999
if days_until_empty > days_threshold * 2: # 저빈도는 기준 완화
continue
avg_daily = total_outbound / 30 # 대략적
calc_method = 'low_freq'
# 재고가 0 이하면 긴급
if current_stock <= 0:
days_until_empty = 0
# 소진 예상일
empty_date = today + timedelta(days=int(min(days_until_empty, 365)))
# 신뢰도
if data_days >= 20 and rx_count >= 10:
confidence = 'high'
elif data_days >= 7 and rx_count >= 3:
confidence = 'medium'
else:
confidence = 'low'
# 긴급도
if days_until_empty <= 3:
urgency = 'critical'
elif days_until_empty <= 5:
urgency = 'high'
elif days_until_empty <= days_threshold:
urgency = 'normal'
else:
urgency = 'low'
recommendations.append({
'drug_code': drug_code,
'goods_name': goods_name,
'barcode': barcode,
'current_stock': current_stock,
'total_outbound_30d': total_outbound,
'avg_daily_usage': round(avg_daily, 2),
'days_until_empty': round(days_until_empty, 1),
'empty_date': empty_date.strftime('%Y-%m-%d'),
'recommended_qty': recommended_qty,
'rx_count_30d': rx_count,
'data_days': data_days,
'confidence': confidence,
'urgency': urgency,
'calc_method': calc_method, # 계산 방식
'is_high_frequency': is_high_frequency
})
# 4단계: 정렬 (긴급도 → 소진일)
urgency_order = {'critical': 0, 'high': 1, 'normal': 2, 'low': 3}
recommendations.sort(key=lambda x: (urgency_order.get(x['urgency'], 9), x['days_until_empty']))
recommendations = recommendations[:limit]
# 5단계: 요약
critical_count = sum(1 for r in recommendations if r['urgency'] == 'critical')
high_count = sum(1 for r in recommendations if r['urgency'] == 'high')
high_freq_count = sum(1 for r in recommendations if r['is_high_frequency'])
low_freq_count = sum(1 for r in recommendations if not r['is_high_frequency'])
total_order_qty = sum(r['recommended_qty'] for r in recommendations)
return jsonify({
'success': True,
'version': '2.0',
'generated_at': datetime.now().isoformat(),
'params': {
'days_threshold': days_threshold,
'order_days': order_days,
'min_data_days': min_data_days
},
'summary': {
'total_items': len(recommendations),
'critical_count': critical_count,
'high_count': high_count,
'high_frequency_items': high_freq_count,
'low_frequency_items': low_freq_count,
'total_recommended_qty': total_order_qty
},
'recommendations': recommendations
})
except Exception as e:
logging.error(f"order-recommendation API error: {e}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
@order_recommendation_bp.route('/api/order-recommendation/execute', methods=['POST'])
def api_execute_order():
"""주문 실행 API (POST) - TODO"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data'}), 400
wholesaler = data.get('wholesaler', 'sooin')
items = data.get('items', [])
dry_run = data.get('dry_run', True)
if not items:
return jsonify({'success': False, 'error': 'No items'}), 400
return jsonify({
'success': True,
'wholesaler': wholesaler,
'dry_run': dry_run,
'items_count': len(items),
'message': 'Simulation complete' if dry_run else 'Order submitted'
})
except Exception as e:
logging.error(f"execute-order API error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1,5 +1,33 @@
# pmr_api.py - 조제관리(PMR) Blueprint API
# PharmaIT3000 MSSQL 연동 (192.168.0.4)
#
# ═══════════════════════════════════════════════════════════════
# 📋 주요 함수 가이드
# ═══════════════════════════════════════════════════════════════
#
# 🏷️ 라벨 관련:
# - normalize_medication_name(med_name)
# 약품명 정규화: 밀리그램→mg, 언더스코어 제거 등
# 예: "케이발린캡슐75밀리그램_" → "케이발린캡슐75mg"
#
# - get_drug_unit(goods_name, sung_code) [utils/drug_unit.py]
# SUNG_CODE 기반 단위 판별
# 예: SUNG_CODE "123456TB" → "정" (TB=정제)
# FormCode: TB=정, CA/CH/CS=캡슐, SY=mL, GA/GB=포 등
#
# - create_label_image(patient_name, med_name, ...)
# PIL로 29mm 라벨 이미지 생성
# 지그재그 테두리, 동적 폰트, 복용량 박스 등
#
# 📊 SUNG_CODE FormCode 참조 (마지막 2자리):
# 정제류: TA, TB, TC, TD, TE, TH, TJ, TR → "정"
# 캡슐류: CA, CB, CH, CI, CJ, CS → "캡슐"
# 액제류: SS, SY, LQ → "mL" (시럽)
# 산제류: GA, GB, GC, GN, PD → "포"
# 점안제: EY, OS → "병" 또는 "개"
# 외용제: XT, XO, XL → "g" 또는 "개"
#
# ═══════════════════════════════════════════════════════════════
from flask import Blueprint, jsonify, request, render_template, send_file
import pyodbc
@@ -8,9 +36,14 @@ from pathlib import Path
from datetime import datetime, date
import logging
from PIL import Image, ImageDraw, ImageFont
# Pillow 10+ 호환성 패치 (brother_ql용)
if not hasattr(Image, 'ANTIALIAS'):
Image.ANTIALIAS = Image.Resampling.LANCZOS
import io
import base64
import os
from utils.drug_unit import get_drug_unit
pmr_bp = Blueprint('pmr', __name__, url_prefix='/pmr')
@@ -363,6 +396,7 @@ def get_prescription_detail(prescription_id):
'total_qty': float(row.INV_QUAN) if row.INV_QUAN else 0,
'type': '급여' if row.PS_Type in ['0', '4'] else '비급여' if row.PS_Type == '1' else row.PS_Type,
'sung_code': row.SUNG_CODE or '',
'unit': get_drug_unit(row.GoodsName or '', row.SUNG_CODE or ''),
'ps_type': row.PS_Type or '0',
'unit_code': unit_code,
'is_substituted': is_substituted,
@@ -401,20 +435,24 @@ def get_prescription_detail(prescription_id):
'name_2': disease_name_2
}
# 환자 특이사항(CUSETC) 조회 - CD_PERSON 테이블
# 환자 특이사항(CUSETC) + 전화번호 조회 - CD_PERSON 테이블
cusetc = ''
phone = ''
cus_code = rx_row.CusCode
if cus_code:
try:
# PM_BASE.dbo.CD_PERSON에서 조회
cursor.execute("""
SELECT CUSETC FROM PM_BASE.dbo.CD_PERSON WHERE CUSCODE = ?
SELECT CUSETC, PHONE, TEL_NO, PHONE2 FROM PM_BASE.dbo.CD_PERSON WHERE CUSCODE = ?
""", (cus_code,))
person_row = cursor.fetchone()
if person_row and person_row.CUSETC:
cusetc = person_row.CUSETC
if person_row:
if person_row.CUSETC:
cusetc = person_row.CUSETC
# 전화번호 (PHONE, TEL_NO, PHONE2 중 하나)
phone = person_row.PHONE or person_row.TEL_NO or person_row.PHONE2 or ''
except Exception as e:
logging.warning(f"특이사항 조회 실패: {e}")
logging.warning(f"환자정보 조회 실패: {e}")
conn.close()
@@ -437,7 +475,8 @@ def get_prescription_detail(prescription_id):
'cus_code': rx_row.CusCode, # 호환성
'age': age,
'gender': gender,
'cusetc': cusetc # 특이사항
'cusetc': cusetc, # 특이사항
'phone': phone # 전화번호
},
'disease_info': disease_info,
'medications': medications,
@@ -536,6 +575,7 @@ def preview_label():
- frequency: 복용 횟수
- duration: 복용 일수
- unit: 단위 (정, 캡슐, mL 등)
- sung_code: 성분코드 (환산계수 조회용, 선택)
"""
try:
data = request.get_json()
@@ -547,6 +587,19 @@ def preview_label():
frequency = int(data.get('frequency', 0))
duration = int(data.get('duration', 0))
unit = data.get('unit', '')
sung_code = data.get('sung_code', '')
# 환산계수 및 보관조건 조회 (sung_code가 있는 경우)
conversion_factor = None
storage_conditions = '실온보관'
if sung_code:
try:
from db.dbsetup import db_manager
cf_result = db_manager.get_conversion_factor(sung_code)
conversion_factor = cf_result.get('conversion_factor')
storage_conditions = cf_result.get('storage_conditions', '실온보관')
except Exception as cf_err:
logging.warning(f"환산계수 조회 실패 (무시): {cf_err}")
# 라벨 이미지 생성
image = create_label_image(
@@ -556,7 +609,9 @@ def preview_label():
dosage=dosage,
frequency=frequency,
duration=duration,
unit=unit
unit=unit,
conversion_factor=conversion_factor,
storage_conditions=storage_conditions
)
# Base64 인코딩
@@ -567,7 +622,9 @@ def preview_label():
return jsonify({
'success': True,
'image': f'data:image/png;base64,{img_base64}'
'image': f'data:image/png;base64,{img_base64}',
'conversion_factor': conversion_factor,
'storage_conditions': storage_conditions
})
except Exception as e:
@@ -575,10 +632,197 @@ def preview_label():
return jsonify({'success': False, 'error': str(e)}), 500
def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=0, duration=0, unit=''):
# API: 라벨 인쇄 (Brother QL 프린터)
# ─────────────────────────────────────────────────────────────
@pmr_bp.route('/api/label/print', methods=['POST'])
def print_label():
"""
라벨 이미지 생성 (29mm 용지 기준)
라벨 인쇄 (PIL 렌더링 → Brother QL 프린터 전송)
Request Body:
- patient_name, med_name, dosage, frequency, duration, unit, sung_code
- printer: 프린터 선택 (선택, 기본값 '168')
- '121': QL-710W (192.168.0.121)
- '168': QL-810W (192.168.0.168)
- orientation: 출력 방향 (선택, 기본값 'portrait')
- 'portrait': 세로 모드 (QR 라벨과 동일, 회전 없음)
- 'landscape': 가로 모드 (90도 회전)
"""
try:
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
data = request.get_json()
patient_name = data.get('patient_name', '')
med_name = data.get('med_name', '')
add_info = data.get('add_info', '')
dosage = float(data.get('dosage', 0))
frequency = int(data.get('frequency', 0))
duration = int(data.get('duration', 0))
unit = data.get('unit', '')
sung_code = data.get('sung_code', '')
printer = data.get('printer', '168') # 기본값: QL-810W
orientation = data.get('orientation', 'portrait') # 기본값: 세로 모드
# 프린터 설정
if printer == '121':
printer_ip = '192.168.0.121'
printer_model = 'QL-710W'
else:
printer_ip = '192.168.0.168'
printer_model = 'QL-810W'
# 환산계수 및 보관조건 조회
conversion_factor = None
storage_conditions = '실온보관'
if sung_code:
try:
from db.dbsetup import db_manager
cf_result = db_manager.get_conversion_factor(sung_code)
conversion_factor = cf_result.get('conversion_factor')
storage_conditions = cf_result.get('storage_conditions', '실온보관')
except Exception as cf_err:
logging.warning(f"환산계수 조회 실패 (무시): {cf_err}")
# 1. 라벨 이미지 생성
label_image = create_label_image(
patient_name=patient_name,
med_name=med_name,
add_info=add_info,
dosage=dosage,
frequency=frequency,
duration=duration,
unit=unit,
conversion_factor=conversion_factor,
storage_conditions=storage_conditions
)
# 2. 방향 설정 (portrait: 회전 없음, landscape: 90도 회전)
if orientation == 'landscape':
# 가로 모드: 90도 회전 (기존 방식)
label_final = label_image.rotate(90, expand=True)
else:
# 세로 모드: 회전 없음 (QR 라벨과 동일)
label_final = label_image
# 3. Brother QL 프린터로 전송
qlr = BrotherQLRaster(printer_model)
instructions = convert(
qlr=qlr,
images=[label_final],
label='29',
rotate='0',
threshold=70.0,
dither=False,
compress=False,
red=False,
dpi_600=False,
hq=True,
cut=True
)
send(instructions, printer_identifier=f"tcp://{printer_ip}:9100")
logging.info(f"[SUCCESS] PMR 라벨 인쇄 성공: {med_name}{printer_model} ({orientation})")
return jsonify({
'success': True,
'message': f'{med_name} 라벨 인쇄 완료 ({printer_model})',
'printer': printer_model,
'orientation': orientation
})
except ImportError as e:
logging.error(f"brother_ql 라이브러리 없음: {e}")
return jsonify({'success': False, 'error': 'brother_ql 라이브러리가 설치되지 않았습니다'}), 500
except Exception as e:
logging.error(f"라벨 인쇄 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def normalize_medication_name(med_name):
"""
약품명 정제 - 밀리그램 등을 mg로 변환, 불필요한 부분 제거
"""
import re
if not med_name:
return med_name
# 언더스코어 뒤 내용 제거 (예: 휴니즈레바미피드정_ → 휴니즈레바미피드정)
med_name = re.sub(r'_.*$', '', med_name)
# 대괄호 및 내용 제거
med_name = re.sub(r'\[.*?\]', '', med_name)
med_name = re.sub(r'\[.*$', '', med_name)
# 밀리그램 변환
med_name = re.sub(r'밀리그램|밀리그람|미리그램|미리그람', 'mg', med_name)
# 마이크로그램 변환
med_name = re.sub(r'마이크로그램|마이크로그람', 'μg', med_name)
# 그램 변환 (mg/μg 제외)
med_name = re.sub(r'(?<!m)(?<!μ)그램|그람', 'g', med_name)
# 밀리리터 변환
med_name = re.sub(r'밀리리터|밀리리타|미리리터|미리리타', 'mL', med_name)
# 공백 정리
med_name = re.sub(r'\s+', ' ', med_name).strip()
return med_name
def draw_scissor_border(draw, width, height, edge_size=5, steps=20):
"""
지그재그 패턴의 테두리를 그립니다 (가위로 자른 느낌).
"""
# 상단 테두리
top_points = []
step_x = width / (steps * 2)
for i in range(steps * 2 + 1):
x = i * step_x
y = 0 if i % 2 == 0 else edge_size
top_points.append((int(x), int(y)))
draw.line(top_points, fill="black", width=2)
# 하단 테두리
bottom_points = []
for i in range(steps * 2 + 1):
x = i * step_x
y = height if i % 2 == 0 else height - edge_size
bottom_points.append((int(x), int(y)))
draw.line(bottom_points, fill="black", width=2)
# 좌측 테두리
left_points = []
step_y = height / (steps * 2)
for i in range(steps * 2 + 1):
y = i * step_y
x = 0 if i % 2 == 0 else edge_size
left_points.append((int(x), int(y)))
draw.line(left_points, fill="black", width=2)
# 우측 테두리
right_points = []
for i in range(steps * 2 + 1):
y = i * step_y
x = width if i % 2 == 0 else width - edge_size
right_points.append((int(x), int(y)))
draw.line(right_points, fill="black", width=2)
def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=0, duration=0, unit='', conversion_factor=None, storage_conditions='실온보관'):
"""
라벨 이미지 생성 (29mm 용지 기준) - 레거시 디자인 적용
Args:
conversion_factor: 건조시럽 환산계수 (mL→g 변환용, 선택)
- 예: 0.11이면 120ml * 0.11 = 13.2g
- 총량 옆에 괄호로 표시: "총120mL (13.2g)/5일분"
storage_conditions: 보관조건 (예: '냉장보관', '실온보관')
- 용법 박스와 조제일 사이 여백에 표시
"""
# 약품명 정제 (밀리그램 → mg 등)
med_name = normalize_medication_name(med_name)
# 라벨 크기 (29mm 용지, 300dpi 기준)
label_width = 306
label_height = 380
@@ -592,15 +836,38 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
font_path = "C:/Windows/Fonts/malgun.ttf"
try:
name_font = ImageFont.truetype(font_path, 36)
drug_font = ImageFont.truetype(font_path, 24)
info_font = ImageFont.truetype(font_path, 22)
small_font = ImageFont.truetype(font_path, 18)
name_font = ImageFont.truetype(font_path, 44) # 환자명 폰트 크게
drug_font = ImageFont.truetype(font_path, 32) # 약품명
info_font = ImageFont.truetype(font_path, 30) # 복용 정보
small_font = ImageFont.truetype(font_path, 20) # 조제일
additional_font = ImageFont.truetype(font_path, 27) # 총량/효능
signature_font = ImageFont.truetype(font_path, 32) # 시그니처
except:
name_font = ImageFont.load_default()
drug_font = ImageFont.load_default()
info_font = ImageFont.load_default()
small_font = ImageFont.load_default()
additional_font = ImageFont.load_default()
signature_font = ImageFont.load_default()
# 동적 폰트 크기 조정 함수
def get_adaptive_font(text, max_width, initial_font_size, min_font_size=20):
"""텍스트가 max_width를 초과하지 않도록 폰트 크기를 동적으로 조정"""
current_size = initial_font_size
while current_size >= min_font_size:
try:
test_font = ImageFont.truetype(font_path, current_size)
except:
return ImageFont.load_default()
bbox = draw.textbbox((0, 0), text, font=test_font)
text_width = bbox[2] - bbox[0]
if text_width <= max_width:
return test_font
current_size -= 2
try:
return ImageFont.truetype(font_path, min_font_size)
except:
return ImageFont.load_default()
# 중앙 정렬 텍스트 함수
def draw_centered(text, y, font, fill="black"):
@@ -611,6 +878,25 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
return y + bbox[3] - bbox[1] + 5
# 약품명 줄바꿈 처리
def wrap_text_korean(text, font, max_width, draw):
"""한글/영문 혼합 텍스트 줄바꿈 (글자 단위)"""
if not text:
return [text]
lines = []
current_line = ""
for char in text:
test_line = current_line + char
bbox = draw.textbbox((0, 0), test_line, font=font)
if bbox[2] - bbox[0] <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
return lines if lines else [text]
def wrap_text(text, font, max_width):
lines = []
words = text.split()
@@ -628,7 +914,7 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
lines.append(current_line)
return lines if lines else [text]
y = 15
y = 8 # 상단 지그재그 ↔ 이름 간격 (공간 확보를 위해 축소)
# 환자명 (띄어쓰기)
spaced_name = " ".join(patient_name) if patient_name else ""
@@ -637,44 +923,64 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
y += 5
# 약품명 (줄바꿈)
# 괄호 앞에서 분리
if '(' in med_name:
main_name = med_name.split('(')[0].strip()
else:
# 앞에 있는 (숫자mg) 패턴 제거 후, 뒤의 괄호 앞에서 분리
import re
# (2.5mg)노바스크정 → 노바스크정
main_name = re.sub(r'^\([^)]+\)', '', med_name).strip()
# 노바스크정(고혈압) → 노바스크정
if '(' in main_name:
main_name = main_name.split('(')[0].strip()
# 빈 문자열이면 원본 사용
if not main_name:
main_name = med_name
# 약품명 줄바꿈
name_lines = wrap_text(main_name, drug_font, label_width - 30)
# 약품명 - 동적 폰트 크기 적용 (긴 이름 자동 축소)
adaptive_drug_font = get_adaptive_font(main_name, label_width - 30, 32, 18)
name_lines = wrap_text_korean(main_name, adaptive_drug_font, label_width - 30, draw)
for line in name_lines:
y = draw_centered(line, y, drug_font)
y = draw_centered(line, y, adaptive_drug_font)
# 효능효과 (add_info)
# 효능효과 (add_info) - 동적 폰트 크기 적용
if add_info:
y = draw_centered(f"({add_info})", y, small_font, fill="gray")
efficacy_text = f"({add_info})"
adaptive_efficacy_font = get_adaptive_font(efficacy_text, label_width - 40, 30, 20)
y = draw_centered(efficacy_text, y, adaptive_efficacy_font, fill="black")
y += 5
# 총량 계산
# 총량 계산 및 표시 (환산계수 반영)
if dosage > 0 and frequency > 0 and duration > 0:
total = dosage * frequency * duration
total_str = str(int(total)) if total == int(total) else f"{total:.1f}"
total_text = f"{total_str}{unit} / {duration}일분"
y = draw_centered(total_text, y, info_font)
total_str = str(int(total)) if total == int(total) else f"{total:.2f}".rstrip('0').rstrip('.')
# 환산계수가 있으면 변환된 총량도 표시 (예: "총120mL (13.2g)/5일분")
if conversion_factor is not None and conversion_factor > 0:
converted_total = total * conversion_factor
if converted_total == int(converted_total):
converted_str = str(int(converted_total))
else:
converted_str = f"{converted_total:.2f}".rstrip('0').rstrip('.')
total_text = f"{total_str}{unit} ({converted_str}g)/{duration}일분"
else:
total_text = f"{total_str}{unit}/{duration}일분"
y = draw_centered(total_text, y, additional_font)
y += 5
# 용법 박스
# 용법 박스 (테두리 있는 박스)
box_margin = 20
box_height = 75
box_top = y
box_bottom = y + 70
box_bottom = y + box_height
box_width = label_width - 2 * box_margin
draw.rectangle(
[(box_margin, box_top), (label_width - box_margin, box_bottom)],
outline="black",
width=2
)
# 박스 내용
dosage_str = str(int(dosage)) if dosage == int(dosage) else f"{dosage:.2f}".rstrip('0').rstrip('.')
# 박스 내용 - 1회 복용량
dosage_str = str(int(dosage)) if dosage == int(dosage) else f"{dosage:.4f}".rstrip('0').rstrip('.')
dosage_text = f"{dosage_str}{unit}"
# 복용 시간
@@ -687,24 +993,65 @@ def create_label_image(patient_name, med_name, add_info='', dosage=0, frequency=
else:
time_text = f"1일 {frequency}"
box_center_y = (box_top + box_bottom) // 2
draw_centered(dosage_text, box_center_y - 20, info_font)
draw_centered(time_text, box_center_y + 5, info_font)
# 박스 내 텍스트 중앙 배치 (수직 중앙 정렬)
line_spacing = 6 # 1회복용량 ↔ 복용횟수 간격
bbox1 = draw.textbbox((0, 0), dosage_text, font=info_font)
text1_height = bbox1[3] - bbox1[1]
bbox2 = draw.textbbox((0, 0), time_text, font=info_font)
text2_height = bbox2[3] - bbox2[1]
# 방법 2: 폰트 최대 높이 기준 고정 (글자 내용과 무관하게 일정한 레이아웃)
fixed_line_height = 32 # 폰트 크기 30 기반 고정 라인 높이
fixed_total_height = fixed_line_height * 2 + line_spacing
center_y = (box_top + box_bottom) // 2
start_y = center_y - (fixed_total_height // 2) - 5 # 박스 내 텍스트 전체 위로 조정
y = box_bottom + 10
draw_centered(dosage_text, start_y, info_font)
draw_centered(time_text, start_y + fixed_line_height + line_spacing, info_font)
# 조제일
# 조제일 (시그니처 위쪽에 배치) - 먼저 위치 계산
today = datetime.now().strftime('%Y-%m-%d')
y = draw_centered(f"조제일: {today}", y, small_font)
print_date_text = f"조제일 : {today}"
bbox = draw.textbbox((0, 0), print_date_text, font=small_font)
date_w, date_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
print_date_y = label_height - date_h - 70 # 시그니처 위쪽
# 약국명 (하단)
pharmacy_y = label_height - 40
draw.rectangle(
[(50, pharmacy_y - 5), (label_width - 50, pharmacy_y + 25)],
outline="black",
width=1
)
draw_centered("청 춘 약 국", pharmacy_y, info_font)
# 보관조건 표시 (조제일 바로 위에 고정 배치)
if storage_conditions:
storage_text = f"* {storage_conditions}"
try:
storage_font = ImageFont.truetype(font_path, 28)
except:
storage_font = ImageFont.load_default()
bbox_storage = draw.textbbox((0, 0), storage_text, font=storage_font)
storage_w = bbox_storage[2] - bbox_storage[0]
storage_h = bbox_storage[3] - bbox_storage[1]
storage_y = print_date_y - storage_h - 8 # 조제일 위 8px 간격
draw.text(((label_width - storage_w) / 2, storage_y), storage_text, font=storage_font, fill="black")
# 조제일 그리기
draw.text(((label_width - date_w) / 2, print_date_y), print_date_text, font=small_font, fill="black")
# 시그니처 박스 (하단 - 약국명)
signature_text = "청 춘 약 국"
bbox = draw.textbbox((0, 0), signature_text, font=signature_font)
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 시그니처 박스 패딩 및 위치 계산
padding_top = int(h_sig * 0.1)
padding_bottom = int(h_sig * 0.5)
padding_sides = int(h_sig * 0.2)
box_x = (label_width - w_sig) / 2 - padding_sides
box_y = label_height - h_sig - padding_top - padding_bottom - 10
box_x2 = box_x + w_sig + 2 * padding_sides
box_y2 = box_y + h_sig + padding_top + padding_bottom
# 시그니처 테두리 및 텍스트
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=1)
draw.text(((label_width - w_sig) / 2, box_y + padding_top), signature_text, font=signature_font, fill="black")
# 지그재그 테두리 (가위로 자른 느낌)
draw_scissor_border(draw, label_width, label_height, edge_size=10, steps=20)
return image
@@ -1208,7 +1555,7 @@ def paai_analyze():
# 5. Clawdbot AI 호출 (WebSocket)
ai_start = time_module.time()
ai_response = call_clawdbot_ai(ai_prompt)
ai_response = call_clawdbot_ai(ai_prompt, cus_code=cus_code)
ai_time = int((time_module.time() - ai_start) * 1000)
# AI 결과 로그 업데이트
@@ -1397,21 +1744,32 @@ def build_paai_prompt(
return prompt
def call_clawdbot_ai(prompt: str) -> dict:
"""Clawdbot AI 호출 (WebSocket Gateway)"""
def call_clawdbot_ai(prompt: str, cus_code: str = None) -> dict:
"""Clawdbot AI 호출 (WebSocket Gateway)
Args:
prompt: AI에게 보낼 프롬프트
cus_code: 환자 코드 (세션 분리용, 같은 날 같은 환자는 세션 공유)
"""
import json
import re
from datetime import datetime
from services.clawdbot_client import ask_clawdbot
PAAI_SYSTEM_PROMPT = """당신은 경험 많은 약사입니다.
처방 데이터를 분석하여 약사에게 유용한 정보를 제공합니다.
이전 대화와 관계없이, 아래 제공된 처방 정보만 보고 독립적으로 분석하세요.
반드시 요청된 JSON 형식으로만 응답하세요."""
# 세션 ID: 날짜별 단일 세션 (하루 1개)
today = datetime.now().strftime('%Y%m%d')
session_id = f'paai-{today}'
try:
# Clawdbot Gateway WebSocket API 호출
ai_text = ask_clawdbot(
message=prompt,
session_id='paai-analysis',
session_id=session_id,
system_prompt=PAAI_SYSTEM_PROMPT,
timeout=60,
model='anthropic/claude-sonnet-4-5' # 빠른 Sonnet 사용

View File

@@ -1,25 +1,102 @@
# -*- coding: utf-8 -*-
"""
동물약 일괄 APC 매칭 - 후보 찾기
동물약 일괄 APC 매칭 (개선판)
- 띄어쓰기 무시 매칭
- 체중 범위로 정밀 매칭
- dry-run 모드 (검증용)
"""
import sys, io
import sys, io, re
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
from datetime import datetime
DRY_RUN = True # True: 검증만, False: 실제 INSERT
# ── 유틸 함수 ──
def normalize(name):
"""띄어쓰기/특수문자 제거하여 비교용 문자열 생성"""
# 공백, 하이픈, 점 제거
return re.sub(r'[\s\-\.]+', '', name).lower()
def extract_base_name(mssql_name):
"""MSSQL 제품명에서 검색용 기본명 추출 (여러 후보 반환)
예: '다이로하트정M(12~22kg)' → ['다이로하트정', '다이로하트']
'하트캅츄어블(11kg이하)' → ['하트캅츄어블', '하트캅']
'클라펫정50(100정)' → ['클라펫정50', '클라펫정', '클라펫']
"""
name = mssql_name.replace('(판)', '')
# 사이즈 라벨(XS/SS/S/M/L/XL/mini) + 괄호 이전까지
m = re.match(r'^(.+?)(XS|SS|XL|xs|mini|S|M|L)?\s*[\(/]', name)
if m:
base = m.group(1)
else:
base = re.sub(r'[\(/].*', '', name)
base = base.strip()
candidates = [base]
# 끝의 숫자 제거: 클라펫정50 → 클라펫정
no_num = re.sub(r'\d+$', '', base)
if no_num and no_num != base:
candidates.append(no_num)
# 제형 접미사 제거: 다이로하트정 → 다이로하트, 하트캅츄어블 → 하트캅
for suffix in ['츄어블', '', '', '캡슐', '', '시럽']:
for c in list(candidates):
stripped = re.sub(suffix + r'$', '', c)
if stripped and stripped != c and stripped not in candidates:
candidates.append(stripped)
return candidates
def extract_weight_range(mssql_name):
"""MSSQL 제품명에서 체중 범위 추출
'가드닐L(20~40kg)' → (20, 40)
'셀라이트액SS(2.5kg이하)' → (0, 2.5)
'파라캅L(5kg이상)' → (5, 999)
'하트웜솔루션츄어블S(11kg이하)' → (0, 11)
'다이로하트정S(5.6~11kg)' → (5.6, 11)
"""
# 범위: (5.6~11kg), (2~10kg)
m = re.search(r'\((\d+\.?\d*)[-~](\d+\.?\d*)\s*kg\)', mssql_name)
if m:
return float(m.group(1)), float(m.group(2))
# 이하: (2.5kg이하), (11kg이하)
m = re.search(r'\((\d+\.?\d*)\s*kg\s*이하\)', mssql_name)
if m:
return 0, float(m.group(1))
# 이상: (5kg이상)
m = re.search(r'\((\d+\.?\d*)\s*kg\s*이상\)', mssql_name)
if m:
return float(m.group(1)), 999
return None, None
def weight_match(mssql_min, mssql_max, pg_min, pg_max):
"""체중 범위가 일치하는지 확인 (약간의 오차 허용)"""
if pg_min is None or pg_max is None:
return False
# 이상(999)인 경우 pg_max도 큰 값이면 OK
if mssql_max == 999 and pg_max >= 50:
return abs(mssql_min - pg_min) <= 1
return abs(mssql_min - pg_min) <= 1 and abs(mssql_max - pg_max) <= 1
# ── 1. MSSQL 동물약 (APC 없는 것만) ──
# 1. MSSQL 동물약 (APC 없는 것만)
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT
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
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
@@ -39,44 +116,190 @@ for row in result:
session.close()
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===\n')
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===')
print(f'=== 모드: {"DRY-RUN (검증만)" if DRY_RUN else "실제 INSERT"} ===\n')
# ── 2. PostgreSQL에서 매칭 ──
# 2. PostgreSQL에서 매칭 후보 찾기
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
matches = []
matched = [] # 확정 매칭
ambiguous = [] # 후보 여러 개 (수동 확인 필요)
no_match = [] # 매칭 없음
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({
base_names = extract_base_name(name)
w_min, w_max = extract_weight_range(name)
# 여러 기본명 후보로 검색 (좁은 것부터 시도)
candidates = []
used_base = None
for bn in base_names:
norm_base = normalize(bn)
result = pg.execute(text("""
SELECT apc, product_name,
weight_min_kg, weight_max_kg,
dosage,
llm_pharm->>'사용가능 동물' as target
FROM apc
WHERE REGEXP_REPLACE(LOWER(product_name), '[\\s\\-\\.]+', '', 'g') LIKE :pattern
ORDER BY product_name
"""), {'pattern': f'%{norm_base}%'})
candidates = list(result)
if candidates:
used_base = bn
break
if not used_base:
used_base = base_names[0]
if not candidates:
no_match.append(drug)
print(f'{name}')
print(f' 기본명: {base_names} → 매칭 없음')
continue
# ── 단계별 필터링 ──
# (A) 제형 필터: MSSQL 이름에 "정"이 있으면 PG에서도 "정" 포함 우선
filtered = candidates
for form in ['', '', '캡슐']:
if form in name.split('(')[0]:
form_match = [c for c in filtered if form in c.product_name]
if form_match:
filtered = form_match
break
# (B) 체중 범위로 정밀 매칭
if w_min is not None:
exact = [c for c in filtered
if weight_match(w_min, w_max, c.weight_min_kg, c.weight_max_kg)]
if exact:
filtered = exact
# (C) 포장단위 여러 개면 최소 포장 선택 (낱개 판매 기준)
# "/ 6 정", "/ 1 피펫" 등에서 숫자 추출
if len(filtered) > 1:
def extract_pack_qty(pname):
m = re.search(r'/\s*(\d+)\s*(정|피펫|개|포)', pname)
return int(m.group(1)) if m else 0
has_qty = [(c, extract_pack_qty(c.product_name)) for c in filtered]
# 포장수량이 있는 것들만 필터
with_qty = [(c, q) for c, q in has_qty if q > 0]
if with_qty:
min_qty = min(q for _, q in with_qty)
filtered = [c for c, q in with_qty if q == min_qty]
# (D) 그래도 여러 개면 대표 APC (product_name이 가장 짧은 것) 선택
if len(filtered) > 1:
# 포장수량 정보가 없는 대표 코드가 있으면 우선
no_qty = [c for c in filtered if '/' not in c.product_name]
if len(no_qty) == 1:
filtered = no_qty
# ── 결과 판정 ──
if len(filtered) == 1:
method = '체중매칭' if w_min is not None and filtered[0].weight_min_kg is not None else '유일후보'
matched.append({
'mssql': drug,
'candidates': candidates
'apc': filtered[0],
'method': method
})
print(f'{name}')
for c in candidates[:2]:
print(f'{c.apc}: {c.product_name[:40]}... [{c.target or "?"}]')
else:
print(f'{name} - 매칭 없음')
print(f'{filtered[0].apc}: {filtered[0].product_name}')
if w_min is not None and filtered[0].weight_min_kg is not None:
print(f' 체중: MSSQL({w_min}~{w_max}kg) = PG({filtered[0].weight_min_kg}~{filtered[0].weight_max_kg}kg)')
continue
# 후보가 0개 (필터가 너무 강했으면 원래 candidates로 복구)
if len(filtered) == 0:
filtered = candidates
# 수동 확인
ambiguous.append({
'mssql': drug,
'candidates': filtered,
'reason': f'후보 {len(filtered)}'
})
print(f'⚠️ {name} - 후보 {len(filtered)}건 (수동 확인)')
for c in filtered[:5]:
wt = f'({c.weight_min_kg}~{c.weight_max_kg}kg)' if c.weight_min_kg else ''
print(f'{c.apc}: {c.product_name} {wt}')
pg.close()
print(f'\n=== 요약 ===')
# ── 3. 요약 ──
print(f'\n{"="*50}')
print(f'=== 매칭 요약 ===')
print(f'APC 없는 제품: {len(no_apc)}')
print(f'매칭 후보 있음: {len(matches)}')
print(f'매칭 없음: {len(no_apc) - len(matches)}')
print(f'✅ 확정 매칭: {len(matched)}')
print(f'⚠️ 수동 확인: {len(ambiguous)}')
print(f'❌ 매칭 없음: {len(no_match)}')
if matched:
print(f'\n{"="*50}')
print(f'=== 확정 매칭 목록 (INSERT 대상) ===')
for m in matched:
d = m['mssql']
a = m['apc']
print(f' {d["name"]:40s}{a.apc} [{m["method"]}]')
# ── 4. INSERT (DRY_RUN=False일 때만) ──
if matched and not DRY_RUN:
print(f'\n{"="*50}')
print(f'=== INSERT 실행 ===')
session = get_db_session('PM_DRUG')
today = datetime.now().strftime('%Y%m%d')
for m in matched:
drugcode = m['mssql']['code']
apc = m['apc'].apc
# 기존 가격 조회
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'{m["mssql"]["name"]}: 기존 레코드 없음')
continue
# 중복 확인
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' ⏭️ {m["mssql"]["name"]}: 이미 등록됨')
continue
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'{m["mssql"]["name"]}{apc}')
except Exception as e:
session.rollback()
print(f'{m["mssql"]["name"]}: {e}')
session.close()
print('\n완료!')

View File

@@ -18,6 +18,15 @@ MAPPINGS = [
# 세레니아
('세레니아정16mg(개멀미약)', 'LB000003353', '0231884610109'), # 세레니아 정 16mg / 4정
('세레니아정24mg(개멀미약)', 'LB000003354', '0231884620107'), # 세레니아 정 24mg / 4정
# ── 2차 매칭 (2026-03-08) ──
# 클라펫 (유일후보)
('(판)클라펫정50(100정)', 'LB000003504', '0232065900005'), # 클라펫 정
# 넥스가드 (체중매칭)
('넥스가드L(15~30kg)', 'LB000003531', '0232155400009'), # 넥스가드 스펙트라 츄어블 정 대형견용
('넥스가드xs(2~3.5kg)', 'LB000003530', '0232169000004'), # 넥스가드 츄어블 정 소형견용
# 하트웜 (체중매칭)
('하트웜솔루션츄어블M(12~22kg)', 'LB000003155', '0230758520105'), # 하트웜 솔루션 츄어블 0.136mg / 114mg / 6 정
('하트웜솔루션츄어블S(11kg이하)', 'LB000003156', '0230758510107'), # 하트웜 솔루션 츄어블 0.068mg / 57mg / 6 정
]
session = get_db_session('PM_DRUG')

View File

@@ -0,0 +1,545 @@
# -*- coding: utf-8 -*-
"""
APDB weight_min_kg / weight_max_kg 일괄 채우기
- dosage_instructions에서 (사이즈라벨, 체중구간) 쌍을 파싱
- APC 레코드의 product_name에 포함된 사이즈 라벨로 매칭
매칭 전략:
1. 제품명에 사이즈 라벨(소형견, 중형견 등)이 있으면 → 해당 체중구간 적용
2. 체중 구간이 1개뿐이면 → 전체 APC에 적용
3. 다중 구간인데 제품명에 라벨 없으면 → SKIP (안전)
예외 처리:
- 사료/축산 관련(톤당 kg) → SKIP
- 축산용(max > 60kg) → SKIP
- 체중 구간 파싱 불가 → SKIP
실행: python scripts/fill_weight_from_dosage.py [--commit] [--verbose]
기본: dry-run (DB 변경 없음)
--commit: 실제 DB 업데이트 수행
--verbose: 상세 로그
"""
import sys
import io
import re
import argparse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from sqlalchemy import text, create_engine
# ─────────────────────────────────────────────
# 1. 사이즈 라벨 정의
# ─────────────────────────────────────────────
SIZE_LABELS = {
'초소형견': 'XS', '초소형': 'XS',
'소형견': 'S', '소형': 'S',
'중형견': 'M', '중형': 'M',
'대형견': 'L', '대형': 'L',
'초대형견': 'XL', '초대형': 'XL',
}
# 제품명에서 사이즈 감지용 (긴 것부터 먼저 매칭)
PRODUCT_NAME_SIZE_PATTERNS = [
(r'초소형견', 'XS'),
(r'초소형', 'XS'),
(r'소형견', 'S'),
(r'소형', 'S'),
(r'중형견', 'M'),
(r'중형', 'M'),
(r'초대형견', 'XL'),
(r'초대형', 'XL'),
(r'대형견', 'L'),
(r'대형', 'L'),
# 영문/약어
(r'\bSS\b', 'XS'),
(r'\bXS\b', 'XS'),
(r'[-\s]S\b', 'S'),
(r'\bS\(', 'S'),
(r'[-\s]M\b', 'M'),
(r'\bM\(', 'M'),
(r'[-\s]L\b', 'L'),
(r'\bL\(', 'L'),
(r'\bXL\b', 'XL'),
]
# ─────────────────────────────────────────────
# 2. 체중 구간 파싱
# ─────────────────────────────────────────────
def strip_html(html_text):
"""HTML 태그 제거, 줄 단위 텍스트 반환"""
if not html_text:
return ""
t = html_text.replace('<p class="indent0">', '\n').replace('</p>', '')
t = re.sub(r'<[^>]+>', '', t)
return t
def is_livestock_context(text_content):
"""축산/사료 관련인지 판단"""
# 톤당 kg은 사료 관련
if '톤당' in text_content and 'kg' in text_content:
# 체중 구간이 별도로 있는 경우는 반려동물일 수 있음
if '체중' not in text_content and '형견' not in text_content:
return True
return False
def parse_weight_ranges(dosage_instructions):
"""
dosage_instructions에서 (사이즈라벨, 체중min, 체중max) 리스트를 추출.
체중 구간만 추출하며, 성분 용량은 무시.
Returns:
list of dict: [{'min': 0, 'max': 11, 'size': 'S', 'label': '소형견'}, ...]
"""
if not dosage_instructions:
return []
txt = strip_html(dosage_instructions)
# 축산/사료 관련 제외
if is_livestock_context(txt):
return []
ranges = []
seen = set() # (size, min, max) 중복 방지
# ── 전처리: 줄 분리된 사이즈+체중 합치기 ──
# HTML 변환 후 빈 줄이 끼어있을 수 있음:
# "소형견 1chewable 68㎍ 57mg\n\n(체중0-11kg)"
# → "소형견 1chewable 68㎍ 57mg (체중0-11kg)"
lines = txt.split('\n')
# 빈 줄 제거한 리스트 (인덱스 보존)
non_empty = [(i, line.strip()) for i, line in enumerate(lines) if line.strip()]
merged_set = set() # 합쳐진 줄 인덱스 (원본 기준)
merged_lines = []
for idx, (orig_i, stripped) in enumerate(non_empty):
if orig_i in merged_set:
continue
# 현재 줄에 사이즈 라벨이 있고, 다음 비어있지 않은 줄이 (체중...) 패턴이면 합치기
if idx + 1 < len(non_empty):
next_orig_i, next_stripped = non_empty[idx + 1]
if (re.match(r'\(체중', next_stripped)
and re.search(r'(초소형|소형|중형|대형|초대형)견?', stripped)):
merged_lines.append(stripped + ' ' + next_stripped)
merged_set.add(next_orig_i)
continue
merged_lines.append(stripped)
txt = '\n'.join(merged_lines)
def add_range(size, wmin, wmax, label):
"""중복 방지하며 범위 추가"""
if wmax > 60: # 반려동물 체중 범위 초과 → 축산용
return
if wmax <= wmin:
return
key = (size, wmin, wmax)
if key not in seen:
seen.add(key)
ranges.append({'min': wmin, 'max': wmax, 'size': size, 'label': label})
def get_size(label_text):
"""라벨 텍스트 → 사이즈 코드"""
return SIZE_LABELS.get(label_text + '', SIZE_LABELS.get(label_text))
# ── 패턴1: "X형견(체중A-Bkg)" / "X형견 ... (체중A-Bkg)" ──
# 예: "소형견(체중0-11kg)", "중형견(체중12-22kg)"
# 예(줄 합침): "소형견 1chewable 68㎍ 57mg (체중0-11kg)"
for m in re.finditer(
r'(초소형|소형|중형|대형|초대형)견?\s*(?:용\s*)?\(?(?:체중\s*)?'
r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)\s*kg',
txt
):
label = m.group(1)
add_range(get_size(label), float(m.group(2)), float(m.group(3)), label + '')
# ── 패턴1b: 같은 줄에 라벨과 (체중...)이 먼 경우 ──
# 예: "소형견 1chewable 68㎍ 57mg 1개월 (체중0-11kg)"
for m in re.finditer(
r'(초소형|소형|중형|대형|초대형)견?\b[^\n]*?\(체중\s*'
r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)\s*kg\)',
txt
):
label = m.group(1)
add_range(get_size(label), float(m.group(2)), float(m.group(3)), label + '')
# ── 패턴2: "체중A~Bkg X형견용" ──
# 예: "체중1222kg 중형견용(M)"
for m in re.finditer(
r'(?:체중\s*)?(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
r'(초소형|소형|중형|대형|초대형)견?',
txt
):
label = m.group(3)
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '')
# ── 패턴3: "Akg이하 X형견" / "~Akg X형견" ──
# 예: "11kg이하 소형견용"
for m in re.finditer(
r'(?:체중\s*)?[~]?\s*(\d+\.?\d*)\s*kg\s*(?:이하|까지)?\s*(?:의\s*)?'
r'(초소형|소형|중형|대형|초대형)견?',
txt
):
label = m.group(2)
add_range(get_size(label), 0, float(m.group(1)), label + '')
# ── 패턴4: "(Akg~Bkg의 X형견에게)" ──
# 예: "(5.7kg ~11kg의 소형견에게 본제 1정 투여)"
for m in re.finditer(
r'\(\s*(\d+\.?\d*)\s*kg?\s*[-~]\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
r'(초소형|소형|중형|대형|초대형)견',
txt
):
label = m.group(3)
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '')
# ── 패턴4b: "(Akg.Bkg의 X형견에게)" - 마침표 구분자 ──
# 예: "(12kg.22kg의 중형견에게)"
for m in re.finditer(
r'\(\s*(\d+\.?\d*)\s*kg\s*\.\s*(\d+\.?\d*)\s*kg\s*(?:의\s*)?'
r'(초소형|소형|중형|대형|초대형)견',
txt
):
label = m.group(3)
add_range(get_size(label), float(m.group(1)), float(m.group(2)), label + '')
# ── 패턴5: "Akg이하의 X형견에게" ──
# 예: "(5.6kg이하의 초소형견에게)"
for m in re.finditer(
r'(\d+\.?\d*)\s*kg\s*이하\s*(?:의\s*)?(초소형|소형|중형|대형|초대형)견',
txt
):
label = m.group(2)
add_range(get_size(label), 0, float(m.group(1)), label + '')
# ── 패턴6: 테이블 "A~B | 제품명 X형견용" ──
# 예: "2~3.5 넥스가드 스펙트라 츄어블 초소형견용 1"
# 예: "2-5 | 프론트라인 트리액트 초소형견용 | 0.5ml"
for m in re.finditer(
r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)\s*[\s|]*[^\n]*?'
r'(초소형|소형|중형|대형|초대형)견?용?',
txt
):
label = m.group(3)
wmin, wmax = float(m.group(1)), float(m.group(2))
if wmax <= 60:
add_range(get_size(label), wmin, wmax, label + '')
# ── 패턴7: "체중 Akg 미만 X형견용" ──
# 예: "체중 15kg 미만 소, 중형견용"
for m in re.finditer(
r'체중\s*(\d+\.?\d*)\s*kg\s*미만\s*[^\n]*?'
r'(초소형|소형|중형|대형|초대형)견',
txt
):
label = m.group(2)
add_range(get_size(label), 0, float(m.group(1)), label + '')
# ── 패턴8: 라벨 없이 체중 구간만 (반려동물 키워드 있을 때) ──
if not ranges and ('' in txt or '고양이' in txt or '반려' in txt or '애완' in txt):
# 8a: "Xkg 초과-Ykg 이하" / "Xkg 초과 ~ Ykg" (먼저 처리)
for m in re.finditer(
r'(\d+\.?\d*)\s*kg\s*초과\s*[-~]?\s*(\d+\.?\d*)\s*kg(?:\s*(?:이하|까지))?',
txt
):
wmin, wmax = float(m.group(1)), float(m.group(2))
if wmax <= 60 and wmax > wmin:
add_range(None, wmin, wmax, None)
# 8b: "X-Ykg" / "X~Ykg" 일반 범위
for m in re.finditer(
r'(?:체중\s*)?(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)\s*kg',
txt
):
wmin, wmax = float(m.group(1)), float(m.group(2))
if wmax <= 60 and wmax > wmin:
add_range(None, wmin, wmax, None)
# 8c: "Xkg 이하" / "~Xkg" (최소=0)
# 단, "Akg 초과-Xkg 이하"는 8a에서 이미 처리되었으므로 제외
for m in re.finditer(
r'(?:체중\s*)?[~]\s*(\d+\.?\d*)\s*kg|(\d+\.?\d*)\s*kg\s*(?:이하|까지)',
txt
):
val = m.group(1) or m.group(2)
wmax = float(val)
if wmax <= 60:
# "초과-Xkg 이하" 컨텍스트인지 확인 → 이미 8a에서 처리됨
start = max(0, m.start() - 15)
before = txt[start:m.start()]
if '초과' in before:
continue
add_range(None, 0, wmax, None)
# 정렬 (min 기준)
ranges.sort(key=lambda x: x['min'])
return ranges
def is_multi_size_product_name(product_name):
"""
제품명에 여러 사이즈가 함께 들어있는 통합 제품인지 판단.
예: "하트커버(SS,S,M,L)정" → True
"""
if not product_name:
return False
# 여러 사이즈 약어가 한 제품명에 있는 경우
if re.search(r'[(\(].*(?:SS|XS).*[,/].*(?:S|M|L).*[)\)]', product_name):
return True
# 소형/중형/대형 등이 2개 이상 포함된 경우
size_count = sum(1 for kw in ['초소형', '소형', '중형', '대형', '초대형']
if kw in product_name)
if size_count >= 2:
return True
return False
def detect_size_from_product_name(product_name):
"""
제품명에서 사이즈 라벨을 감지.
Returns: 'XS', 'S', 'M', 'L', 'XL' 또는 None
통합 제품(SS,S,M,L 등 여러 사이즈)은 None 반환.
"""
if not product_name:
return None
# 통합 제품 제외
if is_multi_size_product_name(product_name):
return None
for pattern, size in PRODUCT_NAME_SIZE_PATTERNS:
if re.search(pattern, product_name):
return size
return None
# ─────────────────────────────────────────────
# 3. 메인 로직
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='APDB weight_min_kg/weight_max_kg 일괄 채우기')
parser.add_argument('--commit', action='store_true', help='실제 DB 업데이트 수행 (기본: dry-run)')
parser.add_argument('--verbose', '-v', action='store_true', help='상세 로그')
args = parser.parse_args()
dry_run = not args.commit
if dry_run:
print("=" * 60)
print(" DRY-RUN 모드 (DB 변경 없음)")
print(" 실제 업데이트: python scripts/fill_weight_from_dosage.py --commit")
print("=" * 60)
else:
print("=" * 60)
print(" COMMIT 모드 - DB에 실제 업데이트합니다")
print("=" * 60)
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
conn = pg.connect()
# ── 동물용의약품 중 dosage_instructions에 kg 있고 weight 미입력인 APC 전체 조회 ──
apcs = conn.execute(text('''
SELECT apc, item_seq, product_name, dosage, dosage_instructions,
product_type
FROM apc
WHERE product_type = '동물용의약품'
AND dosage_instructions ILIKE '%%kg%%'
AND weight_min_kg IS NULL
ORDER BY item_seq, apc
''')).fetchall()
print(f'\n대상 APC 레코드: {len(apcs)}')
# item_seq별로 그룹핑
from collections import defaultdict
items = defaultdict(list)
di_cache = {}
for row in apcs:
items[row.item_seq].append(row)
if row.item_seq not in di_cache:
di_cache[row.item_seq] = row.dosage_instructions
print(f'대상 item_seq: {len(items)}\n')
stats = {
'total_items': len(items),
'updated': 0,
'matched_by_name': 0,
'matched_by_dosage_order': 0,
'matched_single': 0,
'skipped_no_parse': 0,
'skipped_livestock': 0,
'skipped_multi_no_label': 0,
}
updates = [] # (apc, weight_min, weight_max, product_name, reason)
for item_seq, apc_rows in items.items():
di = di_cache[item_seq]
first_name = apc_rows[0].product_name
# 체중 구간 파싱
weight_ranges = parse_weight_ranges(di)
if not weight_ranges:
stats['skipped_no_parse'] += 1
if args.verbose:
print(f' SKIP (파싱불가): {first_name} ({item_seq})')
continue
# 축산용 필터 (max > 60kg인 구간이 있으면 전체 SKIP)
if any(r['max'] > 60 for r in weight_ranges):
stats['skipped_livestock'] += 1
if args.verbose:
large = [r for r in weight_ranges if r['max'] > 60]
print(f' SKIP (축산용): {first_name} ({item_seq}) max={large[0]["max"]}kg')
continue
if len(weight_ranges) == 1:
# ── 체중 구간 1개 → 전체 APC에 적용 ──
wr = weight_ranges[0]
for row in apc_rows:
updates.append((row.apc, wr['min'], wr['max'], row.product_name, '단일구간'))
stats['matched_single'] += len(apc_rows)
stats['updated'] += len(apc_rows)
if args.verbose:
print(f' 적용 (단일구간): {first_name}{wr["min"]}~{wr["max"]}kg ({len(apc_rows)}건)')
else:
# ── 체중 구간 여러 개 → 제품명의 사이즈 라벨로 매칭 ──
size_to_weight = {}
for wr in weight_ranges:
if wr['size']:
size_to_weight[wr['size']] = (wr['min'], wr['max'])
# 먼저 제품명 라벨로 매칭 시도
unmatched_rows = []
for row in apc_rows:
size = detect_size_from_product_name(row.product_name)
if size and size in size_to_weight:
wmin, wmax = size_to_weight[size]
updates.append((row.apc, wmin, wmax, row.product_name, f'제품명→{size}'))
stats['matched_by_name'] += 1
stats['updated'] += 1
if args.verbose:
print(f' 적용 (제품명 {size}): {row.product_name}{wmin}~{wmax}kg')
else:
unmatched_rows.append(row)
# ── 제품명 매칭 실패한 것들 → dosage 순서 매칭 시도 ──
if unmatched_rows:
# dosage 값이 있는 APC만 추출 (NaN 제외)
rows_with_dosage = [r for r in unmatched_rows
if r.dosage and r.dosage != 'NaN']
rows_no_dosage = [r for r in unmatched_rows
if not r.dosage or r.dosage == 'NaN']
if rows_with_dosage and len(weight_ranges) >= 2:
# dosage에서 첫 번째 숫자 추출하여 정렬 키로 사용
def dosage_sort_key(dosage_str):
nums = re.findall(r'(\d+\.?\d+)', dosage_str)
return float(nums[0]) if nums else 0
# 고유 dosage 값 추출 (순서 유지)
unique_dosages = sorted(
set(r.dosage for r in rows_with_dosage),
key=dosage_sort_key
)
# 체중 구간도 min 기준 정렬 (이미 정렬됨)
sorted_ranges = sorted(weight_ranges, key=lambda x: x['min'])
if len(unique_dosages) == len(sorted_ranges):
# 개수 일치 → 순서 매칭 (작은 용량 = 작은 체중)
dosage_to_weight = {}
for d, wr in zip(unique_dosages, sorted_ranges):
dosage_to_weight[d] = (wr['min'], wr['max'])
for row in rows_with_dosage:
if row.dosage in dosage_to_weight:
wmin, wmax = dosage_to_weight[row.dosage]
updates.append((row.apc, wmin, wmax, row.product_name,
f'dosage순서→{wmin}~{wmax}'))
stats['matched_by_dosage_order'] += 1
stats['updated'] += 1
if args.verbose:
print(f' 적용 (dosage순서): {row.product_name} '
f'dosage={row.dosage}{wmin}~{wmax}kg')
else:
stats['skipped_multi_no_label'] += 1
if args.verbose:
print(f' SKIP (dosage매칭실패): {row.product_name}')
# dosage 없는 APC (대표 품목 등)
for row in rows_no_dosage:
stats['skipped_multi_no_label'] += 1
if args.verbose:
print(f' SKIP (다중구간+dosage없음): {row.product_name}')
if args.verbose and dosage_to_weight:
print(f' dosage 매핑: {dict((d, f"{w[0]}~{w[1]}kg") for d, w in dosage_to_weight.items())}')
else:
# 개수 불일치 → SKIP
for row in unmatched_rows:
stats['skipped_multi_no_label'] += 1
if args.verbose:
print(f' SKIP (dosage수≠구간수): {row.product_name} '
f'(dosage {len(unique_dosages)}종 vs 구간 {len(sorted_ranges)}개)')
else:
# dosage 없는 APC만 남음
for row in unmatched_rows:
stats['skipped_multi_no_label'] += 1
if args.verbose:
print(f' SKIP (다중구간+라벨없음): {row.product_name} '
f'(감지={detect_size_from_product_name(row.product_name)}, '
f'가용={list(size_to_weight.keys())})')
# ── 결과 출력 ──
print('\n' + '=' * 60)
print(' 결과 요약')
print('=' * 60)
print(f' 대상 item_seq: {stats["total_items"]}')
print(f' 업데이트할 APC: {stats["updated"]}')
print(f' - 단일구간 적용: {stats["matched_single"]}')
print(f' - 제품명 라벨 매칭: {stats["matched_by_name"]}')
print(f' - dosage 순서 매칭: {stats["matched_by_dosage_order"]}')
print(f' SKIP - 파싱 불가: {stats["skipped_no_parse"]}')
print(f' SKIP - 축산용 (>60kg): {stats["skipped_livestock"]}')
print(f' SKIP - 다중구간+라벨없음: {stats["skipped_multi_no_label"]}')
if updates:
print(f'\n === 업데이트 미리보기 (처음 30건) ===')
for apc, wmin, wmax, pname, reason in updates[:30]:
print(f' {apc} | {pname[:35]:35s}{wmin}~{wmax}kg [{reason}]')
if len(updates) > 30:
print(f' ... 외 {len(updates) - 30}')
# ── DB 업데이트 ──
if not dry_run and updates:
print(f'\n DB 업데이트 시작...')
conn.close()
with pg.begin() as tx_conn:
for apc_code, wmin, wmax, _, _ in updates:
tx_conn.execute(text('''
UPDATE apc
SET weight_min_kg = :wmin, weight_max_kg = :wmax
WHERE apc = :apc
'''), {'wmin': wmin, 'wmax': wmax, 'apc': apc_code})
print(f' 완료: {len(updates)}건 업데이트')
elif not dry_run and not updates:
print('\n 업데이트할 항목이 없습니다.')
conn.close()
else:
conn.close()
print('\n완료.')
if __name__ == '__main__':
main()

View File

@@ -26,6 +26,11 @@ ANIMAL_KEYWORDS = [
'펫팜', '동물약품', '애니팜'
]
# 동물약 공급처 (SplName이 이 값이면 전부 동물약)
ANIMAL_SUPPLIERS = [
'펫팜'
]
# 제외 키워드 (사람용 약)
EXCLUDE_KEYWORDS = [
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
@@ -58,24 +63,38 @@ def init_sqlite_db():
print(f"✅ SQLite DB 준비: {DB_PATH}")
def search_animal_drugs():
"""MSSQL에서 동물약 키워드 검색"""
"""MSSQL에서 동물약 검색 (키워드 + 공급처)"""
print("🔍 CD_GOODS에서 동물약 검색 중...")
session = db_manager.get_session('PM_DRUG')
# 키워드 조건 생성
conditions = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
# 키워드 조건
keyword_conds = ' OR '.join([f"GoodsName LIKE '%{kw}%'" for kw in ANIMAL_KEYWORDS])
# 공급처 조건
supplier_conds = ' OR '.join([f"SplName = '{sp}'" for sp in ANIMAL_SUPPLIERS])
query = text(f"""
SELECT DrugCode, GoodsName, BARCODE, POS_BOON
SELECT DrugCode, GoodsName, BARCODE, POS_BOON, SplName
FROM CD_GOODS
WHERE ({conditions})
WHERE (({keyword_conds}) OR ({supplier_conds}))
AND GoodsSelCode = 'B'
""")
result = session.execute(query)
drugs = result.fetchall()
print(f"✅ 발견: {len(drugs)}")
# 키워드 vs 공급처 통계
by_keyword = [d for d in drugs if any(kw in (d.GoodsName or '') for kw in ANIMAL_KEYWORDS)]
by_supplier = [d for d in drugs if d.SplName in ANIMAL_SUPPLIERS]
supplier_only = [d for d in by_supplier if not any(kw in (d.GoodsName or '') for kw in ANIMAL_KEYWORDS)]
print(f"✅ 발견: {len(drugs)}개 (키워드: {len(by_keyword)}, 공급처 추가: {len(supplier_only)})")
if supplier_only:
print(" 📦 공급처 기반 신규:")
for d in supplier_only:
print(f" {d.DrugCode}: {d.GoodsName} ({d.SplName})")
return drugs
def tag_to_sqlite(drugs):
@@ -93,20 +112,27 @@ def tag_to_sqlite(drugs):
drug_code = drug[0]
drug_name = drug[1] or ''
barcode = drug[2]
spl_name = drug[4] if len(drug) > 4 else ''
# 제외 키워드 체크
if any(ex in drug_name for ex in EXCLUDE_KEYWORDS):
excluded += 1
print(f" ⛔ 제외: {drug_code} - {drug_name}")
continue
# 매칭 소스 구분
by_kw = any(kw in drug_name for kw in ANIMAL_KEYWORDS)
by_sp = spl_name in ANIMAL_SUPPLIERS
source = 'keyword' if by_kw else 'supplier'
note = '키워드 자동 태깅' if by_kw else f'공급처({spl_name}) 자동 태깅'
try:
cursor.execute('''
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note)
VALUES (?, ?, ?, 'animal_drug', 'all', '키워드 자동 태깅')
''', (drug_code, drug_name, barcode))
INSERT INTO drug_tags (drug_code, drug_name, barcode, tag_type, tag_value, note, source)
VALUES (?, ?, ?, 'animal_drug', 'all', ?, ?)
''', (drug_code, drug_name, barcode, note, source))
added += 1
print(f"{drug_code}: {drug_name}")
print(f"{drug_code}: {drug_name} [{source}]")
except sqlite3.IntegrityError:
skipped += 1

View File

@@ -25,23 +25,29 @@ import websockets
logger = logging.getLogger(__name__)
# Gateway 설정 (clawdbot.json에서 읽기)
# Gateway 설정 (openclaw.json 또는 clawdbot.json에서 읽기)
OPENCLAW_CONFIG_PATH = Path.home() / '.openclaw' / 'openclaw.json'
CLAWDBOT_CONFIG_PATH = Path.home() / '.clawdbot' / 'clawdbot.json'
def _load_gateway_config():
"""clawdbot.json에서 Gateway 설정 로드"""
try:
with open(CLAWDBOT_CONFIG_PATH, 'r', encoding='utf-8') as f:
config = json.load(f)
gw = config.get('gateway', {})
return {
'port': gw.get('port', 18789),
'token': gw.get('auth', {}).get('token', ''),
}
except Exception as e:
logger.warning(f"[Clawdbot] 설정 파일 로드 실패: {e}")
return {'port': 18789, 'token': ''}
"""OpenClaw/Clawdbot Gateway 설정 로드"""
for config_path in [OPENCLAW_CONFIG_PATH, CLAWDBOT_CONFIG_PATH]:
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
gw = config.get('gateway', {})
token = gw.get('auth', {}).get('token', '')
if token:
logger.info(f"[Gateway] 설정 로드: {config_path.name}")
return {
'port': gw.get('port', 18789),
'token': token,
}
except Exception:
continue
logger.warning("[Gateway] 설정 파일 로드 실패")
return {'port': 18789, 'token': ''}
async def _ask_gateway(message, session_id='pharmacy-upsell',
@@ -85,10 +91,10 @@ async def _ask_gateway(message, session_id='pharmacy-upsell',
'maxProtocol': 3,
'client': {
'id': 'gateway-client',
'displayName': 'Pharmacy Upsell',
'displayName': 'Pharmacy PAAI',
'version': '1.0.0',
'platform': 'win32',
'mode': 'backend',
'mode': 'cli',
'instanceId': str(uuid.uuid4()),
},
'caps': [],
@@ -96,7 +102,7 @@ async def _ask_gateway(message, session_id='pharmacy-upsell',
'token': token,
},
'role': 'operator',
'scopes': ['operator.admin'],
'scopes': ['operator.admin', 'operator.write', 'operator.read'],
}
}
await ws.send(json.dumps(connect_frame))
@@ -198,27 +204,65 @@ async def _ask_gateway(message, session_id='pharmacy-upsell',
def ask_clawdbot(message, session_id='pharmacy-upsell',
system_prompt=None, timeout=60, model=None):
"""
동기 래퍼: Flask에서 직접 호출 가능
OpenClaw CLI를 통한 AI 호출 (WebSocket 대신)
Args:
message: 사용자 메시지
session_id: 세션 ID (대화 구분용)
system_prompt: 추가 시스템 프롬프트
system_prompt: 추가 시스템 프롬프트 (현재 미사용)
timeout: 타임아웃 (초)
model: 모델 오버라이드 (예: 'anthropic/claude-sonnet-4-5')
model: 모델 오버라이드 (현재 미사용 - CLI가 기본 모델 사용)
Returns:
str: AI 응답 텍스트 (실패 시 None)
"""
import subprocess
import os
from pathlib import Path
try:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(
_ask_gateway(message, session_id, system_prompt, timeout, model=model)
# Node.js로 OpenClaw 직접 호출 (shell 없이, 특수문자 안전)
node_path = r'C:\Program Files\nodejs\node.exe'
openclaw_path = str(Path.home() / 'AppData/Roaming/npm/node_modules/openclaw/openclaw.mjs')
cmd = [node_path, openclaw_path, 'agent', '-m', message, '--session-id', session_id, '--json']
logger.info(f"[OpenClaw] session={session_id}, msg_len={len(message)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout + 30,
encoding='utf-8',
env={**os.environ, 'PYTHONIOENCODING': 'utf-8'}
)
loop.close()
return result
if result.returncode != 0:
logger.warning(f"[OpenClaw] CLI 에러: {result.stderr}")
# 에러가 있어도 stdout에 결과가 있을 수 있음
if not result.stdout:
return None
# JSON 파싱
data = json.loads(result.stdout)
if data.get('status') == 'ok':
payloads = data.get('result', {}).get('payloads', [])
if payloads:
text = payloads[0].get('text', '')
logger.info(f"[OpenClaw] 응답 수신: {len(text)}")
return text
logger.warning(f"[OpenClaw] 응답 없음: {data}")
return None
except subprocess.TimeoutExpired:
logger.warning(f"[OpenClaw] 타임아웃 ({timeout}초)")
return None
except json.JSONDecodeError as e:
logger.warning(f"[OpenClaw] JSON 파싱 실패: {e}")
return None
except Exception as e:
logger.warning(f"[Clawdbot] 호출 실패: {e}")
logger.warning(f"[OpenClaw] 호출 실패: {e}")
return None
@@ -356,13 +400,13 @@ async def _get_gateway_status():
'displayName': 'Pharmacy Status',
'version': '1.0.0',
'platform': 'win32',
'mode': 'backend',
'mode': 'cli',
'instanceId': str(uuid.uuid4()),
},
'caps': [],
'auth': {'token': token},
'role': 'operator',
'scopes': ['operator.read'],
'scopes': ['operator.admin', 'operator.write', 'operator.read'],
}
}
await ws.send(json.dumps(connect_frame))

View File

@@ -437,3 +437,270 @@ def api_sooin_order_batch():
'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:
"""
규격에서 박스당 단위 수 추출
정량 단위 (T, 정, 캡슐, C, PTP, 포 등): 숫자 추출
용량 단위 (g, ml, mL, mg, L 등): 1 반환 (튜브/병 단위)
예시:
- '30T' → 30 (정제 30정)
- '100정(PTP)' → 100
- '15g' → 1 (튜브 1개)
- '10ml' → 1 (병 1개)
- '500mg' → 1 (용량 표시)
"""
if not spec:
return 1
spec_lower = spec.lower()
# 용량 단위 패턴: 숫자 + g/ml/mg/l (단독 또는 끝)
# 이 경우 튜브/병 단위이므로 1 반환
volume_pattern = r'^\d+\s*(g|ml|mg|l)(\s|$|\)|/)'
if re.search(volume_pattern, spec_lower):
return 1
# 정량 단위 패턴: 숫자 + T/정/캡슐/C/PTP/포
qty_pattern = r'(\d+)\s*(t|정|캡슐?|c|ptp|포|tab|cap)'
qty_match = re.search(qty_pattern, spec_lower)
if qty_match:
return int(qty_match.group(1))
# 기본: 숫자만 있으면 추출하되, 용량 단위 재확인
# 끝에 g/ml이 있으면 1 반환
if re.search(r'\d+(g|ml)$', spec_lower):
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 one or more lines are too long

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -1354,13 +1354,16 @@
html += `
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; overflow: hidden;">
<!-- 아코디언 헤더 -->
<div onclick="toggleAccordion('${accordionId}')" style="padding: 16px; background: #fff; cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
<div onclick="toggleAccordion('${accordionId}')" style="padding: 16px; background: #fff; cursor: pointer; display: flex; justify-content: space-between; align-items: center; position: relative;">
<div style="flex: 1;">
<div style="font-size: 15px; font-weight: 600; color: #212529; margin-bottom: 6px;">
${purchase.items_summary}
${purchase.source === 'pos'
? '<span style="position: relative; top: -2px; margin-left: 8px; padding: 2px 6px; background: linear-gradient(135deg, #94a3b8, #64748b); color: white; border-radius: 4px; font-size: 10px; font-weight: 600;">POS</span>'
: '<span style="position: relative; top: -2px; margin-left: 8px; padding: 2px 6px; background: linear-gradient(135deg, #22c55e, #16a34a); color: white; border-radius: 4px; font-size: 10px; font-weight: 600;">QR</span>'}
</div>
<div style="font-size: 13px; color: #868e96;">
${purchase.date} | ${purchase.amount.toLocaleString()}원 구매 | ${purchase.points.toLocaleString()}P 적립
${purchase.date} | ${purchase.amount.toLocaleString()}원 구매 | ${purchase.points > 0 ? purchase.points.toLocaleString() + 'P 적립' : '적립 안됨'}
</div>
</div>
<div id="${accordionId}-icon" style="width: 24px; height: 24px; color: #868e96; transition: transform 0.3s;">

View File

@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>동물약 챗봇 로그 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&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, #10b981 0%, #059669 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: 1400px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
margin-bottom: 28px;
}
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
.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: 26px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.green { color: #10b981; }
.stat-value.blue { color: #3b82f6; }
.stat-value.orange { color: #f59e0b; }
.stat-value.red { color: #ef4444; }
.stat-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
/* 필터 */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
}
.filter-input {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
}
.filter-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.filter-btn.primary {
background: #10b981;
color: #fff;
}
.filter-btn.primary:hover { background: #059669; }
.filter-btn.secondary {
background: #f1f5f9;
color: #64748b;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #64748b;
}
/* 테이블 */
.table-container {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
background: #f8fafc;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
}
tr:hover { background: #f8fafc; }
tr.error { background: #fef2f2; }
.time-cell {
font-size: 13px;
color: #64748b;
white-space: nowrap;
}
.msg-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.token-cell {
font-size: 13px;
font-weight: 500;
color: #3b82f6;
}
.cost-cell {
font-size: 13px;
font-weight: 600;
color: #f59e0b;
}
.duration-cell {
font-size: 13px;
color: #64748b;
}
.error-badge {
display: inline-block;
padding: 2px 8px;
background: #fecaca;
color: #dc2626;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
/* 모달 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.show { display: flex; }
.modal-content {
background: #fff;
border-radius: 16px;
max-width: 800px;
width: 95%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 18px;
font-weight: 700;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #94a3b8;
}
.modal-body {
padding: 24px;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section h3 {
font-size: 14px;
font-weight: 600;
color: #64748b;
margin-bottom: 10px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.detail-item {
background: #f8fafc;
padding: 12px;
border-radius: 8px;
}
.detail-label {
font-size: 11px;
color: #94a3b8;
margin-bottom: 4px;
}
.detail-value {
font-size: 14px;
font-weight: 600;
}
.message-box {
background: #f8fafc;
padding: 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.message-box.user {
background: #ede9fe;
color: #5b21b6;
}
.message-box.assistant {
background: #ecfdf5;
color: #065f46;
}
/* 로딩 */
.loading {
text-align: center;
padding: 60px;
color: #94a3b8;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/products">제품 관리</a>
</div>
<h1>🐾 동물약 챗봇 로그</h1>
<p>RAG 기반 동물약 상담 기록 · 토큰 사용량 · 비용 분석</p>
</div>
<div class="content">
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-label">총 대화</div>
<div class="stat-value green" id="statTotal">-</div>
</div>
<div class="stat-card">
<div class="stat-label">평균 응답시간</div>
<div class="stat-value blue" id="statDuration">-</div>
<div class="stat-sub">ms</div>
</div>
<div class="stat-card">
<div class="stat-label">총 토큰</div>
<div class="stat-value" id="statTokens">-</div>
</div>
<div class="stat-card">
<div class="stat-label">총 비용</div>
<div class="stat-value orange" id="statCost">-</div>
<div class="stat-sub">USD</div>
</div>
<div class="stat-card">
<div class="stat-label">평균 벡터 검색</div>
<div class="stat-value blue" id="statVector">-</div>
<div class="stat-sub">ms</div>
</div>
<div class="stat-card">
<div class="stat-label">에러</div>
<div class="stat-value red" id="statErrors">-</div>
</div>
</div>
<!-- 필터 -->
<div class="filter-bar">
<input type="date" class="filter-input" id="dateFrom" />
<span style="color:#94a3b8;">~</span>
<input type="date" class="filter-input" id="dateTo" />
<label class="filter-checkbox">
<input type="checkbox" id="errorOnly" />
에러만 보기
</label>
<button class="filter-btn primary" onclick="loadLogs()">검색</button>
<button class="filter-btn secondary" onclick="resetFilters()">초기화</button>
</div>
<!-- 테이블 -->
<div class="table-container">
<table>
<thead>
<tr>
<th>시간</th>
<th>질문</th>
<th>응답</th>
<th>토큰</th>
<th>비용</th>
<th>소요시간</th>
<th>상태</th>
</tr>
</thead>
<tbody id="logsTable">
<tr>
<td colspan="7" class="loading">
<div class="spinner"></div>
로딩 중...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal" id="detailModal" onclick="if(event.target===this)closeModal()">
<div class="modal-content">
<div class="modal-header">
<h2>🔍 대화 상세</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modalBody">
<!-- 동적 내용 -->
</div>
</div>
</div>
<script>
// 초기 로드
document.addEventListener('DOMContentLoaded', () => {
// 기본 날짜: 오늘
const today = new Date().toISOString().split('T')[0];
document.getElementById('dateTo').value = today;
loadLogs();
});
async function loadLogs() {
const dateFrom = document.getElementById('dateFrom').value;
const dateTo = document.getElementById('dateTo').value;
const errorOnly = document.getElementById('errorOnly').checked;
const params = new URLSearchParams();
if (dateFrom) params.append('date_from', dateFrom);
if (dateTo) params.append('date_to', dateTo);
if (errorOnly) params.append('error_only', 'true');
params.append('limit', '200');
try {
const res = await fetch(`/api/animal-chat-logs?${params}`);
const data = await res.json();
if (data.success) {
renderStats(data.stats);
renderLogs(data.logs);
}
} catch (err) {
console.error('로그 조회 실패:', err);
}
}
function renderStats(stats) {
document.getElementById('statTotal').textContent = (stats.total_chats || 0).toLocaleString();
document.getElementById('statDuration').textContent = (stats.avg_duration_ms || 0).toLocaleString();
document.getElementById('statTokens').textContent = (stats.total_tokens || 0).toLocaleString();
document.getElementById('statCost').textContent = '$' + (stats.total_cost_usd || 0).toFixed(4);
document.getElementById('statVector').textContent = (stats.avg_vector_ms || 0).toLocaleString();
document.getElementById('statErrors').textContent = stats.error_count || 0;
}
function renderLogs(logs) {
const tbody = document.getElementById('logsTable');
if (!logs || logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:#94a3b8;">로그가 없습니다</td></tr>';
return;
}
tbody.innerHTML = logs.map(log => `
<tr class="${log.error ? 'error' : ''}" onclick='showDetail(${JSON.stringify(log).replace(/'/g, "&#39;")})' style="cursor:pointer;">
<td class="time-cell">${formatTime(log.created_at)}</td>
<td class="msg-cell" title="${escapeHtml(log.user_message || '')}">${escapeHtml(truncate(log.user_message, 40))}</td>
<td class="msg-cell" title="${escapeHtml(log.assistant_response || '')}">${escapeHtml(truncate(log.assistant_response, 50))}</td>
<td class="token-cell">${log.openai_total_tokens || 0}</td>
<td class="cost-cell">$${(log.openai_cost_usd || 0).toFixed(4)}</td>
<td class="duration-cell">${log.total_duration_ms || 0}ms</td>
<td>${log.error ? '<span class="error-badge">에러</span>' : '✅'}</td>
</tr>
`).join('');
}
function showDetail(log) {
const vectorScores = JSON.parse(log.vector_top_scores || '[]');
const vectorSources = JSON.parse(log.vector_sources || '[]');
const products = JSON.parse(log.products_mentioned || '[]');
document.getElementById('modalBody').innerHTML = `
<div class="detail-section">
<h3>📊 처리 시간</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">MSSQL (보유 동물약)</div>
<div class="detail-value">${log.mssql_duration_ms || 0}ms (${log.mssql_drug_count || 0}개)</div>
</div>
<div class="detail-item">
<div class="detail-label">PostgreSQL (RAG)</div>
<div class="detail-value">${log.pgsql_duration_ms || 0}ms (${log.pgsql_rag_count || 0}개)</div>
</div>
<div class="detail-item">
<div class="detail-label">벡터 검색 (LanceDB)</div>
<div class="detail-value">${log.vector_duration_ms || 0}ms (${log.vector_results_count || 0}개)</div>
</div>
<div class="detail-item">
<div class="detail-label">OpenAI API</div>
<div class="detail-value">${log.openai_duration_ms || 0}ms</div>
</div>
<div class="detail-item">
<div class="detail-label">총 소요시간</div>
<div class="detail-value" style="color:#10b981;">${log.total_duration_ms || 0}ms</div>
</div>
<div class="detail-item">
<div class="detail-label">모델</div>
<div class="detail-value">${log.openai_model || '-'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>🎯 토큰 & 비용</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">입력 토큰</div>
<div class="detail-value">${log.openai_prompt_tokens || 0}</div>
</div>
<div class="detail-item">
<div class="detail-label">출력 토큰</div>
<div class="detail-value">${log.openai_completion_tokens || 0}</div>
</div>
<div class="detail-item">
<div class="detail-label">비용</div>
<div class="detail-value" style="color:#f59e0b;">$${(log.openai_cost_usd || 0).toFixed(6)}</div>
</div>
</div>
</div>
${vectorSources.length > 0 ? `
<div class="detail-section">
<h3>📚 벡터 검색 결과</h3>
<div style="font-size:13px;">
${vectorSources.map((src, i) => `
<div style="padding:8px 12px;background:#f0fdf4;border-radius:6px;margin-bottom:6px;">
<strong>[${(vectorScores[i] * 100 || 0).toFixed(0)}%]</strong> ${src}
</div>
`).join('')}
</div>
</div>
` : ''}
<div class="detail-section">
<h3>💬 사용자 질문</h3>
<div class="message-box user">${escapeHtml(log.user_message || '')}</div>
</div>
<div class="detail-section">
<h3>🤖 AI 응답</h3>
<div class="message-box assistant">${escapeHtml(log.assistant_response || '')}</div>
</div>
${products.length > 0 ? `
<div class="detail-section">
<h3>📦 언급된 제품</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
${products.map(p => `<span style="background:#10b981;color:#fff;padding:4px 12px;border-radius:20px;font-size:13px;">${p}</span>`).join('')}
</div>
</div>
` : ''}
${log.error ? `
<div class="detail-section">
<h3>⚠️ 에러</h3>
<div class="message-box" style="background:#fef2f2;color:#dc2626;">${escapeHtml(log.error)}</div>
</div>
` : ''}
`;
document.getElementById('detailModal').classList.add('show');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('show');
}
function resetFilters() {
document.getElementById('dateFrom').value = '';
document.getElementById('dateTo').value = new Date().toISOString().split('T')[0];
document.getElementById('errorOnly').checked = false;
loadLogs();
}
// 유틸
function formatTime(dt) {
if (!dt) return '-';
return dt.replace('T', ' ').substring(5, 16);
}
function truncate(str, len) {
if (!str) return '';
return str.length > len ? str.substring(0, len) + '...' : str;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ESC로 모달 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,980 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>건조시럽 환산계수 관리 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--border: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-teal: #14b8a6;
--accent-blue: #3b82f6;
--accent-purple: #a855f7;
--accent-amber: #f59e0b;
--accent-emerald: #10b981;
--accent-rose: #f43f5e;
--accent-orange: #f97316;
--accent-cyan: #06b6d4;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #a855f7 0%, #8b5cf6 50%, #7c3aed 100%);
padding: 20px 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.header-inner {
max-width: 1600px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.header-left p {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.header-nav {
display: flex;
gap: 8px;
}
.header-nav a {
color: rgba(255,255,255,0.85);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
background: rgba(255,255,255,0.1);
transition: all 0.2s;
}
.header-nav a:hover {
background: rgba(255,255,255,0.2);
color: #fff;
}
/* ══════════════════ 컨텐츠 ══════════════════ */
.content {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
}
/* ══════════════════ 검색 & 액션 바 ══════════════════ */
.action-bar {
background: var(--bg-card);
border-radius: 16px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.search-group {
display: flex;
gap: 12px;
align-items: center;
flex: 1;
max-width: 500px;
}
.search-group input {
flex: 1;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
color: var(--text-primary);
transition: all 0.2s;
}
.search-group input:focus {
outline: none;
border-color: var(--accent-purple);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
}
.search-group input::placeholder { color: var(--text-muted); }
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
color: #fff;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(168, 85, 247, 0.4);
}
.btn-success {
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-teal));
color: #fff;
}
.btn-success:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.btn-danger {
background: linear-gradient(135deg, var(--accent-rose), #dc2626);
color: #fff;
}
.btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 63, 94, 0.4);
}
.btn-secondary {
background: var(--bg-card-hover);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--border);
}
/* ══════════════════ 통계 ══════════════════ */
.stats-row {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-card);
border-radius: 14px;
padding: 20px;
border: 1px solid var(--border);
flex: 1;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.purple::before { background: var(--accent-purple); }
.stat-card.cyan::before { background: var(--accent-cyan); }
.stat-card.amber::before { background: var(--accent-amber); }
.stat-value {
font-size: 28px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-card.purple .stat-value { color: var(--accent-purple); }
.stat-card.cyan .stat-value { color: var(--accent-cyan); }
.stat-card.amber .stat-value { color: var(--accent-amber); }
.stat-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ══════════════════ 테이블 ══════════════════ */
.table-container {
background: var(--bg-card);
border-radius: 16px;
border: 1px solid var(--border);
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.badge {
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
color: #fff;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 14px 16px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
td {
padding: 14px 16px;
font-size: 13px;
border-bottom: 1px solid rgba(255,255,255,0.03);
vertical-align: middle;
}
tr:hover td {
background: rgba(168, 85, 247, 0.05);
}
.code-cell {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
}
.factor-cell {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: var(--accent-amber);
}
.storage-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
}
.storage-badge.cold {
background: rgba(59, 130, 246, 0.2);
color: var(--accent-blue);
}
.storage-badge.room {
background: rgba(16, 185, 129, 0.2);
color: var(--accent-emerald);
}
.action-btns {
display: flex;
gap: 8px;
}
.action-btns button {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-edit {
background: rgba(59, 130, 246, 0.2);
color: var(--accent-blue);
}
.btn-edit:hover {
background: rgba(59, 130, 246, 0.3);
}
.btn-delete {
background: rgba(244, 63, 94, 0.2);
color: var(--accent-rose);
}
.btn-delete:hover {
background: rgba(244, 63, 94, 0.3);
}
/* ══════════════════ 빈 상태 ══════════════════ */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-state h3 {
font-size: 16px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.empty-state p {
font-size: 13px;
}
/* ══════════════════ 에러 메시지 ══════════════════ */
.error-banner {
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.3);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 12px;
color: var(--accent-rose);
}
.error-banner.hidden { display: none; }
.error-banner .icon { font-size: 20px; }
/* ══════════════════ 모달 ══════════════════ */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--bg-secondary);
border-radius: 20px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.9) translateY(20px);
transition: all 0.3s;
}
.modal-overlay.active .modal {
transform: scale(1) translateY(0);
}
.modal-header {
padding: 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
line-height: 1;
}
.modal-close:hover { color: var(--text-primary); }
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
color: var(--text-primary);
transition: all 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-purple);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
}
.form-group input:disabled {
background: var(--bg-card-hover);
color: var(--text-muted);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.modal-footer {
padding: 20px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ══════════════════ 토스트 ══════════════════ */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 2000;
}
.toast {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
margin-top: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease;
}
.toast.success { border-left: 4px solid var(--accent-emerald); }
.toast.error { border-left: 4px solid var(--accent-rose); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ══════════════════ 로딩 ══════════════════ */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent-purple);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ══════════════════ 반응형 ══════════════════ */
@media (max-width: 768px) {
.action-bar { flex-direction: column; }
.search-group { max-width: 100%; width: 100%; }
.stats-row { flex-direction: column; }
.form-row { grid-template-columns: 1fr; }
th, td { padding: 10px 12px; }
.header-nav { display: none; }
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header">
<div class="header-inner">
<div class="header-left">
<h1>💧 건조시럽 환산계수 관리</h1>
<p>건조시럽 mL → g 환산계수 데이터 관리</p>
</div>
<nav class="header-nav">
<a href="/admin">관리자 홈</a>
<a href="/pmr">PMR</a>
</nav>
</div>
</header>
<!-- 컨텐츠 -->
<div class="content">
<!-- 에러 배너 -->
<div id="errorBanner" class="error-banner hidden">
<span class="icon">⚠️</span>
<span id="errorMessage">PostgreSQL 연결에 실패했습니다.</span>
</div>
<!-- 액션 바 -->
<div class="action-bar">
<div class="search-group">
<input type="text" id="searchInput" placeholder="성분명, 제품명, 성분코드로 검색..." autocomplete="off">
<button class="btn btn-primary" onclick="loadData()">🔍 검색</button>
</div>
<button class="btn btn-success" onclick="openCreateModal()"> 신규 등록</button>
</div>
<!-- 통계 -->
<div class="stats-row">
<div class="stat-card purple">
<div class="stat-value" id="statTotal">-</div>
<div class="stat-label">전체 등록</div>
</div>
<div class="stat-card cyan">
<div class="stat-value" id="statCold">-</div>
<div class="stat-label">냉장보관</div>
</div>
<div class="stat-card amber">
<div class="stat-value" id="statRoom">-</div>
<div class="stat-label">실온보관</div>
</div>
</div>
<!-- 테이블 -->
<div class="table-container">
<div class="table-header">
<div class="table-title">
<span>환산계수 목록</span>
<span class="badge" id="countBadge">0건</span>
</div>
</div>
<div id="tableWrapper">
<table>
<thead>
<tr>
<th style="width: 120px;">성분코드</th>
<th>성분명</th>
<th>제품명</th>
<th style="width: 100px;">환산계수</th>
<th style="width: 100px;">보관조건</th>
<th style="width: 100px;">유효기간</th>
<th style="width: 130px;">관리</th>
</tr>
</thead>
<tbody id="dataBody">
<tr>
<td colspan="7">
<div class="empty-state">
<div class="loading-spinner"></div>
<p style="margin-top: 12px;">데이터 로딩 중...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 등록/수정 모달 -->
<div class="modal-overlay" id="editModal">
<div class="modal">
<div class="modal-header">
<h2 id="modalTitle">건조시럽 등록</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="editMode" value="create">
<div class="form-group">
<label>성분코드 (SUNG_CODE) *</label>
<input type="text" id="formSungCode" placeholder="예: 535000ASY">
</div>
<div class="form-group">
<label>성분명</label>
<input type="text" id="formIngredientName" placeholder="예: 아목시실린수화물·클라불란산칼륨">
</div>
<div class="form-group">
<label>제품명</label>
<input type="text" id="formProductName" placeholder="예: 일성오구멘틴듀오시럽">
</div>
<div class="form-row">
<div class="form-group">
<label>환산계수 (g/mL)</label>
<input type="number" id="formConversionFactor" step="0.001" placeholder="예: 0.11">
</div>
<div class="form-group">
<label>조제 후 유효기간</label>
<input type="text" id="formExpiration" placeholder="예: 7일">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>조제 후 함량</label>
<input type="text" id="formPostPrepAmount" placeholder="예: 228mg/5ml">
</div>
<div class="form-group">
<label>분말 중 주성분량</label>
<input type="text" id="formMainIngredientAmt" placeholder="예: 200mg/g">
</div>
</div>
<div class="form-group">
<label>보관조건</label>
<select id="formStorageConditions">
<option value="실온">실온</option>
<option value="냉장">냉장</option>
<option value="냉동">냉동</option>
<option value="차광">차광</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">취소</button>
<button class="btn btn-success" onclick="saveData()">💾 저장</button>
</div>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div class="modal-overlay" id="deleteModal">
<div class="modal" style="max-width: 400px;">
<div class="modal-header">
<h2>삭제 확인</h2>
<button class="modal-close" onclick="closeDeleteModal()">&times;</button>
</div>
<div class="modal-body" style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 16px;">🗑️</div>
<p style="color: var(--text-secondary); margin-bottom: 8px;">
<strong id="deleteTarget" style="color: var(--accent-rose);"></strong>
</p>
<p style="color: var(--text-muted); font-size: 13px;">
이 항목을 정말 삭제하시겠습니까?<br>삭제 후 복구할 수 없습니다.
</p>
</div>
<div class="modal-footer" style="justify-content: center;">
<button class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
<button class="btn btn-danger" onclick="confirmDelete()">🗑️ 삭제</button>
</div>
</div>
</div>
<!-- 토스트 컨테이너 -->
<div class="toast-container" id="toastContainer"></div>
<script>
// 전역 변수
let allData = [];
let deleteSungCode = null;
// 페이지 로드 시
document.addEventListener('DOMContentLoaded', () => {
loadData();
// 엔터키로 검색
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadData();
});
});
// 데이터 로드
async function loadData() {
const searchQuery = document.getElementById('searchInput').value.trim();
const url = searchQuery
? `/api/drug-info/drysyrup?q=${encodeURIComponent(searchQuery)}`
: '/api/drug-info/drysyrup';
try {
const response = await fetch(url);
const result = await response.json();
if (!result.success) {
showError(result.error || 'PostgreSQL 연결에 실패했습니다.');
renderEmptyState('데이터베이스 연결 오류');
return;
}
hideError();
allData = result.data || [];
renderTable(allData);
updateStats(allData);
} catch (error) {
console.error('데이터 로드 오류:', error);
showError('서버 연결에 실패했습니다.');
renderEmptyState('서버 연결 오류');
}
}
// 테이블 렌더링
function renderTable(data) {
const tbody = document.getElementById('dataBody');
document.getElementById('countBadge').textContent = `${data.length}`;
if (data.length === 0) {
renderEmptyState('등록된 환산계수가 없습니다');
return;
}
tbody.innerHTML = data.map(item => `
<tr>
<td class="code-cell">${escapeHtml(item.sung_code || '')}</td>
<td>${escapeHtml(item.ingredient_name || '-')}</td>
<td>${escapeHtml(item.product_name || '-')}</td>
<td class="factor-cell">${item.conversion_factor !== null ? item.conversion_factor.toFixed(3) : '-'}</td>
<td>
<span class="storage-badge ${getStorageClass(item.storage_conditions)}">
${escapeHtml(item.storage_conditions || '실온')}
</span>
</td>
<td>${escapeHtml(item.expiration_date || '-')}</td>
<td>
<div class="action-btns">
<button class="btn-edit" onclick="openEditModal('${escapeHtml(item.sung_code)}')">✏️ 수정</button>
<button class="btn-delete" onclick="openDeleteModal('${escapeHtml(item.sung_code)}')">🗑️</button>
</div>
</td>
</tr>
`).join('');
}
// 빈 상태 렌더링
function renderEmptyState(message) {
document.getElementById('dataBody').innerHTML = `
<tr>
<td colspan="7">
<div class="empty-state">
<div class="icon">📭</div>
<h3>${escapeHtml(message)}</h3>
<p>신규 등록 버튼을 눌러 환산계수를 추가하세요.</p>
</div>
</td>
</tr>
`;
document.getElementById('countBadge').textContent = '0건';
}
// 통계 업데이트
function updateStats(data) {
const total = data.length;
const cold = data.filter(d => (d.storage_conditions || '').includes('냉')).length;
const room = total - cold;
document.getElementById('statTotal').textContent = total.toLocaleString();
document.getElementById('statCold').textContent = cold.toLocaleString();
document.getElementById('statRoom').textContent = room.toLocaleString();
}
// 보관조건 클래스
function getStorageClass(storage) {
if (!storage) return 'room';
return storage.includes('냉') ? 'cold' : 'room';
}
// 신규 등록 모달 열기
function openCreateModal() {
document.getElementById('modalTitle').textContent = '건조시럽 신규 등록';
document.getElementById('editMode').value = 'create';
document.getElementById('formSungCode').value = '';
document.getElementById('formSungCode').disabled = false;
document.getElementById('formIngredientName').value = '';
document.getElementById('formProductName').value = '';
document.getElementById('formConversionFactor').value = '';
document.getElementById('formExpiration').value = '';
document.getElementById('formPostPrepAmount').value = '';
document.getElementById('formMainIngredientAmt').value = '';
document.getElementById('formStorageConditions').value = '실온';
document.getElementById('editModal').classList.add('active');
}
// 수정 모달 열기
async function openEditModal(sungCode) {
try {
const response = await fetch(`/api/drug-info/drysyrup/${encodeURIComponent(sungCode)}`);
const result = await response.json();
if (!result.success || !result.exists) {
showToast('데이터를 불러올 수 없습니다.', 'error');
return;
}
document.getElementById('modalTitle').textContent = '건조시럽 수정';
document.getElementById('editMode').value = 'edit';
document.getElementById('formSungCode').value = result.sung_code;
document.getElementById('formSungCode').disabled = true;
document.getElementById('formIngredientName').value = result.ingredient_name || '';
document.getElementById('formProductName').value = result.product_name || '';
document.getElementById('formConversionFactor').value = result.conversion_factor || '';
document.getElementById('formExpiration').value = result.expiration_date || '';
document.getElementById('formPostPrepAmount').value = result.post_prep_amount || '';
document.getElementById('formMainIngredientAmt').value = result.main_ingredient_amt || '';
document.getElementById('formStorageConditions').value = result.storage_conditions || '실온';
document.getElementById('editModal').classList.add('active');
} catch (error) {
console.error('수정 모달 오류:', error);
showToast('데이터 로드 실패', 'error');
}
}
// 모달 닫기
function closeModal() {
document.getElementById('editModal').classList.remove('active');
}
// 데이터 저장
async function saveData() {
const mode = document.getElementById('editMode').value;
const sungCode = document.getElementById('formSungCode').value.trim();
if (!sungCode) {
showToast('성분코드는 필수입니다.', 'error');
return;
}
const data = {
sung_code: sungCode,
ingredient_name: document.getElementById('formIngredientName').value.trim(),
product_name: document.getElementById('formProductName').value.trim(),
conversion_factor: parseFloat(document.getElementById('formConversionFactor').value) || null,
expiration_date: document.getElementById('formExpiration').value.trim(),
post_prep_amount: document.getElementById('formPostPrepAmount').value.trim(),
main_ingredient_amt: document.getElementById('formMainIngredientAmt').value.trim(),
storage_conditions: document.getElementById('formStorageConditions').value
};
try {
const url = mode === 'create'
? '/api/drug-info/drysyrup'
: `/api/drug-info/drysyrup/${encodeURIComponent(sungCode)}`;
const method = mode === 'create' ? 'POST' : 'PUT';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showToast(mode === 'create' ? '등록 완료' : '수정 완료', 'success');
closeModal();
loadData();
} else {
showToast(result.error || '저장 실패', 'error');
}
} catch (error) {
console.error('저장 오류:', error);
showToast('서버 오류', 'error');
}
}
// 삭제 모달 열기
function openDeleteModal(sungCode) {
deleteSungCode = sungCode;
document.getElementById('deleteTarget').textContent = sungCode;
document.getElementById('deleteModal').classList.add('active');
}
// 삭제 모달 닫기
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('active');
deleteSungCode = null;
}
// 삭제 확인
async function confirmDelete() {
if (!deleteSungCode) return;
try {
const response = await fetch(`/api/drug-info/drysyrup/${encodeURIComponent(deleteSungCode)}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
showToast('삭제 완료', 'success');
closeDeleteModal();
loadData();
} else {
showToast(result.error || '삭제 실패', 'error');
}
} catch (error) {
console.error('삭제 오류:', error);
showToast('서버 오류', 'error');
}
}
// 에러 표시/숨김
function showError(message) {
document.getElementById('errorMessage').textContent = message;
document.getElementById('errorBanner').classList.remove('hidden');
}
function hideError() {
document.getElementById('errorBanner').classList.add('hidden');
}
// 토스트 메시지
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${type === 'success' ? '✅' : '❌'}</span>
<span>${escapeHtml(message)}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// HTML 이스케이프
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</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'
}) : '';
@@ -1075,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'
}) : '';

View File

@@ -358,7 +358,8 @@
}
tbody.innerHTML = data.logs.map(log => {
const time = new Date(log.created_at).toLocaleString('ko-KR', {
// 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'
});

View File

@@ -263,11 +263,159 @@
font-weight: 600;
color: #1e293b;
}
.customer {
.customer-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.customer-badge:hover {
transform: scale(1.05);
}
.customer-badge.has-name {
background: #dbeafe;
color: #1e40af;
}
.customer-badge.has-name:hover {
background: #bfdbfe;
}
.customer-badge.no-name {
background: #f1f5f9;
color: #94a3b8;
border: 1px dashed #cbd5e1;
}
.customer-badge.no-name:hover {
background: #e2e8f0;
border-color: #3b82f6;
color: #2563eb;
}
.mileage-badge {
display: inline-block;
padding: 2px 6px;
margin-left: 6px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
/* 고객 검색 모달 */
.customer-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
align-items: center;
justify-content: center;
}
.customer-modal.show { display: flex; }
.customer-modal-content {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.customer-modal h3 {
margin: 0 0 16px 0;
color: #1e40af;
font-size: 18px;
}
.customer-search-box {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.customer-search-input {
flex: 1;
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
}
.customer-search-input:focus {
outline: none;
border-color: #3b82f6;
}
.customer-search-btn {
padding: 12px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.customer.non-member {
.customer-search-btn:hover {
background: #2563eb;
}
.customer-results {
flex: 1;
overflow-y: auto;
max-height: 400px;
}
.customer-result-item {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.customer-result-item:hover {
background: #f0f9ff;
border-color: #3b82f6;
}
.customer-result-name {
font-weight: 600;
color: #1e293b;
}
.customer-result-birth {
font-size: 12px;
color: #64748b;
margin-left: 8px;
}
.customer-result-activity {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
.customer-result-activity.recent {
color: #22c55e;
}
.customer-modal-close {
margin-top: 16px;
padding: 10px 20px;
background: #f1f5f9;
color: #64748b;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.customer-modal-close:hover {
background: #e2e8f0;
}
.customer-no-results {
text-align: center;
color: #94a3b8;
padding: 40px 20px;
}
.customer-order-info {
background: #f0f9ff;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
font-size: 13px;
color: #64748b;
}
/* 결제수단 뱃지 */
@@ -917,7 +1065,13 @@
const rows = data.sales.map((sale, idx) => {
const payBadge = getPayBadge(sale.pay_method);
const customerClass = sale.customer === '[비고객]' ? 'customer non-member' : 'customer';
const hasCustomer = sale.customer && sale.customer !== '[비고객]' && sale.customer_code !== '0000000000';
const mileageSpan = hasCustomer
? `<span class="mileage-badge" id="mileage-${sale.customer_code}" style="display:none;"></span>`
: '';
const customerBadge = hasCustomer
? `<span class="customer-badge has-name" onclick="event.stopPropagation();openCustomerModal('${sale.order_no}', '${escapeHtml(sale.customer)}', '${sale.customer_code}')">${escapeHtml(sale.customer)}</span>${mileageSpan}`
: `<span class="customer-badge no-name" onclick="event.stopPropagation();openCustomerModal('${sale.order_no}', '', '0000000000')">미입력</span>`;
const qrIcon = sale.qr_issued
? '<span class="status-icon qr-yes">✓</span>'
: '<span class="status-icon qr-no">✗</span>';
@@ -944,7 +1098,7 @@
</td>
<td onclick="showDetail('${sale.order_no}', ${idx})"><span class="time">${sale.time}</span></td>
<td class="right" onclick="showDetail('${sale.order_no}', ${idx})"><span class="amount">₩${Math.floor(sale.amount).toLocaleString()}</span></td>
<td onclick="showDetail('${sale.order_no}', ${idx})"><span class="${customerClass}">${sale.customer}</span></td>
<td>${customerBadge}</td>
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${payBadge}</td>
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${sale.item_count}</td>
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${qrIcon}</td>
@@ -955,6 +1109,37 @@
document.getElementById('salesTable').innerHTML = rows;
updateSelectedCount();
// 비동기로 마일리지 조회
fetchMileagesAsync(data.sales);
}
async function fetchMileagesAsync(sales) {
// 고객코드 있는 건들만 수집 (중복 제거)
const cusCodes = [...new Set(
sales
.filter(s => s.customer_code && s.customer_code !== '0000000000')
.map(s => s.customer_code)
)];
// 각 고객코드에 대해 비동기 조회
for (const code of cusCodes) {
try {
const res = await fetch(`/api/customers/${code}/mileage`);
const data = await res.json();
if (data.success && data.mileage !== null) {
// 해당 코드의 모든 mileage-badge 업데이트
const badges = document.querySelectorAll(`#mileage-${code}`);
badges.forEach(badge => {
badge.textContent = data.mileage.toLocaleString() + 'P';
badge.style.display = 'inline-block';
});
}
} catch (err) {
console.warn(`마일리지 조회 실패 (${code}):`, err);
}
}
}
function getPayBadge(method) {
@@ -1481,6 +1666,149 @@
}
`;
document.head.appendChild(toastStyle);
// ═══════════════════════════════════════════════════════════════
// 고객 검색/매핑 모달
// ═══════════════════════════════════════════════════════════════
let currentOrderNo = null;
let currentCustomerName = '';
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function openCustomerModal(orderNo, customerName, customerCode) {
currentOrderNo = orderNo;
currentCustomerName = customerName;
document.getElementById('customerOrderInfo').textContent =
`주문번호: ${orderNo} | 현재: ${customerName || '미입력'}`;
document.getElementById('customerSearchInput').value = customerName || '';
document.getElementById('customerResults').innerHTML =
'<div class="customer-no-results">이름을 검색하세요</div>';
document.getElementById('customerModal').classList.add('show');
document.getElementById('customerSearchInput').focus();
}
function closeCustomerModal() {
document.getElementById('customerModal').classList.remove('show');
currentOrderNo = null;
}
async function searchCustomers() {
const name = document.getElementById('customerSearchInput').value.trim();
if (name.length < 2) {
document.getElementById('customerResults').innerHTML =
'<div class="customer-no-results">2자 이상 입력하세요</div>';
return;
}
try {
const res = await fetch(`/api/customers/search?name=${encodeURIComponent(name)}`);
const data = await res.json();
if (!data.success) {
document.getElementById('customerResults').innerHTML =
`<div class="customer-no-results">${data.error}</div>`;
return;
}
if (data.results.length === 0) {
document.getElementById('customerResults').innerHTML =
'<div class="customer-no-results">검색 결과가 없습니다</div>';
return;
}
const html = data.results.map(c => {
const birthDisplay = c.birth
? `(${c.birth.substring(0,2)}.${c.birth.substring(2,4)}.${c.birth.substring(4,6)})`
: '';
let activityHtml = '';
if (c.activity_type && c.days_ago !== null) {
const isRecent = c.days_ago <= 30;
activityHtml = `<div class="customer-result-activity ${isRecent ? 'recent' : ''}">
${c.activity_type === '조제' ? '📋' : '🛒'}
${c.activity_type} ${c.days_ago === 0 ? '오늘' : c.days_ago + '일 전'}
</div>`;
} else {
activityHtml = '<div class="customer-result-activity">활동 기록 없음</div>';
}
return `
<div class="customer-result-item" onclick="selectCustomer('${c.cus_code}', '${escapeHtml(c.name)}')">
<span class="customer-result-name">${escapeHtml(c.name)}</span>
<span class="customer-result-birth">${birthDisplay}</span>
${activityHtml}
</div>
`;
}).join('');
document.getElementById('customerResults').innerHTML = html;
} catch (err) {
document.getElementById('customerResults').innerHTML =
`<div class="customer-no-results">오류: ${err.message}</div>`;
}
}
async function selectCustomer(cusCode, cusName) {
if (!currentOrderNo) return;
try {
const res = await fetch(`/api/pos-live/${currentOrderNo}/customer`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cus_code: cusCode, cus_name: cusName })
});
const data = await res.json();
if (data.success) {
showToast(`${cusName}님으로 업데이트됨`, 'success');
closeCustomerModal();
loadSales(); // 테이블 새로고침
} else {
showToast('업데이트 실패: ' + data.error, 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
// Enter 키로 검색
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('customerSearchInput')?.addEventListener('keypress', e => {
if (e.key === 'Enter') searchCustomers();
});
});
// 모달 외부 클릭 시 닫기
document.getElementById('customerModal')?.addEventListener('click', e => {
if (e.target.id === 'customerModal') closeCustomerModal();
});
</script>
<!-- 고객 검색 모달 -->
<div class="customer-modal" id="customerModal">
<div class="customer-modal-content">
<h3>👤 고객 매핑</h3>
<div class="customer-order-info" id="customerOrderInfo">주문번호: -</div>
<div class="customer-search-box">
<input type="text" class="customer-search-input" id="customerSearchInput"
placeholder="이름 검색..." autocomplete="off">
<button class="customer-search-btn" onclick="searchCustomers()">검색</button>
</div>
<div class="customer-results" id="customerResults">
<div class="customer-no-results">이름을 검색하세요</div>
</div>
<button class="customer-modal-close" onclick="closeCustomerModal()">닫기</button>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,789 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>가격 변동 추이 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--border: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-teal: #14b8a6;
--accent-blue: #3b82f6;
--accent-purple: #a855f7;
--accent-amber: #f59e0b;
--accent-emerald: #10b981;
--accent-rose: #f43f5e;
--accent-orange: #f97316;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
padding: 20px 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.header-left p {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.header-nav {
display: flex;
gap: 8px;
}
.header-nav a {
color: rgba(255,255,255,0.85);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
background: rgba(255,255,255,0.1);
transition: all 0.2s;
}
.header-nav a:hover {
background: rgba(255,255,255,0.2);
color: #fff;
}
/* ══════════════════ 컨텐츠 ══════════════════ */
.content {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* ══════════════════ 검색 영역 ══════════════════ */
.search-section {
background: var(--bg-card);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.search-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.search-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.search-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.search-group input, .search-group select {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
background: var(--bg-primary);
color: var(--text-primary);
min-width: 200px;
}
.search-group input:focus, .search-group select:focus {
outline: none;
border-color: var(--accent-teal);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
}
/* ══════════════════ 약품 검색 자동완성 ══════════════════ */
.drug-search-wrap {
position: relative;
flex: 1;
min-width: 280px;
}
.drug-search-wrap input {
width: 100%;
}
.drug-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
max-height: 320px;
overflow-y: auto;
z-index: 50;
display: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
margin-top: 4px;
}
.drug-search-results.show {
display: block;
}
.drug-item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.05);
transition: background 0.2s;
}
.drug-item:hover {
background: var(--bg-card-hover);
}
.drug-item:last-child {
border-bottom: none;
}
.drug-item-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.drug-item-info {
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.drug-item-barcode {
color: var(--accent-teal);
}
.search-btn {
background: var(--accent-teal);
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:hover {
background: #0d9488;
transform: translateY(-1px);
}
.search-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
transform: none;
}
/* ══════════════════ 통계 카드 ══════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.stat-card .label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-card .value {
font-size: 24px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.stat-card .sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.stat-card.teal .value { color: var(--accent-teal); }
.stat-card.blue .value { color: var(--accent-blue); }
.stat-card.amber .value { color: var(--accent-amber); }
.stat-card.emerald .value { color: var(--accent-emerald); }
.stat-card.rose .value { color: var(--accent-rose); }
/* ══════════════════ 차트 영역 ══════════════════ */
.chart-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
@media (max-width: 1000px) {
.chart-section { grid-template-columns: 1fr; }
}
.chart-card {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.chart-card h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.chart-container {
position: relative;
height: 280px;
}
/* ══════════════════ 데이터 테이블 ══════════════════ */
.table-section {
background: var(--bg-card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.table-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
font-size: 14px;
font-family: 'JetBrains Mono', monospace;
}
tr:hover {
background: var(--bg-card-hover);
}
.price-up { color: var(--accent-rose); }
.price-down { color: var(--accent-emerald); }
.price-same { color: var(--text-muted); }
/* ══════════════════ 빈 상태 ══════════════════ */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-state p {
font-size: 14px;
}
/* ══════════════════ 로딩 ══════════════════ */
.loading {
display: none;
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.loading.active { display: block; }
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent-teal);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ══════════════════ 제품 정보 ══════════════════ */
.product-info {
background: var(--bg-card);
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
display: none;
}
.product-info.active { display: block; }
.product-info h2 {
font-size: 20px;
font-weight: 700;
color: var(--accent-teal);
margin-bottom: 8px;
}
.product-info .barcode {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--text-muted);
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<div class="header-left">
<h1>📈 가격 변동 추이</h1>
<p>제품별 판매가/마진 변화 분석</p>
</div>
<nav class="header-nav">
<a href="/admin">관리자</a>
<a href="/admin/sales/pos">POS 매출</a>
<a href="/admin/stock-analytics">재고 분석</a>
</nav>
</div>
</header>
<main class="content">
<!-- 검색 -->
<section class="search-section">
<div class="search-row">
<div class="search-group drug-search-wrap">
<label>바코드 또는 약품명</label>
<input type="text" id="searchQuery" placeholder="약품명 또는 바코드 입력..." autocomplete="off">
<div class="drug-search-results" id="drugSearchResults"></div>
</div>
<div class="search-group">
<label>기간</label>
<select id="periodSelect">
<option value="90">최근 3개월</option>
<option value="180">최근 6개월</option>
<option value="365" selected>최근 1년</option>
<option value="730">최근 2년</option>
<option value="0">전체 기간</option>
</select>
</div>
<button class="search-btn" id="searchBtn" onclick="searchProduct()">
🔍 조회
</button>
</div>
</section>
<!-- 제품 정보 -->
<section class="product-info" id="productInfo">
<h2 id="productName">-</h2>
<p class="barcode">바코드: <span id="productBarcode">-</span></p>
</section>
<!-- 통계 카드 -->
<section class="stats-grid" id="statsGrid" style="display: none;">
<div class="stat-card teal">
<div class="label">현재 판매가</div>
<div class="value" id="currentPrice">-</div>
<div class="sub" id="priceChange">-</div>
</div>
<div class="stat-card blue">
<div class="label">현재 입고가</div>
<div class="value" id="currentCost">-</div>
<div class="sub" id="costChange">-</div>
</div>
<div class="stat-card emerald">
<div class="label">현재 마진율</div>
<div class="value" id="currentMargin">-</div>
<div class="sub" id="marginRange">-</div>
</div>
<div class="stat-card amber">
<div class="label">총 판매건수</div>
<div class="value" id="totalSales">-</div>
<div class="sub" id="salesPeriod">-</div>
</div>
</section>
<!-- 차트 -->
<section class="chart-section" id="chartSection" style="display: none;">
<div class="chart-card">
<h3>💰 판매가 변동 추이</h3>
<div class="chart-container">
<canvas id="priceChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>📊 마진율 변동 추이</h3>
<div class="chart-container">
<canvas id="marginChart"></canvas>
</div>
</div>
</section>
<!-- 상세 테이블 -->
<section class="table-section" id="tableSection" style="display: none;">
<div class="table-header">
<h3>📋 일별 상세 내역</h3>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>날짜</th>
<th>판매가</th>
<th>입고가</th>
<th>마진</th>
<th>마진율</th>
<th>판매건수</th>
<th>변동</th>
</tr>
</thead>
<tbody id="dataTable">
</tbody>
</table>
</div>
</section>
<!-- 로딩 -->
<div class="loading" id="loading">
<div class="spinner"></div>
<p>데이터 조회 중...</p>
</div>
<!-- 빈 상태 -->
<div class="empty-state" id="emptyState">
<div class="icon">📊</div>
<p>바코드 또는 약품명을 검색하여<br>가격 변동 추이를 확인하세요</p>
</div>
</main>
<script>
let priceChart = null;
let marginChart = null;
let searchTimeout = null;
// 초기화
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchQuery');
// 입력 시 자동완성
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => searchDrugs(this.value), 300);
});
// 포커스 시 결과 표시
searchInput.addEventListener('focus', function() {
if (this.value.length >= 2) {
searchDrugs(this.value);
}
});
// 외부 클릭 시 드롭다운 숨기기
document.addEventListener('click', function(e) {
if (!e.target.closest('.drug-search-wrap')) {
document.getElementById('drugSearchResults').classList.remove('show');
}
});
// 엔터키 검색
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
document.getElementById('drugSearchResults').classList.remove('show');
searchProduct();
}
});
});
// 약품 자동완성 검색
async function searchDrugs(query) {
const resultsDiv = document.getElementById('drugSearchResults');
if (!query || query.length < 2) {
resultsDiv.classList.remove('show');
return;
}
try {
const response = await fetch(`/api/price-trend/search?q=${encodeURIComponent(query)}&limit=15`);
const data = await response.json();
if (data.success && data.items.length > 0) {
resultsDiv.innerHTML = data.items.map(item => `
<div class="drug-item" onclick="selectDrug('${escapeHtml(item.barcode)}', '${escapeHtml(item.product_name)}')">
<div class="drug-item-name">${escapeHtml(item.product_name)}</div>
<div class="drug-item-info">
바코드: <span class="drug-item-barcode">${item.barcode}</span>
· 판매건수: ${item.sale_count.toLocaleString()}
</div>
</div>
`).join('');
resultsDiv.classList.add('show');
} else {
resultsDiv.innerHTML = '<div class="drug-item"><div class="drug-item-name" style="color:var(--text-muted)">검색 결과 없음</div></div>';
resultsDiv.classList.add('show');
}
} catch (err) {
console.error('약품 검색 실패:', err);
}
}
function selectDrug(barcode, productName) {
document.getElementById('searchQuery').value = barcode;
document.getElementById('drugSearchResults').classList.remove('show');
searchProduct();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
async function searchProduct() {
const query = document.getElementById('searchQuery').value.trim();
const period = document.getElementById('periodSelect').value;
if (!query) {
alert('바코드 또는 약품명을 입력하세요');
return;
}
// UI 초기화
document.getElementById('emptyState').style.display = 'none';
document.getElementById('loading').classList.add('active');
document.getElementById('productInfo').classList.remove('active');
document.getElementById('statsGrid').style.display = 'none';
document.getElementById('chartSection').style.display = 'none';
document.getElementById('tableSection').style.display = 'none';
document.getElementById('searchBtn').disabled = true;
try {
const response = await fetch(`/api/price-trend?query=${encodeURIComponent(query)}&period=${period}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || '조회 실패');
}
if (!data.data || data.data.length === 0) {
document.getElementById('emptyState').innerHTML = `
<div class="icon">🔍</div>
<p>"${query}"에 대한 판매 기록이 없습니다</p>
`;
document.getElementById('emptyState').style.display = 'block';
return;
}
// 데이터 표시
displayData(data);
} catch (error) {
console.error('Error:', error);
document.getElementById('emptyState').innerHTML = `
<div class="icon">⚠️</div>
<p>오류: ${error.message}</p>
`;
document.getElementById('emptyState').style.display = 'block';
} finally {
document.getElementById('loading').classList.remove('active');
document.getElementById('searchBtn').disabled = false;
}
}
function displayData(result) {
const data = result.data;
const stats = result.stats;
const productName = result.product_name || '알 수 없음';
const barcode = result.barcode;
// 제품 정보
document.getElementById('productName').textContent = productName;
document.getElementById('productBarcode').textContent = barcode;
document.getElementById('productInfo').classList.add('active');
// 통계
document.getElementById('currentPrice').textContent = formatNumber(stats.current_price) + '원';
document.getElementById('currentCost').textContent = formatNumber(stats.current_cost) + '원';
document.getElementById('currentMargin').textContent = stats.current_margin.toFixed(1) + '%';
document.getElementById('totalSales').textContent = formatNumber(stats.total_count) + '건';
document.getElementById('priceChange').textContent =
`범위: ${formatNumber(stats.min_price)}원 ~ ${formatNumber(stats.max_price)}`;
document.getElementById('costChange').textContent =
`범위: ${formatNumber(stats.min_cost)}원 ~ ${formatNumber(stats.max_cost)}`;
document.getElementById('marginRange').textContent =
`범위: ${stats.min_margin.toFixed(1)}% ~ ${stats.max_margin.toFixed(1)}%`;
document.getElementById('salesPeriod').textContent =
`${stats.first_date} ~ ${stats.last_date}`;
document.getElementById('statsGrid').style.display = 'grid';
// 차트 데이터 준비
const labels = data.map(d => d.date.substring(0, 10));
const prices = data.map(d => d.avg_price);
const margins = data.map(d => d.margin_rate);
// 판매가 차트
if (priceChart) priceChart.destroy();
const priceCtx = document.getElementById('priceChart').getContext('2d');
priceChart = new Chart(priceCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '판매가',
data: prices,
borderColor: '#14b8a6',
backgroundColor: 'rgba(20, 184, 166, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#64748b', maxTicksLimit: 10 },
grid: { color: '#334155' }
},
y: {
ticks: {
color: '#64748b',
callback: v => formatNumber(v) + '원'
},
grid: { color: '#334155' }
}
}
}
});
// 마진율 차트
if (marginChart) marginChart.destroy();
const marginCtx = document.getElementById('marginChart').getContext('2d');
marginChart = new Chart(marginCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '마진율',
data: margins,
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#64748b', maxTicksLimit: 10 },
grid: { color: '#334155' }
},
y: {
ticks: {
color: '#64748b',
callback: v => v.toFixed(1) + '%'
},
grid: { color: '#334155' }
}
}
}
});
document.getElementById('chartSection').style.display = 'grid';
// 테이블
const tbody = document.getElementById('dataTable');
tbody.innerHTML = '';
let prevPrice = null;
data.forEach((row, idx) => {
let changeClass = 'price-same';
let changeText = '-';
if (idx > 0 && prevPrice !== null) {
if (row.avg_price > prevPrice) {
changeClass = 'price-up';
changeText = '↑ ' + formatNumber(row.avg_price - prevPrice);
} else if (row.avg_price < prevPrice) {
changeClass = 'price-down';
changeText = '↓ ' + formatNumber(prevPrice - row.avg_price);
}
}
prevPrice = row.avg_price;
tbody.innerHTML += `
<tr>
<td>${row.date}</td>
<td>${formatNumber(row.avg_price)}원</td>
<td>${formatNumber(row.avg_cost)}원</td>
<td>${formatNumber(row.avg_margin)}원</td>
<td>${row.margin_rate.toFixed(1)}%</td>
<td>${row.count}건</td>
<td class="${changeClass}">${changeText}</td>
</tr>
`;
});
document.getElementById('tableSection').style.display = 'block';
}
function formatNumber(num) {
return Math.round(num).toLocaleString('ko-KR');
}
</script>
</body>
</html>

View File

@@ -590,6 +590,186 @@
.location-modal-btn.primary { background: #f59e0b; color: #fff; }
.location-modal-btn.primary:hover { background: #d97706; }
/* ── 입고이력 모달 ── */
.purchase-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.purchase-modal.show { display: flex; }
.purchase-modal-content {
background: #fff;
border-radius: 16px;
padding: 0;
max-width: 600px;
width: 95%;
max-height: 80vh;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: modalSlideIn 0.2s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
.purchase-modal-header {
padding: 20px 24px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: #fff;
}
.purchase-modal-header h3 {
margin: 0 0 6px 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.purchase-modal-header .drug-name {
font-size: 14px;
opacity: 0.9;
}
.purchase-modal-body {
padding: 16px 24px 24px;
overflow-y: auto;
flex: 1;
}
.purchase-history-table {
width: 100%;
border-collapse: collapse;
}
.purchase-history-table th {
background: #f8fafc;
padding: 12px 10px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 2px solid #e2e8f0;
position: sticky;
top: 0;
}
.purchase-history-table td {
padding: 14px 10px;
font-size: 14px;
border-bottom: 1px solid #f1f5f9;
}
.purchase-history-table tr:hover td {
background: #f8fafc;
}
.supplier-name {
font-weight: 600;
color: #1e293b;
}
.supplier-tel {
font-size: 12px;
color: #3b82f6;
cursor: pointer;
}
.supplier-tel:hover {
text-decoration: underline;
}
.purchase-date {
color: #64748b;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.purchase-qty {
font-weight: 600;
color: #10b981;
}
.purchase-price {
color: #6b7280;
}
.purchase-empty {
text-align: center;
padding: 40px 20px;
color: #94a3b8;
}
.purchase-empty .icon {
font-size: 40px;
margin-bottom: 12px;
}
.purchase-modal-footer {
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: flex-end;
}
.purchase-modal-btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 14px;
background: #f1f5f9;
color: #64748b;
transition: all 0.15s;
}
.purchase-modal-btn:hover {
background: #e2e8f0;
}
/* ── 제품명 링크 스타일 ── */
.product-name-link {
cursor: pointer;
transition: color 0.15s;
}
.product-name-link:hover {
color: #8b5cf6;
text-decoration: underline;
}
/* ── 환자명 링크 스타일 ── */
.patient-name-link {
cursor: pointer;
color: #1e293b;
transition: all 0.15s;
padding: 2px 6px;
border-radius: 4px;
}
.patient-name-link:hover {
color: #8b5cf6;
background: #f3e8ff;
text-decoration: underline;
}
/* ── 페이지네이션 버튼 ── */
.pagination-btn {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
.pagination-btn:hover:not(:disabled) {
background: #f1f5f9;
border-color: #cbd5e1;
}
.pagination-btn.active {
background: #10b981;
color: #fff;
border-color: #10b981;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
tbody tr {
cursor: pointer;
}
tbody tr:active {
background: #ede9fe;
}
/* ── 가격 ── */
.price {
font-weight: 600;
@@ -916,6 +1096,7 @@
<!-- 결과 -->
<div class="result-count" id="resultCount" style="display:none;">
검색 결과: <strong id="resultNum">0</strong>
<span style="margin-left: 16px; color: #94a3b8; font-size: 12px;">💡 행 더블클릭 → 입고이력</span>
</div>
<div class="table-wrap">
@@ -993,6 +1174,81 @@
</div>
</div>
<!-- 입고이력 모달 -->
<div class="purchase-modal" id="purchaseModal" onclick="if(event.target===this)closePurchaseModal()">
<div class="purchase-modal-content">
<div class="purchase-modal-header">
<h3>📦 입고 이력</h3>
<div class="drug-name" id="purchaseDrugName">-</div>
</div>
<div class="purchase-modal-body">
<table class="purchase-history-table">
<thead>
<tr>
<th>도매상</th>
<th>입고일</th>
<th>수량</th>
<th>단가</th>
</tr>
</thead>
<tbody id="purchaseHistoryBody">
<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
</tbody>
</table>
</div>
<div class="purchase-modal-footer">
<button class="purchase-modal-btn" onclick="closePurchaseModal()">닫기</button>
</div>
</div>
</div>
<!-- 사용이력 모달 -->
<!-- 환자 최근 처방 모달 (z-index 더 높게 - 제품 모달 위에 표시) -->
<div class="purchase-modal" id="patientPrescriptionsModal" style="z-index: 2100;" onclick="if(event.target===this)closePatientPrescriptionsModal()">
<div class="purchase-modal-content" style="max-width: 850px;">
<div class="purchase-modal-header" style="background: linear-gradient(135deg, #8b5cf6, #7c3aed);">
<h3>📋 환자 최근 처방</h3>
<div class="drug-name" id="patientPrescriptionsName">-</div>
</div>
<div class="purchase-modal-body" id="patientPrescriptionsBody" style="max-height: 500px; overflow-y: auto;">
<div class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></div>
</div>
<div class="purchase-modal-footer">
<button class="purchase-modal-btn" onclick="closePatientPrescriptionsModal()">닫기</button>
</div>
</div>
</div>
<div class="purchase-modal" id="usageHistoryModal" onclick="if(event.target===this)closeUsageHistoryModal()">
<div class="purchase-modal-content" style="max-width: 700px;">
<div class="purchase-modal-header" style="background: linear-gradient(135deg, #10b981, #059669);">
<h3>💊 처방 사용이력</h3>
<div class="drug-name" id="usageHistoryDrugName">-</div>
</div>
<div class="purchase-modal-body">
<table class="purchase-history-table">
<thead>
<tr>
<th>환자명</th>
<th>처방일</th>
<th>수량</th>
<th>횟수</th>
<th>일수</th>
<th>총투약량</th>
</tr>
</thead>
<tbody id="usageHistoryBody">
<tr><td colspan="6" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
</tbody>
</table>
</div>
<div class="purchase-modal-footer" style="flex-direction: column; gap: 12px;">
<div id="usageHistoryPagination" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center;"></div>
<button class="purchase-modal-btn" onclick="closeUsageHistoryModal()">닫기</button>
</div>
</div>
</div>
<script>
let productsData = [];
let selectedItem = null;
@@ -1069,17 +1325,17 @@
: '';
return `
<tr>
<tr ondblclick="openPurchaseModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')">
<td style="text-align:center;">
${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>`
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="event.stopPropagation();openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="event.stopPropagation();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>`
}
</td>
<td>
<div class="product-name">
${escapeHtml(item.product_name)}
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
<span class="product-name-link" onclick="event.stopPropagation();openUsageHistoryModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')" title="클릭하여 사용이력 보기">${escapeHtml(item.product_name)}</span>
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="event.stopPropagation();printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
${categoryBadge}
</div>
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
@@ -1092,12 +1348,12 @@
<div style="margin-top:4px;">${item.apc ? `<span class="code code-apc">${item.apc}</span>` : `<span class="code code-apc-na">APC미지정</span>`}</div>`
: (item.barcode ? `<span class="code code-barcode">${item.barcode}</span>` : `<span class="code code-na">없음</span>`)}</td>
<td>${item.location
? `<span class="location-badge" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
: `<span class="location-badge unset" onclick="openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
? `<span class="location-badge" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
: `<span class="location-badge unset" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}${wsStock}</td>
<td class="price">${formatPrice(item.sale_price)}</td>
<td>
<button class="btn-qr" onclick="printQR(${idx})">🏷️ QR</button>
<button class="btn-qr" onclick="event.stopPropagation();printQR(${idx})">🏷️ QR</button>
</td>
</tr>
`}).join('');
@@ -1552,6 +1808,7 @@
function closeImageModal() {
stopCamera();
stopQrPolling();
document.getElementById('imageModal').classList.remove('show');
imgModalBarcode = null;
imgModalDrugCode = null;
@@ -1563,6 +1820,106 @@
document.querySelectorAll('.img-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
document.querySelectorAll('.img-tab-content').forEach(c => c.classList.toggle('active', c.id === 'imgTab' + tab.charAt(0).toUpperCase() + tab.slice(1)));
if (tab === 'camera') startCamera(); else stopCamera();
if (tab === 'qr') startQrSession(); else stopQrPolling();
}
// ═══ QR 모바일 업로드 ═══
let qrSessionId = null;
let qrPollingInterval = null;
async function startQrSession() {
const barcode = imgModalBarcode;
if (!barcode) return;
document.getElementById('qrLoading').style.display = 'flex';
document.getElementById('qrCanvas').style.display = 'none';
document.getElementById('qrStatus').style.display = 'none';
try {
const res = await fetch('/api/upload-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcode, product_name: imgModalName || barcode })
});
const data = await res.json();
if (data.success) {
qrSessionId = data.session_id;
generateQrCode(data.qr_url);
startQrPolling();
} else {
document.getElementById('qrLoading').textContent = '❌ QR 생성 실패';
}
} catch (err) {
document.getElementById('qrLoading').textContent = '❌ 네트워크 오류';
}
}
function generateQrCode(url) {
const canvas = document.getElementById('qrCanvas');
const ctx = canvas.getContext('2d');
canvas.width = 180;
canvas.height = 180;
// QR 라이브러리 없이 API 사용
const img = new Image();
img.onload = function() {
ctx.drawImage(img, 0, 0, 180, 180);
document.getElementById('qrLoading').style.display = 'none';
canvas.style.display = 'block';
};
img.onerror = function() {
document.getElementById('qrLoading').textContent = '❌ QR 생성 실패';
};
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(url);
}
function startQrPolling() {
stopQrPolling();
qrPollingInterval = setInterval(checkQrSession, 2000);
}
function stopQrPolling() {
if (qrPollingInterval) {
clearInterval(qrPollingInterval);
qrPollingInterval = null;
}
qrSessionId = null;
}
async function checkQrSession() {
if (!qrSessionId) return;
try {
const res = await fetch('/api/upload-session/' + qrSessionId);
const data = await res.json();
const statusEl = document.getElementById('qrStatus');
if (data.status === 'uploaded') {
statusEl.style.display = 'block';
statusEl.style.background = '#d1fae5';
statusEl.style.color = '#065f46';
statusEl.textContent = '✅ 이미지 업로드 완료!';
stopQrPolling();
// 1.5초 후 모달 닫고 목록 새로고침
setTimeout(() => {
closeImageModal();
searchProducts();
showToast('📱 모바일 이미지 등록 완료!', 'success');
}, 1500);
} else if (data.status === 'expired') {
statusEl.style.display = 'block';
statusEl.style.background = '#fee2e2';
statusEl.style.color = '#991b1b';
statusEl.textContent = '⏰ 세션 만료 - 탭을 다시 선택하세요';
stopQrPolling();
}
} catch (err) {
console.error('QR 세션 확인 오류:', err);
}
}
async function startCamera() {
@@ -1625,13 +1982,15 @@
if (!capturedImageData) { alert('촬영된 이미지가 없습니다'); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
const drugCode = imgModalDrugCode;
const imageData = capturedImageData; // 먼저 저장!
closeImageModal(); // 이 후 capturedImageData = null 됨
showToast(`"${name}" 이미지 저장 중...`);
try {
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_data: capturedImageData, product_name: name, drug_code: imgModalDrugCode })
body: JSON.stringify({ image_data: imageData, product_name: name, drug_code: drugCode })
});
const data = await res.json();
if (data.success) { showToast('✅ 이미지 저장 완료!', 'success'); searchProducts(); }
@@ -1682,8 +2041,9 @@
</div>
<div class="image-modal-tabs">
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL</button>
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 PC촬영</button>
<button class="img-tab-btn" data-tab="qr" onclick="switchImageTab('qr')">📱 모바일</button>
</div>
<div class="img-tab-content active" id="imgTabUrl">
@@ -1713,6 +2073,22 @@
<button class="img-modal-btn primary" onclick="submitCapturedImage()">저장하기</button>
</div>
</div>
<div class="img-tab-content" id="imgTabQr">
<div style="text-align:center;padding:20px 0;">
<div id="qrContainer" style="display:inline-block;background:#fff;padding:16px;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1);">
<div id="qrLoading" style="width:180px;height:180px;display:flex;align-items:center;justify-content:center;color:#94a3b8;">
QR 생성 중...
</div>
<canvas id="qrCanvas" style="display:none;"></canvas>
</div>
<p style="margin-top:16px;color:#64748b;font-size:14px;">📱 휴대폰으로 QR 스캔하여 촬영</p>
<div id="qrStatus" style="margin-top:12px;padding:8px 16px;border-radius:8px;font-size:13px;display:none;"></div>
</div>
<div class="img-modal-btns">
<button class="img-modal-btn secondary" onclick="closeImageModal()">닫기</button>
</div>
</div>
</div>
</div>
@@ -1833,6 +2209,236 @@
document.getElementById('locationModal')?.addEventListener('click', e => {
if (e.target.id === 'locationModal') closeLocationModal();
});
// ══════════════════════════════════════════════════════════════════
// 입고이력 모달
// ══════════════════════════════════════════════════════════════════
async function openPurchaseModal(drugCode, drugName) {
const modal = document.getElementById('purchaseModal');
const nameEl = document.getElementById('purchaseDrugName');
const tbody = document.getElementById('purchaseHistoryBody');
nameEl.textContent = drugName || drugCode;
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">⏳</div><p>입고이력 조회 중...</p></td></tr>';
modal.classList.add('show');
try {
const res = await fetch(`/api/drugs/${drugCode}/purchase-history`);
const data = await res.json();
if (data.success) {
if (data.history.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>입고 이력이 없습니다</p></td></tr>';
} else {
tbody.innerHTML = data.history.map(h => `
<tr>
<td>
<div class="supplier-name">${escapeHtml(h.supplier)}</div>
${h.supplier_tel ? `<div class="supplier-tel" onclick="copyToClipboard('${h.supplier_tel}')" title="클릭하여 복사">📞 ${h.supplier_tel}</div>` : ''}
</td>
<td class="purchase-date">${h.date}</td>
<td class="purchase-qty">${h.quantity.toLocaleString()}</td>
<td class="purchase-price">${h.unit_price ? formatPrice(h.unit_price) : '-'}</td>
</tr>
`).join('');
}
} else {
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></td></tr>`;
}
}
function closePurchaseModal() {
document.getElementById('purchaseModal').classList.remove('show');
}
// ══════════════════════════════════════════════════════════════════
// 사용이력 모달
// ══════════════════════════════════════════════════════════════════
let currentUsageDrugCode = '';
let currentUsageDrugName = '';
let currentUsagePage = 1;
async function openUsageHistoryModal(drugCode, drugName) {
currentUsageDrugCode = drugCode;
currentUsageDrugName = drugName;
currentUsagePage = 1;
const modal = document.getElementById('usageHistoryModal');
const nameEl = document.getElementById('usageHistoryDrugName');
nameEl.textContent = drugName || drugCode;
modal.classList.add('show');
await loadUsageHistoryPage(1);
}
async function loadUsageHistoryPage(page) {
currentUsagePage = page;
const tbody = document.getElementById('usageHistoryBody');
const pagination = document.getElementById('usageHistoryPagination');
tbody.innerHTML = '<tr><td colspan="5" class="purchase-empty"><div class="icon">⏳</div><p>사용이력 조회 중...</p></td></tr>';
pagination.innerHTML = '';
try {
const res = await fetch(`/api/products/${currentUsageDrugCode}/usage-history?page=${page}&per_page=20&months=12`);
const data = await res.json();
if (data.success) {
if (data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="purchase-empty"><div class="icon">📭</div><p>최근 12개월간 사용이력이 없습니다</p></td></tr>';
} else {
tbody.innerHTML = data.items.map(item => `
<tr>
<td>
<span class="patient-name-link" onclick="event.stopPropagation();openPatientPrescriptionsModal('${item.cus_code}', '${escapeHtml(item.patient_name).replace(/'/g, "\\'")}')" title="클릭하여 최근 처방 보기">${escapeHtml(item.patient_name)}</span>
</td>
<td class="purchase-date">${item.rx_date}</td>
<td style="text-align: center;">${item.quantity}</td>
<td style="text-align: center;">${item.times}</td>
<td style="text-align: center;">${item.days}</td>
<td style="text-align: center; font-weight: 600; color: #10b981;">${item.total_dose}</td>
</tr>
`).join('');
}
// 페이지네이션 렌더링
renderUsageHistoryPagination(data.pagination);
} else {
tbody.innerHTML = `<tr><td colspan="6" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="6" class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></td></tr>`;
}
}
function renderUsageHistoryPagination(pg) {
const pagination = document.getElementById('usageHistoryPagination');
if (pg.total_pages <= 1) {
pagination.innerHTML = `<span style="color: #64748b; font-size: 13px;">총 ${pg.total_count}건</span>`;
return;
}
let html = '';
// 이전 버튼
html += `<button class="pagination-btn" ${pg.page <= 1 ? 'disabled' : ''} onclick="loadUsageHistoryPage(${pg.page - 1})">◀ 이전</button>`;
// 페이지 번호들
const maxPages = 5;
let startPage = Math.max(1, pg.page - Math.floor(maxPages / 2));
let endPage = Math.min(pg.total_pages, startPage + maxPages - 1);
if (endPage - startPage < maxPages - 1) {
startPage = Math.max(1, endPage - maxPages + 1);
}
if (startPage > 1) {
html += `<button class="pagination-btn" onclick="loadUsageHistoryPage(1)">1</button>`;
if (startPage > 2) html += `<span style="color: #94a3b8;">...</span>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `<button class="pagination-btn ${i === pg.page ? 'active' : ''}" onclick="loadUsageHistoryPage(${i})">${i}</button>`;
}
if (endPage < pg.total_pages) {
if (endPage < pg.total_pages - 1) html += `<span style="color: #94a3b8;">...</span>`;
html += `<button class="pagination-btn" onclick="loadUsageHistoryPage(${pg.total_pages})">${pg.total_pages}</button>`;
}
// 다음 버튼
html += `<button class="pagination-btn" ${pg.page >= pg.total_pages ? 'disabled' : ''} onclick="loadUsageHistoryPage(${pg.page + 1})">다음 ▶</button>`;
// 총 건수
html += `<span style="color: #64748b; font-size: 13px; margin-left: 12px;">총 ${pg.total_count}건</span>`;
pagination.innerHTML = html;
}
function closeUsageHistoryModal() {
document.getElementById('usageHistoryModal').classList.remove('show');
}
// 환자 최근 처방 모달
async function openPatientPrescriptionsModal(cusCode, patientName) {
const modal = document.getElementById('patientPrescriptionsModal');
const nameEl = document.getElementById('patientPrescriptionsName');
const bodyEl = document.getElementById('patientPrescriptionsBody');
nameEl.textContent = patientName || cusCode;
bodyEl.innerHTML = '<div class="purchase-empty"><div class="icon">⏳</div><p>처방 내역 조회 중...</p></div>';
modal.classList.add('show');
try {
const response = await fetch(`/api/patients/${cusCode}/recent-prescriptions`);
const data = await response.json();
if (data.success && data.prescriptions.length > 0) {
let html = '';
data.prescriptions.forEach(rx => {
html += `
<div class="rx-card" style="margin-bottom: 16px; padding: 16px; background: #f8fafc; border-radius: 12px; border-left: 4px solid #8b5cf6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<span style="font-weight: 600; color: #1e293b; font-size: 15px;">📅 ${rx.rx_date}</span>
<span style="color: #64748b; font-size: 13px; margin-left: 12px;">${escapeHtml(rx.hospital_name || '')}</span>
</div>
<span style="color: #8b5cf6; font-size: 13px; font-weight: 500;">${escapeHtml(rx.doctor_name || '')}</span>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: #e2e8f0;">
<th style="padding: 8px; text-align: left; border-radius: 6px 0 0 6px;">약품명</th>
<th style="padding: 8px; text-align: center; width: 80px;">용법</th>
<th style="padding: 8px; text-align: center; width: 60px; border-radius: 0 6px 6px 0;">총량</th>
</tr>
</thead>
<tbody>
${rx.items.map(item => `
<tr style="border-bottom: 1px solid #e2e8f0;">
<td style="padding: 8px;">
<div style="color: #334155; font-weight: 500;">${escapeHtml(item.drug_name)}</div>
${item.category ? `<div style="font-size: 11px; color: #8b5cf6; margin-top: 2px;">${escapeHtml(item.category)}</div>` : ''}
</td>
<td style="padding: 8px; text-align: center; color: #475569; font-size: 12px;">${item.quantity}×${item.times}×${item.days}</td>
<td style="padding: 8px; text-align: center; font-weight: 600; color: #8b5cf6;">${item.total_dose}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
});
bodyEl.innerHTML = html;
} else {
bodyEl.innerHTML = '<div class="purchase-empty"><div class="icon">📭</div><p>최근 6개월간 처방 내역이 없습니다</p></div>';
}
} catch (err) {
bodyEl.innerHTML = `<div class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></div>`;
}
}
function closePatientPrescriptionsModal() {
document.getElementById('patientPrescriptionsModal').classList.remove('show');
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast(`📋 ${text} 복사됨`, 'success');
}).catch(() => {
// fallback
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showToast(`📋 ${text} 복사됨`, 'success');
});
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,98 @@
min-height: 100vh;
}
/* ══════════════════ 주문량 로딩 ══════════════════ */
.order-loading {
display: inline-block;
color: var(--accent-cyan);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ══════════════════ 주문량 툴팁 ══════════════════ */
.order-qty-cell {
position: relative;
cursor: pointer;
}
.order-qty-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 100;
opacity: 0;
visibility: hidden;
transition: all 0.2s;
pointer-events: none;
}
.order-qty-cell:hover .order-qty-tooltip {
opacity: 1;
visibility: visible;
bottom: calc(100% + 8px);
}
.order-qty-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border);
}
.order-qty-tooltip-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.order-qty-tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 12px;
}
.order-qty-tooltip-row:not(:last-child) {
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.order-qty-vendor {
display: flex;
align-items: center;
gap: 6px;
}
.order-qty-vendor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.order-qty-vendor-dot.geoyoung { background: #06b6d4; }
.order-qty-vendor-dot.sooin { background: #a855f7; }
.order-qty-vendor-dot.baekje { background: #f59e0b; }
.order-qty-vendor-dot.dongwon { background: #22c55e; }
.order-qty-value {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: var(--text-primary);
}
.order-qty-total {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-weight: 700;
color: var(--accent-cyan);
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
@@ -391,6 +483,28 @@
font-family: inherit;
}
.patient-badge {
display: inline-block;
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
margin-left: 4px;
font-family: inherit;
}
.patient-badge.has-today {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
}
.today-patient {
color: #ec4899;
font-weight: 700;
}
/* 수량 관련 */
.qty-cell {
text-align: center;
@@ -745,7 +859,7 @@
<option value="amount_desc">금액 높은순</option>
</select>
</div>
<button class="search-btn" onclick="loadUsageData()">🔍 조회</button>
<button class="search-btn" onclick="loadUsageData(); loadOrderData();">🔍 조회</button>
<button class="search-btn" style="background: linear-gradient(135deg, #a855f7, #7c3aed);" onclick="openBalanceModal()">💰 도매상 잔고</button>
</div>
@@ -774,7 +888,7 @@
<div class="stat-card emerald">
<div class="stat-icon">💰</div>
<div class="stat-value" id="statTotalAmount">-</div>
<div class="stat-label">약가</div>
<div class="stat-label">매출액</div>
</div>
<div class="stat-card orange">
<div class="stat-icon">🛒</div>
@@ -803,17 +917,19 @@
<thead>
<tr>
<th class="check-col"><input type="checkbox" class="custom-check" id="checkAll" onchange="toggleCheckAll()"></th>
<th style="width:32%">약품</th>
<th style="width:28%">약품</th>
<th class="center">현재고</th>
<th class="center">처방횟수</th>
<th class="center">투약량</th>
<th class="right">약가</th>
<th class="center" style="color:var(--accent-cyan);">주문량</th>
<th class="center" style="color:var(--accent-purple);font-size:11px;">선호도매상</th>
<th class="right">매출액</th>
<th class="center" style="width:90px">주문수량</th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="7">
<td colspan="9">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
@@ -860,6 +976,38 @@
<script>
let usageData = [];
let cart = [];
let orderDataByKd = {};
let preferredVendors = {}; // 약품별 선호 도매상
// 선호 도매상 표시
function getPreferredVendor(drugCode) {
const v = preferredVendors[drugCode];
if (!v || !v.success) return '-';
const recent = v.recent_vendor;
const most = v.most_frequent_vendor;
if (!recent && !most) return '-';
const shorten = (n) => n ? n.replace('강원','').replace('(주)','').replace('지점','').replace('약품','').substring(0,3) : '';
const rn = recent ? shorten(recent.vendor_name) : '';
const mn = most ? shorten(most.vendor_name) : '';
if (rn === mn && rn) return `<span style="color:#10b981" title="${most?.vendor_name}">${rn}</span>`;
let h = '';
if (mn) h += `<span style="color:#a855f7" title="최다(${most.order_count}회)">★${mn}</span>`;
if (rn && rn !== mn) h += `<br><span style="color:#888" title="최근">▸${rn}</span>`;
return h || '-';
}
// 선호 도매상 로드
async function loadPreferredVendors(codes) {
if (!codes || !codes.length) return;
try {
const r = await fetch('/api/order/drugs/preferred-vendors', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({drug_codes: codes, period: 365})
});
const d = await r.json();
if (d.success) { preferredVendors = d.results; renderTable(); }
} catch(e) { console.error('선호도매상 로드 실패:', e); }
} // 도매상 주문량 합산 (KD코드별) - 지오영 + 수인
// 초기화
document.addEventListener('DOMContentLoaded', function() {
@@ -869,8 +1017,122 @@
document.getElementById('endDate').value = todayStr;
loadUsageData();
loadOrderData(); // 수인약품 주문량 로드
});
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 + 동원 합산) ────────────────
async function loadOrderData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
orderDataLoading = true;
orderDataByKd = {};
try {
// 지오영 + 수인 + 백제 + 동원 병렬 조회
const [geoRes, sooinRes, baekjeRes, dongwonRes] = await Promise.all([
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/dongwon/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false }))
]);
let totalOrders = 0;
// 도매상 정보 (확장 가능)
const vendorConfig = {
geoyoung: { name: '지오영', icon: '🏭', res: geoRes },
sooin: { name: '수인', icon: '💜', res: sooinRes },
baekje: { name: '백제', icon: '💉', res: baekjeRes },
dongwon: { name: '동원', icon: '🟠', res: dongwonRes }
};
// 각 도매상 데이터 합산 (상세 정보 포함)
for (const [vendorId, config] of Object.entries(vendorConfig)) {
const res = config.res;
if (res.success && res.by_kd_code) {
for (const [kd, data] of Object.entries(res.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = {
product_name: data.product_name,
spec: data.spec,
boxes: 0,
units: 0,
details: [] // 도매상별 상세 배열
};
}
const boxes = data.boxes || 0;
const units = data.units || 0;
orderDataByKd[kd].boxes += boxes;
orderDataByKd[kd].units += units;
// 상세 정보 추가 (수량이 있는 경우만)
if (units > 0 || boxes > 0) {
orderDataByKd[kd].details.push({
vendor: vendorId,
name: config.name,
boxes: boxes,
units: units
});
}
}
totalOrders += res.order_count || 0;
console.log(`${config.icon} ${config.name} 주문량:`, Object.keys(res.by_kd_code).length, '품목,', res.order_count, '건');
}
}
console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
} catch(err) {
console.warn('주문량 조회 실패:', err);
orderDataByKd = {};
} finally {
orderDataLoading = false;
renderTable(); // 로딩 완료 후 테이블 갱신
}
}
// KD코드로 주문량 조회 (툴팁 포함)
let orderDataLoading = true; // 로딩 상태
function getOrderedQty(kdCode) {
if (orderDataLoading) return '<span class="order-loading">···</span>';
const order = orderDataByKd[kdCode];
if (!order || order.units === 0) return '-';
// 상세 정보가 없거나 1개만 있으면 단순 표시
if (!order.details || order.details.length <= 1) {
const vendorName = order.details && order.details[0] ? order.details[0].name : '';
return `<span title="${vendorName}">${order.units.toLocaleString()}</span>`;
}
// 2개 이상 도매상이면 툴팁 표시
let tooltipHtml = `<div class="order-qty-tooltip">
<div class="order-qty-tooltip-title">도매상별 주문</div>`;
for (const detail of order.details) {
tooltipHtml += `
<div class="order-qty-tooltip-row">
<span class="order-qty-vendor">
<span class="order-qty-vendor-dot ${detail.vendor}"></span>
${detail.name}
</span>
<span class="order-qty-value">${detail.units.toLocaleString()}</span>
</div>`;
}
tooltipHtml += `
<div class="order-qty-tooltip-row order-qty-total">
<span>합계</span>
<span>${order.units.toLocaleString()}</span>
</div>
</div>`;
return `<div class="order-qty-cell">
${order.units.toLocaleString()}
${tooltipHtml}
</div>`;
}
// ──────────────── 데이터 로드 ────────────────
function loadUsageData() {
const startDate = document.getElementById('startDate').value;
@@ -879,7 +1141,7 @@
const sort = document.getElementById('sortSelect').value;
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
@@ -904,9 +1166,11 @@
document.getElementById('resultCount').textContent = `(${data.items.length}개)`;
renderTable();
// 선호 도매상 로드 (백그라운드)
loadPreferredVendors(data.items.map(i => i.drug_code));
} else {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${data.error}</div>
@@ -916,7 +1180,7 @@
})
.catch(err => {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">❌</div>
<div>데이터 로드 실패</div>
@@ -931,7 +1195,7 @@
if (usageData.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7">
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">💊</div>
<div>해당 기간 처방 내역이 없습니다</div>
@@ -957,7 +1221,7 @@
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}</span>
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}${item.patient_names ? ` <span class="patient-badge ${item.today_patients ? 'has-today' : ''}" title="${item.patient_count}명 사용${item.today_patients ? ' (오늘: ' + item.today_patients + ')' : ''}">👤${formatPatientNames(item.patient_names, item.today_patients)}</span>` : ''}</span>
</div>
</div>
</td>
@@ -966,6 +1230,8 @@
</td>
<td class="qty-cell" style="color:var(--text-secondary);">${item.prescription_count}건</td>
<td class="qty-cell ${qtyClass}">${item.total_dose}</td>
<td class="qty-cell" style="color:var(--accent-cyan);">${getOrderedQty(item.drug_code)}</td>
<td class="qty-cell" style="font-size:10px;">${getPreferredVendor(item.drug_code)}</td>
<td style="text-align:right;font-family:'JetBrains Mono',monospace;font-size:12px;">
${formatPrice(item.total_amount)}
</td>
@@ -1116,6 +1382,16 @@
gradient: 'linear-gradient(135deg, #d97706, #f59e0b)',
filterFn: (item) => item.supplier === '백제약품' || item.wholesaler === 'baekje',
getCode: (item) => item.baekje_code || item.drug_code
},
dongwon: {
id: 'dongwon',
name: '동원약품',
icon: '🏥',
logo: '/static/img/logo_dongwon.png',
color: '#22c55e',
gradient: 'linear-gradient(135deg, #16a34a, #22c55e)',
filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon',
getCode: (item) => item.dongwon_code || item.internal_code || item.drug_code
}
};
@@ -1156,44 +1432,135 @@
// 다중 도매상 선택을 위한 전역 변수
let pendingWholesalerItems = {};
let pendingOtherItems = [];
let wholesalerLimits = {}; // 도매상 한도 캐시
function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
async function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
pendingWholesalerItems = itemsByWholesaler;
pendingOtherItems = otherItems;
const modal = document.getElementById('multiWholesalerModal');
const body = document.getElementById('multiWholesalerBody');
// 로딩 표시
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted);">📊 한도 및 월 매출 조회 중...</div>';
modal.classList.add('show');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// 1. 도매상 한도 정보 가져오기
try {
const res = await fetch('/api/order/wholesaler/limits');
const data = await res.json();
if (data.success) {
data.limits.forEach(l => {
wholesalerLimits[l.wholesaler_id] = l;
});
}
} catch (e) {
console.warn('한도 조회 실패:', e);
}
// 2. 실제 월 매출 가져오기 (도매상 API 호출)
const wholesalerConfigs = WHOLESALER_ORDER.map(id => WHOLESALER_CONFIG[id]);
await Promise.all(wholesalerConfigs.map(async ws => {
try {
const salesRes = await fetch(`${ws.salesApi}?year=${year}&month=${month}`);
const salesData = await salesRes.json();
if (salesData.success && wholesalerLimits[ws.id]) {
// 실제 월 매출로 current_usage 업데이트
wholesalerLimits[ws.id].current_usage = salesData.total_amount || 0;
wholesalerLimits[ws.id].usage_percent = wholesalerLimits[ws.id].monthly_limit > 0
? Math.round((salesData.total_amount || 0) / wholesalerLimits[ws.id].monthly_limit * 1000) / 10
: 0;
wholesalerLimits[ws.id].remaining = wholesalerLimits[ws.id].monthly_limit - (salesData.total_amount || 0);
}
} catch (e) {
console.warn(`${ws.id} 월매출 조회 실패:`, e);
}
}));
const wsIds = Object.keys(itemsByWholesaler);
// 전체 총액 계산
let grandTotal = 0;
wsIds.forEach(wsId => {
itemsByWholesaler[wsId].forEach(item => {
grandTotal += (item.unit_price || 0) * item.qty;
});
});
let html = `
<div class="multi-ws-summary">
<p style="margin-bottom:16px;color:var(--text-secondary);">
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
</p>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<p style="color:var(--text-secondary);margin:0;">
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
</p>
<div style="font-size:18px;font-weight:700;color:var(--accent-emerald);font-family:'JetBrains Mono',monospace;">
${grandTotal > 0 ? '₩' + grandTotal.toLocaleString() : ''}
</div>
</div>
`;
// 각 도매상별 품목 표시
wsIds.forEach(wsId => {
const ws = WHOLESALERS[wsId];
const items = itemsByWholesaler[wsId];
const limit = wholesalerLimits[wsId];
// 도매상별 소계
const wsTotal = items.reduce((sum, item) => sum + (item.unit_price || 0) * item.qty, 0);
// 한도 정보 계산
let limitHtml = '';
if (limit) {
const afterOrder = limit.current_usage + wsTotal;
const afterPercent = (afterOrder / limit.monthly_limit * 100).toFixed(1);
const isOver = afterOrder > limit.monthly_limit;
const isWarning = afterPercent >= (limit.warning_threshold * 100);
limitHtml = `
<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;font-size:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span>월 한도</span>
<span style="font-family:'JetBrains Mono',monospace;">${(limit.monthly_limit/10000).toLocaleString()}만원</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span>이번달 사용</span>
<span style="font-family:'JetBrains Mono',monospace;">${(limit.current_usage/10000).toLocaleString()}만원 (${limit.usage_percent}%)</span>
</div>
<div style="display:flex;justify-content:space-between;color:${isOver ? 'var(--accent-red)' : isWarning ? 'var(--accent-amber)' : 'var(--accent-emerald)'};">
<span>주문 후</span>
<span style="font-family:'JetBrains Mono',monospace;font-weight:600;">
${(afterOrder/10000).toLocaleString()}만원 (${afterPercent}%)
${isOver ? ' ⚠️ 초과!' : isWarning ? ' ⚠️' : ' ✓'}
</span>
</div>
</div>
`;
}
html += `
<div class="multi-ws-card ${wsId}">
<div class="multi-ws-header">
<span class="multi-ws-icon">${ws.icon}</span>
<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;margin-right:8px;">
<span class="multi-ws-name">${ws.name}</span>
<span class="multi-ws-count">${items.length}개 품목</span>
${wsTotal > 0 ? `<span style="margin-left:auto;margin-right:12px;font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent-cyan);">₩${wsTotal.toLocaleString()}</span>` : ''}
<label class="multi-ws-checkbox">
<input type="checkbox" id="ws_check_${wsId}" checked>
<span>포함</span>
</label>
</div>
<div class="multi-ws-items">
${items.slice(0, 3).map(item => `
<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)</div>
`).join('')}
${items.slice(0, 3).map(item => {
const itemAmt = (item.unit_price || 0) * item.qty;
return `<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)${itemAmt > 0 ? ` <span style="color:var(--text-muted);">${itemAmt.toLocaleString()}원</span>` : ''}</div>`;
}).join('')}
${items.length > 3 ? `<div class="multi-ws-item more">... 외 ${items.length - 3}개</div>` : ''}
</div>
${limitHtml}
</div>
`;
});
@@ -1224,7 +1591,7 @@
}
// 선택된 도매상 전체 일괄 처리
async function executeAllWholesalers(dryRun = false) {
async function executeAllWholesalers(dryRun = false, cartOnly = false) {
const wsIds = Object.keys(pendingWholesalerItems);
// 체크된 도매상만 필터
@@ -1240,10 +1607,20 @@
// 버튼 비활성화
const btnTest = document.getElementById('btnMultiTest');
const btnCart = document.getElementById('btnMultiCart');
const btnReal = document.getElementById('btnMultiReal');
btnTest.disabled = true;
btnCart.disabled = true;
btnReal.disabled = true;
btnReal.textContent = '처리 중...';
// 진행 상태 표시
if (dryRun) {
btnTest.textContent = '처리 중...';
} else if (cartOnly) {
btnCart.textContent = '처리 중...';
} else {
btnReal.textContent = '처리 중...';
}
const allResults = [];
let totalSuccess = 0;
@@ -1272,7 +1649,7 @@
})),
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: dryRun,
cart_only: false // 장바구니 + 주문 확정까지
cart_only: cartOnly // true=장바구니만, false=즉시주문
};
const response = await fetch('/api/order/quick-submit', {
@@ -1311,12 +1688,15 @@
}
closeMultiWholesalerModal();
showMultiOrderResultModal(allResults, totalSuccess, totalFailed, dryRun);
showMultiOrderResultModal(allResults, totalSuccess, totalFailed, dryRun, cartOnly);
// 버튼 복원
btnTest.disabled = false;
btnCart.disabled = false;
btnReal.disabled = false;
btnReal.textContent = '📤 전체 주문 전송';
btnTest.textContent = '🧪 테스트';
btnCart.textContent = '🛒 장바구니만';
btnReal.textContent = '🚀 전체 즉시주문';
}
// 특정 품목만 클립보드 복사
@@ -1329,7 +1709,7 @@
}
// 다중 도매상 결과 모달
function showMultiOrderResultModal(results, totalSuccess, totalFailed, isDryRun) {
function showMultiOrderResultModal(results, totalSuccess, totalFailed, isDryRun, isCartOnly = false) {
const modal = document.getElementById('orderResultModal');
const content = document.getElementById('orderResultContent');
const header = modal.querySelector('.order-modal-header h3');
@@ -1339,7 +1719,7 @@
header.innerHTML = '📋 전체 주문 결과';
const statusEmoji = totalFailed === 0 ? '✅' : totalSuccess === 0 ? '❌' : '⚠️';
const modeText = isDryRun ? '[테스트]' : '';
const modeText = isDryRun ? '[테스트]' : isCartOnly ? '[장바구니]' : '';
let html = `
<div class="result-header ${totalFailed === 0 ? 'success' : 'partial'}">
@@ -1414,22 +1794,33 @@
const headerDiv = modal.querySelector('.order-modal-header');
// 도매상별 헤더 및 본문 텍스트 변경
document.getElementById('orderConfirmTitle').innerHTML = `${ws.icon} ${ws.name} 주문 확인`;
document.getElementById('orderConfirmTitle').innerHTML = `<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;vertical-align:middle;margin-right:8px;">${ws.name} 주문 확인`;
document.getElementById('orderConfirmWholesaler').textContent = ws.name;
headerDiv.style.background = ws.gradient;
let html = '';
let totalAmount = 0;
items.forEach((item, idx) => {
// 예상 금액 계산 (단가 × 수량)
const unitPrice = item.unit_price || item.price || 0;
const itemAmount = unitPrice * item.qty;
totalAmount += itemAmount;
html += `
<tr>
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.specification || '-'}</td>
<td class="mono">${item.qty}</td>
<td class="mono" style="text-align:center;">${item.qty}</td>
<td class="mono" style="text-align:right;">${itemAmount > 0 ? itemAmount.toLocaleString() + '원' : '-'}</td>
</tr>`;
});
tbody.innerHTML = html;
document.getElementById('orderConfirmCount').textContent = items.length;
document.getElementById('orderConfirmTotal').innerHTML = totalAmount > 0
? `${totalAmount.toLocaleString()}`
: '<span style="font-size:12px;color:var(--text-muted);">금액 미정</span>';
modal.classList.add('show');
}
@@ -1464,6 +1855,10 @@
showToast(`📤 ${item.product_name} 주문 중...`, 'info');
// 🔍 디버그: 장바구니 아이템 확인
console.log('[DEBUG] orderSingleItem - cart item:', JSON.stringify(item, null, 2));
console.log('[DEBUG] internal_code:', item.internal_code);
try {
const payload = {
wholesaler_id: wholesaler,
@@ -1504,7 +1899,7 @@
}
}
async function executeOrder(dryRun = true) {
async function executeOrder(dryRun = true, cartOnly = false) {
const wholesaler = currentOrderWholesaler || 'geoyoung';
// 해당 도매상 품목 필터
@@ -1518,11 +1913,20 @@
// 버튼 비활성화
const btnTest = document.getElementById('btnOrderTest');
const btnCart = document.getElementById('btnOrderCart');
const btnReal = document.getElementById('btnOrderReal');
btnTest.disabled = true;
btnCart.disabled = true;
btnReal.disabled = true;
btnTest.textContent = dryRun ? '처리 중...' : '🧪 테스트';
btnReal.textContent = !dryRun ? '처리 중...' : '🛒 장바구니 담기';
// 진행 상태 표시
if (dryRun) {
btnTest.textContent = '처리 중...';
} else if (cartOnly) {
btnCart.textContent = '처리 중...';
} else {
btnReal.textContent = '처리 중...';
}
try {
const payload = {
@@ -1540,7 +1944,7 @@
})),
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: dryRun,
cart_only: false // 장바구니 + 주문 확정까지
cart_only: cartOnly // true=장바구니만, false=즉시주문
};
// 타임아웃 설정
@@ -1572,9 +1976,11 @@
showToast(`❌ 오류: ${err.message}`, 'error');
} finally {
btnTest.disabled = false;
btnCart.disabled = false;
btnReal.disabled = false;
btnTest.textContent = '🧪 테스트';
btnReal.textContent = '📤 주문 전송';
btnCart.textContent = '🛒 장바구니만';
btnReal.textContent = '🚀 즉시주문';
}
}
@@ -1731,6 +2137,23 @@
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
// 환자 이름 포맷 (오늘 사용 환자 강조)
function formatPatientNames(allNames, todayNames) {
if (!allNames) return '';
if (!todayNames) return escapeHtml(allNames);
const todaySet = new Set(todayNames.split(', ').map(n => n.trim()));
const names = allNames.split(', ');
return names.map(name => {
const trimmed = name.trim();
if (todaySet.has(trimmed)) {
return `<strong class="today-patient">${escapeHtml(trimmed)}</strong>`;
}
return escapeHtml(trimmed);
}).join(', ');
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
@@ -1743,9 +2166,9 @@
if (e.key === 'Enter') loadUsageData();
});
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제) ────────────────
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제 + 동원) ────────────────
let currentWholesaleItem = null;
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [] };
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [], dongwon: [] };
function openWholesaleModal(idx) {
const item = usageData[idx];
@@ -1765,7 +2188,7 @@
document.getElementById('geoResultBody').innerHTML = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제)</div>
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제 + 동원)</div>
</div>`;
document.getElementById('geoSearchKeyword').style.display = 'none';
@@ -1781,22 +2204,24 @@
async function searchAllWholesalers(kdCode, productName) {
const resultBody = document.getElementById('geoResultBody');
// 도매상 동시 호출
const [geoResult, sooinResult, baekjeResult] = await Promise.all([
// 도매상 동시 호출
const [geoResult, sooinResult, baekjeResult, dongwonResult] = await Promise.all([
searchGeoyoungAPI(kdCode, productName),
searchSooinAPI(kdCode),
searchBaekjeAPI(kdCode)
searchBaekjeAPI(kdCode),
searchDongwonAPI(kdCode, productName)
]);
// 결과 저장
window.wholesaleItems = {
geoyoung: geoResult.items || [],
sooin: sooinResult.items || [],
baekje: baekjeResult.items || []
baekje: baekjeResult.items || [],
dongwon: dongwonResult.items || []
};
// 통합 렌더링
renderWholesaleResults(geoResult, sooinResult, baekjeResult);
renderWholesaleResults(geoResult, sooinResult, baekjeResult, dongwonResult);
}
async function searchGeoyoungAPI(kdCode, productName) {
@@ -1836,18 +2261,42 @@
return { success: false, error: err.message, items: [] };
}
}
async function searchDongwonAPI(kdCode, productName) {
try {
// 1차: KD코드(보험코드)로 검색 (searchType=0)
let response = await fetch(`/api/dongwon/stock?keyword=${encodeURIComponent(kdCode)}`);
let data = await response.json();
// 결과 없으면 제품명으로 재검색
if (data.success && data.count === 0 && productName) {
// 제품명 정제: "휴니즈레바미피드정_(0.1g/1정)" → "휴니즈레바미피드정"
let cleanName = productName.split('_')[0].split('(')[0].trim();
if (cleanName) {
response = await fetch(`/api/dongwon/stock?keyword=${encodeURIComponent(cleanName)}`);
data = await response.json();
}
}
return data;
} catch (err) {
return { success: false, error: err.message, items: [] };
}
}
function renderWholesaleResults(geoResult, sooinResult, baekjeResult) {
function renderWholesaleResults(geoResult, sooinResult, baekjeResult, dongwonResult) {
const resultBody = document.getElementById('geoResultBody');
const geoItems = geoResult.items || [];
const sooinItems = sooinResult.items || [];
const baekjeItems = (baekjeResult && baekjeResult.items) || [];
const dongwonItems = (dongwonResult && dongwonResult.items) || [];
// 재고 있는 것 먼저 정렬
geoItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
sooinItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
baekjeItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
dongwonItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
let html = '';
@@ -1960,6 +2409,44 @@
}
html += '</div>';
// ═══════ 동원약품 섹션 ═══════
html += `<div class="ws-section">
<div class="ws-header dongwon">
<span class="ws-logo">🏥</span>
<span class="ws-name">동원약품</span>
<span class="ws-count">${dongwonItems.length}건</span>
</div>`;
if (dongwonItems.length > 0) {
html += `<table class="geo-table">
<thead><tr><th>제품명</th><th>규격</th><th>단가</th><th>재고</th><th></th></tr></thead>
<tbody>`;
dongwonItems.forEach((item, idx) => {
const hasStock = item.stock > 0;
// 동원: code=KD코드(보험코드), internal_code=내부코드(주문용)
const displayCode = item.code || item.internal_code || '';
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.name)}</span>
<span class="geo-code">${displayCode} · ${item.manufacturer || ''}</span>
</div>
</td>
<td class="geo-spec">${item.spec || '-'}</td>
<td class="geo-price">${item.price ? item.price.toLocaleString() + '원' : '-'}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>${hasStock ? `<button class="geo-add-btn dongwon" onclick="addToCartFromWholesale('dongwon', ${idx})">담기</button>` : ''}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += `<div class="ws-empty">📭 검색 결과 없음</div>`;
}
html += '</div>';
resultBody.innerHTML = html;
}
@@ -1977,7 +2464,7 @@
const needed = currentWholesaleItem.total_dose;
const suggestedQty = Math.ceil(needed / specQty);
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' };
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품', dongwon: '동원약품' };
const supplierName = supplierNames[wholesaler] || wholesaler;
const productName = wholesaler === 'geoyoung' ? item.product_name : item.name;
@@ -1986,6 +2473,7 @@
if (!qty || isNaN(qty)) return;
// 장바구니에 추가
const unitPrice = item.price || item.unit_price || 0;
const cartItem = {
drug_code: currentWholesaleItem.drug_code,
product_name: productName,
@@ -1996,9 +2484,16 @@
internal_code: item.internal_code,
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
sooin_code: wholesaler === 'sooin' ? item.code : null,
baekje_code: wholesaler === 'baekje' ? item.internal_code : null
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null, // 동원: 내부코드로 주문
unit_price: unitPrice // 💰 단가 추가
};
// 🔍 디버그: 장바구니 추가 시 internal_code 확인
console.log('[DEBUG] addToCartFromWholesale');
console.log('[DEBUG] wholesaler item:', JSON.stringify(item, null, 2));
console.log('[DEBUG] cartItem internal_code:', cartItem.internal_code);
// 기존 항목 체크 (같은 도매상 + 같은 규격)
const existing = cart.find(c =>
c.drug_code === currentWholesaleItem.drug_code &&
@@ -2235,6 +2730,12 @@
.geo-add-btn.baekje:hover {
background: #d97706;
}
.geo-add-btn.dongwon {
background: #22c55e;
}
.geo-add-btn.dongwon:hover {
background: #16a34a;
}
.geo-price {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
@@ -2269,6 +2770,10 @@
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(217, 119, 6, 0.1));
border-left: 3px solid var(--accent-amber);
}
.ws-header.dongwon {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.1));
border-left: 3px solid #22c55e;
}
.ws-logo {
width: 24px;
height: 24px;
@@ -2471,6 +2976,9 @@
.multi-ws-card.baekje {
border-left: 3px solid var(--accent-amber);
}
.multi-ws-card.dongwon {
border-left: 3px solid #22c55e;
}
.multi-ws-card.other {
border-left: 3px solid var(--text-muted);
opacity: 0.7;
@@ -2527,26 +3035,32 @@
<!-- 주문 확인 모달 -->
<div class="order-modal" id="orderConfirmModal">
<div class="order-modal-content">
<div class="order-modal-content" style="max-width:600px;">
<div class="order-modal-header">
<h3 id="orderConfirmTitle">🏭 지오영 주문 확인</h3>
<button class="order-close" onclick="closeOrderConfirmModal()"></button>
</div>
<div class="order-modal-body">
<p style="margin-bottom:12px;color:var(--text-secondary);">
<span id="orderConfirmCount">0</span>개 품목을 <span id="orderConfirmWholesaler">지오영</span> 장바구니에 담습니다.
</p>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<p style="color:var(--text-secondary);margin:0;">
<span id="orderConfirmCount">0</span>개 품목을 <span id="orderConfirmWholesaler">지오영</span> 장바구니에 담습니다.
</p>
<div id="orderConfirmTotal" style="font-size:18px;font-weight:700;color:var(--accent-cyan);font-family:'JetBrains Mono',monospace;">
₩0
</div>
</div>
<p style="margin-bottom:12px;font-size:12px;color:var(--accent-amber);">
⚠️ 장바구니 담기만 진행됩니다. 도매상 사이트에서 최종 확정이 필요합니다.
</p>
<table class="order-confirm-table">
<thead><tr><th>품목명</th><th>규격</th><th>수량</th></tr></thead>
<thead><tr><th>품목명</th><th>규격</th><th>수량</th><th style="text-align:right;">예상금액</th></tr></thead>
<tbody id="orderConfirmBody"></tbody>
</table>
</div>
<div class="order-modal-footer">
<button class="btn-order-test" id="btnOrderTest" onclick="executeOrder(true)">🧪 테스트</button>
<button class="btn-order-real" id="btnOrderReal" onclick="executeOrder(false)">📤 주문 전송</button>
<div class="order-modal-footer" style="gap:8px;">
<button class="btn-order-test" id="btnOrderTest" onclick="executeOrder(true, false)">🧪 테스트</button>
<button class="btn-order-cart" id="btnOrderCart" onclick="executeOrder(false, true)" style="background:linear-gradient(135deg, #0891b2, #06b6d4);">🛒 장바구니만</button>
<button class="btn-order-real" id="btnOrderReal" onclick="executeOrder(false, false)">🚀 즉시주문</button>
</div>
</div>
</div>
@@ -2568,16 +3082,17 @@
<!-- 다중 도매상 선택 모달 -->
<div class="order-modal" id="multiWholesalerModal">
<div class="order-modal-content" style="max-width:550px;">
<div class="order-modal-content" style="max-width:650px;">
<div class="order-modal-header" style="background:linear-gradient(135deg, #059669, #10b981);">
<h3>🛒 전체 도매상 주문</h3>
<button class="order-close" onclick="closeMultiWholesalerModal()"></button>
</div>
<div class="order-modal-body" id="multiWholesalerBody">
</div>
<div class="order-modal-footer">
<button class="btn-order-test" id="btnMultiTest" onclick="executeAllWholesalers(true)">🧪 테스트</button>
<button class="btn-order-real" id="btnMultiReal" onclick="executeAllWholesalers(false)">📤 전체 주문 전송</button>
<div class="order-modal-footer" style="gap:8px;">
<button class="btn-order-test" id="btnMultiTest" onclick="executeAllWholesalers(true, false)">🧪 테스트</button>
<button class="btn-order-cart" id="btnMultiCart" onclick="executeAllWholesalers(false, true)" style="background:linear-gradient(135deg, #0891b2, #06b6d4);">🛒 장바구니만</button>
<button class="btn-order-real" id="btnMultiReal" onclick="executeAllWholesalers(false, false)">🚀 전체 즉시주문</button>
</div>
</div>
</div>
@@ -2763,9 +3278,16 @@
color: '#a855f7',
balanceApi: '/api/sooin/balance',
salesApi: '/api/sooin/monthly-sales'
},
dongwon: {
id: 'dongwon', name: '동원약품', icon: '🏥',
logo: '/static/img/logo_dongwon.png',
color: '#22c55e',
balanceApi: '/api/dongwon/balance',
salesApi: '/api/dongwon/monthly-orders'
}
};
const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin'];
const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin', 'dongwon'];
async function loadBalances() {
const content = document.getElementById('balanceContent');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
<!-- 건조시럽 환산계수 모달 -->
<div id="drysyrupModal" class="drysyrup-modal">
<div class="drysyrup-modal-content">
<div class="drysyrup-modal-header">
<h3>🧪 건조시럽 환산계수</h3>
<button class="drysyrup-modal-close" onclick="closeDrysyrupModal()">&times;</button>
</div>
<div class="drysyrup-modal-body">
<div class="drysyrup-form">
<div class="drysyrup-form-row">
<label>성분코드</label>
<input type="text" id="drysyrup_sung_code" readonly class="readonly">
</div>
<div class="drysyrup-form-row">
<label>성분명</label>
<input type="text" id="drysyrup_ingredient_name" placeholder="예: 아목시실린">
</div>
<div class="drysyrup-form-row">
<label>제품명</label>
<input type="text" id="drysyrup_product_name" placeholder="예: 오구멘틴듀오시럽">
</div>
<div class="drysyrup-form-row">
<label>환산계수 (g/ml)</label>
<input type="number" id="drysyrup_conversion_factor" step="0.001" placeholder="예: 0.11">
<span class="hint">ml × 환산계수 = g</span>
</div>
<div class="drysyrup-form-row">
<label>조제 후 함량</label>
<input type="text" id="drysyrup_post_prep_amount" placeholder="예: 4.8mg/ml">
</div>
<div class="drysyrup-form-row">
<label>분말 중 주성분량</label>
<input type="text" id="drysyrup_main_ingredient_amt" placeholder="예: 0.787g/100g">
</div>
<div class="drysyrup-form-row">
<label>보관조건</label>
<select id="drysyrup_storage_conditions">
<option value="실온">실온</option>
<option value="냉장">냉장</option>
</select>
</div>
<div class="drysyrup-form-row">
<label>조제 후 유효기간</label>
<input type="text" id="drysyrup_expiration_date" placeholder="예: 15일">
</div>
</div>
</div>
<div class="drysyrup-modal-footer">
<span id="drysyrup_status" class="status-text"></span>
<div class="button-group">
<button class="btn-cancel" onclick="closeDrysyrupModal()">취소</button>
<button class="btn-save" onclick="saveDrysyrup()">💾 저장</button>
</div>
</div>
</div>
</div>

View File

@@ -254,6 +254,70 @@
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* 전화번호 뱃지 */
.detail-header .phone-badge {
cursor: pointer;
margin-left: 10px;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.85rem;
transition: all 0.2s;
}
.detail-header .phone-badge.has-phone {
background: rgba(255,255,255,0.25);
color: #fff;
}
.detail-header .phone-badge.no-phone {
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.7);
border: 1px dashed rgba(255,255,255,0.4);
}
.detail-header .phone-badge:hover {
background: rgba(255,255,255,0.35);
transform: scale(1.05);
}
/* 키오스크 스타일 전화번호 입력 */
.phone-input-kiosk {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 20px 0;
}
.phone-input-kiosk .phone-prefix {
font-size: 28px;
font-weight: 700;
color: #4c1d95;
background: #f3e8ff;
padding: 12px 16px;
border-radius: 10px;
}
.phone-input-kiosk .phone-hyphen {
font-size: 28px;
font-weight: 300;
color: #9ca3af;
}
.phone-input-kiosk input {
width: 80px;
font-size: 28px;
font-weight: 600;
text-align: center;
padding: 12px 8px;
border: 2px solid #e2e8f0;
border-radius: 10px;
transition: all 0.2s;
}
.phone-input-kiosk input:focus {
border-color: #f59e0b;
outline: none;
box-shadow: 0 0 0 3px rgba(245,158,11,0.2);
}
.phone-input-kiosk input::placeholder {
color: #d1d5db;
font-weight: 400;
}
.detail-header .cusetc-inline .cusetc-label {
font-weight: 600;
margin-right: 6px;
@@ -422,6 +486,34 @@
.paai-feedback button:hover { border-color: #10b981; }
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
.paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; }
.paai-reanalyze-btn {
background: linear-gradient(135deg, #3b82f6, #2563eb) !important;
color: #fff !important;
border: none !important;
margin-left: 10px;
}
.paai-reanalyze-btn:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
transform: scale(1.02);
}
.paai-reanalyze-btn:disabled {
background: #9ca3af !important;
cursor: not-allowed;
}
.paai-reprint-btn {
background: linear-gradient(135deg, #10b981, #059669) !important;
color: #fff !important;
border: none !important;
margin-left: 10px;
}
.paai-reprint-btn:hover {
background: linear-gradient(135deg, #059669, #047857) !important;
transform: scale(1.02);
}
.paai-reprint-btn:disabled {
background: #9ca3af !important;
cursor: not-allowed;
}
.paai-timing {
font-size: 0.8rem;
color: #9ca3af;
@@ -878,6 +970,13 @@
vertical-align: middle;
}
.med-table tr:hover { background: #f8fafc; }
.med-num {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
background: #7c3aed; color: #fff;
font-size: 0.7rem; font-weight: 700;
margin-right: 6px; flex-shrink: 0;
}
.med-name { font-weight: 600; color: #1e293b; }
.med-code { font-size: 0.75rem; color: #94a3b8; }
.med-dosage {
@@ -888,6 +987,7 @@
font-weight: 600;
font-size: 0.9rem;
display: inline-block;
white-space: nowrap;
}
.med-form {
background: #fef3c7;
@@ -1154,6 +1254,133 @@
align-items: center;
gap: 4px;
}
/* 건조시럽 환산계수 모달 */
.drysyrup-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1100;
overflow-y: auto;
}
.drysyrup-modal.show { display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; }
.drysyrup-modal-content {
width: 100%;
max-width: 500px;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.drysyrup-modal-header {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 18px 25px;
display: flex;
justify-content: space-between;
align-items: center;
}
.drysyrup-modal-header h3 { margin: 0; font-size: 1.2rem; }
.drysyrup-modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
font-size: 1.5rem;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
transition: background 0.2s;
}
.drysyrup-modal-close:hover { background: rgba(255,255,255,0.3); }
.drysyrup-modal-body { padding: 25px; }
.drysyrup-form { display: flex; flex-direction: column; gap: 16px; }
.drysyrup-form-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.drysyrup-form-row label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
.drysyrup-form-row input,
.drysyrup-form-row select {
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.drysyrup-form-row input:focus,
.drysyrup-form-row select:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.drysyrup-form-row input.readonly {
background: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
.drysyrup-form-row .hint {
font-size: 0.75rem;
color: #9ca3af;
}
.drysyrup-modal-footer {
background: #f9fafb;
padding: 16px 25px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.drysyrup-modal-footer .status-text {
font-size: 0.85rem;
color: #6b7280;
}
.drysyrup-modal-footer .button-group {
display: flex;
gap: 10px;
}
.drysyrup-modal-footer .btn-cancel {
padding: 10px 20px;
background: #e5e7eb;
color: #374151;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.drysyrup-modal-footer .btn-cancel:hover { background: #d1d5db; }
.drysyrup-modal-footer .btn-save {
padding: 10px 24px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.drysyrup-modal-footer .btn-save:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
/* 약품명 더블클릭 힌트 */
.med-name[data-sung-code]:not([data-sung-code=""]) {
cursor: pointer;
}
.med-name[data-sung-code]:not([data-sung-code=""]):hover {
text-decoration: underline;
text-decoration-style: dotted;
}
</style>
</head>
<body>
@@ -1257,6 +1484,8 @@
<span>도움이 되셨나요?</span>
<button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button>
<button onclick="sendPaaiFeedback(false)" id="paaiNotUseful">👎 아니요</button>
<button onclick="reanalyzePaai()" id="paaiReanalyze" class="paai-reanalyze-btn">🔄 재분석</button>
<button onclick="reprintPaai()" id="paaiReprint" class="paai-reprint-btn">🖨️ 재인쇄</button>
</div>
<div class="paai-timing" id="paaiTiming"></div>
</div>
@@ -1305,6 +1534,30 @@
</div>
</div>
<!-- 전화번호 모달 (키오스크 스타일) -->
<div class="cusetc-modal" id="phoneModal">
<div class="cusetc-modal-content" style="max-width:360px;">
<div class="cusetc-modal-header">
<h3>📞 전화번호 입력</h3>
<button class="cusetc-modal-close" onclick="closePhoneModal()">×</button>
</div>
<div class="cusetc-modal-body">
<div class="cusetc-patient-info" id="phonePatientInfo"></div>
<div class="phone-input-kiosk">
<span class="phone-prefix">010</span>
<span class="phone-hyphen">-</span>
<input type="tel" id="phoneMid" maxlength="4" placeholder="0000" inputmode="numeric" pattern="[0-9]*">
<span class="phone-hyphen">-</span>
<input type="tel" id="phoneLast" maxlength="4" placeholder="0000" inputmode="numeric" pattern="[0-9]*">
</div>
</div>
<div class="cusetc-modal-footer">
<button class="cusetc-btn-cancel" onclick="closePhoneModal()">취소</button>
<button class="cusetc-btn-save" onclick="savePhone()">💾 저장</button>
</div>
</div>
</div>
<!-- OTC 구매 이력 모달 -->
<div class="otc-modal" id="otcModal">
<div class="otc-modal-content">
@@ -1493,19 +1746,28 @@
pre_serial: prescriptionId,
cus_code: data.patient.cus_code || data.patient.code,
name: data.patient.name,
age: data.patient.age,
gender: data.patient.gender,
st1: data.disease_info?.code_1 || '',
st1_name: data.disease_info?.name_1 || '',
st2: data.disease_info?.code_2 || '',
st2_name: data.disease_info?.name_2 || '',
medications: data.medications || [],
cusetc: data.patient.cusetc || '' // 특이사항
cusetc: data.patient.cusetc || '', // 특이사항
phone: data.patient.phone || '' // 전화번호
};
// 헤더 업데이트
document.getElementById('detailHeader').style.display = 'block';
document.getElementById('detailName').textContent = data.patient.name || '이름없음';
document.getElementById('detailInfo').textContent =
`${data.patient.age || '-'}세 / ${data.patient.gender || '-'} / ${data.patient.birthdate || '-'}`;
// 나이/성별 + 전화번호 표시
const phone = data.patient.phone || '';
const phoneDisplay = phone
? `<span class="phone-badge has-phone" onclick="event.stopPropagation();openPhoneModal()" title="${phone}">📞 ${phone}</span>`
: `<span class="phone-badge no-phone" onclick="event.stopPropagation();openPhoneModal()">📞 전화번호 추가</span>`;
document.getElementById('detailInfo').innerHTML =
`${data.patient.age || '-'}세 / ${data.patient.gender || '-'} ${phoneDisplay}`;
// 질병 정보 표시 (각각 별도 뱃지)
let diseaseHtml = '';
@@ -1548,19 +1810,18 @@
<tr>
<th style="width:40px;"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
<th>약품명</th>
<th>제형</th>
<th>용량</th>
<th>횟수</th>
<th>일수</th>
</tr>
</thead>
<tbody>
${data.medications.map(m => `
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
${data.medications.map((m, i) => `
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}" data-med-name="${escapeHtml(m.med_name || m.medication_code)}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
<td>
<div class="med-name">
${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
<span class="med-num">${i+1}</span>${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
@@ -1574,8 +1835,7 @@
</details>
` : ''}
</td>
<td>${m.formulation ? `<span class="med-form">${m.formulation}</span>` : '-'}</td>
<td><span class="med-dosage">${m.dosage || '-'}</span></td>
<td><span class="med-dosage">${m.dosage || '-'}${m.unit || ''}</span></td>
<td>${m.frequency || '-'}회</td>
<td>${m.duration || '-'}일</td>
</tr>
@@ -1666,10 +1926,10 @@
</tr>
</thead>
<tbody>
${h.medications.map(m => `
${h.medications.map((m, i) => `
<tr>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.7rem;color:#94a3b8;">${escapeHtml(m.add_info)}</div>` : ''}
</td>
<td>${m.dosage || '-'}</td>
@@ -1790,7 +2050,7 @@
</tr>
</thead>
<tbody>
${compared.map(m => {
${compared.map((m, i) => {
const rowClass = 'row-' + m.status;
const statusLabel = {
added: '<span class="med-status status-added">🆕 추가</span>',
@@ -1814,10 +2074,10 @@
const disabled = m.status === 'removed' ? 'disabled' : '';
return `
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}">
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}">
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
</td>
@@ -1851,11 +2111,11 @@
</tr>
</thead>
<tbody>
${currentMedications.map(m => `
<tr data-add-info="${escapeHtml(m.add_info || '')}">
${currentMedications.map((m, i) => `
<tr data-add-info="${escapeHtml(m.add_info || '')}" data-unit="${m.unit || '정'}" data-sung-code="${m.sung_code || ''}">
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
</td>
@@ -2059,6 +2319,106 @@
}
}
// ─────────────────────────────────────────────────────────────
// 전화번호 모달 함수들
// ─────────────────────────────────────────────────────────────
window.openPhoneModal = function() {
if (!currentPrescriptionData) {
alert('❌ 먼저 환자를 선택하세요.');
return;
}
const modal = document.getElementById('phoneModal');
const patientInfo = document.getElementById('phonePatientInfo');
const phoneMid = document.getElementById('phoneMid');
const phoneLast = document.getElementById('phoneLast');
patientInfo.innerHTML = `
<strong>${currentPrescriptionData.name || '환자'}</strong>
<span style="margin-left: 10px; color: #6b7280;">고객코드: ${currentPrescriptionData.cus_code || '-'}</span>
`;
// 기존 전화번호 파싱 (010-1234-5678 또는 01012345678)
const existingPhone = currentPrescriptionData.phone || '';
const digits = existingPhone.replace(/\D/g, '');
if (digits.length >= 10) {
phoneMid.value = digits.slice(3, 7);
phoneLast.value = digits.slice(7, 11);
} else {
phoneMid.value = '';
phoneLast.value = '';
}
modal.style.display = 'flex';
phoneMid.focus();
// 4자리 입력 시 자동 포커스 이동
phoneMid.oninput = function() {
this.value = this.value.replace(/\D/g, '');
if (this.value.length >= 4) phoneLast.focus();
};
phoneLast.oninput = function() {
this.value = this.value.replace(/\D/g, '');
};
};
window.closePhoneModal = function() {
document.getElementById('phoneModal').style.display = 'none';
};
window.savePhone = async function() {
if (!currentPrescriptionData || !currentPrescriptionData.cus_code) {
alert('❌ 환자 정보가 없습니다.');
return;
}
const phoneMid = document.getElementById('phoneMid').value.trim();
const phoneLast = document.getElementById('phoneLast').value.trim();
// 유효성 검사
if (phoneMid.length !== 4 || phoneLast.length !== 4) {
alert('❌ 전화번호 8자리를 모두 입력해주세요.');
return;
}
// 010-XXXX-XXXX 형식으로 조합
const newPhone = `010-${phoneMid}-${phoneLast}`;
const cusCode = currentPrescriptionData.cus_code;
try {
const res = await fetch(`/api/members/${cusCode}/phone`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: newPhone })
});
const data = await res.json();
if (data.success) {
currentPrescriptionData.phone = newPhone;
updatePhoneBadge(newPhone);
closePhoneModal();
showPaaiToast(currentPrescriptionData.name, '전화번호가 저장되었습니다.', 'completed');
} else {
alert('❌ ' + (data.error || '저장 실패'));
}
} catch (err) {
alert('❌ 오류: ' + err.message);
}
};
function updatePhoneBadge(phone) {
const detailInfo = document.getElementById('detailInfo');
if (!detailInfo || !currentPrescriptionData) return;
const phoneDisplay = phone
? `<span class="phone-badge has-phone" onclick="event.stopPropagation();openPhoneModal()" title="${phone}">📞 ${phone}</span>`
: `<span class="phone-badge no-phone" onclick="event.stopPropagation();openPhoneModal()">📞 전화번호 추가</span>`;
detailInfo.innerHTML =
`${currentPrescriptionData.age || '-'}세 / ${currentPrescriptionData.gender || '-'} ${phoneDisplay}`;
}
// ─────────────────────────────────────────────────────────────
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
// ─────────────────────────────────────────────────────────────
@@ -2344,6 +2704,146 @@
triggerPaaiAnalysis();
}
// 🖨️ 재인쇄 함수
async function reprintPaai() {
if (!currentPrescriptionData) return;
const btn = document.getElementById('paaiReprint');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '🖨️ 인쇄 중...';
try {
const response = await fetch('/api/paai/reprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pre_serial: currentPrescriptionData.pre_serial
})
});
const result = await response.json();
if (result.success) {
btn.textContent = '✅ 인쇄 완료!';
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
} else {
btn.textContent = '❌ 실패';
alert('인쇄 실패: ' + (result.error || '알 수 없는 오류'));
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
}
} catch (error) {
btn.textContent = '❌ 오류';
alert('인쇄 오류: ' + error.message);
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
}, 2000);
}
}
// 🔄 재분석 함수 - 캐시 무시하고 새로 분석
async function reanalyzePaai() {
if (!currentPrescriptionData) return;
const btn = document.getElementById('paaiReanalyze');
const body = document.getElementById('paaiBody');
const footer = document.getElementById('paaiFooter');
// 버튼 비활성화
btn.disabled = true;
btn.textContent = '⏳ 분석 중...';
// 로딩 표시
body.innerHTML = `
<div class="paai-loading">
<div class="spinner"></div>
<div>AI 재분석 중...</div>
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">캐시 무시하고 새로 분석합니다</div>
</div>
`;
footer.style.display = 'none';
const preSerial = currentPrescriptionData.pre_serial;
// 캐시 삭제
delete paaiResultCache[preSerial];
try {
// triggerPaaiAnalysis와 동일한 형식으로 데이터 구성
const requestData = {
pre_serial: preSerial,
cus_code: currentPrescriptionData.cus_code,
patient_name: currentPrescriptionData.name || '환자',
patient_note: currentPrescriptionData.cusetc || '',
disease_info: {
code_1: currentPrescriptionData.st1 || '',
name_1: currentPrescriptionData.st1_name || '',
code_2: currentPrescriptionData.st2 || '',
name_2: currentPrescriptionData.st2_name || ''
},
current_medications: (currentPrescriptionData.medications || []).map(med => ({
code: med.medication_code,
name: med.med_name,
dosage: med.dosage,
frequency: med.frequency,
days: med.duration
})),
previous_medications: (currentPrescriptionData.previous_medications || []).map(med => ({
code: med.medication_code,
name: med.med_name,
dosage: med.dosage,
frequency: med.frequency,
days: med.duration
})),
otc_history: otcData ? {
visit_count: otcData.summary?.total_visits || 0,
frequent_items: otcData.summary?.frequent_items || [],
purchases: otcData.purchases || []
} : {}
};
const response = await fetch('/pmr/api/paai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
const result = await response.json();
if (result.success) {
// 새 결과 캐시
paaiResultCache[preSerial] = { result, cached: false };
currentPaaiLogId = result.log_id;
currentPaaiResponse = JSON.stringify(result.analysis || {});
displayPaaiResult(result);
// 성공 표시
btn.textContent = '✅ 완료!';
setTimeout(() => {
btn.textContent = '🔄 재분석';
btn.disabled = false;
}, 2000);
} else {
body.innerHTML = `<div style="color:#ef4444;padding:20px;">❌ 재분석 실패: ${result.error}</div>`;
btn.textContent = '🔄 재분석';
btn.disabled = false;
}
} catch (error) {
body.innerHTML = `<div style="color:#ef4444;padding:20px;">❌ 오류: ${error.message}</div>`;
btn.textContent = '🔄 재분석';
btn.disabled = false;
}
footer.style.display = 'flex';
}
function displayPaaiResult(result) {
const body = document.getElementById('paaiBody');
const footer = document.getElementById('paaiFooter');
@@ -2595,20 +3095,25 @@
const tr = checkbox.closest('tr');
const cells = tr.querySelectorAll('td');
// 약품명: 두 번째 셀의 .med-name
const medName = tr.querySelector('.med-name')?.textContent?.trim() || '';
// 약품명: data-med-name 속성에서 (번호/뱃지 제외된 순수 약품명)
const medName = tr.dataset.medName || '';
const addInfo = tr.dataset.addInfo || '';
// 용량: 번째 셀 (index 3)
const dosageText = cells[3]?.textContent?.replace(/[^0-9.]/g, '') || '0';
// 용량: 번째 셀 (index 2) - 제형 컬럼 제거됨
const dosageText = cells[2]?.textContent?.replace(/[^0-9.]/g, '') || '0';
const dosage = parseFloat(dosageText) || 0;
// 횟수: 다섯 번째 셀 (index 4)
const freqText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
// 횟수: 번째 셀 (index 3)
const freqText = cells[3]?.textContent?.replace(/[^0-9]/g, '') || '0';
const frequency = parseInt(freqText) || 0;
// 일수: 섯 번째 셀 (index 5)
const durText = cells[5]?.textContent?.replace(/[^0-9]/g, '') || '0';
// 일수: 섯 번째 셀 (index 4)
const durText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
const duration = parseInt(durText) || 0;
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration });
// 단위: data-unit 속성에서 가져오기 (SUNG_CODE 기반 자동 판별)
const unit = tr.dataset.unit || '정';
// 성분코드: 환산계수 조회용
const sungCode = tr.dataset.sungCode || '';
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration, unit, sungCode });
try {
const res = await fetch('/pmr/api/label/preview', {
@@ -2621,7 +3126,8 @@
dosage: dosage,
frequency: frequency,
duration: duration,
unit: '정'
unit: unit,
sung_code: sungCode
})
});
const data = await res.json();
@@ -2649,14 +3155,76 @@
document.getElementById('previewModal').style.display = 'none';
}
// 라벨 인쇄 (TODO: 구현)
function printLabels() {
const selected = Array.from(document.querySelectorAll('.med-check:checked')).map(c => c.dataset.code);
if (selected.length === 0) {
// 라벨 인쇄 (Brother QL 프린터)
async function printLabels() {
const checkboxes = document.querySelectorAll('.med-check:checked');
if (checkboxes.length === 0) {
alert('인쇄할 약품을 선택하세요');
return;
}
alert(`선택된 약품 ${selected.length}개 인쇄 기능은 추후 구현 예정입니다.\n\n${selected.join('\n')}`);
const patientName = document.getElementById('detailName')?.textContent?.trim() || '';
let printedCount = 0;
let failedCount = 0;
for (const checkbox of checkboxes) {
const tr = checkbox.closest('tr');
if (!tr) continue;
const cells = tr.querySelectorAll('td');
const medName = tr.dataset.medName || cells[1]?.querySelector('.med-name')?.textContent?.replace(/^\d+/, '').trim() || '';
const addInfo = tr.dataset.addInfo || '';
const sungCode = tr.dataset.sungCode || '';
const unit = tr.dataset.unit || '정';
// 용량 파싱 (1회 투약량)
const doseText = cells[2]?.textContent || '0';
const dosage = parseFloat(doseText.replace(/[^0-9.]/g, '')) || 0;
// 횟수 파싱
const freqText = cells[3]?.textContent || '0';
const frequency = parseInt(freqText.replace(/[^0-9]/g, '')) || 0;
// 일수 파싱
const durText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
const duration = parseInt(durText) || 0;
try {
const res = await fetch('/pmr/api/label/print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
patient_name: patientName,
med_name: medName,
add_info: addInfo,
dosage: dosage,
frequency: frequency,
duration: duration,
unit: unit,
sung_code: sungCode,
printer: '168' // 기본: QL-810W
})
});
const data = await res.json();
if (data.success) {
printedCount++;
console.log('Print success:', medName);
} else {
failedCount++;
console.error('Print failed:', medName, data.error);
}
} catch (err) {
failedCount++;
console.error('Print error:', medName, err);
}
}
if (failedCount === 0) {
alert(`${printedCount}개 라벨 인쇄 완료!`);
} else {
alert(`⚠️ 인쇄 완료: ${printedCount}\n실패: ${failedCount}`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -3069,5 +3637,190 @@
})();
</script>
<!-- 건조시럽 환산계수 모달 -->
<div id="drysyrupModal" class="drysyrup-modal">
<div class="drysyrup-modal-content">
<div class="drysyrup-modal-header">
<h3>🧪 건조시럽 환산계수</h3>
<button class="drysyrup-modal-close" onclick="closeDrysyrupModal()">&times;</button>
</div>
<div class="drysyrup-modal-body">
<div class="drysyrup-form">
<div class="drysyrup-form-row">
<label>성분코드</label>
<input type="text" id="drysyrup_sung_code" readonly class="readonly">
</div>
<div class="drysyrup-form-row">
<label>성분명</label>
<input type="text" id="drysyrup_ingredient_name" placeholder="예: 아목시실린">
</div>
<div class="drysyrup-form-row">
<label>제품명 <span style="font-size:0.75rem;color:#6b7280;">(MSSQL 원본)</span></label>
<input type="text" id="drysyrup_product_name" placeholder="예: 오구멘틴듀오시럽" readonly style="background:#f3f4f6;cursor:not-allowed;">
</div>
<div class="drysyrup-form-row">
<label>환산계수 (g/ml)</label>
<input type="number" id="drysyrup_conversion_factor" step="0.001" placeholder="예: 0.11">
<span class="hint">ml × 환산계수 = g</span>
</div>
<div class="drysyrup-form-row">
<label>조제 후 함량</label>
<input type="text" id="drysyrup_post_prep_amount" placeholder="예: 4.8mg/ml">
</div>
<div class="drysyrup-form-row">
<label>분말 중 주성분량</label>
<input type="text" id="drysyrup_main_ingredient_amt" placeholder="예: 0.787g/100g">
</div>
<div class="drysyrup-form-row">
<label>보관조건</label>
<select id="drysyrup_storage_conditions">
<option value="실온">실온</option>
<option value="냉장">냉장</option>
</select>
</div>
<div class="drysyrup-form-row">
<label>조제 후 유효기간</label>
<input type="text" id="drysyrup_expiration_date" placeholder="예: 15일">
</div>
</div>
</div>
<div class="drysyrup-modal-footer">
<span id="drysyrup_status" class="status-text"></span>
<div class="button-group">
<button class="btn-cancel" onclick="closeDrysyrupModal()">취소</button>
<button class="btn-save" onclick="saveDrysyrup()">💾 저장</button>
</div>
</div>
</div>
</div>
<script>
// ==================== 건조시럽 환산계수 모달 ====================
let drysyrupIsNew = false;
// 약품명 더블클릭 이벤트 등록
document.addEventListener('DOMContentLoaded', function() {
// 동적으로 생성되는 요소를 위해 이벤트 위임 사용
document.addEventListener('dblclick', function(e) {
// 약품 행(tr)에서 더블클릭 감지
const row = e.target.closest('tr[data-sung-code]');
if (row) {
const sungCode = row.dataset.sungCode;
const medName = row.dataset.medName || '';
if (sungCode) {
openDrysyrupModal(sungCode, medName);
}
}
});
});
// 모달 열기
async function openDrysyrupModal(sungCode, medName) {
const modal = document.getElementById('drysyrupModal');
const statusEl = document.getElementById('drysyrup_status');
// 폼 초기화
document.getElementById('drysyrup_sung_code').value = sungCode;
document.getElementById('drysyrup_ingredient_name').value = '';
document.getElementById('drysyrup_product_name').value = medName || '';
document.getElementById('drysyrup_conversion_factor').value = '';
document.getElementById('drysyrup_post_prep_amount').value = '';
document.getElementById('drysyrup_main_ingredient_amt').value = '';
document.getElementById('drysyrup_storage_conditions').value = '실온';
document.getElementById('drysyrup_expiration_date').value = '';
statusEl.textContent = '로딩 중...';
modal.classList.add('show');
// API 호출
try {
const resp = await fetch('/api/drug-info/drysyrup/' + encodeURIComponent(sungCode));
const data = await resp.json();
if (data.exists) {
// 기존 데이터 채우기
document.getElementById('drysyrup_ingredient_name').value = data.ingredient_name || '';
document.getElementById('drysyrup_product_name').value = data.product_name || '';
document.getElementById('drysyrup_conversion_factor').value = data.conversion_factor || '';
document.getElementById('drysyrup_post_prep_amount').value = data.post_prep_amount || '';
document.getElementById('drysyrup_main_ingredient_amt').value = data.main_ingredient_amt || '';
document.getElementById('drysyrup_storage_conditions').value = data.storage_conditions || '실온';
document.getElementById('drysyrup_expiration_date').value = data.expiration_date || '';
statusEl.textContent = '✅ 등록된 데이터';
drysyrupIsNew = false;
} else {
statusEl.textContent = '🆕 신규 등록';
drysyrupIsNew = true;
}
} catch (err) {
console.error('드라이시럽 조회 오류:', err);
statusEl.textContent = '⚠️ 조회 실패 (신규 등록 가능)';
drysyrupIsNew = true;
}
}
// 모달 닫기
function closeDrysyrupModal() {
document.getElementById('drysyrupModal').classList.remove('show');
}
// 저장
async function saveDrysyrup() {
const sungCode = document.getElementById('drysyrup_sung_code').value;
const statusEl = document.getElementById('drysyrup_status');
const data = {
sung_code: sungCode,
ingredient_name: document.getElementById('drysyrup_ingredient_name').value,
product_name: document.getElementById('drysyrup_product_name').value,
conversion_factor: parseFloat(document.getElementById('drysyrup_conversion_factor').value) || null,
post_prep_amount: document.getElementById('drysyrup_post_prep_amount').value,
main_ingredient_amt: document.getElementById('drysyrup_main_ingredient_amt').value,
storage_conditions: document.getElementById('drysyrup_storage_conditions').value,
expiration_date: document.getElementById('drysyrup_expiration_date').value
};
statusEl.textContent = '저장 중...';
try {
const url = drysyrupIsNew ? '/api/drug-info/drysyrup' : '/api/drug-info/drysyrup/' + encodeURIComponent(sungCode);
const method = drysyrupIsNew ? 'POST' : 'PUT';
const resp = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await resp.json();
if (result.success) {
statusEl.textContent = '✅ 저장 완료!';
window.showToast && window.showToast('환산계수 저장 완료', 'success');
setTimeout(closeDrysyrupModal, 1000);
} else {
statusEl.textContent = '❌ ' + (result.error || '저장 실패');
}
} catch (err) {
console.error('드라이시럽 저장 오류:', err);
statusEl.textContent = '❌ 저장 오류';
}
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDrysyrupModal();
}
});
// 모달 바깥 클릭시 닫기
document.getElementById('drysyrupModal').addEventListener('click', function(e) {
if (e.target === this) {
closeDrysyrupModal();
}
});
</script>
</body>
</html>

View File

@@ -651,7 +651,8 @@
</thead>
<tbody>
${logs.map(log => {
const date = new Date(log.created_at);
// 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',
@@ -772,7 +773,7 @@
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
<dt>약품</dt><dd>${medsHtml}</dd>
<dt>분석일시</dt><dd>${log.created_at}</dd>
<dt>분석일시</dt><dd>${new Date(log.created_at + 'Z').toLocaleString('ko-KR')}</dd>
<dt>상태</dt><dd>${log.status}</dd>
<dt>피드백</dt><dd>${feedbackHtml}</dd>
</dl>

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("품목을 찾을 수 없습니다")

View File

@@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 주문 원장 API 분석"""
import json
import requests
from datetime import datetime, timedelta
import calendar
# 저장된 토큰 로드
TOKEN_FILE = r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.baekje_token.json'
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
token_data = json.load(f)
token = token_data['token']
cust_cd = token_data['cust_cd']
print(f"Token expires: {datetime.fromtimestamp(token_data['expires'])}")
print(f"Customer code: {cust_cd}")
# API 세션 설정
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Origin': 'https://ibjp.co.kr',
'Referer': 'https://ibjp.co.kr/',
'Authorization': f'Bearer {token}'
})
API_URL = "https://www.ibjp.co.kr"
# 1. 주문 원장 API 시도 - 다양한 엔드포인트
endpoints = [
'/ordLedger/listSearch',
'/ordLedger/list',
'/ord/ledgerList',
'/ord/ledgerSearch',
'/cust/ordLedger',
'/custOrd/ledgerList',
'/ordHist/listSearch',
'/ordHist/list',
]
# 날짜 설정 (이번 달)
today = datetime.now()
year = today.year
month = today.month
_, last_day = calendar.monthrange(year, month)
from_date = f"{year}{month:02d}01"
to_date = f"{year}{month:02d}{last_day:02d}"
print(f"\n조회 기간: {from_date} ~ {to_date}")
print("\n=== API 엔드포인트 탐색 ===\n")
params = {
'custCd': cust_cd,
'startDt': from_date,
'endDt': to_date,
'stDate': from_date,
'edDate': to_date,
'year': str(year),
'month': f"{month:02d}",
}
for endpoint in endpoints:
try:
# GET 시도
resp = session.get(f"{API_URL}{endpoint}", params=params, timeout=10)
print(f"GET {endpoint}: {resp.status_code}")
if resp.status_code == 200:
try:
data = resp.json()
print(f" -> JSON Response (first 500 chars): {str(data)[:500]}")
except:
print(f" -> Text (first 200 chars): {resp.text[:200]}")
except Exception as e:
print(f"GET {endpoint}: Error - {e}")
try:
# POST 시도
resp = session.post(f"{API_URL}{endpoint}", json=params, timeout=10)
print(f"POST {endpoint}: {resp.status_code}")
if resp.status_code == 200:
try:
data = resp.json()
print(f" -> JSON Response (first 500 chars): {str(data)[:500]}")
except:
print(f" -> Text (first 200 chars): {resp.text[:200]}")
except Exception as e:
print(f"POST {endpoint}: Error - {e}")
# 2. 이미 알려진 API로 데이터 확인
print("\n=== 알려진 API 테스트 ===\n")
# 월간 잔고 조회 (이미 있는 함수에서 사용)
resp = session.get(f"{API_URL}/custMonth/listSearch", params={'custCd': cust_cd, 'year': str(year), 'endDt': to_date}, timeout=10)
print(f"custMonth/listSearch: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f" -> {json.dumps(data, ensure_ascii=False, indent=2)[:1500]}")

View File

@@ -1,126 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 주문 원장 API 분석 - 상세 탐색"""
import json
import requests
from datetime import datetime
import calendar
# 저장된 토큰 로드
TOKEN_FILE = r'c:\Users\청춘약국\source\pharmacy-wholesale-api\.baekje_token.json'
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
token_data = json.load(f)
token = token_data['token']
cust_cd = token_data['cust_cd']
# API 세션 설정
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'Origin': 'https://ibjp.co.kr',
'Referer': 'https://ibjp.co.kr/',
'Authorization': f'Bearer {token}'
})
API_URL = "https://www.ibjp.co.kr"
today = datetime.now()
year = today.year
month = today.month
_, last_day = calendar.monthrange(year, month)
print("=== 주문 원장 API 탐색 (다양한 파라미터) ===\n")
# 날짜 형식 변형
date_formats = [
{'startDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'},
{'stDt': f'{year}{month:02d}01', 'edDt': f'{year}{month:02d}{last_day:02d}'},
{'fromDate': f'{year}-{month:02d}-01', 'toDate': f'{year}-{month:02d}-{last_day:02d}'},
{'strDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'},
{'ordDt': f'{year}{month:02d}'},
]
endpoints = [
'/ordLedger/listSearch',
'/ordLedger/search',
'/ordLedger/ledgerList',
'/cust/ordLedgerList',
'/cust/ledger',
'/ord/histList',
'/ord/history',
'/ord/list',
]
for endpoint in endpoints:
for params in date_formats:
full_params = {**params, 'custCd': cust_cd}
try:
resp = session.get(f"{API_URL}{endpoint}", params=full_params, timeout=10)
if resp.status_code == 200:
print(f"✓ GET {endpoint} {params}: {resp.status_code}")
try:
data = resp.json()
print(f" -> {str(data)[:300]}")
except:
print(f" -> {resp.text[:200]}")
except Exception as e:
pass
try:
resp = session.post(f"{API_URL}{endpoint}", json=full_params, timeout=10)
if resp.status_code == 200:
print(f"✓ POST {endpoint} {params}: {resp.status_code}")
try:
data = resp.json()
print(f" -> {str(data)[:300]}")
except:
print(f" -> {resp.text[:200]}")
except Exception as e:
pass
print("\n=== 주문 이력 관련 API ===\n")
# 주문 이력 조회 시도
order_endpoints = [
'/ord/ordList',
'/ord/orderHistory',
'/ordReg/list',
'/ordReg/history',
'/order/list',
'/order/history',
]
for endpoint in order_endpoints:
try:
params = {'custCd': cust_cd, 'startDt': f'{year}{month:02d}01', 'endDt': f'{year}{month:02d}{last_day:02d}'}
resp = session.get(f"{API_URL}{endpoint}", params=params, timeout=10)
print(f"GET {endpoint}: {resp.status_code}")
if resp.status_code == 200:
try:
data = resp.json()
print(f" -> {str(data)[:500]}")
except:
print(f" -> {resp.text[:200]}")
except:
pass
print("\n=== custMonth/listSearch 상세 데이터 분석 ===\n")
# 이미 작동하는 API의 데이터 상세 분석
resp = session.get(f"{API_URL}/custMonth/listSearch", params={'custCd': cust_cd, 'year': str(year), 'endDt': f'{year}{month:02d}{last_day:02d}'}, timeout=10)
if resp.status_code == 200:
data = resp.json()
print("월간 데이터 구조:")
for item in data:
print(f"\n월: {item.get('BALANCE_YM')}")
print(f" 매출액(SALE_AMT): {item.get('SALE_AMT'):,}")
print(f" 반품액(BACK_AMT): {item.get('BACK_AMT'):,}")
print(f" 순반품(PURE_BACK_AMT): {item.get('PURE_BACK_AMT'):,}")
print(f" 순매출(TOTAL_AMT): {item.get('TOTAL_AMT'):,}")
print(f" 입금액(PAY_CASH_AMT): {item.get('PAY_CASH_AMT'):,}")
print(f" 전월이월(PRE_TOTAL_AMT): {item.get('PRE_TOTAL_AMT'):,}")
print(f" 월말잔고(BALANCE_A_AMT): {item.get('BALANCE_A_AMT'):,}")
print(f" 회전일수(ROTATE_DAY): {item.get('ROTATE_DAY')}")

View File

@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""백제약품 get_monthly_sales() 테스트"""
import os
import sys
# wholesale 패키지 경로 추가
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-wholesale-api')
os.chdir(r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from dotenv import load_dotenv
load_dotenv()
from wholesale import BaekjeSession
def test_monthly_sales():
print("=" * 60)
print("백제약품 월간 매출 조회 테스트")
print("=" * 60)
session = BaekjeSession()
# 현재 월 조회
from datetime import datetime
now = datetime.now()
year = now.year
month = now.month
print(f"\n1. 현재 월 ({year}-{month:02d}) 조회:")
result = session.get_monthly_sales(year, month)
print(f" Success: {result.get('success')}")
if result.get('success'):
print(f" 월간 매출: {result.get('total_amount'):,}")
print(f" 월간 반품: {result.get('total_returns'):,}")
print(f" 순매출: {result.get('net_amount'):,}")
print(f" 월간 입금: {result.get('total_paid'):,}")
print(f" 월말 잔고: {result.get('ending_balance'):,}")
print(f" 전월이월: {result.get('prev_balance'):,}")
print(f" 회전일수: {result.get('rotate_days')}")
print(f" 조회기간: {result.get('from_date')} ~ {result.get('to_date')}")
else:
print(f" Error: {result.get('error')}")
# 전월 조회
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
print(f"\n2. 전월 ({prev_year}-{prev_month:02d}) 조회:")
result = session.get_monthly_sales(prev_year, prev_month)
print(f" Success: {result.get('success')}")
if result.get('success'):
print(f" 월간 매출: {result.get('total_amount'):,}")
print(f" 월간 반품: {result.get('total_returns'):,}")
print(f" 순매출: {result.get('net_amount'):,}")
print(f" 월간 입금: {result.get('total_paid'):,}")
print(f" 월말 잔고: {result.get('ending_balance'):,}")
print(f" 전월이월: {result.get('prev_balance'):,}")
print(f" 회전일수: {result.get('rotate_days')}")
print(f" 조회기간: {result.get('from_date')} ~ {result.get('to_date')}")
else:
print(f" Error: {result.get('error')}")
# 2달 전 조회
prev_month2 = prev_month - 1 if prev_month > 1 else 12
prev_year2 = prev_year if prev_month > 1 else prev_year - 1
print(f"\n3. 2달 전 ({prev_year2}-{prev_month2:02d}) 조회:")
result = session.get_monthly_sales(prev_year2, prev_month2)
print(f" Success: {result.get('success')}")
if result.get('success'):
print(f" 월간 매출: {result.get('total_amount'):,}")
print(f" 월간 반품: {result.get('total_returns'):,}")
print(f" 순매출: {result.get('net_amount'):,}")
print(f" 월간 입금: {result.get('total_paid'):,}")
print(f" 월말 잔고: {result.get('ending_balance'):,}")
else:
print(f" Error: {result.get('error')}")
print("\n" + "=" * 60)
print("테스트 완료!")
print("=" * 60)
if __name__ == '__main__':
test_monthly_sales()

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js 분석"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# del 포함된 부분 찾기
lines = js.split('\n')
for i, line in enumerate(lines):
if 'del' in line.lower() and ('kind' in line.lower() or 'bagorder' in line.lower()):
print(f'{i}: {line.strip()[:100]}')

View File

@@ -1,18 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js 전체에서 del 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
print(f'JS 길이: {len(js)}')
# del 포함된 줄 모두
for i, line in enumerate(js.split('\n')):
line = line.strip()
if 'del' in line.lower():
print(f'{line[:120]}')

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js 체크박스 관련 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# chk, checkbox 관련 코드 찾기
lines = js.split('\n')
for i, line in enumerate(lines):
if 'chk' in line.lower() or 'check' in line.lower():
print(f'{i}: {line.strip()[:120]}')

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
"""Bag.js AJAX URL 찾기"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228')
js = resp.text
# AJAX 호출 찾기 ($.ajax, url:, type: 패턴)
ajax_blocks = re.findall(r'\$\.ajax\s*\(\s*\{[^}]{0,500}\}', js, re.DOTALL)
print(f'AJAX 호출 {len(ajax_blocks)}개 발견:\n')
for i, block in enumerate(ajax_blocks[:5]):
print(f'=== AJAX {i+1} ===')
print(block[:300])
print()

View File

@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""항목 취소 테스트"""
from sooin_api import SooinSession
import json
session = SooinSession()
session.login()
print('=== 항목 취소 테스트 ===\n')
# 1. 장바구니 비우기
session.clear_cart()
print('1. 장바구니 비움')
# 2. 두 개 담기
session.order_product('073100220', 1, '30T') # 코자정
print('2. 코자정 담음')
session.order_product('652100640', 1) # 스틸녹스
print('3. 스틸녹스 담음')
# 3. 장바구니 확인
cart = session.get_cart()
print(f'\n현재 장바구니:')
print(f' 총 항목: {cart.get("all_items", 0)}')
print(f' 활성(주문포함): {cart.get("total_items", 0)}')
print(f' 취소됨: {cart.get("cancelled_items", 0)}')
for item in cart.get('items', []):
status = '❌ 취소' if item.get('checked') else '✅ 활성'
print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}')
# 4. 첫 번째 항목 취소
print(f'\n4. 첫 번째 항목(idx=0) 취소 시도...')
result = session.cancel_item(row_index=0)
print(f' 결과: {result.get("success")} - {result.get("message", result.get("error", ""))}')
# 5. 다시 확인
cart = session.get_cart()
print(f'\n취소 후 장바구니:')
print(f' 활성: {cart.get("total_items", 0)}')
print(f' 취소됨: {cart.get("cancelled_items", 0)}')
for item in cart.get('items', []):
status = '❌ 취소' if item.get('checked') else '✅ 활성'
print(f' [{item.get("row_index")}] {item.get("product_name")} - {status}')
print('\n=== 완료 ===')

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
"""장바구니 추가 테스트 (실제 주문 X)"""
import json
import sys
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
print("=" * 60)
print("수인약품 API 장바구니 테스트")
print("=" * 60)
session = SooinSession()
# 1. 로그인
print("\n1. 로그인...")
if not session.login():
print("❌ 로그인 실패")
sys.exit(1)
print("✅ 로그인 성공!")
# 2. 장바구니 비우기
print("\n2. 장바구니 비우기...")
result = session.clear_cart()
print(f" 결과: {'성공' if result['success'] else '실패'}")
# 3. 제품 검색
print("\n3. 제품 검색 (KD코드: 073100220 - 코자정)...")
products = session.search_products('073100220', 'kd_code')
print(f" 검색 결과: {len(products)}")
for p in products:
print(f" - {p['product_name']} ({p['specification']}) 재고: {p['stock']} 단가: {p['unit_price']:,}")
print(f" 내부코드: {p['internal_code']}")
# 4. 장바구니 추가
if products:
print("\n4. 장바구니 추가 (첫 번째 제품, 1개)...")
product = products[1] # 30T 선택
result = session.add_to_cart(
internal_code=product['internal_code'],
quantity=1,
stock=product['stock'],
price=product['unit_price']
)
print(f" 결과: {json.dumps(result, ensure_ascii=False, indent=2)}")
# 5. 장바구니 조회
print("\n5. 장바구니 조회...")
cart = session.get_cart()
print(f" 장바구니: {cart['total_items']}개 품목, {cart['total_amount']:,}")
for item in cart['items']:
print(f" - {item['product_name']}: {item['quantity']}개 ({item['amount']:,}원)")
# 6. 장바구니 비우기 (정리)
print("\n6. 장바구니 비우기 (정리)...")
result = session.clear_cart()
print(f" 결과: {'성공' if result['success'] else '실패'}")
print("\n" + "=" * 60)
print("테스트 완료! (실제 주문은 하지 않았습니다)")
print("=" * 60)

View File

@@ -1,114 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 장바구니 API 직접 테스트 (requests)"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
async def get_cookies():
"""Playwright로 로그인 후 쿠키 획득"""
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()
return cookies
def test_cart_api():
# 1. 쿠키 획득
print("1. 로그인 중...")
cookies = asyncio.run(get_cookies())
# 2. requests 세션 설정
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
})
print(f" 쿠키: {[c['name'] for c in cookies]}")
# 3. 제품 검색
print("\n2. 제품 검색...")
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '643104281',
'srchCate': '',
'prdtType': '',
'prdOrder': '',
'srchCompany': '',
'startdate': '',
'enddate': ''
})
print(f" 검색 응답: {search_resp.status_code}, 길이: {len(search_resp.text)}")
# 4. 장바구니 API 테스트 - 여러 엔드포인트 시도
print("\n3. 장바구니 API 테스트...")
endpoints = [
'/Home/PartialProductCart',
'/Home/AddCart',
'/Order/AddCart',
'/Home/AddToCart',
'/Order/AddToCart',
'/Home/InsertCart',
'/Order/InsertCart',
]
for endpoint in endpoints:
url = f'https://gwn.geoweb.kr{endpoint}'
# 다양한 파라미터 조합 시도
params_list = [
{'prdtCode': '643104281', 'qty': 1},
{'productCode': '643104281', 'quantity': 1},
{'code': '643104281', 'cnt': 1},
{'insCode': '643104281', 'orderQty': 1},
]
for params in params_list:
try:
resp = session.post(url, data=params, timeout=5)
if resp.status_code == 200:
text = resp.text[:200]
if 'error' not in text.lower() and '404' not in text:
print(f"{endpoint}")
print(f" Params: {params}")
print(f" Response: {text[:100]}...")
except Exception as e:
pass
# 5. 현재 장바구니 조회
print("\n4. 장바구니 조회...")
cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
print(f" 응답: {cart_resp.status_code}")
soup = BeautifulSoup(cart_resp.text, 'html.parser')
# 장바구니 테이블에서 상품 찾기
rows = soup.find_all('tr')
print(f" 테이블 행: {len(rows)}")
# HTML에서 장바구니 추가 폼 찾기
forms = soup.find_all('form')
for form in forms:
action = form.get('action', '')
if 'cart' in action.lower() or 'order' in action.lower():
print(f" 폼 발견: {action}")
inputs = form.find_all('input')
for inp in inputs:
print(f" - {inp.get('name')}: {inp.get('value', '')}")
if __name__ == "__main__":
test_cart_api()

View File

@@ -1,44 +0,0 @@
# -*- coding: utf-8 -*-
"""장바구니 디버깅"""
import sys
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
session = SooinSession()
if not session.login():
print("로그인 실패")
sys.exit(1)
print("로그인 성공!")
# 1. 장바구니 추가 요청의 실제 응답 확인
print("\n=== 장바구니 추가 요청 ===")
data = {
'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': ''
}
resp = session.session.post(session.BAG_URL, data=data, timeout=15)
print(f"Status: {resp.status_code}")
print(f"URL: {resp.url}")
print(f"\n응답 (처음 2000자):\n{resp.text[:2000]}")
# 2. 장바구니 조회 응답 확인
print("\n\n=== 장바구니 조회 요청 ===")
params = {'currVenCd': session.VENDOR_CODE}
resp2 = session.session.get(session.BAG_URL, params=params, timeout=15)
print(f"Status: {resp2.status_code}")
print(f"URL: {resp2.url}")
print(f"\n응답 (처음 3000자):\n{resp2.text[:3000]}")

View File

@@ -1,127 +0,0 @@
# -*- coding: utf-8 -*-
"""장바구니 조회 API 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import json
async def get_cookies():
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()
return cookies
def test():
print("="*60)
print("장바구니 조회 API 테스트")
print("="*60)
cookies = asyncio.run(get_cookies())
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 1. 먼저 제품 하나 담기
print("\n1. 테스트용 제품 담기...")
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct',
data={'srchText': '661700390'})
soup = BeautifulSoup(search_resp.text, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
if product_div:
lis = product_div.find_all('li')
internal_code = lis[0].get_text(strip=True)
cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': internal_code,
'moveCode': '',
'orderQty': 3
})
print(f" 담기 결과: {cart_resp.json()}")
# 2. 장바구니 조회
print("\n2. 장바구니 조회 (PartialProductCart)...")
cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
print(f" 상태: {cart_resp.status_code}")
print(f" 길이: {len(cart_resp.text)}")
# HTML 파싱
soup = BeautifulSoup(cart_resp.text, 'html.parser')
# 테이블 찾기
tables = soup.find_all('table')
print(f" 테이블 수: {len(tables)}")
# 장바구니 항목 파싱
cart_items = []
# div_cart_detail 클래스 찾기
cart_divs = soup.find_all('div', class_='div_cart_detail')
print(f" cart_detail divs: {len(cart_divs)}")
for div in cart_divs:
lis = div.find_all('li')
if len(lis) >= 5:
item = {
'product_code': lis[0].get_text(strip=True) if len(lis) > 0 else '',
'move_code': lis[1].get_text(strip=True) if len(lis) > 1 else '',
'quantity': lis[2].get_text(strip=True) if len(lis) > 2 else '',
'price': lis[3].get_text(strip=True) if len(lis) > 3 else '',
'total': lis[4].get_text(strip=True) if len(lis) > 4 else '',
}
cart_items.append(item)
print(f" - {item}")
# 테이블 행 분석
print("\n 테이블 행 분석:")
for table in tables:
rows = table.find_all('tr')
for row in rows[:5]:
cells = row.find_all(['td', 'th'])
if cells:
texts = [c.get_text(strip=True)[:20] for c in cells[:6]]
print(f" {texts}")
# 3. 다른 API 시도
print("\n3. 다른 장바구니 API 시도...")
endpoints = [
'/Home/GetCartList',
'/Home/CartList',
'/Order/GetCart',
'/Order/CartList',
'/Home/DataCart/list',
]
for ep in endpoints:
try:
resp = session.post(f'https://gwn.geoweb.kr{ep}', timeout=5)
if resp.status_code == 200 and len(resp.text) > 100:
print(f"{ep}: {len(resp.text)} bytes")
print(f" {resp.text[:100]}...")
except:
pass
# 4. 장바구니 비우기
print("\n4. 장바구니 비우기...")
session.post('https://gwn.geoweb.kr/Home/DataCart/delAll')
print(" 완료")
if __name__ == "__main__":
test()

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""실제 챗봇 API 테스트"""
import requests
import json
import time
API_URL = "http://localhost:7001/api/animal-chat"
questions = [
"우리 강아지가 피부에 뭐가 났어요. 빨갛고 진물이 나요",
"고양이 심장사상충 예방약 뭐가 좋아요?",
"개시딘 어떻게 사용해요?",
"강아지가 구토를 해요 약 있나요?",
"진드기 예방약 추천해주세요",
]
print("=" * 70)
print("🐾 동물의약품 챗봇 API 테스트")
print("=" * 70)
for q in questions:
print(f"\n💬 질문: {q}")
print("-" * 50)
try:
start = time.time()
resp = requests.post(API_URL, json={
"messages": [{"role": "user", "content": q}]
}, timeout=30)
elapsed = time.time() - start
data = resp.json()
if data.get("success"):
msg = data.get("message", "")
products = data.get("products", [])
# 응답 앞부분만
print(f"🤖 응답 ({elapsed:.1f}초):")
print(msg[:500] + "..." if len(msg) > 500 else msg)
if products:
print(f"\n📦 추천 제품: {', '.join([p['name'] for p in products[:3]])}")
else:
print(f"❌ 에러: {data.get('message')}")
except Exception as e:
print(f"❌ 요청 실패: {e}")
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}")

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""체크박스 로직 테스트 - 체크 안 함 vs 체크함"""
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'])
print("="*60)
print("테스트 1: 체크박스 제외 (체크 안 함 = 주문 포함)")
print("="*60)
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':
continue # 체크박스 제외!
form_data[name] = inp.get('value', '')
print(f"chk_0 전송됨? {'chk_0' in form_data}")
print(f"intArray: {form_data.get('intArray')}")
resp = s.session.post(
s.ORDER_END_URL,
data=form_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
timeout=30
)
alert_match = re.search(r'alert\("([^"]*)"\)', resp.text)
alert_msg = alert_match.group(1) if alert_match else ''
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 'N/A'
print(f"주문 후 intArray: {val}")
if '주문이 완료' in alert_msg:
print("✅ 성공!")
else:
print("❌ 실패")

21
backend/test_chroma.py Normal file
View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import os
from dotenv import load_dotenv
load_dotenv()
import chromadb
print('1. creating client...', flush=True)
client = chromadb.PersistentClient(path='./db/chroma_test3')
print('2. client created', flush=True)
# 임베딩 없이 컬렉션 생성
col = client.get_or_create_collection('test3')
print('3. collection created (no ef)', flush=True)
col.add(ids=['1'], documents=['hello world'], embeddings=[[0.1]*384])
print('4. document added with manual embedding', flush=True)
result = col.query(query_embeddings=[[0.1]*384], n_results=1)
print(f'5. query result: {len(result["documents"][0])} docs', flush=True)
print('Done!')

View File

@@ -1,74 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 DataCart API 테스트"""
import requests
import asyncio
from playwright.async_api import async_playwright
import time
async def get_cookies():
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()
return cookies
def test_datacart():
print("1. 로그인 중...")
start = time.time()
cookies = asyncio.run(get_cookies())
print(f" 로그인 완료: {time.time()-start:.1f}")
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
})
# 2. 장바구니 추가 테스트
print("\n2. 장바구니 추가 테스트...")
start = time.time()
resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': '643104281', # 하일렌플러스
'moveCode': '',
'orderQty': 1
})
print(f" 소요시간: {time.time()-start:.1f}")
print(f" 상태코드: {resp.status_code}")
print(f" 응답: {resp.text[:500]}")
# JSON 파싱
try:
result = resp.json()
print(f" result: {result.get('result')}")
print(f" msg: {result.get('msg')}")
except:
pass
# 3. 장바구니 조회
print("\n3. 장바구니 조회...")
cart_resp = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
print(f" 응답 길이: {len(cart_resp.text)}")
# 장바구니에 상품 있는지 확인
if '643104281' in cart_resp.text or '하일렌' in cart_resp.text:
print(" ✓ 장바구니에 상품 추가됨!")
else:
print(" ? 장바구니 확인 필요")
if __name__ == "__main__":
test_datacart()

View File

@@ -1,83 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 검색 → 장바구니 추가 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import time
import re
async def get_cookies():
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()
return cookies
def test():
print("1. 로그인...")
cookies = asyncio.run(get_cookies())
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 2. 검색
print("\n2. 제품 검색 (661700390 - 콩코르정)...")
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '661700390'
})
soup = BeautifulSoup(search_resp.text, 'html.parser')
# 제품 코드 찾기 - data 속성이나 hidden input에서
rows = soup.find_all('tr')
print(f" 테이블 행: {len(rows)}")
# HTML 구조 분석
for row in rows[:2]:
tds = row.find_all('td')
if tds:
print(f" TD 개수: {len(tds)}")
for i, td in enumerate(tds[:8]):
text = td.get_text(strip=True)[:30]
onclick = td.get('onclick', '')[:50]
data_attrs = {k:v for k,v in td.attrs.items() if k.startswith('data')}
print(f" [{i}] {text} | onclick={onclick} | data={data_attrs}")
# onclick에서 제품 코드 추출
onclick_pattern = re.findall(r"onclick=['\"]([^'\"]+)['\"]", search_resp.text)
for oc in onclick_pattern[:3]:
print(f" onclick: {oc[:100]}")
# SelectProduct 함수 호출에서 인덱스 확인
select_pattern = re.findall(r'SelectProduct\s*\(\s*(\d+)', search_resp.text)
print(f" SelectProduct 인덱스: {select_pattern[:3]}")
# div-product-detail에서 제품 코드 찾기
product_divs = soup.find_all('div', class_='div-product-detail')
print(f" product-detail divs: {len(product_divs)}")
for div in product_divs[:2]:
lis = div.find_all('li')
if lis:
print(f" li 개수: {len(lis)}")
for i, li in enumerate(lis[:5]):
print(f" [{i}] {li.get_text(strip=True)[:50]}")
if __name__ == "__main__":
test()

View File

@@ -1,105 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 장바구니 추가 - 정확한 productCode로 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import time
async def get_cookies():
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()
return cookies
def test():
print("="*60)
print("지오영 API 직접 호출 테스트")
print("="*60)
# 1. 로그인
print("\n1. 로그인...")
start = time.time()
cookies = asyncio.run(get_cookies())
print(f" 완료: {time.time()-start:.1f}")
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 2. 검색해서 productCode 획득
print("\n2. 제품 검색...")
start = time.time()
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '661700390' # 콩코르정
})
print(f" 완료: {time.time()-start:.1f}")
soup = BeautifulSoup(search_resp.text, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
if product_div:
lis = product_div.find_all('li')
product_code = lis[0].get_text(strip=True) if lis else None
print(f" productCode: {product_code}")
else:
print(" 제품 없음!")
return
# 3. 장바구니 추가
print("\n3. 장바구니 추가...")
start = time.time()
cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': product_code,
'moveCode': '',
'orderQty': 2
})
print(f" 완료: {time.time()-start:.1f}")
print(f" 상태: {cart_resp.status_code}")
try:
result = cart_resp.json()
print(f" result: {result.get('result')}")
print(f" msg: {result.get('msg', 'OK')}")
if result.get('result') == 1:
print("\n ✅ 장바구니 추가 성공!")
else:
print(f"\n ❌ 실패: {result.get('msg')}")
except:
print(f" 응답: {cart_resp.text[:200]}")
# 4. 장바구니 확인
print("\n4. 장바구니 확인...")
cart_check = session.post('https://gwn.geoweb.kr/Home/PartialProductCart')
if '콩코르' in cart_check.text or product_code in cart_check.text:
print(" ✅ 장바구니에 상품 있음!")
else:
print(" 확인 필요")
# 전체 시간
print("\n" + "="*60)
print("총 API 호출 시간: 검색 + 장바구니 추가 = ~3초")
print("(Playwright 30초+ 대비 10배 이상 빠름!)")
print("="*60)
if __name__ == "__main__":
test()

View File

@@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
"""지오영 주문 확정 API 테스트"""
import requests
from bs4 import BeautifulSoup
import asyncio
from playwright.async_api import async_playwright
import time
async def get_cookies():
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()
return cookies
def test():
print("="*60)
print("지오영 전체 주문 플로우 테스트")
print("="*60)
# 1. 로그인
print("\n1. 로그인...")
start = time.time()
cookies = asyncio.run(get_cookies())
print(f" 완료: {time.time()-start:.1f}")
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
session.headers.update({
'User-Agent': 'Mozilla/5.0',
'X-Requested-With': 'XMLHttpRequest'
})
# 2. 검색 → productCode 획득
print("\n2. 제품 검색...")
start = time.time()
search_resp = session.post('https://gwn.geoweb.kr/Home/PartialSearchProduct', data={
'srchText': '661700390'
})
soup = BeautifulSoup(search_resp.text, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
lis = product_div.find_all('li') if product_div else []
product_code = lis[0].get_text(strip=True) if lis else None
print(f" productCode: {product_code}")
print(f" 완료: {time.time()-start:.1f}")
# 3. 장바구니 추가
print("\n3. 장바구니 추가...")
start = time.time()
cart_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/add', data={
'productCode': product_code,
'moveCode': '',
'orderQty': 1
})
result = cart_resp.json()
print(f" result: {result.get('result')}")
print(f" 완료: {time.time()-start:.1f}")
if result.get('result') != 1:
print(f" ❌ 장바구니 추가 실패: {result.get('msg')}")
return
# 4. 주문 확정 (실제 주문!) - 테스트이므로 실행 안함
print("\n4. 주문 확정 API 테스트...")
print(" ⚠️ 실제 주문이 들어가므로 테스트 중지!")
print(" API: POST /Home/DataOrder")
print(" params: { p_desc: '메모' }")
# 실제 주문 코드 (주석 처리)
# order_resp = session.post('https://gwn.geoweb.kr/Home/DataOrder', data={
# 'p_desc': '테스트 주문'
# })
# print(f" 응답: {order_resp.text[:200]}")
# 5. 장바구니 비우기 (테스트용)
print("\n5. 장바구니 비우기...")
# 장바구니에서 삭제
clear_resp = session.post('https://gwn.geoweb.kr/Home/DataCart/delAll')
print(f" 상태: {clear_resp.status_code}")
print("\n" + "="*60)
print("✅ 전체 API 플로우 확인 완료!")
print("")
print("1. 검색: POST /Home/PartialSearchProduct")
print("2. 장바구니: POST /Home/DataCart/add")
print("3. 주문확정: POST /Home/DataOrder")
print("="*60)
if __name__ == "__main__":
test()

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""submit_order 디버깅"""
import sys; sys.path.insert(0, '.'); import wholesale_path
from bs4 import BeautifulSoup
import re
import importlib
import wholesale.sooin
importlib.reload(wholesale.sooin)
from wholesale import SooinSession
SooinSession._instance = None
s = SooinSession()
s.login()
s.clear_cart()
# 품목 담기
r1 = s.search_products('코자정')
s.add_to_cart(r1['items'][0]['internal_code'], qty=1, price=r1['items'][0]['price'], stock=r1['items'][0]['stock'])
# 취소
s.cancel_item(row_index=0)
# Bag.asp GET
print('=== Bag.asp GET 후 form 분석 ===')
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', 'text').lower()
if inp_type == 'checkbox':
checked = inp.get('checked')
print(f"체크박스 {name}: checked={checked}, type={type(checked)}")
if checked is not None:
form_data[name] = 'on'
print(f" → form_data['{name}'] = 'on' (취소됨, 제외)")
else:
print(f" → 안 보냄 (활성, 포함)")
continue
form_data[name] = inp.get('value', '')
print(f"\n체크박스 관련 form_data: {[(k,v) for k,v in form_data.items() if 'chk' in k]}")

View File

@@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# 개별 삭제 관련 찾기
html = resp.text
# kind 파라미터 종류
kinds = re.findall(r'kind=(\w+)', html)
print('kind 파라미터들:', list(set(kinds)))
# 체크박스 관련 함수
if 'chk_' in html:
print('\n체크박스 있음 (chk_0, chk_1 등)')
# delOne 같은 개별 삭제
if 'delOne' in html or 'deleteOne' in html:
print('개별 삭제 함수 있음')
# 선택삭제 버튼
if '선택삭제' in html or '선택 삭제' in html:
print('선택삭제 버튼 있음')
# 전체 삭제 URL
del_url = re.search(r'BagOrder\.asp\?kind=del[^"\'>\s]*', html)
if del_url:
print(f'\n전체 삭제 URL: {del_url.group()}')

View File

@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
html = resp.text
# 모든 script 내용 출력
scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL)
for i, script in enumerate(scripts):
# 삭제/취소 관련 있으면 출력
if any(x in script.lower() for x in ['del', 'cancel', 'remove', 'chk_']):
print(f'=== Script {i+1} ===')
# 함수 시그니처만 추출
funcs = re.findall(r'function\s+\w+[^{]+', script)
for f in funcs[:5]:
print(f' {f.strip()}')
# 특정 패턴 찾기
patterns = re.findall(r'(delPhysic|cancelOrder|chkBag|selectDel)[^(]*\([^)]*\)', script)
if patterns:
print(f' Patterns: {patterns[:5]}')

View File

@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
html = resp.text
# 모든 <a> 태그의 href와 onclick 찾기
links = re.findall(r'<a[^>]*(href|onclick)=["\']([^"\']+)["\'][^>]*>', html)
for attr, val in links:
if 'del' in val.lower() or 'cancel' in val.lower():
print(f'{attr}: {val[:100]}')
print('\n--- form actions ---')
forms = re.findall(r'<form[^>]*action=["\']([^"\']+)["\']', html)
for f in forms:
print(f'form action: {f}')
print('\n--- hidden inputs ---')
hiddens = re.findall(r'<input[^>]*type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']', html)
for name, val in hiddens[:10]:
print(f'{name}: {val}')

View File

@@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
"""체크박스로 삭제 테스트"""
from sooin_api import SooinSession
import re
session = SooinSession()
session.login()
# Bag.asp의 JavaScript 전체 확인
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# onclick 이벤트들 찾기
onclicks = re.findall(r'onclick="([^"]*)"', resp.text)
print('onclick handlers:')
for oc in onclicks[:10]:
if len(oc) < 200:
print(f' {oc}')
# form의 name과 action
forms = re.findall(r'<form[^>]*name="([^"]*)"[^>]*action="([^"]*)"', resp.text)
print('\nForms:')
for name, action in forms:
print(f' {name}: {action}')
# 삭제 관련 JavaScript 함수 찾기
scripts = re.findall(r'function\s+(\w+Del\w*|\w+Cancel\w*|\w+Remove\w*)\s*\([^)]*\)\s*\{[^}]{0,300}', resp.text, re.IGNORECASE)
print('\nDelete functions:')
for s in scripts[:5]:
print(f' {s[:100]}...')

View File

@@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
"""HTML 전체 분석"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# 전체 저장해서 분석
with open('bag_page.html', 'w', encoding='utf-8') as f:
f.write(resp.text)
print('bag_page.html 저장됨')
print(f'길이: {len(resp.text)}')
# 현재 장바구니 상태
cart = session.get_cart()
print(f'장바구니: {cart.get("total_items", 0)}')
for item in cart.get('items', []):
print(f' - {item.get("product_name")}')

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