Compare commits

...

129 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3. 컨텍스트 주입 개선:
   - 상세 질문 시 n_results=5로 증가
   - RAG 활용 지시 추가
2026-03-08 15:28:58 +09:00
thug0bin
5d7a8fc3f4 feat(animal-chat): 로깅 시스템 구축
- SQLite DB: animal_chat_logs.db
- 로거 모듈: utils/animal_chat_logger.py
- 단계별 로깅:
  - MSSQL (보유 동물약): 개수, 소요시간
  - PostgreSQL (RAG): 개수, 소요시간
  - LanceDB (벡터 검색): 상위 N개, 유사도, 소스, 소요시간
  - OpenAI: 모델, 토큰(입력/출력), 비용, 소요시간
- Admin 페이지: /admin/animal-chat-logs
- API: /api/animal-chat-logs
- 통계: 총 대화, 평균 응답시간, 총 토큰, 총 비용
2026-03-08 15:17:11 +09:00
thug0bin
be1e6c2bb7 feat(animal-chat): LanceDB 벡터 검색 RAG 통합
- LanceDB로 MD 문서 252개 청크 인덱싱
- /api/animal-chat에 벡터 검색 컨텍스트 주입
- 마지막 사용자 메시지로 관련 문서 검색 (top 3)
- ChromaDB Windows crash로 LanceDB 채택
2026-03-08 15:00:39 +09:00
thug0bin
3631da2953 fix(products): PC 촬영 시 이미지 데이터 null 버그 수정
- closeImageModal() 호출 전 imageData 로컬 변수에 저장
- closeImageModal()에서 capturedImageData = null 초기화로 인한 버그
2026-03-08 14:06:09 +09:00
thug0bin
3507d17dc5 fix(upload): 모바일 QR 업로드 이미지 처리 수정
- PIL 이미지 처리 추가 (product-images API와 동일)
- 800x800 리사이즈
- 200x200 썸네일 생성
- RGBA->RGB 변환
- 1:1 중앙 크롭
2026-03-08 13:57:32 +09:00
thug0bin
90cb91d644 fix(upload): product_name NOT NULL 에러 수정
- 세션 생성 시 product_name 저장
- 업로드 API에서 product_name INSERT
- 프론트엔드에서 product_name 전달
2026-03-08 13:49:23 +09:00
thug0bin
aef867645e fix(upload): render_template_string import 추가
- 모바일 업로드 페이지 500 에러 수정
- Flask import에 render_template_string 추가
2026-03-08 13:45:50 +09:00
thug0bin
4614fc4c0d feat(products): 모바일 이미지 업로드 QR 시스템 추가
- API 3개 추가:
  - POST /api/upload-session (세션 생성)
  - GET /api/upload-session/{id} (상태 확인/폴링)
  - POST /api/upload-session/{id}/image (이미지 업로드)
- 모바일 업로드 페이지 (/upload/{session_id})
- 이미지 등록 모달에 '📱 모바일' 탭 추가
- QR 스캔 → 모바일 촬영 → PC 실시간 반영
- 2초 폴링으로 업로드 완료 감지
- 세션 10분 만료, 메모리 기반 관리
- Edit 툴로 부분 수정하여 인코딩 유지
2026-03-08 13:40:12 +09:00
thug0bin
a7bcf46aaa feat(반품관리): 위치 지정 기능 추가
- 위치 뱃지 클릭 시 위치 수정 모달 표시
- '미지정' 뱃지 스타일 (점선 테두리, 클릭 유도)
- 기존 위치 선택 드롭다운 + 직접 입력 가능
- 위치 삭제 기능
- products 페이지와 동일한 API 재활용 (/api/locations, /api/drugs/.../location)
- 다크 테마에 맞는 모달 스타일
- Edit 툴로 부분 수정하여 인코딩 유지
2026-03-08 12:45:06 +09:00
thug0bin
e82f4be4af feat(반품관리): 위치 컬럼 추가
- CSS: .location-badge (노란 배지 스타일)
- 테이블 헤더에 위치 컬럼 추가
- API의 location 필드 활용 (CD_item_position 조인)
- Edit으로 부분 수정하여 인코딩 유지
2026-03-08 11:16:13 +09:00
thug0bin
eda0429a85 fix(반품관리): 인코딩 수정 (UTF-8)
- admin_return_management.html 한글 깨짐 수정
- Python으로 UTF-8 인코딩으로 전체 파일 재작성
- 모든 기능 유지 (입고이력, 위치 컬럼 등)
2026-03-08 11:08:02 +09:00
thug0bin
71d1916efb feat(반품관리): 약품 위치 컬럼 추가
- API: return-candidates에서 CD_item_position 조인하여 위치 정보 반환
- 테이블에 '위치' 컬럼 추가 (노란색 뱃지 스타일)
- 위치 미지정 약품은 '-' 표시
2026-03-08 10:46:43 +09:00
thug0bin
c71c9ad678 feat(반품관리): 약품 더블클릭 시 입고이력 모달 추가
- admin_return_management.html 업데이트:
  - 입고이력 모달 스타일/HTML 추가 (다크테마 적용)
  - tr ondblclick → openPurchaseModal()
  - 도매상 전화번호 클릭 시 복사 기능
  - 테이블 위에 더블클릭 힌트 추가
  - 상태변경 버튼에 event.stopPropagation() 추가
