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:
318
static/app.js
318
static/app.js
@@ -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 || '알 수 없는 오류'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user