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:
시골약사 2026-02-17 02:16:02 +00:00
parent 6ad8bac5c2
commit 0f40cdfba7
6 changed files with 717 additions and 66 deletions

81
add_test_dangui_lot.py Normal file
View 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
View File

@ -1308,67 +1308,125 @@ def create_compound():
ingredient['grams_per_cheop'], total_grams,
modification_type, original_grams))
# 재고 차감 (FIFO 방식 - 원산지 지정 시 해당 원산지만)
# 재고 차감 처리
remaining_qty = total_grams
# 원산지가 지정된 경우 해당 원산지만, 아니면 전체에서 FIFO
if origin_country and origin_country != 'auto':
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
AND origin_country = ?
ORDER BY unit_price_per_g, received_date, lot_id
""", (herb_item_id, origin_country))
# 수동 로트 배분이 있는 경우 (lot_assignments 배열 사용)
if 'lot_assignments' in ingredient and ingredient['lot_assignments']:
# 수동 로트 배분 검증
assigned_total = sum(la['quantity'] for la in ingredient['lot_assignments'])
if abs(assigned_total - total_grams) > 0.01:
raise ValueError(f"로트 배분 합계({assigned_total}g)와 필요량({total_grams}g)이 일치하지 않습니다")
# 각 로트별로 처리
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:
# 자동 선택: 가격이 저렴한 것부터
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,))
# 원산지가 지정된 경우 해당 원산지만, 아니면 전체에서 FIFO
if origin_country and origin_country != 'auto':
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
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:
if remaining_qty <= 0:
break
for lot in lots:
if remaining_qty <= 0:
break
lot_id = lot[0]
available = lot[1]
unit_price = lot[2]
lot_id = lot[0]
available = lot[1]
unit_price = lot[2]
used = min(remaining_qty, available)
cost = used * unit_price
total_cost += cost
used = min(remaining_qty, available)
cost = used * 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, used, unit_price, 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, used, unit_price, cost))
# 로트 재고 감소
new_qty = available - used
cursor.execute("""
UPDATE inventory_lots
SET quantity_onhand = ?, is_depleted = ?
WHERE lot_id = ?
""", (new_qty, 1 if new_qty == 0 else 0, lot_id))
# 로트 재고 감소
new_qty = available - used
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, -used, unit_price, compound_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, -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)}")
# 총 원가 업데이트

View 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 개선이 필요
**구현 난이도: 중간**
- 기존 시스템에 미치는 영향 최소
- 점진적 구현 가능 (자동 모드는 이미 작동 중)
- 수동 모드는 선택적 기능으로 추가 가능

View File

@ -3,6 +3,14 @@
// 원래 처방 구성 저장용 전역 변수
let originalFormulaIngredients = {};
// 로트 배분 관련 전역 변수
let currentLotAllocation = {
herbId: null,
requiredQty: 0,
row: null,
data: null
};
$(document).ready(function() {
// 페이지 네비게이션
$('.sidebar .nav-link').on('click', function(e) {
@ -975,22 +983,8 @@ $(document).ready(function() {
$('#compoundEntryForm').on('submit', function(e) {
e.preventDefault();
const ingredients = [];
$('#compoundIngredients tr').each(function() {
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 // 원산지 선택 정보 추가
});
}
});
// getIngredientDataForCompound 함수 사용하여 lot_assignments 포함
const ingredients = getIngredientDataForCompound();
const compoundData = {
patient_id: $('#compoundPatient').val() ? parseInt($('#compoundPatient').val()) : null,
@ -1834,6 +1828,12 @@ $(document).ready(function() {
} else {
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 => {
const stockStatus = origin.total_quantity >= requiredQty ? '' : ' (재고 부족)';
const priceInfo = `${formatCurrency(origin.min_price)}/g`;
@ -1865,6 +1865,20 @@ $(document).ready(function() {
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 statusElement = $(`tr[data-herb-id="${herbId}"] .stock-status`);
@ -2533,4 +2547,169 @@ $(document).ready(function() {
$('.nav-link[data-page="herbs"]').on('click', function() {
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;
};
});

View File

@ -1226,9 +1226,63 @@
</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 -->
<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="/static/app.js?v=20260215"></script>
<script src="/static/app.js?v=20260217"></script>
</body>
</html>

112
test_multi_lot_compound.py Normal file
View 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}")