Compare commits

..

47 Commits

Author SHA1 Message Date
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
62 changed files with 13618 additions and 279 deletions

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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,
@ -517,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"}
@ -1387,3 +1391,226 @@ def api_drugs_preferred_vendors():
'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

@ -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: 건조시럽 환산계수 (mLg 변환용, 선택)
- : 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

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

@ -596,8 +596,42 @@ def api_sooin_orders_by_kd():
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

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

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

@ -712,6 +712,57 @@
.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;
}
@ -1151,6 +1202,53 @@
</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;
@ -1236,7 +1334,7 @@
</td>
<td>
<div class="product-name">
${escapeHtml(item.product_name)}
<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>
@ -1710,6 +1808,7 @@
function closeImageModal() {
stopCamera();
stopQrPolling();
document.getElementById('imageModal').classList.remove('show');
imgModalBarcode = null;
imgModalDrugCode = null;
@ -1721,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() {
@ -1783,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(); }
@ -1840,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">
@ -1871,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>
@ -2035,6 +2253,177 @@
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(() => {

View File

@ -48,6 +48,87 @@
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%);
@ -939,7 +1020,7 @@
loadOrderData(); // 수인약품 주문량 로드
});
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ────────────────
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 + 동원 합산) ────────────────
async function loadOrderData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
@ -948,58 +1029,58 @@
orderDataByKd = {};
try {
// 지오영 + 수인 + 백제 병렬 조회
const [geoRes, sooinRes, baekjeRes] = await Promise.all([
// 지오영 + 수인 + 백제 + 동원 병렬 조회
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/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;
// 지오영 데이터 합산
if (geoRes.success && geoRes.by_kd_code) {
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
// 도매상 정보 (확장 가능)
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
});
}
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('지오영');
totalOrders += res.order_count || 0;
console.log(`${config.icon} ${config.name} 주문량:`, Object.keys(res.by_kd_code).length, '품목,', res.order_count, '건');
}
totalOrders += geoRes.order_count || 0;
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
}
// 수인 데이터 합산
if (sooinRes.success && sooinRes.by_kd_code) {
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('수인');
}
totalOrders += sooinRes.order_count || 0;
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
}
// 백제 데이터 합산
if (baekjeRes.success && baekjeRes.by_kd_code) {
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('백제');
}
totalOrders += baekjeRes.order_count || 0;
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
}
console.log('📦 3사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
} catch(err) {
console.warn('주문량 조회 실패:', err);
@ -1010,14 +1091,46 @@
}
}
// KD코드로 주문량 조회
// KD코드로 주문량 조회 (툴팁 포함)
let orderDataLoading = true; // 로딩 상태
function getOrderedQty(kdCode) {
if (orderDataLoading) return '<span class="order-loading">···</span>';
const order = orderDataByKd[kdCode];
if (!order) return '-';
return order.units.toLocaleString();
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>`;
}
// ──────────────── 데이터 로드 ────────────────
@ -1269,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
}
};
@ -2043,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];
@ -2065,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';
@ -2081,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) {
@ -2136,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 = '';
@ -2260,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;
}
@ -2277,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;
@ -2298,6 +2485,7 @@
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
sooin_code: wholesaler === 'sooin' ? item.code : null,
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null, // 동원: 내부코드로 주문
unit_price: unitPrice // 💰 단가 추가
};
@ -2542,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;
@ -2576,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;
@ -2778,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;
@ -3077,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;
@ -878,6 +942,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 +959,7 @@
font-weight: 600;
font-size: 0.9rem;
display: inline-block;
white-space: nowrap;
}
.med-form {
background: #fef3c7;
@ -1154,6 +1226,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>
@ -1305,6 +1504,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 +1716,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 +1780,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 +1805,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 +1896,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 +2020,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 +2044,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 +2081,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 +2289,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) 함수들 - 비동기 토스트 방식
// ─────────────────────────────────────────────────────────────
@ -2595,20 +2925,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 +2956,8 @@
dosage: dosage,
frequency: frequency,
duration: duration,
unit: '정'
unit: unit,
sung_code: sungCode
})
});
const data = await res.json();
@ -2649,14 +2985,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 +3467,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

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

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

32
backend/test_final.py Normal file
View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""개선된 RAG 테스트"""
import importlib
import utils.animal_rag
importlib.reload(utils.animal_rag)
rag = utils.animal_rag.AnimalDrugRAG()
queries = [
'가이시딘',
'개시딘',
'개시딘 피부염',
'심장사상충 예방약',
'강아지 구토약',
'고양이 귀진드기',
'넥스가드',
'후시딘 동물용',
]
print("=" * 70)
print("🎯 개선된 RAG 테스트 (prefix 추가 후)")
print("=" * 70)
for q in queries:
results = rag.search(q)
print(f'\n🔍 "{q}" - {len(results)}개 결과')
for r in results[:3]: # 상위 3개만
product = r.get('product_name', '')[:20] if 'product_name' in r else ''
print(f" [{r['score']:.0%}] {r['source'][:35]}")
# 청크 prefix 확인
text_preview = r['text'][:80].replace('\n', ' ')
print(f"{text_preview}...")

27
backend/test_rag.py Normal file
View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
print("1. Starting...")
print(f" CWD: {os.getcwd()}")
from dotenv import load_dotenv
load_dotenv()
print(f"2. API Key: {os.getenv('OPENAI_API_KEY', 'NOT SET')[:20]}...")
from utils.animal_rag import AnimalDrugRAG
print("3. Module imported")
rag = AnimalDrugRAG()
print("4. RAG created")
try:
count = rag.index_md_files()
print(f"5. Indexed: {count} chunks")
except Exception as e:
print(f"5. Error: {e}")
import traceback
traceback.print_exc()
print("6. Done")

View File

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from utils.animal_rag import get_animal_rag
rag = get_animal_rag()
queries = [
'가이시딘 어떻게 써?',
'심장사상충 예방약 추천',
'고양이 구충제',
'강아지 진통제',
'귀진드기 약',
'피부염 치료',
'구토 멈추는 약',
'항생제 추천',
'넥스가드 용법',
'셀라멕틴 스팟온'
]
print("=" * 60)
print("RAG 검색 품질 테스트")
print("=" * 60)
for q in queries:
results = rag.search(q, n_results=3)
print(f'\n🔍 "{q}"')
if not results:
print(' ❌ 검색 결과 없음 (score < 0.3)')
else:
for r in results:
print(f" [{r['score']:.0%}] {r['source']} - {r['section']}")
# 첫 100자 미리보기
preview = r['text'][:100].replace('\n', ' ')
print(f"{preview}...")

View File

@ -0,0 +1,18 @@
from utils.animal_rag import get_animal_rag
rag = get_animal_rag()
# 테스트 쿼리
queries = [
"피부 붉고 염증",
"피부염 치료",
"피부 발적 연고",
]
for query in queries:
print(f"\n=== 검색: {query} ===")
results = rag.search(query, n_results=5)
if not results:
print(" (결과 없음)")
for r in results:
print(f" [{r['score']:.0%}] {r['source']} - {r['section']}")

26
backend/test_skincasol.py Normal file
View File

@ -0,0 +1,26 @@
from utils.animal_rag import get_animal_rag
rag = get_animal_rag()
queries = [
"스킨카솔",
"센텔라",
"피부 재생",
"피부 보호",
"피부 진정",
"상처 회복",
"피부 케어",
"습진",
"아토피",
"티트리오일",
]
for query in queries:
results = rag.search(query, n_results=3)
has_skincasol = any("skincasol" in r["source"].lower() for r in results)
mark = "O" if has_skincasol else "X"
print(f"[{mark}] {query}")
if has_skincasol:
for r in results:
if "skincasol" in r["source"].lower():
print(f" -> {r['score']:.0%} {r['section']}")

31
backend/test_threshold.py Normal file
View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""임계값 없이 raw 검색 결과 확인"""
from utils.animal_rag import get_animal_rag
rag = get_animal_rag()
rag._init_db()
queries = [
'가이시딘', # 오타 버전
'개시딘', # 정확한 이름
'개시딘 겔',
'피부 농피증',
'후시딘', # 사람용 약 이름으로 검색
]
print("=" * 70)
print("임계값 제거 후 RAW 검색 결과 (상위 5개)")
print("=" * 70)
for q in queries:
# 임계값 없이 raw 검색
query_emb = rag._get_embedding(q)
results = rag.table.search(query_emb).limit(5).to_list()
print(f'\n🔍 "{q}"')
for r in results:
distance = r.get("_distance", 10)
score = 1 / (1 + distance)
source = r["source"]
section = r["section"]
print(f" [{score:.1%}] (dist:{distance:.2f}) {source} - {section}")

28
backend/test_tuned.py Normal file
View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""튜닝 후 테스트 (기본값 사용)"""
from utils.animal_rag import get_animal_rag
# 새로 import해서 변경사항 적용
import importlib
import utils.animal_rag
importlib.reload(utils.animal_rag)
rag = utils.animal_rag.get_animal_rag()
queries = [
'가이시딘',
'개시딘 피부염',
'심장사상충 예방약',
'강아지 구토약',
]
print("=" * 60)
print("튜닝 후 테스트 (n_results=5, threshold=0.2)")
print("=" * 60)
for q in queries:
# 기본값 사용 (n_results=5)
results = rag.search(q)
print(f'\n🔍 "{q}" - {len(results)}개 결과')
for r in results:
print(f" [{r['score']:.0%}] {r['source'][:30]} - {r['section'][:20]}")

21
backend/test_vomit.py Normal file
View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import importlib
import utils.animal_rag as ar
importlib.reload(ar)
rag = ar.AnimalDrugRAG()
rag._init_db()
queries = ['구토 멈추는 약', '구토 치료제', '마로피턴트', '세레니아']
for q in queries:
query_emb = rag._get_embedding(q)
results = rag.table.search(query_emb).limit(5).to_list()
print(f'\n🔍 "{q}"')
for r in results:
dist = r.get('_distance', 10)
score = 1 / (1 + dist)
source = r['source'][:40]
section = r['section'][:25]
print(f' [{score:.0%}] {source} - {section}')

View File

@ -0,0 +1,277 @@
# -*- coding: utf-8 -*-
"""
동물약 챗봇 로깅 모듈
- SQLite에 대화 로그 저장
- 단계별 소요시간, 토큰, 비용 기록
"""
import os
import json
import sqlite3
import logging
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
logger = logging.getLogger(__name__)
# DB 경로
DB_PATH = Path(__file__).parent.parent / "db" / "animal_chat_logs.db"
SCHEMA_PATH = Path(__file__).parent.parent / "db" / "animal_chat_logs_schema.sql"
# GPT-4o-mini 가격 (USD per 1K tokens)
INPUT_COST_PER_1K = 0.00015 # $0.15 / 1M = $0.00015 / 1K
OUTPUT_COST_PER_1K = 0.0006 # $0.60 / 1M = $0.0006 / 1K
@dataclass
class ChatLogEntry:
"""챗봇 로그 엔트리"""
session_id: str = ""
# 입력
user_message: str = ""
history_length: int = 0
# MSSQL
mssql_drug_count: int = 0
mssql_duration_ms: int = 0
# PostgreSQL
pgsql_rag_count: int = 0
pgsql_duration_ms: int = 0
# LanceDB
vector_results_count: int = 0
vector_top_scores: List[float] = field(default_factory=list)
vector_sources: List[str] = field(default_factory=list)
vector_duration_ms: int = 0
# OpenAI
openai_model: str = ""
openai_prompt_tokens: int = 0
openai_completion_tokens: int = 0
openai_total_tokens: int = 0
openai_cost_usd: float = 0.0
openai_duration_ms: int = 0
# 출력
assistant_response: str = ""
products_mentioned: List[str] = field(default_factory=list)
# 메타
total_duration_ms: int = 0
error: str = ""
def calculate_cost(self):
"""토큰 기반 비용 계산"""
self.openai_cost_usd = (
self.openai_prompt_tokens * INPUT_COST_PER_1K / 1000 +
self.openai_completion_tokens * OUTPUT_COST_PER_1K / 1000
)
def init_db():
"""DB 초기화 (테이블 생성)"""
try:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
if SCHEMA_PATH.exists():
schema = SCHEMA_PATH.read_text(encoding='utf-8')
conn.executescript(schema)
else:
# 스키마 파일 없으면 인라인 생성
conn.executescript("""
CREATE TABLE IF NOT EXISTS chat_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
user_message TEXT,
history_length INTEGER,
mssql_drug_count INTEGER,
mssql_duration_ms INTEGER,
pgsql_rag_count INTEGER,
pgsql_duration_ms INTEGER,
vector_results_count INTEGER,
vector_top_scores TEXT,
vector_sources TEXT,
vector_duration_ms INTEGER,
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,
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);
""")
conn.commit()
conn.close()
logger.info(f"동물약 챗봇 로그 DB 초기화: {DB_PATH}")
return True
except Exception as e:
logger.error(f"DB 초기화 실패: {e}")
return False
def log_chat(entry: ChatLogEntry) -> Optional[int]:
"""
챗봇 대화 로그 저장
Returns:
저장된 로그 ID (실패시 None)
"""
try:
# 비용 계산
entry.calculate_cost()
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
INSERT INTO chat_logs (
session_id, user_message, history_length,
mssql_drug_count, mssql_duration_ms,
pgsql_rag_count, pgsql_duration_ms,
vector_results_count, vector_top_scores, vector_sources, vector_duration_ms,
openai_model, openai_prompt_tokens, openai_completion_tokens,
openai_total_tokens, openai_cost_usd, openai_duration_ms,
assistant_response, products_mentioned,
total_duration_ms, error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
entry.session_id,
entry.user_message,
entry.history_length,
entry.mssql_drug_count,
entry.mssql_duration_ms,
entry.pgsql_rag_count,
entry.pgsql_duration_ms,
entry.vector_results_count,
json.dumps(entry.vector_top_scores),
json.dumps(entry.vector_sources),
entry.vector_duration_ms,
entry.openai_model,
entry.openai_prompt_tokens,
entry.openai_completion_tokens,
entry.openai_total_tokens,
entry.openai_cost_usd,
entry.openai_duration_ms,
entry.assistant_response,
json.dumps(entry.products_mentioned),
entry.total_duration_ms,
entry.error
))
log_id = cursor.lastrowid
conn.commit()
conn.close()
logger.debug(f"챗봇 로그 저장: ID={log_id}, tokens={entry.openai_total_tokens}")
return log_id
except Exception as e:
logger.error(f"로그 저장 실패: {e}")
return None
def get_logs(
limit: int = 100,
offset: int = 0,
date_from: str = None,
date_to: str = None,
error_only: bool = False
) -> List[Dict]:
"""로그 조회"""
try:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM chat_logs WHERE 1=1"
params = []
if date_from:
query += " AND created_at >= ?"
params.append(date_from)
if date_to:
query += " AND created_at <= ?"
params.append(date_to + " 23:59:59")
if error_only:
query += " AND error IS NOT NULL AND error != ''"
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"로그 조회 실패: {e}")
return []
def get_stats(date_from: str = None, date_to: str = None) -> Dict:
"""통계 조회"""
try:
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
query = """
SELECT
COUNT(*) as total_chats,
AVG(total_duration_ms) as avg_duration_ms,
SUM(openai_total_tokens) as total_tokens,
SUM(openai_cost_usd) as total_cost_usd,
AVG(openai_total_tokens) as avg_tokens,
SUM(CASE WHEN error IS NOT NULL AND error != '' THEN 1 ELSE 0 END) as error_count,
AVG(vector_duration_ms) as avg_vector_ms,
AVG(openai_duration_ms) as avg_openai_ms
FROM chat_logs
WHERE 1=1
"""
params = []
if date_from:
query += " AND created_at >= ?"
params.append(date_from)
if date_to:
query += " AND created_at <= ?"
params.append(date_to + " 23:59:59")
cursor.execute(query, params)
row = cursor.fetchone()
conn.close()
if row:
return {
'total_chats': row[0] or 0,
'avg_duration_ms': round(row[1] or 0),
'total_tokens': row[2] or 0,
'total_cost_usd': round(row[3] or 0, 4),
'avg_tokens': round(row[4] or 0),
'error_count': row[5] or 0,
'avg_vector_ms': round(row[6] or 0),
'avg_openai_ms': round(row[7] or 0)
}
return {}
except Exception as e:
logger.error(f"통계 조회 실패: {e}")
return {}
# 모듈 로드 시 DB 초기화
init_db()

402
backend/utils/animal_rag.py Normal file
View File

@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
"""
동물약 벡터 DB RAG 모듈
- LanceDB + OpenAI text-embedding-3-small
- MD 파일 청킹 임베딩
- 유사도 검색
"""
import os
import re
import logging
from pathlib import Path
from typing import List, Dict, Optional
# .env 로드
from dotenv import load_dotenv
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
# LanceDB
import lancedb
from openai import OpenAI
logger = logging.getLogger(__name__)
# 설정
LANCE_DB_PATH = Path(__file__).parent.parent / "db" / "lance_animal_drugs"
MD_DOCS_PATH = Path("C:/Users/청춘약국/source/new_anipharm")
TABLE_NAME = "animal_drugs"
CHUNK_SIZE = 1500 # 약 500 토큰
CHUNK_OVERLAP = 300 # 약 100 토큰
EMBEDDING_DIM = 1536 # text-embedding-3-small
class AnimalDrugRAG:
"""동물약 RAG 클래스 (LanceDB 버전)"""
def __init__(self, openai_api_key: str = None):
"""
Args:
openai_api_key: OpenAI API (없으면 환경변수에서 가져옴)
"""
self.api_key = openai_api_key or os.getenv('OPENAI_API_KEY')
self.db = None
self.table = None
self.openai_client = None
self._initialized = False
def _init_db(self):
"""DB 초기화 (lazy loading)"""
if self._initialized:
return
try:
# LanceDB 연결
LANCE_DB_PATH.mkdir(parents=True, exist_ok=True)
self.db = lancedb.connect(str(LANCE_DB_PATH))
# OpenAI 클라이언트
if self.api_key:
self.openai_client = OpenAI(api_key=self.api_key)
else:
logger.warning("OpenAI API 키 없음")
# 기존 테이블 열기
if TABLE_NAME in self.db.table_names():
self.table = self.db.open_table(TABLE_NAME)
logger.info(f"기존 테이블 열림 (행 수: {len(self.table)})")
else:
logger.info("테이블 없음 - index_md_files() 호출 필요")
self._initialized = True
except Exception as e:
logger.error(f"AnimalDrugRAG 초기화 실패: {e}")
raise
def _get_embedding(self, text: str) -> List[float]:
"""OpenAI 임베딩 생성"""
if not self.openai_client:
raise ValueError("OpenAI 클라이언트 없음")
response = self.openai_client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data[0].embedding
def _get_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
"""배치 임베딩 생성"""
if not self.openai_client:
raise ValueError("OpenAI 클라이언트 없음")
# OpenAI는 한 번에 최대 2048개 텍스트 처리
embeddings = []
batch_size = 100
for i in range(0, len(texts), batch_size):
batch = texts[i:i+batch_size]
response = self.openai_client.embeddings.create(
model="text-embedding-3-small",
input=batch
)
embeddings.extend([d.embedding for d in response.data])
logger.info(f"임베딩 생성: {i+len(batch)}/{len(texts)}")
return embeddings
def _extract_product_info(self, content: str) -> Dict[str, str]:
"""
MD 파일 상단에서 제품 정보 추출
- 제품명 (한글/영문)
- 성분
- 대상 동물
"""
info = {"product_name": "", "ingredients": "", "target_animal": ""}
# # 제목에서 제품명 추출 (예: "# 복합 개시딘 겔 - 표면성...")
title_match = re.search(r'^# (.+?)(?:\s*[-–—]|$)', content, re.MULTILINE)
if title_match:
info["product_name"] = title_match.group(1).strip()
# > 성분: 라인에서 추출
ingredient_match = re.search(r'>\s*성분[:\s]+(.+?)(?:\n|$)', content)
if ingredient_match:
info["ingredients"] = ingredient_match.group(1).strip()[:100] # 100자 제한
# 대상 동물 추출 (테이블에서)
animal_match = re.search(r'\*\*대상\s*동물\*\*[^\|]*\|\s*([^\|]+)', content)
if animal_match:
info["target_animal"] = animal_match.group(1).strip()
return info
def _make_chunk_prefix(self, product_info: Dict[str, str]) -> str:
"""청크 prefix 생성"""
parts = []
if product_info["product_name"]:
parts.append(f"제품명: {product_info['product_name']}")
if product_info["target_animal"]:
parts.append(f"대상: {product_info['target_animal']}")
if product_info["ingredients"]:
parts.append(f"성분: {product_info['ingredients']}")
if parts:
return "[" + " | ".join(parts) + "]\n\n"
return ""
def chunk_markdown(self, content: str, source_file: str) -> List[Dict]:
"""
마크다운 청킹 (섹션 기반 + 제품명 prefix)
"""
chunks = []
# 제품 정보 추출 & prefix 생성
product_info = self._extract_product_info(content)
prefix = self._make_chunk_prefix(product_info)
# ## 헤더 기준 분리
sections = re.split(r'\n(?=## )', content)
for i, section in enumerate(sections):
if not section.strip():
continue
# 섹션 제목 추출
title_match = re.match(r'^## (.+?)(?:\n|$)', section)
section_title = title_match.group(1).strip() if title_match else f"섹션{i+1}"
# prefix + section 결합
prefixed_section = prefix + section
# 큰 섹션은 추가 분할
if len(prefixed_section) > CHUNK_SIZE:
sub_chunks = self._split_by_size(prefixed_section, CHUNK_SIZE, CHUNK_OVERLAP)
for j, sub_chunk in enumerate(sub_chunks):
# 분할된 청크에도 prefix 보장 (overlap으로 잘렸을 경우)
if j > 0 and not sub_chunk.startswith("["):
sub_chunk = prefix + sub_chunk
chunk_id = f"{source_file}#{section_title}#{j}"
chunks.append({
"id": chunk_id,
"text": sub_chunk,
"source": source_file,
"section": section_title,
"chunk_index": j,
"product_name": product_info["product_name"]
})
else:
chunk_id = f"{source_file}#{section_title}"
chunks.append({
"id": chunk_id,
"text": prefixed_section,
"source": source_file,
"section": section_title,
"chunk_index": 0,
"product_name": product_info["product_name"]
})
return chunks
def _split_by_size(self, text: str, size: int, overlap: int) -> List[str]:
"""텍스트를 크기 기준으로 분할"""
chunks = []
start = 0
while start < len(text):
end = start + size
# 문장 경계에서 자르기
if end < len(text):
last_break = text.rfind('\n', start, end)
if last_break == -1:
last_break = text.rfind('. ', start, end)
if last_break > start + size // 2:
end = last_break + 1
chunks.append(text[start:end])
start = end - overlap
return chunks
def index_md_files(self, md_path: Path = None) -> int:
"""
MD 파일들을 인덱싱
"""
self._init_db()
md_path = md_path or MD_DOCS_PATH
if not md_path.exists():
logger.error(f"MD 파일 경로 없음: {md_path}")
return 0
# 기존 테이블 삭제
if TABLE_NAME in self.db.table_names():
self.db.drop_table(TABLE_NAME)
logger.info("기존 테이블 삭제")
# 모든 청크 수집
all_chunks = []
md_files = list(md_path.glob("*.md"))
for md_file in md_files:
try:
content = md_file.read_text(encoding='utf-8')
chunks = self.chunk_markdown(content, md_file.name)
all_chunks.extend(chunks)
logger.info(f"청킹: {md_file.name} ({len(chunks)}개)")
except Exception as e:
logger.error(f"청킹 실패 ({md_file.name}): {e}")
if not all_chunks:
logger.warning("청크 없음")
return 0
# 임베딩 생성
texts = [c["text"] for c in all_chunks]
logger.info(f"{len(texts)}개 청크 임베딩 시작...")
embeddings = self._get_embeddings_batch(texts)
# 데이터 준비
data = []
for chunk, emb in zip(all_chunks, embeddings):
data.append({
"id": chunk["id"],
"text": chunk["text"],
"source": chunk["source"],
"section": chunk["section"],
"chunk_index": chunk["chunk_index"],
"product_name": chunk.get("product_name", ""),
"vector": emb
})
# 테이블 생성
self.table = self.db.create_table(TABLE_NAME, data)
logger.info(f"인덱싱 완료: {len(data)}개 청크")
return len(data)
def search(self, query: str, n_results: int = 5) -> List[Dict]:
"""
유사도 검색
"""
self._init_db()
if self.table is None:
logger.warning("테이블 없음 - index_md_files() 필요")
return []
try:
# 쿼리 임베딩
query_emb = self._get_embedding(query)
# 검색
results = self.table.search(query_emb).limit(n_results).to_list()
output = []
for r in results:
# L2 거리 (0~∞) → 유사도 (1~0)
# 거리가 작을수록 유사도 높음
distance = r.get("_distance", 10)
score = 1 / (1 + distance) # 0~1 범위로 변환
# 임계값: 유사도 0.2 미만은 제외 (관련 없는 문서)
# L2 거리 4.0 이상이면 제외
if score < 0.2:
continue
output.append({
"text": r["text"],
"source": r["source"],
"section": r["section"],
"score": score
})
return output
except Exception as e:
logger.error(f"검색 실패: {e}")
return []
def get_context_for_chat(self, query: str, n_results: int = 3) -> str:
"""
챗봇용 컨텍스트 생성
"""
results = self.search(query, n_results)
if not results:
return ""
context_parts = ["## 📚 관련 문서 (RAG 검색 결과)"]
for i, r in enumerate(results, 1):
source = r["source"].replace(".md", "")
section = r["section"]
score = r["score"]
text = r["text"][:1500]
context_parts.append(f"\n### [{i}] {source} - {section} (관련도: {score:.0%})")
context_parts.append(text)
return "\n".join(context_parts)
def get_stats(self) -> Dict:
"""통계 정보 반환"""
self._init_db()
count = len(self.table) if self.table else 0
return {
"table_name": TABLE_NAME,
"document_count": count,
"db_path": str(LANCE_DB_PATH)
}
# 싱글톤 인스턴스
_rag_instance: Optional[AnimalDrugRAG] = None
def get_animal_rag(api_key: str = None) -> AnimalDrugRAG:
"""싱글톤 RAG 인스턴스 반환"""
global _rag_instance
if _rag_instance is None:
_rag_instance = AnimalDrugRAG(api_key)
return _rag_instance
# CLI 테스트
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO)
rag = AnimalDrugRAG()
if len(sys.argv) > 1:
cmd = sys.argv[1]
if cmd == "index":
count = rag.index_md_files()
print(f"\n{count}개 청크 인덱싱 완료")
elif cmd == "search" and len(sys.argv) > 2:
query = " ".join(sys.argv[2:])
results = rag.search(query)
print(f"\n🔍 검색: {query}")
for r in results:
print(f"\n[{r['score']:.0%}] {r['source']} - {r['section']}")
print(r['text'][:300] + "...")
elif cmd == "stats":
stats = rag.get_stats()
print(f"\n📊 통계:")
print(f" - 테이블: {stats['table_name']}")
print(f" - 문서 수: {stats['document_count']}")
print(f" - DB 경로: {stats['db_path']}")
else:
print("사용법:")
print(" python animal_rag.py index # MD 파일 인덱싱")
print(" python animal_rag.py search 질문 # 검색")
print(" python animal_rag.py stats # 통계")

160
backend/utils/drug_unit.py Normal file
View File

@ -0,0 +1,160 @@
"""
약품 포장단위 판별 유틸리티
SUNG_CODE 기반으로 약품의 단위(, 캡슐, mL, ) 판별
참고: person-lookup-web-local/dev_docs/pharmit_3000db_sung_code.md
"""
import re
# FormCode -> 기본 단위 매핑
FORM_CODE_UNIT_MAP = {
# 정제류
'TA': '', 'TB': '', 'TC': '', 'TD': '', 'TE': '',
'TF': '', 'TG': '', 'TH': '', 'TL': '', 'TR': '',
# 캡슐류
'CA': '캡슐', 'CB': '캡슐', 'CC': '캡슐', 'CD': '캡슐', 'CE': '캡슐',
'CH': '캡슐', 'CR': '캡슐', 'CS': '캡슐',
# 과립/산제
'GA': '', 'GB': '', 'GC': '', 'GN': '', 'PD': '',
# 액상제
'SS': 'mL', 'SY': 'mL', 'LQ': 'mL', 'SI': '앰플',
# 외용제
'EY': '', 'EN': '', 'EO': '', 'OS': '', 'OO': '튜브',
'GT': '', 'OT': '', 'OM': '', 'CT': '', 'CM': '',
'LT': '', 'PT': '', 'PC': '', 'SP': '',
# 좌제/질정
'SU': '', 'VT': '',
# 주사제
'IN': '바이알', 'IA': '앰플', 'IJ': '바이알', 'IP': '프리필드',
# 흡입제
'IH': '', 'NE': '앰플',
}
def get_drug_unit(goods_name: str, sung_code: str) -> str:
"""
약품명과 SUNG_CODE를 기반으로 포장단위를 판별
Args:
goods_name: 약품명 (: "씨투스건조시럽_(0.5g)")
sung_code: SUNG_CODE (: "100701ATB" - 마지막 2자리가 FormCode)
Returns:
포장단위 문자열 (: "", "캡슐", "mL", "" )
"""
if not sung_code or len(sung_code) < 2:
return '' # 기본값
# FormCode 추출 (SUNG_CODE 마지막 2자리)
form_code = sung_code[-2:].upper()
# 건조시럽(SS) / 시럽(SY) 특수 처리
if form_code in ('SS', 'SY'):
return _get_syrup_unit(goods_name)
# 점안액(EY, OS) 특수 처리
if form_code in ('EY', 'OS'):
return _get_eye_drop_unit(goods_name)
# 안연고(OO) 특수 처리
if form_code == 'OO':
if '안연고' in goods_name or '눈연고' in goods_name:
return '튜브'
return ''
# 액제(LQ) 특수 처리
if form_code == 'LQ':
return _get_liquid_unit(goods_name)
# 파우더/산제(PD, GN) 특수 처리
if form_code in ('PD', 'GN'):
return _get_powder_unit(goods_name)
# 흡입제/스프레이(SI) 특수 처리
if form_code == 'SI':
if '흡입액' in goods_name or '네뷸' in goods_name:
return '앰플'
return ''
# 기본 매핑에서 찾기
return FORM_CODE_UNIT_MAP.get(form_code, '')
def _get_syrup_unit(goods_name: str) -> str:
"""시럽/건조시럽 단위 판별"""
# 개별 g 포장: (0.5g), (0.7g) 등 -> 포
if re.search(r'\([\d.]+g\)', goods_name):
return ''
# g/Xg 벌크 패턴 -> g
if re.search(r'_\([^)]+/\d+g\)', goods_name):
return 'g'
# 건조시럽/현탁용분말 -> mL
if '건조시럽' in goods_name or '현탁용분말' in goods_name:
return 'mL'
# 소용량 mL (5~30mL) -> 포
match = re.search(r'[_(/](\d+)mL\)', goods_name, re.IGNORECASE)
if match:
volume = int(match.group(1))
if volume <= 30:
return ''
return 'mL'
def _get_eye_drop_unit(goods_name: str) -> str:
"""점안액 단위 판별"""
# 소용량 (1mL 이하) = 일회용 -> 개
match = re.search(r'[_/\(]([\d.]+)mL\)', goods_name)
if match:
try:
volume = float(match.group(1))
if volume <= 1.0:
return ''
except ValueError:
pass
return ''
def _get_liquid_unit(goods_name: str) -> str:
"""액제 단위 판별"""
# 알긴산/거드액 -> 포
if '알긴' in goods_name or '거드' in goods_name:
return ''
# 외용액 -> 병
if any(k in goods_name for k in ['외용', '네일', '라카', '베이트', '더마톱', '라미실']):
return ''
# 점이/점비액 -> 병
if '점비' in goods_name or '이용액' in goods_name:
return ''
# 흡입액 -> 앰플
if '흡입' in goods_name or '네뷸' in goods_name:
return '앰플'
return 'mL'
def _get_powder_unit(goods_name: str) -> str:
"""파우더/산제 단위 판별"""
# 분모 10g 이상 = 벌크 -> g
match = re.search(r'_\([^)]+/(\d+(?:\.\d+)?)g\)', goods_name)
if match:
try:
denominator = float(match.group(1))
if denominator >= 10:
return 'g'
except ValueError:
pass
return ''

View File

@ -0,0 +1,260 @@
# API 개발 가이드 및 트러블슈팅
## 📋 목차
1. [도매상 주문 API 응답 형식](#도매상-주문-api-응답-형식)
2. [동원약품 API 버그 수정](#동원약품-api-버그-수정)
---
## 도매상 주문 API 응답 형식
### `/api/order/quick-submit` 응답 표준
모든 도매상(지오영, 수인, 백제, 동원)의 주문 응답은 **동일한 형식**을 따라야 합니다:
```json
{
"success": true,
"dry_run": true,
"cart_only": false,
"order_id": 123,
"order_no": "ORD-20260308-001",
"wholesaler": "dongwon",
"total_items": 1,
"success_count": 1,
"failed_count": 0,
"results": [
{
"item_id": 456,
"drug_code": "643900470",
"product_name": "부루펜정200mg",
"specification": "500정(병)",
"order_qty": 1,
"status": "success",
"result_code": "OK",
"result_message": "[DRY RUN] 주문 가능: 재고 9, 단가 17,000원",
"price": 17000
}
],
"note": "장바구니에 담김. 도매상 사이트에서 최종 확정 필요."
}
```
### ⚠️ 필수 필드
| 필드 | 설명 | 비고 |
|------|------|------|
| `wholesaler` | 도매상 ID | 프론트엔드에서 결과 모달 표시에 사용 |
| `success_count` | 성공 개수 | 최상위 레벨에 있어야 함 (summary 안에만 있으면 안됨) |
| `failed_count` | 실패 개수 | 최상위 레벨에 있어야 함 |
| `order_no` | 주문번호 | 프론트엔드 결과 모달에 표시 |
---
## 동원약품 API 버그 수정
### 📅 수정일: 2026-03-08
### 🐛 문제
**증상:**
- 동원약품으로 주문하면 결과 모달에 "**지오영 주문 결과**"로 표시됨
- 성공/실패 개수가 "**undefined**"로 표시됨
**원인:**
`submit_dongwon_order()` 함수의 응답에 다음 필드가 누락됨:
1. `wholesaler` 필드 없음
2. `success_count`, `failed_count``summary` 객체 안에만 있음 (최상위에 없음)
3. `order_no` 필드 없음
### 🔧 수정 내용
**파일:** `backend/order_api.py`
**수정 전 (dry_run 응답):**
```python
return {
'success': True,
'dry_run': True,
'results': results,
'summary': {
'total': len(items),
'success': success_count,
'failed': failed_count
}
}
```
**수정 후:**
```python
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
}
```
### ✅ 검증
테스트 절차:
1. `http://localhost:7001/admin/rx-usage` 접속
2. 테이블에서 약품 더블클릭 → 도매상 재고 모달 열기
3. 동원약품 섹션에서 "담기" 버튼 클릭
4. 장바구니에서 "주문서 생성하기" 클릭
5. "🧪 테스트" 버튼 클릭
6. 결과 모달에서 확인:
- 제목: "🏥 **동원약품** 주문 결과"
- 성공: "1개"
- 실패: "0개"
---
## 프론트엔드 장바구니 구조
### `addToCartFromWholesale()` 함수
동원약품에서 "담기" 버튼 클릭 시 장바구니에 추가되는 아이템 구조:
```javascript
const cartItem = {
drug_code: '643900470',
product_name: '부루펜정200mg',
supplier: '동원약품',
qty: 1,
specification: '500정(병)',
wholesaler: 'dongwon', // ← 필터링에 사용
internal_code: '16045',
dongwon_code: '16045', // ← 동원 API 호출에 사용
unit_price: 17000
};
```
### 도매상 필터링 로직
```javascript
const WHOLESALERS = {
dongwon: {
filterFn: (item) => item.supplier === '동원약품' || item.wholesaler === 'dongwon'
}
};
```
---
## 📝 개발 시 체크리스트
새로운 도매상 API 추가 시:
- [ ] `submit_xxx_order()` 함수 응답에 `wholesaler` 필드 포함
- [ ] `success_count`, `failed_count` 최상위 레벨에 포함
- [ ] `order_no` 필드 포함
- [ ] 프론트엔드 `WHOLESALERS` 객체에 도매상 추가
- [ ] `filterFn` 함수 정의
- [ ] E2E 테스트 수행
---
## 주문량 조회 API (summary-by-kd)
### 📅 추가일: 2025-07-14
### 📋 개요
전문의약품 사용량 페이지(`/admin/rx-usage`)의 "주문량" 컬럼은 도매상별 주문량을 KD 코드 기준으로 합산하여 표시합니다.
### ⚠️ 필수 구현: `/orders/summary-by-kd` 엔드포인트
**새로운 도매상 추가 시 반드시 구현해야 합니다!**
#### 요청
```
GET /api/{wholesaler}/orders/summary-by-kd?start_date=2025-07-01&end_date=2025-07-14
```
#### 응답 형식 (표준)
```json
{
"success": true,
"order_count": 4,
"period": {
"start": "2025-07-01",
"end": "2025-07-14"
},
"by_kd_code": {
"670400830": {
"product_name": "레바미피드정100mg",
"spec": "100T",
"boxes": 2,
"units": 200
},
"643900470": {
"product_name": "부루펜정200mg",
"spec": "500정(병)",
"boxes": 1,
"units": 500
}
},
"total_products": 2
}
```
### 현재 구현 상태
| 도매상 | 엔드포인트 | KD 코드 집계 | 비고 |
|--------|------------|--------------|------|
| 지오영 | `/api/geoyoung/orders/summary-by-kd` | ✅ | 정상 작동 |
| 수인 | `/api/sooin/orders/summary-by-kd` | ✅ | 정상 작동 |
| 백제 | `/api/baekje/orders/summary-by-kd` | ✅ | 정상 작동 |
| 동원 | `/api/dongwon/orders/summary-by-kd` | ⚠️ | 주문 건수만 제공, 품목별 집계 불가 |
### 동원약품 한계
동원약품 API(`onLineOrderListAX`)는 주문 목록만 반환하고, 각 주문의 상세 품목(items)을 제공하지 않습니다.
**향후 개선 필요:**
- 동원 주문 상세 조회 API 탐색 필요
- 또는 주문 상세 페이지 크롤링 구현
### 프론트엔드 연동
`admin_rx_usage.html``loadOrderData()` 함수:
```javascript
// 4사 병렬 조회
const [geoRes, sooinRes, baekjeRes, dongwonRes] = await Promise.all([
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`),
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`),
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`),
fetch(`/api/dongwon/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`)
]);
// 각 도매상 데이터를 KD 코드 기준으로 합산
if (dongwonRes.success && dongwonRes.by_kd_code) {
for (const [kd, data] of Object.entries(dongwonRes.by_kd_code)) {
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('동원');
}
}
```
### 📝 새 도매상 추가 시 체크리스트
- [ ] `{wholesaler}_api.py``/orders/summary-by-kd` 엔드포인트 구현
- [ ] 응답 형식 표준 준수 (`by_kd_code`, `order_count` 등)
- [ ] `admin_rx_usage.html``loadOrderData()`에 새 도매상 추가
- [ ] 합산 로직에 새 도매상 데이터 추가
- [ ] API 테스트 수행
---
*마지막 업데이트: 2025-07-14*

View File

@ -0,0 +1,410 @@
# 약국 POS DB 구조 - 제품/입고/판매/마진
> **분석일**: 2026-03-13
> **DB**: PM_DRUG, PM_PRES (MSSQL, 192.168.0.4\PM2014)
> **목적**: 입고 기록 없이 판매 시 마진이 0으로 나오는 문제 원인 파악
---
## 1. 개요
약국 POS 시스템(PharmIT3000)은 크게 3개의 DB를 사용합니다:
| DB명 | 용도 | 주요 테이블 |
|------|------|------------|
| **PM_DRUG** | 약품 마스터, 재고, 입고 | CD_GOODS, IM_total, WH_main/sub |
| **PM_PRES** | 판매, 처방 | SALE_MAIN/SUB, PS_main/sub_pharm |
| **PM_BASE** | 고객, 도매상 정보 | CD_PERSON, CD_custom |
---
## 2. 핵심 테이블
### 2.1 제품 마스터 - PM_DRUG.CD_GOODS
```sql
-- 핵심 컬럼
DrugCode NVARCHAR(20) -- 제품코드 (PK)
GoodsName NVARCHAR(80) -- 제품명
BARCODE NVARCHAR(20) -- 대표 바코드
Price DECIMAL -- 입고가 (매입가) ⭐ 마진 계산의 핵심
Saleprice DECIMAL -- 판매가
SUNG_CODE NVARCHAR(9) -- 성분코드
POS_BOON NVARCHAR(6) -- 분류 (010103=동물약)
GoodsSelCode NVARCHAR(2) -- 사용여부 (B=사용, !=미사용)
```
### 2.2 바코드 매핑
#### CD_BARCODE - 표준코드 ↔ 바코드 매핑
```sql
BARCODE NVARCHAR(20) -- 바코드
BASECODE NVARCHAR(10) -- 표준코드 (EDI 코드)
DRUGCODE NVARCHAR(10) -- 제품코드
TITLECODE NVARCHAR(20) -- 대표코드
```
#### CD_ITEM_UNIT_MEMBER - 단위별 바코드 (낱개/박스)
```sql
DRUGCODE NVARCHAR(20) -- 제품코드
CD_CD_UNIT NVARCHAR(3) -- 단위코드
CD_NM_UNIT REAL -- 단위수량
CD_CD_BARCODE NVARCHAR(20) -- 단위 바코드 ⭐ APC 코드 (02로 시작)
CD_MY_UNIT DECIMAL -- 단위가격
```
> **APC 코드**: `02`로 시작하는 13자리 바코드 = 동물약 식별 코드
> 예: CD_ITEM_UNIT_MEMBER에서 `CD_CD_BARCODE LIKE '02%'`
### 2.3 재고 테이블
#### IM_total - 현재 재고
```sql
DrugCode NVARCHAR(12) -- 제품코드
IM_QT_sale_debit FLOAT -- 현재 재고 수량
```
#### IM_date_total - 일별 재고 변동
```sql
IM_DT_appl NVARCHAR(8) -- 날짜 (YYYYMMDD)
DrugCode NVARCHAR(12) -- 제품코드
IM_QT_sale_credit DECIMAL -- 입고량
im_qt_sale_debit DECIMAL -- 출고량
```
### 2.4 입고 테이블 (PM_DRUG)
#### WH_main - 입고 마스터
```sql
WH_NO_stock NVARCHAR(14) -- 입고번호 (PK)
WH_DT_appl NVARCHAR(8) -- 입고일 (YYYYMMDD)
WH_CD_cust_sale NVARCHAR(10) -- 도매상코드 (→ PM_BASE.CD_custom)
WH_BUSINAME NVARCHAR(200) -- 도매상명
WH_MY_amount_t DECIMAL -- 입고 총액
```
#### WH_sub - 입고 상세
```sql
WH_SR_stock NVARCHAR(14) -- 입고번호 (FK → WH_main)
DrugCode NVARCHAR(20) -- 제품코드
WH_DT_appl NVARCHAR(8) -- 입고일
WH_NM_item_a DECIMAL -- 입고 수량 ⭐
WH_MY_unit_a DECIMAL -- 입고 단가 ⭐
WH_MY_amount_a DECIMAL -- 입고 금액 (수량 × 단가)
WH_END_validity NVARCHAR(8) -- 유효기한
WH_LOT_NO NVARCHAR(20) -- LOT 번호
```
### 2.5 판매 테이블 (PM_PRES)
#### SALE_MAIN - 판매 마스터
```sql
SL_NO_order NVARCHAR(14) -- 거래번호 (PK)
SL_DT_appl NVARCHAR(8) -- 판매일
SL_CD_custom NVARCHAR(10) -- 고객코드
InsertTime DATETIME -- 등록시간
SL_MY_total DECIMAL -- 총액
SL_MY_sale DECIMAL -- 판매액 ⭐
SL_MY_sale_cost DECIMAL -- 원가 합계 ⭐ (마진 계산용)
SL_MY_discount DECIMAL -- 할인액
```
#### SALE_SUB - 판매 상세
```sql
SL_NO_order NVARCHAR(14) -- 거래번호 (FK)
DrugCode NVARCHAR(20) -- 제품코드
SL_NM_item DECIMAL -- 판매 수량
SL_NM_cost_a DECIMAL -- 판매 단가
SL_TOTAL_PRICE DECIMAL -- 판매 금액 (수량 × 단가)
INPRICE DECIMAL -- 입고가 ⭐⭐⭐ 마진 계산의 핵심!
SL_MY_in_cost DECIMAL -- 입고 원가 (= INPRICE)
SL_INPUT_PRICE DECIMAL -- 입력가격
BARCODE NVARCHAR(20) -- 판매 시 사용된 바코드
```
---
## 3. 제품/바코드 구조
### 3.1 코드 체계
```
제품코드 (DrugCode)
├── 전문의약품: 9자리 숫자 (예: 652606580)
├── 일반의약품: 9자리 숫자
└── 자체등록: LB로 시작 (예: LB000003778)
바코드 종류
├── 대표바코드: CD_GOODS.BARCODE
├── 단위바코드: CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE
├── APC 코드: 02/92로 시작하는 13자리 (동물약)
└── 표준코드: CD_BARCODE.BASECODE (EDI 연동용)
```
### 3.2 바코드 조회 순서
```sql
-- 제품의 바코드 찾기 (우선순위)
1. CD_GOODS.BARCODE -- 대표 바코드
2. CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE -- 단위 바코드 (낱개/박스)
3. CD_BARCODE.BARCODE -- 표준코드 매핑
```
---
## 4. 입고 흐름
### 4.1 입고 기록 생성
```
도매상 발주 → 입고 등록
┌─────────────┐
│ WH_main │ 입고 마스터 생성
│ (입고번호) │ - 도매상코드
└─────┬───────┘ - 입고일
┌─────────────┐
│ WH_sub │ 입고 상세 생성
│ (품목별) │ - 제품코드
└─────┬───────┘ - 수량, 단가 ⭐
┌─────────────┐
│ IM_total │ 재고 증가
└─────────────┘
```
### 4.2 입고 시 가격 업데이트
입고 처리 시 **CD_GOODS.Price** (입고가)가 업데이트될 수 있음:
```sql
-- 최근 입고 단가로 CD_GOODS.Price 업데이트 (POS 설정에 따름)
UPDATE CD_GOODS
SET Price = (최근 입고 단가)
WHERE DrugCode = ?
```
> **중요**: POS 설정에 따라 입고 시 자동 업데이트 여부가 결정됨
---
## 5. 판매 흐름
### 5.1 판매 기록 생성
```
바코드 스캔 → 판매 등록
┌─────────────┐
│ SALE_MAIN │ 판매 마스터 생성
│ (거래번호) │ - 고객코드, 날짜
└─────┬───────┘ - 총액, 원가합계 (SL_MY_sale_cost)
┌─────────────┐
│ SALE_SUB │ 판매 상세 생성 (품목별)
│ │ - 제품코드
│ │ - 판매가 (SL_NM_cost_a)
│ │ - 입고가 (INPRICE) ⭐
└─────┬───────┘
┌─────────────┐
│ IM_total │ 재고 감소
└─────────────┘
```
### 5.2 판매 시 INPRICE 설정 (핵심!)
**판매 시 SALE_SUB.INPRICE는 CD_GOODS.Price에서 가져옴**
```sql
-- 판매 등록 시 (POS 시스템 내부 로직 추정)
INSERT INTO SALE_SUB (
SL_NO_order, DrugCode, SL_NM_item, SL_NM_cost_a,
INPRICE, -- CD_GOODS.Price 값 사용
...
) VALUES (
@거래번호, @제품코드, @수량, @판매단가,
(SELECT Price FROM CD_GOODS WHERE DrugCode = @제품코드),
...
)
```
> **실제 확인 결과**: SALE_SUB.INPRICE ≈ CD_GOODS.Price (99% 일치)
---
## 6. 마진 계산 로직
### 6.1 거래별 마진 계산
```sql
-- SALE_MAIN에서 마진 계산
마진액 = SL_MY_sale (판매액) - SL_MY_sale_cost (원가합계)
마진율 = (판매액 - 원가합계) / 판매액 × 100
-- SL_MY_sale_cost 계산 (내부 로직)
SL_MY_sale_cost = SUM(SALE_SUB.INPRICE × SALE_SUB.SL_NM_item)
-- 입고가 × 판매수량의 합계
```
### 6.2 품목별 마진 계산
```sql
-- SALE_SUB에서 품목별 마진
품목_마진 = SL_TOTAL_PRICE - (INPRICE × SL_NM_item)
= 판매금액 - (입고가 × 수량)
```
### 6.3 마진 계산 예시
```
거래 20260313000076:
├── 판매액: 64,500원
├── 원가: 31,650원 ← SALE_SUB.INPRICE 합계
├── 마진: 32,850원
└── 마진율: 50.9%
```
---
## 7. 문제점: 입고 없이 판매 시 마진 0
### 7.1 문제 원인
**INPRICE = 0이 되는 경우:**
1. **CD_GOODS.Price가 0 또는 NULL인 경우**
- 제품 등록 시 입고가를 설정하지 않음
- 입고 기록 없이 제품만 등록
2. **POS 시스템 특수 케이스**
- 일부 상황에서 INPRICE가 0으로 설정됨
- (정확한 조건은 POS 내부 로직에 따름)
### 7.2 실제 데이터 분석 (2026-03-13 기준)
```
최근 1개월 판매 건수: 1,767건
├── INPRICE > 0: 1,749건 (98.98%)
└── INPRICE = 0: 18건 (1.02%) ← 마진 0 문제 발생!
```
### 7.3 INPRICE=0 사례 분석
| 제품코드 | 제품명 | CD_GOODS.Price | SALE_SUB.INPRICE | 입고기록 |
|----------|--------|----------------|------------------|----------|
| LB000003658 | 수리팍(제로슈거) | 1,450 | **0** | 있음 |
| LB000003575 | 알파플러스정 | 1 | **0** | 있음 |
| LB000001822 | 헤파토스시럽 | 1,613 | **0** | 있음 |
> **특이사항**: 입고 기록이 있고 CD_GOODS.Price도 있는데 INPRICE=0인 경우 존재
> → POS 특수 상황에서 발생 (추가 조사 필요)
### 7.4 해결 방안
#### 방안 1: 제품 등록 시 입고가 필수 입력
```sql
-- 제품 등록 시 Price 필수 체크
IF Price IS NULL OR Price = 0 THEN
ERROR '입고가를 입력하세요'
```
#### 방안 2: 판매 전 입고 기록 확인
```sql
-- 판매 시 입고 기록 확인
IF NOT EXISTS (SELECT 1 FROM WH_sub WHERE DrugCode = @코드) THEN
WARNING '입고 기록 없음. 마진 계산 불가'
```
#### 방안 3: INPRICE 자동 채우기
```sql
-- INPRICE=0인 경우 CD_GOODS.Price로 업데이트
UPDATE SALE_SUB
SET INPRICE = (SELECT Price FROM CD_GOODS WHERE DrugCode = SALE_SUB.DrugCode)
WHERE INPRICE = 0
```
---
## 8. 관련 API 목록 (app.py)
| 엔드포인트 | 기능 | 사용 테이블 |
|------------|------|-------------|
| `/api/products` | 제품 검색 | CD_GOODS, IM_total, CD_ITEM_UNIT_MEMBER |
| `/api/drugs/<code>/purchase-history` | 입고 이력 | WH_main, WH_sub |
| `/api/sales-detail` | 판매 상세 | SALE_SUB, CD_GOODS |
| `/api/usage` | 기간별 사용량 | SALE_SUB, CD_GOODS |
| `/api/rx-usage` | 처방 사용량 | PS_sub_pharm, PS_main |
| `/admin/transaction/<id>` | 거래 상세 | SALE_MAIN, SALE_SUB |
---
## 9. 테이블 관계도
```
PM_DRUG PM_PRES
======== ========
CD_GOODS ────────────────────────┐
│ DrugCode (PK) │
│ │
├── CD_ITEM_UNIT_MEMBER │
│ (단위바코드) │
│ │
├── CD_BARCODE │
│ (표준코드 매핑) │
│ │
├── IM_total │
│ (현재 재고) │
│ │
├── WH_sub ◄─── WH_main │
│ (입고 상세) (입고 마스터)│
│ │
└──────────────────────────────┼──► SALE_SUB ◄─── SALE_MAIN
│ (판매 상세) (판매 마스터)
│ │
│ │ INPRICE = CD_GOODS.Price
│ │
└─────────┘
마진 계산:
SALE_MAIN.SL_MY_sale_cost = Σ(SALE_SUB.INPRICE × 수량)
마진 = SL_MY_sale - SL_MY_sale_cost
```
---
## 10. 핵심 요약
### 10.1 마진 계산 흐름
```
입고 등록 (WH_sub)
CD_GOODS.Price 업데이트 (입고가)
판매 등록 (SALE_SUB)
SALE_SUB.INPRICE ← CD_GOODS.Price ⭐
SALE_MAIN.SL_MY_sale_cost = Σ(INPRICE × 수량)
마진 = 판매액 - 원가
```
### 10.2 문제 원인
- **INPRICE = 0**이면 마진 = 판매액 (100% 마진처럼 보이지만 실제로는 잘못된 데이터)
- **CD_GOODS.Price = 0**이면 판매 시 INPRICE도 0
### 10.3 권장 조치
1. 제품 등록 시 입고가(Price) 필수 입력 강제
2. 입고 처리 후 판매 권장 (입고 기록 없으면 경고)
3. 마진 리포트에서 INPRICE=0인 건 별도 표시/경고
---
*분석: 용림 (Yongrim) | 2026-03-13*

View File

@ -0,0 +1,129 @@
# 동원약품 rx-usage 프론트엔드 연동 트러블슈팅
**작성일**: 2025-07-14
**수정 파일**: `backend/templates/admin_rx_usage.html`
## 발견된 문제점 3가지
### 문제 1: 재고 모달에서 KD코드가 아닌 내부코드 표시
**증상**: 동원약품만 재고 모달에서 내부코드(예: 16045, A4394)가 표시됨. 다른 도매상(지오영, 수인, 백제)은 KD코드(보험코드)가 정상 표시됨.
**원인**: `renderWholesaleResults()` 함수의 동원 섹션에서 `item.internal_code`를 표시함
```javascript
// 잘못된 코드
<span class="geo-code">${item.internal_code || ''} · ${item.manufacturer || ''}</span>
```
**해결**: 동원 API는 `code`에 KD코드(보험코드)를, `internal_code`에 내부코드를 반환함. 표시용은 `code` 사용.
```javascript
// 수정된 코드
const displayCode = item.code || item.internal_code || '';
<span class="geo-code">${displayCode} · ${item.manufacturer || ''}</span>
```
### 문제 2: 장바구니 "주문서 생성하기"에 동원 미포함
**증상**: 장바구니에 동원 상품을 담아도 "주문서 생성하기" 모달에 동원이 나오지 않음.
**원인**: `WHOLESALERS` 객체에 동원 설정 누락
```javascript
// 기존 코드 - 동원 없음
const WHOLESALERS = {
geoyoung: {...},
sooin: {...},
baekje: {...}
};
```
**해결**: `WHOLESALERS`에 동원 추가
```javascript
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
}
```
### 문제 3: 장바구니에서 "dongwon"으로 표시
**증상**: 동원 상품이 장바구니에 담기면 "동원약품" 대신 "dongwon"으로 표시됨.
**원인**: `addToCartFromWholesale()` 함수의 `supplierNames` 객체에 동원 누락
```javascript
// 기존
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' };
```
**해결**: 동원 추가
```javascript
const supplierNames = {
geoyoung: '지오영',
sooin: '수인약품',
baekje: '백제약품',
dongwon: '동원약품'
};
```
## 추가 수정 사항
### 1. 장바구니 아이템에 dongwon_code 필드 추가
```javascript
const cartItem = {
...
dongwon_code: wholesaler === 'dongwon' ? item.internal_code : null,
...
};
```
동원은 장바구니 담기/주문 시 `internal_code`를 사용해야 함.
### 2. CSS 스타일 - 다중 도매상 모달 카드 색상
```css
.multi-ws-card.dongwon {
border-left: 3px solid #22c55e;
}
```
## 동원약품 API 필드 매핑
| API 필드 | 의미 | 용도 |
|----------|------|------|
| `code` | KD코드 (보험코드) | 화면 표시용 |
| `internal_code` | 동원 내부코드 | 장바구니 담기/주문 시 사용 |
| `name` | 제품명 | 표시용 |
| `manufacturer` | 제조사 | 표시용 |
| `spec` | 규격 | 표시용 |
| `price` | 단가 | 표시용 |
| `stock` | 재고 | 표시용 |
## 관련 파일
- **프론트엔드**: `backend/templates/admin_rx_usage.html`
- **동원 API**: `backend/dongwon_api.py`
- **동원 세션**: `pharmacy-wholesale-api/wholesale/dongwon.py`
## 테스트 방법
1. 전문의약품 사용량 페이지 접속: http://localhost:7001/admin/rx-usage
2. 약품 행 더블클릭하여 재고 모달 열기
3. 동원약품 섹션에서:
- KD코드(9자리 숫자)가 표시되는지 확인
- "담기" 버튼 클릭하여 장바구니 추가
4. 장바구니 열어서:
- "동원약품"으로 표시되는지 확인
5. "주문서 생성하기" 클릭하여:
- 동원약품이 도매상 목록에 나타나는지 확인

193
docs/DRYSYRUP_CONVERSION.md Normal file
View File

@ -0,0 +1,193 @@
# 건조시럽 환산계수 기능
## 개요
건조시럽(dry syrup)은 물로 희석하여 복용하는 시럽 형태의 의약품입니다. 복용량을 mL로 표시하지만, 실제 약 성분의 양은 g(그램)으로 환산해야 정확합니다.
**환산계수(conversion_factor)**를 사용하여 총 복용량(mL)을 실제 성분량(g)으로 변환합니다.
### 예시
- 오구멘틴듀오시럽 228mg/5ml
- 환산계수: 0.11
- 총량 120mL × 0.11 = **13.2g**
## 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ Flask Backend (7001) │
├─────────────────────────────────────────────────────────────┤
│ /api/drug-info/conversion-factor/<sung_code>
│ /pmr/api/label/preview (sung_code 파라미터 추가) │
├─────────────────────────────────────────────────────────────┤
│ DatabaseManager │
│ ├── MSSQL (192.168.0.4) - PIT3000 │
│ │ └── CD_GOODS.SUNG_CODE (성분코드) │
│ ├── PostgreSQL (192.168.0.39:5432/label10) │
│ │ └── drysyrup 테이블 (환산계수 23건) │
│ └── SQLite - 마일리지 등 │
└─────────────────────────────────────────────────────────────┘
```
## 데이터베이스
### PostgreSQL 연결 정보
```
Host: 192.168.0.39
Port: 5432
Database: label10
User: admin
Password: trajet6640
```
### drysyrup 테이블 스키마
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| ingredient_code | VARCHAR | 성분코드 (SUNG_CODE와 매칭) |
| conversion_factor | DECIMAL | 환산계수 (mL → g) |
| ingredient_name | VARCHAR | 성분명 |
| product_name | VARCHAR | 대표 제품명 |
### 매핑 관계
- MSSQL `PM_DRUG.CD_GOODS.SUNG_CODE` = PostgreSQL `drysyrup.ingredient_code`
## API 명세
### 1. 환산계수 조회 API
**Endpoint:** `GET /api/drug-info/conversion-factor/<sung_code>`
**응답 (성공):**
```json
{
"success": true,
"sung_code": "535000ASY",
"conversion_factor": 0.11,
"ingredient_name": "아목시실린수화물·클라불란산칼륨",
"product_name": "일성오구멘틴듀오시럽 228mg/5ml"
}
```
**응답 (데이터 없음/연결 실패):**
```json
{
"success": true,
"sung_code": "NOTEXIST",
"conversion_factor": null,
"ingredient_name": null,
"product_name": null
}
```
> ⚠️ 연결 실패나 데이터 없음에도 에러 없이 null 반환 (서비스 안정성 우선)
### 2. 라벨 미리보기 API (확장)
**Endpoint:** `POST /pmr/api/label/preview`
**Request Body:**
```json
{
"patient_name": "홍길동",
"med_name": "오구멘틴듀오시럽",
"dosage": 8,
"frequency": 3,
"duration": 5,
"unit": "mL",
"sung_code": "535000ASY"
}
```
**Response:**
```json
{
"success": true,
"image": "data:image/png;base64,...",
"conversion_factor": 0.11
}
```
### 라벨 출력 예시
환산계수가 있는 경우:
```
총120mL (13.2g)/5일분
```
환산계수가 없는 경우:
```
총120mL/5일분
```
## 코드 위치
### 수정된 파일
1. **dbsetup.py** - PostgreSQL 연결 관리
- `DatabaseConfig.POSTGRES_URL` 추가
- `DatabaseManager.get_postgres_engine()` 추가
- `DatabaseManager.get_postgres_session()` 추가
- `DatabaseManager.get_conversion_factor(sung_code)` 추가
2. **app.py** - 환산계수 조회 API
- `GET /api/drug-info/conversion-factor/<sung_code>`
3. **pmr_api.py** - 라벨 미리보기
- `preview_label()` - sung_code 파라미터 추가
- `create_label_image()` - conversion_factor 파라미터 추가
## 유료/무료 버전 구분 설계 (추후)
환산계수는 추후 유료 기능으로 분리 가능합니다.
### 설계 방안
1. **라이선스 체크**
- 환산계수 조회 전 라이선스 확인
- 무료 버전: `conversion_factor: null` 반환
- 유료 버전: 실제 값 반환
2. **API 분리**
- `/api/drug-info/conversion-factor` → 유료 전용
- 무료 버전은 API 자체를 비활성화
3. **현재 구현**
- 환산계수가 없어도 라벨 출력 정상 동작
- null 체크 후 기존 포맷 유지
## 예외처리
| 상황 | 동작 |
|------|------|
| PostgreSQL 연결 실패 | null 반환, 에러 로그 |
| 데이터 없음 | null 반환 |
| sung_code 미전달 | 환산계수 조회 skip |
| 환산계수 0 또는 음수 | 적용 안 함 (기존 포맷) |
## 테스트 방법
```bash
# 환산계수 조회 테스트
curl http://localhost:7001/api/drug-info/conversion-factor/535000ASY
# 존재하지 않는 코드 테스트
curl http://localhost:7001/api/drug-info/conversion-factor/NOTEXIST
# 라벨 미리보기 테스트 (PowerShell)
$body = @{
patient_name = "홍길동"
med_name = "오구멘틴듀오시럽"
dosage = 8
frequency = 3
duration = 5
unit = "mL"
sung_code = "535000ASY"
} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:7001/pmr/api/label/preview" `
-Method Post -ContentType "application/json" -Body $body
```
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-12 | 최초 구현 |

View File

@ -0,0 +1,63 @@
# Rx-Usage 주문량 상세 (도매상별) 툴팁 기능
## 구현일: 2026-06-19
## 배경
- `/admin/rx-usage` 페이지에서 주문량이 합계로만 표시됨
- 사용자가 어떤 도매상에 얼마나 주문했는지 확인 필요
## 구현 내용
### 1. 데이터 구조 변경 (`loadOrderData` 함수)
**기존:**
```javascript
orderDataByKd[kd] = {
product_name, spec, boxes, units,
sources: ['지오영', '수인'] // 이름만 저장
};
```
**변경:**
```javascript
orderDataByKd[kd] = {
product_name, spec, boxes, units,
details: [
{ vendor: 'geoyoung', name: '지오영', boxes: 10, units: 100 },
{ vendor: 'sooin', name: '수인', boxes: 5, units: 50 }
]
};
```
### 2. 툴팁 CSS 추가
```css
.order-qty-cell { position: relative; cursor: pointer; }
.order-qty-tooltip { /* 툴팁 스타일 */ }
.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; }
```
### 3. `getOrderedQty()` 함수 수정
- 단일 도매상: 단순 숫자 표시
- 복수 도매상: hover 시 도매상별 상세 툴팁 표시
## 수정 파일
- `backend/templates/admin_rx_usage.html`
## 동작
1. 주문량 셀에 마우스 hover
2. 2개 이상 도매상에서 주문한 경우 툴팁 표시
3. 각 도매상별 수량과 합계 표시
## 확장 포인트
- `vendorConfig` 객체에 새 도매상 추가 시 자동 지원
- 도매상별 색상은 CSS의 `.order-qty-vendor-dot` 클래스로 관리
## 테스트
- URL: http://localhost:7001/admin/rx-usage
- 기간 조회 후 "주문량" 컬럼 확인
- 여러 도매상 주문이 있는 품목에서 hover 시 툴팁 확인

130
docs/SUIN_API_FIX.md Normal file
View File

@ -0,0 +1,130 @@
# 수인 API 주문 수량 파싱 문제 수정
**날짜**: 2026-03-09
**문제**: 라미실크림 15g 주문 시 **1개 → 15개**로 잘못 표시
**원인**: `parse_spec` 함수에서 용량 단위(g, ml)를 정량 단위(T, 정)로 착각
## 📋 문제 상황
| 도매상 | 제품 | 실제 주문 | 표시된 수량 |
|--------|------|----------|------------|
| 동원 | 라미실크림 15g | 1개 | **1개** ✅ |
| 수인 | 라미실크림 15g | 1개 | **15개** ❌ |
## 🔍 원인 분석
### 문제 코드 위치
- **파일**: `sooin_api.py` (Flask Blueprint)
- **API**: `GET /api/sooin/orders/summary-by-kd`
- **함수**: `parse_spec()`
### 기존 코드 (문제)
```python
def parse_spec(spec: str) -> int:
if not spec:
return 1
match = re.search(r'(\d+)', spec)
return int(match.group(1)) if match else 1
```
**문제점**: 규격에서 **숫자만 추출**
- `'30T'` → 30 (정제 30정) ✅
- `'15g'` → 15 🚨 **문제!** (튜브 15그램인데 15개로 계산)
### 계산 과정
```
수인 라미실크림 15g 1박스 주문
→ quantity = 1
→ per_unit = parse_spec('15g') = 15
→ total_units = 1 × 15 = 15개 ❌
```
### 동원 API는 정상인 이유
동원의 `parse_spec()` (wholesale/dongwon.py:1718-1720):
```python
# mg/ml 등의 용량 단위는 1로 처리
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
return 1
```
## ✅ 수정 내용
### 수정된 코드
```python
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
```
### 수정 결과
| 규격 | 기존 결과 | 수정 후 결과 |
|------|----------|-------------|
| `'30T'` | 30 | 30 ✅ |
| `'100정(PTP)'` | 100 | 100 ✅ |
| `'15g'` | 15 ❌ | **1** ✅ |
| `'10ml'` | 10 ❌ | **1** ✅ |
| `'500mg'` | 500 ❌ | **1** ✅ |
## 📁 관련 파일
| 파일 | 역할 |
|------|------|
| `backend/sooin_api.py` | Flask Blueprint (수정됨) |
| `wholesale/sooin.py` | 수인약품 핵심 API 클래스 |
| `wholesale/dongwon.py` | 동원약품 API (참고) |
## 🔄 적용 방법
```bash
# Flask 서버 재시작
pm2 restart flask-pharmacy
```
## 🧪 테스트
```bash
# 수인 주문 조회 API 테스트
curl "http://localhost:7001/api/sooin/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-09"
```
## 📝 참고
- **도매상 API 문서**: `docs/WHOLESALE_API_INTEGRATION.md`
- **수인 API 문서**: `docs/SOOIN_API.md`
- **동원 API**: 이미 올바른 `parse_spec` 로직 적용됨

278
docs/postgresql-apdb.md Normal file
View File

@ -0,0 +1,278 @@
# PostgreSQL APDB (apdb_master) 데이터베이스 문서
## 접속 정보
| 항목 | 값 |
|------|-----|
| Host | 192.168.0.87 |
| Port | 5432 |
| Database | apdb_master |
| User | admin |
| Password | trajet6640 |
| Connection String | `postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master` |
```python
from sqlalchemy import create_engine
engine = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master')
```
---
## 핵심 테이블
### apc — 동물약품 마스터 (16,326건)
APC(Animal Product Code) 기반 동물약품 정보. 모든 동물약의 기준 테이블.
| 컬럼 | 타입 | 설명 |
|------|------|------|
| idx | INTEGER PK | 일련번호 |
| apc | VARCHAR(100) | APC 코드 (13자리, '023'으로 시작) |
| item_seq | VARCHAR(100) | 품목기준코드 |
| item_code | VARCHAR(100) | 품목코드 (APC 앞 8자리 = item_code) |
| product_name | VARCHAR(200) | 제품명 (한글) |
| product_english_name | VARCHAR(200) | 제품 영문명 |
| company_name | VARCHAR(100) | 제조/수입사명 |
| approval_number | VARCHAR(100) | 허가번호 |
| ac | VARCHAR(100) | AC 코드 |
| dosage_code | VARCHAR(100) | 제형코드 |
| packaging_code | VARCHAR(100) | 포장코드 |
| pc | VARCHAR(100) | PC 코드 |
| dosage | VARCHAR(100) | 제형 (정, 액, 캡슐 등) |
| packaging | VARCHAR(100) | 포장단위 |
| approval_date | VARCHAR(100) | 허가일자 |
| product_type | VARCHAR(500) | 제품유형 |
| main_ingredient | VARCHAR(500) | 주성분 |
| finished_material | VARCHAR(500) | 완제원료 |
| manufacture_import | VARCHAR(100) | 제조/수입 구분 |
| country_of_manufacture | VARCHAR(100) | 제조국 |
| basic_info | TEXT | 기본정보 |
| raw_material | TEXT | 원료약품 |
| efficacy_effect | TEXT | 효능효과 |
| dosage_instructions | TEXT | 용법용량 |
| precautions | TEXT | 주의사항 |
| component_code | VARCHAR(100) | 성분코드 |
| component_name_ko | VARCHAR(200) | 성분명(한글) |
| component_name_en | VARCHAR(200) | 성분명(영문) |
| dosage_factor | VARCHAR(100) | 용량계수 |
| llm_pharm | JSONB | LLM 생성 약사용 정보 (투여량, 주의사항 등) |
| llm_user | VARCHAR(500) | LLM 생성 사용자용 설명 |
| image_url1~3 | VARCHAR(500) | 제품 이미지 URL |
| list_price | NUMERIC(10,2) | 정가 |
| weight_min_kg | DOUBLE PRECISION | 체중 하한 (kg) |
| weight_max_kg | DOUBLE PRECISION | 체중 상한 (kg) |
| pet_size_label | VARCHAR(100) | 체중 라벨 (소형견용, 대형견용 등) |
| pet_size_code | VARCHAR(10) | 체중 코드 |
| for_pets | BOOLEAN | 반려동물용 여부 |
| prescription_target | BOOLEAN | 처방대상 여부 |
| is_not_medicine | BOOLEAN | 비의약품 여부 |
| usage_guide | JSONB | 사용 가이드 (구조화) |
| godoimage_url_f/b/d | VARCHAR(500) | 고도몰 이미지 URL |
| pill_color | VARCHAR(100) | 알약 색상 |
| updated_at | TIMESTAMP | 수정일시 |
| parent_item_id | INTEGER | 부모 품목 ID |
**APC 코드 구조**: `023XXXXXYYZZZ`
- 앞 8자리 (`023XXXXX`) = item_code (품목코드, 대표 APC)
- 나머지 = 포장단위별 구분
---
### component_code — 성분 정보 (1,105건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| idx | INTEGER PK | 일련번호 |
| code | VARCHAR(500) | 성분코드 |
| component_name_ko | VARCHAR(500) | 성분명(한글) |
| component_name_en | VARCHAR(500) | 성분명(영문) |
| description | VARCHAR(500) | 설명 |
| efficacy | TEXT | 효능 |
| target_animals | JSONB | 대상 동물 |
| precautions | TEXT | 주의사항 |
| additional_precautions | TEXT | 추가 주의사항 |
| prohibited_breeds | VARCHAR(500) | 금기 품종 |
| offlabel | TEXT | 오프라벨 사용 |
### component_guide — 성분별 투여 가이드 (1건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| component_code | VARCHAR(50) PK | 성분코드 |
| component_name_ko/en | VARCHAR(200) | 성분명 |
| dosing_interval_adult | VARCHAR(200) | 성체 투여간격 |
| dosing_interval_high_risk | VARCHAR(200) | 고위험군 투여간격 |
| dosing_interval_puppy | VARCHAR(200) | 유아 투여간격 |
| dosing_interval_source | VARCHAR(500) | 출처 |
| withdrawal_period | VARCHAR(200) | 휴약기간 |
| contraindication | VARCHAR(500) | 금기사항 |
| companion_drugs | VARCHAR(500) | 병용약물 |
### dosage_info — 용량 정보 (152건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 일련번호 |
| apdb_idx | INTEGER | apc 테이블 idx 참조 |
| component_code | VARCHAR(100) | 성분코드 |
| dose_per_kg | DOUBLE PRECISION | kg당 용량 |
| dose_per_kg_min/max | DOUBLE PRECISION | kg당 용량 범위 |
| dose_unit | VARCHAR(20) | 용량 단위 |
| unit_dose | DOUBLE PRECISION | 단위 용량 |
| unit_type | VARCHAR(20) | 단위 타입 |
| frequency | VARCHAR(50) | 투여 빈도 |
| route | VARCHAR(30) | 투여 경로 |
| weight_min/max_kg | DOUBLE PRECISION | 적용 체중 범위 |
| animal_type | VARCHAR(10) | 동물 종류 |
| source | VARCHAR(20) | 출처 |
| verified | BOOLEAN | 검증 여부 |
| raw_text | TEXT | 원문 |
### symptoms — 증상 코드 (51건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| idx | INTEGER PK | 일련번호 |
| prefix | VARCHAR(1) | 카테고리 접두사 |
| prefix_description | VARCHAR(50) | 카테고리 설명 |
| symptom_code | VARCHAR(10) | 증상 코드 |
| symptom_description | VARCHAR(255) | 증상 설명 |
| disease_description | VARCHAR(255) | 질병 설명 |
### symptom_component_mapping — 증상-성분 매핑 (111건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| symptom_code | VARCHAR(10) | 증상 코드 |
| component_code | VARCHAR(500) | 성분 코드 |
---
## 재고/유통 테이블
### inventory — 재고 (656건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 일련번호 |
| apdb_id | INTEGER | apc.idx 참조 |
| supplier_cost | NUMERIC(12,2) | 공급가 |
| wholesaler_price | NUMERIC(12,2) | 도매가 |
| retail_price | NUMERIC(12,2) | 소매가 |
| quantity | INTEGER | 수량 |
| transaction_type | VARCHAR(20) | 거래유형 |
| order_no | VARCHAR(100) | 주문번호 |
| serial_number | VARCHAR(100) | 시리얼번호 |
| expiration_date | DATE | 유효기간 |
| receipt_id | INTEGER | 입고전표 ID |
| entity_id | VARCHAR(50) | 거래처 ID |
| entity_type | VARCHAR(20) | 거래처 유형 |
| location_id | INTEGER | 보관위치 ID |
| goods_no | INTEGER | 고도몰 상품번호 |
### receipt — 입고전표 (21건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| idx | INTEGER PK | 일련번호 |
| receipt_number | VARCHAR(100) | 전표번호 |
| receipt_date | TIMESTAMP | 입고일 |
| total_quantity | INTEGER | 총수량 |
| total_amount | NUMERIC(10,2) | 총금액 |
| entity_id | VARCHAR(50) | 거래처 ID |
| entity_type | VARCHAR(20) | 거래처 유형 |
### vendor — 거래처 (3건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| idx | INTEGER PK | 일련번호 |
| vendor_code | VARCHAR(50) | 거래처 코드 |
| name | VARCHAR(200) | 거래처명 |
| business_reg_no | VARCHAR(50) | 사업자번호 |
---
## 약국/회원 테이블
### animal_pharmacies — 동물약국 목록 (18,955건)
전국 동물약국 데이터 (공공데이터 기반).
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER PK | 일련번호 |
| management_number | VARCHAR(50) | 관리번호 |
| name | VARCHAR(200) | 약국명 |
| phone | VARCHAR(20) | 전화번호 |
| address_old/new | VARCHAR(500) | 주소 |
| latitude/longitude | NUMERIC | 위경도 |
| business_status | VARCHAR(10) | 영업상태 |
### p_member — 약국 회원 (31건)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| idx | INTEGER PK | 일련번호 |
| memno | INTEGER | 회원번호 |
| pharmacyname | VARCHAR(100) | 약국명 |
| businessregno | VARCHAR(20) | 사업자번호 |
| kioskusage | BOOLEAN | 키오스크 사용 |
| mem_nm | VARCHAR(100) | 회원명 |
---
## 기타 테이블
| 테이블 | 행수 | 설명 |
|--------|------|------|
| apc_subnames | 0 | APC 별칭 (미사용) |
| cs_memo | 13 | CS 메모 |
| excluded_pharmacies | 15 | 제외 약국 |
| evidence_reference | 0 | 근거 문헌 참조 |
| recommendation_log | 3 | 추천 로그 |
| supplementary_product | 5 | 보조제품 |
| optimal_stock | 3 | 적정재고 설정 |
| sync_status | 168 | 동기화 상태 |
| system_log | 438 | 시스템 로그 |
| location | 4 | 보관 위치 |
| region / subregion | 3/8 | 지역 구분 |
| member_group_change_logs | 4 | 회원그룹 변경 이력 |
---
## 주요 쿼리 예시
```sql
-- APC로 제품 조회
SELECT * FROM apc WHERE apc = '0230338510101';
-- 제품명 검색 (띄어쓰기 무시)
SELECT apc, product_name
FROM apc
WHERE REGEXP_REPLACE(LOWER(product_name), '[\s\-\.]+', '', 'g')
LIKE '%파라캅%';
-- 체중별 제품 검색
SELECT apc, product_name, weight_min_kg, weight_max_kg
FROM apc
WHERE weight_min_kg IS NOT NULL
ORDER BY product_name;
-- 대표 APC → 포장단위 APC 조회 (앞 8자리 기준)
SELECT apc, product_name, packaging
FROM apc
WHERE LEFT(apc, 8) = '02303385';
-- 성분별 제품 검색
SELECT a.apc, a.product_name, a.component_name_ko
FROM apc a
WHERE a.component_code = 'P001';
-- 증상 → 성분 → 제품 검색
SELECT s.symptom_description, cc.component_name_ko, a.product_name
FROM symptoms s
JOIN symptom_component_mapping scm ON s.symptom_code = scm.symptom_code
JOIN component_code cc ON cc.code = scm.component_code
JOIN apc a ON a.component_code = cc.code;
```

View File

@ -0,0 +1,153 @@
# 📊 재고 분석 통계 최적화 노트
> 재고량 vs 사용량 비교 그래프의 추세 분석 로직을 최적화하는 과정 기록
---
## 📌 커밋 히스토리 (롤백 포인트)
| 커밋 | Hash | 설명 | 특징 |
|------|------|------|------|
| 재고 변화만 | `2ca35cd` | 재고 변화 추이 그래프 (단일 Y축) | 보라색 라인만, 깔끔함 |
| 이중 Y축 v1 | `0b81999` | 재고 + 사용량 비교 (전반부/후반부 합) | 피크에 민감한 문제 |
| 이중 Y축 v2 | *(현재)* | 재고 + 사용량 비교 (최근 3개월 평균) | 피크 영향 줄임 |
### 롤백 방법
```bash
# 재고 변화만 있던 깔끔한 버전으로 돌아가기
git checkout 2ca35cd -- backend/templates/admin_stock_analytics.html backend/app.py
# 이중 Y축 v1으로 돌아가기
git checkout 0b81999 -- backend/templates/admin_stock_analytics.html backend/app.py
```
---
## 🔄 추세 분석 로직 변천사
### v1: 전반부 합 vs 후반부 합 (❌ 폐기)
```javascript
// 데이터를 반으로 나눠서 총합 비교
const half = Math.floor(items.length / 2);
const firstHalfUsage = items.slice(0, half).reduce((sum, i) => sum + i.rx_usage, 0);
const secondHalfUsage = items.slice(half).reduce((sum, i) => sum + i.rx_usage, 0);
const usageChange = secondHalfUsage - firstHalfUsage;
```
**문제점:**
- 19개월 데이터 → 앞 9개월 vs 뒤 10개월
- 후반부에 피크(예: 2025-06)가 하나만 있어도 "증가 추세"로 판정
- 최근 3개월이 계속 떨어져도 피크 하나 때문에 잘못된 결과
**실제 사례 (테라펜세미정):**
- 눈으로 보면: 명백한 감소 추세 (6,500 → 1,000)
- 로직 결과: "+13,457 (+37%)" 증가 추세 ← 틀림!
- 원인: 2025-06 피크(~7,000)가 후반부 총합을 뻥튀기
---
### v2: 최근 3개월 평균 vs 이전 3개월 평균 (현재)
```javascript
// 최근 3개 기간 vs 그 이전 3개 기간의 평균 비교
const recentCount = Math.min(3, Math.floor(items.length / 2));
const recentItems = items.slice(-recentCount);
const previousItems = items.slice(-recentCount * 2, -recentCount);
const recentAvg = recentItems.reduce((sum, i) => sum + i.rx_usage, 0) / recentItems.length;
const previousAvg = previousItems.reduce((sum, i) => sum + i.rx_usage, 0) / previousItems.length;
const usageChange = Math.round(recentAvg - previousAvg);
const usageChangePercent = previousAvg > 0 ? Math.round((usageChange / previousAvg) * 100) : 0;
```
**장점:**
- 중간의 피크에 영향 안 받음
- "최근" 변화를 정확히 감지
- 직관적인 결과
**단점:**
- 월별 분석 시 6개월만 봄 (더 긴 추세 놓칠 수 있음)
- 계절성 반영 안 됨
---
## 🎯 고려 중인 대안들
### 옵션 A: 전월 대비 (MoM)
```javascript
const lastMonth = items[items.length - 1].rx_usage;
const prevMonth = items[items.length - 2].rx_usage;
const change = lastMonth - prevMonth;
```
- ✅ 가장 직관적 ("지난달 대비 얼마?")
- ✅ 비즈니스에서 표준
- ❌ 단기 변동에 민감
### 옵션 B: 이동평균 (Moving Average)
```javascript
// 3개월 이동평균 계산 후 기울기 비교
const ma3 = items.map((item, i, arr) => {
if (i < 2) return null;
return (arr[i].rx_usage + arr[i-1].rx_usage + arr[i-2].rx_usage) / 3;
}).filter(v => v !== null);
```
- ✅ 노이즈 제거
- ✅ 그래프에 추세선으로 표시 가능
- ❌ 구현 복잡
### 옵션 C: 선형 회귀 기울기
```javascript
// 최소자승법으로 기울기 계산
function linearRegression(data) {
const n = data.length;
const sumX = data.reduce((s, _, i) => s + i, 0);
const sumY = data.reduce((s, v) => s + v, 0);
const sumXY = data.reduce((s, v, i) => s + i * v, 0);
const sumX2 = data.reduce((s, _, i) => s + i * i, 0);
return (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
}
```
- ✅ 통계적으로 정확
- ✅ 전체 데이터 활용
- ❌ 학술적, 직관성 떨어짐
### 옵션 D: 복합 (추천 예정)
- **전월 대비**: 카드에 숫자로 표시
- **3개월 이동평균**: 그래프에 추세선으로 표시
- **추세 판정**: 이동평균 기울기로
---
## 📝 TODO
- [ ] 전월 대비 수치 추가
- [ ] 이동평균 추세선 그래프에 표시
- [ ] 계절성 고려 (전년 동월 대비)
- [ ] 일별 분석 시 7일 이동평균 적용
- [ ] 추세 판정 기준값 튜닝 (현재 ±10%)
---
## 📅 변경 이력
| 날짜 | 변경 내용 |
|------|-----------|
| 2026-03-13 | 이중 Y축 그래프 최초 구현 (전반부/후반부 합) |
| 2026-03-13 | 추세 로직 수정 (최근 3개월 평균으로 변경) |
| 2026-03-13 | 최적화 노트 문서 생성 |
---
## 🖼️ 참고: 이전 버전 그래프
### 재고 변화만 (단일 Y축) - `2ca35cd`
- 보라색 라인 하나로 재고 추이만 표시
- 깔끔하고 심플함
- 사용량 정보 없음
### 이중 Y축 v1 - `0b81999`
- 왼쪽 Y축: 재고량 (보라색 라인)
- 오른쪽 Y축: 처방 사용량 (파란색 바)
- 추세 분석: 전반부/후반부 합 비교 (문제 있음)