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

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;
};
});