Compare commits

...

36 Commits

Author SHA1 Message Date
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
69 changed files with 15326 additions and 107 deletions

View File

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

View File

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

View File

@ -56,6 +56,21 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90) # 3개월 유지
from pmr_api import pmr_bp
app.register_blueprint(pmr_bp)
from paai_feedback import paai_feedback_bp
app.register_blueprint(paai_feedback_bp)
from geoyoung_api import geoyoung_bp
app.register_blueprint(geoyoung_bp)
from sooin_api import sooin_bp
app.register_blueprint(sooin_bp)
from baekje_api import baekje_bp
app.register_blueprint(baekje_bp)
from order_api import order_bp
app.register_blueprint(order_bp)
# 데이터베이스 매니저
db_manager = DatabaseManager()
@ -3874,6 +3889,339 @@ def api_sales_detail():
}), 500
# ===== 사용량 조회 페이지 및 API =====
@app.route('/admin/usage')
def admin_usage():
"""OTC 사용량 조회 · 주문 페이지"""
return render_template('admin_usage.html')
@app.route('/admin/rx-usage')
def admin_rx_usage():
"""전문의약품 사용량 조회 · 주문 페이지"""
return render_template('admin_rx_usage.html')
@app.route('/api/usage')
def api_usage():
"""
기간별 품목 사용량 조회 API
GET /api/usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
"""
try:
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
search = request.args.get('search', '').strip()
sort = request.args.get('sort', 'qty_desc') # qty_desc, qty_asc, name_asc, amount_desc
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
mssql_session = db_manager.get_session('PM_PRES')
# 품목별 사용량 집계 쿼리
usage_query = text("""
SELECT
S.DrugCode as drug_code,
ISNULL(G.GoodsName, '알 수 없음') as product_name,
CASE
WHEN G.SplName IS NOT NULL AND G.SplName != '' THEN G.SplName
WHEN SET_CHK.is_set = 1 THEN '세트상품'
ELSE ''
END as supplier,
SUM(ISNULL(S.QUAN, 1)) as total_qty,
SUM(ISNULL(S.SL_TOTAL_PRICE, 0)) as total_amount,
COALESCE(NULLIF(G.BARCODE, ''), U.CD_CD_BARCODE, '') as barcode
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
OUTER APPLY (
SELECT TOP 1 CD_CD_BARCODE
FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER
WHERE DRUGCODE = S.DrugCode AND CD_CD_BARCODE IS NOT NULL AND CD_CD_BARCODE != ''
) U
OUTER APPLY (
SELECT TOP 1 1 as is_set
FROM PM_DRUG.dbo.CD_item_set
WHERE SetCode = S.DrugCode AND DrugCode = 'SET0000'
) SET_CHK
WHERE S.SL_DT_appl >= :start_date
AND S.SL_DT_appl <= :end_date
GROUP BY S.DrugCode, G.GoodsName, G.SplName, SET_CHK.is_set, G.BARCODE, U.CD_CD_BARCODE
ORDER BY SUM(ISNULL(S.QUAN, 1)) DESC
""")
rows = mssql_session.execute(usage_query, {
'start_date': start_date_fmt,
'end_date': end_date_fmt
}).fetchall()
items = []
total_qty = 0
total_amount = 0
for row in rows:
drug_code = row.drug_code or ''
product_name = row.product_name or ''
# 검색 필터
if search:
search_lower = search.lower()
if (search_lower not in product_name.lower() and
search_lower not in drug_code.lower()):
continue
qty = int(row.total_qty or 0)
amount = float(row.total_amount or 0)
items.append({
'drug_code': drug_code,
'product_name': product_name,
'supplier': row.supplier or '',
'barcode': row.barcode or '',
'total_qty': qty,
'total_amount': int(amount),
'thumbnail': None
})
total_qty += qty
total_amount += amount
# 정렬
if sort == 'qty_asc':
items.sort(key=lambda x: x['total_qty'])
elif sort == 'qty_desc':
items.sort(key=lambda x: x['total_qty'], reverse=True)
elif sort == 'name_asc':
items.sort(key=lambda x: x['product_name'])
elif sort == 'amount_desc':
items.sort(key=lambda x: x['total_amount'], reverse=True)
# 제품 이미지 조회
try:
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
if images_db_path.exists():
img_conn = sqlite3.connect(str(images_db_path))
img_cursor = img_conn.cursor()
barcodes = [item['barcode'] for item in items if item['barcode']]
drug_codes = [item['drug_code'] for item in items]
image_map = {}
if barcodes:
placeholders = ','.join(['?' for _ in barcodes])
img_cursor.execute(f'''
SELECT barcode, thumbnail_base64
FROM product_images
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
''', barcodes)
for r in img_cursor.fetchall():
image_map[f'bc:{r[0]}'] = r[1]
if drug_codes:
placeholders = ','.join(['?' for _ in drug_codes])
img_cursor.execute(f'''
SELECT drug_code, thumbnail_base64
FROM product_images
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
''', drug_codes)
for r in img_cursor.fetchall():
if f'dc:{r[0]}' not in image_map:
image_map[f'dc:{r[0]}'] = r[1]
img_conn.close()
for item in items:
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
if thumb:
item['thumbnail'] = thumb
except Exception as img_err:
logging.warning(f"제품 이미지 조회 오류: {img_err}")
# 기간 일수 계산
try:
from datetime import datetime as dt
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
period_days = (end_dt - start_dt).days + 1
except:
period_days = 1
return jsonify({
'success': True,
'items': items[:500], # 최대 500건
'stats': {
'period_days': period_days,
'product_count': len(items),
'total_qty': total_qty,
'total_amount': int(total_amount)
}
})
except Exception as e:
logging.error(f"사용량 조회 오류: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/rx-usage')
def api_rx_usage():
"""
전문의약품(처방전) 기간별 사용량 조회 API
GET /api/rx-usage?start_date=2026-01-01&end_date=2026-01-31&search=타이레놀&sort=qty_desc
"""
try:
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
search = request.args.get('search', '').strip()
sort = request.args.get('sort', 'qty_desc')
# 날짜 형식 변환 (YYYY-MM-DD -> YYYYMMDD)
start_date_fmt = start_date.replace('-', '') if start_date else datetime.now().strftime('%Y%m%d')
end_date_fmt = end_date.replace('-', '') if end_date else datetime.now().strftime('%Y%m%d')
mssql_session = db_manager.get_session('PM_PRES')
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
rx_query = text("""
SELECT
P.DrugCode as drug_code,
ISNULL(G.GoodsName, '알 수 없음') as product_name,
ISNULL(G.SplName, '') as supplier,
SUM(ISNULL(P.QUAN, 1)) as total_qty,
SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_dose,
SUM(ISNULL(P.DRUPRICE, 0) * ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) as total_amount,
COUNT(DISTINCT P.PreSerial) as prescription_count,
COALESCE(NULLIF(G.BARCODE, ''), '') as barcode,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock,
ISNULL(POS.CD_NM_sale, '') as location
FROM PS_sub_pharm P
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode
LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode
LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode
WHERE P.Indate >= :start_date
AND P.Indate <= :end_date
GROUP BY P.DrugCode, G.GoodsName, G.SplName, G.BARCODE, IT.IM_QT_sale_debit, POS.CD_NM_sale
ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC
""")
rows = mssql_session.execute(rx_query, {
'start_date': start_date_fmt,
'end_date': end_date_fmt
}).fetchall()
items = []
total_qty = 0
total_dose = 0
total_amount = 0
total_prescriptions = set()
for row in rows:
drug_code = row.drug_code or ''
product_name = row.product_name or ''
# 검색 필터
if search:
search_lower = search.lower()
if (search_lower not in product_name.lower() and
search_lower not in drug_code.lower()):
continue
qty = int(row.total_qty or 0)
dose = int(row.total_dose or 0)
amount = float(row.total_amount or 0)
rx_count = int(row.prescription_count or 0)
items.append({
'drug_code': drug_code,
'product_name': product_name,
'supplier': row.supplier or '',
'barcode': row.barcode or '',
'total_qty': qty,
'total_dose': dose, # 총 투약량 (수량 x 일수)
'total_amount': int(amount),
'prescription_count': rx_count,
'current_stock': int(row.current_stock or 0), # 현재고
'location': row.location or '', # 약국 내 위치
'thumbnail': None
})
total_qty += qty
total_dose += dose
total_amount += amount
# 정렬
if sort == 'qty_asc':
items.sort(key=lambda x: x['total_dose'])
elif sort == 'qty_desc':
items.sort(key=lambda x: x['total_dose'], reverse=True)
elif sort == 'name_asc':
items.sort(key=lambda x: x['product_name'])
elif sort == 'amount_desc':
items.sort(key=lambda x: x['total_amount'], reverse=True)
elif sort == 'rx_desc':
items.sort(key=lambda x: x['prescription_count'], reverse=True)
# 제품 이미지 조회
try:
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
if images_db_path.exists():
img_conn = sqlite3.connect(str(images_db_path))
img_cursor = img_conn.cursor()
drug_codes = [item['drug_code'] for item in items]
image_map = {}
if drug_codes:
placeholders = ','.join(['?' for _ in drug_codes])
img_cursor.execute(f'''
SELECT drug_code, thumbnail_base64
FROM product_images
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
''', drug_codes)
for r in img_cursor.fetchall():
image_map[r[0]] = r[1]
img_conn.close()
for item in items:
if item['drug_code'] in image_map:
item['thumbnail'] = image_map[item['drug_code']]
except Exception as img_err:
logging.warning(f"제품 이미지 조회 오류: {img_err}")
# 기간 일수 계산
try:
from datetime import datetime as dt
start_dt = dt.strptime(start_date_fmt, '%Y%m%d')
end_dt = dt.strptime(end_date_fmt, '%Y%m%d')
period_days = (end_dt - start_dt).days + 1
except:
period_days = 1
return jsonify({
'success': True,
'items': items[:500],
'stats': {
'period_days': period_days,
'product_count': len(items),
'total_qty': total_qty,
'total_dose': total_dose,
'total_amount': int(total_amount)
}
})
except Exception as e:
logging.error(f"전문의약품 사용량 조회 오류: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
# ===== Claude 상태 API =====
@app.route('/api/claude-status')

262
backend/baekje_api.py Normal file
View File

@ -0,0 +1,262 @@
# -*- 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)

206
backend/bag_page.html Normal file
View File

@ -0,0 +1,206 @@
<!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>
</table>
<div id="bag_view"
style='height:375px;'
> <!--닫는 태그-->
<div class="wrap_table" style="height:375px;overflow-y:scroll;overflow-x:hidden;"><!--scroll div-->
<table class="bag_list">
<caption>장바구니 리스트</caption>
<colgroup>
<col width="30" />
<col width="*" />
<col width="35" />
<col width="60" />
</colgroup>
<thead style="display:none;">
<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>
<tbody>
<tr id="bagLine0">
<td class="first"><input type="checkbox" name="chk_0" id="chk_0" class="chkBox" /></td>
<td class="td_nm" title="(향)스틸녹스정 10mg(병) 100T" ><a href="./PhysicInfo.asp?pc=02719&currVenCd=50911" target="_blank" class="bagPhysic_ln">(향)스틸녹스정 10mg(병)100T</a></td>
<td >
<input type="text" name="bagQty_0" id="bagQty_0" maxlength="10" class="setInput_h18_qty" value="1" data="1"
style="width:25px;"/>
<input type="hidden" name="pc_0" id="pc_0" value="02719" />
<input type="hidden" name="stock_0" id="stock_0" value="50" />
<input type="hidden" name="price_0" value="17300" />
<input type="hidden" name="physic_nm0" value="(향)스틸녹스정 10mg(병)" />
<input type="hidden" name="totalPrice0" id="totalPrice0" value="17300" />
<input type="hidden" name="ordunitqty_0" id="ordunitqty_0" value="0" />
<input type="hidden" name="bidqty_0" id="bidqty_0" value="" />
<input type="hidden" name="outqty_0" id="outqty_0" value="" />
<input type="hidden" name="pg_0" id="pg_0" value="" />
<input type="hidden" name="prodno_0" id="prodno_0" value="" />
<input type="hidden" name="termdt_0" id="termdt_0" value="" />
</td>
<td class="td_num" >
17,300
</td>
</tr>
<tr id="bagLine1">
<td class="first"><input type="checkbox" name="chk_1" id="chk_1" class="chkBox" /></td>
<td class="td_nm" title="(오가논)코자정 50mg(PTP) 30T" ><a href="./PhysicInfo.asp?pc=32495&currVenCd=50911" target="_blank" class="bagPhysic_ln">(오가논)코자정 50mg(PTP)30T</a></td>
<td >
<input type="text" name="bagQty_1" id="bagQty_1" maxlength="10" class="setInput_h18_qty" value="1" data="1"
style="width:25px;"/>
<input type="hidden" name="pc_1" id="pc_1" value="32495" />
<input type="hidden" name="stock_1" id="stock_1" value="234" />
<input type="hidden" name="price_1" value="14220" />
<input type="hidden" name="physic_nm1" value="(오가논)코자정 50mg(PTP)" />
<input type="hidden" name="totalPrice1" id="totalPrice1" value="14220" />
<input type="hidden" name="ordunitqty_1" id="ordunitqty_1" value="0" />
<input type="hidden" name="bidqty_1" id="bidqty_1" value="" />
<input type="hidden" name="outqty_1" id="outqty_1" value="" />
<input type="hidden" name="pg_1" id="pg_1" value="" />
<input type="hidden" name="prodno_1" id="prodno_1" value="" />
<input type="hidden" name="termdt_1" id="termdt_1" value="" />
</td>
<td class="td_num" >
14,220
</td>
</tr>
</tbody>
</table>
</div><!--scroll-->
</div>
</fieldset>
<fieldset class="total_price">
<legend>장바구니 총 금액</legend>
<div class="cntPhysic">
<dl class="orderPhy">
<dt><span>주문품목</span></dt>
<dd class=""><span id="cnt_order">2개</span></dd>
</dl>
<dl class="cancelPhy">
<dt><span>취소품목</span></dt>
<dd class=""><span id="cnt_cancel">0개</span></dd>
</dl>
</div>
<dl class="total">
<dt>주문금액</dt>
<dd id="bag_totPrice" class="" data="31520">
31,520원
</dd>
</dl>
<input type="hidden" name="chkOrderOk" id="chkOrderOk" value="Y" />
<input type="hidden" name="order_min_amt" id="order_min_amt" value="" />
<input type="hidden" name="intArray" id="intArray" value="1" />
<input type="hidden" name="currVenCd" id="currVenCd" value="50911" />
<input type="hidden" name="currMkind" id="currMkind" value="" />
<input type="hidden" name="kind" value="bag_saveall" />
<input type="hidden" name="currLoc" id="currLoc" value="" />
<input type="hidden" name="currRealVenCd" id="currRealVenCd" value="" />
<input type="hidden" name="ven_rotation_check" id="ven_rotation_check" value="N"/>
</fieldset>
</form>
</div><!-- //bag -->
<input type="hidden" name="cookStockFlag_order" id="cookStockFlag_order" value="N" />
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/1.7.2/jquery.min.js"></script>
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/1.8/jquery-ui.min.js"></script>
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/Bag.js?v=250228"></script>
<script type="text/javascript" src="http://sooinpharm.co.kr/Common/Javascript/Common.js?v=220125"></script>
</body>
</html>

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

11
backend/check_db.py Normal file
View File

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

13
backend/check_order_db.py Normal file
View File

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

28
backend/check_paai_db.py Normal file
View File

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

85
backend/download_js.py Normal file
View File

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

View File

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

View File

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

90
backend/find_cart_js.py Normal file
View File

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

82
backend/find_frmsave.py Normal file
View File

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

70
backend/find_order_api.py Normal file
View File

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

View File

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

453
backend/geoyoung_api.py Normal file
View File

@ -0,0 +1,453 @@
# -*- 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
# ========== 하위 호환성 ==========
# 기존 코드에서 직접 클래스 참조하는 경우를 위해
GeoyoungSession = GeoYoungSession

915
backend/order_api.py Normal file
View File

@ -0,0 +1,915 @@
# -*- coding: utf-8 -*-
"""
주문 API 모듈
- 주문 생성/조회
- 지오영 실제 주문 연동
- dry_run 테스트 모드
"""
import sys
import os
import asyncio
import re
from flask import Blueprint, jsonify, request
import logging
logger = logging.getLogger(__name__)
# Blueprint 생성
order_bp = Blueprint('order', __name__, url_prefix='/api/order')
# 지오영 크롤러 경로
CRAWLER_PATH = r'c:\Users\청춘약국\source\person-lookup-web-local\crawler'
if CRAWLER_PATH not in sys.path:
sys.path.insert(0, CRAWLER_PATH)
# 주문 DB
from order_db import (
create_order, get_order, update_order_status,
update_item_result, get_order_history,
save_order_context, get_usage_stats, get_order_pattern,
get_ai_training_data
)
def run_async(coro):
"""동기 컨텍스트에서 비동기 함수 실행"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def parse_specification(spec: str) -> int:
"""규격에서 숫자 추출 (30T -> 30)"""
if not spec:
return 1
match = re.search(r'(\d+)', spec)
return int(match.group(1)) if match else 1
# ─────────────────────────────────────────────
# API 엔드포인트
# ─────────────────────────────────────────────
@order_bp.route('/create', methods=['POST'])
def api_create_order():
"""
주문 생성 (draft 상태)
POST /api/order/create
{
"wholesaler_id": "geoyoung",
"items": [
{
"drug_code": "670400830",
"kd_code": "670400830",
"product_name": "레바미피드정 30T",
"specification": "30T",
"order_qty": 10,
"usage_qty": 280,
"current_stock": 50
}
],
"reference_period": "2026-03-01~2026-03-06",
"note": "오전 주문"
}
"""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data'}), 400
wholesaler_id = data.get('wholesaler_id', 'geoyoung')
items = data.get('items', [])
if not items:
return jsonify({'success': False, 'error': 'No items'}), 400
# unit_qty 계산
for item in items:
if 'unit_qty' not in item:
item['unit_qty'] = parse_specification(item.get('specification'))
result = create_order(
wholesaler_id=wholesaler_id,
items=items,
order_type=data.get('order_type', 'manual'),
order_session=data.get('order_session'),
reference_period=data.get('reference_period'),
note=data.get('note')
)
return jsonify(result)
@order_bp.route('/submit', methods=['POST'])
def api_submit_order():
"""
주문 제출 (실제 도매상 주문)
POST /api/order/submit
{
"order_id": 1,
"dry_run": true
}
dry_run=true: 시뮬레이션 (실제 주문 X)
dry_run=false: 실제 주문
"""
data = request.get_json()
order_id = data.get('order_id')
dry_run = data.get('dry_run', True) # 기본은 테스트 모드
if not order_id:
return jsonify({'success': False, 'error': 'order_id required'}), 400
# 주문 조회
order = get_order(order_id)
if not order:
return jsonify({'success': False, 'error': 'Order not found'}), 404
if order['status'] not in ('draft', 'pending', 'failed'):
return jsonify({
'success': False,
'error': f"Cannot submit order with status: {order['status']}"
}), 400
wholesaler_id = order['wholesaler_id']
# 도매상별 주문 처리
if wholesaler_id == 'geoyoung':
result = submit_geoyoung_order(order, dry_run)
else:
result = {
'success': False,
'error': f'Wholesaler {wholesaler_id} not supported yet'
}
return jsonify(result)
def submit_geoyoung_order(order: dict, dry_run: bool) -> dict:
"""지오영 주문 제출"""
order_id = order['id']
items = order['items']
# 상태 업데이트
update_order_status(order_id, 'pending',
f'주문 제출 시작 (dry_run={dry_run})')
results = []
success_count = 0
failed_count = 0
try:
if dry_run:
# ─────────────────────────────────────────
# DRY RUN: 시뮬레이션
# ─────────────────────────────────────────
for item in items:
# 재고 확인만 (실제 주문 X)
from geoyoung_api import search_geoyoung_stock
kd_code = item.get('kd_code') or item.get('drug_code')
stock_result = search_geoyoung_stock(kd_code)
# 규격 매칭 (재고 있는 것 우선!)
spec = item.get('specification', '')
matched = None
matched_with_stock = None
matched_any = None
if stock_result.get('success'):
for geo_item in stock_result.get('items', []):
if spec in geo_item.get('specification', ''):
# 첫 번째 규격 매칭 저장
if matched_any is None:
matched_any = geo_item
# 재고 있는 제품 우선
if geo_item.get('stock', 0) > 0:
matched_with_stock = geo_item
break
# 재고 있는 것 우선, 없으면 첫 번째 매칭
matched = matched_with_stock or matched_any
# 모든 규격과 재고 수집 (AI 학습용)
available_specs = []
spec_stocks = {}
if stock_result.get('success'):
for geo_item in stock_result.get('items', []):
s = geo_item.get('specification', '')
available_specs.append(s)
spec_stocks[s] = geo_item.get('stock', 0)
if matched:
if matched['stock'] >= item['order_qty']:
# 주문 가능
status = 'success'
result_code = 'OK'
result_message = f"[DRY RUN] 주문 가능: 재고 {matched['stock']}"
success_count += 1
selection_reason = 'stock_available'
else:
# 재고 부족
status = 'failed'
selection_reason = 'low_stock'
result_code = 'LOW_STOCK'
result_message = f"[DRY RUN] 재고 부족: {matched['stock']}개 (요청: {item['order_qty']})"
failed_count += 1
else:
# 제품 없음
status = 'failed'
result_code = 'NOT_FOUND'
result_message = f"[DRY RUN] 지오영에서 규격 {spec} 미발견"
failed_count += 1
selection_reason = 'not_found'
update_item_result(item['id'], status, result_code, result_message)
# ─────────────────────────────────────────
# AI 학습용 컨텍스트 저장
# ─────────────────────────────────────────
save_order_context(item['id'], {
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'stock_at_order': item.get('current_stock', 0),
'usage_1d': item.get('usage_qty', 0) // 7 if item.get('usage_qty') else 0, # 추정
'usage_7d': item.get('usage_qty', 0), # 조회 기간 사용량
'usage_30d': (item.get('usage_qty', 0) * 30) // 7 if item.get('usage_qty') else 0, # 추정
'ordered_spec': spec,
'ordered_qty': item['order_qty'],
'available_specs': available_specs,
'spec_stocks': spec_stocks,
'selection_reason': selection_reason if 'selection_reason' in dir() else 'unknown'
})
results.append({
'item_id': item['id'],
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'specification': spec,
'order_qty': item['order_qty'],
'status': status,
'result_code': result_code,
'result_message': result_message,
'available_specs': available_specs,
'spec_stocks': spec_stocks
})
# 주문 상태 업데이트
if failed_count == 0:
update_order_status(order_id, 'completed',
f'[DRY RUN] 시뮬레이션 완료: {success_count}개 성공')
elif success_count == 0:
update_order_status(order_id, 'failed',
f'[DRY RUN] 시뮬레이션 완료: {failed_count}개 실패')
else:
update_order_status(order_id, 'partial',
f'[DRY RUN] 부분 성공: {success_count}개 성공, {failed_count}개 실패')
else:
# ─────────────────────────────────────────
# 실제 주문 (빠른 API - ~1초/품목)
# ─────────────────────────────────────────
from geoyoung_api import get_geo_session
geo_session = get_geo_session()
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
order_qty = item['order_qty']
spec = item.get('specification', '')
try:
# 지오영 주문 실행 (빠른 API - 장바구니+확정)
result = geo_session.full_order(
kd_code=kd_code,
quantity=order_qty,
specification=spec if spec else None,
check_stock=True,
auto_confirm=True,
memo=f"자동주문 - {item.get('product_name', '')}"
)
if result.get('success'):
status = 'success'
result_code = 'OK'
result_message = result.get('message', '주문 완료')
success_count += 1
else:
status = 'failed'
result_code = result.get('error', 'UNKNOWN')
result_message = result.get('message', '주문 실패')
failed_count += 1
except Exception as e:
status = 'failed'
result_code = 'ERROR'
result_message = str(e)
failed_count += 1
update_item_result(item['id'], status, result_code, result_message)
# AI 학습용 컨텍스트 저장 (실제 주문)
save_order_context(item['id'], {
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'stock_at_order': item.get('current_stock', 0),
'usage_7d': item.get('usage_qty', 0),
'ordered_spec': spec,
'ordered_qty': order_qty,
'selection_reason': 'user_order'
})
results.append({
'item_id': item['id'],
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'specification': spec,
'order_qty': order_qty,
'status': status,
'result_code': result_code,
'result_message': result_message
})
# 주문 상태 업데이트
if failed_count == 0:
update_order_status(order_id, 'submitted',
f'주문 제출 완료: {success_count}개 품목')
elif success_count == 0:
update_order_status(order_id, 'failed',
f'주문 실패: {failed_count}개 품목')
else:
update_order_status(order_id, 'partial',
f'부분 주문: {success_count}개 성공, {failed_count}개 실패')
return {
'success': True,
'dry_run': dry_run,
'order_id': order_id,
'order_no': order['order_no'],
'total_items': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
}
except Exception as e:
logger.error(f"지오영 주문 오류: {e}")
update_order_status(order_id, 'failed', str(e))
return {
'success': False,
'order_id': order_id,
'error': str(e)
}
@order_bp.route('/<int:order_id>', methods=['GET'])
def api_get_order(order_id):
"""주문 상세 조회"""
order = get_order(order_id)
if not order:
return jsonify({'success': False, 'error': 'Order not found'}), 404
return jsonify({'success': True, 'order': order})
@order_bp.route('/history', methods=['GET'])
def api_order_history():
"""
주문 이력 조회
GET /api/order/history?wholesaler_id=geoyoung&start_date=2026-03-01&limit=20
"""
orders = get_order_history(
wholesaler_id=request.args.get('wholesaler_id'),
start_date=request.args.get('start_date'),
end_date=request.args.get('end_date'),
status=request.args.get('status'),
limit=int(request.args.get('limit', 50))
)
return jsonify({
'success': True,
'count': len(orders),
'orders': orders
})
@order_bp.route('/quick-submit', methods=['POST'])
def api_quick_submit():
"""
빠른 주문 (생성 + 제출 한번에)
POST /api/order/quick-submit
{
"wholesaler_id": "geoyoung" | "sooin",
"items": [...],
"dry_run": true
}
"""
data = request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'No items'}), 400
# 1. 주문 생성
create_result = create_order(
wholesaler_id=data.get('wholesaler_id', 'geoyoung'),
items=data['items'],
order_type='manual',
reference_period=data.get('reference_period'),
note=data.get('note')
)
if not create_result.get('success'):
return jsonify(create_result), 400
order_id = create_result['order_id']
# 2. 주문 조회
order = get_order(order_id)
# 3. 주문 제출
dry_run = data.get('dry_run', True)
if order['wholesaler_id'] == 'geoyoung':
submit_result = submit_geoyoung_order(order, dry_run)
elif order['wholesaler_id'] == 'sooin':
submit_result = submit_sooin_order(order, dry_run)
elif order['wholesaler_id'] == 'baekje':
submit_result = submit_baekje_order(order, dry_run)
else:
submit_result = {'success': False, 'error': f"Wholesaler {order['wholesaler_id']} not supported"}
submit_result['order_no'] = create_result['order_no']
return jsonify(submit_result)
def submit_sooin_order(order: dict, dry_run: bool) -> dict:
"""수인약품 주문 제출"""
order_id = order['id']
items = order['items']
# 상태 업데이트
update_order_status(order_id, 'pending',
f'수인 주문 시작 (dry_run={dry_run})')
results = []
success_count = 0
failed_count = 0
try:
from sooin_api import get_sooin_session
sooin_session = get_sooin_session()
if dry_run:
# ─────────────────────────────────────────
# DRY RUN: 재고 확인만
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
spec = item.get('specification', '')
# 재고 검색
search_result = sooin_session.search_products(kd_code)
matched = None
available_specs = []
spec_stocks = {}
if search_result.get('success'):
for sooin_item in search_result.get('items', []):
s = sooin_item.get('spec', '')
available_specs.append(s)
spec_stocks[s] = sooin_item.get('stock', 0)
# 규격 매칭
if spec in s or s in spec:
if matched is None or sooin_item.get('stock', 0) > matched.get('stock', 0):
matched = sooin_item
if matched:
stock = matched.get('stock', 0)
if stock >= item['order_qty']:
status = 'success'
result_code = 'OK'
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}"
success_count += 1
selection_reason = 'stock_available'
elif stock > 0:
status = 'failed'
result_code = 'LOW_STOCK'
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
failed_count += 1
selection_reason = 'low_stock'
else:
status = 'failed'
result_code = 'OUT_OF_STOCK'
result_message = f"[DRY RUN] 재고 없음"
failed_count += 1
selection_reason = 'out_of_stock'
else:
status = 'failed'
result_code = 'NOT_FOUND'
result_message = f"[DRY RUN] 수인에서 규격 {spec} 미발견"
failed_count += 1
selection_reason = 'not_found'
update_item_result(item['id'], status, result_code, result_message)
# AI 학습용 컨텍스트 저장
save_order_context(item['id'], {
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'stock_at_order': item.get('current_stock', 0),
'usage_7d': item.get('usage_qty', 0),
'ordered_spec': spec,
'ordered_qty': item['order_qty'],
'available_specs': available_specs,
'spec_stocks': spec_stocks,
'selection_reason': selection_reason,
'wholesaler_id': 'sooin'
})
results.append({
'item_id': item['id'],
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'specification': spec,
'order_qty': item['order_qty'],
'status': status,
'result_code': result_code,
'result_message': result_message,
'available_specs': available_specs,
'spec_stocks': spec_stocks,
'price': matched.get('price') if matched else None
})
# 상태 업데이트
if failed_count == 0:
update_order_status(order_id, 'completed',
f'[DRY RUN] 수인 시뮬레이션 완료: {success_count}개 성공')
elif success_count == 0:
update_order_status(order_id, 'failed',
f'[DRY RUN] 수인 시뮬레이션 완료: {failed_count}개 실패')
else:
update_order_status(order_id, 'partial',
f'[DRY RUN] 수인 부분 성공: {success_count}개 성공, {failed_count}개 실패')
else:
# ─────────────────────────────────────────
# 실제 주문
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
order_qty = item['order_qty']
spec = item.get('specification', '')
internal_code = item.get('internal_code')
try:
# internal_code가 없으면 검색해서 찾기
if not internal_code:
search_result = sooin_session.search_products(kd_code)
if search_result.get('success'):
for sooin_item in search_result.get('items', []):
if spec in sooin_item.get('spec', '') or sooin_item.get('spec', '') in spec:
internal_code = sooin_item.get('internal_code')
break
if not internal_code:
raise ValueError(f"내부 코드를 찾을 수 없음: {kd_code} {spec}")
# 장바구니 추가
cart_result = sooin_session.add_to_cart(internal_code, order_qty)
if cart_result.get('success'):
status = 'success'
result_code = 'CART_ADDED'
result_message = f"장바구니 추가 완료 (확정 필요)"
success_count += 1
else:
status = 'failed'
result_code = cart_result.get('error', 'CART_FAILED')
result_message = cart_result.get('message', '장바구니 추가 실패')
failed_count += 1
except Exception as e:
status = 'failed'
result_code = 'ERROR'
result_message = str(e)
failed_count += 1
update_item_result(item['id'], status, result_code, result_message)
save_order_context(item['id'], {
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'stock_at_order': item.get('current_stock', 0),
'usage_7d': item.get('usage_qty', 0),
'ordered_spec': spec,
'ordered_qty': order_qty,
'selection_reason': 'user_order',
'wholesaler_id': 'sooin',
'internal_code': internal_code
})
results.append({
'item_id': item['id'],
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'specification': spec,
'order_qty': order_qty,
'status': status,
'result_code': result_code,
'result_message': result_message
})
# 주문 확정은 별도로 (장바구니에 담기만 한 상태)
if success_count > 0:
update_order_status(order_id, 'pending',
f'수인 장바구니 추가 완료: {success_count}개 (확정 필요)')
else:
update_order_status(order_id, 'failed',
f'수인 주문 실패: {failed_count}')
return {
'success': True,
'dry_run': dry_run,
'order_id': order_id,
'order_no': order['order_no'],
'wholesaler': 'sooin',
'total_items': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results,
'note': '실제 주문 시 장바구니에 담김. 수인약품 사이트에서 최종 확정 필요.' if not dry_run else None
}
except Exception as e:
logger.error(f"수인 주문 오류: {e}")
update_order_status(order_id, 'failed', str(e))
return {
'success': False,
'order_id': order_id,
'error': str(e)
}
def submit_baekje_order(order: dict, dry_run: bool) -> dict:
"""백제약품 주문 제출"""
order_id = order['id']
items = order['items']
# 상태 업데이트
update_order_status(order_id, 'pending',
f'백제약품 주문 시작 (dry_run={dry_run})')
results = []
success_count = 0
failed_count = 0
try:
from baekje_api import get_baekje_session
baekje_session = get_baekje_session()
if dry_run:
# ─────────────────────────────────────────
# DRY RUN: 재고 확인만
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
spec = item.get('specification', '')
# 재고 검색
search_result = baekje_session.search_products(kd_code)
matched = None
available_specs = []
spec_stocks = {}
if search_result.get('success'):
for baekje_item in search_result.get('items', []):
s = baekje_item.get('spec', '')
available_specs.append(s)
spec_stocks[s] = baekje_item.get('stock', 0)
# 규격 매칭
if spec in s or s in spec:
if matched is None or baekje_item.get('stock', 0) > matched.get('stock', 0):
matched = baekje_item
if matched:
stock = matched.get('stock', 0)
if stock >= item['order_qty']:
status = 'success'
result_code = 'OK'
result_message = f"[DRY RUN] 주문 가능: 재고 {stock}, 단가 {matched.get('price', 0):,}"
success_count += 1
selection_reason = 'stock_available'
elif stock > 0:
status = 'failed'
result_code = 'LOW_STOCK'
result_message = f"[DRY RUN] 재고 부족: {stock}개 (요청: {item['order_qty']})"
failed_count += 1
selection_reason = 'low_stock'
else:
status = 'failed'
result_code = 'OUT_OF_STOCK'
result_message = f"[DRY RUN] 재고 없음"
failed_count += 1
selection_reason = 'out_of_stock'
else:
status = 'failed'
result_code = 'NOT_FOUND'
result_message = f"[DRY RUN] 백제에서 규격 {spec} 미발견"
failed_count += 1
selection_reason = 'not_found'
update_item_result(item['id'], status, result_code, result_message)
# AI 학습용 컨텍스트 저장
save_order_context(item['id'], {
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'stock_at_order': item.get('current_stock', 0),
'usage_7d': item.get('usage_qty', 0),
'ordered_spec': spec,
'ordered_qty': item['order_qty'],
'available_specs': available_specs,
'spec_stocks': spec_stocks,
'selection_reason': selection_reason,
'wholesaler_id': 'baekje'
})
results.append({
'item_id': item['id'],
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'specification': spec,
'order_qty': item['order_qty'],
'status': status,
'result_code': result_code,
'result_message': result_message,
'available_specs': available_specs,
'spec_stocks': spec_stocks,
'price': matched.get('price') if matched else None
})
# 상태 업데이트
if failed_count == 0:
update_order_status(order_id, 'completed',
f'[DRY RUN] 백제 시뮬레이션 완료: {success_count}개 성공')
elif success_count == 0:
update_order_status(order_id, 'failed',
f'[DRY RUN] 백제 시뮬레이션 완료: {failed_count}개 실패')
else:
update_order_status(order_id, 'partial',
f'[DRY RUN] 백제 부분 성공: {success_count}개 성공, {failed_count}개 실패')
else:
# ─────────────────────────────────────────
# 실제 주문 (장바구니 추가)
# ─────────────────────────────────────────
for item in items:
kd_code = item.get('kd_code') or item.get('drug_code')
order_qty = item['order_qty']
spec = item.get('specification', '')
try:
# 장바구니 추가
cart_result = baekje_session.add_to_cart(kd_code, order_qty)
if cart_result.get('success'):
status = 'success'
result_code = 'CART_ADDED'
result_message = f"장바구니 추가 완료 (백제몰에서 확정 필요)"
success_count += 1
else:
status = 'failed'
result_code = cart_result.get('error', 'CART_FAILED')
result_message = cart_result.get('message', '장바구니 추가 실패')
failed_count += 1
except Exception as e:
status = 'failed'
result_code = 'ERROR'
result_message = str(e)
failed_count += 1
update_item_result(item['id'], status, result_code, result_message)
save_order_context(item['id'], {
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'stock_at_order': item.get('current_stock', 0),
'usage_7d': item.get('usage_qty', 0),
'ordered_spec': spec,
'ordered_qty': order_qty,
'selection_reason': 'user_order',
'wholesaler_id': 'baekje'
})
results.append({
'item_id': item['id'],
'drug_code': item['drug_code'],
'product_name': item['product_name'],
'specification': spec,
'order_qty': order_qty,
'status': status,
'result_code': result_code,
'result_message': result_message
})
# 상태 업데이트
if success_count > 0:
update_order_status(order_id, 'pending',
f'백제 장바구니 추가 완료: {success_count}개 (확정 필요)')
else:
update_order_status(order_id, 'failed',
f'백제 주문 실패: {failed_count}')
return {
'success': True,
'dry_run': dry_run,
'order_id': order_id,
'order_no': order['order_no'],
'wholesaler': 'baekje',
'total_items': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results,
'note': '실제 주문 시 장바구니에 담김. 백제몰(ibjp.co.kr)에서 최종 확정 필요.' if not dry_run else None
}
except Exception as e:
logger.error(f"백제 주문 오류: {e}")
update_order_status(order_id, 'failed', str(e))
return {
'success': False,
'order_id': order_id,
'error': str(e)
}
# ─────────────────────────────────────────────
# AI 학습용 API
# ─────────────────────────────────────────────
@order_bp.route('/ai/training-data', methods=['GET'])
def api_ai_training_data():
"""
AI 학습용 데이터 추출
GET /api/order/ai/training-data?limit=1000
"""
limit = int(request.args.get('limit', 1000))
data = get_ai_training_data(limit)
return jsonify({
'success': True,
'count': len(data),
'data': data
})
@order_bp.route('/ai/usage-stats/<drug_code>', methods=['GET'])
def api_ai_usage_stats(drug_code):
"""
약품 사용량 통계 (AI 분석용)
GET /api/order/ai/usage-stats/670400830?days=30
"""
days = int(request.args.get('days', 30))
stats = get_usage_stats(drug_code, days)
return jsonify({
'success': True,
'stats': stats
})
@order_bp.route('/ai/order-pattern/<drug_code>', methods=['GET'])
def api_ai_order_pattern(drug_code):
"""
약품 주문 패턴 조회
GET /api/order/ai/order-pattern/670400830
"""
pattern = get_order_pattern(drug_code)
if pattern:
return jsonify({'success': True, 'pattern': pattern})
else:
return jsonify({
'success': True,
'pattern': None,
'message': '주문 이력이 없습니다'
})

859
backend/order_db.py Normal file
View File

@ -0,0 +1,859 @@
# -*- 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, -- 보험코드 (지오영 검색용)
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, 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('product_name'),
item.get('manufacturer'),
item.get('specification'),
unit_qty,
order_qty,
order_qty * unit_qty,
item.get('usage_qty'),
item.get('current_stock')
))
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, new_status, message)
VALUES (?, 'created', 'draft', ?)
''', (order_id, f'{len(items)}개 품목 주문 생성'))
conn.commit()
return {
'success': True,
'order_id': order_id,
'order_no': order_no,
'total_items': len(items)
}
except Exception as e:
conn.rollback()
return {'success': False, 'error': str(e)}
finally:
conn.close()
def get_order(order_id: int) -> Optional[Dict]:
"""주문 조회 (품목 포함)"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM orders WHERE id = ?', (order_id,))
order = cursor.fetchone()
if not order:
conn.close()
return None
cursor.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,))
items = cursor.fetchall()
conn.close()
return {
**dict(order),
'items': [dict(item) for item in items]
}
def update_order_status(order_id: int, status: str, message: str = None) -> bool:
"""주문 상태 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
# 현재 상태 조회
cursor.execute('SELECT status FROM orders WHERE id = ?', (order_id,))
row = cursor.fetchone()
if not row:
return False
old_status = row['status']
# 상태 업데이트
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
update_fields = ['status = ?', 'updated_at = ?']
params = [status, now]
if status == 'submitted':
update_fields.append('submitted_at = ?')
params.append(now)
elif status in ('completed', 'failed'):
update_fields.append('completed_at = ?')
params.append(now)
params.append(order_id)
cursor.execute(f'''
UPDATE orders SET {', '.join(update_fields)} WHERE id = ?
''', params)
# 로그
cursor.execute('''
INSERT INTO order_logs (order_id, action, old_status, new_status, message)
VALUES (?, ?, ?, ?, ?)
''', (order_id, status, old_status, status, message))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_item_result(item_id: int, status: str, result_code: str = None,
result_message: str = None, wholesaler_order_no: str = None) -> bool:
"""품목 결과 업데이트"""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
UPDATE order_items SET
status = ?,
result_code = ?,
result_message = ?,
wholesaler_order_no = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (status, result_code, result_message, wholesaler_order_no, item_id))
# 주문 집계 업데이트
cursor.execute('SELECT order_id FROM order_items WHERE id = ?', (item_id,))
order_id = cursor.fetchone()['order_id']
cursor.execute('''
UPDATE orders SET
success_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'success'),
failed_items = (SELECT COUNT(*) FROM order_items WHERE order_id = ? AND status = 'failed'),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (order_id, order_id, order_id))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_order_history(wholesaler_id: str = None,
start_date: str = None,
end_date: str = None,
status: str = None,
limit: int = 50) -> List[Dict]:
"""주문 이력 조회"""
conn = get_connection()
cursor = conn.cursor()
query = 'SELECT * FROM orders WHERE 1=1'
params = []
if wholesaler_id:
query += ' AND wholesaler_id = ?'
params.append(wholesaler_id)
if start_date:
query += ' AND order_date >= ?'
params.append(start_date)
if end_date:
query += ' AND order_date <= ?'
params.append(end_date)
if status:
query += ' AND status = ?'
params.append(status)
query += ' ORDER BY created_at DESC LIMIT ?'
params.append(limit)
cursor.execute(query, params)
orders = [dict(row) for row in cursor.fetchall()]
conn.close()
return orders
# ─────────────────────────────────────────────
# AI 학습용 함수들
# ─────────────────────────────────────────────
def save_order_context(order_item_id: int, context: Dict) -> bool:
"""
주문 시점 컨텍스트 저장 (AI 학습용)
context: {
'drug_code': '670400830',
'product_name': '레바미피드정',
'stock_at_order': 50,
'usage_1d': 30,
'usage_7d': 180,
'usage_30d': 800,
'ordered_spec': '30T',
'ordered_qty': 10,
'available_specs': ['30T', '300T'],
'spec_stocks': {'30T': 50, '300T': 0},
'selection_reason': 'stock_available'
}
"""
conn = get_connection()
cursor = conn.cursor()
try:
# 일평균 사용량 계산
usage_30d = context.get('usage_30d', 0)
avg_daily = usage_30d / 30.0 if usage_30d else 0
# 재고 소진 예상일 계산
stock = context.get('stock_at_order', 0)
days_until_stockout = stock / avg_daily if avg_daily > 0 else None
# 주문 총 정제수
ordered_qty = context.get('ordered_qty', 0)
spec = context.get('ordered_spec', '')
unit_qty = int(''.join(filter(str.isdigit, spec))) if spec else 1
ordered_dose = ordered_qty * unit_qty
cursor.execute('''
INSERT INTO order_context (
order_item_id, drug_code, product_name,
stock_at_order, usage_1d, usage_7d, usage_30d, avg_daily_usage,
ordered_spec, ordered_qty, ordered_dose,
available_specs, spec_stocks, selection_reason,
days_until_stockout
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
order_item_id,
context.get('drug_code'),
context.get('product_name'),
context.get('stock_at_order'),
context.get('usage_1d'),
context.get('usage_7d'),
context.get('usage_30d'),
avg_daily,
context.get('ordered_spec'),
ordered_qty,
ordered_dose,
json.dumps(context.get('available_specs', []), ensure_ascii=False),
json.dumps(context.get('spec_stocks', {}), ensure_ascii=False),
context.get('selection_reason'),
days_until_stockout
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def update_daily_usage(drug_code: str, usage_date: str,
rx_count: int = 0, rx_qty: int = 0,
pos_count: int = 0, pos_qty: int = 0,
stock_end: int = None) -> bool:
"""일별 사용량 업데이트 (UPSERT)"""
conn = get_connection()
cursor = conn.cursor()
try:
total_qty = rx_qty + pos_qty
cursor.execute('''
INSERT INTO daily_usage (
drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, usage_date) DO UPDATE SET
rx_count = rx_count + excluded.rx_count,
rx_qty = rx_qty + excluded.rx_qty,
pos_count = pos_count + excluded.pos_count,
pos_qty = pos_qty + excluded.pos_qty,
total_qty = total_qty + excluded.total_qty,
stock_end = COALESCE(excluded.stock_end, stock_end)
''', (drug_code, usage_date, rx_count, rx_qty, pos_count, pos_qty,
total_qty, stock_end))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
def get_usage_stats(drug_code: str, days: int = 30) -> Dict:
"""약품 사용량 통계 조회 (AI 분석용)"""
conn = get_connection()
cursor = conn.cursor()
from datetime import datetime, timedelta
end_date = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cursor.execute('''
SELECT
COUNT(*) as days_with_data,
SUM(total_qty) as total_usage,
AVG(total_qty) as avg_daily,
MAX(total_qty) as max_daily,
MIN(total_qty) as min_daily
FROM daily_usage
WHERE drug_code = ? AND usage_date BETWEEN ? AND ?
''', (drug_code, start_date, end_date))
row = cursor.fetchone()
conn.close()
if row and row['total_usage']:
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': row['days_with_data'],
'total_usage': row['total_usage'],
'avg_daily': round(row['avg_daily'], 2) if row['avg_daily'] else 0,
'max_daily': row['max_daily'],
'min_daily': row['min_daily']
}
return {
'drug_code': drug_code,
'period_days': days,
'days_with_data': 0,
'total_usage': 0,
'avg_daily': 0,
'max_daily': 0,
'min_daily': 0
}
def get_order_pattern(drug_code: str) -> Optional[Dict]:
"""약품 주문 패턴 조회"""
conn = get_connection()
cursor = conn.cursor()
# 최근 주문 이력 분석
cursor.execute('''
SELECT
oi.specification,
oi.order_qty,
oi.total_dose,
o.order_date
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE oi.drug_code = ? AND oi.status = 'success'
ORDER BY o.order_date DESC
LIMIT 10
''', (drug_code,))
orders = [dict(row) for row in cursor.fetchall()]
if not orders:
conn.close()
return None
# 가장 많이 사용된 규격
spec_counts = {}
for o in orders:
spec = o['specification']
spec_counts[spec] = spec_counts.get(spec, 0) + 1
typical_spec = max(spec_counts, key=spec_counts.get)
# 평균 주문 수량
typical_qty = sum(o['order_qty'] for o in orders) // len(orders)
# 주문 주기 계산
if len(orders) >= 2:
dates = [datetime.strptime(o['order_date'], '%Y-%m-%d') for o in orders]
intervals = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
avg_interval = sum(intervals) / len(intervals) if intervals else 0
else:
avg_interval = 0
conn.close()
return {
'drug_code': drug_code,
'order_count': len(orders),
'typical_spec': typical_spec,
'typical_qty': typical_qty,
'avg_order_interval_days': round(avg_interval, 1),
'recent_orders': orders[:5]
}
def get_ai_training_data(limit: int = 1000) -> List[Dict]:
"""AI 학습용 데이터 추출"""
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
oc.*,
oi.status as order_status,
oi.result_code,
o.order_date,
o.wholesaler_id
FROM order_context oc
JOIN order_items oi ON oc.order_item_id = oi.id
JOIN orders o ON oi.order_id = o.id
ORDER BY oc.created_at DESC
LIMIT ?
''', (limit,))
data = []
for row in cursor.fetchall():
item = dict(row)
# JSON 필드 파싱
if item.get('available_specs'):
item['available_specs'] = json.loads(item['available_specs'])
if item.get('spec_stocks'):
item['spec_stocks'] = json.loads(item['spec_stocks'])
data.append(item)
conn.close()
return data
def save_ai_pattern(drug_code: str, pattern: Dict) -> bool:
"""AI 분석 결과 저장"""
conn = get_connection()
cursor = conn.cursor()
try:
today = datetime.now().strftime('%Y-%m-%d')
cursor.execute('''
INSERT INTO order_patterns (
drug_code, analysis_date, analysis_period_days,
avg_daily_usage, usage_stddev, peak_usage,
typical_order_spec, typical_order_qty, order_frequency_days,
recommended_spec, recommended_qty, recommended_reorder_point,
confidence_score, model_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(drug_code, analysis_date) DO UPDATE SET
avg_daily_usage = excluded.avg_daily_usage,
recommended_spec = excluded.recommended_spec,
recommended_qty = excluded.recommended_qty,
confidence_score = excluded.confidence_score
''', (
drug_code,
today,
pattern.get('period_days', 30),
pattern.get('avg_daily_usage'),
pattern.get('usage_stddev'),
pattern.get('peak_usage'),
pattern.get('typical_order_spec'),
pattern.get('typical_order_qty'),
pattern.get('order_frequency_days'),
pattern.get('recommended_spec'),
pattern.get('recommended_qty'),
pattern.get('recommended_reorder_point'),
pattern.get('confidence_score'),
pattern.get('model_version', 'v1')
))
conn.commit()
return True
except Exception as e:
conn.rollback()
return False
finally:
conn.close()
# 초기화 실행
init_db()

225
backend/paai_feedback.py Normal file
View File

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

159
backend/paai_printer.py Normal file
View File

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

201
backend/paai_printer_cli.py Normal file
View File

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

View File

@ -38,6 +38,119 @@ def get_mssql_connection(database='PM_PRES'):
return pyodbc.connect(conn_str, timeout=10)
def warmup_db_connection():
"""앱 시작 시 DB 연결 미리 생성 (첫 요청 속도 개선)"""
try:
conn = get_mssql_connection('PM_PRES')
conn.cursor().execute("SELECT 1")
conn.close()
logging.info("[PMR] DB 연결 warmup 완료")
except Exception as e:
logging.warning(f"[PMR] DB warmup 실패: {e}")
# 앱 로드 시 warmup 실행
warmup_db_connection()
def enrich_medications(medications: list) -> list:
"""
약품 목록에 성분/분류/상호작용/금기 정보 추가 (PAAI용)
CD_SUNG: 성분 정보
CD_MC: 분류(PRINT_TYPE), 상호작용(INTERACTION), 금기(CONTRA)
"""
if not medications:
return medications
try:
conn = get_mssql_connection('PM_DRUG')
cursor = conn.cursor()
# DrugCode 목록 추출
drug_codes = [m.get('code') or m.get('medication_code') for m in medications if m.get('code') or m.get('medication_code')]
if not drug_codes:
conn.close()
return medications
# 1. CD_MC에서 분류/상호작용/금기 조회
placeholders = ','.join(['?' for _ in drug_codes])
cursor.execute(f"""
SELECT
DRUGCODE,
PRINT_TYPE,
INTERACTION,
CONTRA
FROM CD_MC
WHERE DRUGCODE IN ({placeholders})
""", drug_codes)
mc_info = {}
for row in cursor.fetchall():
mc_info[row.DRUGCODE] = {
'print_type': row.PRINT_TYPE or '',
'interaction': (row.INTERACTION or '')[:500], # 너무 길면 자르기
'contra': (row.CONTRA or '')[:300]
}
# 2. CD_GOODS에서 SUNG_CODE 조회
cursor.execute(f"""
SELECT DrugCode, SUNG_CODE
FROM CD_GOODS
WHERE DrugCode IN ({placeholders}) AND SUNG_CODE IS NOT NULL
""", drug_codes)
sung_codes = {}
for row in cursor.fetchall():
if row.SUNG_CODE:
sung_codes[row.DrugCode] = row.SUNG_CODE
# 3. CD_SUNG에서 성분 정보 조회
components_by_drug = {}
if sung_codes:
unique_sung_codes = list(set(sung_codes.values()))
placeholders2 = ','.join(['?' for _ in unique_sung_codes])
cursor.execute(f"""
SELECT SUNG_CODE, SUNG_HNM
FROM CD_SUNG
WHERE SUNG_CODE IN ({placeholders2})
""", unique_sung_codes)
# SUNG_CODE별 성분 목록
sung_components = {}
for row in cursor.fetchall():
if row.SUNG_CODE not in sung_components:
sung_components[row.SUNG_CODE] = []
sung_components[row.SUNG_CODE].append(row.SUNG_HNM)
# DrugCode별로 매핑
for drug_code, sung_code in sung_codes.items():
components_by_drug[drug_code] = sung_components.get(sung_code, [])
conn.close()
# 4. medications에 정보 추가
for med in medications:
code = med.get('code') or med.get('medication_code')
if code:
# MC 정보
if code in mc_info:
med['print_type'] = mc_info[code]['print_type']
med['interaction_info'] = mc_info[code]['interaction']
med['contra_info'] = mc_info[code]['contra']
# 성분 정보
if code in components_by_drug:
med['components'] = components_by_drug[code]
return medications
except Exception as e:
logging.error(f"[PAAI] Medication enrichment 오류: {e}")
return medications
# ─────────────────────────────────────────────────────────────
# 조제관리 페이지
# ─────────────────────────────────────────────────────────────
@ -188,7 +301,10 @@ def get_prescription_detail(prescription_id):
return jsonify({'success': False, 'error': '처방전을 찾을 수 없습니다'}), 404
# 처방 약품 목록 (PS_sub_pharm + CD_GOODS + CD_MC JOIN)
# PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본)
medications = []
original_prescriptions = {} # PS_Type=9인 원본 처방 저장
cursor.execute("""
SELECT
s.DrugCode,
@ -197,6 +313,8 @@ def get_prescription_detail(prescription_id):
s.QUAN_TIME,
s.PS_Type,
s.INV_QUAN,
s.SUB_SERIAL,
s.UnitCode,
g.GoodsName,
g.SUNG_CODE,
m.PRINT_TYPE,
@ -208,10 +326,33 @@ def get_prescription_detail(prescription_id):
ORDER BY s.SUB_SERIAL
""", (prescription_id,))
for row in cursor.fetchall():
all_rows = cursor.fetchall()
# 1차: PS_Type=9 (원본 처방) 수집 - 인덱스로 저장
for i, row in enumerate(all_rows):
if row.PS_Type == '9':
original_prescriptions[i] = {
'drug_code': row.DrugCode or '',
'drug_name': row.GoodsName or row.DrugCode or '',
'add_info': row.PRINT_TYPE or row.SIM_EFFECT or ''
}
# 2차: 실제 조제약만 추가 (PS_Type != 9)
for i, row in enumerate(all_rows):
if row.PS_Type == '9':
continue # 원본 처방은 스킵
# 효능: PRINT_TYPE > SIM_EFFECT > 없음
add_info = row.PRINT_TYPE or row.SIM_EFFECT or ''
# 대체조제 여부 확인: PS_Type=4이고 바로 다음이 PS_Type=9
# 순서: 4(대체) → 9(원본)
is_substituted = row.PS_Type == '4' and (i + 1) in original_prescriptions
original_drug = original_prescriptions.get(i + 1) if is_substituted else None
# UnitCode: 1=보험, 2=비보험, 3=100/100, 4~7=급여(본인부담률)
unit_code = int(row.UnitCode) if row.UnitCode else 1
medications.append({
'medication_code': row.DrugCode or '',
'med_name': row.GoodsName or row.DrugCode or '',
@ -221,7 +362,11 @@ def get_prescription_detail(prescription_id):
'duration': row.Days or 0,
'total_qty': float(row.INV_QUAN) if row.INV_QUAN else 0,
'type': '급여' if row.PS_Type in ['0', '4'] else '비급여' if row.PS_Type == '1' else row.PS_Type,
'sung_code': row.SUNG_CODE or ''
'sung_code': row.SUNG_CODE or '',
'ps_type': row.PS_Type or '0',
'unit_code': unit_code,
'is_substituted': is_substituted,
'original_drug': original_drug
})
# 나이/성별 계산
@ -615,18 +760,21 @@ def get_patient_history(cus_code):
pre_serial = row.PreSerial
# 해당 처방의 약품 목록 조회
# PS_Type=9 (대체조제 원처방)는 제외
cursor.execute("""
SELECT
s.DrugCode,
s.Days,
s.QUAN,
s.QUAN_TIME,
s.PS_Type,
g.GoodsName,
m.PRINT_TYPE
FROM PS_sub_pharm s
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
LEFT JOIN PM_DRUG.dbo.CD_MC m ON s.DrugCode = m.DRUGCODE
WHERE s.PreSerial = ?
AND (s.PS_Type IS NULL OR s.PS_Type != '9')
ORDER BY s.SUB_SERIAL
""", (pre_serial,))
@ -638,7 +786,8 @@ def get_patient_history(cus_code):
'add_info': med_row.PRINT_TYPE or '',
'dosage': float(med_row.QUAN) if med_row.QUAN else 0,
'frequency': med_row.QUAN_TIME or 0,
'duration': med_row.Days or 0
'duration': med_row.Days or 0,
'ps_type': med_row.PS_Type or '0'
})
# 날짜 포맷
@ -694,36 +843,69 @@ def get_patient_otc_history(cus_code):
conn = get_mssql_connection('PM_PRES')
cursor = conn.cursor()
# OTC 거래 목록 조회 (PRESERIAL = 'V' = OTC 판매)
# ✅ 최적화: 한번의 쿼리로 거래 + 품목 모두 조회 (JOIN)
cursor.execute("""
SELECT
m.SL_NO_order,
m.SL_DT_appl,
m.InsertTime,
m.SL_MY_sale,
m.SL_NM_custom
FROM SALE_MAIN m
WHERE m.SL_CD_custom = ?
AND m.PRESERIAL = 'V'
ORDER BY m.InsertTime DESC
""", (cus_code,))
m.SL_NM_custom,
s.DrugCode,
g.GoodsName,
s.SL_NM_item,
s.SL_TOTAL_PRICE,
mc.PRINT_TYPE
FROM (
SELECT TOP (?) *
FROM SALE_MAIN
WHERE SL_CD_custom = ? AND PRESERIAL = 'V'
ORDER BY InsertTime DESC
) m
LEFT JOIN SALE_SUB s ON m.SL_NO_order = s.SL_NO_order
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
LEFT JOIN PM_DRUG.dbo.CD_MC mc ON s.DrugCode = mc.DRUGCODE
ORDER BY m.InsertTime DESC, s.DrugCode
""", (limit, cus_code))
# 결과를 order_no별로 그룹핑
orders_dict = {}
all_drug_codes = []
# 먼저 거래 목록 수집
orders = []
for row in cursor.fetchall():
orders.append({
'order_no': row.SL_NO_order,
'date': row.SL_DT_appl,
'datetime': row.InsertTime.strftime('%Y-%m-%d %H:%M') if row.InsertTime else '',
'amount': int(row.SL_MY_sale or 0),
'customer_name': row.SL_NM_custom or ''
})
order_no = row.SL_NO_order
if order_no not in orders_dict:
orders_dict[order_no] = {
'order_no': order_no,
'date': row.SL_DT_appl,
'datetime': row.InsertTime.strftime('%Y-%m-%d %H:%M') if row.InsertTime else '',
'amount': int(row.SL_MY_sale or 0),
'customer_name': row.SL_NM_custom or '',
'items': []
}
# 품목 추가 (DrugCode가 있는 경우만)
if row.DrugCode:
drug_code = row.DrugCode
all_drug_codes.append(drug_code)
orders_dict[order_no]['items'].append({
'drug_code': drug_code,
'name': row.GoodsName or drug_code,
'quantity': int(row.SL_NM_item or 0),
'price': int(row.SL_TOTAL_PRICE or 0),
'category': row.PRINT_TYPE or '',
'image': None
})
# 최근 limit개만
orders = orders[:limit]
conn.close()
if not orders:
conn.close()
# dict → list 변환
purchases = list(orders_dict.values())
for p in purchases:
p['item_count'] = len(p['items'])
if not purchases:
return jsonify({
'success': True,
'cus_code': cus_code,
@ -731,46 +913,6 @@ def get_patient_otc_history(cus_code):
'purchases': []
})
# 각 거래의 품목 조회
purchases = []
all_drug_codes = []
for order in orders:
cursor.execute("""
SELECT
s.DrugCode,
g.GoodsName,
s.SL_NM_item,
s.SL_TOTAL_PRICE,
mc.PRINT_TYPE
FROM SALE_SUB s
LEFT JOIN PM_DRUG.dbo.CD_GOODS g ON s.DrugCode = g.DrugCode
LEFT JOIN PM_DRUG.dbo.CD_MC mc ON s.DrugCode = mc.DRUGCODE
WHERE s.SL_NO_order = ?
ORDER BY s.DrugCode
""", (order['order_no'],))
items = []
for item_row in cursor.fetchall():
drug_code = item_row.DrugCode or ''
all_drug_codes.append(drug_code)
items.append({
'drug_code': drug_code,
'name': item_row.GoodsName or drug_code,
'quantity': int(item_row.SL_NM_item or 0),
'price': int(item_row.SL_TOTAL_PRICE or 0),
'category': item_row.PRINT_TYPE or '',
'image': None
})
purchases.append({
**order,
'items': items,
'item_count': len(items)
})
conn.close()
# 제품 이미지 조회 (product_images.db)
image_map = {}
try:
@ -975,12 +1117,16 @@ def paai_analyze():
pre_serial = data.get('pre_serial')
cus_code = data.get('cus_code')
patient_name = data.get('patient_name')
patient_note = data.get('patient_note', '') # 환자 특이사항 (알러지, 기저질환 등)
disease_info = data.get('disease_info', {})
current_medications = data.get('current_medications', [])
previous_serial = data.get('previous_serial')
previous_medications = data.get('previous_medications', [])
otc_history = data.get('otc_history', {})
# ✅ 약품 정보 Enrichment (성분/분류/상호작용/금기)
current_medications = enrich_medications(current_medications)
# 처방 변화 분석
prescription_changes = analyze_prescription_changes(
current_medications, previous_medications
@ -1056,7 +1202,8 @@ def paai_analyze():
current_medications=current_medications,
prescription_changes=prescription_changes,
kims_interactions=kims_interactions,
otc_history=otc_history
otc_history=otc_history,
patient_note=patient_note
)
# 5. Clawdbot AI 호출 (WebSocket)
@ -1146,7 +1293,8 @@ def build_paai_prompt(
current_medications: list,
prescription_changes: dict,
kims_interactions: list,
otc_history: dict
otc_history: dict,
patient_note: str = ''
) -> str:
"""AI 프롬프트 생성"""
@ -1157,10 +1305,27 @@ def build_paai_prompt(
if disease_info.get('code_2'):
diseases.append(f"[{disease_info['code_2']}] {disease_info.get('name_2', '')}")
# 현재 처방
# 현재 처방 (성분 정보 포함)
med_lines = []
for med in current_medications:
line = f"- {med.get('name', '?')}: {med.get('dosage', 0)}× {med.get('frequency', 0)}× {med.get('days', 0)}"
name = med.get('name', '?')
dosage = med.get('dosage', 0)
freq = med.get('frequency', 0)
days = med.get('days', 0)
line = f"- {name}: {dosage}× {freq}× {days}"
# 분류 정보
if med.get('print_type'):
line += f"\n └ 분류: {med['print_type']}"
# 성분 정보
if med.get('components'):
components_str = ', '.join(med['components'][:3]) # 최대 3개
if len(med['components']) > 3:
components_str += f"{len(med['components'])-3}"
line += f"\n └ 성분: {components_str}"
med_lines.append(line)
# 처방 변화
@ -1194,11 +1359,17 @@ def build_paai_prompt(
for item in otc_history['frequent_items'][:5]:
otc_lines.append(f"- {item.get('name', '?')} ({item.get('count', 0)}회 구매)")
# 환자 특이사항 (알러지, 기저질환 등)
note_text = patient_note.strip() if patient_note else ''
prompt = f"""당신은 약사를 보조하는 AI입니다. 환자 정보와 KIMS 상호작용 데이터를 바탕으로 분석해주세요.
## 환자 질병
{chr(10).join(diseases) if diseases else '- 정보 없음'}
## 환자 특이사항 (알러지/기저질환/주의사항)
{note_text if note_text else '- 없음'}
## 현재 처방
{chr(10).join(med_lines) if med_lines else '- 정보 없음'}
@ -1434,3 +1605,216 @@ def paai_admin_feedback_stats():
except Exception as e:
logging.error(f"피드백 통계 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# ─────────────────────────────────────────────────────────────
# ESC/POS 자동인쇄 API (EUC-KR 텍스트 방식)
# ─────────────────────────────────────────────────────────────
import socket
# 프린터 설정
ESCPOS_PRINTER_IP = "192.168.0.174"
ESCPOS_PRINTER_PORT = 9100
# ESC/POS 명령어
_ESC = b'\x1b'
_INIT = _ESC + b'@' # 프린터 초기화
_CUT = _ESC + b'd\x03' # 피드 + 커트
def _log_print_history(pre_serial, patient_name, success, error=None):
"""인쇄 이력을 파일에 기록"""
try:
log_dir = Path(__file__).parent / 'logs'
log_dir.mkdir(exist_ok=True)
log_file = log_dir / 'print_history.log'
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
status = '✅ 성공' if success else f'❌ 실패: {error}'
line = f"[{timestamp}] {pre_serial} | {patient_name} | {status}\n"
with open(log_file, 'a', encoding='utf-8') as f:
f.write(line)
except Exception as e:
logging.warning(f"인쇄 로그 기록 실패: {e}")
@pmr_bp.route('/api/paai/print', methods=['POST'])
def paai_print():
"""PAAI 분석 결과 ESC/POS 인쇄"""
try:
data = request.get_json()
pre_serial = data.get('pre_serial', '')
patient_name = data.get('patient_name', '')
result = data.get('result', {})
analysis = result.get('analysis', {})
kims_summary = result.get('kims_summary', {})
logging.info(f"[PRINT] 요청 수신: {pre_serial} ({patient_name})")
# 영수증 텍스트 생성
message = _format_paai_receipt(pre_serial, patient_name, analysis, kims_summary)
# 인쇄
success = _print_escpos_text(message)
if success:
logging.info(f"[PRINT] ✅ 완료: {pre_serial} ({patient_name})")
_log_print_history(pre_serial, patient_name, True)
return jsonify({'success': True, 'message': '인쇄 완료'})
else:
logging.error(f"[PRINT] ❌ 프린터 연결 실패: {pre_serial}")
_log_print_history(pre_serial, patient_name, False, '프린터 연결 실패')
return jsonify({'success': False, 'error': '프린터 연결 실패'}), 500
except Exception as e:
logging.error(f"[PRINT] ❌ 오류: {pre_serial} - {e}")
_log_print_history(pre_serial, patient_name, False, str(e))
return jsonify({'success': False, 'error': str(e)}), 500
def _print_escpos_text(message: str) -> bool:
"""ESC/POS 프린터로 텍스트 전송 (EUC-KR)"""
try:
# EUC-KR 인코딩
text_bytes = message.encode('euc-kr', errors='replace')
# 명령어 조합
command = _INIT + text_bytes + b'\n\n\n' + _CUT
# 소켓 전송
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((ESCPOS_PRINTER_IP, ESCPOS_PRINTER_PORT))
sock.sendall(command)
sock.close()
return True
except Exception as e:
logging.error(f"ESC/POS 전송 실패: {e}")
return False
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 _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 _wrap_text(text: str, width: int = 44) -> list:
"""텍스트 줄바꿈"""
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]]

390
backend/sooin_api.py Normal file
View File

@ -0,0 +1,390 @@
# -*- coding: utf-8 -*-
"""
수인약품 도매상 API - Flask Blueprint
핵심 로직은 wholesale 패키지에서 가져옴
파일은 Flask API 연동만 담당
"""
import time
import logging
from flask import Blueprint, jsonify, request as flask_request
# wholesale 패키지 경로 설정
import wholesale_path
# wholesale 패키지에서 핵심 클래스 가져오기
from wholesale import SooinSession
logger = logging.getLogger(__name__)
# Blueprint 생성
sooin_bp = Blueprint('sooin', __name__, url_prefix='/api/sooin')
# ========== 세션 관리 ==========
_sooin_session = None
def get_sooin_session():
global _sooin_session
if _sooin_session is None:
_sooin_session = SooinSession()
return _sooin_session
def search_sooin_stock(keyword: str, search_type: str = 'kd_code'):
"""수인약품 재고 검색 (동기, 빠름)"""
try:
session = get_sooin_session()
result = session.search_products(keyword)
if result.get('success'):
return {
'success': True,
'keyword': keyword,
'search_type': search_type,
'count': result['total'],
'items': result['items']
}
else:
return result
except Exception as e:
logger.error(f"수인약품 검색 오류: {e}")
return {'success': False, 'error': 'SEARCH_ERROR', 'message': str(e)}
# ========== Flask API Routes ==========
@sooin_bp.route('/stock', methods=['GET'])
def api_sooin_stock():
"""
수인약품 재고 조회 API
GET /api/sooin/stock?kd_code=073100220
GET /api/sooin/stock?keyword=코자정&type=name
"""
kd_code = flask_request.args.get('kd_code', '').strip()
keyword = flask_request.args.get('keyword', '').strip()
search_type = flask_request.args.get('type', 'kd_code').strip()
search_term = kd_code or keyword
if kd_code:
search_type = 'kd_code'
if not search_term:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code 또는 keyword 파라미터가 필요합니다'
}), 400
try:
result = search_sooin_stock(search_term, search_type)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 API 오류: {e}")
return jsonify({
'success': False,
'error': 'API_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/session-status', methods=['GET'])
def api_session_status():
"""세션 상태 확인"""
session = get_sooin_session()
return jsonify({
'logged_in': session._logged_in,
'last_login': session._last_login,
'session_age_sec': int(time.time() - session._last_login) if session._last_login else None
})
@sooin_bp.route('/balance', methods=['GET'])
def api_sooin_balance():
"""
수인약품 잔고(미수금) 조회 API
GET /api/sooin/balance
Returns:
{
"success": true,
"balance": 14293001, // 현재 잔고 (누계합)
"prev_balance": 10592762, // 전일잔액
"monthly_sales": 3700239, // 매출
"yearly_sales": 34380314 // 누계 매출
}
"""
try:
session = get_sooin_session()
result = session.get_balance()
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 잔고 조회 오류: {e}")
return jsonify({
'success': False,
'error': 'BALANCE_ERROR',
'message': str(e),
'balance': 0
}), 500
@sooin_bp.route('/cart', methods=['GET'])
def api_sooin_cart():
"""장바구니 조회 API"""
try:
session = get_sooin_session()
result = session.get_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e), 'items': []}), 500
@sooin_bp.route('/cart/clear', methods=['POST'])
def api_sooin_cart_clear():
"""장바구니 비우기 API"""
try:
session = get_sooin_session()
result = session.clear_cart()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/cancel', methods=['POST'])
def api_sooin_cart_cancel():
"""
장바구니 항목 취소 API
POST /api/sooin/cart/cancel
{ "row_index": 0 }
또는
{ "internal_code": "32495" }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
if row_index is None and not internal_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'row_index 또는 internal_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.cancel_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/cart/restore', methods=['POST'])
def api_sooin_cart_restore():
"""
취소된 항목 복원 API
POST /api/sooin/cart/restore
{ "row_index": 0 }
"""
data = flask_request.get_json() or {}
row_index = data.get('row_index')
internal_code = data.get('internal_code')
try:
session = get_sooin_session()
result = session.restore_item(row_index=row_index, product_code=internal_code)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order', methods=['POST'])
def api_sooin_order():
"""
수인약품 주문 API (장바구니 추가)
POST /api/sooin/order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"check_stock": true
}
"""
data = flask_request.get_json()
if not data:
return jsonify({'success': False, 'error': 'NO_DATA'}), 400
kd_code = data.get('kd_code', '').strip()
quantity = data.get('quantity', 1)
specification = data.get('specification')
check_stock = data.get('check_stock', True)
if not kd_code:
return jsonify({
'success': False,
'error': 'MISSING_PARAM',
'message': 'kd_code가 필요합니다'
}), 400
try:
session = get_sooin_session()
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
return jsonify(result)
except Exception as e:
logger.error(f"수인약품 주문 오류: {e}")
return jsonify({
'success': False,
'error': 'ORDER_ERROR',
'message': str(e)
}), 500
@sooin_bp.route('/confirm', methods=['POST'])
def api_sooin_confirm():
"""주문 확정 API"""
data = flask_request.get_json() or {}
memo = data.get('memo', '')
try:
session = get_sooin_session()
result = session.submit_order(memo)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/full-order', methods=['POST'])
def api_sooin_full_order():
"""
전체 주문 API (검색 장바구니 확정)
POST /api/sooin/full-order
{
"kd_code": "073100220",
"quantity": 1,
"specification": "30T",
"auto_confirm": true,
"memo": "자동주문"
}
"""
data = flask_request.get_json()
if not data or not data.get('kd_code'):
return jsonify({'success': False, 'error': 'kd_code required'}), 400
try:
session = get_sooin_session()
# 장바구니에 담기
cart_result = session.quick_order(
kd_code=data['kd_code'],
quantity=data.get('quantity', 1),
spec=data.get('specification'),
check_stock=data.get('check_stock', True)
)
if not cart_result.get('success'):
return jsonify(cart_result)
if not data.get('auto_confirm', True):
return jsonify(cart_result)
# 주문 확정
confirm_result = session.submit_order(data.get('memo', ''))
if confirm_result.get('success'):
return jsonify({
'success': True,
'message': f"{cart_result['product']['name']} {cart_result['quantity']}개 주문 완료",
'product': cart_result['product'],
'quantity': cart_result['quantity'],
'confirmed': True
})
else:
return jsonify({
'success': False,
'error': confirm_result.get('error', 'CONFIRM_FAILED'),
'message': f"장바구니 담기 성공, 주문 확정 실패",
'product': cart_result['product'],
'cart_added': True
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@sooin_bp.route('/order-batch', methods=['POST'])
def api_sooin_order_batch():
"""수인약품 일괄 주문 API"""
data = flask_request.get_json()
if not data or not data.get('items'):
return jsonify({'success': False, 'error': 'NO_ITEMS'}), 400
items = data.get('items', [])
check_stock = data.get('check_stock', True)
session = get_sooin_session()
results = []
success_count = 0
failed_count = 0
for item in items:
kd_code = item.get('kd_code', '').strip()
quantity = item.get('quantity', 1)
specification = item.get('specification')
if not kd_code:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'MISSING_KD_CODE'
})
failed_count += 1
continue
try:
result = session.quick_order(
kd_code=kd_code,
quantity=quantity,
spec=specification,
check_stock=check_stock
)
result['kd_code'] = kd_code
result['requested_qty'] = quantity
results.append(result)
if result.get('success'):
success_count += 1
else:
failed_count += 1
except Exception as e:
results.append({
'kd_code': kd_code,
'success': False,
'error': 'EXCEPTION',
'message': str(e)
})
failed_count += 1
return jsonify({
'success': True,
'total': len(items),
'success_count': success_count,
'failed_count': failed_count,
'results': results
})

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,59 @@
align-items: center;
gap: 15px;
}
.auto-controls {
display: flex;
gap: 8px;
}
.status-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.status-badge .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-badge.disconnected {
background: #fef2f2;
color: #dc2626;
}
.status-badge.disconnected .status-dot {
background: #ef4444;
}
.status-badge.connected {
background: #ecfdf5;
color: #059669;
}
.status-badge.connected .status-dot {
background: #10b981;
}
.status-badge.auto-print-off {
background: #f3f4f6;
color: #6b7280;
}
.status-badge.auto-print-off .status-dot {
background: #9ca3af;
}
.status-badge.auto-print-on {
background: #dbeafe;
color: #2563eb;
}
.status-badge.auto-print-on .status-dot {
background: #3b82f6;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.date-picker {
padding: 8px 15px;
border: 2px solid #8b5cf6;
@ -64,7 +117,7 @@
/* 왼쪽: 환자 목록 */
.patient-list {
width: 380px;
width: clamp(250px, 22vw, 380px);
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
@ -72,6 +125,12 @@
flex-direction: column;
overflow: hidden;
}
/* 세로 모니터 최적화 */
@media (orientation: portrait) {
.patient-list {
width: clamp(220px, 18vw, 300px);
}
}
.patient-list-header {
background: #4c1d95;
color: #fff;
@ -362,11 +421,99 @@
}
.paai-feedback button:hover { border-color: #10b981; }
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
.paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; }
.paai-timing {
font-size: 0.8rem;
color: #9ca3af;
}
/* 피드백 코멘트 모달 */
.feedback-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1200;
justify-content: center;
align-items: center;
}
.feedback-modal.show { display: flex; }
.feedback-modal-content {
background: #fff;
border-radius: 16px;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
}
.feedback-modal-header {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #fff;
padding: 18px 24px;
border-radius: 16px 16px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.feedback-modal-header h3 { font-size: 1.1rem; margin: 0; }
.feedback-modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 1.3rem;
cursor: pointer;
}
.feedback-modal-body { padding: 24px; }
.feedback-categories {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.feedback-category {
padding: 6px 14px;
border: 2px solid #e5e7eb;
border-radius: 20px;
background: #fff;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.feedback-category:hover { border-color: #ef4444; }
.feedback-category.selected { background: #fee2e2; border-color: #ef4444; color: #dc2626; }
.feedback-textarea {
width: 100%;
min-height: 100px;
padding: 12px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 0.95rem;
resize: vertical;
font-family: inherit;
}
.feedback-textarea:focus { outline: none; border-color: #ef4444; }
.feedback-modal-footer {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.feedback-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
border: none;
}
.feedback-btn-cancel { background: #f3f4f6; color: #374151; }
.feedback-btn-submit { background: #ef4444; color: #fff; font-weight: 600; }
/* PAAI 토스트 알림 */
.paai-toast-container {
position: fixed;
@ -889,6 +1036,83 @@
tr.row-removed { background: #fef2f2 !important; opacity: 0.7; }
tr.row-changed { background: #fffbeb !important; }
/* 일반 대체조제 표시 */
.subst-badge {
display: inline-block;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
padding: 1px 5px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
margin-right: 4px;
vertical-align: middle;
}
/* 저가대체 인센티브 표시 */
.lowcost-badge {
display: inline-block;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
padding: 1px 5px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
margin-right: 4px;
vertical-align: middle;
}
/* 비급여 표시 */
.noncov-badge {
display: inline-block;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
padding: 1px 5px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
margin-right: 4px;
vertical-align: middle;
}
/* 본인부담률 표시 (30/50/80/90/100) */
.copay-badge {
display: inline-block;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: white;
padding: 1px 5px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
margin-right: 4px;
vertical-align: middle;
}
.original-rx {
margin-top: 4px;
font-size: 0.75rem;
}
.original-rx summary {
cursor: pointer;
color: #6b7280;
user-select: none;
}
.original-rx summary:hover {
color: #4b5563;
}
.original-drug-info {
margin-top: 4px;
padding: 6px 10px;
background: #fef3c7;
border-radius: 4px;
border-left: 3px solid #f59e0b;
}
.orig-name {
display: block;
font-weight: 500;
color: #92400e;
}
.orig-code {
font-size: 0.7rem;
color: #b45309;
}
.change-arrow {
color: #94a3b8;
margin: 0 4px;
@ -940,6 +1164,17 @@
<header class="header">
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
<div class="controls">
<!-- 자동감지/자동인쇄 상태 -->
<div class="auto-controls">
<div id="triggerIndicator" class="status-badge disconnected">
<span class="status-dot"></span>
자동감지 OFF
</div>
<div id="autoPrintToggle" class="status-badge auto-print-off">
<span class="status-dot"></span>
자동인쇄 OFF
</div>
</div>
<input type="date" id="dateSelect" class="date-picker">
<div class="stats-box">
<div class="stat-item">
@ -1028,6 +1263,29 @@
</div>
</div>
<!-- 피드백 코멘트 모달 -->
<div class="feedback-modal" id="feedbackModal">
<div class="feedback-modal-content">
<div class="feedback-modal-header">
<h3>📝 어떤 점이 문제였나요?</h3>
<button class="feedback-modal-close" onclick="closeFeedbackModal()">×</button>
</div>
<div class="feedback-modal-body">
<div class="feedback-categories">
<button class="feedback-category" data-cat="interaction">💊 약물 상호작용</button>
<button class="feedback-category" data-cat="indication">🎯 적응증/용도</button>
<button class="feedback-category" data-cat="dosage">📏 용법용량</button>
<button class="feedback-category" data-cat="other">📋 기타</button>
</div>
<textarea class="feedback-textarea" id="feedbackComment" placeholder="잘못된 점이나 개선할 내용을 적어주세요...&#10;예: NSAID와 아세트아미노펜은 병용 가능합니다."></textarea>
</div>
<div class="feedback-modal-footer">
<button class="feedback-btn feedback-btn-cancel" onclick="closeFeedbackModal()">취소</button>
<button class="feedback-btn feedback-btn-submit" onclick="submitFeedbackComment()">제출</button>
</div>
</div>
</div>
<!-- 특이사항 모달 -->
<div class="cusetc-modal" id="cusetcModal">
<div class="cusetc-modal-content">
@ -1298,12 +1556,23 @@
</thead>
<tbody>
${data.medications.map(m => `
<tr data-add-info="${escapeHtml(m.add_info || '')}">
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
<td>
<div class="med-name">${m.med_name || m.medication_code}</div>
<div class="med-name">
${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
</div>
<div class="med-code">${m.medication_code}</div>
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
${m.is_substituted && m.original_drug ? `
<details class="original-rx">
<summary>원처방 보기</summary>
<div class="original-drug-info">
<span class="orig-name">${escapeHtml(m.original_drug.drug_name)}</span>
<span class="orig-code">${m.original_drug.drug_code}</span>
</div>
</details>
` : ''}
</td>
<td>${m.formulation ? `<span class="med-form">${m.formulation}</span>` : '-'}</td>
<td><span class="med-dosage">${m.dosage || '-'}</span></td>
@ -1611,10 +1880,15 @@
if (data.success && data.count > 0) {
otcData = data;
// OTC 뱃지 추가 (질병 뱃지 앞에)
// OTC 뱃지 추가 ( 앞에)
const rxInfo = document.getElementById('rxInfo');
const otcBadge = `<span class="otc-badge" onclick="showOtcModal()">💊 OTC ${data.count}건</span>`;
rxInfo.innerHTML = otcBadge + rxInfo.innerHTML;
if (rxInfo && !rxInfo.querySelector('.otc-badge')) {
const otcBadge = document.createElement('span');
otcBadge.className = 'otc-badge';
otcBadge.onclick = showOtcModal;
otcBadge.innerHTML = `💊 OTC ${data.count}건`;
rxInfo.insertBefore(otcBadge, rxInfo.firstChild);
}
} else {
otcData = null;
}
@ -1893,6 +2167,7 @@
pre_serial: preSerial,
cus_code: currentPrescriptionData.cus_code,
patient_name: patientName,
patient_note: currentPrescriptionData.cusetc || '', // 환자 특이사항 (알러지, 기저질환 등)
disease_info: {
code_1: currentPrescriptionData.st1 || '',
name_1: currentPrescriptionData.st1_name || '',
@ -1946,6 +2221,9 @@
// 토스트 알림
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
// 자동인쇄 (활성화된 경우)
printPaaiResult(preSerial, patientName, result);
} else {
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
}
@ -2045,6 +2323,7 @@
}
currentPaaiLogId = cached.result.log_id;
currentPaaiResponse = JSON.stringify(cached.result.analysis || {});
displayPaaiResult(cached.result);
modal.classList.add('show');
}
@ -2169,28 +2448,93 @@
document.getElementById('paaiModal').classList.remove('show');
}
// 피드백 관련 변수
let currentPaaiResponse = '';
let currentFeedbackCategory = 'other';
async function sendPaaiFeedback(useful) {
if (!currentPaaiLogId) return;
// 버튼 즉시 반영
document.getElementById('paaiUseful').classList.toggle('selected', useful);
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
document.getElementById('paaiNotUseful').classList.toggle('selected-bad', !useful);
document.getElementById('paaiNotUseful').classList.remove('selected');
if (useful) {
// 👍 좋아요: 바로 저장하고 닫기
try {
await fetch('/api/paai/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
prescription_id: currentPrescriptionId,
patient_name: document.querySelector('.patient-name')?.textContent || '',
paai_response: currentPaaiResponse,
rating: 'good'
})
});
} catch (err) {
console.error('Feedback error:', err);
}
setTimeout(() => closePaaiModal(), 500);
} else {
// 👎 아니요: 코멘트 모달 열기
openFeedbackModal();
}
}
function openFeedbackModal() {
document.getElementById('feedbackModal').classList.add('show');
document.getElementById('feedbackComment').value = '';
document.getElementById('feedbackComment').focus();
// 카테고리 버튼 이벤트
document.querySelectorAll('.feedback-category').forEach(btn => {
btn.classList.remove('selected');
btn.onclick = () => {
document.querySelectorAll('.feedback-category').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
currentFeedbackCategory = btn.dataset.cat;
};
});
}
function closeFeedbackModal() {
document.getElementById('feedbackModal').classList.remove('show');
}
async function submitFeedbackComment() {
const comment = document.getElementById('feedbackComment').value.trim();
if (!comment) {
alert('코멘트를 입력해주세요.');
return;
}
try {
await fetch('/pmr/api/paai/feedback', {
const res = await fetch('/api/paai/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
log_id: currentPaaiLogId,
useful: useful
prescription_id: currentPrescriptionId,
patient_name: document.querySelector('.patient-name')?.textContent || '',
paai_response: currentPaaiResponse,
rating: 'bad',
category: currentFeedbackCategory,
pharmacist_comment: comment
})
});
const data = await res.json();
if (data.success) {
closeFeedbackModal();
closePaaiModal();
showPaaiToast('피드백이 저장되었습니다. 감사합니다! 🙏', 'success');
}
} catch (err) {
console.error('Feedback error:', err);
alert('피드백 저장 실패');
}
// 0.5초 후 모달 닫기
setTimeout(() => closePaaiModal(), 500);
}
// ─────────────────────────────────────────────────────────────
@ -2434,6 +2778,13 @@
true // clickable
);
playTriggerSound();
// 자동인쇄 (활성화된 경우)
printPaaiResult(data.pre_serial, data.patient_name, {
success: true,
analysis: data.analysis,
kims_summary: data.kims_summary
});
break;
case 'analysis_failed':
showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌');
@ -2561,39 +2912,121 @@
// 연결 상태 표시
function updateTriggerIndicator(isConnected) {
let indicator = document.getElementById('triggerIndicator');
if (!indicator) {
const controls = document.querySelector('.header .controls');
if (controls) {
indicator = document.createElement('div');
indicator.id = 'triggerIndicator';
indicator.style.cssText = `
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #f1f5f9;
border-radius: 8px;
font-size: 0.8rem;
color: #64748b;
`;
controls.insertBefore(indicator, controls.firstChild);
}
}
const indicator = document.getElementById('triggerIndicator');
if (indicator) {
indicator.className = `status-badge ${isConnected ? 'connected' : 'disconnected'}`;
indicator.innerHTML = `
<span style="
width: 8px;
height: 8px;
border-radius: 50%;
background: ${isConnected ? '#10b981' : '#ef4444'};
"></span>
${isConnected ? '자동감지 ON' : '자동감지 OFF'}
<span class="status-dot"></span>
자동감지 ${isConnected ? 'ON' : 'OFF'}
`;
}
}
// ═══════════════════════════════════════════════════════════════
// 자동인쇄 기능 (모두 window 전역)
// ═══════════════════════════════════════════════════════════════
window.autoPrintEnabled = localStorage.getItem('pmr_auto_print') === 'true';
window.printedSerials = new Set(); // 중복 인쇄 방지용
// 간단한 토스트 알림
window.showToast = function(message, type) {
var container = document.getElementById('paaiToastContainer');
if (!container) return;
var toast = document.createElement('div');
toast.className = 'paai-toast';
toast.style.background = type === 'success' ? 'linear-gradient(135deg, #10b981, #059669)' :
type === 'error' ? 'linear-gradient(135deg, #ef4444, #dc2626)' :
'linear-gradient(135deg, #6b7280, #4b5563)';
toast.innerHTML = '<div class="content"><div class="title">' + message + '</div></div>';
toast.onclick = function() { toast.remove(); };
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3000);
};
// 표시 업데이트
window.updateAutoPrintIndicator = function() {
var toggle = document.getElementById('autoPrintToggle');
if (toggle) {
toggle.className = 'status-badge ' + (window.autoPrintEnabled ? 'auto-print-on' : 'auto-print-off');
toggle.innerHTML = '<span class="status-dot"></span>자동인쇄 ' + (window.autoPrintEnabled ? 'ON' : 'OFF');
}
};
// PAAI 결과 인쇄 (중복 방지 포함)
window.printPaaiResult = async function(preSerial, patientName, result) {
// 1. 자동인쇄 비활성화 체크
if (!window.autoPrintEnabled) {
console.log('[AutoPrint] 비활성화됨, 스킵:', preSerial);
return;
}
// 2. 중복 인쇄 방지 (요청 전에 먼저 체크 & 추가!)
if (window.printedSerials.has(preSerial)) {
console.log('[AutoPrint] 이미 인쇄됨, 스킵:', preSerial);
return;
}
// 즉시 Set에 추가 (비동기 race condition 방지)
window.printedSerials.add(preSerial);
// 3. 인쇄 진행
try {
var now = new Date().toLocaleTimeString('ko-KR');
console.log('[AutoPrint] ' + now + ' 인쇄 요청:', preSerial, patientName);
var response = await fetch('/pmr/api/paai/print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
pre_serial: preSerial,
patient_name: patientName,
result: result
})
});
var data = await response.json();
if (data.success) {
console.log('[AutoPrint] ' + now + ' ✅ 인쇄 완료:', preSerial, patientName);
window.showToast('🖨️ ' + patientName, 'success');
} else {
// 실패 시 Set에서 제거 (재시도 가능하도록)
window.printedSerials.delete(preSerial);
console.error('[AutoPrint] ' + now + ' ❌ 실패:', preSerial, data.error);
window.showToast('인쇄 실패: ' + patientName, 'error');
}
// 5분 후 Set에서 제거 (메모리 관리)
setTimeout(function() {
window.printedSerials.delete(preSerial);
}, 5 * 60 * 1000);
} catch (err) {
// 오류 시 Set에서 제거
window.printedSerials.delete(preSerial);
console.error('[AutoPrint] 오류:', preSerial, err);
window.showToast('인쇄 오류', 'error');
}
};
// 즉시 실행: 토글 이벤트 바인딩
(function() {
var toggle = document.getElementById('autoPrintToggle');
if (toggle) {
toggle.style.cursor = 'pointer';
toggle.onclick = function() {
window.autoPrintEnabled = !window.autoPrintEnabled;
localStorage.setItem('pmr_auto_print', window.autoPrintEnabled ? 'true' : 'false');
window.updateAutoPrintIndicator();
window.showToast(window.autoPrintEnabled ? '자동인쇄 ON' : '자동인쇄 OFF', window.autoPrintEnabled ? 'success' : 'info');
console.log('[AutoPrint] 토글:', window.autoPrintEnabled);
};
// 초기 상태 표시
window.updateAutoPrintIndicator();
console.log('[AutoPrint] 초기화 완료, 상태:', window.autoPrintEnabled);
}
})();
// 알림 소리
function playTriggerSound() {
try {

16
backend/test_bagjs.py Normal file
View File

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

18
backend/test_bagjs2.py Normal file
View File

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

16
backend/test_bagjs3.py Normal file
View File

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

19
backend/test_bagjs4.py Normal file
View File

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

46
backend/test_cancel.py Normal file
View File

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

60
backend/test_cart.py Normal file
View File

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

114
backend/test_cart_api.py Normal file
View File

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

View File

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

127
backend/test_cart_list.py Normal file
View File

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

74
backend/test_datacart.py Normal file
View File

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

83
backend/test_datacart2.py Normal file
View File

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

105
backend/test_datacart3.py Normal file
View File

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

101
backend/test_dataorder.py Normal file
View File

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

32
backend/test_del.py Normal file
View File

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

26
backend/test_del2.py Normal file
View File

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

25
backend/test_del3.py Normal file
View File

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

29
backend/test_del_chk.py Normal file
View File

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

21
backend/test_del_html.py Normal file
View File

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

38
backend/test_del_one.py Normal file
View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""개별 삭제 테스트"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
# 1. 장바구니 비우기
session.clear_cart()
print('1. 장바구니 비움')
# 2. 두 개 담기
session.order_product('073100220', 1, '30T') # 코자정
print('2. 코자정 담음')
session.order_product('652100640', 1) # 스틸녹스
print('3. 스틸녹스 담음')
# 장바구니 확인
cart = session.get_cart()
count = cart.get('total_items', 0)
print(f' 현재 장바구니: {count}')
for item in cart.get('items', []):
print(f' - {item.get("product_name", "")}')
# 3. 첫 번째 항목만 삭제 (idx=0)
print('\n4. idx=0 (첫 번째) 삭제...')
resp = session.session.get(
'http://sooinpharm.co.kr/Service/Order/BagOrder.asp',
params={'kind': 'delOne', 'idx': '0', 'currVenCd': '50911'}
)
# 장바구니 다시 확인
cart = session.get_cart()
count = cart.get('total_items', 0)
print(f' 삭제 후: {count}')
for item in cart.get('items', []):
print(f' - {item.get("product_name", "")}')

33
backend/test_del_pc.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""pc 파라미터로 삭제 테스트"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
# 장바구니 확인
resp = session.session.get('http://sooinpharm.co.kr/Service/Order/Bag.asp?currVenCd=50911')
# hidden input들 확인
import re
hiddens = re.findall(r'name="(pc_\d+|idx_\d+|bagIdx_\d+)"[^>]*value="([^"]*)"', resp.text)
print('Hidden fields:')
for name, val in hiddens[:10]:
print(f' {name}: {val}')
# 장바구니 iframe의 실제 삭제 로직 찾기
# del + pc 조합 시도
print('\ndel with pc 시도...')
resp = session.session.get(
'http://sooinpharm.co.kr/Service/Order/BagOrder.asp',
params={
'kind': 'delOne',
'idx': '0',
'pc': '31840', # 스틸녹스 코드
'currVenCd': '50911'
}
)
# 결과
cart = session.get_cart()
print(f'삭제 후: {cart.get("total_items", 0)}')

26
backend/test_del_post.py Normal file
View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""개별 삭제 POST 테스트"""
from sooin_api import SooinSession
session = SooinSession()
session.login()
# 장바구니 확인
cart = session.get_cart()
print(f'현재: {cart.get("total_items", 0)}')
# POST로 삭제 시도
print('\nPOST로 delOne 시도...')
resp = session.session.post(
'http://sooinpharm.co.kr/Service/Order/BagOrder.asp',
data={
'kind': 'delOne',
'idx': '0',
'currVenCd': '50911'
}
)
print(f'응답: {resp.text[:300]}')
# 다시 확인
cart = session.get_cart()
print(f'\n삭제 후: {cart.get("total_items", 0)}')

39
backend/test_encoding.py Normal file
View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
import sys
import re
sys.path.insert(0, r'c:\Users\청춘약국\source\pharmacy-pos-qr-system\backend')
from sooin_api import SooinSession
session = SooinSession()
if session.login():
# 직접 요청해서 인코딩 확인
params = {
'so': '0', 'so2': '0', 'so3': '2',
'tx_physic': '073100220',
'tx_ven': '50911',
'currVenNm': '청춘약국'
}
resp = session.session.get(session.ORDER_URL, params=params, timeout=15)
print('Content-Type:', resp.headers.get('Content-Type'))
print('Encoding:', resp.encoding)
print('Apparent Encoding:', resp.apparent_encoding)
# charset 확인
charset_match = re.search(r'charset=([^\s;"]+)', resp.text[:1000])
print('HTML charset:', charset_match.group(1) if charset_match else 'Not found')
# 직접 디코딩 테스트
print('\n--- 디코딩 테스트 ---')
test_encodings = ['euc-kr', 'cp949', 'utf-8', 'iso-8859-1']
for enc in test_encodings:
try:
decoded = resp.content.decode(enc, errors='replace')
# 코자정이 포함되어 있는지 확인
if '코자정' in decoded:
print(f'{enc}: 성공! (코자정 발견)')
elif '' in decoded or '' in decoded:
print(f'{enc}: 부분 실패 (깨진 문자 발견)')
else:
print(f'{enc}: 확인 불가')
except Exception as e:
print(f'{enc}: 오류 - {e}')

25
backend/test_flask_api.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""Flask Blueprint 테스트"""
import wholesale_path
from geoyoung_api import geoyoung_bp, get_geo_session
from sooin_api import sooin_bp, get_sooin_session
print('=== Flask Blueprint 테스트 ===\n')
# Blueprint 확인
print(f'지오영 Blueprint: {geoyoung_bp.name} ({geoyoung_bp.url_prefix})')
print(f'수인약품 Blueprint: {sooin_bp.name} ({sooin_bp.url_prefix})')
# 세션 함수 확인
geo_session = get_geo_session()
sooin_session = get_sooin_session()
print(f'\n지오영 세션: {geo_session}')
print(f'수인약품 세션: {sooin_session}')
# 라우트 확인
print('\n지오영 라우트:')
for rule in geoyoung_bp.deferred_functions:
print(f' - {rule}')
print('\n✅ Blueprint 로드 성공!')

View File

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""지오영 API 직접 테스트"""
import asyncio
from playwright.async_api import async_playwright
import json
async def capture_cart_api():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 요청/응답 캡처
cart_requests = []
async def handle_request(request):
if 'Cart' in request.url or 'Order' in request.url or 'Add' in request.url:
cart_requests.append({
'url': request.url,
'method': request.method,
'headers': dict(request.headers),
'data': request.post_data
})
page.on('request', handle_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')
print("로그인 완료")
# 쿠키 저장
cookies = await page.context.cookies()
print(f"쿠키: {[c['name'] for c in cookies]}")
# 검색 페이지
await page.goto('https://gwn.geoweb.kr/Home/Index')
await page.wait_for_timeout(2000)
# 검색 (AJAX)
await page.evaluate('''
$.ajax({
url: "/Home/PartialSearchProduct",
type: "POST",
data: {srchText: "643104281"},
success: function(data) {
console.log("검색 결과:", data.substring(0, 500));
}
});
''')
await page.wait_for_timeout(2000)
# 장바구니 추가 시도 (JavaScript로)
result = await page.evaluate('''
async function testCart() {
// 장바구니 추가 함수 찾기
if (typeof AddCart !== 'undefined') {
return "AddCart 함수 존재";
}
if (typeof fnAddCart !== 'undefined') {
return "fnAddCart 함수 존재";
}
// 전역 함수 목록
var funcs = [];
for (var key in window) {
if (typeof window[key] === 'function' &&
(key.toLowerCase().includes('cart') ||
key.toLowerCase().includes('order') ||
key.toLowerCase().includes('add'))) {
funcs.push(key);
}
}
return "발견된 함수: " + funcs.join(", ");
}
return testCart();
''')
print(f"JavaScript 분석: {result}")
# 페이지 소스에서 장바구니 관련 스크립트 찾기
scripts = await page.evaluate('''
var scripts = document.querySelectorAll('script');
var result = [];
scripts.forEach(function(s) {
var text = s.textContent || s.innerText || '';
if (text.includes('Cart') || text.includes('AddProduct')) {
result.push(text.substring(0, 1000));
}
});
return result;
''')
await browser.close()
print("\n" + "="*60)
print("캡처된 Cart/Order 요청:")
print("="*60)
for r in cart_requests:
print(json.dumps(r, indent=2, ensure_ascii=False))
print("\n" + "="*60)
print("장바구니 관련 스크립트:")
print("="*60)
for i, s in enumerate(scripts[:3]):
print(f"\n--- Script {i+1} ---")
print(s[:800])
if __name__ == "__main__":
asyncio.run(capture_cart_api())

49
backend/test_sooin.py Normal file
View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""수인약품 API 테스트"""
import time
import sys
# 현재 디렉토리 추가
sys.path.insert(0, '.')
from sooin_api import SooinSession
print('수인약품 API 테스트')
print('='*50)
session = SooinSession()
# 1. 로그인 테스트
start = time.time()
print('1. 로그인 중...')
if session.login():
print(f' ✅ 로그인 성공! ({time.time()-start:.1f}초)')
else:
print(' ❌ 로그인 실패')
sys.exit(1)
# 2. 검색 테스트 (KD코드: 코자정)
start = time.time()
print('\n2. 검색 테스트 (KD코드: 073100220 - 코자정)...')
products = session.search_products('073100220', 'kd_code')
elapsed = time.time() - start
print(f' 검색 완료: {len(products)}개 ({elapsed:.2f}초)')
for p in products[:3]:
name = p.get('product_name', '')
spec = p.get('specification', '')
stock = p.get('stock', 0)
price = p.get('unit_price', 0)
code = p.get('internal_code', '')
print(f' - {name} ({spec})')
print(f' 재고: {stock}, 단가: {price:,}원, 내부코드: {code}')
# 3. 장바구니 조회
start = time.time()
print('\n3. 장바구니 조회...')
cart = session.get_cart()
elapsed = time.time() - start
print(f' 장바구니: {cart.get("total_items", 0)}개 품목 ({elapsed:.2f}초)')
print('\n' + '='*50)
print('✅ 테스트 완료!')

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""수인약품 API 전체 플로우 테스트"""
import time
from sooin_api import SooinSession
session = SooinSession()
print('=== 수인약품 API 전체 테스트 ===')
print()
# 로그인
start = time.time()
session.login()
print(f'1. 로그인: {time.time()-start:.1f}')
# 장바구니 비우기
start = time.time()
session.clear_cart()
print(f'2. 장바구니 비우기: {time.time()-start:.2f}')
# 검색 + 장바구니 추가
start = time.time()
result = session.order_product('073100220', 2, '30T')
elapsed = time.time() - start
success = result.get('success', False)
msg = result.get('message', '')
print(f'3. 검색+장바구니: {elapsed:.2f}')
print(f' 결과: {success} - {msg}')
# 장바구니 조회
start = time.time()
cart = session.get_cart()
elapsed = time.time() - start
items = cart.get('total_items', 0)
amount = cart.get('total_amount', 0)
print(f'4. 장바구니 조회: {elapsed:.2f}')
print(f' 품목: {items}개, 금액: {amount:,}')
print()
print('=== 완료! ===')

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""wholesale 통합 테스트"""
import wholesale_path
from wholesale import SooinSession, GeoYoungSession
print('=== 도매상 API 통합 테스트 ===\n')
# 수인약품 테스트
print('1. 수인약품 테스트')
sooin = SooinSession()
if sooin.login():
print(' ✅ 로그인 성공')
result = sooin.search_products('073100220')
print(f' ✅ 검색: {result["total"]}개 결과')
cart = sooin.get_cart()
print(f' ✅ 장바구니: {cart["total_items"]}')
else:
print(' ❌ 로그인 실패')
# 지오영 테스트
print('\n2. 지오영 테스트')
geo = GeoYoungSession()
if geo.login():
print(' ✅ 로그인 성공')
result = geo.search_products('레바미피드')
print(f' ✅ 검색: {result["total"]}개 결과')
cart = geo.get_cart()
print(f' ✅ 장바구니: {cart["total_items"]}')
else:
print(' ❌ 로그인 실패')
print('\n=== 테스트 완료 ===')

13
backend/wholesale_path.py Normal file
View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""wholesale 패키지 경로 설정"""
import sys
import os
# wholesale 패키지 경로 추가
WHOLESALE_PATH = r"c:\Users\청춘약국\source\pharmacy-wholesale-api"
if WHOLESALE_PATH not in sys.path:
sys.path.insert(0, WHOLESALE_PATH)
# dotenv 로드
from dotenv import load_dotenv
load_dotenv(os.path.join(WHOLESALE_PATH, '.env'))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,875 @@
# AI ERP 자동 주문 시스템 기획서
> 버전: 1.0
> 작성일: 2026-03-06
> 목표: 약국 재고 관리 및 주문을 AI가 학습하여 완전 자동화
---
## 📋 Executive Summary
### 비전
**"약사님이 주문에 신경 쓰지 않아도 되는 약국"**
AI가 사용량, 재고, 도매상 상황, 과거 주문 패턴을 학습하여:
- 언제 주문할지
- 어느 도매상에 주문할지
- 어떤 규격으로 주문할지
- 얼마나 주문할지
모든 것을 자동으로 결정하고 실행합니다.
### 핵심 가치
| AS-IS | TO-BE |
|-------|-------|
| 매일 재고 확인 | AI가 자동 모니터링 |
| 수동으로 도매상 선택 | AI가 최적 도매상 선택 |
| 경험에 의존한 주문량 | 데이터 기반 최적 주문량 |
| 주문 누락/지연 발생 | 선제적 자동 주문 |
---
## 🎯 시스템 목표
### 1차 목표 (자동화)
- [ ] 재고 부족 품목 자동 감지
- [ ] 도매상 자동 선택 및 주문
- [ ] 주문 결과 자동 피드백
### 2차 목표 (최적화)
- [ ] 비용 최소화 (가격, 배송비)
- [ ] 재고 최적화 (과잉/부족 방지)
- [ ] 주문 타이밍 최적화
### 3차 목표 (예측)
- [ ] 수요 예측 (계절, 요일, 이벤트)
- [ ] 공급 리스크 예측 (품절, 단종)
- [ ] 가격 변동 예측
---
## 🧠 AI 학습 요소
### 1. 주문 패턴 학습
#### 1.1 규격 선택 패턴 (Spec Selection)
```
학습 데이터:
- 약품별 과거 주문 규격 (30T, 100T, 300T, 500T)
- 각 규격 선택 시점의 재고/사용량
- 선택 결과 (남은 재고, 다음 주문까지 기간)
학습 목표:
- 사용량 대비 최적 규격 예측
- 낭비 최소화 (유통기한 고려)
- 단가 최적화 (대용량 할인 vs 소량 회전)
```
**예시 시나리오:**
| 사용량/월 | 학습된 최적 규격 | 이유 |
|-----------|-----------------|------|
| 50개 | 30T x 2 | 소량, 빠른 회전 |
| 200개 | 100T x 2 | 중간, 적정 재고 |
| 800개 | 300T x 3 | 대량, 단가 절감 |
#### 1.2 재고 전략 학습 (Inventory Strategy)
```
학습 데이터:
- 주문 시점의 재고 수준
- 재고 소진까지 남은 일수
- 주문 후 입고까지 리드타임
- 품절 발생 이력
학습 목표:
- 약사님의 재고 선호도 파악
- 타이트형: 최소 재고 유지 (현금 흐름 중시)
- 여유형: 안전 재고 확보 (품절 방지 중시)
```
**재고 전략 프로파일:**
```python
class InventoryStrategy:
TIGHT = {
'safety_days': 2, # 안전 재고 2일치
'reorder_point': 0.8, # 80% 소진 시 주문
'order_coverage': 7 # 7일치 주문
}
MODERATE = {
'safety_days': 5,
'reorder_point': 0.6,
'order_coverage': 14
}
CONSERVATIVE = {
'safety_days': 10,
'reorder_point': 0.5,
'order_coverage': 30
}
```
#### 1.3 주문량 전략 학습 (Order Quantity)
```
학습 데이터:
- 사용량 (일별, 주별, 월별)
- 주문량
- 주문 후 소진까지 기간
- 사용량 변동성 (표준편차)
학습 패턴:
1. 정확 매칭형: 사용량 = 주문량
2. 안전 마진형: 사용량 + α
3. 라운드업형: 규격 단위로 올림
4. 할인 최적형: MOQ(최소주문량) 충족
```
#### 1.4 도매상 선택 학습 (Wholesaler Selection)
```
학습 데이터:
- 도매상별 주문 빈도
- 도매상별 가격
- 도매상별 재고 상황
- 도매상별 배송 속도
- 분할 주문 패턴
학습 목표:
- 기본 도매상 선호도
- 상황별 대체 도매상
- 분할 주문 조건
```
**도매상 선택 로직:**
```python
def select_wholesaler(product, quantity, urgency):
"""
AI가 학습한 도매상 선택 로직
고려 요소:
1. 재고 (있는 곳 우선)
2. 가격 (저렴한 곳)
3. 선호도 (과거 패턴)
4. 긴급도 (배송 속도)
"""
candidates = []
for ws in wholesalers:
score = 0
# 재고 체크
if ws.has_stock(product, quantity):
score += 100
# 가격 (낮을수록 높은 점수)
score += (1 - ws.price_ratio) * 50
# 학습된 선호도
score += ai_model.preference_score(ws, product) * 30
# 긴급도 반영
if urgency == 'high':
score += ws.delivery_speed * 20
candidates.append((ws, score))
return max(candidates, key=lambda x: x[1])
```
---
## 📊 데이터 모델
### 주문 컨텍스트 (AI 학습용)
```sql
CREATE TABLE order_context (
id INTEGER PRIMARY KEY,
order_item_id INTEGER,
-- 약품 정보
drug_code TEXT,
product_name TEXT,
-- 주문 시점 상황
stock_at_order INTEGER, -- 주문 시점 재고
usage_1d INTEGER, -- 최근 1일 사용량
usage_7d INTEGER, -- 최근 7일 사용량
usage_30d INTEGER, -- 최근 30일 사용량
avg_daily_usage REAL, -- 일평균 사용량
usage_stddev REAL, -- 사용량 변동성
-- 주문 결정
ordered_spec TEXT, -- 선택한 규격 (30T, 300T)
ordered_qty INTEGER, -- 주문 수량
ordered_dose INTEGER, -- 총 정제수
wholesaler_id TEXT, -- 선택한 도매상
-- 선택지 정보
available_specs JSON, -- 가능했던 규격들
available_wholesalers JSON, -- 가능했던 도매상들
spec_stocks JSON, -- 규격별 재고
wholesaler_prices JSON, -- 도매상별 가격
-- 선택 이유 (AI 분석용)
selection_reason TEXT, -- 'price', 'stock', 'preference', 'urgency'
-- 예측 vs 실제
predicted_days_coverage REAL, -- 예상 커버 일수
actual_days_to_reorder INT, -- 실제 재주문까지 일수
-- 결과 평가
was_optimal BOOLEAN, -- 최적 선택이었나
waste_amount INTEGER, -- 낭비량 (폐기, 유통기한)
stockout_occurred BOOLEAN, -- 품절 발생했나
created_at TIMESTAMP
);
```
### 사용량 시계열
```sql
CREATE TABLE daily_usage (
id INTEGER PRIMARY KEY,
drug_code TEXT,
usage_date DATE,
-- 출처별 사용량
rx_qty INTEGER, -- 처방전 사용량
pos_qty INTEGER, -- POS 판매량
return_qty INTEGER, -- 반품량
-- 집계
net_usage INTEGER, -- 순 사용량
-- 재고 스냅샷
stock_start INTEGER,
stock_end INTEGER,
-- 특이사항
is_holiday BOOLEAN,
is_event BOOLEAN, -- 프로모션 등
weather TEXT, -- 날씨 (선택)
UNIQUE(drug_code, usage_date)
);
```
### AI 분석 결과
```sql
CREATE TABLE ai_recommendations (
id INTEGER PRIMARY KEY,
drug_code TEXT,
analysis_date DATE,
-- 현재 상황
current_stock INTEGER,
avg_daily_usage REAL,
days_of_stock REAL,
-- AI 추천
should_order BOOLEAN,
recommended_qty INTEGER,
recommended_spec TEXT,
recommended_wholesaler TEXT,
urgency_level TEXT, -- 'low', 'medium', 'high', 'critical'
-- 추천 근거
reasoning JSON,
confidence_score REAL,
-- 실행 상태
auto_executed BOOLEAN,
executed_at TIMESTAMP,
execution_result TEXT,
created_at TIMESTAMP
);
```
---
## 🔄 시스템 아키텍처
### 전체 흐름
```
┌─────────────────────────────────────────────────────────────────┐
│ AI ERP 자동 주문 시스템 │
└─────────────────────────────────────────────────────────────────┘
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 데이터 수집 │ │ AI 분석 │ │ 자동 실행 │
│ │ │ │ │ │
│ • POS 판매 │─────▶│ • 사용량 예측 │─────▶│ • 도매상 API │
│ • 처방전 조제 │ │ • 재고 분석 │ │ • 주문 실행 │
│ • 현재 재고 │ │ • 주문 추천 │ │ • 결과 피드백 │
│ • 도매상 재고 │ │ • 패턴 학습 │ │ │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌───────────────────┐
│ 학습 루프 │
│ │
│ 주문 결과 평가 │
│ → 모델 업데이트 │
│ → 전략 조정 │
└───────────────────┘
```
### 컴포넌트 상세
```
┌──────────────────────────────────────────────────────────────────┐
│ 데이터 레이어 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ PIT3000 │ │ SQLite │ │ 지오영 │ │ 수인 │ │
│ │ (MSSQL) │ │ Orders DB │ │ API │ │ API │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ └───────────────┴───────────────┴───────────────┘ │
│ │ │
└────────────────────────────────┼─────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 서비스 레이어 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ InventorySync │ │ UsageAnalyzer │ │ OrderExecutor │ │
│ │ │ │ │ │ │ │
│ │ • 재고 동기화 │ │ • 사용량 집계 │ │ • 주문 실행 │ │
│ │ • 실시간 추적 │ │ • 트렌드 분석 │ │ • 결과 처리 │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AIPredictor │ │ AIOptimizer │ │ AILearner │ │
│ │ │ │ │ │ │ │
│ │ • 수요 예측 │ │ • 규격 최적화 │ │ • 패턴 학습 │ │
│ │ • 재고 예측 │ │ • 도매상 선택 │ │ • 모델 업데이트 │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 인터페이스 레이어 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 웹 대시보드 │ │ 알림 시스템 │ │ 관리자 앱 │ │
│ │ │ │ │ │ │ │
│ │ • 재고 현황 │ │ • 주문 알림 │ │ • 수동 개입 │ │
│ │ • 주문 이력 │ │ • 이상 감지 │ │ • 설정 조정 │ │
│ │ • AI 추천 │ │ • 승인 요청 │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 🤖 AI 모델 설계
### 1. 수요 예측 모델
```python
class DemandPredictor:
"""
약품별 일간 수요 예측
입력:
- 과거 30일 사용량
- 요일 (월~일)
- 계절/월
- 특수일 (공휴일, 이벤트)
출력:
- 향후 7일 예측 사용량
- 예측 신뢰구간
"""
def predict(self, drug_code: str, days: int = 7) -> dict:
features = self._extract_features(drug_code)
prediction = {
'daily_forecast': [], # 일별 예측
'total_forecast': 0, # 총 예측량
'confidence': 0.0, # 신뢰도
'lower_bound': 0, # 하한
'upper_bound': 0 # 상한
}
return prediction
```
### 2. 재고 최적화 모델
```python
class InventoryOptimizer:
"""
최적 재고 수준 및 재주문점 계산
입력:
- 예측 수요
- 리드타임 (주문~입고)
- 서비스 수준 (품절 허용률)
- 재고 유지 비용
출력:
- 재주문점 (Reorder Point)
- 안전 재고 (Safety Stock)
- 최적 주문량 (EOQ)
"""
def calculate_reorder_point(self, drug_code: str) -> dict:
demand = self.demand_predictor.predict(drug_code)
lead_time = self._get_lead_time(drug_code)
# 재주문점 = 리드타임 수요 + 안전재고
lead_time_demand = demand['daily_avg'] * lead_time
safety_stock = self._calculate_safety_stock(drug_code)
return {
'reorder_point': lead_time_demand + safety_stock,
'safety_stock': safety_stock,
'lead_time_days': lead_time
}
```
### 3. 규격 선택 모델
```python
class SpecSelector:
"""
최적 규격 선택
고려 요소:
- 예상 사용량
- 규격별 단가
- 유통기한
- 과거 선택 패턴
"""
def select_spec(self, drug_code: str, needed_qty: int,
available_specs: list) -> dict:
candidates = []
for spec in available_specs:
spec_qty = self._parse_spec_qty(spec) # "300T" → 300
# 필요 단위 수 계산
units_needed = math.ceil(needed_qty / spec_qty)
total_qty = units_needed * spec_qty
waste = total_qty - needed_qty
# 비용 계산
unit_price = self._get_unit_price(drug_code, spec)
total_cost = units_needed * unit_price
cost_per_dose = total_cost / total_qty
# 학습된 선호도
preference = self.ai_model.spec_preference(drug_code, spec)
# 점수 계산
score = self._calculate_score(
waste_ratio=waste / total_qty,
cost_efficiency=1 / cost_per_dose,
preference=preference
)
candidates.append({
'spec': spec,
'units': units_needed,
'total_qty': total_qty,
'waste': waste,
'cost': total_cost,
'score': score
})
return max(candidates, key=lambda x: x['score'])
```
### 4. 도매상 선택 모델
```python
class WholesalerSelector:
"""
최적 도매상 선택 (다중 도매상 지원)
고려 요소:
- 재고 유무
- 가격
- 배송 속도
- 과거 선호도
- 최소 주문 금액
"""
def select_wholesaler(self, drug_code: str, spec: str,
quantity: int, urgency: str) -> dict:
wholesalers = ['geoyoung', 'sooin', 'baekje']
candidates = []
for ws in wholesalers:
# 재고 확인
stock = self._check_stock(ws, drug_code, spec)
if stock < quantity:
continue
# 가격 조회
price = self._get_price(ws, drug_code, spec)
# 배송 속도
delivery_hours = self._get_delivery_time(ws)
# AI 학습 선호도
preference = self.ai_model.wholesaler_preference(
drug_code, ws
)
# 종합 점수
score = self._calculate_score(
has_stock=True,
price=price,
delivery=delivery_hours,
preference=preference,
urgency=urgency
)
candidates.append({
'wholesaler': ws,
'stock': stock,
'price': price,
'delivery_hours': delivery_hours,
'score': score
})
if not candidates:
return self._handle_no_stock(drug_code, spec, quantity)
return max(candidates, key=lambda x: x['score'])
def _handle_no_stock(self, drug_code, spec, quantity):
"""재고 없을 때: 분할 주문 또는 대체품"""
# 1. 다른 규격으로 분할
# 2. 다중 도매상 분할
# 3. 대체 약품 추천
pass
```
### 5. 주문 결정 엔진
```python
class OrderDecisionEngine:
"""
종합 주문 결정
매일 실행:
1. 모든 약품 재고 스캔
2. 재주문점 도달 품목 식별
3. 각 품목별 최적 주문 계획 수립
4. 자동 실행 또는 승인 요청
"""
def daily_analysis(self) -> list:
recommendations = []
for drug in self._get_all_drugs():
current_stock = self._get_stock(drug.code)
reorder_point = self.inventory_optimizer.calculate_reorder_point(drug.code)
if current_stock <= reorder_point['reorder_point']:
# 주문 필요
order_plan = self._create_order_plan(drug)
recommendations.append(order_plan)
return recommendations
def _create_order_plan(self, drug) -> dict:
# 1. 필요 수량 계산
needed_qty = self._calculate_needed_qty(drug)
# 2. 최적 규격 선택
spec = self.spec_selector.select_spec(
drug.code, needed_qty, drug.available_specs
)
# 3. 최적 도매상 선택
wholesaler = self.wholesaler_selector.select_wholesaler(
drug.code, spec['spec'], spec['units'],
urgency=self._determine_urgency(drug)
)
return {
'drug_code': drug.code,
'drug_name': drug.name,
'current_stock': self._get_stock(drug.code),
'needed_qty': needed_qty,
'recommended_spec': spec['spec'],
'recommended_units': spec['units'],
'recommended_wholesaler': wholesaler['wholesaler'],
'estimated_cost': wholesaler['price'] * spec['units'],
'urgency': self._determine_urgency(drug),
'confidence': self._calculate_confidence(),
'auto_execute': self._should_auto_execute(drug)
}
```
---
## 📈 학습 파이프라인
### 피드백 루프
```
주문 실행 → 결과 기록 → 평가 → 학습 → 모델 업데이트
│ │
└────────────────────────────────────────┘
```
### 평가 지표
```python
class OrderEvaluator:
"""주문 결과 평가"""
def evaluate(self, order_id: int) -> dict:
order = self._get_order(order_id)
# 1. 재고 효율성
days_covered = self._calculate_days_covered(order)
expected_days = order.expected_coverage
coverage_accuracy = days_covered / expected_days
# 2. 비용 효율성
actual_cost_per_dose = order.total_cost / order.total_dose
market_avg_cost = self._get_market_avg_cost(order.drug_code)
cost_efficiency = market_avg_cost / actual_cost_per_dose
# 3. 낭비율
waste = self._calculate_waste(order)
waste_ratio = waste / order.total_dose
# 4. 품절 발생 여부
stockout = self._check_stockout_before_next_order(order)
return {
'coverage_accuracy': coverage_accuracy,
'cost_efficiency': cost_efficiency,
'waste_ratio': waste_ratio,
'stockout_occurred': stockout,
'overall_score': self._calculate_overall_score(...)
}
```
### 모델 업데이트
```python
class AILearner:
"""주문 결과로부터 학습"""
def learn_from_order(self, order_id: int):
evaluation = self.evaluator.evaluate(order_id)
context = self._get_order_context(order_id)
# 1. 규격 선택 학습
self.spec_model.update(
drug_code=context.drug_code,
chosen_spec=context.ordered_spec,
was_optimal=evaluation['waste_ratio'] < 0.1
)
# 2. 재고 전략 학습
self.inventory_model.update(
drug_code=context.drug_code,
reorder_point=context.stock_at_order,
was_optimal=not evaluation['stockout_occurred']
)
# 3. 도매상 선호도 학습
self.wholesaler_model.update(
drug_code=context.drug_code,
chosen_wholesaler=context.wholesaler_id,
satisfaction=evaluation['cost_efficiency']
)
```
---
## ⚙️ 자동화 레벨
### Level 0: 수동
- AI 추천만 제공
- 모든 주문은 수동 실행
### Level 1: 반자동
- AI가 주문 계획 생성
- 약사님 승인 후 자동 실행
- 알림: 승인 요청
### Level 2: 조건부 자동
- 신뢰도 높은 주문은 자동 실행
- 신뢰도 낮은 주문만 승인 요청
- 조건 예시:
- 자주 주문하는 품목
- 금액 임계값 이하
- 긴급하지 않은 주문
### Level 3: 완전 자동
- 모든 주문 자동 실행
- 이상 상황만 알림
- 약사님은 대시보드로 모니터링
```python
class AutomationLevel:
def should_auto_execute(self, order_plan: dict) -> bool:
level = self.settings.automation_level
if level == 0:
return False
if level == 1:
return False # 항상 승인 필요
if level == 2:
# 조건부 자동
conditions = [
order_plan['confidence'] > 0.9,
order_plan['estimated_cost'] < 100000,
order_plan['drug_code'] in self.trusted_drugs,
order_plan['urgency'] != 'critical'
]
return all(conditions)
if level == 3:
# 완전 자동 (이상 상황만 제외)
return not self._is_anomaly(order_plan)
```
---
## 🔔 알림 시스템
### 알림 유형
| 유형 | 조건 | 채널 |
|------|------|------|
| 승인 요청 | Level 1-2에서 자동 실행 안 되는 주문 | 카톡, 앱 푸시 |
| 주문 완료 | 자동 주문 실행됨 | 앱 푸시 |
| 재고 경고 | 안전 재고 이하 | 카톡 |
| 품절 긴급 | 재고 0, 당일 필요 | 전화, 카톡 |
| 이상 감지 | 비정상 사용량, 가격 급등 | 앱 푸시 |
| 일간 리포트 | 매일 오전 | 이메일 |
### 알림 메시지 예시
```
📦 주문 승인 요청
약품: 콩코르정 2.5mg
현재고: 45개 (3일치)
추천 주문: 300T x 2박스
도매상: 지오영
예상 금액: 72,000원
[승인] [수정] [거절]
```
---
## 📅 개발 로드맵
### Phase 1: 기반 구축 (1-2주)
- [x] 지오영 API 연동
- [x] 주문 DB 스키마 설계
- [x] 주문 컨텍스트 로깅
- [ ] 수인 API 연동
- [ ] 일별 사용량 집계 자동화
### Phase 2: AI 기본 (2-3주)
- [ ] 수요 예측 모델 (단순 이동평균)
- [ ] 재주문점 계산
- [ ] 규격 선택 로직 (규칙 기반)
- [ ] 도매상 선택 로직 (규칙 기반)
- [ ] 주문 추천 대시보드
### Phase 3: 학습 시스템 (2-3주)
- [ ] 피드백 루프 구현
- [ ] 주문 평가 시스템
- [ ] 패턴 학습 (규격, 도매상)
- [ ] 재고 전략 프로파일링
### Phase 4: 자동화 (1-2주)
- [ ] Level 1 (승인 후 자동)
- [ ] 알림 시스템 연동
- [ ] Level 2 (조건부 자동)
- [ ] 모니터링 대시보드
### Phase 5: 고도화 (지속)
- [ ] ML 모델 적용 (XGBoost, LSTM)
- [ ] Level 3 (완전 자동)
- [ ] 다중 약국 지원
- [ ] 수요 예측 정교화
---
## 📊 성공 지표 (KPI)
| 지표 | 현재 | 목표 |
|------|------|------|
| 주문 소요 시간 | 30분/일 | 0분 (자동) |
| 품절 발생률 | 5% | <1% |
| 재고 회전율 | - | +20% |
| 주문 비용 절감 | - | 5-10% |
| 폐기 손실 | - | -30% |
---
## 🔐 보안 및 안전장치
### 자동 주문 제한
- 일일 자동 주문 금액 상한
- 단일 품목 최대 수량
- 신규 품목 자동 주문 제외
- 가격 급등 시 수동 전환
### 롤백 메커니즘
- 모든 주문 취소 가능 (확정 전)
- 자동화 레벨 즉시 변경
- 긴급 수동 모드 전환
### 감사 로그
- 모든 AI 결정 기록
- 자동 실행 이력
- 승인/거절 이력
---
## 💡 핵심 인사이트
> "AI는 약사님의 주문 습관을 학습합니다."
- 약사님이 항상 지오영에 먼저 주문하면 → AI도 지오영 우선
- 약사님이 300T보다 30T를 선호하면 → AI도 소량 주문
- 약사님이 여유 있게 주문하면 → AI도 안전 재고 확보
- 약사님이 가격에 민감하면 → AI도 최저가 추적
**AI는 대체하는 것이 아니라, 약사님의 방식을 자동화합니다.**
---
## 📚 참고 자료
- 지오영 API 문서: `docs/GEOYOUNG_API_REVERSE_ENGINEERING.md`
- 주문 DB 스키마: `backend/order_db.py`
- 사용량 조회 페이지: `docs/RX_USAGE_GEOYOUNG_GUIDE.md`

View File

@ -119,6 +119,53 @@ LIMIT 10;
| `PHONE` | 전화번호 |
| `PANUM` | 주민번호 |
**PM_PRES.dbo.PS_sub_pharm** - 조제 약품 상세 ⭐
| 컬럼 | 설명 |
|------|------|
| `PreSerial` | 처방번호 (FK) |
| `SUB_SERIAL` | 약품 순번 |
| `DrugCode` | 제품 코드 |
| `Days` | 복용일수 |
| `QUAN` | 1회 복용량 |
| `QUAN_TIME` | 1일 복용횟수 |
| `INV_QUAN` | 총 투약량 |
| `PS_Type` | **조제 유형** (아래 참고) |
#### PS_Type 값 (대체조제 구분) ⭐
| PS_Type | 의미 | 표시 |
|---------|------|------|
| **0** | 일반 처방 | ✅ 표시 |
| **1** | 일반 대체조제 | ✅ 표시 + `대)` 배지 (주황색) |
| **4** | 저가대체 인센티브 - **실제 조제약** | ✅ 표시 + `저)` 배지 (초록색) |
| **9** | 저가대체 인센티브 - **원본 처방약** | ❌ 숨김 (약가 계산용)
**대체조제 데이터 패턴:**
```
SUB_SERIAL 순서로 4(실제) → 9(원본) 쌍으로 저장됨
예시 (김현지 처방):
PS_Type=4 | 사이톱신정 ← 실제 조제 (표시)
PS_Type=9 | 씨프러스정 ← 원본 처방 (숨김, 사이톱신의 원처방)
PS_Type=4 | 티로파정 ← 실제 조제 (표시)
PS_Type=9 | 티램정 ← 원본 처방 (숨김, 티로파의 원처방)
```
**쿼리 예시:**
```sql
-- 실제 조제약만 조회 (대체조제 원본 제외)
SELECT * FROM PS_sub_pharm WHERE PreSerial = '처방번호' AND PS_Type != '9'
-- 대체조제 쌍 확인
SELECT
s1.DrugCode AS 실제조제,
s2.DrugCode AS 원본처방
FROM PS_sub_pharm s1
JOIN PS_sub_pharm s2 ON s1.PreSerial = s2.PreSerial
AND s1.SUB_SERIAL + 1 = s2.SUB_SERIAL
WHERE s1.PS_Type = '4' AND s2.PS_Type = '9'
```
```sql
-- 예시: 오늘 판매 내역 + 제품명 조회
SELECT
@ -420,6 +467,128 @@ OPENAI_MODEL=gpt-4o-mini
---
---
## 🤖 PAAI 시스템 (처방 AI 분석)
### 개요
**PAAI (Prescription AI Analysis)**는 처방 접수 시 자동으로 AI 분석을 수행하고,
분석 결과를 영수증 프린터로 출력하는 시스템입니다.
### 아키텍처
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PAAI 시스템 흐름 │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ POS 접수 │────►│ PM_PRES_LOG │────►│ Trigger Module │
│ (처방입력) │ │ (MSSQL) │ │ (폴링 감지) │
└─────────────┘ └─────────────────┘ └─────────────────┘
┌───────────────────────────────┤
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ WebSocket 알림 │ │ PAAI 분석 요청 │
│ (ws://8765) │ │ Flask API │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 프론트엔드 │ │ Claude API │
│ pmr.html │ │ (분석 수행) │
└─────────────────┘ └─────────────────┘
│ │
│◄──────────────────────────────┤
│ analysis_completed 이벤트
┌─────────────────┐
│ 자동 인쇄 │
│ ESC/POS 프린터 │
└─────────────────┘
```
### 구성 요소
| 모듈 | 위치 | 역할 |
|------|------|------|
| **Trigger Module** | `prescription-trigger/prescription_trigger.py` | PM_PRES_LOG 폴링, 처방 감지, 분석 요청 |
| **WebSocket Server** | Trigger 내장 (port 8765) | 프론트엔드에 실시간 이벤트 전송 |
| **PAAI API** | `backend/pmr_api.py` | 분석 요청 처리, Claude API 호출, 결과 저장 |
| **프론트엔드** | `backend/templates/pmr.html` | 조제관리 UI, 자동인쇄 토글 |
| **프린터 모듈** | `backend/paai_printer.py` | ESC/POS 영수증 프린터 출력 |
### WebSocket 이벤트
| 이벤트 | 방향 | 설명 |
|--------|------|------|
| `prescription_detected` | Server → Client | 새 처방 감지됨 |
| `analysis_started` | Server → Client | AI 분석 시작 |
| `analysis_completed` | Server → Client | 분석 완료 (결과 포함) |
| `analysis_failed` | Server → Client | 분석 실패 (에러 포함) |
### 자동 인쇄 흐름
```javascript
// 1. WebSocket으로 analysis_completed 수신
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.event === 'analysis_completed') {
// 2. 자동인쇄 ON 상태면 인쇄
if (window.autoPrintEnabled) {
printPaaiResult(data.pre_serial, data.patient_name, data);
}
}
};
// 3. 인쇄 API 호출
POST /pmr/api/paai/print
{
"pre_serial": "20260305000099",
"patient_name": "홍길동",
"result": { "analysis": {...}, "kims_summary": {...} }
}
// 4. ESC/POS 프린터로 출력
```
### 중복 방지
```javascript
// window.printedSerials (Set) 으로 중복 인쇄 방지
if (window.printedSerials.has(preSerial)) {
console.log('[AutoPrint] 이미 인쇄됨, 스킵:', preSerial);
return;
}
window.printedSerials.add(preSerial); // 요청 전에 추가 (race condition 방지)
```
### 자동 재시도
| 시도 | 대기 시간 | 상태 |
|------|----------|------|
| 1회차 | - | 최초 시도 |
| 2회차 | 2초 | 첫 번째 재시도 |
| 3회차 | 4초 | 두 번째 재시도 |
| 실패 | - | `analysis_failed` 이벤트 발송 |
### 로그 파일
| 파일 | 위치 | 내용 |
|------|------|------|
| `print_history.log` | `backend/logs/` | 인쇄 성공/실패 기록 |
| `analysis_failures.log` | `prescription-trigger/logs/` | 분석 실패 상세 기록 |
| `paai_logs.db` | `backend/db/` | 분석 결과 SQLite 저장 |
### 관련 문서
- `docs/PAAI_AUTO_PRINT_TROUBLESHOOTING.md` - 자동인쇄 트러블슈팅 가이드
---
## 📝 버전 이력
| 날짜 | 버전 | 변경 내용 |
@ -427,6 +596,8 @@ OPENAI_MODEL=gpt-4o-mini
| 2026-02-28 | 1.0 | 초기 아키텍처 문서 작성 |
| | | 동물약 AI 챗봇 추가 |
| | | 플로팅 챗봇 UI 구현 |
| 2026-03-05 | 1.1 | PAAI 시스템 아키텍처 추가 |
| | | 자동인쇄, WebSocket, 재시도 로직 |
---

View File

@ -0,0 +1,252 @@
# ESC/POS 영수증 프린터 트러블슈팅 가이드
> 작성일: 2026-03-05
> 프린터: 192.168.0.174:9100 (올댓포스 오른쪽)
---
## 핵심 요약
| 항목 | 올바른 방식 | 잘못된 방식 |
|------|------------|------------|
| **인코딩** | EUC-KR | UTF-8 |
| **전송 방식** | socket 직접 전송 | python-escpos 라이브러리 |
| **이모지** | 사용 금지 (`>>`, `[V]`) | ❌ 🖨️ ✅ |
| **이미지** | 사용 금지 | PIL Image |
| **용지 폭** | 48자 기준 | 글자수 무제한 |
| **용지 길이** | 무제한 (롤 용지) | 제한 없음 |
---
## 증상별 해결책
### 1. 아무것도 안 나옴
```
원인: 프린터 연결 실패
해결:
1. ping 192.168.0.174 확인
2. 포트 9100 확인 (Test-NetConnection -ComputerName 192.168.0.174 -Port 9100)
3. 프린터 전원 확인
```
### 2. "EAT" 또는 깨진 문자만 나옴
```
원인: 이미지 인쇄 방식 사용
해결: 이미지 방식 사용 금지! 텍스트 + EUC-KR 인코딩 사용
❌ 잘못된 코드:
from escpos.printer import Network
p = Network(...)
p.image(img) # 이미지 인쇄 - 안 됨!
✅ 올바른 코드:
sock = socket.socket(...)
text_bytes = message.encode('euc-kr', errors='replace')
sock.sendall(INIT + text_bytes + CUT)
```
### 3. 한글이 ???? 로 나옴
```
원인: UTF-8 인코딩 사용
해결: EUC-KR 인코딩 사용
❌ 잘못된 코드:
text.encode('utf-8')
✅ 올바른 코드:
text.encode('euc-kr', errors='replace')
```
### 4. 이모지가 ? 로 나옴
```
원인: ESC/POS 프린터는 이모지 미지원
해결: 텍스트로 대체
❌ ✅ 상호작용 없음
✅ [V] 상호작용 없음
❌ ⚠️ 주의 필요
✅ [!] 주의 필요
❌ 📋 처방 해석
✅ >> 처방 해석
```
### 5. 첫 줄만 나오고 잘림
```
원인: python-escpos 라이브러리의 set() 함수 문제
해결: socket 직접 전송 방식 사용
❌ 잘못된 코드:
from escpos.printer import Network
p = Network(...)
p.set(align='center', bold=True) # 이 명령이 문제!
p.text("내용")
✅ 올바른 코드:
sock = socket.socket(...)
sock.sendall(INIT + text.encode('euc-kr') + CUT)
```
### 6. 연결은 되는데 인쇄 안 됨
```
원인: 프린터가 이전 작업에서 hang 상태
해결:
1. 프린터 전원 껐다 켜기
2. 또는 INIT 명령 먼저 전송: ESC + b'@'
```
---
## 올바른 코드 템플릿
### 기본 텍스트 인쇄
```python
import socket
# 프린터 설정
PRINTER_IP = "192.168.0.174"
PRINTER_PORT = 9100
# ESC/POS 명령어
ESC = b'\x1b'
INIT = ESC + b'@' # 프린터 초기화
CUT = ESC + b'd\x03' # 피드 + 커트
def print_text(message: str) -> bool:
try:
# EUC-KR 인코딩 (한글 지원)
text_bytes = message.encode('euc-kr', errors='replace')
# 명령어 조합
command = INIT + text_bytes + b'\n\n\n' + CUT
# 소켓 전송
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((PRINTER_IP, PRINTER_PORT))
sock.sendall(command)
sock.close()
return True
except Exception as e:
print(f"인쇄 오류: {e}")
return False
# 사용 예시
message = """
================================================
[ 테스트 출력 ]
================================================
환자: 홍길동
처방번호: 20260305000001
[V] 상호작용 없음
>> 처방 해석
감기 증상 완화를 위한 처방입니다.
================================================
청춘약국
================================================
"""
print_text(message)
```
### 중앙 정렬 헬퍼
```python
def center_text(text: str, width: int = 48) -> str:
"""48자 기준 중앙 정렬"""
text_len = len(text)
if text_len >= width:
return text
spaces = (width - text_len) // 2
return " " * spaces + text
# 사용
print(center_text("[ PAAI 복약안내 ]"))
# 출력: " [ PAAI 복약안내 ]"
```
### 줄바꿈 헬퍼
```python
def wrap_text(text: str, width: int = 44) -> list:
"""44자 기준 줄바꿈 (들여쓰기 여유)"""
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]]
# 사용
long_text = "경골 하단 및 중족골 골절로 인한 통증과 부종 관리를 위해 NSAIDs를 처방합니다."
for line in wrap_text(long_text, 44):
print(f" {line}")
```
---
## 프린터 사양
| 항목 | 값 |
|------|-----|
| IP | 192.168.0.174 |
| Port | 9100 |
| 용지 폭 | 80mm (48자) |
| 인코딩 | EUC-KR (CP949) |
| 한글 | 지원 |
| 이모지 | 미지원 |
| 이미지 | 미지원 (이 프린터) |
---
## 참고 파일
| 파일 | 설명 |
|------|------|
| `backend/pos_printer.py` | ESC/POS 기본 유틸리티 |
| `backend/paai_printer_cli.py` | PAAI 인쇄 전용 CLI |
| `clawd/memory/3월4일 동물약_복약지도서.md` | 동물약 인쇄 가이드 |
---
## 테스트 명령어
```powershell
# 연결 테스트
Test-NetConnection -ComputerName 192.168.0.174 -Port 9100
# 간단 인쇄 테스트
python -c "
import socket
sock = socket.socket()
sock.connect(('192.168.0.174', 9100))
sock.sendall(b'\x1b@Test OK\n\n\n\x1bd\x03')
sock.close()
print('OK')
"
# pos_printer.py 테스트
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
python pos_printer.py
```
---
## 히스토리
| 날짜 | 문제 | 해결 |
|------|------|------|
| 2026-03-04 | 동물약 투약지도서 이모지 깨짐 | 이모지 제거, 텍스트로 대체 |
| 2026-03-05 | PAAI 인쇄 "EAT"만 출력 | 이미지 방식 → 텍스트 방식 변경 |
| 2026-03-05 | python-escpos 라이브러리 문제 | socket 직접 전송으로 변경 |

View File

@ -0,0 +1,375 @@
# 지오영 API 리버스 엔지니어링 가이드
> 작성일: 2026-03-06
> 목적: 지오영 도매상 웹사이트의 내부 API를 분석하여 Playwright 대신 requests로 빠른 주문 시스템 구축
---
## 📋 개요
### 문제점
- **Playwright 방식**: 30초+ 소요 (브라우저 실행 → 로그인 → 검색 → 클릭 → 장바구니)
- **경쟁사**: 훨씬 빠른 주문 처리
### 해결책
- 웹사이트의 **내부 AJAX API**를 분석
- **requests + 세션 쿠키**로 직접 호출
- 결과: **~1초** 주문 완료 (30배 빨라짐!)
---
## 🔍 분석 과정
### 1단계: 인증 쿠키 확인
Playwright로 로그인 후 쿠키 확인:
```python
cookies = await page.context.cookies()
print([c['name'] for c in cookies])
# 출력: ['GEORELAUTH']
```
**핵심 발견**: `GEORELAUTH` 쿠키가 인증 토큰
### 2단계: 네트워크 요청 캡처
```python
page.on('request', lambda req: print(req.url, req.method))
```
**발견된 POST 요청:**
- `/Member/Login` - 로그인
- `/Home/PartialSearchProduct` - 제품 검색
- `/Home/PartialProductCart` - 장바구니 조회
### 3단계: JavaScript 번들 분석
```
https://gwn.geoweb.kr/bundles/order?v=...
https://gwn.geoweb.kr/bundles/order_product_cart?v=...
```
정규식으로 함수/URL 추출:
```python
import re
# 함수 찾기
funcs = re.findall(r'function\s+(Add\w*|Process\w*)\s*\(', content)
# AJAX URL 찾기
urls = re.findall(r'url\s*:\s*["\']([^"\']+)["\']', content)
```
### 4단계: 핵심 함수 발견
**AddCart 함수:**
```javascript
function AddCart(n,t,i){
// ... 유효성 검사 ...
ProcessCart("add", e, i, r); // ← 핵심!
}
```
**ProcessCart 함수:**
```javascript
function ProcessCart(n,t,i,r){
var u = {};
u.productCode = t;
u.moveCode = i;
u.orderQty = r;
jsf_com_GetAjax("/Home/DataCart/" + n, u, "json", ...);
}
```
**발견!**
- 장바구니 API: `POST /Home/DataCart/add`
- 파라미터: `productCode`, `moveCode`, `orderQty`
### 5단계: 주문 확정 API 찾기
HTML에서 폼 분석:
```python
soup = BeautifulSoup(html, 'html.parser')
form = soup.find('form', id='frmSave')
print(form.get('action'))
# 출력: /Home/DataOrder
```
**발견!** 주문 확정 API: `POST /Home/DataOrder`
---
## 🔑 최종 API 명세
### 1. 로그인
```
POST https://gwn.geoweb.kr/Member/Login
Content-Type: application/x-www-form-urlencoded
LoginID=7390&Password=trajet6640
→ 쿠키 'GEORELAUTH' 반환
```
### 2. 제품 검색
```
POST https://gwn.geoweb.kr/Home/PartialSearchProduct
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
srchText=661700390
→ HTML 테이블 반환 (보험코드, 제품명, 재고 등)
```
### 3. 장바구니 추가 ⭐
```
POST https://gwn.geoweb.kr/Home/DataCart/add
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
productCode=008709 ← 내부 코드 (보험코드 아님!)
moveCode=
orderQty=2
→ {"result": 1, "msg": ""} (성공)
→ {"result": -100, "msg": "주문 등록을 할수없는 제품"} (실패)
```
### 4. 주문 확정 ⭐
```
POST https://gwn.geoweb.kr/Home/DataOrder
Content-Type: application/x-www-form-urlencoded
p_desc=메모
→ 리다이렉트 또는 성공 페이지
```
### 5. 장바구니 비우기
```
POST https://gwn.geoweb.kr/Home/DataCart/delAll
→ 성공 시 200
```
---
## ⚠️ 주의사항 (삽질 포인트)
### 1. productCode ≠ 보험코드
**실수:**
```python
# ❌ 보험코드로 장바구니 추가 시도
session.post('/Home/DataCart/add', data={
'productCode': '661700390', # 보험코드
'orderQty': 1
})
# 결과: {"result": -100, "msg": "주문 등록을 할수없는 제품"}
```
**해결:**
```python
# ✅ 검색 결과에서 내부 코드 추출
soup = BeautifulSoup(search_html, 'html.parser')
product_div = soup.find('div', class_='div-product-detail')
internal_code = product_div.find_all('li')[0].get_text() # 예: "008709"
session.post('/Home/DataCart/add', data={
'productCode': internal_code, # 내부 코드
'orderQty': 1
})
# 결과: {"result": 1} 성공!
```
### 2. X-Requested-With 헤더 필요
```python
session.headers.update({
'X-Requested-With': 'XMLHttpRequest' # AJAX 요청임을 명시
})
```
### 3. 세션 쿠키 유지
Playwright로 로그인 → requests 세션에 쿠키 복사:
```python
# Playwright에서 쿠키 획득
cookies = await page.context.cookies()
# requests 세션에 복사
session = requests.Session()
for c in cookies:
session.cookies.set(c['name'], c['value'])
```
### 4. 로그인 세션 만료
- 세션 유효시간: 약 30분
- 해결: 로그인 후 시간 체크, 만료 시 재로그인
```python
if time.time() - self.last_login > 1800: # 30분
self.login()
```
---
## 📊 성능 비교
| 방식 | 첫 요청 | 이후 요청 | 비고 |
|------|---------|----------|------|
| **Playwright** | ~12초 | ~30초 | 브라우저 실행 |
| **API 직접 호출** | **~5초** | **~1초** | requests 사용 |
**30배 속도 향상!**
---
## 🛠️ 구현 코드
### GeoyoungSession 클래스 (geoyoung_api.py)
```python
class GeoyoungSession:
"""지오영 세션 관리 (싱글톤, 세션 재사용)"""
BASE_URL = "https://gwn.geoweb.kr"
def login(self) -> bool:
"""Playwright로 로그인 → 쿠키 획득"""
# ... Playwright 로그인 ...
cookies = await page.context.cookies()
for c in cookies:
self.session.cookies.set(c['name'], c['value'])
self.logged_in = True
self.last_login = time.time()
def search_stock_with_code(self, keyword: str) -> list:
"""검색 + 내부 코드 추출"""
resp = self.session.post(f"{self.BASE_URL}/Home/PartialSearchProduct",
data={'srchText': keyword})
# HTML 파싱 → internal_code 추출
def add_to_cart(self, product_code: str, quantity: int) -> dict:
"""장바구니 추가"""
resp = self.session.post(f"{self.BASE_URL}/Home/DataCart/add", data={
'productCode': product_code,
'moveCode': '',
'orderQty': quantity
})
return resp.json()
def confirm_order(self, memo: str = '') -> dict:
"""주문 확정"""
resp = self.session.post(f"{self.BASE_URL}/Home/DataOrder",
data={'p_desc': memo})
return {'success': True}
def full_order(self, kd_code: str, quantity: int, ...) -> dict:
"""전체 주문 플로우"""
# 1. 검색 → internal_code
# 2. 장바구니 추가
# 3. 주문 확정
```
---
## 🔧 분석 도구/스크립트
분석에 사용한 스크립트들 (backend/ 폴더):
| 파일 | 용도 |
|------|------|
| `capture_geoyoung_api.py` | 네트워크 요청 캡처 |
| `analyze_geoyoung.py` | HTML/JS 분석 |
| `download_js.py` | JS 번들 다운로드 |
| `extract_addcart.py` | AddCart 함수 추출 |
| `extract_processcart.py` | ProcessCart 함수 추출 |
| `find_frmsave.py` | 주문 확정 폼 찾기 |
| `test_datacart.py` | 장바구니 API 테스트 |
| `test_dataorder.py` | 전체 플로우 테스트 |
---
## 📝 API 엔드포인트 (Flask)
```
GET /api/geoyoung/stock?kd_code=661700390 # 재고 조회
POST /api/geoyoung/order # 장바구니 추가
POST /api/geoyoung/confirm # 주문 확정
POST /api/geoyoung/full-order # 전체 주문 (추천!)
```
### full-order 요청 예시
```bash
curl -X POST http://localhost:7001/api/geoyoung/full-order \
-H "Content-Type: application/json" \
-d '{
"kd_code": "661700390",
"quantity": 2,
"specification": "30T",
"auto_confirm": true,
"memo": "자동주문"
}'
```
### 응답
```json
{
"success": true,
"message": "콩코르정2.5mg 30T 머크(대웅) 2개 주문 완료",
"product": {
"insurance_code": "661700390",
"internal_code": "008709",
"product_name": "콩코르정2.5mg 30T 머크(대웅)",
"specification": "30T",
"stock": 533
},
"quantity": 2,
"confirmed": true
}
```
---
## 🎯 핵심 교훈
1. **웹사이트 = API 서버**
모든 웹사이트는 내부적으로 API를 사용함. 브라우저 개발자도구로 분석 가능.
2. **JavaScript 번들 분석**
minified JS도 함수명, URL 패턴으로 핵심 로직 파악 가능.
3. **쿠키 = 인증**
대부분의 사이트는 쿠키로 세션 관리. 쿠키만 있으면 requests로 동일 동작.
4. **내부 코드 ≠ 외부 코드**
보험코드, 바코드 등 외부 식별자와 내부 DB 키가 다를 수 있음.
5. **AJAX 헤더**
`X-Requested-With: XMLHttpRequest` 헤더가 필요한 경우 많음.
---
## 🔮 향후 개선
- [ ] 로그인을 requests로 직접 (Playwright 없이)
- [ ] 다중 도매상 지원 (수인, 백제 등)
- [ ] 주문 실패 시 자동 재시도
- [ ] 주문 상태 조회 API
---
## 📚 참고
- 지오영 URL: https://gwn.geoweb.kr
- 관련 파일: `backend/geoyoung_api.py`
- 주문 DB: `backend/db/orders.db`

View File

@ -0,0 +1,232 @@
# PAAI 자동인쇄 트러블슈팅 가이드
## 개요
PAAI(처방 AI 분석) 결과를 영수증 프린터로 자동 출력하는 기능의 트러블슈팅 가이드입니다.
---
## 시스템 구성
```
[처방 접수] → [WebSocket 감지] → [PAAI 분석] → [자동 인쇄]
↓ ↓ ↓ ↓
POS 입력 ws://8765 Claude API ESC/POS 프린터
```
### 관련 파일
- `backend/templates/pmr.html` - 프론트엔드 (토글, WebSocket 클라이언트)
- `backend/pmr_api.py` - API 엔드포인트 (`/pmr/api/paai/print`)
- `backend/paai_printer.py` - ESC/POS 프린터 모듈
---
## 문제 1: 자동인쇄 토글이 작동하지 않음
### 증상
- "자동인쇄" 버튼 클릭해도 ON/OFF 전환 안 됨
- 콘솔에 `showToast is not defined` 에러
### 원인
JavaScript 함수가 전역 스코프에 등록되지 않음
### 해결
모든 변수/함수를 `window.` 접두사로 전역 등록:
```javascript
// ❌ 잘못된 방식
var autoPrintEnabled = true;
function showToast() { ... }
// ✅ 올바른 방식
window.autoPrintEnabled = true;
window.showToast = function() { ... };
```
### 체크리스트
- [ ] `window.autoPrintEnabled` - boolean
- [ ] `window.showToast` - function
- [ ] `window.updateAutoPrintIndicator` - function
- [ ] `window.printPaaiResult` - function
브라우저 콘솔에서 확인:
```javascript
typeof window.showToast // "function" 이어야 함
```
---
## 문제 2: 새로고침 후 변경사항 미반영
### 증상
- 코드 수정 후 새로고침해도 이전 버전 실행
- `showToast is not defined` 에러 지속
### 원인
1. Flask 서버가 템플릿을 캐시함 (개발 모드 아닐 때)
2. 브라우저 캐시
### 해결
**Flask 재시작:**
```powershell
# app.py 프로세스 종료 후 재시작
Get-Process -Name python | Where-Object {
(Get-WmiObject Win32_Process -Filter "ProcessId=$($_.Id)").CommandLine -match "app.py"
} | Stop-Process -Force
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
python app.py
```
**브라우저 강제 새로고침:**
- `Ctrl + F5` (캐시 무시)
- 또는 URL에 쿼리 파라미터 추가: `?v=2`
---
## 문제 3: 프린터 출력 안 됨
### 증상
- 콘솔에 `[AutoPrint] 인쇄 요청:` 로그는 보임
- 프린터에서 출력 없음
### 원인 및 해결
**1. 프린터 연결 확인:**
```powershell
# USB 프린터 확인
Get-WmiObject Win32_Printer | Select Name, PortName
```
**2. API 응답 확인:**
```powershell
# 직접 API 테스트
curl -X POST http://localhost:7001/pmr/api/paai/print `
-H "Content-Type: application/json" `
-d '{"pre_serial":"test","patient_name":"테스트","result":{"analysis":{}}}'
```
**3. paai_printer.py 단독 테스트:**
```powershell
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
python paai_printer_cli.py test
```
---
## 문제 4: 한글 깨짐
### 증상
- 프린터 출력에서 한글이 `????` 또는 깨진 문자로 출력
### 원인
프린터가 EUC-KR 인코딩 필요 (CP949)
### 해결
`paai_printer.py`에서 EUC-KR 인코딩 사용:
```python
def encode_korean(text):
"""한글을 EUC-KR로 인코딩"""
try:
return text.encode('euc-kr', errors='replace')
except:
return text.encode('ascii', errors='replace')
```
ESC/POS 명령어:
```python
# 한글 모드 설정 (Code Page 949)
printer.write(b'\x1b\x40') # 초기화
printer.write(b'\x1c\x43\x01') # 한글 모드
```
---
## 문제 5: WebSocket 연결 실패
### 증상
- "자동감지 OFF" 표시
- 콘솔에 `WebSocket connection failed` 에러
### 원인
처방감지 서버 (`trigger_server.py`)가 실행되지 않음
### 해결
```powershell
cd C:\Users\청춘약국\source\pharmacy-pos-qr-system\backend
python trigger_server.py
```
정상 연결 시 콘솔 로그:
```
[Trigger] 연결 시도: ws://localhost:8765
[Trigger] ✅ 연결됨
```
---
## 디버깅 체크리스트
### 브라우저 콘솔 확인
```javascript
// 전역 함수 확인
typeof window.autoPrintEnabled // boolean
typeof window.showToast // "function"
typeof window.printPaaiResult // "function"
// 수동 인쇄 테스트
window.printPaaiResult('test123', '테스트환자', {
analysis: { prescription_insight: '테스트' }
});
```
### 서버 로그 확인
```powershell
# Flask 로그에서 인쇄 API 호출 확인
# POST /pmr/api/paai/print 200
```
### localStorage 확인
```javascript
localStorage.getItem('pmr_auto_print') // "true" 또는 "false"
```
---
## 관련 커밋 히스토리
| 커밋 | 설명 |
|------|------|
| `b4e4a44` | 자동인쇄 전역 변수/함수 완전 수정 |
| `e33204f` | printPaaiResult 전역 함수로 변경 |
| `0bbc8a5` | showToast 함수 추가 |
| `0b17139` | PAAI 자동인쇄 기능 완성 (EUC-KR) |
---
## 전체 플로우 요약
```
1. 처방 접수 (POS)
2. trigger_server.py가 감지 → WebSocket 브로드캐스트
3. pmr.html이 WebSocket 메시지 수신
4. 자동감지 ON이면 → PAAI 분석 요청 (/pmr/api/paai/analyze)
5. 분석 완료 → analysis_completed 이벤트
6. 자동인쇄 ON이면 → printPaaiResult() 호출
7. /pmr/api/paai/print API → paai_printer.py
8. ESC/POS 명령어로 영수증 프린터 출력 🖨️
```
---
*문서 작성일: 2026-03-05*
*작성자: 용림 🐉*

View File

@ -0,0 +1,316 @@
# 전문의약품 사용량 조회 + 지오영 주문 시스템
> 작성일: 2026-03-06
> 상태: 1단계 완료 (재고 조회), 2단계 진행 예정 (자동 주문)
---
## 📋 개요
약국의 전문의약품(처방전 조제) 사용량을 기간별로 조회하고, 지오영 도매상에서 재고를 확인하여 주문까지 연결하는 시스템.
### 핵심 기능
1. **사용량 조회**: 기간별 전문의약품 사용량 집계
2. **현재고 표시**: PIT3000 재고 데이터 연동
3. **지오영 재고 조회**: 도매상 재고 실시간 확인
4. **규격별 표시**: 30T, 100T, 300T 등 다양한 규격
5. **주문 장바구니**: 선택 품목 장바구니 담기
---
## 🗂️ 파일 구조
```
pharmacy-pos-qr-system/backend/
├── app.py # Flask 메인 (Blueprint 등록)
├── geoyoung_api.py # 지오영 API 모듈 ⭐ NEW
└── templates/
├── admin_rx_usage.html # 전문의약품 사용량 페이지 ⭐ NEW
└── admin_usage.html # OTC 사용량 페이지 ⭐ NEW
```
---
## 🔗 API 엔드포인트
### 1. 전문의약품 사용량 조회
```
GET /api/rx-usage?start_date=2026-03-01&end_date=2026-03-06&sort=qty_desc
```
**파라미터:**
| 파라미터 | 설명 | 예시 |
|---------|------|------|
| start_date | 시작일 (YYYY-MM-DD) | 2026-03-01 |
| end_date | 종료일 (YYYY-MM-DD) | 2026-03-06 |
| search | 검색어 (약품명, 코드) | 레바미피드 |
| sort | 정렬 (qty_desc, qty_asc, name_asc, amount_desc, rx_desc) | qty_desc |
**응답:**
```json
{
"success": true,
"items": [
{
"drug_code": "670400830",
"product_name": "휴니즈레바미피드정_(0.1g/1정)",
"supplier": "(주)휴온스메디텍",
"total_qty": 15,
"total_dose": 980,
"total_amount": 12500,
"prescription_count": 45,
"current_stock": 3809,
"barcode": "",
"thumbnail": null
}
],
"stats": {
"period_days": 6,
"product_count": 312,
"total_qty": 1500,
"total_dose": 15042,
"total_amount": 321837881
}
}
```
### 2. 지오영 재고 조회 (보험코드)
```
GET /api/geoyoung/stock?kd_code=670400830
```
**응답:**
```json
{
"success": true,
"keyword": "670400830",
"count": 2,
"items": [
{
"insurance_code": "670400830",
"manufacturer": "휴온스메디텍",
"product_name": "레바미피드정 300T 휴온스메디케어(구.휴니즈)",
"specification": "300T",
"stock": 0
},
{
"insurance_code": "670400830",
"manufacturer": "휴온스메디텍",
"product_name": "레바미피드정 30T 휴온스메디케어(구.휴니즈)",
"specification": "30T",
"stock": 0
}
]
}
```
### 3. 지오영 재고 조회 (제품명 → 성분 추출)
```
GET /api/geoyoung/stock-by-name?product_name=휴니즈레바미피드정_(0.1g/1정)
```
성분명 "레바미피드"를 추출하여 검색 → 여러 제약사 제품 반환
### 4. 지오영 세션 상태
```
GET /api/geoyoung/session-status
```
---
## 🗄️ 데이터베이스 구조
### MSSQL - PM_PRES (처방전)
**PS_sub_pharm** - 처방 상세
| 컬럼 | 설명 |
|------|------|
| PreSerial | 처방전 일련번호 |
| Indate | 조제일 (YYYYMMDD) |
| DrugCode | 약품코드 |
| QUAN | 수량 |
| Days | 투약일수 |
| DRUPRICE | 약가 |
### MSSQL - PM_DRUG (약품)
**CD_GOODS** - 약품 마스터
| 컬럼 | 설명 |
|------|------|
| DrugCode | 약품코드 (PK) |
| GoodsName | 약품명 |
| SplName | 제조사명 |
| BARCODE | 바코드 |
**IM_total** - 현재고 ⭐ 중요
| 컬럼 | 설명 |
|------|------|
| DrugCode | 약품코드 |
| **IM_QT_sale_debit** | **현재고 수량** |
### 현재고 조회 쿼리
```sql
SELECT
P.DrugCode,
G.GoodsName,
ISNULL(IT.IM_QT_sale_debit, 0) as current_stock
FROM PS_sub_pharm P
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON P.DrugCode = G.DrugCode
LEFT JOIN PM_DRUG.dbo.IM_total IT ON P.DrugCode = IT.DrugCode
```
---
## 🏭 지오영 API 연동
### 아키텍처
```
[브라우저] → [Flask API] → [GeoyoungSession] → [지오영 웹]
[Playwright 로그인] (최초 1회)
[requests 검색] (이후 빠름)
```
### 세션 관리 (geoyoung_api.py)
```python
class GeoyoungSession:
"""싱글톤 패턴, 세션 30분 유지"""
def login(self):
# Playwright로 로그인 → 쿠키 획득
# requests 세션에 쿠키 복사
def search_stock(self, keyword):
# requests로 빠른 검색
# POST /Home/PartialSearchProduct
```
### 성능
| 요청 | 소요시간 | 비고 |
|------|----------|------|
| 첫 요청 (로그인) | ~12초 | Playwright 브라우저 |
| 이후 요청 | **~2.5초** | requests 재사용 |
| 세션 유효기간 | 30분 | 자동 재로그인 |
### 지오영 로그인 정보
```
URL: https://gwn.geoweb.kr
ID: 7390
PW: trajet6640
```
---
## 💻 UI 사용법
### 페이지 접속
```
http://localhost:7001/admin/rx-usage
```
### 기능
1. **날짜 선택**: 시작일/종료일 지정
2. **검색**: 약품명, 코드로 필터
3. **정렬**: 투약량순, 처방건수순, 금액순
4. **지오영 조회**: 행 **더블클릭** → 모달
5. **장바구니**: 체크 후 "장바구니 추가"
6. **주문서**: "주문서 생성" → 클립보드 복사
### 색상 의미 (현재고)
- 🟢 초록: 재고 충분 (현재고 > 사용량)
- 🟡 노랑: 재고 부족 (현재고 < 사용량)
- 🔴 빨강: 재고 없음 (0)
---
## 🚀 향후 개발 계획
### 2단계: 자동 주문
- [ ] 지오영 장바구니 담기 API
- [ ] 주문 확정 API (dry_run 모드)
- [ ] 주문 내역 SQLite 저장
### 3단계: 다중 도매상
- [ ] 수인 API 연동
- [ ] 도매상 선택 UI
- [ ] 재고 비교 (A사 vs B사)
### 4단계: 스마트 주문
- [ ] 사용량 기반 최적 규격 추천
- 예: 220개 필요 → "30T x 8개" vs "300T x 1개"
- [ ] 분할 주문 (오전/오후)
- [ ] 주문 누적 관리
### 5단계: 주문 DB
```sql
-- SQLite: orders.db
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
order_date TEXT,
wholesaler TEXT, -- 'geoyoung', 'sooin'
drug_code TEXT,
product_name TEXT,
specification TEXT, -- '30T', '300T'
quantity INTEGER,
status TEXT, -- 'pending', 'ordered', 'delivered'
created_at TEXT
);
```
---
## 🔧 트러블슈팅
### 문제: 지오영 로그인 실패
**원인**: requests만으로는 로그인 불가 (JavaScript 필요)
**해결**: Playwright 하이브리드 방식 (로그인만 Playwright)
### 문제: 검색 결과 0개
**원인**: 보험코드가 아닌 내부 코드로 검색
**해결**: 보험코드(KD코드) 사용, 또는 성분명으로 재검색
### 문제: 현재고가 0으로 표시
**원인**: IM_inventory 테이블이 비어있음
**해결**: `IM_total.IM_QT_sale_debit` 컬럼 사용
### 문제: Flask 서버 시작 안됨
**원인**: stdout 인코딩 문제 (Start-Process 사용 시)
**해결**: geoyoung_api.py에서 stdout 재설정 코드 제거
---
## 📝 관련 파일 참조
### 지오영 크롤러 원본
```
c:\Users\청춘약국\source\person-lookup-web-local\crawler\
├── gangwon_geoyoung_api.py # API 클라이언트
├── gangwon_geoyoung_order.py # 주문 자동화 (order_by_kd_code)
└── gangwon_geoyoung_crawler.py # 데이터 크롤링
```
### 주문 함수 사용 예시
```python
from gangwon_geoyoung_order import order_by_kd_code
# 테스트 (실제 주문 안함)
result = await order_by_kd_code("670400830", quantity=10, dry_run=True)
# 실제 주문
result = await order_by_kd_code("670400830", quantity=10, dry_run=False)
```
---
## ✅ 체크리스트
- [x] 전문의약품 사용량 조회 API
- [x] 현재고 표시 (IM_total)
- [x] 지오영 재고 조회 API
- [x] 지오영 세션 관리 (속도 개선)
- [x] UI 모달 (더블클릭)
- [x] 장바구니 기능
- [ ] 지오영 실제 주문 연동
- [ ] 주문 내역 DB 저장
- [ ] 다중 도매상 지원

View File

@ -0,0 +1,199 @@
# 도매상 API 통합 가이드
> 작성일: 2026-03-06
> 버전: 1.0
## 📦 패키지 구조
```
pharmacy-wholesale-api/ # 별도 리포지토리
├── wholesale/
│ ├── __init__.py # SooinSession, GeoYoungSession 노출
│ ├── base.py # WholesaleSession 공통 인터페이스
│ ├── sooin.py # 수인약품 API
│ └── geoyoung.py # 지오영 API
└── docs/
└── SOOIN.md # 수인약품 상세 문서
pharmacy-pos-qr-system/backend/ # 기존 프로젝트
├── wholesale_path.py # 패키지 경로 설정
├── sooin_api.py # Flask Blueprint (wholesale 사용)
└── geoyoung_api.py # Flask Blueprint (wholesale 사용)
```
---
## 🔌 도매상별 API 특성
| 항목 | 지오영 | 수인약품 |
|------|--------|----------|
| 웹사이트 | gwn.geoweb.kr | sooinpharm.co.kr |
| 인증 방식 | Playwright → requests | Playwright → requests |
| 세션 유효시간 | 30분 | 30분 |
| 검색 코드 | 보험코드 (KD) | KD코드 + 내부코드 (pc) |
| 장바구니 추가 | productCode 필요 | internal_code (pc) 필요 |
| **개별 삭제** | ❌ 없음 | ✅ 체크박스 soft delete |
| 장바구니 조회 | PartialProductCart | Bag.asp |
---
## 🔑 핵심 발견: 코드 체계
### 지오영
```
보험코드 (KD코드) → 검색 → productCode (내부) → 장바구니 추가
```
### 수인약품
```
KD코드 → 검색 → internal_code (pc) → 장바구니 추가
PhysicInfo.asp?pc=32495 에서 추출
```
**⚠️ 중요:** `internal_code`가 없으면 장바구니 추가 불가!
---
## 🛒 수인약품 개별 취소 (Soft Delete)
### 발견 과정
- `kind=delOne` API 존재하지만 작동 안 함
- 체크박스가 실제 "취소" 역할
- `ControlBag.asp` AJAX 엔드포인트 발견
### API 사용법
```python
from wholesale import SooinSession
session = SooinSession()
session.login()
# 장바구니 조회 (체크 상태 포함)
cart = session.get_cart()
# cart['items'][0]['checked'] = False (활성)
# cart['items'][0]['active'] = True
# 항목 취소 (체크)
session.cancel_item(row_index=0)
# 또는
session.cancel_item(product_code="32495")
# 취소 복원 (체크 해제)
session.restore_item(row_index=0)
```
### 내부 동작
```
POST /Service/Order/ControlBag.asp
Content-Type: application/x-www-form-urlencoded; charset=euc-kr
X-Requested-With: XMLHttpRequest
vc=50911 (거래처코드)
pc=32495 (내부 제품코드)
f=true (true=취소, false=복원)
pg= (제품구분, 빈값)
pdno= (제품번호, 빈값)
tmdt= (기한, 빈값)
```
---
## 📊 SQLite 스키마 연동
### order_context (AI 학습용)
```sql
-- 새로 추가된 필드 (2026-03-06)
wholesaler_id TEXT, -- 'geoyoung' 또는 'sooin'
wholesaler_price INTEGER, -- 도매상 가격
internal_code TEXT, -- 도매상 내부 코드
was_cancelled BOOLEAN, -- 취소 여부 (수인 soft delete)
```
### 도매상별 주문 시 기록할 데이터
```python
order_context = {
'drug_code': 'D12345',
'product_name': '아세탑정',
'wholesaler_id': 'sooin',
'internal_code': '32495', # 수인 내부코드
'ordered_spec': '30T',
'ordered_qty': 2,
'wholesaler_price': 4800,
'available_specs': '["30T", "500T"]',
'spec_stocks': '{"30T": 0, "500T": 0}', # 재고 상황
'selection_reason': 'only_option',
'was_cancelled': False
}
```
---
## 🔄 Flask API 엔드포인트
### 수인약품 (/api/sooin/*)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | /stock | 재고 검색 |
| GET | /cart | 장바구니 조회 |
| POST | /order | 장바구니 추가 |
| POST | /cart/clear | 장바구니 비우기 |
| POST | /cart/cancel | **항목 취소 (soft delete)** |
| POST | /cart/restore | **항목 복원** |
| POST | /confirm | 주문 전송 |
### 지오영 (/api/geoyoung/*)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | /stock | 재고 검색 |
| GET | /cart | 장바구니 조회 |
| POST | /order | 장바구니 추가 |
| POST | /cart/clear | 장바구니 비우기 |
| POST | /cart/cancel | **항목 삭제 (hard delete)** |
| POST | /cart/restore | ❌ NOT_SUPPORTED |
| POST | /confirm | 주문 전송 |
### 개별 삭제 API 차이
| 도매상 | cancel 동작 | restore 가능 | 내부 API |
|--------|-------------|-------------|----------|
| 수인 | 체크박스 soft delete | ✅ 가능 | ControlBag.asp |
| 지오영 | 완전 삭제 | ❌ 불가 | DataCart/del |
---
## 📁 관련 문서
| 문서 | 위치 | 내용 |
|------|------|------|
| AI ERP 자동주문 기획 | `docs/AI_ERP_AUTO_ORDER_SYSTEM.md` | 전체 시스템 설계 |
| 지오영 API 분석 | `docs/GEOYOUNG_API_REVERSE_ENGINEERING.md` | 지오영 리버스 엔지니어링 |
| 수인 API 분석 | `pharmacy-wholesale-api/docs/SOOIN.md` | 수인 리버스 엔지니어링 |
| 사용량 조회 가이드 | `docs/RX_USAGE_GEOYOUNG_GUIDE.md` | 처방 사용량 조회 |
---
## ✅ 체크리스트
### 완료
- [x] 지오영 API 연동
- [x] 수인약품 API 연동
- [x] 개별 취소 기능 (수인) - soft delete
- [x] 개별 삭제 기능 (지오영) - hard delete
- [x] Flask Blueprint 통합
- [x] wholesale 패키지 분리
- [x] SQLite 스키마 업데이트
### 진행 예정
- [ ] daily_usage 자동 수집
- [ ] AI 규격 선택 모델
- [ ] AI 도매상 선택 모델
- [ ] 자동 주문 Level 1 (승인 후 실행)
---
*업데이트: 2026-03-06 by 용림 🐉*

View File

@ -0,0 +1,170 @@
# PMR 세로 모니터 레이아웃 개선 계획
## 현재 상황
### 환경
- 모니터: 2.5K (2560x1440) 세로 모드 → 1440x2560
- 문제: 환자목록과 처방전 내용이 **거의 절반씩** 차지
- 환자목록은 그렇게 넓을 필요 없음
### 현재 CSS 구조
```css
.main-content {
display: flex;
gap: 20px;
}
.patient-list {
width: 380px; /* 고정 너비 */
}
.prescription-panel {
flex: 1; /* 나머지 공간 */
}
```
### 세로 모니터에서의 문제
- 화면 너비: 1440px
- 환자목록: 380px (26%)
- 처방전: ~1040px (72%)
- **실제로는 환자목록이 26%인데 "절반처럼" 느껴짐** → 세로가 길어서 상대적으로 넓어 보임
---
## 해결 방안 비교
### 방안 1: 미디어쿼리 (aspect-ratio)
```css
/* 세로 모니터 감지 (높이 > 너비) */
@media (orientation: portrait) {
.patient-list {
width: 280px; /* 더 좁게 */
}
}
```
**장점:**
- 간단, 직관적
- 기존 코드 영향 최소화
**단점:**
- 세로 모니터 전용 스타일 분기 필요
---
### 방안 2: CSS Container Queries
```css
.main-content {
container-type: inline-size;
}
@container (max-width: 1500px) {
.patient-list {
width: 280px;
}
}
```
**장점:**
- 모던한 접근
- 컨테이너 기준으로 반응
**단점:**
- 브라우저 지원 확인 필요 (Chrome 105+)
---
### 방안 3: 환자목록 비율 기반 (추천 ⭐)
```css
.patient-list {
width: 280px;
min-width: 250px;
max-width: 380px;
}
```
또는:
```css
.patient-list {
width: clamp(250px, 20vw, 380px);
}
```
**장점:**
- 미디어쿼리 없이 자동 조절
- 모든 화면에서 적절한 비율 유지
- **가장 단순함**
**단점:**
- 특정 breakpoint 세밀 조정 어려움
---
### 방안 4: 세로 모니터 전용 레이아웃
```css
@media (orientation: portrait) and (min-height: 1800px) {
.main-content {
flex-direction: column;
}
.patient-list {
width: 100%;
height: 200px; /* 상단 고정 */
}
}
```
**장점:**
- 세로 모니터 최적화
**단점:**
- 레이아웃 완전 변경 → 복잡
- UX 변화 큼
---
## 추천 방안: **방안 3 + 방안 1 조합**
### 구현
```css
/* 기본: 비율 기반 너비 */
.patient-list {
width: clamp(250px, 22vw, 380px);
}
/* 세로 모니터에서 더 좁게 */
@media (orientation: portrait) {
.patient-list {
width: clamp(220px, 18vw, 300px);
}
}
```
### 이유
1. **clamp()**: 최소/최대 범위 내에서 자동 조절
2. **portrait 미디어쿼리**: 세로 모니터 추가 최적화
3. **코드 2줄 추가**로 해결 가능
---
## 작업 범위
### 변경 파일
- `pmr.html` - CSS 수정 (약 5줄)
### 테스트
- 가로 모니터 (기존 동작 유지)
- 세로 모니터 (환자목록 좁아짐)
- 반응형 resize
---
## 예상 결과
| 모드 | 환자목록 너비 | 비율 |
|------|--------------|------|
| 가로 (1920px) | ~380px | 20% |
| 가로 (1440px) | ~320px | 22% |
| **세로 (1440px)** | **~260px** | **18%** |
---
## 승인 시 진행
약사님 확인 후 바로 구현 가능합니다.