feat: 판매관리 시스템 Phase 1 및 마일리지 시스템 구현

- 판매 관리 기능 추가
  - compounds 테이블에 판매 관련 컬럼 추가 (payment_method, discount_rate, delivery_method 등)
  - 판매 상태 관리 (조제완료→결제대기→결제완료→배송대기→배송완료)
  - 판매 처리 모달 UI 구현
  - 9가지 상태별 뱃지 표시

- 마일리지 시스템 구축
  - patients 테이블에 마일리지 컬럼 추가 (balance, earned, used)
  - mileage_transactions 테이블 생성 (거래 이력 관리)
  - 마일리지 사용/적립 기능 구현

- 복합 결제 기능
  - 할인율(%) / 할인액(원) 직접 입력 선택 가능
  - 마일리지 + 현금 + 카드 + 계좌이체 복합 결제
  - 결제 금액 자동 검증
  - 결제 방법 자동 분류 (복합결제 지원)

- API 엔드포인트 추가
  - POST /api/compounds/<id>/status (상태 업데이트)
  - PUT /api/compounds/<id>/price (가격 조정)
  - GET /api/sales/statistics (판매 통계)

- 데이터베이스 설정 통합
  - config.py 생성하여 DB 경로 중앙화

TODO: 처방별 기본가격 정책 시스템 (price_policies 테이블 활용)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-02-18 05:42:29 +00:00
parent ad9ac396e2
commit f3f1efd8c2
12 changed files with 2154 additions and 4 deletions

View File

