feat: 복합 로트 사용 기능 구현 (수동 로트 배분)
## 구현 내용 ### 1. 백엔드 (app.py) - 수동 로트 배분 지원 (lot_assignments 배열 처리) - 각 로트별 지정 수량만큼 재고 차감 - 검증: 배분 합계 확인, 재고 충분 확인 - compound_consumptions 테이블에 각 로트별 소비 기록 ### 2. 프론트엔드 (app.js, index.html) - 로트 배분 모달 UI 구현 - 로트별 재고, 단가 표시 - 수동 입력 및 자동 배분 기능 - 실시간 합계 계산 및 검증 - 원산지 선택에 "수동 배분" 옵션 추가 (로트 2개 이상 시) - 조제 저장 시 lot_assignments 포함 ### 3. 테스트 - 테스트용 당귀 로트 추가 (한국산) - E2E 테스트 성공 - 당귀 100g을 2개 로트(중국산 60g + 한국산 40g)로 배분 - 각 로트별 재고 정확히 차감 - 소비 내역 올바르게 기록 ## 장점 - DB 스키마 변경 없음 - 기존 자동 선택과 호환 - 재고 부족 시 여러 로트 조합 가능 - 원가 최적화 가능 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6ad8bac5c2
commit
0f40cdfba7
81
add_test_dangui_lot.py
Normal file
81
add_test_dangui_lot.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
테스트용 당귀 로트 추가 - 복합 로트 테스트를 위함
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def add_test_lot():
|
||||||
|
conn = sqlite3.connect('database/kdrug.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 휴먼일당귀 herb_item_id 확인
|
||||||
|
cursor.execute("SELECT herb_item_id FROM herb_items WHERE herb_name = '휴먼일당귀'")
|
||||||
|
herb_item_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# 공급업체 ID 확인
|
||||||
|
cursor.execute("SELECT supplier_id FROM suppliers WHERE name = '한의사랑' LIMIT 1")
|
||||||
|
supplier_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# 기존 로트의 receipt_line_id 복사
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT receipt_line_id
|
||||||
|
FROM inventory_lots
|
||||||
|
WHERE herb_item_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", (herb_item_id,))
|
||||||
|
receipt_line_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# 새 로트 추가 (한국산, 다른 가격)
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO inventory_lots (
|
||||||
|
herb_item_id, supplier_id, receipt_line_id, received_date, origin_country,
|
||||||
|
unit_price_per_g, quantity_received, quantity_onhand,
|
||||||
|
expiry_date, lot_number, is_depleted, display_name
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
herb_item_id, # herb_item_id (휴먼일당귀)
|
||||||
|
supplier_id, # supplier_id
|
||||||
|
receipt_line_id, # receipt_line_id (기존 로트에서 복사)
|
||||||
|
datetime.now().strftime('%Y-%m-%d'), # received_date
|
||||||
|
'한국', # origin_country (기존은 중국)
|
||||||
|
18.5, # unit_price_per_g (기존은 12.9)
|
||||||
|
3000.0, # quantity_received
|
||||||
|
3000.0, # quantity_onhand
|
||||||
|
(datetime.now() + timedelta(days=365)).strftime('%Y-%m-%d'), # expiry_date
|
||||||
|
'TEST-DG-2024-001', # lot_number
|
||||||
|
0, # is_depleted
|
||||||
|
'일당귀(한국산)' # display_name
|
||||||
|
))
|
||||||
|
|
||||||
|
new_lot_id = cursor.lastrowid
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"✅ 테스트용 당귀 로트 추가 완료!")
|
||||||
|
print(f" - Lot ID: {new_lot_id}")
|
||||||
|
print(f" - 약재: 휴먼일당귀")
|
||||||
|
print(f" - 원산지: 한국")
|
||||||
|
print(f" - 재고: 3000g")
|
||||||
|
print(f" - 단가: 18.5원/g")
|
||||||
|
|
||||||
|
# 현재 당귀 로트 현황 표시
|
||||||
|
print("\n=== 현재 휴먼일당귀 로트 현황 ===")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT lot_id, origin_country, quantity_onhand, unit_price_per_g
|
||||||
|
FROM inventory_lots
|
||||||
|
WHERE herb_item_id = ? AND is_depleted = 0
|
||||||
|
ORDER BY lot_id
|
||||||
|
""", (herb_item_id,))
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
print(f"Lot #{row[0]}: {row[1]}산, 재고 {row[2]}g, 단가 {row[3]}원/g")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"❌ 오류: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
add_test_lot()
|
||||||
156
app.py
156
app.py
@ -1308,67 +1308,125 @@ def create_compound():
|
|||||||
ingredient['grams_per_cheop'], total_grams,
|
ingredient['grams_per_cheop'], total_grams,
|
||||||
modification_type, original_grams))
|
modification_type, original_grams))
|
||||||
|
|
||||||
# 재고 차감 (FIFO 방식 - 원산지 지정 시 해당 원산지만)
|
# 재고 차감 처리
|
||||||
remaining_qty = total_grams
|
remaining_qty = total_grams
|
||||||
|
|
||||||
# 원산지가 지정된 경우 해당 원산지만, 아니면 전체에서 FIFO
|
# 수동 로트 배분이 있는 경우 (lot_assignments 배열 사용)
|
||||||
if origin_country and origin_country != 'auto':
|
if 'lot_assignments' in ingredient and ingredient['lot_assignments']:
|
||||||
cursor.execute("""
|
# 수동 로트 배분 검증
|
||||||
SELECT lot_id, quantity_onhand, unit_price_per_g
|
assigned_total = sum(la['quantity'] for la in ingredient['lot_assignments'])
|
||||||
FROM inventory_lots
|
if abs(assigned_total - total_grams) > 0.01:
|
||||||
WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0
|
raise ValueError(f"로트 배분 합계({assigned_total}g)와 필요량({total_grams}g)이 일치하지 않습니다")
|
||||||
AND origin_country = ?
|
|
||||||
ORDER BY unit_price_per_g, received_date, lot_id
|
# 각 로트별로 처리
|
||||||
""", (herb_item_id, origin_country))
|
for assignment in ingredient['lot_assignments']:
|
||||||
|
lot_id = assignment['lot_id']
|
||||||
|
requested_qty = assignment['quantity']
|
||||||
|
|
||||||
|
# 로트 정보 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT quantity_onhand, unit_price_per_g
|
||||||
|
FROM inventory_lots
|
||||||
|
WHERE lot_id = ? AND herb_item_id = ? AND is_depleted = 0
|
||||||
|
""", (lot_id, herb_item_id))
|
||||||
|
|
||||||
|
lot_info = cursor.fetchone()
|
||||||
|
if not lot_info:
|
||||||
|
raise ValueError(f"로트 #{lot_id}를 찾을 수 없거나 사용 불가능합니다")
|
||||||
|
|
||||||
|
available = lot_info[0]
|
||||||
|
unit_price = lot_info[1]
|
||||||
|
|
||||||
|
if requested_qty > available:
|
||||||
|
raise ValueError(f"로트 #{lot_id}의 재고({available}g)가 부족합니다 (요청: {requested_qty}g)")
|
||||||
|
|
||||||
|
cost = requested_qty * unit_price
|
||||||
|
total_cost += cost
|
||||||
|
|
||||||
|
# 소비 내역 기록
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO compound_consumptions (compound_id, herb_item_id, lot_id,
|
||||||
|
quantity_used, unit_cost_per_g, cost_amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (compound_id, herb_item_id, lot_id, requested_qty, unit_price, cost))
|
||||||
|
|
||||||
|
# 로트 재고 감소
|
||||||
|
new_qty = available - requested_qty
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE inventory_lots
|
||||||
|
SET quantity_onhand = ?, is_depleted = ?
|
||||||
|
WHERE lot_id = ?
|
||||||
|
""", (new_qty, 1 if new_qty == 0 else 0, lot_id))
|
||||||
|
|
||||||
|
# 재고 원장 기록
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
||||||
|
quantity_delta, unit_cost_per_g,
|
||||||
|
reference_table, reference_id)
|
||||||
|
VALUES ('CONSUME', ?, ?, ?, ?, 'compounds', ?)
|
||||||
|
""", (herb_item_id, lot_id, -requested_qty, unit_price, compound_id))
|
||||||
|
|
||||||
|
# 자동 로트 선택 (기존 로직)
|
||||||
else:
|
else:
|
||||||
# 자동 선택: 가격이 저렴한 것부터
|
# 원산지가 지정된 경우 해당 원산지만, 아니면 전체에서 FIFO
|
||||||
cursor.execute("""
|
if origin_country and origin_country != 'auto':
|
||||||
SELECT lot_id, quantity_onhand, unit_price_per_g
|
cursor.execute("""
|
||||||
FROM inventory_lots
|
SELECT lot_id, quantity_onhand, unit_price_per_g
|
||||||
WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0
|
FROM inventory_lots
|
||||||
ORDER BY unit_price_per_g, received_date, lot_id
|
WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0
|
||||||
""", (herb_item_id,))
|
AND origin_country = ?
|
||||||
|
ORDER BY unit_price_per_g, received_date, lot_id
|
||||||
|
""", (herb_item_id, origin_country))
|
||||||
|
else:
|
||||||
|
# 자동 선택: 가격이 저렴한 것부터
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT lot_id, quantity_onhand, unit_price_per_g
|
||||||
|
FROM inventory_lots
|
||||||
|
WHERE herb_item_id = ? AND is_depleted = 0 AND quantity_onhand > 0
|
||||||
|
ORDER BY unit_price_per_g, received_date, lot_id
|
||||||
|
""", (herb_item_id,))
|
||||||
|
|
||||||
lots = cursor.fetchall()
|
lots = cursor.fetchall()
|
||||||
|
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
if remaining_qty <= 0:
|
if remaining_qty <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
lot_id = lot[0]
|
lot_id = lot[0]
|
||||||
available = lot[1]
|
available = lot[1]
|
||||||
unit_price = lot[2]
|
unit_price = lot[2]
|
||||||
|
|
||||||
used = min(remaining_qty, available)
|
used = min(remaining_qty, available)
|
||||||
cost = used * unit_price
|
cost = used * unit_price
|
||||||
total_cost += cost
|
total_cost += cost
|
||||||
|
|
||||||
# 소비 내역 기록
|
# 소비 내역 기록
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO compound_consumptions (compound_id, herb_item_id, lot_id,
|
INSERT INTO compound_consumptions (compound_id, herb_item_id, lot_id,
|
||||||
quantity_used, unit_cost_per_g, cost_amount)
|
quantity_used, unit_cost_per_g, cost_amount)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""", (compound_id, herb_item_id, lot_id, used, unit_price, cost))
|
""", (compound_id, herb_item_id, lot_id, used, unit_price, cost))
|
||||||
|
|
||||||
# 로트 재고 감소
|
# 로트 재고 감소
|
||||||
new_qty = available - used
|
new_qty = available - used
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE inventory_lots
|
UPDATE inventory_lots
|
||||||
SET quantity_onhand = ?, is_depleted = ?
|
SET quantity_onhand = ?, is_depleted = ?
|
||||||
WHERE lot_id = ?
|
WHERE lot_id = ?
|
||||||
""", (new_qty, 1 if new_qty == 0 else 0, lot_id))
|
""", (new_qty, 1 if new_qty == 0 else 0, lot_id))
|
||||||
|
|
||||||
# 재고 원장 기록
|
# 재고 원장 기록
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
INSERT INTO stock_ledger (event_type, herb_item_id, lot_id,
|
||||||
quantity_delta, unit_cost_per_g,
|
quantity_delta, unit_cost_per_g,
|
||||||
reference_table, reference_id)
|
reference_table, reference_id)
|
||||||
VALUES ('CONSUME', ?, ?, ?, ?, 'compounds', ?)
|
VALUES ('CONSUME', ?, ?, ?, ?, 'compounds', ?)
|
||||||
""", (herb_item_id, lot_id, -used, unit_price, compound_id))
|
""", (herb_item_id, lot_id, -used, unit_price, compound_id))
|
||||||
|
|
||||||
remaining_qty -= used
|
remaining_qty -= used
|
||||||
|
|
||||||
if remaining_qty > 0:
|
# 수동 배분이 아닌 경우에만 재고 부족 체크
|
||||||
|
if remaining_qty > 0 and not ('lot_assignments' in ingredient and ingredient['lot_assignments']):
|
||||||
raise Exception(f"재고 부족: {ingredient.get('herb_name', herb_item_id)}")
|
raise Exception(f"재고 부족: {ingredient.get('herb_name', herb_item_id)}")
|
||||||
|
|
||||||
# 총 원가 업데이트
|
# 총 원가 업데이트
|
||||||
|
|||||||
167
docs/복합_로트_사용_분석.md
Normal file
167
docs/복합_로트_사용_분석.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# 복합 로트 사용 기능 구현 분석
|
||||||
|
|
||||||
|
## 1. 현재 시스템 구조
|
||||||
|
|
||||||
|
### 1.1 좋은 소식 - 이미 지원 가능한 구조
|
||||||
|
현재 `compound_consumptions` 테이블은 **이미 복합 로트를 지원할 수 있는 구조**입니다:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
compound_consumptions (
|
||||||
|
consumption_id INTEGER PRIMARY KEY,
|
||||||
|
compound_id INTEGER,
|
||||||
|
herb_item_id INTEGER,
|
||||||
|
lot_id INTEGER,
|
||||||
|
quantity_used REAL,
|
||||||
|
unit_cost_per_g REAL,
|
||||||
|
cost_amount REAL
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**핵심 포인트:**
|
||||||
|
- 한 조제(`compound_id`)에서 같은 약재(`herb_item_id`)에 대해 여러 레코드 생성 가능
|
||||||
|
- 각 레코드는 다른 `lot_id`를 가질 수 있음
|
||||||
|
- 즉, **DB 구조 변경 없이** 복합 로트 사용 가능
|
||||||
|
|
||||||
|
### 1.2 현재 백엔드 로직
|
||||||
|
`app.py`의 조제 생성 로직을 보면:
|
||||||
|
```python
|
||||||
|
# 이미 FIFO 방식으로 여러 로트를 순차 소비하는 로직이 구현되어 있음
|
||||||
|
for lot in available_lots:
|
||||||
|
lot_id = lot[0]
|
||||||
|
available = lot[1]
|
||||||
|
unit_price = lot[2]
|
||||||
|
|
||||||
|
used = min(remaining_qty, available)
|
||||||
|
|
||||||
|
# 각 로트별로 별도의 소비 레코드 생성
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO compound_consumptions (compound_id, herb_item_id, lot_id,
|
||||||
|
quantity_used, unit_cost_per_g, cost_amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", (compound_id, herb_item_id, lot_id, used, unit_price, cost))
|
||||||
|
```
|
||||||
|
|
||||||
|
**즉, 백엔드는 이미 복합 로트를 지원하고 있습니다!**
|
||||||
|
|
||||||
|
## 2. 필요한 개선 사항
|
||||||
|
|
||||||
|
### 2.1 프론트엔드 UI/UX 개선
|
||||||
|
현재 문제는 **프론트엔드에서 복합 로트 선택을 지원하지 않는 것**입니다.
|
||||||
|
|
||||||
|
#### 현재 상태:
|
||||||
|
- 약재별로 "자동 선택" 또는 단일 원산지/로트만 선택 가능
|
||||||
|
- 수동으로 여러 로트를 조합할 수 없음
|
||||||
|
|
||||||
|
#### 개선 방안:
|
||||||
|
1. **자동 모드 (현재 구현됨)**
|
||||||
|
- FIFO 방식으로 자동 할당
|
||||||
|
- 재고가 부족하면 다음 로트에서 자동 보충
|
||||||
|
|
||||||
|
2. **수동 모드 (구현 필요)**
|
||||||
|
- 약재별로 "로트 배분" 버튼 추가
|
||||||
|
- 모달 창에서 사용 가능한 로트 목록 표시
|
||||||
|
- 각 로트별 사용량 수동 입력
|
||||||
|
- 예: 로트A 40g + 로트B 60g = 총 100g
|
||||||
|
|
||||||
|
### 2.2 API 개선
|
||||||
|
현재 API 구조:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"herb_item_id": 52,
|
||||||
|
"grams_per_cheop": 4.8,
|
||||||
|
"origin": "auto", // 또는 특정 원산지
|
||||||
|
"lot_assignments": [] // 현재 미사용
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
개선된 API 구조:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"herb_item_id": 52,
|
||||||
|
"grams_per_cheop": 4.8,
|
||||||
|
"origin": "auto" | "manual",
|
||||||
|
"lot_assignments": [ // manual일 때 사용
|
||||||
|
{"lot_id": 123, "quantity": 40},
|
||||||
|
{"lot_id": 124, "quantity": 60}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 구현 방안
|
||||||
|
|
||||||
|
### 3.1 최소 변경 방안 (권장)
|
||||||
|
**DB 스키마 변경 없이** 프론트엔드와 백엔드 로직만 개선:
|
||||||
|
|
||||||
|
1. **백엔드 (app.py)**
|
||||||
|
- `origin: "manual"`일 때 `lot_assignments` 배열 처리
|
||||||
|
- 지정된 로트별로 소비 처리
|
||||||
|
- 검증: 총량 일치 확인, 재고 충분 확인
|
||||||
|
|
||||||
|
2. **프론트엔드 (app.js)**
|
||||||
|
- 로트 배분 모달 추가
|
||||||
|
- 사용 가능 로트 목록 표시 (재고, 단가, 원산지 정보)
|
||||||
|
- 로트별 사용량 입력 UI
|
||||||
|
- 실시간 합계 및 검증
|
||||||
|
|
||||||
|
### 3.2 영향도 분석
|
||||||
|
|
||||||
|
#### 영향 없는 부분:
|
||||||
|
- ✅ DB 스키마 (변경 불필요)
|
||||||
|
- ✅ 재고 관리 로직
|
||||||
|
- ✅ 원가 계산 로직
|
||||||
|
- ✅ 입출고 원장
|
||||||
|
- ✅ 조제 내역 조회
|
||||||
|
|
||||||
|
#### 수정 필요한 부분:
|
||||||
|
- ⚠️ 조제 생성 API (`/api/compounds` POST)
|
||||||
|
- ⚠️ 프론트엔드 조제 화면
|
||||||
|
- ⚠️ 로트 가용성 확인 API (수동 모드 지원)
|
||||||
|
|
||||||
|
## 4. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 백엔드 지원 (필수)
|
||||||
|
1. API에서 `lot_assignments` 처리 로직 추가
|
||||||
|
2. 수동 로트 배분 검증 로직
|
||||||
|
3. 트랜잭션 안전성 확보
|
||||||
|
|
||||||
|
### Phase 2: 프론트엔드 기본 (필수)
|
||||||
|
1. 로트 배분 모달 UI
|
||||||
|
2. 수동 입력 폼
|
||||||
|
3. 실시간 검증 및 피드백
|
||||||
|
|
||||||
|
### Phase 3: UX 개선 (선택)
|
||||||
|
1. 드래그 앤 드롭으로 로트 배분
|
||||||
|
2. 자동 최적화 제안 (단가 최소화, FIFO 등)
|
||||||
|
3. 로트 배분 히스토리 저장 및 재사용
|
||||||
|
|
||||||
|
## 5. 예상 시나리오
|
||||||
|
|
||||||
|
### 시나리오 1: 재고 부족으로 인한 복합 사용
|
||||||
|
- 감초 100g 필요
|
||||||
|
- 로트A(한국산): 40g 재고, 20원/g
|
||||||
|
- 로트B(중국산): 70g 재고, 15원/g
|
||||||
|
- 수동 배분: 로트A 40g + 로트B 60g
|
||||||
|
|
||||||
|
### 시나리오 2: 원가 최적화
|
||||||
|
- 당귀 100g 필요
|
||||||
|
- 로트A(구재고): 80g, 10원/g
|
||||||
|
- 로트B(신재고): 50g, 15원/g
|
||||||
|
- 원가 최적화: 로트A 80g + 로트B 20g
|
||||||
|
|
||||||
|
### 시나리오 3: 품질 균일성
|
||||||
|
- 인삼 100g 필요
|
||||||
|
- 같은 원산지의 다른 로트들 조합
|
||||||
|
- 품질 일관성 유지
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
**좋은 소식: 현재 시스템은 이미 복합 로트를 지원할 수 있는 구조입니다!**
|
||||||
|
|
||||||
|
- DB 스키마 변경 불필요
|
||||||
|
- 백엔드는 일부 로직 추가만 필요
|
||||||
|
- 주로 프론트엔드 UI/UX 개선이 필요
|
||||||
|
|
||||||
|
**구현 난이도: 중간**
|
||||||
|
- 기존 시스템에 미치는 영향 최소
|
||||||
|
- 점진적 구현 가능 (자동 모드는 이미 작동 중)
|
||||||
|
- 수동 모드는 선택적 기능으로 추가 가능
|
||||||
211
static/app.js
211
static/app.js
@ -3,6 +3,14 @@
|
|||||||
// 원래 처방 구성 저장용 전역 변수
|
// 원래 처방 구성 저장용 전역 변수
|
||||||
let originalFormulaIngredients = {};
|
let originalFormulaIngredients = {};
|
||||||
|
|
||||||
|
// 로트 배분 관련 전역 변수
|
||||||
|
let currentLotAllocation = {
|
||||||
|
herbId: null,
|
||||||
|
requiredQty: 0,
|
||||||
|
row: null,
|
||||||
|
data: null
|
||||||
|
};
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
// 페이지 네비게이션
|
// 페이지 네비게이션
|
||||||
$('.sidebar .nav-link').on('click', function(e) {
|
$('.sidebar .nav-link').on('click', function(e) {
|
||||||
@ -975,22 +983,8 @@ $(document).ready(function() {
|
|||||||
$('#compoundEntryForm').on('submit', function(e) {
|
$('#compoundEntryForm').on('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const ingredients = [];
|
// getIngredientDataForCompound 함수 사용하여 lot_assignments 포함
|
||||||
$('#compoundIngredients tr').each(function() {
|
const ingredients = getIngredientDataForCompound();
|
||||||
const herbId = $(this).data('herb-id');
|
|
||||||
const gramsPerCheop = parseFloat($(this).find('.grams-per-cheop').val());
|
|
||||||
const totalGrams = parseFloat($(this).find('.total-grams').text());
|
|
||||||
const originCountry = $(this).find('.origin-select').val();
|
|
||||||
|
|
||||||
if (herbId && gramsPerCheop) {
|
|
||||||
ingredients.push({
|
|
||||||
herb_item_id: parseInt(herbId),
|
|
||||||
grams_per_cheop: gramsPerCheop,
|
|
||||||
total_grams: totalGrams,
|
|
||||||
origin_country: originCountry || null // 원산지 선택 정보 추가
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const compoundData = {
|
const compoundData = {
|
||||||
patient_id: $('#compoundPatient').val() ? parseInt($('#compoundPatient').val()) : null,
|
patient_id: $('#compoundPatient').val() ? parseInt($('#compoundPatient').val()) : null,
|
||||||
@ -1834,6 +1828,12 @@ $(document).ready(function() {
|
|||||||
} else {
|
} else {
|
||||||
selectElement.append('<option value="auto">자동 선택 (저렴한 것부터)</option>');
|
selectElement.append('<option value="auto">자동 선택 (저렴한 것부터)</option>');
|
||||||
|
|
||||||
|
// 로트가 2개 이상인 경우 수동 배분 옵션 추가
|
||||||
|
const totalLots = origins.reduce((sum, o) => sum + o.lot_count, 0);
|
||||||
|
if (totalLots > 1) {
|
||||||
|
selectElement.append('<option value="manual">수동 배분 (로트별 지정)</option>');
|
||||||
|
}
|
||||||
|
|
||||||
origins.forEach(origin => {
|
origins.forEach(origin => {
|
||||||
const stockStatus = origin.total_quantity >= requiredQty ? '' : ' (재고 부족)';
|
const stockStatus = origin.total_quantity >= requiredQty ? '' : ' (재고 부족)';
|
||||||
const priceInfo = `${formatCurrency(origin.min_price)}/g`;
|
const priceInfo = `${formatCurrency(origin.min_price)}/g`;
|
||||||
@ -1865,6 +1865,20 @@ $(document).ready(function() {
|
|||||||
|
|
||||||
selectElement.prop('disabled', false);
|
selectElement.prop('disabled', false);
|
||||||
|
|
||||||
|
// 원산지 선택 변경 이벤트 (수동 배분 모달 트리거)
|
||||||
|
selectElement.off('change').on('change', function() {
|
||||||
|
const selectedValue = $(this).val();
|
||||||
|
const row = $(this).closest('tr');
|
||||||
|
|
||||||
|
if (selectedValue === 'manual') {
|
||||||
|
// 수동 배분 모달 열기
|
||||||
|
openLotAllocationModal(herbId, requiredQty, row, response.data);
|
||||||
|
} else {
|
||||||
|
// 기존 자동/원산지 선택 - lot_assignments 제거
|
||||||
|
row.removeAttr('data-lot-assignments');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 재고 상태 업데이트
|
// 재고 상태 업데이트
|
||||||
const totalAvailable = response.data.total_quantity;
|
const totalAvailable = response.data.total_quantity;
|
||||||
const statusElement = $(`tr[data-herb-id="${herbId}"] .stock-status`);
|
const statusElement = $(`tr[data-herb-id="${herbId}"] .stock-status`);
|
||||||
@ -2533,4 +2547,169 @@ $(document).ready(function() {
|
|||||||
$('.nav-link[data-page="herbs"]').on('click', function() {
|
$('.nav-link[data-page="herbs"]').on('click', function() {
|
||||||
setTimeout(() => loadHerbMasters(), 100);
|
setTimeout(() => loadHerbMasters(), 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 로트 배분 모달 관련 함수들 ====================
|
||||||
|
|
||||||
|
// 로트 배분 모달 열기
|
||||||
|
window.openLotAllocationModal = function(herbId, requiredQty, row, data) {
|
||||||
|
currentLotAllocation = {
|
||||||
|
herbId: herbId,
|
||||||
|
requiredQty: requiredQty,
|
||||||
|
row: row,
|
||||||
|
data: data
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 초기화
|
||||||
|
$('#lotAllocationHerbName').text(data.herb_name);
|
||||||
|
$('#lotAllocationRequired').text(requiredQty.toFixed(1));
|
||||||
|
$('#lotAllocationError').addClass('d-none');
|
||||||
|
|
||||||
|
// 로트 목록 생성
|
||||||
|
const tbody = $('#lotAllocationList');
|
||||||
|
tbody.empty();
|
||||||
|
|
||||||
|
// 모든 로트를 하나의 목록으로 표시
|
||||||
|
let allLots = [];
|
||||||
|
data.origins.forEach(origin => {
|
||||||
|
origin.lots.forEach(lot => {
|
||||||
|
allLots.push({
|
||||||
|
...lot,
|
||||||
|
origin: origin.origin_country
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 단가 순으로 정렬
|
||||||
|
allLots.sort((a, b) => a.unit_price_per_g - b.unit_price_per_g);
|
||||||
|
|
||||||
|
allLots.forEach(lot => {
|
||||||
|
tbody.append(`
|
||||||
|
<tr data-lot-id="${lot.lot_id}">
|
||||||
|
<td>#${lot.lot_id}</td>
|
||||||
|
<td>${lot.origin || '미지정'}</td>
|
||||||
|
<td>${lot.quantity_onhand.toFixed(1)}g</td>
|
||||||
|
<td>${formatCurrency(lot.unit_price_per_g)}/g</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control form-control-sm lot-allocation-input"
|
||||||
|
min="0" max="${lot.quantity_onhand}" step="0.1" value="0"
|
||||||
|
data-max="${lot.quantity_onhand}" data-price="${lot.unit_price_per_g}">
|
||||||
|
</td>
|
||||||
|
<td class="lot-subtotal">0원</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 입력 이벤트
|
||||||
|
$('.lot-allocation-input').on('input', function() {
|
||||||
|
updateLotAllocationSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#lotAllocationModal').modal('show');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로트 배분 합계 업데이트
|
||||||
|
function updateLotAllocationSummary() {
|
||||||
|
let totalQty = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
|
||||||
|
$('.lot-allocation-input').each(function() {
|
||||||
|
const qty = parseFloat($(this).val()) || 0;
|
||||||
|
const price = parseFloat($(this).data('price')) || 0;
|
||||||
|
const subtotal = qty * price;
|
||||||
|
|
||||||
|
totalQty += qty;
|
||||||
|
totalCost += subtotal;
|
||||||
|
|
||||||
|
// 소계 표시
|
||||||
|
$(this).closest('tr').find('.lot-subtotal').text(formatCurrency(subtotal) + '원');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#lotAllocationTotal').text(totalQty.toFixed(1));
|
||||||
|
$('#lotAllocationSumQty').text(totalQty.toFixed(1) + 'g');
|
||||||
|
$('#lotAllocationSumCost').text(formatCurrency(totalCost) + '원');
|
||||||
|
|
||||||
|
// 검증
|
||||||
|
const required = currentLotAllocation.requiredQty;
|
||||||
|
const diff = Math.abs(totalQty - required);
|
||||||
|
|
||||||
|
if (diff > 0.01) {
|
||||||
|
$('#lotAllocationError')
|
||||||
|
.removeClass('d-none')
|
||||||
|
.text(`필요량(${required.toFixed(1)}g)과 배분 합계(${totalQty.toFixed(1)}g)가 일치하지 않습니다.`);
|
||||||
|
$('#lotAllocationConfirmBtn').prop('disabled', true);
|
||||||
|
} else {
|
||||||
|
$('#lotAllocationError').addClass('d-none');
|
||||||
|
$('#lotAllocationConfirmBtn').prop('disabled', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 배분
|
||||||
|
$('#lotAllocationAutoBtn').on('click', function() {
|
||||||
|
let remaining = currentLotAllocation.requiredQty;
|
||||||
|
|
||||||
|
$('.lot-allocation-input').each(function() {
|
||||||
|
const maxAvailable = parseFloat($(this).data('max'));
|
||||||
|
const allocate = Math.min(remaining, maxAvailable);
|
||||||
|
|
||||||
|
$(this).val(allocate.toFixed(1));
|
||||||
|
remaining -= allocate;
|
||||||
|
|
||||||
|
if (remaining <= 0) return false; // break
|
||||||
|
});
|
||||||
|
|
||||||
|
updateLotAllocationSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로트 배분 확인
|
||||||
|
$('#lotAllocationConfirmBtn').on('click', function() {
|
||||||
|
const allocations = [];
|
||||||
|
|
||||||
|
$('.lot-allocation-input').each(function() {
|
||||||
|
const qty = parseFloat($(this).val()) || 0;
|
||||||
|
if (qty > 0) {
|
||||||
|
const lotId = $(this).closest('tr').data('lot-id');
|
||||||
|
allocations.push({
|
||||||
|
lot_id: lotId,
|
||||||
|
quantity: qty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 행에 로트 배분 정보 저장
|
||||||
|
currentLotAllocation.row.attr('data-lot-assignments', JSON.stringify(allocations));
|
||||||
|
|
||||||
|
// 상태 표시 업데이트
|
||||||
|
const statusElement = currentLotAllocation.row.find('.stock-status');
|
||||||
|
statusElement.html(`<span class="text-info">수동 배분 (${allocations.length}개 로트)</span>`);
|
||||||
|
|
||||||
|
$('#lotAllocationModal').modal('hide');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 조제 실행 시 lot_assignments 포함
|
||||||
|
window.getIngredientDataForCompound = function() {
|
||||||
|
const ingredients = [];
|
||||||
|
|
||||||
|
$('#compoundIngredients tr').each(function() {
|
||||||
|
const herbId = $(this).attr('data-herb-id');
|
||||||
|
if (herbId) {
|
||||||
|
const ingredient = {
|
||||||
|
herb_item_id: parseInt(herbId),
|
||||||
|
grams_per_cheop: parseFloat($(this).find('.grams-per-cheop').val()) || 0,
|
||||||
|
total_grams: parseFloat($(this).find('.total-grams').text()) || 0,
|
||||||
|
origin: $(this).find('.origin-select').val() || 'auto'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수동 로트 배분이 있는 경우
|
||||||
|
const lotAssignments = $(this).attr('data-lot-assignments');
|
||||||
|
if (lotAssignments) {
|
||||||
|
ingredient.lot_assignments = JSON.parse(lotAssignments);
|
||||||
|
ingredient.origin = 'manual'; // origin을 manual로 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredients.push(ingredient);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ingredients;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
@ -1226,9 +1226,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 로트 배분 모달 -->
|
||||||
|
<div class="modal fade" id="lotAllocationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-shuffle"></i> 로트 배분
|
||||||
|
<span id="lotAllocationHerbName" class="text-primary"></span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>필요량: <span id="lotAllocationRequired">0</span>g</strong>
|
||||||
|
<span class="float-end">배분 합계: <span id="lotAllocationTotal" class="fw-bold">0</span>g</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>로트 번호</th>
|
||||||
|
<th>원산지</th>
|
||||||
|
<th>재고량</th>
|
||||||
|
<th>단가</th>
|
||||||
|
<th>사용량</th>
|
||||||
|
<th>소계</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="lotAllocationList">
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="table-secondary">
|
||||||
|
<tr>
|
||||||
|
<th colspan="4" class="text-end">합계:</th>
|
||||||
|
<th id="lotAllocationSumQty">0g</th>
|
||||||
|
<th id="lotAllocationSumCost">0원</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="lotAllocationError" class="alert alert-danger d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="button" class="btn btn-warning" id="lotAllocationAutoBtn">
|
||||||
|
<i class="bi bi-magic"></i> 자동 배분
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="lotAllocationConfirmBtn">
|
||||||
|
<i class="bi bi-check"></i> 확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260215"></script>
|
<script src="/static/app.js?v=20260217"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
112
test_multi_lot_compound.py
Normal file
112
test_multi_lot_compound.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
복합 로트 사용 E2E 테스트
|
||||||
|
- 당귀 2개 로트를 수동 배분하여 커스텀 조제 테스트
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:5001"
|
||||||
|
|
||||||
|
def test_multi_lot_compound():
|
||||||
|
print("=== 복합 로트 사용 E2E 테스트 시작 ===\n")
|
||||||
|
|
||||||
|
# 1. 당귀 재고 현황 확인
|
||||||
|
print("1. 당귀(휴먼일당귀) 재고 현황 확인")
|
||||||
|
response = requests.get(f"{BASE_URL}/api/herbs/63/available-lots")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()['data']
|
||||||
|
print(f" - 약재명: {data['herb_name']}")
|
||||||
|
print(f" - 총 재고: {data['total_quantity']}g")
|
||||||
|
|
||||||
|
for origin in data['origins']:
|
||||||
|
print(f"\n [{origin['origin_country']}] 로트 {origin['lot_count']}개, 총 {origin['total_quantity']}g")
|
||||||
|
for lot in origin['lots']:
|
||||||
|
print(f" - 로트 #{lot['lot_id']}: {lot['quantity_onhand']}g @ {lot['unit_price_per_g']}원/g")
|
||||||
|
else:
|
||||||
|
print(f" ❌ 오류: {response.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 커스텀 조제 생성 (당귀 100g 필요)
|
||||||
|
print("\n2. 커스텀 조제 생성 - 당귀 100g를 2개 로트로 수동 배분")
|
||||||
|
|
||||||
|
compound_data = {
|
||||||
|
"patient_id": 1, # 테스트 환자
|
||||||
|
"formula_id": None, # 커스텀 조제
|
||||||
|
"je_count": 1,
|
||||||
|
"cheop_total": 1,
|
||||||
|
"pouch_total": 1,
|
||||||
|
"ingredients": [
|
||||||
|
{
|
||||||
|
"herb_item_id": 63, # 휴먼일당귀
|
||||||
|
"grams_per_cheop": 100.0,
|
||||||
|
"total_grams": 100.0, # total_grams 추가
|
||||||
|
"origin": "manual", # 수동 배분
|
||||||
|
"lot_assignments": [
|
||||||
|
{"lot_id": 208, "quantity": 60.0}, # 중국산 60g
|
||||||
|
{"lot_id": 219, "quantity": 40.0} # 한국산 40g
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
print(" - 로트 배분:")
|
||||||
|
print(" * 로트 #208 (중국산): 60g")
|
||||||
|
print(" * 로트 #219 (한국산): 40g")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{BASE_URL}/api/compounds",
|
||||||
|
json=compound_data,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
if result.get('success'):
|
||||||
|
compound_id = result.get('compound_id')
|
||||||
|
total_cost = result.get('total_cost')
|
||||||
|
print(f"\n ✅ 조제 성공!")
|
||||||
|
print(f" - 조제 ID: {compound_id}")
|
||||||
|
print(f" - 총 원가: {total_cost}원")
|
||||||
|
|
||||||
|
# 3. 조제 상세 확인
|
||||||
|
print("\n3. 조제 상세 정보 확인")
|
||||||
|
response = requests.get(f"{BASE_URL}/api/compounds/{compound_id}")
|
||||||
|
if response.status_code == 200:
|
||||||
|
detail = response.json()['data']
|
||||||
|
|
||||||
|
print(" - 소비 내역:")
|
||||||
|
for con in detail.get('consumptions', []):
|
||||||
|
print(f" * 로트 #{con['lot_id']}: {con['quantity_used']}g @ {con['unit_cost_per_g']}원/g = {con['cost_amount']}원")
|
||||||
|
|
||||||
|
# 4. 재고 변동 확인
|
||||||
|
print("\n4. 재고 변동 확인")
|
||||||
|
response = requests.get(f"{BASE_URL}/api/herbs/63/available-lots")
|
||||||
|
if response.status_code == 200:
|
||||||
|
after_data = response.json()['data']
|
||||||
|
print(" - 조제 후 재고:")
|
||||||
|
for origin in after_data['origins']:
|
||||||
|
for lot in origin['lots']:
|
||||||
|
if lot['lot_id'] in [208, 219]:
|
||||||
|
print(f" * 로트 #{lot['lot_id']} ({origin['origin_country']}): {lot['quantity_onhand']}g")
|
||||||
|
|
||||||
|
print("\n✅ 복합 로트 사용 테스트 성공!")
|
||||||
|
print(" - 2개의 로트를 수동으로 배분하여 조제")
|
||||||
|
print(" - 각 로트별 재고가 정확히 차감됨")
|
||||||
|
print(" - 소비 내역이 올바르게 기록됨")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f" ❌ 상세 조회 실패: {response.status_code}")
|
||||||
|
else:
|
||||||
|
print(f" ❌ 조제 실패: {result.get('error')}")
|
||||||
|
else:
|
||||||
|
print(f" ❌ API 호출 실패: {response.status_code}")
|
||||||
|
print(f" 응답: {response.text}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
test_multi_lot_compound()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 테스트 중 오류 발생: {e}")
|
||||||
Loading…
Reference in New Issue
Block a user