Compare commits

..

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

상세 패널:
- 반려동물 카드 섹션 추가
- 사진 + 이름 + 품종 표시
- 노란색 그라데이션 카드 스타일
2026-03-02 16:11:59 +09:00
thug0bin
695c1f707f feat: 상세 패널에 키오스크/라벨출력 액션 버튼 추가
- 상세 패널 상단에 2열 액션 버튼 배치
- 📺 키오스크: 해당 건 즉시 전송
- 🏷️ 라벨출력: QR 생성 + Brother QL 출력
- 버튼에 예상 적립 포인트 표시
- 호버 효과 + 로딩 상태 표시
- QR 발행 여부, 적립 완료 정보 표시
2026-03-02 15:59:47 +09:00
thug0bin
f1e609ba9f feat: 체크박스 선택 방식으로 UX 개선
- 테이블 헤더에 전체 선택 체크박스 추가
- 각 행에 개별 체크박스 추가
- 체크박스 클릭 = 선택만 (상세 패널 안 열림)
- 행 클릭 = 상세 패널 열기 (기존 동작 유지)
- 여러 건 선택 → 일괄 라벨 출력 가능
- 버튼에 선택 건수 표시: '📺 키오스크 (3건)'
2026-03-02 15:50:21 +09:00
thug0bin
e10b50e0c3 feat: 키오스크 전송 + 라벨 출력 버튼 추가 (UX 개선)
- 📺 키오스크 버튼: 기존 /api/kiosk/trigger API 활용
- 🏷️ 라벨출력 버튼: QR 생성 + Brother QL-810W 출력 (1클릭)
- 복잡한 QR 모달 제거 → 심플한 버튼 방식
- 토스트 메시지로 결과 표시
2026-03-02 15:44:50 +09:00
thug0bin
c279e53c3e feat: 2단계 - QR 생성 및 Brother QL-810W 라벨 출력 API
- POST /api/admin/qr/generate: QR 토큰 생성 + 미리보기
- POST /api/admin/qr/print: Brother QL / POS 프린터 출력
- 프론트: QR 발행 버튼, 프린터 선택 모달
- 기존 qr_token_generator, qr_label_printer 모듈 활용
2026-03-02 15:35:48 +09:00
thug0bin
e37659dc04 feat: POS 실시간 판매 조회 웹 페이지 (Qt GUI 웹 버전) 2026-03-02 15:26:51 +09:00
thug0bin
52a4f69abc feat: 관리자 대시보드 사용자 모달에 반려동물 탭 추가
- /admin/user/<id> API에 pets 데이터 추가
- 사용자 상세 모달에 🐾 반려동물 탭 추가
- 반려동물 사진, 이름, 종류, 품종, 성별, 등록일 표시
2026-03-02 14:51:46 +09:00
thug0bin
1cebb02ec6 feat: 반려동물 등록 기능 및 확장 마이페이지 추가
- pets 테이블 추가 (이름, 종류, 품종, 사진 등)
- 반려동물 CRUD API (/api/pets)
- 확장 마이페이지 (/mypage) - 카카오 로그인 기반
- 기존 마이페이지에 퀵 메뉴 추가 (반려동물/쿠폰/구매내역/내정보)
- 카카오 로그인 시 세션에 user_id 저장
- 동물약 APC 매핑 가이드 문서 추가
2026-03-02 13:56:22 +09:00
thug0bin
f102f6b42e feat: 대시보드 조제 모달에도 AI 상호작용 체크 버튼 추가 2026-02-28 13:50:02 +09:00
thug0bin
16adca3646 feat: KIMS 상호작용 로그 뷰어 페이지 추가 (/admin/kims-logs) 2026-02-28 13:38:47 +09:00
thug0bin
fbe7dde4ce feat: KIMS API 호출 SQLite 로깅 (AI 학습용 데이터 수집) 2026-02-28 13:32:53 +09:00
thug0bin
8c20c8b8db fix: KIMS 심각도 매핑 수정 (SeverityDesc 사용) + 상호작용 약품 pill 색상 강조 2026-02-28 13:29:53 +09:00
thug0bin
67e576736d fix: KIMS API에 DrugCode 직접 사용 (BASECODE 조인 제거) 2026-02-28 13:22:26 +09:00
thug0bin
4c0cd68267 fix: KIMS 코드 조회 쿼리 최적화 (중복 제거) 2026-02-28 13:20:31 +09:00
thug0bin
68dcb919e4 feat: KIMS 약물 상호작용 체크 기능 추가 (조제 탭 버튼 + 모달) 2026-02-28 13:15:31 +09:00
thug0bin
6a786ff042 feat: 제품 검색에 분류 뱃지 + 도매상 재고 추가 (PostgreSQL 방어적 lazy fetch) 2026-02-28 12:48:58 +09:00
thug0bin
4c93ee038a feat: 챗봇 관련 제품에 분류 뱃지 추가 (내부구충제, 심장사상충약 등) 2026-02-28 12:32:03 +09:00
thug0bin
a42af23038 feat: 도매상 재고 표시 추가 (약국 N / 도매 M) + 문서화 2026-02-28 12:19:34 +09:00
thug0bin
180393700b feat: 챗봇 관련 제품에 재고 표시 추가 2026-02-28 12:04:44 +09:00
thug0bin
21e07bcca9 fix: admin_products.html 인코딩 수정 + 재고 컬럼 추가 2026-02-28 12:01:32 +09:00
thug0bin
95d7ebab71 feat: 제품 검색 페이지에 재고 컬럼 추가 (초록/빨강 표시) 2026-02-28 11:59:49 +09:00
thug0bin
c1c38c68ac feat: 동물약 API에 재고 정보 추가 (IM_total.IM_QT_sale_debit) 2026-02-28 11:56:11 +09:00
thug0bin
fd77dcbef9 feat: 챗봇 업셀링 로직 추가 (항생제→정장제 추천) 2026-02-28 11:50:02 +09:00
thug0bin
912679b137 feat: PostgreSQL에서 image_url 직접 조회 (바코드=APC 케이스 지원) 2026-02-28 11:45:27 +09:00
thug0bin
f438f42d15 docs: APC 매핑 현황 및 바코드=APC 케이스 문서화 2026-02-28 11:43:55 +09:00
thug0bin
b1d5bcfc98 feat: APC 없을 때 바코드로 PostgreSQL RAG 조회 2026-02-28 11:43:18 +09:00
thug0bin
8b58ab0d3a feat: RAG에 component_name_ko 추가 (성분 정보 개선) 2026-02-28 11:35:21 +09:00
thug0bin
c022ee21d0 feat: RAG에 성분/용도 정보 추가 2026-02-28 11:33:38 +09:00
thug0bin
d612563580 fix: (판) 접두어 제품 매칭 수정 2026-02-28 11:27:17 +09:00
thug0bin
dfbc6e4761 feat: 동물약 APC 일괄 매핑 (7개 완료) 2026-02-28 11:24:16 +09:00
thug0bin
8ee148abe4 refactor: 안텔민 하드코딩 제거, PostgreSQL RAG만 사용 2026-02-28 11:15:10 +09:00
thug0bin
3c9739a92e fix: RAG 정보 우선 참조하도록 프롬프트 개선 2026-02-28 11:04:23 +09:00
thug0bin
73b8c8ec88 fix: 안텔민 개/고양이 공용 정보 수정 (ANIMAL_DRUG_KNOWLEDGE) 2026-02-28 11:02:56 +09:00
thug0bin
4254a0f7a2 fix: 챗봇 제품 매칭 개선 (안텔민→안텔민킹/뽀삐 매칭, APC 우선) 2026-02-28 11:01:01 +09:00
thug0bin
e12328ec17 feat: 동물약 챗봇 RAG 연동 (PostgreSQL llm_pharm) 2026-02-28 10:46:43 +09:00
thug0bin
009d133aef chore: 테스트 스크립트 gitignore 추가 및 추적 제거 2026-02-28 10:46:31 +09:00
thug0bin
9019347d48 chore: DB 분석 스크립트 추가 (APC/바코드 조사용) 2026-02-28 10:45:00 +09:00
thug0bin
b95e14419e feat: 동물약 APC 이미지 지원 (CD_ITEM_UNIT_MEMBER 연동) 2026-02-28 10:44:55 +09:00
thug0bin
dd28958a59 docs: 데이터베이스 구조 문서화 (APC, MSSQL, PostgreSQL) 2026-02-28 10:44:50 +09:00
thug0bin
68ad59285a fix: 동물약 뱃지 위치 제품명 뒤로 변경 2026-02-27 17:59:11 +09:00
thug0bin
d106db64f3 feat: 동물약만 보기 체크박스 필터 추가
- 검색창 옆에 '🐾 동물약만 보기' 체크박스
- animal_only 파라미터로 API 필터링
- POS_BOON='010103' 기준 필터
2026-02-27 17:58:08 +09:00
thug0bin
197ded3806 feat: 제품 검색 페이지에 동물약 뱃지 표시
- /api/products API에 is_animal_drug 필드 추가
- POS_BOON='010103' 기준으로 동물약 판별
- 🐾 동물약 뱃지 표시 (초록색)
2026-02-27 17:56:34 +09:00
thug0bin
431909e50b fix: 생일 표시 형식 수정 (MM-DD 지원) 2026-02-27 17:32:39 +09:00
thug0bin
8c127cfb95 feat: 사용자 상세 모달에 생일 표시
- /admin/user/<id> API에 birthday 필드 추가
- 카카오 인증 시 저장된 생일 정보 표시
- 🎂 MM월 DD일 형식으로 표시
2026-02-27 17:17:40 +09:00
thug0bin
8c366cc4db feat: 대시보드 모달에 관심상품 탭 추가
- /admin/user/<id> API에 interests 필드 추가
- ai_recommendations 테이블에서 status='interested' 조회
- 모달에 💝 관심 탭 추가
- 트리거 상품, 추천 이유 표시
2026-02-27 17:10:35 +09:00
thug0bin
3fc9bbaf8e feat: 대시보드 모달에 조제 이력 탭 추가
- /admin/user/<id> API에 prescriptions 필드 추가
- 전화번호 → CD_PERSON(CUSCODE) → PS_main 연동
- 모달에 💊 조제 탭 추가 (admin_members.html 스타일 적용)
- 병원명, 의사명, 투약일수, 처방품목 표시
2026-02-27 17:07:41 +09:00
thug0bin
c33d857fa6 fix: 조제 이력 조회 쿼리 개선 (기존 로직 참고)
- PM_BASE 세션과 PM_PRES 세션 분리
- 1단계: CD_PERSON에서 전화번호로 CUSCODE 조회 (PHONE/TEL_NO/PHONE2)
- 2단계: PS_main에서 CUSCODE로 조제 기록 확인
2026-02-27 16:44:37 +09:00
thug0bin
d0e7d6bbd2 feat: 대시보드에 조제 이력 뱃지 추가
- PM_BASE.CD_PERSON에서 전화번호로 CUSCODE 매칭
- PS_main에서 조제 기록 유무 확인
- 조제 기록 있으면 녹색 '💊 환자' 뱃지
- 조제 기록 없으면 회색 '일반' 뱃지
2026-02-27 16:42:14 +09:00
thug0bin
04b0f3a8ca feat: 카카오 인증일(kakao_verified_at) 필드 추가
- DB에 kakao_verified_at 컬럼 추가
- link_kakao_identity()에서 최초 연동 시 인증일 기록
- 대시보드 테이블에 실제 인증일 표시
- 기존 카카오 연동 사용자 마이그레이션 완료
2026-02-27 16:31:31 +09:00
thug0bin
159386942e feat: 대시보드에 인증일 컬럼 추가
- 테이블 헤더에 '인증' 컬럼 추가
- 카카오 인증자: 노란 뱃지 + 인증일 (updated_at)
- 미인증: 회색 '미인증' 뱃지
2026-02-27 16:26:35 +09:00
thug0bin
3467cacd2f feat: 대시보드 최근 가입자 테이블에 카카오 뱃지 추가 2026-02-27 16:25:29 +09:00
thug0bin
a3a0bc8868 feat: 카카오 인증 여부 뱃지 추가
- API에 is_kakao_verified 필드 추가 (nickname != '고객')
- 사용자 상세 모달에 카카오 노란 뱃지 표시
- 검색 결과 목록에도 뱃지 표시
- 미인증 회원은 회색 '미인증' 뱃지
2026-02-27 16:23:26 +09:00
thug0bin
bd30ece284 docs: SQLite 연결 에러 트러블슈팅 문서 추가 2026-02-27 16:17:20 +09:00
thug0bin
94a8df6653 fix: product_category_mapping 테이블 없을 때 에러 무시
- 카테고리 조회 시 테이블 없으면 건너뛰도록 try-except 추가
2026-02-27 16:16:00 +09:00
thug0bin
4691d65c14 fix: /admin/user/<id> SQLite 연결 에러 해결
- new_connection=True + finally close 적용
2026-02-27 16:11:44 +09:00
thug0bin
866d10fd92 fix: lottie CDN을 로컬 파일로 변경 (Tracking Prevention 차단 해결) 2026-02-27 16:10:28 +09:00
thug0bin
1414bb1432 fix: /admin 사이드바 검색 SQLite 연결 에러 해결
- /admin/search/user: new_connection=True + finally close
- /admin/search/product: new_connection=True + finally close
- 에러 로깅 강화 (traceback 포함)
2026-02-27 16:09:07 +09:00
thug0bin
87a56d0f6c debug: 에러 로깅 강화 (traceback 포함) 2026-02-27 16:02:22 +09:00
thug0bin
76da7d9cd1 fix: SQLite 멀티스레드 I/O 에러 해결
- 요청마다 새 SQLite 연결 생성 (new_connection=True)
- 사용 후 명시적 close
- 간헐적 'I/O operation on closed file' 에러 방지
2026-02-27 15:43:52 +09:00
thug0bin
870e40a6db fix: SQLite 연결 체크 강화
- 커서 생성/실행/close로 연결 상태 확인
- 연결 닫힐 때 명시적 close 호출
- I/O operation on closed file 에러 방지
2026-02-27 15:41:28 +09:00
thug0bin
d44aed16be fix: 회원 상세 조회 시 모든 전화번호 컬럼 시도
- phone, phone1, tel_no, phone2 순서로 시도
- 전화번호 없는 회원 에러 방지 강화
2026-02-27 15:40:06 +09:00
thug0bin
a1640f55f8 fix: 전화번호 없는 회원 상세 조회 시 에러 처리
- 전화번호가 없으면 API 호출 전 안내 메시지 표시
- I/O 에러 방지
2026-02-27 15:36:55 +09:00
thug0bin
753df2c13c feat: 회원 상세 - 관심 상품 탭 추가
- AI 업셀링에서 '관심있어요' 표시한 상품 조회
- status='interested'인 ai_recommendations 조회
- 상품명, 추천 메시지, 구매 상품(트리거) 표시
- 💝 관심 탭 UI 구현
2026-02-27 15:31:08 +09:00
thug0bin
79369d9a56 fix: 조제이력 투약정보 표시 개선
- 투약량 x 횟수 x 일수 형식으로 표시
- 예: 1정 × 3회 × 7일
2026-02-27 15:24:45 +09:00
thug0bin
02e56b9413 feat: 회원 상세 - 전체 구매이력 + 조제이력 탭 추가
- 전화번호 → CD_PERSON(CUSCODE) 매핑
- 구매 탭: SALE_MAIN/SALE_SUB (전체 POS 구매)
- 조제 탭: PS_main/PS_sub_pharm (처방전 조제)
- 병원명, 의사명, 투약일수, 처방 약품 표시
- POS 미등록 회원 안내 메시지 추가
2026-02-27 15:19:13 +09:00
thug0bin
8c3bcb525d fix: 회원 상세 - transaction_id로 POS 품목 조회 연동
- 마일리지 적립 시 저장된 transaction_id로 SALE_SUB 조회
- 적립 내역에 구매 품목 표시 (품명, 수량, 가격)
- 구매 이력 탭: QR 적립된 구매만 품목과 함께 표시
- 기존 전화번호→고객코드 매핑 로직 제거 (불필요)
2026-02-27 15:11:23 +09:00
thug0bin
7843ca8fcf feat: 회원 상세 모달 구현 (마일리지 + POS 이력)
- /api/members/history/<phone>: 통합 이력 조회 API
- 마일리지 적립/사용 내역 (SQLite)
- POS 구매 이력 (MSSQL - 전화번호→고객코드 매핑)
- 세련된 UI: 탭 전환, 거래 카드, 구매 카드
- 상세에서 바로 메시지 발송 가능
2026-02-27 15:08:09 +09:00
thug0bin
a7e96e5efa docs: 회원 상세 기능 구현 계획 문서
- 마일리지 내역 + POS 구매 이력 연동 계획
- 전화번호 기반 통합 조회 전략
- API/UI 설계 초안
2026-02-27 15:00:49 +09:00
thug0bin
625012f5ee feat: PM2 설정 파일 추가
- ecosystem.config.js: PM2 프로세스 매니저 설정
- 자동 재시작, 로그 관리, 메모리 제한 설정
- logs/ 폴더 생성
2026-02-27 14:56:08 +09:00
thug0bin
c4ab865c93 feat: 서버 시작/중지 스크립트 추가
- scripts/start_server.ps1: 기존 프로세스 종료 후 시작
- scripts/stop_server.ps1: 서버 중지
- scripts/*.bat: 더블클릭 실행용
2026-02-27 14:55:46 +09:00
thug0bin
6e23dc8b20 fix: 서버 시작 시 포트 충돌 자동 해결
- 포트 7001 사용 중이면 기존 프로세스 자동 종료
- Flask reloader 자식 프로세스 구분 처리
- check_port_available(), kill_process_on_port() 함수 추가
2026-02-27 14:55:07 +09:00
thug0bin
705696a7fb feat: 회원 검색 페이지 및 API 추가
- /admin/members: 회원 검색 페이지 (팜IT3000 CD_PERSON)
- /api/members/search: 이름/전화번호 검색 API (TEL_NO, PHONE, PHONE2)
- /api/members/<cuscode>: 회원 상세 + 메모 조회 API
- /api/message/send: 알림톡/SMS 발송 API (테스트 모드)
- 대시보드 헤더에 회원검색 탭 추가
- 다중 선택 + 일괄 발송 UI
2026-02-27 14:10:44 +09:00
thug0bin
9bd2174501 feat: 제품 검색 페이지 및 QR 라벨 인쇄 기능
- /admin/products: 전체 제품 검색 페이지 (OTC)
- /api/products: 제품 검색 API (세트상품 바코드 포함)
- qr_printer.py: Brother QL-710W 프린터 연동
- /api/qr-print, /api/qr-preview: QR 라벨 인쇄/미리보기 API
- 판매상세 페이지에 QR 인쇄 버튼 추가
- 수량 선택 UI (+/- 버튼, 최대 10장)
- 세트상품 제조사 표시 개선
- 대시보드 헤더에 제품검색/판매조회 탭 추가
2026-02-27 13:56:26 +09:00
thug0bin
f3fa4707ac docs: 알리미팜 세트상품 DB 구조 문서화
- CD_ITEM_UNIT_MEMBER 테이블 (세트 바코드)
- CD_item_set 테이블 (세트 구성품)
- 바코드 조회 쿼리 예시
- 마진 계산 시 구성품 분해 필요 안내
2026-02-27 12:36:17 +09:00
thug0bin
1b78704ca6 fix: 세트상품 바코드 조회 - CD_ITEM_UNIT_MEMBER 테이블 연동
- CD_GOODS.BARCODE 없으면 CD_ITEM_UNIT_MEMBER.CD_CD_BARCODE 사용
- 알리미팜 세트상품/자체등록 바코드 지원
- 바코드 매핑률 89.8% → 99.8% 개선
2026-02-27 12:35:34 +09:00
thug0bin
2a090c9704 feat: Clawdbot Gateway 모니터링 페이지 + API 클라이언트
- /admin/ai-gw: 토큰 사용량/비용 실시간 모니터링 대시보드
- clawdbot_client.py: Gateway HTTP API 클라이언트 (세션 상태, 사용량 조회)
- 세션별 토큰/비용 통계, 모델별 breakdown
- API 문서 추가 (docs/clawdbot-gateway-api.md)
2026-02-27 12:22:05 +09:00
thug0bin
ccb0067a1c feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회
- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언)
- /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지
- 상품코드/바코드/표준코드 전환 버튼
- 바코드 시각화 + 매핑률 통계
- 대시보드 메뉴에 판매내역 링크 추가
2026-02-27 12:14:50 +09:00
thug0bin
da51f4bfd1 fix: 키오스크 세로 모니터 QR 코드 중앙 정렬
- portrait 모드 claim-left: row → column 레이아웃으로 변경
- QR 컨테이너, 결제 카드, 품목 카드 모두 중앙 정렬
- QR 이미지 크기 140px → 160px 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:37:24 +09:00
thug0bin
db5f6063ec fix: SQLite 싱글톤 연결 I/O 에러 수정 + clawdbot 모델 오버라이드
- dbsetup: get_sqlite_connection()에 SELECT 1 헬스체크 추가 (죽은 연결 자동 재생성)
- pos_sales_gui: 싱글톤 SQLite conn.close() 제거 (I/O closed file 에러 원인)
- qr_token_generator: DatabaseManager() 새 생성 → 전역 db_manager 싱글톤 사용
- clawdbot_client: model 파라미터 추가, 업셀링에 claude-sonnet-4-5 지정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:27:47 +09:00
thug0bin
4c3e1d08b2 feat: 실데이터 기반 AI 업셀링 추천 — 약국 보유 제품 목록에서 추천
- generate_upsell_real(): MSSQL 최근 30일 판매 TOP 40 제품 목록을 AI에 제공
- AI가 실제 약국 보유 제품 중에서만 선택하여 추천
- 실데이터 실패 시 기존 자유 생성(generate_upsell) fallback
- 기존 generate_upsell은 그대로 보존

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:21:48 +09:00
thug0bin
a2829436d1 feat: 바텀시트 '관심있어요' 버튼 분리 — interested 상태 DB 저장 + 어드민 표시
- "관심있어요!" 클릭 → status='interested' (기존: dismissed와 동일했음)
- "다음에요" / 드래그 닫기 → status='dismissed'
- dismiss API에 action 파라미터 추가
- AI CRM 대시보드: interested 배지(주황) + 통계 카드 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:47:20 +09:00
thug0bin
3e3934e2e5 fix: AI 업셀링 생성을 별도 스레드로 분리 — 키오스크 적립 응답 블로킹 방지
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:41:40 +09:00
thug0bin
5042cffb9f feat: AI CRM 어드민 대시보드 + 바텀시트 드래그 닫기 + UTF-8 인코딩 + 문서화
- /admin/ai-crm: AI 업셀링 추천 생성 현황 대시보드 (통계 카드 + 로그 테이블 + 아코디언 상세)
- 마이페이지 바텀시트: 터치 드래그로 닫기 기능 추가 (80px 임계값)
- Windows 콘솔 UTF-8 인코딩 강제 (app.py, clawdbot_client.py)
- admin.html 헤더에 AI CRM 네비 링크 추가
- docs: ai-upselling-crm.md, windows-utf8-encoding.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:38:04 +09:00
thug0bin
b5a99f7b3b feat: AI 업셀링 CRM - Clawdbot Gateway 기반 맞춤 추천 시스템
키오스크 적립 시 Clawdbot Gateway(Claude Max)를 통해 구매 이력 기반
맞춤 제품 추천을 생성하고, 마이페이지 방문 시 바텀시트 팝업으로 표시.

- ai_recommendations SQLite 테이블 추가 (스키마 + 마이그레이션)
- clawdbot_client.py: Gateway WebSocket 프로토콜 v3 Python 클라이언트
- app.py: 추천 생성 + GET/POST API 엔드포인트
- my_page.html: 바텀시트 UI (슬라이드업 애니메이션, 1.5초 후 자동 표시)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:57:03 +09:00
thug0bin
a3ff69b67f feat: 알림톡 발송 로그 시스템 + 현영 표시 + 문서화
- 알림톡 발송 로그: alimtalk_logs SQLite 테이블 + DB 자동 기록
- /admin/alimtalk 페이지: 서버 로그, NHN Cloud 내역 조회, 수동 발송 테스트
- 적립일시 포맷 수정: %Y-%m-%d %H:%M (16자 초과) → %m/%d %H:%M (11자)
- POS GUI 현금영수증(현영) 표시: 청록색 볼드
- 결제수납구조.md: CD_SUNAB/PS_main/SALE_MAIN 3테이블 관계 문서
- 실행구조.md: Flask 서버 + Qt GUI 실행 가이드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:28:29 +09:00
thug0bin
0c52542713 feat: POS GUI 할인 표시 + 적립자 클릭 버그 수정 + 결제수납 문서
- 할인 적용 건: 주황색 볼드로 "금액 (-할인액)" 표시 + 툴팁 상세
- on_cell_clicked 하드코딩 인덱스 → SALES_COLUMNS 기반 동적 인덱스로 수정
- docs/결제수납구조.md: CD_SUNAB 조인 키, 금액 구조, 결제수단 판별 로직 문서화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:25:34 +09:00
thug0bin
ac59464612 feat: POS GUI에 결제수단(카드/현금) 및 수납 여부 컬럼 추가
CD_SUNAB 테이블을 OUTER APPLY로 조인하여 결제 정보 표시
- 결제 컬럼: 카드(파랑), 현금(주황), 카드+현금(보라)
- 수납 컬럼: 수납완료(✓) / 미수납(-)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:10:28 +09:00
thug0bin
e4ccfd60c9 fix: QR 컬럼 stretchLastSection 제거 - 마지막 컬럼 리사이즈 가능하도록
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:55:16 +09:00
thug0bin
2625430ca5 fix: 마이페이지 카카오 로그인 시 계정 머지(연동) 누락 수정
- _handle_mypage_kakao_callback()에서 link_kakao_identity() 호출 추가
- 키오스크(번호) → 알림톡 → 카카오 로그인 시 자동 머지
- "고객" 이름 → 카카오 실명으로 자동 업데이트
- 케이스별 시나리오 문서 추가 (docs/user-identity-merge.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:25:46 +09:00
thug0bin
e7c529c22c feat: 알림톡 MILEAGE_CLAIM_V3 템플릿 대응 + 구매품목 요약
- nhn_alimtalk.py: build_item_summary() 추가 ("타이레놀 외 3건" 형식)
- send_mileage_claim_alimtalk()에 items 파라미터 추가, V3 우선 시도
- app.py: kiosk_current_session 클리어 전 items 캡처 버그 수정
- NHN API에 MILEAGE_CLAIM_V3 템플릿 등록 (발송 근거 문구 포함)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:09:39 +09:00
thug0bin
cb927d2207 feat: 어드민 적립내역 클릭 시 품목 상세 모달 + 키오스크 UI 개선
- 어드민 최근 적립 내역에서 행 클릭 시 MSSQL 품목 상세 모달 표시
- transaction_id가 있는 행만 클릭 가능 (돋보기 아이콘 표시)
- 키오스크 품목 목록 표시, 세로 모니터 반응형 레이아웃 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:37:50 +09:00
thug0bin
22cbf3d42e fix: 기존 QR 토큰 거래도 키오스크에서 QR 코드 표시
- 기존 토큰의 nonce를 복원할 수 없는 문제 해결
- verify_claim_token이 transaction_id로만 검증하므로 새 nonce로 QR URL 생성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:32:25 +09:00
thug0bin
a4410f5fe0 feat: 키오스크 대기화면 슬라이드쇼 + 브랜딩 + 010 기본입력
- 대기화면: 3장 자동 슬라이드 (동물의약품/건기식/부외품, 30% 한도)
- 브랜딩: AI 에이전트 개발 약국, 복약안내 진심 약사, 모바일 약료 시스템
- 전화번호 010 고정 + 나머지 8자리만 입력
- 다크 배경 대기화면 → 밝은 적립/성공 화면

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:30:07 +09:00
thug0bin
f80c19567a feat: 키오스크 마일리지 적립 시스템 추가
- 키오스크 전체화면 웹 UI (/kiosk) - QR 표시 + 전화번호 숫자패드 입력
- 키오스크 API 4개 (trigger, current, claim, kiosk 페이지)
- POS GUI에 "키오스크 적립" 버튼 추가 (Flask 서버로 HTTP 트리거)
- NHN Cloud 알림톡 발송 모듈 (적립 완료 시 자동 발송)
- Qt 플랫폼 플러그인 경로 자동 설정 (no Qt platform plugin 에러 해결)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:08:02 +09:00
thug0bin
a30374cd4a fix: JS SDK authorize에서 scope 제거 - 앱 직접 실행 개선
scope 파라미터가 있으면 웹 동의 페이지를 강제 표시함.
제거하면 개발자 콘솔 동의항목 설정대로 동작하며,
이미 동의한 사용자는 카카오톡 앱에서 바로 인증 완료.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:13:54 +09:00
thug0bin
d868a494c2 feat: 카카오 JS SDK 전환 - 앱 직접 실행으로 로그인 UX 개선
- claim_form.html, my_page_login.html 카카오 버튼을 JS SDK Kakao.Auth.authorize()로 전환
- 카카오톡 앱 설치 시 앱으로 직접 전환 (원탭 로그인), 미설치 시 웹 폴백
- JS SDK 로드 실패 시 기존 서버 리다이렉트(/claim/kakao/start) 폴백 유지
- app.py: /claim, /my-page 라우트에서 kakao_state 생성하여 템플릿에 전달
- kakao_client.py: birthyear 스코프 제거 (미승인 → KOE205 에러 방지)
- docs/kakao-oauth-setup.md: 플랫폼 키, JS SDK 비교, 다른 계정 적립 안내, 콘솔 설정 문서화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:55:51 +09:00
thug0bin
f969756caa feat: 생년월일 필드 추가 + 카카오 스코프 확장 + 채널 연동 문서
- signup.html: 수집 목적 안내 카드, 생년월일(선택) 필드, 필수/선택 배지
- app.py: /api/signup에 birthday 처리, get_or_create_user birthday 파라미터
- mileage_schema.sql: users 테이블 birthday 컬럼 추가
- dbsetup.py: 기존 DB 마이그레이션 (ALTER TABLE ADD birthday)
- kakao_client.py: scope에 phone_number,birthday,birthyear 추가
- privacy.html: 항목별 수집 목적 테이블, 필수/선택 구분, 9항 신설
- kakao-phone-request.md: 전화번호+생일 스코프 신청 사유 문서
- kakao-channel-integration.md: 채널 API 분석 및 알림톡 로드맵
- kakao-chanell-rest-api.md: 카카오 채널 REST API 원문 참고 문서

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:12:41 +09:00
thug0bin
2b3d8649ba feat: 회원가입 페이지 추가 + 카카오 전화번호 신청 문서
- /signup 회원가입 페이지 (이름 + 전화번호 + 개인정보 동의)
- /api/signup API (get_or_create_user + 세션 저장)
- 카카오 간편 가입 버튼 (카카오 로그인으로 가입)
- 홈 화면에 회원가입 메뉴 추가
- 이미 로그인 시 /signup 접근하면 마이페이지로 리다이렉트
- 카카오 전화번호 수집 신청용 수집 사유 + 시나리오 문서

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:34:41 +09:00
thug0bin
c4fa655005 feat: 홈 화면 리뉴얼 - QR 스캐너 + 카카오 로그인 + 세션 상태 표시
- 인라인 HTML → index.html 템플릿으로 전환
- HTML5 QR 코드 스캐너 (html5-qrcode 라이브러리)
- 로그인 상태에 따라 다른 메뉴 표시:
  - 비로그인: 카카오로 시작하기 + 마일리지 조회
  - 로그인: 내 마일리지 + 로그아웃
- QR 스캔 → /claim 자동 이동 (로그인 시 자동 적립)
- 디자인 시스템 통일 (Noto Sans KR, 보라색 그라디언트)
- PWA 메타 태그 + 개인정보 처리방침 푸터 링크

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:57:55 +09:00
264 changed files with 78139 additions and 1363 deletions

7
.gitignore vendored
View File

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

View File

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

View File

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

16
backend/analyze_bag.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

447
backend/baekje_api.py Normal file
View File

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

150
backend/bag_page.html Normal file
View File

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

View File

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

82
backend/capture_order.py Normal file
View File

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

79
backend/capture_order2.py Normal file
View File

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

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

@@ -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!%(" # 원본 비밀번호
@@ -88,6 +90,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
def get_database_urls(cls):
@@ -136,6 +141,10 @@ class DatabaseManager:
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'):
"""특정 데이터베이스 엔진 반환"""
if database not in self.engines:
@@ -154,11 +163,46 @@ class DatabaseManager:
return self.engines[database]
def get_session(self, database='PM_BASE'):
"""특정 데이터베이스 세션 반환"""
"""특정 데이터베이스 세션 반환 (자동 복구 포함)"""
if database not in self.sessions:
engine = self.get_engine(database)
Session = sessionmaker(bind=engine)
self.sessions[database] = Session()
else:
# 🔥 기존 세션 상태 체크 및 자동 복구
session = self.sessions[database]
try:
# 세션이 유효한지 간단한 쿼리로 테스트
session.execute(text("SELECT 1"))
except Exception as e:
error_msg = str(e).lower()
# 연결 끊김 또는 트랜잭션 에러 감지
if any(keyword in error_msg for keyword in [
'invalid transaction', 'rollback', 'connection',
'closed', 'lost', 'timeout', 'network', 'disconnect'
]):
print(f"[DB Manager] {database} 세션 복구 시도: {e}")
try:
session.rollback()
print(f"[DB Manager] {database} 롤백 성공, 세션 재사용")
except Exception as rollback_err:
print(f"[DB Manager] {database} 롤백 실패, 세션 재생성: {rollback_err}")
try:
session.close()
except:
pass
del self.sessions[database]
# 새 세션 생성
engine = self.get_engine(database)
Session = sessionmaker(bind=engine)
self.sessions[database] = Session()
print(f"[DB Manager] {database} 새 세션 생성 완료")
else:
# 다른 종류의 에러면 롤백만 시도
try:
session.rollback()
except:
pass
return self.sessions[database]
def rollback_session(self, database='PM_BASE'):
@@ -185,37 +229,193 @@ class DatabaseManager:
# 새 세션 생성
return self.get_session(database)
def get_sqlite_connection(self):
# ─────────────────────────────────────────────────────────────
# PostgreSQL 연결 (건조시럽 환산계수)
# ─────────────────────────────────────────────────────────────
def get_postgres_engine(self):
"""
SQLite mileage.db 연결 반환 (싱글톤 패턴)
최초 호출 시 스키마 자동 초기화
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 연결 반환
Args:
new_connection: True면 항상 새 연결 생성 (멀티스레드 안전)
Returns:
sqlite3.Connection: SQLite 연결 객체
"""
# 새 연결 요청 시 항상 새로 생성
if new_connection:
return self._create_sqlite_connection()
# 기존 싱글톤 방식 (하위 호환)
if self.sqlite_conn is not None:
try:
cursor = self.sqlite_conn.cursor()
cursor.execute("SELECT 1")
cursor.fetchone()
cursor.close()
except Exception as e:
print(f"[DB Manager] SQLite 연결 체크 실패, 재연결: {e}")
try:
self.sqlite_conn.close()
except:
pass
self.sqlite_conn = None
if self.sqlite_conn is None:
# 파일 존재 여부 확인
is_new_db = not self.sqlite_db_path.exists()
# 연결 생성
self.sqlite_conn = sqlite3.connect(
str(self.sqlite_db_path),
check_same_thread=False, # 멀티스레드 허용
timeout=10.0 # 10초 대기
)
# Row Factory 설정 (dict 형태로 결과 반환)
self.sqlite_conn.row_factory = sqlite3.Row
# 신규 DB면 스키마 초기화
if is_new_db:
self.init_sqlite_schema()
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
else:
print(f"[DB Manager] SQLite 기존 DB 연결: {self.sqlite_db_path}")
self.sqlite_conn = self._create_sqlite_connection()
return self.sqlite_conn
def _create_sqlite_connection(self):
"""새 SQLite 연결 생성"""
is_new_db = not self.sqlite_db_path.exists()
conn = sqlite3.connect(
str(self.sqlite_db_path),
check_same_thread=False,
timeout=10.0
)
conn.row_factory = sqlite3.Row
if is_new_db:
# 스키마 초기화 (임시로 self.sqlite_conn 설정)
old_conn = self.sqlite_conn
self.sqlite_conn = conn
self.init_sqlite_schema()
self.sqlite_conn = old_conn
print(f"[DB Manager] SQLite 신규 DB 생성 완료: {self.sqlite_db_path}")
else:
# 기존 DB: 마이그레이션 실행
old_conn = self.sqlite_conn
self.sqlite_conn = conn
self._migrate_sqlite()
self.sqlite_conn = old_conn
return conn
def init_sqlite_schema(self):
"""
mileage_schema.sql 실행하여 테이블 생성
@@ -235,6 +435,127 @@ class DatabaseManager:
print(f"[DB Manager] SQLite 스키마 초기화 완료")
def _migrate_sqlite(self):
"""기존 DB에 새 컬럼/테이블 추가 (마이그레이션)"""
cursor = self.sqlite_conn.cursor()
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'birthday' not in columns:
cursor.execute("ALTER TABLE users ADD COLUMN birthday VARCHAR(10)")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: users.birthday 컬럼 추가")
# alimtalk_logs 테이블 생성
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='alimtalk_logs'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS alimtalk_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_code VARCHAR(50) NOT NULL,
recipient_no VARCHAR(20) NOT NULL,
user_id INTEGER,
trigger_source VARCHAR(20) NOT NULL,
template_params TEXT,
success BOOLEAN NOT NULL,
result_message TEXT,
transaction_id VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: alimtalk_logs 테이블 생성")
# ai_recommendations 테이블 생성
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_recommendations'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS ai_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
transaction_id VARCHAR(20),
recommended_product TEXT NOT NULL,
recommendation_message TEXT NOT NULL,
recommendation_reason TEXT,
trigger_products TEXT,
ai_raw_response TEXT,
status VARCHAR(20) DEFAULT 'active',
displayed_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
displayed_at DATETIME,
dismissed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: ai_recommendations 테이블 생성")
# customer_identities 토큰 저장 컬럼 추가
cursor.execute("PRAGMA table_info(customer_identities)")
ci_columns = [row[1] for row in cursor.fetchall()]
if 'access_token' not in ci_columns:
cursor.execute("ALTER TABLE customer_identities ADD COLUMN access_token TEXT")
cursor.execute("ALTER TABLE customer_identities ADD COLUMN refresh_token TEXT")
cursor.execute("ALTER TABLE customer_identities ADD COLUMN token_expires_at DATETIME")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: customer_identities 토큰 컬럼 추가")
# pets 테이블 생성 (반려동물)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
species VARCHAR(20) NOT NULL,
breed VARCHAR(50),
gender VARCHAR(10),
birth_date DATE,
age_months INTEGER,
weight DECIMAL(5,2),
photo_url TEXT,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: pets 테이블 생성")
# otc_label_presets 테이블 생성 (OTC 용법 라벨)
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='otc_label_presets'")
if not cursor.fetchone():
cursor.executescript("""
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode VARCHAR(20) NOT NULL UNIQUE,
drug_code VARCHAR(20),
display_name VARCHAR(100),
effect VARCHAR(100),
dosage_instruction TEXT,
usage_tip TEXT,
use_wide_format BOOLEAN DEFAULT TRUE,
print_count INTEGER DEFAULT 0,
last_printed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);
""")
self.sqlite_conn.commit()
print("[DB Manager] SQLite 마이그레이션: otc_label_presets 테이블 생성")
def test_connection(self, database='PM_BASE'):
"""연결 테스트"""
try:
@@ -257,6 +578,20 @@ class DatabaseManager:
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()

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

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

View File

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

View File

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
email VARCHAR(200),
is_email_verified BOOLEAN DEFAULT FALSE,
phone VARCHAR(20),
birthday VARCHAR(10),
mileage_balance INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -21,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customer_identities (
provider VARCHAR(20) NOT NULL,
provider_user_id VARCHAR(100) NOT NULL,
provider_data TEXT,
access_token TEXT,
refresh_token TEXT,
token_expires_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(provider, provider_user_id)
@@ -79,3 +83,84 @@ CREATE TABLE IF NOT EXISTS pos_customer_links (
);
CREATE INDEX IF NOT EXISTS idx_links_cuscode ON pos_customer_links(cuscode);
-- 6. 알림톡 발송 로그 테이블
CREATE TABLE IF NOT EXISTS alimtalk_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_code VARCHAR(50) NOT NULL,
recipient_no VARCHAR(20) NOT NULL,
user_id INTEGER,
trigger_source VARCHAR(20) NOT NULL, -- 'kiosk', 'admin', 'manual' 등
template_params TEXT, -- JSON 문자열
success BOOLEAN NOT NULL,
result_message TEXT,
transaction_id VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_alimtalk_created ON alimtalk_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_alimtalk_recipient ON alimtalk_logs(recipient_no);
-- 7. AI 추천 테이블
CREATE TABLE IF NOT EXISTS ai_recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
transaction_id VARCHAR(20),
recommended_product TEXT NOT NULL,
recommendation_message TEXT NOT NULL,
recommendation_reason TEXT,
trigger_products TEXT,
ai_raw_response TEXT,
status VARCHAR(20) DEFAULT 'active',
displayed_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
displayed_at DATETIME,
dismissed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_rec_user_status ON ai_recommendations(user_id, status);
CREATE INDEX IF NOT EXISTS idx_rec_expires ON ai_recommendations(expires_at);
-- 8. 반려동물 테이블
CREATE TABLE IF NOT EXISTS pets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL, -- 이름 (예: 뽀삐, 나비)
species VARCHAR(20) NOT NULL, -- 종류: dog, cat, other
breed VARCHAR(50), -- 품종 (말티즈, 페르시안 등)
gender VARCHAR(10), -- male, female, unknown
birth_date DATE, -- 생년월일 (나중에 사용)
age_months INTEGER, -- 월령 (나중에 사용)
weight DECIMAL(5,2), -- 체중 kg (나중에 사용)
photo_url TEXT, -- 사진 URL
notes TEXT, -- 특이사항/메모
is_active BOOLEAN DEFAULT TRUE, -- 활성 상태
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_pets_user ON pets(user_id);
CREATE INDEX IF NOT EXISTS idx_pets_species ON pets(species);
-- 9. OTC 용법 라벨 테이블 (바코드 기준 오버라이드 데이터)
CREATE TABLE IF NOT EXISTS otc_label_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
barcode VARCHAR(20) NOT NULL UNIQUE, -- 바코드 (PK 역할)
drug_code VARCHAR(20), -- MSSQL DrugCode (참조용)
display_name VARCHAR(100), -- 표시 이름 (오버라이드, NULL이면 MSSQL 이름 사용)
effect VARCHAR(100), -- 효능 (예: "치통, 두통")
dosage_instruction TEXT, -- 용법 (예: "1일 3회, 1회 1정, 식후 30분")
usage_tip TEXT, -- 부가 설명 (예: "[통증 시에만 복용]")
use_wide_format BOOLEAN DEFAULT TRUE, -- 와이드 포맷 사용 여부
print_count INTEGER DEFAULT 0, -- 인쇄 횟수 (통계용)
last_printed_at DATETIME, -- 마지막 인쇄 시간
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_otc_label_barcode ON otc_label_presets(barcode);
CREATE INDEX IF NOT EXISTS idx_otc_label_drug_code ON otc_label_presets(drug_code);

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

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

View File

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

View File

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

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

View File

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

View File

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

90
backend/find_cart_js.py Normal file
View File

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

82
backend/find_frmsave.py Normal file
View File

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

70
backend/find_order_api.py Normal file
View File

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

View File

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

BIN
backend/geo_cart_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

742
backend/geoyoung_api.py Normal file
View File

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

121
backend/gui/check_cash.py Normal file
View File

@@ -0,0 +1,121 @@
import pyodbc, sys
sys.stdout.reconfigure(encoding='utf-8')
conn = pyodbc.connect(
r'DRIVER={ODBC Driver 17 for SQL Server};SERVER=192.168.0.4\PM2014;DATABASE=PM_PRES;UID=sa;PWD=tmddls214!%(;Encrypt=no;TrustServerCertificate=yes;'
)
cur = conn.cursor()
# 조제 주문(180)이 SALE_MAIN에 있는지 확인
cur.execute("""
SELECT SL_NO_order, SL_DT_appl, SL_NM_custom, SL_MY_sale, InsertTime, PRESERIAL
FROM SALE_MAIN
WHERE SL_NO_order = '20260225000180'
""")
r = cur.fetchone()
print(f'=== 조제 주문 180 in SALE_MAIN: {"있음" if r else "없음"} ===')
if r:
print(f' 주문={r[0]} 날짜={r[1]} 고객={r[2]} 금액={r[3]} 시간={r[4]} PRESERIAL={r[5]}')
# SALE_MAIN 총 건수 vs CD_SUNAB 총 건수
cur.execute("SELECT COUNT(*) FROM SALE_MAIN WHERE SL_DT_appl = '20260225'")
sale_cnt = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM CD_SUNAB WHERE INDATE = '20260225'")
sunab_cnt = cur.fetchone()[0]
print(f'\n=== 오늘 건수 비교 ===')
print(f' SALE_MAIN: {sale_cnt}')
print(f' CD_SUNAB: {sunab_cnt}')
# CD_SUNAB 컬럼 구조 확인
cur.execute("SELECT TOP 1 * FROM CD_SUNAB WHERE INDATE = '20260225'")
cols = [d[0] for d in cur.description]
print(f'\n=== CD_SUNAB 컬럼 ({len(cols)}개) ===')
for i, c in enumerate(cols):
print(f' {i}: {c}')
# CD_SUNAB 조제건(SALE_MAIN 없는 91건)의 PRESERIAL vs PS_main.PreSerial 매칭
cur.execute("""
SELECT S.PRESERIAL
FROM CD_SUNAB S
WHERE S.INDATE = '20260225'
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
""")
sunab_only = [r[0] for r in cur.fetchall()]
print(f'\n=== CD_SUNAB만 있는 91건 vs PS_main 매칭 ===')
# PS_main의 PreSerial 패턴 확인
cur.execute("SELECT TOP 5 PreSerial, Day_Serial, Indate, Paname FROM PS_main WHERE Indate = '20260225' ORDER BY PreSerial DESC")
print('PS_main 샘플:')
for r in cur.fetchall():
print(f' PreSerial={r[0]} | Day_Serial={r[1]} | Indate={r[2]} | 환자={r[3]}')
# CD_SUNAB PRESERIAL vs PS_main PreSerial 직접 비교
# CD_SUNAB.PRESERIAL = '20260225000180' 형태
# PS_main.PreSerial = ? 형태 확인
cur.execute("""
SELECT COUNT(*)
FROM CD_SUNAB S
WHERE S.INDATE = '20260225'
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
AND EXISTS (SELECT 1 FROM PS_main P WHERE P.PreSerial = S.PRESERIAL AND P.Indate = '20260225')
""")
matched = cur.fetchone()[0]
cur.execute("""
SELECT S.PRESERIAL
FROM CD_SUNAB S
WHERE S.INDATE = '20260225'
AND NOT EXISTS (SELECT 1 FROM SALE_MAIN M WHERE M.SL_NO_order = S.PRESERIAL)
AND NOT EXISTS (SELECT 1 FROM PS_main P WHERE P.PreSerial = S.PRESERIAL AND P.Indate = '20260225')
""")
unmatched = cur.fetchall()
print(f'\nCD_SUNAB 91건 중 PS_main 매칭: {matched}')
print(f'CD_SUNAB 91건 중 PS_main 미매칭: {len(unmatched)}')
for r in unmatched:
serial = r[0]
print(f'\n=== 미매칭 {serial} ===')
# CD_SUNAB에서 금액, 승인일시
cur.execute("""
SELECT ISNULL(ETC_CARD,0)+ISNULL(ETC_CASH,0) as etc,
ISNULL(OTC_CARD,0)+ISNULL(OTC_CASH,0) as otc,
APPR_DATE, CUSCODE, DaeRiSunab, YOHUDATE
FROM CD_SUNAB WHERE PRESERIAL = ? AND INDATE = '20260225'
""", serial)
d = cur.fetchone()
print(f' ETC={d[0]:,.0f} OTC={d[1]:,.0f} | 승인일시={d[2]} | CUSCODE={d[3]} | 대리수납={d[4]} | 요후일={d[5]}')
# 다른 날짜의 PS_main에서 같은 PRESERIAL 검색 (날짜 무관)
cur.execute("SELECT PreSerial, Indate, Paname, Day_Serial FROM PS_main WHERE PreSerial = ?", serial)
ps = cur.fetchone()
if ps:
print(f' → PS_main 발견! 날짜={ps[1]} 환자={ps[2]} Day_Serial={ps[3]}')
else:
print(f' → PS_main 전체에서도 없음')
# PRESERIAL 번호 앞 8자리가 다른 날짜인 CD_SUNAB 검색
cur.execute("""
SELECT INDATE, PRESERIAL, ISNULL(ETC_CARD,0)+ISNULL(ETC_CASH,0) as etc
FROM CD_SUNAB WHERE PRESERIAL = ? AND INDATE != '20260225'
""", serial)
other = cur.fetchall()
if other:
for o in other:
print(f' → 다른 날짜 CD_SUNAB 발견! INDATE={o[0]} ETC={o[2]:,.0f}')
# CUSCODE로 PS_main 검색 (같은 환자의 이전 처방?)
if d[3] and d[3].strip():
cur.execute("""
SELECT TOP 3 PreSerial, Indate, Paname, Day_Serial
FROM PS_main WHERE CusCode = ?
ORDER BY Indate DESC, Day_Serial DESC
""", d[3].strip())
ps_list = cur.fetchall()
if ps_list:
print(f' → 같은 CUSCODE({d[3]})의 최근 PS_main:')
for p in ps_list:
print(f' PreSerial={p[0]} 날짜={p[1]} 환자={p[2]}')
conn.close()

View File

@@ -0,0 +1,49 @@
import pyodbc, sys
sys.stdout.reconfigure(encoding='utf-8')
conn = pyodbc.connect(
r'DRIVER={ODBC Driver 17 for SQL Server};SERVER=192.168.0.4\PM2014;DATABASE=PM_PRES;UID=sa;PWD=tmddls214!%(;Encrypt=no;TrustServerCertificate=yes;'
)
cur = conn.cursor()
# 오늘 현금영수증 발행 건 확인
cur.execute("""
SELECT
PRESERIAL,
ETC_CASH, OTC_CASH, ETC_CARD, OTC_CARD,
nCASHINMODE, nAPPROVAL_NUM, nCHK_GUBUN
FROM CD_SUNAB
WHERE INDATE = '20260225'
AND nAPPROVAL_NUM IS NOT NULL AND nAPPROVAL_NUM != ''
ORDER BY PRESERIAL DESC
""")
rows = cur.fetchall()
print(f'=== 오늘 현금영수증 발행 건: {len(rows)}건 ===')
for r in rows:
cash = (r[1] or 0) + (r[2] or 0)
card = (r[3] or 0) + (r[4] or 0)
pay = '카드' if card > 0 else '현금' if cash > 0 else '?'
print(f' 주문={r[0]} | {pay} | 현금={cash:,} 카드={card:,} | 영수증모드={r[5]} | 승인번호={r[6]} | 구분={r[7]}')
# 오늘 전체 현금 결제 건 (영수증 무관)
cur.execute("""
SELECT COUNT(*) FROM CD_SUNAB
WHERE INDATE = '20260225'
AND (ETC_CASH > 0 OR OTC_CASH > 0)
""")
r = cur.fetchone()
print(f'\n=== 오늘 현금 결제 건: {r[0]}건 ===')
# 오늘 nCASHINMODE가 있는 건 (영수증 입력 방식 있음)
cur.execute("""
SELECT nCASHINMODE, COUNT(*) as cnt
FROM CD_SUNAB
WHERE INDATE = '20260225'
AND nCASHINMODE IS NOT NULL AND nCASHINMODE != ''
GROUP BY nCASHINMODE
""")
print(f'\n=== 오늘 nCASHINMODE 분포 ===')
for r in cur.fetchall():
print(f' 모드={r[0]}{r[1]}')
conn.close()

View File

@@ -7,6 +7,18 @@ import sys
import os
import json
from datetime import datetime
# Qt 플랫폼 플러그인 경로 자동 설정 (PyQt5 import 전에 반드시 설정)
if not os.environ.get('QT_QPA_PLATFORM_PLUGIN_PATH'):
import importlib.util
_spec = importlib.util.find_spec('PyQt5')
if _spec and _spec.origin:
_pyqt5_plugins = os.path.join(
os.path.dirname(_spec.origin), 'Qt5', 'plugins', 'platforms'
)
if os.path.isdir(_pyqt5_plugins):
os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = _pyqt5_plugins
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
@@ -55,14 +67,30 @@ class SalesQueryThread(QThread):
sqlite_conn = db_manager.get_sqlite_connection()
sqlite_cursor = sqlite_conn.cursor()
# 메인 쿼리: SALE_MAIN에서 오늘 판매 내역
# 메인 쿼리: SALE_MAIN + CD_SUNAB(수납)
# CD_SUNAB.PRESERIAL = SALE_MAIN.SL_NO_order (주문번호 기준)
query = """
SELECT
M.SL_NO_order,
M.InsertTime,
M.SL_MY_sale,
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name,
ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount,
ISNULL(M.SL_MY_discount, 0) AS discount,
S.cash_receipt_mode,
S.cash_receipt_num
FROM SALE_MAIN M
OUTER APPLY (
SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
nCASHINMODE AS cash_receipt_mode,
nAPPROVAL_NUM AS cash_receipt_num
FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order
) S
WHERE M.SL_DT_appl = ?
ORDER BY M.InsertTime DESC
"""
@@ -72,7 +100,7 @@ class SalesQueryThread(QThread):
sales_list = []
for row in rows:
order_no, insert_time, sale_amount, customer = row
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
# 품목 수 조회 (SALE_SUB)
mssql_cursor.execute("""
@@ -109,11 +137,36 @@ class SalesQueryThread(QThread):
claimed_phone = ""
claimed_points = 0
# 결제수단 판별
card_amt = float(card_total) if card_total else 0.0
cash_amt = float(cash_total) if cash_total else 0.0
# 현금영수증: nCASHINMODE='1' AND nAPPROVAL_NUM 존재 (mode=2는 카드거래 자동세팅)
has_cash_receipt = (
str(cash_receipt_mode or '').strip() == '1'
and str(cash_receipt_num or '').strip() != ''
)
if card_amt > 0 and cash_amt > 0:
pay_method = '카드+현금'
elif card_amt > 0:
pay_method = '카드'
elif cash_amt > 0:
pay_method = '현영' if has_cash_receipt else '현금'
else:
pay_method = ''
paid = (card_amt + cash_amt) > 0
disc_amt = float(discount) if discount else 0.0
total_amt = float(total_amount) if total_amount else 0.0
sales_list.append({
'order_no': order_no,
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
'amount': float(sale_amount) if sale_amount else 0.0,
'discount': disc_amt,
'total_before_dc': total_amt,
'customer': customer,
'pay_method': pay_method,
'paid': paid,
'item_count': item_count,
'claimed_name': claimed_name,
'claimed_phone': claimed_phone,
@@ -128,8 +181,7 @@ class SalesQueryThread(QThread):
finally:
if mssql_conn:
mssql_conn.close()
if sqlite_conn:
sqlite_conn.close()
# sqlite_conn은 싱글톤이므로 닫지 않음 (닫으면 다른 곳에서 I/O 에러 발생)
class QRGeneratorThread(QThread):
@@ -547,9 +599,7 @@ class UserMileageDialog(QDialog):
except Exception as e:
QMessageBox.critical(self, '오류', f'회원 정보 조회 실패:\n{str(e)}')
finally:
if conn:
conn.close()
# conn은 싱글톤이므로 닫지 않음
class POSSalesGUI(QMainWindow):
@@ -563,6 +613,8 @@ class POSSalesGUI(QMainWindow):
('주문번호', 150, 'order_no'),
('시간', 70, 'time'),
('금액', 100, 'amount'),
('결제', 80, 'pay_method'),
('수납', 50, 'paid'),
('고객명', 80, 'customer'),
('품목수', 55, 'item_count'),
('적립자', 90, 'claimed_name'),
@@ -624,6 +676,14 @@ class POSSalesGUI(QMainWindow):
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
settings_layout.addWidget(self.qr_btn)
# 키오스크 적립 버튼
self.kiosk_btn = QPushButton('키오스크 적립')
self.kiosk_btn.setStyleSheet(
'background-color: #6366f1; color: white; padding: 8px; font-weight: bold;')
self.kiosk_btn.setToolTip('선택된 거래를 키오스크 화면에 표시')
self.kiosk_btn.clicked.connect(self.trigger_kiosk_claim)
settings_layout.addWidget(self.kiosk_btn)
# 미리보기 모드 체크박스 추가
self.preview_checkbox = QCheckBox('미리보기 모드')
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
@@ -688,7 +748,7 @@ class POSSalesGUI(QMainWindow):
w = saved_widths[i] if saved_widths and len(saved_widths) == col_count else default_w
self.sales_table.setColumnWidth(i, w)
self.sales_table.horizontalHeader().setStretchLastSection(True)
self.sales_table.horizontalHeader().setStretchLastSection(False)
self.sales_table.horizontalHeader().sectionResized.connect(self._on_column_resized)
self.sales_table.setSelectionBehavior(QTableWidget.SelectRows)
self.sales_table.doubleClicked.connect(self.show_sale_detail)
@@ -786,11 +846,56 @@ class POSSalesGUI(QMainWindow):
self.sales_table.setItem(row, COL['time'],
QTableWidgetItem(sale['time']))
# 금액 (우측 정렬, 천단위 콤마)
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}")
# 금액 (우측 정렬, 천단위 콤마, 할인 표시)
if sale['discount'] > 0:
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}원 (-{sale['discount']:,.0f})")
amount_item.setForeground(QColor('#E65100'))
f = QFont()
f.setBold(True)
amount_item.setFont(f)
amount_item.setToolTip(
f"원가: {sale['total_before_dc']:,.0f}\n"
f"할인: -{sale['discount']:,.0f}\n"
f"결제: {sale['amount']:,.0f}"
)
else:
amount_item = QTableWidgetItem(f"{sale['amount']:,.0f}")
amount_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.sales_table.setItem(row, COL['amount'], amount_item)
# 결제수단
pay_item = QTableWidgetItem(sale['pay_method'])
pay_item.setTextAlignment(Qt.AlignCenter)
if sale['pay_method'] == '카드':
pay_item.setForeground(QColor('#1976D2'))
elif sale['pay_method'] == '현영':
pay_item.setForeground(QColor('#00897B')) # 청록 (현금영수증)
f = QFont()
f.setBold(True)
pay_item.setFont(f)
elif sale['pay_method'] == '현금':
pay_item.setForeground(QColor('#E65100'))
elif sale['pay_method']:
pay_item.setForeground(QColor('#7B1FA2'))
else:
pay_item.setText('-')
pay_item.setForeground(QColor('#BDBDBD'))
self.sales_table.setItem(row, COL['pay_method'], pay_item)
# 수납 여부
paid_item = QTableWidgetItem()
paid_item.setTextAlignment(Qt.AlignCenter)
if sale['paid']:
paid_item.setText('')
paid_item.setForeground(QColor('#4CAF50'))
f = QFont()
f.setBold(True)
paid_item.setFont(f)
else:
paid_item.setText('-')
paid_item.setForeground(QColor('#BDBDBD'))
self.sales_table.setItem(row, COL['paid'], paid_item)
# 고객명 (MSSQL POS)
self.sales_table.setItem(row, COL['customer'],
QTableWidgetItem(sale['customer']))
@@ -862,12 +967,14 @@ class POSSalesGUI(QMainWindow):
def on_cell_clicked(self, row, column):
"""테이블 셀 클릭 이벤트 - 적립 사용자 클릭 시 마일리지 내역 표시"""
# 컬럼 5(적립자명), 6(전화번호), 7(적립포인트) 중 하나를 클릭했는지 확인
if column not in [5, 6, 7]:
# SALES_COLUMNS 기반 인덱스 사용
COL = {key: i for i, (_, _, key) in enumerate(self.SALES_COLUMNS)}
mileage_cols = [COL['claimed_name'], COL['claimed_phone'], COL['claimed_points']]
if column not in mileage_cols:
return
# 전화번호 가져오기 (6번 컬럼)
phone_item = self.sales_table.item(row, 6)
# 전화번호 가져오기
phone_item = self.sales_table.item(row, COL['claimed_phone'])
if not phone_item or not phone_item.text():
# 적립 사용자가 없는 경우
return
@@ -928,6 +1035,35 @@ class POSSalesGUI(QMainWindow):
except:
return False
def trigger_kiosk_claim(self):
"""선택된 판매 건을 키오스크에 표시"""
current_row = self.sales_table.currentRow()
if current_row < 0:
QMessageBox.warning(self, '경고', '거래를 선택해주세요.')
return
order_no = self.sales_table.item(current_row, 0).text()
amount_text = self.sales_table.item(current_row, 2).text()
amount = float(amount_text.replace(',', '').replace('', ''))
try:
import requests as req
resp = req.post(
'http://localhost:7001/api/kiosk/trigger',
json={'transaction_id': order_no, 'amount': amount},
timeout=5
)
result = resp.json()
if result.get('success'):
self.status_label.setText(f'키오스크 적립 대기 중 ({result.get("points", 0)}P)')
self.status_label.setStyleSheet(
'color: #6366f1; font-size: 12px; padding: 5px; font-weight: bold;')
else:
QMessageBox.warning(self, '키오스크', result.get('message', '전송 실패'))
except Exception as e:
QMessageBox.critical(self, '오류', f'Flask 서버 연결 실패:\n{str(e)}')
def generate_qr_label(self):
"""선택된 판매 건에 대해 QR 라벨 생성"""
# 선택된 행 확인

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

2178
backend/pmr_api.py Normal file

File diff suppressed because it is too large Load Diff

169
backend/pos_printer.py Normal file
View File

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

262
backend/qr_printer.py Normal file
View File

@@ -0,0 +1,262 @@
# qr_printer.py - Brother QL-710W QR 라벨 인쇄
# person-lookup-web-local/print_label.py에서 핵심 기능만 추출
from PIL import Image, ImageDraw, ImageFont
import io
import logging
import qrcode
# 프린터 설정
PRINTER_IP = "192.168.0.121"
PRINTER_MODEL = "QL-710W"
LABEL_TYPE = "29" # 29mm 연속 출력 용지
# Windows 폰트 경로
FONT_PATH = "C:/Windows/Fonts/malgunbd.ttf"
logging.basicConfig(level=logging.INFO)
def create_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
"""
약품 QR 라벨 이미지 생성
Parameters:
drug_name (str): 약품명
barcode (str): 바코드 (QR 코드로 변환)
sale_price (float): 판매가격
drug_code (str, optional): 약품 코드 (바코드가 없을 때 대체)
pharmacy_name (str, optional): 약국 이름
Returns:
PIL.Image: 생성된 라벨 이미지
"""
label_width = 306
label_height = 380
image = Image.new("1", (label_width, label_height), "white")
draw = ImageDraw.Draw(image)
# 폰트 설정
try:
drug_name_font = ImageFont.truetype(FONT_PATH, 32)
price_font = ImageFont.truetype(FONT_PATH, 36)
label_font = ImageFont.truetype(FONT_PATH, 24)
except IOError:
drug_name_font = ImageFont.load_default()
price_font = ImageFont.load_default()
label_font = ImageFont.load_default()
logging.warning("폰트 로드 실패. 기본 폰트 사용.")
# 바코드가 없으면 약품 코드 사용
qr_data = barcode if barcode else (drug_code if drug_code else "NO_BARCODE")
# QR 코드 생성
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=4,
border=1,
)
qr.add_data(qr_data)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
# QR 코드 크기 조정 및 배치
qr_size = 130
qr_img = qr_img.resize((qr_size, qr_size), Image.LANCZOS)
qr_x = (label_width - qr_size) // 2
qr_y = 15
if qr_img.mode != '1':
qr_img = qr_img.convert('1')
image.paste(qr_img, (qr_x, qr_y))
# 약품명 (QR 코드 아래)
y_position = qr_y + qr_size + 10
def draw_wrapped_text(draw, text, y, font, max_width):
"""텍스트를 여러 줄로 표시"""
chars = list(text)
lines = []
current_line = ""
for char in chars:
test_line = current_line + char
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = char
if current_line:
lines.append(current_line)
lines = lines[:2] # 최대 2줄
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((label_width - w) / 2, y), line, font=font, fill="black")
y += h + 5
return y
y_position = draw_wrapped_text(draw, drug_name, y_position, drug_name_font, label_width - 40)
y_position += 8
# 가격
if sale_price and sale_price > 0:
price_text = f"{int(sale_price):,}"
else:
price_text = "가격 미정"
bbox = draw.textbbox((0, 0), price_text, font=price_font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((label_width - w) / 2, y_position), price_text, font=price_font, fill="black")
y_position += h + 15
# 구분선
line_margin = 30
draw.line([(line_margin, y_position), (label_width - line_margin, y_position)], fill="black", width=2)
y_position += 20
# 약국 이름
signature_text = " ".join(pharmacy_name)
bbox = draw.textbbox((0, 0), signature_text, font=label_font)
w_sig, h_sig = bbox[2] - bbox[0], bbox[3] - bbox[1]
padding = 10
box_x = (label_width - w_sig) / 2 - padding
box_y = y_position
box_x2 = box_x + w_sig + 2 * padding
box_y2 = box_y + h_sig + 2 * padding
draw.rectangle([(box_x, box_y), (box_x2, box_y2)], outline="black", width=2)
draw.text(((label_width - w_sig) / 2, box_y + padding), signature_text, font=label_font, fill="black")
# 절취선 테두리
draw_scissor_border(draw, label_width, label_height)
return image
def draw_scissor_border(draw, width, height, edge_size=10, steps=20):
"""절취선 테두리"""
# 상단
top_points = []
step_x = width / (steps * 2)
for i in range(steps * 2 + 1):
x = i * step_x
y = 0 if i % 2 == 0 else edge_size
top_points.append((int(x), int(y)))
draw.line(top_points, fill="black", width=2)
# 하단
bottom_points = []
for i in range(steps * 2 + 1):
x = i * step_x
y = height if i % 2 == 0 else height - edge_size
bottom_points.append((int(x), int(y)))
draw.line(bottom_points, fill="black", width=2)
# 좌측
left_points = []
step_y = height / (steps * 2)
for i in range(steps * 2 + 1):
y = i * step_y
x = 0 if i % 2 == 0 else edge_size
left_points.append((int(x), int(y)))
draw.line(left_points, fill="black", width=2)
# 우측
right_points = []
for i in range(steps * 2 + 1):
y = i * step_y
x = width if i % 2 == 0 else width - edge_size
right_points.append((int(x), int(y)))
draw.line(right_points, fill="black", width=2)
def print_drug_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
"""
약품 QR 라벨 인쇄 실행
Parameters:
drug_name (str): 약품명
barcode (str): 바코드
sale_price (float): 판매가격
drug_code (str, optional): 약품 코드
pharmacy_name (str, optional): 약국 이름
Returns:
dict: 성공/실패 결과
"""
try:
from brother_ql.raster import BrotherQLRaster
from brother_ql.conversion import convert
from brother_ql.backends.helpers import send
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
# 이미지를 메모리 스트림으로 변환
image_stream = io.BytesIO()
label_image.save(image_stream, format="PNG")
image_stream.seek(0)
# Brother QL 프린터로 전송
qlr = BrotherQLRaster(PRINTER_MODEL)
instructions = convert(
qlr=qlr,
images=[Image.open(image_stream)],
label=LABEL_TYPE,
rotate="0",
threshold=70.0,
dither=False,
compress=False,
lq=True,
red=False
)
send(instructions, printer_identifier=f"tcp://{PRINTER_IP}:9100")
logging.info(f"QR 라벨 인쇄 성공: {drug_name}, 바코드={barcode}")
return {"success": True, "message": f"{drug_name} QR 라벨 인쇄 완료"}
except ImportError as e:
logging.error(f"brother_ql 라이브러리 없음: {e}")
return {"success": False, "error": "brother_ql 라이브러리가 설치되지 않았습니다"}
except Exception as e:
logging.error(f"QR 라벨 인쇄 실패: {e}")
return {"success": False, "error": str(e)}
def preview_qr_label(drug_name, barcode, sale_price, drug_code=None, pharmacy_name='청춘약국'):
"""
QR 라벨 미리보기 (base64 이미지 반환)
"""
import base64
label_image = create_drug_qr_label(drug_name, barcode, sale_price, drug_code, pharmacy_name)
# PNG로 변환
image_stream = io.BytesIO()
# 1-bit 이미지를 RGB로 변환하여 더 깔끔하게
rgb_image = label_image.convert('RGB')
rgb_image.save(image_stream, format="PNG")
image_stream.seek(0)
base64_image = base64.b64encode(image_stream.read()).decode('utf-8')
return f"data:image/png;base64,{base64_image}"
if __name__ == "__main__":
# 테스트
result = print_drug_qr_label(
drug_name="벤포파워Z",
barcode="8806418067510",
sale_price=3000,
pharmacy_name="청춘약국"
)
print(result)

View File

@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""
동물약 일괄 APC 매칭 (개선판)
- 띄어쓰기 무시 매칭
- 체중 범위로 정밀 매칭
- dry-run 모드 (검증용)
"""
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 없는 것만) ──
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT
G.DrugCode,
G.GoodsName,
G.Saleprice,
(
SELECT TOP 1 U.CD_CD_BARCODE
FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode
AND U.CD_CD_BARCODE LIKE '023%'
) AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103'
AND G.GoodsSelCode = 'B'
ORDER BY G.GoodsName
"""))
no_apc = []
for row in result:
if not row.APC_CODE:
no_apc.append({
'code': row.DrugCode,
'name': row.GoodsName,
'price': row.Saleprice
})
session.close()
print(f'=== APC 없는 동물약: {len(no_apc)}개 ===')
print(f'=== 모드: {"DRY-RUN (검증만)" if DRY_RUN else "실제 INSERT"} ===\n')
# ── 2. PostgreSQL에서 매칭 ──
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
matched = [] # 확정 매칭
ambiguous = [] # 후보 여러 개 (수동 확인 필요)
no_match = [] # 매칭 없음
for drug in no_apc:
name = drug['name']
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,
'apc': filtered[0],
'method': method
})
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()
# ── 3. 요약 ──
print(f'\n{"="*50}')
print(f'=== 매칭 요약 ===')
print(f'APC 없는 제품: {len(no_apc)}')
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
@echo off
chcp 65001 >nul
powershell -ExecutionPolicy Bypass -File "%~dp0start_server.ps1"
pause

View File

@@ -0,0 +1,35 @@
# start_server.ps1 - Flask 서버 시작 스크립트
# 기존 프로세스 종료 후 새로 시작
$PORT = 7001
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
$BACKEND_DIR = Split-Path -Parent $SCRIPT_DIR
Write-Host "=== 청춘약국 마일리지 서버 시작 ===" -ForegroundColor Cyan
# 1. 기존 포트 사용 프로세스 종료
Write-Host "1. 포트 $PORT 확인 중..." -ForegroundColor Yellow
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
if ($netstat) {
$pid = ($netstat -split '\s+')[-1]
Write-Host " 기존 프로세스 발견 (PID: $pid). 종료합니다..." -ForegroundColor Red
taskkill /F /PID $pid 2>$null
Start-Sleep -Seconds 2
}
# 2. 서버 시작
Write-Host "2. 서버 시작 중..." -ForegroundColor Yellow
Set-Location $BACKEND_DIR
Start-Process python -ArgumentList "app.py" -WindowStyle Hidden
# 3. 시작 확인
Start-Sleep -Seconds 3
$check = netstat -ano | Select-String ":$PORT.*LISTENING"
if ($check) {
Write-Host "=== 서버 시작 완료! ===" -ForegroundColor Green
Write-Host "URL: http://localhost:$PORT" -ForegroundColor Cyan
Write-Host "외부: http://192.168.0.14:$PORT" -ForegroundColor Cyan
} else {
Write-Host "=== 서버 시작 실패 ===" -ForegroundColor Red
Write-Host "로그를 확인하세요." -ForegroundColor Red
}

View File

@@ -0,0 +1,4 @@
@echo off
chcp 65001 >nul
powershell -ExecutionPolicy Bypass -File "%~dp0stop_server.ps1"
pause

View File

@@ -0,0 +1,15 @@
# stop_server.ps1 - Flask 서버 중지 스크립트
$PORT = 7001
Write-Host "=== 청춘약국 마일리지 서버 중지 ===" -ForegroundColor Cyan
$netstat = netstat -ano | Select-String ":$PORT.*LISTENING"
if ($netstat) {
$pid = ($netstat -split '\s+')[-1]
Write-Host "서버 프로세스 종료 중 (PID: $pid)..." -ForegroundColor Yellow
taskkill /F /PID $pid 2>$null
Write-Host "=== 서버 중지 완료 ===" -ForegroundColor Green
} else {
Write-Host "실행 중인 서버가 없습니다." -ForegroundColor Yellow
}

View File

@@ -0,0 +1,201 @@
"""
동물약 태깅 및 MSSQL 동기화
1. 키워드로 CD_GOODS에서 동물약 검색
2. SQLite drug_tags.db에 태깅
3. MSSQL CD_GOODS.POS_BOON = '010103' 업데이트
"""
import sqlite3
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from db.dbsetup import db_manager
from sqlalchemy import text
# SQLite DB 경로
DB_PATH = Path(__file__).parent.parent / 'db' / 'drug_tags.db'
# 동물약 키워드
ANIMAL_KEYWORDS = [
'동물', '반려', '애견', '강아지', '고양이', '반려견',
'넥스가드', '브라벡토', '심파리카', '크레델리오', '컴포티스',
'하트세이버', '하트가드', '다이로하트', '하트웜', '하트캅',
'안텔민', '파라캅', '제스타제',
'캐치원', '셀라이트', '가드닐', '리펠로', '심피드독',
'세레니아', '아포퀄', '갈리프란트', '클라펫',
'펫팜', '동물약품', '애니팜'
]
# 동물약 공급처 (SplName이 이 값이면 전부 동물약)
ANIMAL_SUPPLIERS = [
'펫팜'
]
# 제외 키워드 (사람용 약)
EXCLUDE_KEYWORDS = [
'헤리펫사', '토피라펫', '메타트레이스', '페리돈', '세파라캅'
]
def init_sqlite_db():
"""SQLite DB 초기화"""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS drug_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_code TEXT NOT NULL,
drug_name TEXT,
barcode TEXT,
tag_type TEXT NOT NULL,
tag_value TEXT,
note TEXT,
source TEXT DEFAULT 'keyword',
confidence REAL DEFAULT 0.8,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(drug_code, tag_type)
)
''')
conn.commit()
conn.close()
print(f"✅ SQLite DB 준비: {DB_PATH}")
def search_animal_drugs():
"""MSSQL에서 동물약 검색 (키워드 + 공급처)"""
print("🔍 CD_GOODS에서 동물약 검색 중...")
session = db_manager.get_session('PM_DRUG')
# 키워드 조건
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, SplName
FROM CD_GOODS
WHERE (({keyword_conds}) OR ({supplier_conds}))
AND GoodsSelCode = 'B'
""")
result = session.execute(query)
drugs = result.fetchall()
# 키워드 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):
"""SQLite에 동물약 태깅"""
print("\n📝 SQLite 태깅 중...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
added = 0
skipped = 0
excluded = 0
for drug in 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, source)
VALUES (?, ?, ?, 'animal_drug', 'all', ?, ?)
''', (drug_code, drug_name, barcode, note, source))
added += 1
print(f"{drug_code}: {drug_name} [{source}]")
except sqlite3.IntegrityError:
skipped += 1
conn.commit()
conn.close()
print(f"\n📊 태깅 결과: 추가 {added}개, 중복 {skipped}개, 제외 {excluded}")
return added
def sync_to_mssql():
"""SQLite 태그를 MSSQL POS_BOON에 동기화"""
print("\n🔄 MSSQL 동기화 중...")
# SQLite에서 동물약 목록 가져오기
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
SELECT drug_code, drug_name FROM drug_tags
WHERE tag_type = 'animal_drug' AND is_active = 1
''')
animal_drugs = cursor.fetchall()
conn.close()
print(f" 동물약 {len(animal_drugs)}개 → POS_BOON='010103' 업데이트")
# MSSQL 업데이트
session = db_manager.get_session('PM_DRUG')
updated = 0
for drug_code, drug_name in animal_drugs:
try:
result = session.execute(text('''
UPDATE CD_GOODS SET POS_BOON = '010103' WHERE DrugCode = :dc
'''), {'dc': drug_code})
session.commit()
if result.rowcount > 0:
updated += 1
print(f"{drug_code}: {drug_name}")
except Exception as e:
print(f"{drug_code}: {e}")
print(f"\n🎉 완료! MSSQL 업데이트: {updated}")
def main():
print("=" * 50)
print("🐾 동물약 태깅 시스템")
print("=" * 50)
# 1. SQLite 초기화
init_sqlite_db()
# 2. 동물약 검색
drugs = search_animal_drugs()
# 3. SQLite 태깅
tag_to_sqlite(drugs)
# 4. MSSQL 동기화
sync_to_mssql()
print("\n" + "=" * 50)
print("✅ 모든 작업 완료!")
print("=" * 50)
if __name__ == '__main__':
main()

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,513 @@
"""
Clawdbot Gateway Python 클라이언트
카카오톡 봇과 동일한 Gateway WebSocket API를 통해 Claude와 통신
추가 API 비용 없음 (Claude Max 구독 재활용)
"""
import sys
import os
# Windows 콘솔 UTF-8 강제 (한글 깨짐 방지)
if sys.platform == 'win32':
import io
if hasattr(sys.stdout, 'buffer'):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
import json
import uuid
import asyncio
import logging
from pathlib import Path
import websockets
logger = logging.getLogger(__name__)
# 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():
"""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',
system_prompt=None, timeout=60, model=None):
"""
Clawdbot Gateway WebSocket API 호출
프로토콜:
1. WS 연결
2. 서버 → connect.challenge (nonce)
3. 클라이언트 → connect 요청 (token)
4. 서버 → connect 응답 (ok)
5. 클라이언트 → agent 요청
6. 서버 → accepted (ack) → 최종 응답
Returns:
str: AI 응답 텍스트 (실패 시 None)
"""
config = _load_gateway_config()
url = f"ws://127.0.0.1:{config['port']}"
token = config['token']
try:
async with websockets.connect(url, max_size=25 * 1024 * 1024,
close_timeout=5) as ws:
# 1. connect.challenge 대기
nonce = None
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
challenge = json.loads(challenge_msg)
if challenge.get('event') == 'connect.challenge':
nonce = challenge.get('payload', {}).get('nonce')
# 2. connect 요청
connect_id = str(uuid.uuid4())
connect_frame = {
'type': 'req',
'id': connect_id,
'method': 'connect',
'params': {
'minProtocol': 3,
'maxProtocol': 3,
'client': {
'id': 'gateway-client',
'displayName': 'Pharmacy PAAI',
'version': '1.0.0',
'platform': 'win32',
'mode': 'cli',
'instanceId': str(uuid.uuid4()),
},
'caps': [],
'auth': {
'token': token,
},
'role': 'operator',
'scopes': ['operator.admin', 'operator.write', 'operator.read'],
}
}
await ws.send(json.dumps(connect_frame))
# 3. connect 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
if data.get('id') == connect_id:
if not data.get('ok'):
error = data.get('error', {}).get('message', 'unknown')
logger.warning(f"[Clawdbot] connect 실패: {error}")
return None
break # 연결 성공
# 4. 모델 오버라이드 (sessions.patch)
if model:
patch_id = str(uuid.uuid4())
patch_frame = {
'type': 'req',
'id': patch_id,
'method': 'sessions.patch',
'params': {
'key': session_id,
'model': model,
}
}
await ws.send(json.dumps(patch_frame))
# patch 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
if data.get('id') == patch_id:
if not data.get('ok'):
logger.warning(f"[Clawdbot] sessions.patch 실패: {data.get('error', {}).get('message', 'unknown')}")
break
# 5. agent 요청
agent_id = str(uuid.uuid4())
agent_params = {
'message': message,
'sessionId': session_id,
'sessionKey': session_id,
'timeout': timeout,
'idempotencyKey': str(uuid.uuid4()),
}
if system_prompt:
agent_params['extraSystemPrompt'] = system_prompt
agent_frame = {
'type': 'req',
'id': agent_id,
'method': 'agent',
'params': agent_params,
}
await ws.send(json.dumps(agent_frame))
# 5. agent 응답 대기 (accepted → final)
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout + 30)
data = json.loads(msg)
# 이벤트 무시 (tick 등)
if data.get('event'):
continue
# 우리 요청에 대한 응답인지 확인
if data.get('id') != agent_id:
continue
payload = data.get('payload', {})
status = payload.get('status')
# accepted는 대기
if status == 'accepted':
continue
# 최종 응답
if data.get('ok'):
payloads = payload.get('result', {}).get('payloads', [])
text = '\n'.join(p.get('text', '') for p in payloads if p.get('text'))
return text or None
else:
error = data.get('error', {}).get('message', 'unknown')
logger.warning(f"[Clawdbot] agent 실패: {error}")
return None
except asyncio.TimeoutError:
logger.warning("[Clawdbot] Gateway 타임아웃")
return None
except (ConnectionRefusedError, OSError) as e:
logger.warning(f"[Clawdbot] Gateway 연결 실패 (꺼져있음?): {e}")
return None
except Exception as e:
logger.warning(f"[Clawdbot] Gateway 오류: {e}")
return None
def ask_clawdbot(message, session_id='pharmacy-upsell',
system_prompt=None, timeout=60, model=None):
"""
OpenClaw CLI를 통한 AI 호출 (WebSocket 대신)
Args:
message: 사용자 메시지
session_id: 세션 ID (대화 구분용)
system_prompt: 추가 시스템 프롬프트 (현재 미사용)
timeout: 타임아웃 (초)
model: 모델 오버라이드 (현재 미사용 - CLI가 기본 모델 사용)
Returns:
str: AI 응답 텍스트 (실패 시 None)
"""
import subprocess
import os
from pathlib import Path
try:
# 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'}
)
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"[OpenClaw] 호출 실패: {e}")
return None
# 업셀링 전용 ──────────────────────────────────────
UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' # 업셀링은 Sonnet (빠르고 충분)
UPSELL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
고객의 구매 이력을 보고, 자연스럽고 따뜻한 톤으로 약 하나를 추천합니다.
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
def generate_upsell(user_name, current_items, recent_products):
"""
업셀링 추천 생성
Args:
user_name: 고객명
current_items: 오늘 구매 품목 문자열 (예: "타이레놀, 챔프 시럽")
recent_products: 최근 구매 이력 문자열
Returns:
dict: {'product': '...', 'reason': '...', 'message': '...'} 또는 None
"""
prompt = f"""고객 이름: {user_name}
오늘 구매한 약: {current_items}
최근 구매 이력: {recent_products}
위 정보를 바탕으로 이 고객에게 추천할 약품 하나를 제안해주세요.
규칙:
1. 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
2. 실제 약국에서 판매하는 일반의약품/건강기능식품만 추천 (처방약 제외)
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
4. 구체적인 제품명 사용 (예: "비타민C 1000", "오메가3" 등)
응답은 반드시 아래 JSON 형식으로만:
{{"product": "추천 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [오늘 구매 품목]과 함께 [추천약]도 추천드려요. [간단한 이유]."}}"""
response_text = ask_clawdbot(
prompt,
session_id=f'upsell-{user_name}',
system_prompt=UPSELL_SYSTEM_PROMPT,
timeout=30,
model=UPSELL_MODEL
)
if not response_text:
return None
return _parse_upsell_response(response_text)
UPSELL_REAL_SYSTEM_PROMPT = """당신은 동네 약국(청춘약국)의 친절한 약사입니다.
고객의 구매 이력을 보고, 약국에 실제로 있는 제품 중에서 하나를 추천합니다.
반드시 [약국 보유 제품 목록]에 있는 제품명을 그대로 사용하세요.
목록에 없는 제품은 절대 추천하지 마세요.
강압적이거나 광고 같은 느낌이 아닌, 진심으로 건강을 걱정하는 약사의 말투로 작성해주세요.
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트 없이 JSON만 출력하세요."""
def generate_upsell_real(user_name, current_items, recent_products, available_products):
"""
실데이터 기반 업셀링 추천 생성
available_products: 약국 보유 제품 리스트 [{'name': ..., 'price': ..., 'sales': ...}, ...]
"""
product_list = '\n'.join(
f"- {p['name']} ({int(p['price'])}원, 최근 {p['sales']}건 판매)"
for p in available_products if p.get('name')
)
prompt = f"""고객 이름: {user_name}
오늘 구매한 약: {current_items}
최근 구매 이력: {recent_products}
[약국 보유 제품 목록 — 이 중에서만 추천하세요]
{product_list}
규칙:
1. 위 목록에 있는 제품 중 오늘 구매한 약과 함께 먹으면 좋거나, 구매 패턴상 필요해보이는 약 1가지만 추천
2. 오늘 이미 구매한 제품은 추천하지 마세요
3. 메시지는 2문장 이내, 따뜻하고 자연스러운 톤
4. product 필드에는 목록에 있는 제품명을 정확히 그대로 적어주세요
응답은 반드시 아래 JSON 형식으로만:
{{"product": "목록에 있는 정확한 제품명", "reason": "추천 이유 (내부용, 1문장)", "message": "{user_name}님, [추천 메시지 2문장 이내]"}}"""
response_text = ask_clawdbot(
prompt,
session_id=f'upsell-real-{user_name}',
system_prompt=UPSELL_REAL_SYSTEM_PROMPT,
timeout=30,
model=UPSELL_MODEL
)
if not response_text:
return None
return _parse_upsell_response(response_text)
# ===== Claude 상태 조회 =====
async def _get_gateway_status():
"""
Clawdbot Gateway에서 세션 목록 조회
토큰 차감 없음 (AI 호출 아님)
"""
config = _load_gateway_config()
url = f"ws://127.0.0.1:{config['port']}"
token = config['token']
try:
async with websockets.connect(url, max_size=25 * 1024 * 1024,
close_timeout=5) as ws:
# 1. connect.challenge 대기
challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10)
challenge = json.loads(challenge_msg)
nonce = None
if challenge.get('event') == 'connect.challenge':
nonce = challenge.get('payload', {}).get('nonce')
# 2. connect 요청
connect_id = str(uuid.uuid4())
connect_frame = {
'type': 'req',
'id': connect_id,
'method': 'connect',
'params': {
'minProtocol': 3,
'maxProtocol': 3,
'client': {
'id': 'gateway-client',
'displayName': 'Pharmacy Status',
'version': '1.0.0',
'platform': 'win32',
'mode': 'cli',
'instanceId': str(uuid.uuid4()),
},
'caps': [],
'auth': {'token': token},
'role': 'operator',
'scopes': ['operator.admin', 'operator.write', 'operator.read'],
}
}
await ws.send(json.dumps(connect_frame))
# 3. connect 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
if data.get('id') == connect_id:
if not data.get('ok'):
error = data.get('error', {}).get('message', 'connect failed')
logger.warning(f"[Clawdbot] connect 실패: {error}")
return {'error': error, 'connected': False}
break
# 4. sessions.list 요청
list_id = str(uuid.uuid4())
list_frame = {
'type': 'req',
'id': list_id,
'method': 'sessions.list',
'params': {
'limit': 10
}
}
await ws.send(json.dumps(list_frame))
# 5. 응답 대기
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=10)
data = json.loads(msg)
# 이벤트 무시
if data.get('event'):
continue
if data.get('id') == list_id:
if data.get('ok'):
return {
'connected': True,
'sessions': data.get('payload', {})
}
else:
error = data.get('error', {}).get('message', 'unknown')
return {'error': error, 'connected': True}
except asyncio.TimeoutError:
logger.warning("[Clawdbot] Gateway 타임아웃")
return {'error': 'timeout', 'connected': False}
except (ConnectionRefusedError, OSError) as e:
logger.warning(f"[Clawdbot] Gateway 연결 실패: {e}")
return {'error': str(e), 'connected': False}
except Exception as e:
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
return {'error': str(e), 'connected': False}
def get_claude_status():
"""
동기 래퍼: Claude 상태 조회
Returns:
dict: 상태 정보
"""
try:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(_get_gateway_status())
loop.close()
return result
except Exception as e:
logger.warning(f"[Clawdbot] 상태 조회 실패: {e}")
return {'error': str(e), 'connected': False}
def _parse_upsell_response(text):
"""AI 응답에서 JSON 추출"""
import re
try:
# ```json ... ``` 블록 추출 시도
json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
else:
# 직접 JSON 파싱 시도
start = text.find('{')
end = text.rfind('}')
if start >= 0 and end > start:
json_str = text[start:end + 1]
else:
return None
data = json.loads(json_str)
if 'product' not in data or 'message' not in data:
return None
return {
'product': data['product'],
'reason': data.get('reason', ''),
'message': data['message'],
}
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"[Clawdbot] 업셀 응답 파싱 실패: {e}")
return None

View File

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

View File

@@ -0,0 +1,204 @@
"""
NHN Cloud 알림톡 발송 서비스
마일리지 적립 완료 등 알림톡 발송 + SQLite 로깅
"""
import os
import json
import logging
from datetime import datetime, timezone, timedelta
import requests
logger = logging.getLogger(__name__)
# NHN Cloud 알림톡 설정
APPKEY = os.getenv('NHN_ALIMTALK_APPKEY', 'u0TLUaXXY9bfQFkY')
SECRET_KEY = os.getenv('NHN_ALIMTALK_SECRET', 'naraGEUJfpkRu1fgirKewJtwADqWQ5gY')
SENDER_KEY = os.getenv('NHN_ALIMTALK_SENDER', '341352077bce225195ccc2697fb449f723e70982')
API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}'
# KST 타임존
KST = timezone(timedelta(hours=9))
def _log_to_db(template_code, recipient_no, success, result_message,
template_params=None, user_id=None, trigger_source='unknown',
transaction_id=None):
"""발송 결과를 SQLite에 저장"""
try:
from db.dbsetup import db_manager
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO alimtalk_logs
(template_code, recipient_no, user_id, trigger_source,
template_params, success, result_message, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
template_code,
recipient_no,
user_id,
trigger_source,
json.dumps(template_params, ensure_ascii=False) if template_params else None,
success,
result_message,
transaction_id
))
conn.commit()
except Exception as e:
logger.warning(f"알림톡 로그 DB 저장 실패: {e}")
def _send_alimtalk(template_code, recipient_no, template_params):
"""
알림톡 발송 공통 함수
Args:
template_code: 템플릿 코드
recipient_no: 수신 번호 (01012345678)
template_params: 템플릿 변수 딕셔너리
Returns:
tuple: (성공 여부, 메시지)
"""
url = f'{API_BASE}/messages'
headers = {
'Content-Type': 'application/json;charset=UTF-8',
'X-Secret-Key': SECRET_KEY
}
data = {
'senderKey': SENDER_KEY,
'templateCode': template_code,
'recipientList': [
{
'recipientNo': recipient_no,
'templateParameter': template_params
}
]
}
try:
resp = requests.post(url, headers=headers, json=data, timeout=10)
result = resp.json()
if resp.status_code == 200 and result.get('header', {}).get('isSuccessful'):
logger.info(f"알림톡 발송 성공: {template_code}{recipient_no}")
return (True, "발송 성공")
else:
# 상세 에러 추출: sendResults[0].resultMessage 우선, 없으면 header.resultMessage
header_msg = result.get('header', {}).get('resultMessage', '')
send_results = result.get('message', {}).get('sendResults', [])
detail_msg = send_results[0].get('resultMessage', '') if send_results else ''
# 상세 에러가 있으면 그걸 사용, 없으면 header 에러
error_msg = detail_msg if detail_msg and detail_msg != 'SUCCESS' else header_msg
if not error_msg:
error_msg = str(result)
logger.warning(f"알림톡 발송 실패: {template_code}{recipient_no}: {error_msg}")
return (False, error_msg)
except requests.exceptions.Timeout:
logger.warning(f"알림톡 발송 타임아웃: {template_code}{recipient_no}")
return (False, "타임아웃")
except Exception as e:
logger.warning(f"알림톡 발송 오류: {template_code}{recipient_no}: {e}")
return (False, str(e))
def build_item_summary(items):
"""구매 품목 요약 문자열 생성 (예: '타이레놀 외 3건')
Note: 카카오 알림톡 템플릿 변수는 14자 제한
(에러: "Blacklist can't use more than 14 characters in template value.")
특수문자(%, 괄호 등)는 문제없이 발송 가능!
"""
if not items:
return "약국 구매"
first = items[0]['name']
first = first.strip()
if len(items) == 1:
# 단일 품목: 14자 제한 (그냥 자름)
return first[:14]
# 복수 품목: "외 N건" 붙으므로 전체 14자 맞춤
suffix = f"{len(items) - 1}"
max_first = 14 - len(suffix)
return f"{first[:max_first]}{suffix}"
def send_mileage_claim_alimtalk(phone, name, points, balance, items=None,
user_id=None, trigger_source='kiosk',
transaction_id=None):
"""
마일리지 적립 완료 알림톡 발송
Args:
phone: 수신 전화번호 (01012345678)
name: 고객명
points: 적립 포인트
balance: 적립 후 총 잔액
items: 구매 품목 리스트 [{'name': ..., 'qty': ..., 'total': ...}, ...]
user_id: 사용자 ID (로그용)
trigger_source: 발송 주체 ('kiosk', 'admin', 'manual')
transaction_id: 거래 ID (로그용)
Returns:
tuple: (성공 여부, 메시지)
"""
now_kst = datetime.now(KST).strftime('%m/%d %H:%M')
item_summary = build_item_summary(items)
# MILEAGE_CLAIM_V3 (발송 근거 + 구매품목 포함) 우선 시도
template_code = 'MILEAGE_CLAIM_V3'
params = {
'고객명': name,
'구매품목': item_summary,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst,
'전화번호': phone
}
success, msg = _send_alimtalk(template_code, phone, params)
# 결과 로그 (V3만 사용, V2 폴백 제거 - V2 반려 상태)
_log_to_db(template_code, phone, success, msg,
template_params=params, user_id=user_id,
trigger_source=trigger_source, transaction_id=transaction_id)
return (success, msg)
def get_nhn_send_history(start_date, end_date, page=1, page_size=15):
"""
NHN Cloud API에서 실제 발송 내역 조회
Args:
start_date: 시작일 (YYYY-MM-DD HH:mm)
end_date: 종료일 (YYYY-MM-DD HH:mm)
Returns:
list: 발송 메시지 목록
"""
url = (f'{API_BASE}/messages'
f'?startRequestDate={start_date}'
f'&endRequestDate={end_date}'
f'&pageNum={page}&pageSize={page_size}')
headers = {
'Content-Type': 'application/json;charset=UTF-8',
'X-Secret-Key': SECRET_KEY
}
try:
resp = requests.get(url, headers=headers, timeout=10)
data = resp.json()
if data.get('messageSearchResultResponse'):
return data['messageSearchResultResponse'].get('messages', [])
return []
except Exception as e:
logger.warning(f"NHN 발송내역 조회 실패: {e}")
return []

147
backend/sms_client.py Normal file
View File

@@ -0,0 +1,147 @@
# sms_client.py - NHN Cloud SMS API 클라이언트
import requests
import json
import logging
from typing import Dict, List
# NHN Cloud SMS 설정 (SMS 전용 앱키)
SMS_CONFIG = {
"BASE_URL": "https://api-sms.cloud.toast.com",
"APP_KEY": "YWWBZkuJ0ck03cje",
"SECRET_KEY": "jxXbBPnQN2tUL8QnEp4O3YfraGd8ZuNh",
"SENDER_NO": "0334817390", # 발신번호 (033-481-7390)
}
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SMSClient:
"""NHN Cloud SMS 발송 클라이언트"""
def __init__(self):
self.base_url = SMS_CONFIG["BASE_URL"]
self.app_key = SMS_CONFIG["APP_KEY"]
self.secret_key = SMS_CONFIG["SECRET_KEY"]
self.sender_no = SMS_CONFIG["SENDER_NO"]
def _get_headers(self) -> Dict[str, str]:
return {
"Content-Type": "application/json;charset=UTF-8",
"X-Secret-Key": self.secret_key
}
def send_sms(self, recipients: List[Dict], message: str) -> Dict:
"""
SMS 발송
Args:
recipients: [{"phone": "01012345678", "name": "홍길동"}]
message: 메시지 내용 (90바이트 이하 SMS, 초과시 LMS)
Returns:
발송 결과
"""
# 메시지 길이에 따라 SMS/LMS 결정
msg_bytes = len(message.encode('utf-8'))
is_lms = msg_bytes > 90
url = f"{self.base_url}/sms/v3.0/appKeys/{self.app_key}/sender/{'mms' if is_lms else 'sms'}"
# 수신자 리스트 생성
recipient_list = []
for r in recipients:
phone = (r.get('phone') or '').replace('-', '').replace(' ', '')
if phone and len(phone) >= 10:
recipient_list.append({
"recipientNo": phone,
"countryCode": "82"
})
if not recipient_list:
return {
"success": False,
"error": "유효한 수신자가 없습니다"
}
# 요청 데이터
data = {
"body": message,
"sendNo": self.sender_no,
"recipientList": recipient_list
}
# LMS인 경우 제목 추가
if is_lms:
data["title"] = "청춘약국"
try:
logger.info(f"SMS 발송 요청: {len(recipient_list)}명, {msg_bytes}bytes ({'LMS' if is_lms else 'SMS'})")
response = requests.post(
url,
headers=self._get_headers(),
data=json.dumps(data),
timeout=30
)
result = response.json()
logger.info(f"SMS 응답: {result}")
header = result.get("header", {})
if header.get("isSuccessful"):
body = result.get("body", {})
return {
"success": True,
"message": f"SMS 발송 성공 ({len(recipient_list)}명)",
"type": "LMS" if is_lms else "SMS",
"request_id": body.get("data", {}).get("requestId"),
"sent_count": len(recipient_list)
}
else:
return {
"success": False,
"error": header.get("resultMessage", "발송 실패"),
"code": header.get("resultCode")
}
except requests.exceptions.Timeout:
return {"success": False, "error": "요청 시간 초과"}
except requests.exceptions.RequestException as e:
logger.error(f"SMS 발송 오류: {e}")
return {"success": False, "error": str(e)}
except Exception as e:
logger.error(f"SMS 발송 예외: {e}")
return {"success": False, "error": str(e)}
def check_balance(self) -> Dict:
"""잔여 발송량 확인"""
url = f"{self.base_url}/sms/v3.0/appKeys/{self.app_key}/stats"
try:
response = requests.get(url, headers=self._get_headers(), timeout=10)
return response.json()
except Exception as e:
return {"success": False, "error": str(e)}
# 싱글톤 인스턴스
sms_client = SMSClient()
def send_test_sms(phone: str, message: str = None) -> Dict:
"""테스트 SMS 발송"""
if not message:
message = "[청춘약국] 테스트 문자입니다. 정상 수신되었다면 회신 부탁드립니다."
return sms_client.send_sms(
recipients=[{"phone": phone, "name": "테스트"}],
message=message
)
if __name__ == "__main__":
# 테스트 발송
result = send_test_sms("01027027390")
print(json.dumps(result, ensure_ascii=False, indent=2))

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