feat: Enhance insurance billing system to support supplementary insurance
- Updated CoverageRule and related entities to include franchise_percent instead of franchise_rials. - Modified Appointment entity to carry supplementary insurance ID alongside base insurance. - Implemented SessionBillingService to ensure finalized invoices for insured patient sessions. - Created InvoiceFinalized event to trigger claims creation upon invoice finalization. - Added BackfillMissingClaimsCommand to generate claims for finalized invoices without existing claims. - Developed tests to validate the new functionality for supplementary insurance handling in appointments and claims.
This commit is contained in:
@@ -130,9 +130,16 @@ const referenceAppointment = {
|
||||
visit_price_rials: 5_952_000, service_items: [],
|
||||
};
|
||||
|
||||
const SUPP_CONTRACT = {
|
||||
uuid: 'c2', insurance_id: 9, insurance_name: 'بیمه آسیا', insurance_kind: 'supplementary',
|
||||
is_active: true, coverage_percent: 90, franchise_percent: 10, annual_ceiling_rials: null,
|
||||
category_coverages: { outpatient: 90, inpatient: 60 },
|
||||
};
|
||||
|
||||
function mockInsurance(
|
||||
categories: { key: string; label: string; enabled: boolean }[],
|
||||
freeVisitPriceRials = 0,
|
||||
contracts: unknown[] = [CONTRACT],
|
||||
) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/insurance-pricing') {
|
||||
@@ -148,7 +155,7 @@ function mockInsurance(
|
||||
});
|
||||
}
|
||||
if (url === '/api/v1/billing/tenant-insurances') {
|
||||
return Promise.resolve({ success: true, data: { data: [CONTRACT] } });
|
||||
return Promise.resolve({ success: true, data: { data: contracts } });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
@@ -180,7 +187,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
renderReference();
|
||||
|
||||
expect(await screen.findByText('نوع خدمت')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیمه')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('با فعال بودن فقط یک نوع، انتخاب نوع خدمت پنهان است', async () => {
|
||||
@@ -190,7 +197,7 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
]);
|
||||
renderReference();
|
||||
|
||||
expect(await screen.findByText('بیمه')).toBeInTheDocument();
|
||||
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
||||
expect(screen.queryByText('نوع خدمت')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -224,6 +231,52 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('بدون قرارداد تکمیلی، انتخاب بیمهٔ تکمیلی نمایش داده نمیشود', async () => {
|
||||
mockInsurance(BOTH);
|
||||
renderReference();
|
||||
|
||||
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
||||
expect(screen.queryByText('بیمه تکمیلی')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('سهم پایه و تکمیلی جدا و زنجیرهای محاسبه میشوند و هر دو ارسال میگردند', async () => {
|
||||
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
|
||||
renderReference();
|
||||
|
||||
await screen.findByText('نوع خدمت');
|
||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||
await pick('بدون بیمه', 'بیمه ایران');
|
||||
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
|
||||
|
||||
// پایه ۷۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۱۶۶٬۴۰۰؛ باقیمانده ۱٬۷۸۵٬۶۰۰؛
|
||||
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
|
||||
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
|
||||
await waitFor(() => 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,
|
||||
insurance_supplementary_id: 9,
|
||||
payments: [{ method: 'cash', amount_rials: 357_120 }],
|
||||
}));
|
||||
});
|
||||
|
||||
it('بیمهٔ تکمیلیِ تنها هم روی کل مبلغ اعمال میشود', async () => {
|
||||
mockInsurance(BOTH, 0, [CONTRACT, SUPP_CONTRACT]);
|
||||
renderReference();
|
||||
|
||||
await screen.findByText('نوع خدمت');
|
||||
await pick('انتخاب نوع خدمت', 'خدمات سرپایی');
|
||||
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
|
||||
|
||||
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
|
||||
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
||||
mockInsurance(BOTH, 5_952_000);
|
||||
renderWithProviders(
|
||||
|
||||
@@ -36,6 +36,7 @@ interface AppointmentLike {
|
||||
patient_name?: string | null;
|
||||
insurance_service_category?: string | null;
|
||||
insurance_base_id?: number | null;
|
||||
insurance_supplementary_id?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -148,12 +149,14 @@ export default function ConfirmAppointmentModal({
|
||||
const total = visitPrice + servicesTotal;
|
||||
const [serviceCategory, setServiceCategory] = useState<string>('');
|
||||
const [insuranceId, setInsuranceId] = useState<string>('');
|
||||
const [supplementaryId, setSupplementaryId] = useState<string>('');
|
||||
|
||||
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیشفرضِ tenant.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
|
||||
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : '');
|
||||
setSupplementaryId(appt?.insurance_supplementary_id ? String(appt.insurance_supplementary_id) : '');
|
||||
}, [open, appt?.uuid, insurance.defaultCategory]);
|
||||
|
||||
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
||||
@@ -166,9 +169,10 @@ export default function ConfirmAppointmentModal({
|
||||
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
|
||||
insured: s.insurance_covered !== false,
|
||||
})),
|
||||
], insuranceId), [visitPrice, services, effectiveCategory, insuranceId, insurance.breakdown]);
|
||||
], insuranceId, supplementaryId), [visitPrice, services, effectiveCategory, insuranceId, supplementaryId, insurance.breakdown]);
|
||||
|
||||
const payable = insuranceId ? shares.patient : total;
|
||||
const hasInsurance = !!insuranceId || !!supplementaryId;
|
||||
const payable = hasInsurance ? shares.patient : total;
|
||||
|
||||
// ردیفِ اول تا لحظهای که کاربر مبلغ را دستی تغییر ندهد پیشفرضِ «پرداخت کامل» است؛
|
||||
// نوبت هنوز session ندارد، پس باقیماندهاش برابر مبلغِ قابل پرداخت است.
|
||||
@@ -196,6 +200,7 @@ export default function ConfirmAppointmentModal({
|
||||
version: appt?.version,
|
||||
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
|
||||
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
|
||||
...(supplementaryId ? { insurance_supplementary_id: Number(supplementaryId) } : {}),
|
||||
payments: rows
|
||||
.filter(r => tomanToRial(r.amountToman) > 0)
|
||||
.map(r => ({
|
||||
@@ -305,7 +310,7 @@ export default function ConfirmAppointmentModal({
|
||||
</div>
|
||||
)}
|
||||
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
||||
<label>بیمه</label>
|
||||
<label>بیمه پایه</label>
|
||||
<SearchableSelect
|
||||
value={insuranceId}
|
||||
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
|
||||
@@ -316,6 +321,19 @@ export default function ConfirmAppointmentModal({
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
{insurance.hasSupplementary && (
|
||||
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
||||
<label>بیمه تکمیلی</label>
|
||||
<SearchableSelect
|
||||
value={supplementaryId}
|
||||
onChange={(v) => setSupplementaryId(v == null ? '' : String(v))}
|
||||
options={insurance.supplementaryOptions}
|
||||
placeholder="بدون بیمه تکمیلی"
|
||||
isClearable
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* هزینهها */}
|
||||
@@ -339,10 +357,26 @@ export default function ConfirmAppointmentModal({
|
||||
<span>جمع کل</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
|
||||
</div>
|
||||
{/* سهمها زنجیرهایاند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده
|
||||
میشوند تا معلوم باشد هرکدام چقدر برداشتهاند. */}
|
||||
{insuranceId !== '' && (
|
||||
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<span>سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}</span>
|
||||
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.insurance)}</strong>
|
||||
<span>
|
||||
سهم بیمه پایه
|
||||
{insurance.insuranceNameOf(insuranceId) ? ` — ${insurance.insuranceNameOf(insuranceId)}` : ''}
|
||||
{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}
|
||||
</span>
|
||||
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.base)}</strong>
|
||||
</div>
|
||||
)}
|
||||
{supplementaryId !== '' && (
|
||||
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<span>
|
||||
سهم بیمه تکمیلی
|
||||
{insurance.insuranceNameOf(supplementaryId) ? ` — ${insurance.insuranceNameOf(supplementaryId)}` : ''}
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}> (روی باقیمانده)</span>
|
||||
</span>
|
||||
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.supplementary)}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -351,7 +385,7 @@ export default function ConfirmAppointmentModal({
|
||||
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<span>{insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
|
||||
<span>{hasInsurance ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
|
||||
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,10 +47,18 @@ export function useAppointmentInsurance(enabled: boolean) {
|
||||
[contractsQuery.data],
|
||||
);
|
||||
|
||||
const activeContracts = useMemo(() => contracts.filter((c) => c.is_active !== false), [contracts]);
|
||||
const basicContracts = useMemo(
|
||||
() => contracts.filter((c) => c.is_active !== false && c.insurance_kind !== 'supplementary'),
|
||||
[contracts],
|
||||
() => activeContracts.filter((c) => c.insurance_kind !== 'supplementary'),
|
||||
[activeContracts],
|
||||
);
|
||||
const supplementaryContracts = useMemo(
|
||||
() => activeContracts.filter((c) => c.insurance_kind === 'supplementary'),
|
||||
[activeContracts],
|
||||
);
|
||||
|
||||
const optionsOf = (list: TenantContract[]) =>
|
||||
list.map((c) => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
||||
|
||||
return {
|
||||
/**
|
||||
@@ -70,18 +78,26 @@ export function useAppointmentInsurance(enabled: boolean) {
|
||||
categoryLabelOf: (key: string | null | undefined) =>
|
||||
(pricing.service_categories ?? []).find((c) => c.key === key)?.label ?? null,
|
||||
|
||||
insuranceOptions: basicContracts.map((c) => ({
|
||||
value: String(c.insurance_id),
|
||||
label: c.insurance_name ?? `#${c.insurance_id}`,
|
||||
})),
|
||||
insuranceOptions: optionsOf(basicContracts),
|
||||
/** قراردادهای تکمیلیِ فعال — روی باقیماندهٔ بعد از بیمهٔ پایه اعمال میشوند. */
|
||||
supplementaryOptions: optionsOf(supplementaryContracts),
|
||||
hasSupplementary: supplementaryContracts.length > 0,
|
||||
contractOf: (insuranceId: string | number | null | undefined): TenantContract | null =>
|
||||
basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null,
|
||||
insuranceNameOf: (insuranceId: string | number | null | undefined): string | null =>
|
||||
contracts.find((c) => String(c.insurance_id) === String(insuranceId))?.insurance_name ?? null,
|
||||
|
||||
/** تفکیک سهم بیمه/بیمار برای ردیفهای دادهشده تحت قرارداد یک بیمه. */
|
||||
breakdown: (lines: BillableLine[], insuranceId: string | number | null | undefined): ShareBreakdown =>
|
||||
breakdownOf(lines, basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null),
|
||||
/** تفکیک زنجیرهای سهمها: پایه روی کل، تکمیلی روی باقیمانده. */
|
||||
breakdown: (
|
||||
lines: BillableLine[],
|
||||
insuranceId: string | number | null | undefined,
|
||||
supplementaryId: string | number | null | undefined = null,
|
||||
): ShareBreakdown =>
|
||||
breakdownOf(
|
||||
lines,
|
||||
basicContracts.find((c) => String(c.insurance_id) === String(insuranceId)) ?? null,
|
||||
supplementaryContracts.find((c) => String(c.insurance_id) === String(supplementaryId)) ?? null,
|
||||
),
|
||||
|
||||
isLoading: pricingQuery.isLoading || contractsQuery.isLoading,
|
||||
};
|
||||
|
||||
@@ -27,19 +27,25 @@ export interface TenantContract {
|
||||
/** ویزیت آیتم سرویس نیست؛ نوعِ پیشفرضش سرپایی است. */
|
||||
export const DEFAULT_SERVICE_CATEGORY = 'outpatient';
|
||||
|
||||
/** سهم بیمهٔ پایه از کل مبلغ یک ردیف (با سقف تعهد). */
|
||||
export function baseShareOf(total: number, base: CoverageRule | null): number {
|
||||
if (!base || !base.covered) return 0;
|
||||
|
||||
const share = Math.round(total * (base.percent / 100));
|
||||
|
||||
return base.ceiling !== null ? Math.min(share, base.ceiling) : share;
|
||||
}
|
||||
|
||||
/**
|
||||
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی.
|
||||
* فرانشیزِ تکمیلی درصدی از همان مبلغِ تحت پوشش است و از سهم تکمیلی کسر میشود،
|
||||
* نه اینکه روی سهم بیمار سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمیکند.
|
||||
* سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی. محاسبه زنجیرهای است؛
|
||||
* تکمیلی روی باقیماندهٔ بعد از پایه کار میکند، نه روی کل مبلغ. فرانشیزِ تکمیلی
|
||||
* درصدی از همان باقیمانده است و از سهم تکمیلی کسر میشود، نه اینکه روی سهم بیمار
|
||||
* سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمیکند.
|
||||
*/
|
||||
export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number {
|
||||
let baseShare = 0;
|
||||
let remaining = total;
|
||||
if (base && base.covered) {
|
||||
baseShare = Math.round(total * (base.percent / 100));
|
||||
if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling);
|
||||
remaining = total - baseShare;
|
||||
}
|
||||
const baseShare = baseShareOf(total, base);
|
||||
const remaining = total - baseShare;
|
||||
|
||||
let suppShare = 0;
|
||||
if (supp && supp.covered) {
|
||||
suppShare = Math.max(
|
||||
@@ -80,21 +86,35 @@ export interface BillableLine {
|
||||
|
||||
export interface ShareBreakdown {
|
||||
total: number;
|
||||
/** مجموع سهم هر دو بیمه — برای نمایشِ یکخطی. */
|
||||
insurance: number;
|
||||
base: number;
|
||||
supplementary: number;
|
||||
patient: number;
|
||||
}
|
||||
|
||||
/** تفکیک سهم بیمه/بیمار برای مجموعهای از ردیفها تحت یک قرارداد پایه. */
|
||||
export function breakdownOf(lines: BillableLine[], base: TenantContract | null): ShareBreakdown {
|
||||
/**
|
||||
* تفکیک سهم برای مجموعهای از ردیفها تحت قرارداد پایه و (اختیاری) تکمیلی.
|
||||
* محاسبه زنجیرهای است: پایه روی کل، تکمیلی روی باقیماندهٔ بعد از پایه.
|
||||
*/
|
||||
export function breakdownOf(
|
||||
lines: BillableLine[],
|
||||
base: TenantContract | null,
|
||||
supplementary: TenantContract | null = null,
|
||||
): ShareBreakdown {
|
||||
return lines.reduce<ShareBreakdown>((acc, line) => {
|
||||
const patient = line.insured
|
||||
? patientShareOf(line.total, ruleOf(base, line.category), null)
|
||||
: line.total;
|
||||
const baseRule = line.insured ? ruleOf(base, line.category) : null;
|
||||
const suppRule = line.insured ? ruleOf(supplementary, line.category) : null;
|
||||
|
||||
const patient = line.insured ? patientShareOf(line.total, baseRule, suppRule) : line.total;
|
||||
const baseShare = baseShareOf(line.total, baseRule);
|
||||
|
||||
return {
|
||||
total: acc.total + line.total,
|
||||
insurance: acc.insurance + (line.total - patient),
|
||||
patient: acc.patient + patient,
|
||||
total: acc.total + line.total,
|
||||
base: acc.base + baseShare,
|
||||
supplementary: acc.supplementary + (line.total - patient - baseShare),
|
||||
insurance: acc.insurance + (line.total - patient),
|
||||
patient: acc.patient + patient,
|
||||
};
|
||||
}, { total: 0, insurance: 0, patient: 0 });
|
||||
}, { total: 0, base: 0, supplementary: 0, insurance: 0, patient: 0 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user