@@ -1267,14 +1267,32 @@ $(document).ready(function() {
let statusBadge = '';
switch(compound.status) {
case 'PREPARED':
statusBadge = '<span class="badge bg-success">조제완료</span>';
statusBadge = '<span class="badge bg-primary">조제완료</span>';
break;
case 'DISPENSED':
statusBadge = '<span class="badge bg-primary">출고완료</span>';
case 'PENDING_PAYMENT':
statusBadge = '<span class="badge bg-warning">결제대기</span>';
break;
case 'PAID':
statusBadge = '<span class="badge bg-success">결제완료</span>';
break;
case 'PENDING_DELIVERY':
statusBadge = '<span class="badge bg-info">배송대기</span>';
break;
case 'DELIVERED':
statusBadge = '<span class="badge bg-secondary">배송완료</span>';
break;
case 'COMPLETED':
statusBadge = '<span class="badge bg-dark">판매완료</span>';
break;
case 'OTC_CONVERTED':
statusBadge = '<span class="badge bg-purple">OTC전환</span>';
break;
case 'CANCELLED':
statusBadge = '<span class="badge bg-danger">취소</span>';
break;
case 'REFUNDED':
statusBadge = '<span class="badge bg-danger">환불</span>';
break;
default:
statusBadge = '<span class="badge bg-secondary">대기</span>';
}
@@ -1297,6 +1315,20 @@ $(document).ready(function() {
<button class="btn btn-sm btn-outline-info view-compound-detail" data-id="${compound.compound_id}">
<i class="bi bi-eye"></i> 상세
</button>
${compound.status === 'PREPARED' ? `
<button class="btn btn-sm btn-outline-success process-sale" data-id="${compound.compound_id}"
data-formula="${compound.formula_name || '직접조제'}"
data-patient="${compound.patient_name || '직접조제'}"
data-cost="${compound.cost_total || 0}"
data-price="${compound.sell_price_total || 0}">
<i class="bi bi-cash-coin"></i> 판매
</button>
` : ''}
${compound.status === 'PAID' ? `
<button class="btn btn-sm btn-outline-primary process-delivery" data-id="${compound.compound_id}">
<i class="bi bi-truck"></i> 배송
</button>
` : ''}
</td>
</tr>
`);
@@ -1312,6 +1344,23 @@ $(document).ready(function() {
const compoundId = $(this).data('id');
viewCompoundDetail(compoundId);
});
// 판매 처리 버튼 이벤트
$('.process-sale').on('click', function() {
const compoundId = $(this).data('id');
const formulaName = $(this).data('formula');
const patientName = $(this).data('patient');
const costTotal = $(this).data('cost');
const priceTotal = $(this).data('price');
openSalesModal(compoundId, formulaName, patientName, costTotal, priceTotal);
});
// 배송 처리 버튼 이벤트
$('.process-delivery').on('click', function() {
const compoundId = $(this).data('id');
processDelivery(compoundId);
});
} else {
tbody.html('<tr><td colspan="13" class="text-center text-muted">조제 내역이 없습니다.</td></tr>');
$('#todayCompoundCount').text(0);
@@ -3439,4 +3488,267 @@ $(document).ready(function() {
alert('약재 정보 수정 기능은 준비 중입니다.');
// TODO: 정보 수정 폼 구현
}
// ==================== 판매 관리 기능 ====================
// 판매 모달 열기
function openSalesModal(compoundId, formulaName, patientName, costTotal, priceTotal) {
$('#salesCompoundId').val(compoundId);
$('#salesFormulaName').val(formulaName);
$('#salesPatientName').val(patientName);
$('#salesCostTotal').val(costTotal);
// 환자 마일리지 조회
loadPatientMileage(patientName);
// 기본 가격 계산 (원가 + 조제료)
const dispensingFee = parseFloat($('#salesDispensingFee').val()) || 20000;
const basePrice = costTotal + dispensingFee;
$('#salesBasePrice').val(basePrice);
// 판매가가 없으면 기본가격으로 설정
if (!priceTotal || priceTotal === 0) {
priceTotal = basePrice;
}
// 초기화
$('#salesDiscountValue').val(0);
$('#salesPriceAfterDiscount').val(priceTotal);
$('#salesMileageUse').val(0);
$('#salesPaymentCash').val(0);
$('#salesPaymentCard').val(0);
$('#salesPaymentTransfer').val(0);
updatePaymentSummary();
// 현재 시간 설정
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
$('#salesPaymentDate').val(localDateTime);
// 내일 날짜 설정 (배송예정일)
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
$('#salesDeliveryDate').val(tomorrow.toISOString().slice(0, 10));
$('#salesModal').modal('show');
// 가격 자동 계산 이벤트
calculateSalesPrice();
}
// 환자 마일리지 조회
function loadPatientMileage(patientName) {
if (patientName && patientName !== '직접조제') {
$.get('/api/patients/search', { name: patientName }, function(response) {
if (response.success && response.data.length > 0) {
const patient = response.data[0];
const mileage = patient.mileage_balance || 0;
$('#patientMileageBalance').text(mileage.toLocaleString());
$('#salesMileageUse').attr('max', mileage);
} else {
$('#patientMileageBalance').text('0');
$('#salesMileageUse').attr('max', 0);
}
});
} else {
$('#patientMileageBalance').text('0');
$('#salesMileageUse').attr('max', 0);
}
}
// 가격 자동 계산
function calculateSalesPrice() {
const costTotal = parseFloat($('#salesCostTotal').val()) || 0;
const dispensingFee = parseFloat($('#salesDispensingFee').val()) || 0;
const basePrice = costTotal + dispensingFee;
$('#salesBasePrice').val(basePrice);
const discountType = $('input[name="discountType"]:checked').val();
const discountValue = parseFloat($('#salesDiscountValue').val()) || 0;
let discountAmount = 0;
if (discountType === 'rate') {
discountAmount = basePrice * (discountValue / 100);
} else {
discountAmount = discountValue;
}
const priceAfterDiscount = Math.max(0, basePrice - discountAmount);
$('#salesPriceAfterDiscount').val(Math.round(priceAfterDiscount));
updatePaymentSummary();
}
// 결제 요약 업데이트
function updatePaymentSummary() {
const priceAfterDiscount = parseFloat($('#salesPriceAfterDiscount').val()) || 0;
const mileageUse = parseFloat($('#salesMileageUse').val()) || 0;
const cashPayment = parseFloat($('#salesPaymentCash').val()) || 0;
const cardPayment = parseFloat($('#salesPaymentCard').val()) || 0;
const transferPayment = parseFloat($('#salesPaymentTransfer').val()) || 0;
const totalPayment = mileageUse + cashPayment + cardPayment + transferPayment;
const needToPay = Math.max(0, priceAfterDiscount - totalPayment);
$('#salesNeedToPay').text(needToPay.toLocaleString());
$('#salesTotalPayment').text(totalPayment.toLocaleString());
// 결제 방법 자동 설정
if (totalPayment > 0) {
let methodCount = 0;
if (mileageUse > 0) methodCount++;
if (cashPayment > 0) methodCount++;
if (cardPayment > 0) methodCount++;
if (transferPayment > 0) methodCount++;
if (methodCount > 1) {
$('#salesPaymentMethod').val('COMPLEX');
} else if (cashPayment > 0) {
$('#salesPaymentMethod').val('CASH');
} else if (cardPayment > 0) {
$('#salesPaymentMethod').val('CARD');
} else if (transferPayment > 0) {
$('#salesPaymentMethod').val('TRANSFER');
}
}
}
// 할인 방식 변경 이벤트
$('input[name="discountType"]').on('change', function() {
const discountType = $(this).val();
if (discountType === 'rate') {
$('#discountUnit').text('%');
$('#salesDiscountValue').attr('max', 100);
} else {
$('#discountUnit').text('원');
$('#salesDiscountValue').removeAttr('max');
}
calculateSalesPrice();
});
// 가격 계산 이벤트 핸들러
$('#salesDispensingFee, #salesDiscountValue').on('input', calculateSalesPrice);
$('#salesMileageUse, #salesPaymentCash, #salesPaymentCard, #salesPaymentTransfer').on('input', updatePaymentSummary);
// 마일리지 전액 사용 버튼
$('#applyMaxMileage').on('click', function() {
const maxMileage = parseFloat($('#salesMileageUse').attr('max')) || 0;
const priceAfterDiscount = parseFloat($('#salesPriceAfterDiscount').val()) || 0;
const useAmount = Math.min(maxMileage, priceAfterDiscount);
$('#salesMileageUse').val(useAmount);
updatePaymentSummary();
});
// 판매 저장
$('#saveSalesBtn').on('click', function() {
const compoundId = $('#salesCompoundId').val();
const priceAfterDiscount = parseFloat($('#salesPriceAfterDiscount').val()) || 0;
const discountType = $('input[name="discountType"]:checked').val();
const discountValue = parseFloat($('#salesDiscountValue').val()) || 0;
const discountReason = $('#salesDiscountReason').val();
// 복합 결제 정보
const mileageUse = parseFloat($('#salesMileageUse').val()) || 0;
const cashPayment = parseFloat($('#salesPaymentCash').val()) || 0;
const cardPayment = parseFloat($('#salesPaymentCard').val()) || 0;
const transferPayment = parseFloat($('#salesPaymentTransfer').val()) || 0;
const totalPayment = mileageUse + cashPayment + cardPayment + transferPayment;
// 결제 금액 검증
if (Math.abs(totalPayment - priceAfterDiscount) > 1) {
alert(`결제 금액이 일치하지 않습니다.\n필요금액: ${priceAfterDiscount.toLocaleString()}\n결제금액: ${totalPayment.toLocaleString()}`);
return;
}
const paymentMethod = $('#salesPaymentMethod').val();
const paymentDate = $('#salesPaymentDate').val();
const deliveryMethod = $('#salesDeliveryMethod').val();
const deliveryDate = $('#salesDeliveryDate').val();
if (!paymentMethod) {
alert('결제방법을 선택해주세요.');
return;
}
// 할인액 계산
const basePrice = parseFloat($('#salesBasePrice').val()) || 0;
let discountAmount = 0;
if (discountType === 'rate') {
discountAmount = basePrice * (discountValue / 100);
} else {
discountAmount = discountValue;
}
// 1. 가격 및 결제 정보 업데이트
$.ajax({
url: `/api/compounds/${compoundId}/price`,
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify({
sell_price_total: priceAfterDiscount,
discount_rate: discountType === 'rate' ? discountValue : 0,
discount_amount: Math.round(discountAmount),
discount_reason: discountReason,
mileage_used: mileageUse,
payment_cash: cashPayment,
payment_card: cardPayment,
payment_transfer: transferPayment
}),
success: function() {
// 2. 상태를 PAID로 변경
$.ajax({
url: `/api/compounds/${compoundId}/status`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
status: 'PAID',
payment_method: paymentMethod,
payment_date: paymentDate,
actual_payment_amount: priceAfterDiscount,
delivery_method: deliveryMethod,
delivery_date: deliveryDate,
mileage_used: mileageUse,
changed_by: 'user'
}),
success: function() {
alert('판매 처리가 완료되었습니다.');
$('#salesModal').modal('hide');
loadCompounds(); // 목록 새로고침
},
error: function(xhr) {
alert('상태 업데이트 실패: ' + (xhr.responseJSON?.error || '알 수 없는 오류'));
}
});
},
error: function(xhr) {
alert('가격 업데이트 실패: ' + (xhr.responseJSON?.error || '알 수 없는 오류'));
}
});
});
// 배송 처리
function processDelivery(compoundId) {
if (!confirm('배송 처리를 하시겠습니까?')) {
return;
}
$.ajax({
url: `/api/compounds/${compoundId}/status`,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
status: 'DELIVERED',
delivery_date: new Date().toISOString(),
changed_by: 'user'
}),
success: function() {
alert('배송 처리가 완료되었습니다.');
loadCompounds(); // 목록 새로고침
},
error: function(xhr) {
alert('배송 처리 실패: ' + (xhr.responseJSON?.error || '알 수 없는 오류'));
}
});
}
});