2026-03-08 10:35:48 +09:00
thug0bin
91f8dea5b4 feat(재고): 약품 더블클릭 시 입고이력 모달 추가
- 새 API: GET /api/drugs/<drug_code>/purchase-history
  - WH_sub + WH_main + PM_BASE.CD_custom 조인
  - 도매상명, 입고일, 수량, 단가, 전화번호 반환
- admin_products.html 업데이트:
  - tr ondblclick → openPurchaseModal()
  - 입고이력 모달 UI/스타일 추가
  - 도매상 전화번호 클릭 시 복사 기능
  - 결과 카운트 옆에 더블클릭 힌트 추가
- 기타 onclick에 event.stopPropagation() 추가 (충돌 방지)
2026-03-08 10:33:21 +09:00
thug0bin
d6cf4c2cc1 feat: 반품관리 페이지 추가 2026-03-08 10:03:42 +09:00
thug0bin
09948c234f feat(rx-usage): 선호 도매상 컬럼 추가
- 테이블에 '선호도매상' 컬럼 추가
- 입고장 기반 최다/최근 주문 도매상 표시
- API: /api/order/drugs/preferred-vendors 연동
- Python 스크립트로 안전하게 수정
2026-03-07 23:12:42 +09:00
thug0bin
a23e4bad43 feat: 약품별 선호 도매상 API + delivery_schedules 테이블
- GET /api/order/drug/{code}/preferred-vendor: 약품별 선호 도매상 조회
- POST /api/order/drugs/preferred-vendors: 일괄 조회
- MSSQL 입고장 데이터 활용 (WH_main, WH_sub)
- 최근 주문 도매상 + 최다 주문 도매상 반환

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

추가 변경:
- WebSocket 연결/해제 시 토스트 알림 추가
- WebSocket 프록시 트러블슈팅 문서 추가 (NPM 설정 가이드)
2026-03-05 10:56:24 +09:00
178 changed files with 43156 additions and 1581 deletions

View File

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

View File

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

16
backend/analyze_bag.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

447
backend/baekje_api.py Normal file
View File

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

150
backend/bag_page.html Normal file
View File

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

View File

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

82
backend/capture_order.py Normal file
View File

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

79
backend/capture_order2.py Normal file
View File

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

32
backend/check_2024_apc.py Normal file
View File

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

View File

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

18
backend/check_chunks.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

28
backend/check_tiergard.py Normal file
View File

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

View File

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

View File

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

View File

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

11
backend/config.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -289,6 +289,43 @@ def get_log_detail(log_id: int) -> dict:
return log
def get_cached_result(pre_serial: str) -> dict:
"""처방번호로 캐시된 PAAI 결과 조회 (재인쇄용)"""
if not DB_PATH.exists():
return None
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 가장 최근 성공한 분석 결과 조회
cursor.execute('''
SELECT * FROM paai_logs
WHERE pre_serial = ? AND status = 'success'
ORDER BY created_at DESC
LIMIT 1
''', (pre_serial,))
row = cursor.fetchone()
conn.close()
if not row:
return None
result = dict(row)
# JSON 파싱
import json
for field in ['analysis', 'kims_summary', 'raw_response']:
if result.get(field):
try:
result[field] = json.loads(result[field])
except:
pass
return result
def get_stats() -> dict:
"""통계 조회"""
if not DB_PATH.exists():

