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 - 테스트 스크립트들
This commit is contained in:
1072
docs/AI_ERP_AUTO_ORDER_SYSTEM.html
Normal file
1072
docs/AI_ERP_AUTO_ORDER_SYSTEM.html
Normal file
File diff suppressed because it is too large
Load Diff
875
docs/AI_ERP_AUTO_ORDER_SYSTEM.md
Normal file
875
docs/AI_ERP_AUTO_ORDER_SYSTEM.md
Normal 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`
|
||||
375
docs/GEOYOUNG_API_REVERSE_ENGINEERING.md
Normal file
375
docs/GEOYOUNG_API_REVERSE_ENGINEERING.md
Normal 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`
|
||||
316
docs/RX_USAGE_GEOYOUNG_GUIDE.md
Normal file
316
docs/RX_USAGE_GEOYOUNG_GUIDE.md
Normal 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 저장
|
||||
- [ ] 다중 도매상 지원
|
||||
189
docs/WHOLESALE_API_INTEGRATION.md
Normal file
189
docs/WHOLESALE_API_INTEGRATION.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 도매상 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 | **항목 취소** (신규) |
|
||||
| POST | /cart/restore | **항목 복원** (신규) |
|
||||
| POST | /confirm | 주문 전송 |
|
||||
|
||||
### 지오영 (/api/geoyoung/*)
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | /stock | 재고 검색 |
|
||||
| GET | /cart | 장바구니 조회 |
|
||||
| POST | /order | 장바구니 추가 |
|
||||
| POST | /cart/clear | 장바구니 비우기 |
|
||||
| POST | /confirm | 주문 전송 |
|
||||
|
||||
---
|
||||
|
||||
## 📁 관련 문서
|
||||
|
||||
| 문서 | 위치 | 내용 |
|
||||
|------|------|------|
|
||||
| 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] 개별 취소 기능 (수인)
|
||||
- [x] Flask Blueprint 통합
|
||||
- [x] wholesale 패키지 분리
|
||||
- [x] SQLite 스키마 업데이트
|
||||
|
||||
### 진행 예정
|
||||
- [ ] daily_usage 자동 수집
|
||||
- [ ] AI 규격 선택 모델
|
||||
- [ ] AI 도매상 선택 모델
|
||||
- [ ] 자동 주문 Level 1 (승인 후 실행)
|
||||
|
||||
---
|
||||
|
||||
*업데이트: 2026-03-06 by 용림 🐉*
|
||||
Reference in New Issue
Block a user