feat: update session payment logic to ensure accurate payable amounts and reflect consumables in cost breakdown
- Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly. - Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations. - Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings. - Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges. - Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts. - Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers. - Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability.
This commit is contained in:
@@ -17,13 +17,20 @@ const slotStart = Math.floor(new Date('2024-06-01T12:00:00').getTime() / 1000);
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000',
|
||||
slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart,
|
||||
},
|
||||
});
|
||||
// mock باید به URL حساس باشد: /events یک لیست برمیگرداند نه آبجکت نوبت
|
||||
get.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith('/events')
|
||||
? { success: true, data: [] }
|
||||
: {
|
||||
success: true,
|
||||
data: {
|
||||
uuid: 'ap1', patient_name: 'ساغر', patient_mobile: '09120000000',
|
||||
slot_start: slotStart, slot_end: slotStart + 1800, status: 'confirmed', version: 1, created_at: slotStart,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('AppointmentDetailPage — بازگشت به همان روز', () => {
|
||||
|
||||
@@ -100,7 +100,7 @@ function LogoUploadField({ value, onChange, uploadUrl }: {
|
||||
<label className={`btn ghost sm ${uploading ? 'disabled' : ''}`} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||
<PhotoIcon style={{ width: 15, height: 15 }} />
|
||||
{uploading ? 'در حال آپلود...' : (value ? 'تغییر لگو' : 'انتخاب لگو')}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" style={{ display: 'none' }} onChange={handleFile} disabled={uploading} />
|
||||
<input type="file" aria-label="انتخاب فایل لگو" accept="image/jpeg,image/png,image/webp" style={{ display: 'none' }} onChange={handleFile} disabled={uploading} />
|
||||
</label>
|
||||
<span className="muted" style={{ fontSize: 11 }}>JPG، PNG یا WebP</span>
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@ function TabActions({ label, onClick, exportUrl, exportFile, bundle, entityLabel
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 'var(--card-pad)' }}>
|
||||
<input ref={fileRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={handleFile} />
|
||||
<input ref={fileRef} type="file" aria-label="انتخاب فایل JSON برای درونریزی" accept=".json,application/json" style={{ display: 'none' }} onChange={handleFile} />
|
||||
<button onClick={() => fileRef.current?.click()} className="btn ghost sm">
|
||||
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
|
||||
ورود JSON
|
||||
|
||||
@@ -58,7 +58,7 @@ const clinicPayload = {
|
||||
service_name: 'ویزیت عمومی',
|
||||
slot_start: 1_718_000_000,
|
||||
slot_end: 1_718_001_800,
|
||||
status: 'visited',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
doctors: [],
|
||||
@@ -95,9 +95,9 @@ describe('DashboardPage (ported clinic dashboard)', () => {
|
||||
expect(screen.getByText('09136549874')).toBeInTheDocument();
|
||||
expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument();
|
||||
|
||||
// ستون «وضعیت» از داشبورد حذف شده — تغییر وضعیت فقط در صفحه نوبتهاست
|
||||
expect(screen.queryByText('وضعیت')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('ویزیت شده')).not.toBeInTheDocument();
|
||||
// ستون «وضعیت» هست، ولی در داشبورد فقط خواندنی (بدون queryKey → StatusPill نه dropdown)
|
||||
expect(screen.getByText('وضعیت')).toBeInTheDocument();
|
||||
expect(screen.getByText('ویزیت شده').closest('button')).toBeNull();
|
||||
|
||||
// کارت «پزشکان کلینیک» از داشبورد حذف شده
|
||||
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
|
||||
|
||||
@@ -5,6 +5,8 @@ import { renderWithProviders } from '@/test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/components/ui/PwaLoginCard', () => ({ default: () => null }));
|
||||
// Altcha هنگام mount خودش fetch میزند؛ استابکردنش «بدون fetch» را واقعاً معنادار میکند
|
||||
vi.mock('@/components/ui/Altcha', () => ({ default: () => null }));
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
|
||||
@@ -34,7 +34,8 @@ beforeEach(() => {
|
||||
{ insurance_id: 5, insurance_name: 'بیمه دانا', type: 'supplementary' },
|
||||
],
|
||||
} });
|
||||
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [
|
||||
// صفحه ?filter=active را هم اضافه میکند، پس تطبیق باید پیشوندی باشد
|
||||
if (url.startsWith('/api/v1/patient/r1/sessions')) return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 's1', services: [{ service_name: 'اسکیلینگ' }], doctor_name: 'دکتر فتحی', final_price_rials: 2500000, is_paid: false, patient_debt_rials: 1500000, notes: 'یادداشت', created_at: 1700000000, visit_price_rials: 0 },
|
||||
{ uuid: 's2', services: [{ service_name: 'روکش' }], doctor_name: 'دکتر فتحی', final_price_rials: 2350000, is_paid: true, patient_debt_rials: 0, invoice_uuid: 'iv1', created_at: 1700000000, visit_price_rials: 0 },
|
||||
], meta: { totalRecords: 2 } });
|
||||
@@ -103,7 +104,7 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
||||
it('issues the invoice first (create + finalize) when the paid session has none', async () => {
|
||||
const base = get.getMockImplementation()!;
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [
|
||||
if (url.startsWith('/api/v1/patient/r1/sessions')) return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 's3', services: [{ service_name: 'لیزر' }], doctor_name: 'دکتر فتحی', final_price_rials: 1000000, is_paid: true, patient_debt_rials: 0, created_at: 1700000000, visit_price_rials: 0 },
|
||||
], meta: { totalRecords: 1 } });
|
||||
if (url === '/api/v1/billing/invoices/iv9') return Promise.resolve({ success: true, data: { data: {
|
||||
|
||||
@@ -93,10 +93,55 @@ describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
|
||||
const [url, body] = post.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/session/s1/payments');
|
||||
expect(body).toMatchObject({ method: 'cash', amount_rials: 500000 });
|
||||
// فیلد به تومان است و کامپوننت tomanToRial میکند: ۵۰۰,۰۰۰ تومان = ۵,۰۰۰,۰۰۰ ریال
|
||||
expect(body).toMatchObject({ method: 'cash', amount_rials: 5_000_000 });
|
||||
expect(typeof (body as any).paid_at).toBe('number');
|
||||
});
|
||||
|
||||
it('does not inflate «مبلغ نهایی قابل پرداخت» when the patient overpaid', async () => {
|
||||
// سرور remaining را در صفر کلمپ میکند. بازسازی از roo remaining+paid باعث
|
||||
// میشد مبلغ نهایی به اندازهی پرداختی بالا برود و بیشپرداخت دیده نشود.
|
||||
mockGets([session({ remaining_rials: 0, paid_total_rials: 5_000_000 })]);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('مبلغ نهایی قابل پرداخت:');
|
||||
// ۲,۵۰۰,۰۰۰ − ۲۰۰,۰۰۰ تخفیف = ۲,۳۰۰,۰۰۰ — نه ۵,۰۰۰,۰۰۰
|
||||
expect(screen.getAllByText(formatRial(2_300_000)).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(formatRial(5_000_000))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists consumables in the itemised cost breakdown', async () => {
|
||||
// کالای مصرفی در سهم بیمار حساب میشود، پس باید در ریز هزینهها هم بیاید
|
||||
// وگرنه جمعِ خطوط با «جمع کل» نمیخواند.
|
||||
mockGets([session({
|
||||
visit_price_rials: 500_000,
|
||||
consumables: [{ uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 }],
|
||||
payments: [{ uuid: 'p1', method: 'cash', amount_rials: 100_000, paid_at: 1_700_000_000 }],
|
||||
})]);
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByText('ثبت و ادامه'));
|
||||
expect(screen.getByText('عینک')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(formatRial(40_000)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows insurance shares so the breakdown reconciles with the patient share', async () => {
|
||||
mockGets([session({
|
||||
gross_total_rials: 4_000_000,
|
||||
base_insurance_rials: 1_000_000,
|
||||
supplementary_insurance_rials: 500_000,
|
||||
final_price_rials: 2_500_000,
|
||||
payments: [{ uuid: 'p1', method: 'cash', amount_rials: 100_000, paid_at: 1_700_000_000 }],
|
||||
})]);
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByText('ثبت و ادامه'));
|
||||
expect(screen.getByText('جمع کل خدمات')).toBeInTheDocument();
|
||||
expect(screen.getByText('سهم بیمه پایه')).toBeInTheDocument();
|
||||
expect(screen.getByText('سهم بیمه تکمیلی')).toBeInTheDocument();
|
||||
expect(screen.getByText('سهم بیمار')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills the amount field with the remaining balance when a method is opened', async () => {
|
||||
// مانده ۲,۳۰۰,۰۰۰ ریال → ۲۳۰,۰۰۰ تومان در فیلد مبلغ
|
||||
mockGets([session({ remaining_rials: 2_300_000 })]);
|
||||
@@ -121,9 +166,9 @@ describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('هزینه سرویس:');
|
||||
// حذف تخفیف → discount_type: null
|
||||
// حذف تخفیف → discount_rule_uuid: null (تخفیف قاعدهمحور را هم جدا میکند)
|
||||
fireEvent.click(screen.getByText('حذف تخفیف'));
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_type: null }));
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/session/s1', { discount_rule_uuid: null }));
|
||||
});
|
||||
|
||||
it('shows registered payments and moves to details step', async () => {
|
||||
@@ -144,7 +189,8 @@ describe('SessionPaymentPage (تکمیل پرداخت مراجعه)', () => {
|
||||
// گام جزییات
|
||||
fireEvent.click(screen.getByText('ثبت و ادامه'));
|
||||
expect(screen.getByText('صدور فاکتور')).toBeInTheDocument();
|
||||
expect(screen.getByText('کاشت مو')).toBeInTheDocument();
|
||||
// نام سرویس هم در سرتیتر و هم در ریز هزینهها میآید
|
||||
expect(screen.getAllByText('کاشت مو').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('دکتر فتحی')).toBeInTheDocument();
|
||||
expect(screen.getByText('مبلغ باقی مانده:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user