267
backend/dongwon_api.py Normal file
View File

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

85
backend/download_js.py Normal file
View File

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

View File

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

View File

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

90
backend/find_cart_js.py Normal file
View File

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

82
backend/find_frmsave.py Normal file
View File

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

70
backend/find_order_api.py Normal file
View File

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

View File

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

BIN
backend/geo_cart_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

742
backend/geoyoung_api.py Normal file
View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

1616
backend/order_api.py Normal file

File diff suppressed because it is too large Load Diff

861
backend/order_db.py Normal file
View File

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

View File

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

225
backend/paai_feedback.py Normal file
View File

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

159
backend/paai_printer.py Normal file
View File

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

201
backend/paai_printer_cli.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

706
backend/sooin_api.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -358,7 +358,8 @@
}
tbody.innerHTML = data.logs.map(log => {
const time = new Date(log.created_at).toLocaleString('ko-KR', {
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
const time = new Date(log.created_at + 'Z').toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -651,7 +651,8 @@
</thead>
<tbody>
${logs.map(log => {
const date = new Date(log.created_at);
// DB는 UTC로 저장 → 'Z' 붙여서 UTC로 해석 → KST로 표시
const date = new Date(log.created_at + 'Z');
const dateStr = date.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
@@ -772,7 +773,7 @@
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
<dt>약품</dt><dd>${medsHtml}</dd>
<dt>분석일시</dt><dd>${log.created_at}</dd>
<dt>분석일시</dt><dd>${new Date(log.created_at + 'Z').toLocaleString('ko-KR')}</dd>
<dt>상태</dt><dd>${log.status}</dd>
<dt>피드백</dt><dd>${feedbackHtml}</dd>
</dl>

View File

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

60
backend/test_api_debug.py Normal file
View File

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

View File

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

52
backend/test_checkbox.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""체크박스 로직 테스트 - 체크 안 함 vs 체크함"""
import sys; sys.path.insert(0, '.'); import wholesale_path
from wholesale import SooinSession
from bs4 import BeautifulSoup
import re
s = SooinSession()
s.login()
s.clear_cart()
# 장바구니에 품목 추가
result = s.search_products('코자정')
product = result['items'][0]
s.add_to_cart(product['internal_code'], qty=1, price=product['price'], stock=product['stock'])
print("="*60)
print("테스트 1: 체크박스 제외 (체크 안 함 = 주문 포함)")
print("="*60)
resp = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
soup = BeautifulSoup(resp.content, 'html.parser')
form = soup.find('form', {'id': 'frmBag'})
form_data = {}
for inp in form.find_all('input'):
name = inp.get('name', '')
if not name: continue
inp_type = inp.get('type', '').lower()
if inp_type == 'checkbox':
continue # 체크박스 제외!
form_data[name] = inp.get('value', '')
print(f"chk_0 전송됨? {'chk_0' in form_data}")
print(f"intArray: {form_data.get('intArray')}")
resp = s.session.post(
s.ORDER_END_URL,
data=form_data,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
timeout=30
)
alert_match = re.search(r'alert\("([^"]*)"\)', resp.text)
alert_msg = alert_match.group(1) if alert_match else ''
print(f"응답 alert: '{alert_msg}'")
# 장바구니 확인
resp2 = s.session.get(f'{s.BAG_VIEW_URL}?currVenCd={s.vendor_code}', timeout=15)
soup2 = BeautifulSoup(resp2.content, 'html.parser')
int_array = soup2.find('input', {'name': 'intArray'})
val = int_array.get('value') if int_array else 'N/A'
print(f"주문 후 intArray: {val}")
if '주문이 완료' in alert_msg:
print("✅ 성공!")
else:
print("❌ 실패")

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