feat: 처방 관리 및 재고 원장 시스템 구현
## 처방 관리 (조제) 기능 - compounds API 추가 (목록/상세/환자별 조회) - 조제 시 자동 재고 차감 (FIFO) - 조제 내역 UI (EMR 스타일) - 조제 상세보기 모달 (처방구성, 재고소비내역) - 오늘/이번달 조제 통계 표시 ## 재고 원장 시스템 - stock-ledger API 구현 - 입출고 내역 실시간 추적 - 재고 현황 페이지 개선 (통계 카드 추가) - 입출고 원장 모달 UI - 약재별/전체 입출고 내역 조회 ## 확인된 동작 - 박주호 환자 오미자 200g 조제 - 재고 2000g → 1800g 정확히 차감 - 모든 입출고 stock_ledger에 기록 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
422
static/app.js
422
static/app.js
@@ -532,8 +532,145 @@ $(document).ready(function() {
|
||||
|
||||
// 조제 내역 로드
|
||||
function loadCompounds() {
|
||||
// TODO: 조제 내역 API 구현 필요
|
||||
$('#compoundsList').html('<tr><td colspan="7" class="text-center">조제 내역이 없습니다.</td></tr>');
|
||||
$.get('/api/compounds', function(response) {
|
||||
const tbody = $('#compoundsList');
|
||||
tbody.empty();
|
||||
|
||||
if (response.success && response.data.length > 0) {
|
||||
// 통계 업데이트
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
|
||||
let todayCount = 0;
|
||||
let monthCount = 0;
|
||||
|
||||
response.data.forEach((compound, index) => {
|
||||
// 통계 계산
|
||||
if (compound.compound_date === today) todayCount++;
|
||||
if (compound.compound_date && compound.compound_date.startsWith(currentMonth)) monthCount++;
|
||||
|
||||
// 상태 뱃지
|
||||
let statusBadge = '';
|
||||
switch(compound.status) {
|
||||
case 'PREPARED':
|
||||
statusBadge = '<span class="badge bg-success">조제완료</span>';
|
||||
break;
|
||||
case 'DISPENSED':
|
||||
statusBadge = '<span class="badge bg-primary">출고완료</span>';
|
||||
break;
|
||||
case 'CANCELLED':
|
||||
statusBadge = '<span class="badge bg-danger">취소</span>';
|
||||
break;
|
||||
default:
|
||||
statusBadge = '<span class="badge bg-secondary">대기</span>';
|
||||
}
|
||||
|
||||
const row = $(`
|
||||
<tr>
|
||||
<td>${response.data.length - index}</td>
|
||||
<td>${compound.compound_date || ''}<br><small class="text-muted">${compound.created_at ? compound.created_at.split(' ')[1] : ''}</small></td>
|
||||
<td><strong>${compound.patient_name || '직접조제'}</strong></td>
|
||||
<td>${compound.patient_phone || '-'}</td>
|
||||
<td>${compound.formula_name || '직접조제'}</td>
|
||||
<td>${compound.je_count || 0}</td>
|
||||
<td>${compound.cheop_total || 0}</td>
|
||||
<td>${compound.pouch_total || 0}</td>
|
||||
<td>${formatCurrency(compound.cost_total || 0)}</td>
|
||||
<td>${formatCurrency(compound.sell_price_total || 0)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${compound.prescription_no || '-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info view-compound-detail" data-id="${compound.compound_id}">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
tbody.append(row);
|
||||
});
|
||||
|
||||
// 통계 업데이트
|
||||
$('#todayCompoundCount').text(todayCount);
|
||||
$('#monthCompoundCount').text(monthCount);
|
||||
|
||||
// 상세보기 버튼 이벤트
|
||||
$('.view-compound-detail').on('click', function() {
|
||||
const compoundId = $(this).data('id');
|
||||
viewCompoundDetail(compoundId);
|
||||
});
|
||||
} else {
|
||||
tbody.html('<tr><td colspan="13" class="text-center text-muted">조제 내역이 없습니다.</td></tr>');
|
||||
$('#todayCompoundCount').text(0);
|
||||
$('#monthCompoundCount').text(0);
|
||||
}
|
||||
}).fail(function() {
|
||||
$('#compoundsList').html('<tr><td colspan="13" class="text-center text-danger">데이터를 불러오는데 실패했습니다.</td></tr>');
|
||||
});
|
||||
}
|
||||
|
||||
// 조제 상세보기
|
||||
function viewCompoundDetail(compoundId) {
|
||||
$.get(`/api/compounds/${compoundId}`, function(response) {
|
||||
if (response.success && response.data) {
|
||||
const data = response.data;
|
||||
|
||||
// 환자 정보
|
||||
$('#detailPatientName').text(data.patient_name || '직접조제');
|
||||
$('#detailPatientPhone').text(data.patient_phone || '-');
|
||||
$('#detailCompoundDate').text(data.compound_date || '-');
|
||||
|
||||
// 처방 정보
|
||||
$('#detailFormulaName').text(data.formula_name || '직접조제');
|
||||
$('#detailPrescriptionNo').text(data.prescription_no || '-');
|
||||
$('#detailQuantities').text(`${data.je_count}제 / ${data.cheop_total}첩 / ${data.pouch_total}파우치`);
|
||||
|
||||
// 처방 구성 약재
|
||||
const ingredientsBody = $('#detailIngredients');
|
||||
ingredientsBody.empty();
|
||||
if (data.ingredients && data.ingredients.length > 0) {
|
||||
data.ingredients.forEach(ing => {
|
||||
ingredientsBody.append(`
|
||||
<tr>
|
||||
<td>${ing.herb_name}</td>
|
||||
<td>${ing.insurance_code || '-'}</td>
|
||||
<td>${ing.grams_per_cheop}g</td>
|
||||
<td>${ing.total_grams}g</td>
|
||||
<td>${ing.notes || '-'}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// 재고 소비 내역
|
||||
const consumptionsBody = $('#detailConsumptions');
|
||||
consumptionsBody.empty();
|
||||
if (data.consumptions && data.consumptions.length > 0) {
|
||||
data.consumptions.forEach(con => {
|
||||
consumptionsBody.append(`
|
||||
<tr>
|
||||
<td>${con.herb_name}</td>
|
||||
<td>${con.origin_country || '-'}</td>
|
||||
<td>${con.supplier_name || '-'}</td>
|
||||
<td>${con.quantity_used}g</td>
|
||||
<td>${formatCurrency(con.unit_cost_per_g)}/g</td>
|
||||
<td>${formatCurrency(con.cost_amount)}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// 총 원가
|
||||
$('#detailTotalCost').text(formatCurrency(data.cost_total || 0));
|
||||
|
||||
// 비고
|
||||
$('#detailNotes').text(data.notes || '');
|
||||
|
||||
// 모달 표시
|
||||
$('#compoundDetailModal').modal('show');
|
||||
}
|
||||
}).fail(function() {
|
||||
alert('조제 상세 정보를 불러오는데 실패했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 재고 현황 로드
|
||||
@@ -543,6 +680,9 @@ $(document).ready(function() {
|
||||
const tbody = $('#inventoryList');
|
||||
tbody.empty();
|
||||
|
||||
let totalValue = 0;
|
||||
let herbsInStock = 0;
|
||||
|
||||
// 주성분코드 기준 보유 현황 표시
|
||||
if (response.summary) {
|
||||
const summary = response.summary;
|
||||
@@ -611,23 +751,48 @@ $(document).ready(function() {
|
||||
priceDisplay = `${formatCurrency(item.min_price)} ~ ${formatCurrency(item.max_price)}`;
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
totalValue += item.total_value || 0;
|
||||
if (item.total_quantity > 0) herbsInStock++;
|
||||
|
||||
tbody.append(`
|
||||
<tr class="inventory-row" data-herb-id="${item.herb_item_id}" style="cursor: pointer;">
|
||||
<tr class="inventory-row" data-herb-id="${item.herb_item_id}">
|
||||
<td>${item.insurance_code || '-'}</td>
|
||||
<td>${item.herb_name}${originBadge}${efficacyTags}</td>
|
||||
<td>${item.total_quantity.toFixed(1)}</td>
|
||||
<td>${item.lot_count}</td>
|
||||
<td>${priceDisplay}</td>
|
||||
<td>${formatCurrency(item.total_value)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info view-stock-ledger" data-herb-id="${item.herb_item_id}" data-herb-name="${item.herb_name}">
|
||||
<i class="bi bi-journal-text"></i> 입출고
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary view-inventory-detail" data-herb-id="${item.herb_item_id}" style="cursor: pointer;">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 통계 업데이트
|
||||
$('#totalInventoryValue').text(formatCurrency(totalValue));
|
||||
$('#totalHerbsInStock').text(`${herbsInStock}종`);
|
||||
|
||||
// 클릭 이벤트 바인딩
|
||||
$('.inventory-row').on('click', function() {
|
||||
$('.view-inventory-detail').on('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const herbId = $(this).data('herb-id');
|
||||
showInventoryDetail(herbId);
|
||||
});
|
||||
|
||||
// 입출고 내역 버튼 이벤트
|
||||
$('.view-stock-ledger').on('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const herbId = $(this).data('herb-id');
|
||||
const herbName = $(this).data('herb-name');
|
||||
viewStockLedger(herbId, herbName);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1128,6 +1293,98 @@ $(document).ready(function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 재고 원장 보기
|
||||
function viewStockLedger(herbId, herbName) {
|
||||
const url = herbId ? `/api/stock-ledger?herb_id=${herbId}` : '/api/stock-ledger';
|
||||
|
||||
$.get(url, function(response) {
|
||||
if (response.success) {
|
||||
const tbody = $('#stockLedgerList');
|
||||
tbody.empty();
|
||||
|
||||
// 헤더 업데이트
|
||||
if (herbName) {
|
||||
$('#stockLedgerModal .modal-title').html(`<i class="bi bi-journal-text"></i> ${herbName} 입출고 원장`);
|
||||
} else {
|
||||
$('#stockLedgerModal .modal-title').html(`<i class="bi bi-journal-text"></i> 전체 입출고 원장`);
|
||||
}
|
||||
|
||||
response.ledger.forEach(entry => {
|
||||
let typeLabel = '';
|
||||
let typeBadge = '';
|
||||
switch(entry.event_type) {
|
||||
case 'PURCHASE':
|
||||
case 'RECEIPT':
|
||||
typeLabel = '입고';
|
||||
typeBadge = 'badge bg-success';
|
||||
break;
|
||||
case 'CONSUME':
|
||||
typeLabel = '출고';
|
||||
typeBadge = 'badge bg-danger';
|
||||
break;
|
||||
default:
|
||||
typeLabel = entry.event_type;
|
||||
typeBadge = 'badge bg-secondary';
|
||||
}
|
||||
|
||||
const quantity = Math.abs(entry.quantity_delta);
|
||||
const sign = entry.quantity_delta > 0 ? '+' : '-';
|
||||
const quantityDisplay = entry.quantity_delta > 0
|
||||
? `<span class="text-success">+${quantity.toFixed(1)}g</span>`
|
||||
: `<span class="text-danger">-${quantity.toFixed(1)}g</span>`;
|
||||
|
||||
const referenceInfo = entry.patient_name
|
||||
? `${entry.patient_name}`
|
||||
: entry.supplier_name || '-';
|
||||
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${entry.event_time}</td>
|
||||
<td><span class="${typeBadge}">${typeLabel}</span></td>
|
||||
<td>${entry.herb_name}</td>
|
||||
<td>${quantityDisplay}</td>
|
||||
<td>${entry.unit_cost_per_g ? formatCurrency(entry.unit_cost_per_g) + '/g' : '-'}</td>
|
||||
<td>${entry.origin_country || '-'}</td>
|
||||
<td>${referenceInfo}</td>
|
||||
<td>${entry.reference_no || '-'}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 약재 필터 옵션 업데이트
|
||||
const herbFilter = $('#ledgerHerbFilter');
|
||||
if (herbFilter.find('option').length <= 1) {
|
||||
response.summary.forEach(herb => {
|
||||
herbFilter.append(`<option value="${herb.herb_item_id}">${herb.herb_name}</option>`);
|
||||
});
|
||||
}
|
||||
|
||||
$('#stockLedgerModal').modal('show');
|
||||
}
|
||||
}).fail(function() {
|
||||
alert('입출고 내역을 불러오는데 실패했습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// 입출고 원장 모달 버튼 이벤트
|
||||
$('#showStockLedgerBtn').on('click', function() {
|
||||
viewStockLedger(null, null);
|
||||
});
|
||||
|
||||
// 필터 변경 이벤트
|
||||
$('#ledgerHerbFilter, #ledgerTypeFilter').on('change', function() {
|
||||
const herbId = $('#ledgerHerbFilter').val();
|
||||
const typeFilter = $('#ledgerTypeFilter').val();
|
||||
|
||||
// 재로드 (필터 적용은 프론트엔드에서 처리)
|
||||
if (herbId) {
|
||||
const herbName = $('#ledgerHerbFilter option:selected').text();
|
||||
viewStockLedger(herbId, herbName);
|
||||
} else {
|
||||
viewStockLedger(null, null);
|
||||
}
|
||||
});
|
||||
|
||||
function formatCurrency(amount) {
|
||||
if (amount === null || amount === undefined) return '0원';
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
@@ -1135,4 +1392,161 @@ $(document).ready(function() {
|
||||
currency: 'KRW'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// ==================== 주성분코드 기반 약재 관리 ====================
|
||||
let allHerbMasters = []; // 전체 약재 데이터 저장
|
||||
let currentFilter = 'all'; // 현재 필터 상태
|
||||
|
||||
// 약재 마스터 목록 로드
|
||||
function loadHerbMasters() {
|
||||
$.get('/api/herbs/masters', function(response) {
|
||||
if (response.success) {
|
||||
allHerbMasters = response.data;
|
||||
|
||||
// 통계 정보 표시
|
||||
const summary = response.summary;
|
||||
$('#herbMasterSummary').html(`
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-9">
|
||||
<h6 class="mb-2">📊 급여 약재 현황</h6>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-4">
|
||||
<strong>전체:</strong> ${summary.total_herbs}개 주성분
|
||||
</div>
|
||||
<div class="me-4">
|
||||
<strong>재고 있음:</strong> <span class="text-success">${summary.herbs_with_stock}개</span>
|
||||
</div>
|
||||
<div class="me-4">
|
||||
<strong>재고 없음:</strong> <span class="text-secondary">${summary.herbs_without_stock}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>보유율:</strong> <span class="badge bg-primary fs-6">${summary.coverage_rate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="progress" style="height: 25px;">
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: ${summary.coverage_rate}%"
|
||||
aria-valuenow="${summary.coverage_rate}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
${summary.herbs_with_stock} / ${summary.total_herbs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// 목록 표시
|
||||
displayHerbMasters(allHerbMasters);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 약재 목록 표시
|
||||
function displayHerbMasters(herbs) {
|
||||
const tbody = $('#herbMastersList');
|
||||
tbody.empty();
|
||||
|
||||
// 필터링
|
||||
let filteredHerbs = herbs;
|
||||
if (currentFilter === 'stock') {
|
||||
filteredHerbs = herbs.filter(h => h.has_stock);
|
||||
} else if (currentFilter === 'no-stock') {
|
||||
filteredHerbs = herbs.filter(h => !h.has_stock);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
const searchText = $('#herbSearch').val().toLowerCase();
|
||||
if (searchText) {
|
||||
filteredHerbs = filteredHerbs.filter(h =>
|
||||
h.herb_name.toLowerCase().includes(searchText) ||
|
||||
h.ingredient_code.toLowerCase().includes(searchText)
|
||||
);
|
||||
}
|
||||
|
||||
// 효능 필터
|
||||
const efficacyFilter = $('#efficacyFilter').val();
|
||||
if (efficacyFilter) {
|
||||
filteredHerbs = filteredHerbs.filter(h =>
|
||||
h.efficacy_tags && h.efficacy_tags.includes(efficacyFilter)
|
||||
);
|
||||
}
|
||||
|
||||
// 표시
|
||||
filteredHerbs.forEach(herb => {
|
||||
// 효능 태그 표시
|
||||
let efficacyTags = '';
|
||||
if (herb.efficacy_tags && herb.efficacy_tags.length > 0) {
|
||||
efficacyTags = herb.efficacy_tags.map(tag =>
|
||||
`<span class="badge bg-success ms-1">${tag}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 상태 표시
|
||||
const statusBadge = herb.has_stock
|
||||
? '<span class="badge bg-success">재고 있음</span>'
|
||||
: '<span class="badge bg-secondary">재고 없음</span>';
|
||||
|
||||
// 재고량 표시
|
||||
const stockDisplay = herb.stock_quantity > 0
|
||||
? `${herb.stock_quantity.toFixed(1)}g`
|
||||
: '-';
|
||||
|
||||
// 평균단가 표시
|
||||
const priceDisplay = herb.avg_price > 0
|
||||
? formatCurrency(herb.avg_price)
|
||||
: '-';
|
||||
|
||||
tbody.append(`
|
||||
<tr class="${herb.has_stock ? '' : 'table-secondary'}">
|
||||
<td><code>${herb.ingredient_code}</code></td>
|
||||
<td><strong>${herb.herb_name}</strong></td>
|
||||
<td>${efficacyTags}</td>
|
||||
<td>${stockDisplay}</td>
|
||||
<td>${priceDisplay}</td>
|
||||
<td>${herb.product_count || 0}개</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewHerbDetail('${herb.ingredient_code}')">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
if (filteredHerbs.length === 0) {
|
||||
tbody.append('<tr><td colspan="8" class="text-center">표시할 약재가 없습니다.</td></tr>');
|
||||
}
|
||||
}
|
||||
|
||||
// 약재 상세 보기
|
||||
function viewHerbDetail(ingredientCode) {
|
||||
// TODO: 약재 상세 모달 구현
|
||||
console.log('View detail for:', ingredientCode);
|
||||
}
|
||||
|
||||
// 필터 버튼 이벤트
|
||||
$('#herbs .btn-group button[data-filter]').on('click', function() {
|
||||
$('#herbs .btn-group button').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
currentFilter = $(this).data('filter');
|
||||
displayHerbMasters(allHerbMasters);
|
||||
});
|
||||
|
||||
// 검색 이벤트
|
||||
$('#herbSearch').on('keyup', function() {
|
||||
displayHerbMasters(allHerbMasters);
|
||||
});
|
||||
|
||||
// 효능 필터 이벤트
|
||||
$('#efficacyFilter').on('change', function() {
|
||||
displayHerbMasters(allHerbMasters);
|
||||
});
|
||||
|
||||
// 약재 관리 페이지가 활성화되면 데이터 로드
|
||||
$('.nav-link[data-page="herbs"]').on('click', function() {
|
||||
setTimeout(() => loadHerbMasters(), 100);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user