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:
2026-02-15 11:21:20 +00:00
parent 63128fdccb
commit 38838e5ecf
18 changed files with 1835 additions and 46 deletions

View File

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