An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { renderWithProviders } from '../../test/utils';
|
|
|
|
vi.mock('../../lib/api', () => ({
|
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
|
ApiError: class extends Error {},
|
|
}));
|
|
|
|
import { api } from '../../lib/api';
|
|
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
|
|
const appointment = {
|
|
uuid: 'a1',
|
|
version: 1,
|
|
patient_name: 'محمد رضایی',
|
|
visit_price_rials: 2_120_000,
|
|
service_items: [{ uuid: 's1', name: 'لیزر', price_rials: 880_000 }],
|
|
};
|
|
|
|
function render() {
|
|
return renderWithProviders(
|
|
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={appointment} onClose={() => {}} />,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// payment-methods (pos / bank-accounts) و بقیهٔ GETها
|
|
get.mockResolvedValue({ success: true, data: [] });
|
|
post.mockResolvedValue({ success: true, data: {} });
|
|
});
|
|
|
|
/** همهٔ فیلدهای مبلغ (تومان) در ردیفهای پرداخت. */
|
|
function amountInputs() {
|
|
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
|
|
}
|
|
|
|
describe('ConfirmAppointmentModal', () => {
|
|
it('بیمار، اقلام هزینه و جمع کل را نشان میدهد', () => {
|
|
render();
|
|
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
|
|
expect(screen.getByText('ویزیت')).toBeInTheDocument();
|
|
expect(screen.getByText('لیزر')).toBeInTheDocument();
|
|
expect(screen.getByText('جمع کل')).toBeInTheDocument();
|
|
});
|
|
|
|
it('ردیفِ اول پیشفرض برابر کل هزینه است و وضعیت «تسویه کامل» میشود', () => {
|
|
render();
|
|
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
|
|
expect(amountInputs()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
|
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
|
});
|
|
|
|
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» میشود', () => {
|
|
render();
|
|
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
|
|
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
|
|
});
|
|
|
|
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال میشود', () => {
|
|
render();
|
|
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
|
|
expect(submit).not.toBeDisabled();
|
|
|
|
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
|
|
expect(screen.getByText('مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
|
|
expect(submit).toBeDisabled();
|
|
});
|
|
|
|
it('پرداخت جزئی مجاز است و همان یک روش را ثبت میکند', async () => {
|
|
render();
|
|
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
|
|
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
|
version: 1,
|
|
payments: [{ method: 'cash', amount_rials: 1_000_000 }],
|
|
}));
|
|
});
|
|
|
|
it('تقسیم پرداخت بین دو روش: مجموع ردیفها بهصورت آرایه ثبت میشود', async () => {
|
|
render();
|
|
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم میکنیم
|
|
fireEvent.change(amountInputs()[0], { target: { value: '200000' } });
|
|
// افزودن روش دوم — پیشفرض با باقیماندهٔ ۱۰۰٬۰۰۰ تومان پر میشود
|
|
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
|
|
|
const inputs = amountInputs();
|
|
expect(inputs).toHaveLength(2);
|
|
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
|
|
|
|
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
|
|
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
|
version: 1,
|
|
payments: [
|
|
{ method: 'cash', amount_rials: 2_000_000 },
|
|
{ method: 'cash', amount_rials: 1_000_000 },
|
|
],
|
|
}));
|
|
});
|
|
|
|
it('حذف ردیف اضافهشده مجموع را دوباره محاسبه میکند', () => {
|
|
render();
|
|
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
|
expect(amountInputs()).toHaveLength(2);
|
|
|
|
fireEvent.click(screen.getAllByLabelText('حذف روش پرداخت')[0]);
|
|
expect(amountInputs()).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ── بیمه: نوع خدمت + محاسبهٔ سهم ──────────────────────────────────────────────
|
|
|
|
const CONTRACT = {
|
|
uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
|
|
is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null,
|
|
category_coverages: { outpatient: 70, inpatient: 30 },
|
|
};
|
|
|
|
/** ویزیت ۵٬۹۵۲٬۰۰۰ ریال، بدون خدمت — سناریوی مرجعِ سهم بیمه. */
|
|
const referenceAppointment = {
|
|
uuid: 'a1', version: 1, patient_name: 'محمد رضایی',
|
|
visit_price_rials: 5_952_000, service_items: [],
|
|
};
|
|
|
|
function mockInsurance(
|
|
categories: { key: string; label: string; enabled: boolean }[],
|
|
freeVisitPriceRials = 0,
|
|
) {
|
|
get.mockImplementation((url: string) => {
|
|
if (url === '/api/v1/insurance-pricing') {
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: {
|
|
service_categories: categories,
|
|
free_visit_price_rials: freeVisitPriceRials,
|
|
default_service_category: categories.filter((c) => c.enabled).length === 1
|
|
? categories.find((c) => c.enabled)!.key
|
|
: null,
|
|
},
|
|
});
|
|
}
|
|
if (url === '/api/v1/billing/tenant-insurances') {
|
|
return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
|
|
}
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
}
|
|
|
|
const BOTH = [
|
|
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: true },
|
|
{ key: 'inpatient', label: 'خدمات بستری', enabled: true },
|
|
];
|
|
|
|
function renderReference() {
|
|
return renderWithProviders(
|
|
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={referenceAppointment} onClose={() => {}} />,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* react-select با placeholder بهعنوان aria-label رندر میشود و منو با ArrowDown باز
|
|
* میشود. انتخاب با role=option انجام میشود چون متنِ گزینه در live-region هم تکرار است.
|
|
*/
|
|
async function pick(selectLabel: string, optionText: string) {
|
|
fireEvent.keyDown(screen.getByRole('combobox', { name: selectLabel }), { key: 'ArrowDown' });
|
|
fireEvent.click(await screen.findByRole('option', { name: optionText }));
|
|
}
|
|
|
|
describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|
it('با فعال بودن هر دو نوع، انتخاب نوع خدمت نمایش داده میشود', async () => {
|
|
mockInsurance(BOTH);
|
|
renderReference();
|
|
|
|
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
|
|
expect(screen.getByText('بیمه')).toBeInTheDocument();
|
|
});
|
|
|
|
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
|
|
mockInsurance([
|
|
{ key: 'outpatient', label: 'خدمات سرپایی', enabled: true },
|
|
{ key: 'inpatient', label: 'خدمات بستری', enabled: false },
|
|
]);
|
|
renderReference();
|
|
|
|
expect(await screen.findByText('بیمه')).toBeInTheDocument();
|
|
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('بدون انتخاب بیمه، مبلغ قابل پرداخت همان جمع کل است', async () => {
|
|
mockInsurance(BOTH);
|
|
renderReference();
|
|
|
|
await screen.findByText('نوع خدمت');
|
|
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
|
expect(screen.queryByText(/سهم بیمه/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('با انتخاب بیمه، سهم بیمه و سهم بیمار محاسبه و ارسال میشوند (سرپایی ۷۰٪)', async () => {
|
|
mockInsurance(BOTH);
|
|
renderReference();
|
|
|
|
await screen.findByText('نوع خدمت');
|
|
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
|
await pick('بدون بیمه', 'بیمه ایران');
|
|
|
|
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
|
|
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
|
|
expect(amountInputs()[0]).toHaveValue('۱۷۸٬۵۶۰');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
|
version: 1,
|
|
insurance_service_category: 'outpatient',
|
|
insurance_base_id: 3,
|
|
payments: [{ method: 'cash', amount_rials: 1_785_600 }],
|
|
}));
|
|
});
|
|
|
|
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
|
mockInsurance(BOTH, 5_952_000);
|
|
renderWithProviders(
|
|
<ConfirmAppointmentModal
|
|
open
|
|
appointmentUuid="a1"
|
|
appointment={{ uuid: 'a1', version: 1, visit_price_rials: null, service_items: [] }}
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
|
|
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
|
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
|
});
|
|
|
|
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
|
|
mockInsurance(BOTH, 9_000_000);
|
|
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
|
|
|
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
|
});
|
|
|
|
it('نوع بستری درصد خودش را میگیرد (۳۰٪)', async () => {
|
|
mockInsurance(BOTH);
|
|
renderReference();
|
|
|
|
await screen.findByText('نوع خدمت');
|
|
await pick('انتخاب نوع خدمت', 'خدمات بستری');
|
|
await pick('بدون بیمه', 'بیمه ایران');
|
|
|
|
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
|
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
|
|
});
|
|
});
|