feat: 직접조제 및 원산지별 재고 관리 기능 강화
API 개선: - /api/herbs, /api/inventory/summary에 효능 태그 추가 - 조제 시 원산지 선택 처리 로직 추가 - 원산지별 FIFO 또는 자동 선택 (저렴한 것부터) UI 개선: - 재고 목록에 효능 태그 표시 (녹색 배지) - 처방 선택에 "직접조제" 옵션 추가 - 조제 시 원산지 선택 드롭다운 추가 - JavaScript 주석 블록 오류 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
348
static/app.js
348
static/app.js
@@ -32,6 +32,7 @@ $(document).ready(function() {
|
||||
break;
|
||||
case 'purchase':
|
||||
loadPurchaseReceipts();
|
||||
loadSuppliersForSelect();
|
||||
break;
|
||||
case 'formulas':
|
||||
loadFormulas();
|
||||
@@ -287,6 +288,15 @@ $(document).ready(function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 직접조제인 경우
|
||||
if (formulaId === 'custom') {
|
||||
$('#compoundIngredients').empty();
|
||||
// 빈 행 하나 추가
|
||||
addEmptyIngredientRow();
|
||||
return;
|
||||
}
|
||||
|
||||
// 등록된 처방인 경우
|
||||
$.get(`/api/formulas/${formulaId}/ingredients`, function(response) {
|
||||
if (response.success) {
|
||||
$('#compoundIngredients').empty();
|
||||
@@ -303,6 +313,11 @@ $(document).ready(function() {
|
||||
value="${ing.grams_per_cheop}" min="0.1" step="0.1">
|
||||
</td>
|
||||
<td class="total-grams">${totalGrams.toFixed(1)}</td>
|
||||
<td class="origin-select-cell">
|
||||
<select class="form-control form-control-sm origin-select" disabled>
|
||||
<option value="">로딩중...</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="stock-status">확인중...</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-compound-ingredient">
|
||||
@@ -311,6 +326,9 @@ $(document).ready(function() {
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
|
||||
// 각 약재별로 원산지별 재고 확인
|
||||
loadOriginOptions(ing.herb_item_id, totalGrams);
|
||||
});
|
||||
|
||||
// 재고 확인
|
||||
@@ -353,6 +371,77 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// 조제 약재 추가
|
||||
// 빈 약재 행 추가 함수
|
||||
function addEmptyIngredientRow() {
|
||||
const newRow = $(`
|
||||
<tr>
|
||||
<td>
|
||||
<select class="form-control form-control-sm herb-select-compound">
|
||||
<option value="">약재 선택</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm grams-per-cheop"
|
||||
min="0.1" step="0.1" placeholder="0.0">
|
||||
</td>
|
||||
<td class="total-grams">0.0</td>
|
||||
<td class="origin-select-cell">
|
||||
<select class="form-control form-control-sm origin-select" disabled>
|
||||
<option value="">약재 선택 후 표시</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="stock-status">-</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-compound-ingredient">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
|
||||
$('#compoundIngredients').append(newRow);
|
||||
|
||||
// 약재 목록 로드
|
||||
loadHerbsForSelect(newRow.find('.herb-select-compound'));
|
||||
|
||||
// 약재 선택 시 원산지 옵션 로드
|
||||
newRow.find('.herb-select-compound').on('change', function() {
|
||||
const herbId = $(this).val();
|
||||
if (herbId) {
|
||||
const row = $(this).closest('tr');
|
||||
row.attr('data-herb-id', herbId);
|
||||
const cheopTotal = parseFloat($('#cheopTotal').val()) || 0;
|
||||
const gramsPerCheop = parseFloat(row.find('.grams-per-cheop').val()) || 0;
|
||||
const totalGrams = gramsPerCheop * cheopTotal;
|
||||
loadOriginOptions(herbId, totalGrams);
|
||||
}
|
||||
});
|
||||
|
||||
// 이벤트 바인딩
|
||||
newRow.find('.grams-per-cheop').on('input', function() {
|
||||
updateIngredientTotals();
|
||||
// 원산지 옵션 다시 로드
|
||||
const herbId = $(this).closest('tr').attr('data-herb-id');
|
||||
if (herbId) {
|
||||
const cheopTotal = parseFloat($('#cheopTotal').val()) || 0;
|
||||
const gramsPerCheop = parseFloat($(this).val()) || 0;
|
||||
const totalGrams = gramsPerCheop * cheopTotal;
|
||||
loadOriginOptions(herbId, totalGrams);
|
||||
}
|
||||
});
|
||||
|
||||
newRow.find('.remove-compound-ingredient').on('click', function() {
|
||||
$(this).closest('tr').remove();
|
||||
updateIngredientTotals();
|
||||
});
|
||||
}
|
||||
|
||||
$('#addIngredientBtn').on('click', function() {
|
||||
addEmptyIngredientRow();
|
||||
});
|
||||
|
||||
// 기존 약재 추가 버튼 (기존 코드 삭제)
|
||||
/*
|
||||
$('#addIngredientBtn').on('click', function() {
|
||||
const newRow = $(`
|
||||
<tr>
|
||||
@@ -391,6 +480,7 @@ $(document).ready(function() {
|
||||
updateIngredientTotals();
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// 조제 실행
|
||||
$('#compoundEntryForm').on('submit', function(e) {
|
||||
@@ -401,12 +491,14 @@ $(document).ready(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
|
||||
total_grams: totalGrams,
|
||||
origin_country: originCountry || null // 원산지 선택 정보 추가
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -452,17 +544,140 @@ $(document).ready(function() {
|
||||
tbody.empty();
|
||||
|
||||
response.data.forEach(item => {
|
||||
// 원산지가 여러 개인 경우 표시
|
||||
const originBadge = item.origin_count > 1
|
||||
? `<span class="badge bg-info ms-2">${item.origin_count}개 원산지</span>`
|
||||
: '';
|
||||
|
||||
// 효능 태그 표시
|
||||
let efficacyTags = '';
|
||||
if (item.efficacy_tags && item.efficacy_tags.length > 0) {
|
||||
efficacyTags = item.efficacy_tags.map(tag =>
|
||||
`<span class="badge bg-success ms-1">${tag}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 가격 범위 표시 (원산지가 여러 개이고 가격차가 있는 경우)
|
||||
let priceDisplay = item.avg_price ? formatCurrency(item.avg_price) : '-';
|
||||
if (item.origin_count > 1 && item.min_price && item.max_price && item.min_price !== item.max_price) {
|
||||
priceDisplay = `${formatCurrency(item.min_price)} ~ ${formatCurrency(item.max_price)}`;
|
||||
}
|
||||
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<tr class="inventory-row" data-herb-id="${item.herb_item_id}" style="cursor: pointer;">
|
||||
<td>${item.insurance_code || '-'}</td>
|
||||
<td>${item.herb_name}</td>
|
||||
<td>${item.herb_name}${originBadge}${efficacyTags}</td>
|
||||
<td>${item.total_quantity.toFixed(1)}</td>
|
||||
<td>${item.lot_count}</td>
|
||||
<td>${item.avg_price ? formatCurrency(item.avg_price) : '-'}</td>
|
||||
<td>${priceDisplay}</td>
|
||||
<td>${formatCurrency(item.total_value)}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 클릭 이벤트 바인딩
|
||||
$('.inventory-row').on('click', function() {
|
||||
const herbId = $(this).data('herb-id');
|
||||
showInventoryDetail(herbId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 재고 상세 모달 표시
|
||||
function showInventoryDetail(herbId) {
|
||||
$.get(`/api/inventory/detail/${herbId}`, function(response) {
|
||||
if (response.success) {
|
||||
const data = response.data;
|
||||
|
||||
// 원산지별 재고 정보 HTML 생성
|
||||
let originsHtml = '';
|
||||
data.origins.forEach(origin => {
|
||||
originsHtml += `
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-geo-alt"></i> ${origin.origin_country}
|
||||
<span class="badge bg-primary float-end">${origin.total_quantity.toFixed(1)}g</span>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-6">
|
||||
<small class="text-muted">평균 단가:</small><br>
|
||||
<strong>${formatCurrency(origin.avg_price)}/g</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted">재고 가치:</small><br>
|
||||
<strong>${formatCurrency(origin.total_value)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>로트ID</th>
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
<th>입고일</th>
|
||||
<th>도매상</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
origin.lots.forEach(lot => {
|
||||
originsHtml += `
|
||||
<tr>
|
||||
<td>#${lot.lot_id}</td>
|
||||
<td>${lot.quantity_onhand.toFixed(1)}g</td>
|
||||
<td>${formatCurrency(lot.unit_price_per_g)}</td>
|
||||
<td>${lot.received_date}</td>
|
||||
<td>${lot.supplier_name || '-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
originsHtml += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// 모달 생성 및 표시
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="inventoryDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
${data.herb_name} 재고 상세
|
||||
<small class="text-muted">(${data.insurance_code})</small>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${data.total_origins > 1
|
||||
? `<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
이 약재는 ${data.total_origins}개 원산지의 재고가 있습니다.
|
||||
조제 시 원산지를 선택할 수 있습니다.
|
||||
</div>`
|
||||
: ''}
|
||||
${originsHtml}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 기존 모달 제거
|
||||
$('#inventoryDetailModal').remove();
|
||||
$('body').append(modalHtml);
|
||||
|
||||
// 모달 표시
|
||||
const modal = new bootstrap.Modal(document.getElementById('inventoryDetailModal'));
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -520,8 +735,8 @@ $(document).ready(function() {
|
||||
<td>${receipt.receipt_date}</td>
|
||||
<td>${receipt.supplier_name}</td>
|
||||
<td>${receipt.line_count}개</td>
|
||||
<td>${receipt.total_quantity ? receipt.total_quantity.toLocaleString() + 'g' : '-'}</td>
|
||||
<td>${receipt.total_amount ? formatCurrency(receipt.total_amount) : '-'}</td>
|
||||
<td class="fw-bold text-primary">${receipt.total_amount ? formatCurrency(receipt.total_amount) : '-'}</td>
|
||||
<td class="text-muted small">${receipt.total_quantity ? receipt.total_quantity.toLocaleString() + 'g' : '-'}</td>
|
||||
<td>${receipt.source_file || '-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info view-receipt" data-id="${receipt.receipt_id}">
|
||||
@@ -638,10 +853,71 @@ $(document).ready(function() {
|
||||
loadPurchaseReceipts();
|
||||
});
|
||||
|
||||
// 도매상 목록 로드 (셀렉트 박스용)
|
||||
function loadSuppliersForSelect() {
|
||||
$.get('/api/suppliers', function(response) {
|
||||
if (response.success) {
|
||||
const select = $('#uploadSupplier');
|
||||
select.empty().append('<option value="">도매상을 선택하세요</option>');
|
||||
|
||||
response.data.forEach(supplier => {
|
||||
select.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`);
|
||||
});
|
||||
|
||||
// 필터용 셀렉트 박스도 업데이트
|
||||
const filterSelect = $('#purchaseSupplier');
|
||||
filterSelect.empty().append('<option value="">전체</option>');
|
||||
response.data.forEach(supplier => {
|
||||
filterSelect.append(`<option value="${supplier.supplier_id}">${supplier.name}</option>`);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 도매상 등록
|
||||
$('#saveSupplierBtn').on('click', function() {
|
||||
const supplierData = {
|
||||
name: $('#supplierName').val(),
|
||||
business_no: $('#supplierBusinessNo').val(),
|
||||
contact_person: $('#supplierContactPerson').val(),
|
||||
phone: $('#supplierPhone').val(),
|
||||
address: $('#supplierAddress').val()
|
||||
};
|
||||
|
||||
if (!supplierData.name) {
|
||||
alert('도매상명은 필수입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/api/suppliers',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(supplierData),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
alert('도매상이 등록되었습니다.');
|
||||
$('#supplierModal').modal('hide');
|
||||
$('#supplierForm')[0].reset();
|
||||
loadSuppliersForSelect();
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('오류: ' + xhr.responseJSON.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 입고장 업로드
|
||||
$('#purchaseUploadForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const supplierId = $('#uploadSupplier').val();
|
||||
if (!supplierId) {
|
||||
alert('도매상을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const fileInput = $('#purchaseFile')[0];
|
||||
|
||||
@@ -651,6 +927,7 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
formData.append('file', fileInput.files[0]);
|
||||
formData.append('supplier_id', supplierId);
|
||||
|
||||
$('#uploadResult').html('<div class="alert alert-info">업로드 중...</div>');
|
||||
|
||||
@@ -731,9 +1008,17 @@ $(document).ready(function() {
|
||||
const select = $('#compoundFormula');
|
||||
select.empty().append('<option value="">처방을 선택하세요</option>');
|
||||
|
||||
response.data.forEach(formula => {
|
||||
select.append(`<option value="${formula.formula_id}">${formula.formula_name}</option>`);
|
||||
});
|
||||
// 직접조제 옵션 추가
|
||||
select.append('<option value="custom">직접조제</option>');
|
||||
|
||||
// 등록된 처방 추가
|
||||
if (response.data.length > 0) {
|
||||
select.append('<optgroup label="등록된 처방">');
|
||||
response.data.forEach(formula => {
|
||||
select.append(`<option value="${formula.formula_id}">${formula.formula_name}</option>`);
|
||||
});
|
||||
select.append('</optgroup>');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -750,6 +1035,51 @@ $(document).ready(function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 원산지별 재고 옵션 로드
|
||||
function loadOriginOptions(herbId, requiredQty) {
|
||||
$.get(`/api/herbs/${herbId}/available-lots`, function(response) {
|
||||
if (response.success) {
|
||||
const selectElement = $(`tr[data-herb-id="${herbId}"] .origin-select`);
|
||||
selectElement.empty();
|
||||
|
||||
const origins = response.data.origins;
|
||||
|
||||
if (origins.length === 0) {
|
||||
selectElement.append('<option value="">재고 없음</option>');
|
||||
selectElement.prop('disabled', true);
|
||||
$(`tr[data-herb-id="${herbId}"] .stock-status`)
|
||||
.html('<span class="text-danger">재고 없음</span>');
|
||||
} else {
|
||||
selectElement.append('<option value="auto">자동 선택 (저렴한 것부터)</option>');
|
||||
|
||||
origins.forEach(origin => {
|
||||
const stockStatus = origin.total_quantity >= requiredQty ? '' : ' (재고 부족)';
|
||||
const priceInfo = `${formatCurrency(origin.min_price)}/g`;
|
||||
const option = `<option value="${origin.origin_country}"
|
||||
data-price="${origin.min_price}"
|
||||
data-available="${origin.total_quantity}"
|
||||
${origin.total_quantity < requiredQty ? 'disabled' : ''}>
|
||||
${origin.origin_country} - ${priceInfo} (재고: ${origin.total_quantity.toFixed(1)}g)${stockStatus}
|
||||
</option>`;
|
||||
selectElement.append(option);
|
||||
});
|
||||
|
||||
selectElement.prop('disabled', false);
|
||||
|
||||
// 재고 상태 업데이트
|
||||
const totalAvailable = response.data.total_quantity;
|
||||
const statusElement = $(`tr[data-herb-id="${herbId}"] .stock-status`);
|
||||
|
||||
if (totalAvailable >= requiredQty) {
|
||||
statusElement.html(`<span class="text-success">충분 (${totalAvailable.toFixed(1)}g)</span>`);
|
||||
} else {
|
||||
statusElement.html(`<span class="text-danger">부족 (${totalAvailable.toFixed(1)}g)</span>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
if (amount === null || amount === undefined) return '0원';
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
|
||||
Reference in New Issue
Block a user