diff --git a/add_test_dangui_lot.py b/add_test_dangui_lot.py new file mode 100644 index 0000000..49d5ffa --- /dev/null +++ b/add_test_dangui_lot.py @@ -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() \ No newline at end of file diff --git a/app.py b/app.py index bbbadb6..07ac3d4 100644 --- a/app.py +++ b/app.py @@ -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)}") # 총 원가 업데이트 diff --git a/docs/복합_로트_사용_분석.md b/docs/복합_로트_사용_분석.md new file mode 100644 index 0000000..d27f07f --- /dev/null +++ b/docs/복합_로트_사용_분석.md @@ -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 개선이 필요 + +**구현 난이도: 중간** +- 기존 시스템에 미치는 영향 최소 +- 점진적 구현 가능 (자동 모드는 이미 작동 중) +- 수동 모드는 선택적 기능으로 추가 가능 \ No newline at end of file diff --git a/static/app.js b/static/app.js index 8f7bf56..4bf5ba4 100644 --- a/static/app.js +++ b/static/app.js @@ -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(''); + // 로트가 2개 이상인 경우 수동 배분 옵션 추가 + const totalLots = origins.reduce((sum, o) => sum + o.lot_count, 0); + if (totalLots > 1) { + selectElement.append(''); + } + 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(` + + #${lot.lot_id} + ${lot.origin || '미지정'} + ${lot.quantity_onhand.toFixed(1)}g + ${formatCurrency(lot.unit_price_per_g)}/g + + + + 0원 + + `); + }); + + // 입력 이벤트 + $('.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(`수동 배분 (${allocations.length}개 로트)`); + + $('#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; + }; }); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 96b4bb6..36a88f5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1226,9 +1226,63 @@ + + + - + \ No newline at end of file diff --git a/test_multi_lot_compound.py b/test_multi_lot_compound.py new file mode 100644 index 0000000..f9052c3 --- /dev/null +++ b/test_multi_lot_compound.py @@ -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}") \ No newline at end of file