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:
211
static/app.js
211
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('<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;
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user