From e6267080b28e52ec3cc0eca895df8c56b5dc5e6c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 29 Jul 2026 19:57:02 +0330 Subject: [PATCH] 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. --- .../ConfirmAppointmentModal.test.tsx | 59 ++++++- .../appointments/ConfirmAppointmentModal.tsx | 46 +++++- assets/admin/hooks/useAppointmentInsurance.ts | 34 ++-- assets/admin/lib/insuranceShares.ts | 58 ++++--- docs/api/appointment.md | 35 ++-- docs/api/billing.md | 72 ++++++--- docs/api/insurance.md | 25 ++- docs/architecture/insurance-billing-system.md | 8 +- migrations/Version20260729155428.php | 29 ++++ phpstan-baseline.neon | 8 +- .../Controller/AppointmentController.php | 1 + src/Appointment/Entity/Appointment.php | 7 + .../Service/AppointmentInsuranceService.php | 29 +++- .../Command/BackfillMissingClaimsCommand.php | 74 +++++++++ src/Billing/Event/InvoiceFinalized.php | 15 ++ .../CreateClaimsOnInvoiceFinalized.php | 33 ++++ src/Billing/Repository/ClaimRepository.php | 20 +++ src/Billing/Repository/InvoiceRepository.php | 22 +++ src/Billing/Service/ClaimService.php | 47 ++++++ src/Billing/Service/InvoiceService.php | 6 + src/Billing/Service/SessionBillingService.php | 48 ++++++ src/Patient/Controller/PatientController.php | 37 +---- src/Patient/Service/PatientService.php | 11 +- tests/Billing/ClaimAutoCreationTest.php | 125 ++++++++++++++ .../AppointmentSupplementaryInsuranceTest.php | 153 ++++++++++++++++++ 25 files changed, 876 insertions(+), 126 deletions(-) create mode 100644 migrations/Version20260729155428.php create mode 100644 src/Billing/Command/BackfillMissingClaimsCommand.php create mode 100644 src/Billing/Event/InvoiceFinalized.php create mode 100644 src/Billing/EventSubscriber/CreateClaimsOnInvoiceFinalized.php create mode 100644 src/Billing/Service/SessionBillingService.php create mode 100644 tests/Billing/ClaimAutoCreationTest.php create mode 100644 tests/Patient/AppointmentSupplementaryInsuranceTest.php diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx index d906b1d6..33525698 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx @@ -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( diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx index 9b564a49..d0d9af3c 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -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(''); const [insuranceId, setInsuranceId] = useState(''); + const [supplementaryId, setSupplementaryId] = useState(''); // مقدارِ نوبت مبنا است؛ در نبودش نوع پیش‌فرضِ 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({ )}
- + setInsuranceId(v == null ? '' : String(v))} @@ -316,6 +321,19 @@ export default function ConfirmAppointmentModal({ height={40} />
+ {insurance.hasSupplementary && ( +
+ + setSupplementaryId(v == null ? '' : String(v))} + options={insurance.supplementaryOptions} + placeholder="بدون بیمه تکمیلی" + isClearable + height={40} + /> +
+ )} {/* هزینه‌ها */} @@ -339,10 +357,26 @@ export default function ConfirmAppointmentModal({ جمع کل {formatRial(total)} + {/* سهم‌ها زنجیره‌ای‌اند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده + می‌شوند تا معلوم باشد هرکدام چقدر برداشته‌اند. */} {insuranceId !== '' && (
- سهم بیمه{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''} - {formatRial(shares.insurance)} + + سهم بیمه پایه + {insurance.insuranceNameOf(insuranceId) ? ` — ${insurance.insuranceNameOf(insuranceId)}` : ''} + {insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''} + + {formatRial(shares.base)} +
+ )} + {supplementaryId !== '' && ( +
+ + سهم بیمه تکمیلی + {insurance.insuranceNameOf(supplementaryId) ? ` — ${insurance.insuranceNameOf(supplementaryId)}` : ''} + (روی باقیمانده) + + {formatRial(shares.supplementary)}
)}
- {insuranceId !== '' ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'} + {hasInsurance ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'} {formatRial(payable)}
diff --git a/assets/admin/hooks/useAppointmentInsurance.ts b/assets/admin/hooks/useAppointmentInsurance.ts index 25573854..85abb624 100644 --- a/assets/admin/hooks/useAppointmentInsurance.ts +++ b/assets/admin/hooks/useAppointmentInsurance.ts @@ -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, }; diff --git a/assets/admin/lib/insuranceShares.ts b/assets/admin/lib/insuranceShares.ts index e50b0dca..0173cf82 100644 --- a/assets/admin/lib/insuranceShares.ts +++ b/assets/admin/lib/insuranceShares.ts @@ -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((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 }); } diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 496f04ac..6235e6a6 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -367,6 +367,7 @@ Get appointment detail. | `insurance_service_category` | string \| null | نوع خدمتِ بیمه‌ایِ این نوبت — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). `null` = انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد (`default_service_category` در [insurance.md](insurance.md)) | | `insurance_service_category_label` | string \| null | برچسب فارسی همان نوع | | `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد | +| `insurance_supplementary_id` | int \| null | بیمهٔ **تکمیلیِ** انتخاب‌شده؛ قرارداد فعال لازم دارد و روی **باقیماندهٔ بعد از بیمهٔ پایه** محاسبه می‌شود ([فرمول زنجیره‌ای](billing.md)) | نام بیمه در این پاسخ نیست؛ پنل آن را از `GET /api/v1/billing/tenant-insurances` (که کش می‌شود) مپ می‌کند تا لیست‌های نوبت به N+1 نیفتند. @@ -557,6 +558,7 @@ transaction**. If any step fails nothing is committed. | `version` | integer | ❌ | Optimistic lock version; defaults to the stored one | | `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. | | `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. | +| `insurance_supplementary_id` | integer | ❌ | بیمهٔ تکمیلی، همان قواعد `PATCH`. روی باقیماندهٔ بعد از بیمهٔ پایه اعمال می‌شود. | | `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). | | `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` | | `payments[].amount_rials` | integer | ✅ | > 0 | @@ -584,8 +586,10 @@ collected later through `POST /api/v1/session/{uuid}/payments`. A `PatientSession` is opened with the visit price and one line per attached service. 4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve - ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). نوبتِ بدون بیمه - مثل قبل کاملاً سهم بیمار می‌ماند. + ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). محاسبه زنجیره‌ای + است: پایه روی کل، تکمیلی روی باقیمانده. نوبتِ بدون بیمه مثل قبل کاملاً سهم بیمار می‌ماند. + مراجعهٔ بیمه‌دار همین‌جا صورتحساب نهایی و مطالبهٔ بیمه هم می‌گیرد + (`SessionBillingService` → رویداد `InvoiceFinalized`؛ [billing.md](billing.md)). 5. Each payment row is registered on that visit (`wallet` also debits the patient wallet). > پیش از این، پرونده‌ای که با قطعی‌کردن ساخته می‌شد همیشه کل مبلغ را سهم بیمار می‌گذاشت @@ -593,7 +597,7 @@ collected later through `POST /api/v1/session/{uuid}/payments`. پاسخ، `session` را با تفکیک بیمه برمی‌گرداند: `gross_total_rials`، `base_insurance_rials`، `supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`، -`insurance_base_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است. +`insurance_base_id`، `insurance_supplementary_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است. ### Response `200` ```json @@ -603,18 +607,28 @@ collected later through `POST /api/v1/session/{uuid}/payments`. "appointment": { "uuid": "…", "status": "confirmed", "version": 4 }, "session": { "uuid": "…", - "visit_price_rials": 5000000, - "services_total_rials": 1500000, - "final_price_rials": 6500000, + "visit_price_rials": 5950000, + "services_total_rials": 0, + "final_price_rials": 833000, "discount_rials": 0, - "paid_total_rials": 5000000, - "remaining_rials": 1500000, - "is_paid": false + "paid_total_rials": 0, + "remaining_rials": 833000, + "is_paid": false, + "insurance_service_category": "outpatient", + "insurance_base_id": 176, + "insurance_supplementary_id": 182, + "gross_total_rials": 5950000, + "base_insurance_rials": 1785000, + "supplementary_insurance_rials": 3332000, + "patient_share_rials": 833000 } } } ``` +> نمونهٔ بالا خروجی واقعیِ همان مسیر است: ویزیت ۵٬۹۵۰٬۰۰۰ · پایه ۳۰٪ سرپایی → ۱٬۷۸۵٬۰۰۰ · +> تکمیلیِ ۹۰٪ با فرانشیز ۱۰٪ روی باقیماندهٔ ۴٬۱۶۵٬۰۰۰ → ۳٬۳۳۲٬۰۰۰ · سهم بیمار ۸۳۳٬۰۰۰. + `session` is `null` when the tenant does not have the `patient_records` subscription feature — the appointment is still confirmed, it simply has no case file. **Sending `payments` in that situation fails with `403 ERR_SUBSCRIPTION_REQUIRED` and confirms @@ -876,7 +890,8 @@ General update (ویرایش / جا به جایی / انتقال به رزرو / - `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`. - Relation uuids: empty string clears; unknown uuid → `422`. - `insurance_service_category` — نوعِ خدمت باید در تنظیمات بیمهٔ همان tenant **فعال** باشد؛ `null`/`""` انتخاب را پاک می‌کند. نوع نامعتبر یا غیرفعال → `422 ERR_VALIDATION_001` با فیلد `insurance_service_category`. -- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال یا بیمهٔ تکمیلی → `422 ERR_VALIDATION_001` با فیلد `insurance_base_id`. +- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال → `422 ERR_VALIDATION_001` («این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد»)؛ فرستادن بیمهٔ تکمیلی در این فیلد → `422` («اینجا فقط بیمهٔ پایه قابل انتخاب است»)، هر دو با فیلد `insurance_base_id`. +- `insurance_supplementary_id` — همان قواعد، برعکس: فقط قرارداد فعالِ **تکمیلی** پذیرفته می‌شود؛ فرستادن بیمهٔ پایه → `422` («اینجا فقط بیمهٔ تکمیلی قابل انتخاب است») با فیلد `insurance_supplementary_id`. - `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission. - Optimistic lock via `version` → `409` on concurrent edit. diff --git a/docs/api/billing.md b/docs/api/billing.md index eca27930..fe9720fc 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -12,20 +12,43 @@ - قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`). - درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی). - `invoices.service_category` همان نوع را snapshot می‌کند و در `toArray()` به‌صورت `service_category` / `service_category_label` برمی‌گردد. -- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار. - -**بیمهٔ پایه صرفاً درصدی است:** +**محاسبه زنجیره‌ای است، نه هم‌زمان:** اول بیمهٔ پایه روی کل مبلغ، بعد بیمهٔ تکمیلی روی +**باقیماندهٔ سهم بیمار**، نه روی کل. ``` -سهم بیمهٔ پایه = round(کل × درصد پوشش پایه ÷ 100) -سهم بیمار = کل − سهم بیمهٔ پایه +سهم پایه = min(round(کل × درصد پایه ÷ 100), سقف پایه) +باقیمانده = کل − سهم پایه +سهم تکمیلی = min(max(0, round(باقیمانده × درصد تکمیلی ÷ 100) − round(باقیمانده × فرانشیز ÷ 100)), سقف تکمیلی) +سهم بیمار = کل − سهم پایه − سهم تکمیلی ``` -`franchise_rials` قرارداد پایه در محاسبه **بی‌اثر** است (ستون برای سازگاری و قراردادهای تکمیلی می‌ماند). +- **فرانشیز درصد است، نه مبلغ**، و از تعهد بیمهٔ تکمیلی **کسر** می‌شود (نه اینکه روی سهم بیمار + اضافه شود). با مدل قبلیِ «افزودن به سهم بیمار»، جمع سهم‌ها از کل بیشتر می‌شد و مطالبهٔ + ارسالی به بیمه بیش از سهم واقعی‌اش بود. +- `franchise_percent` قرارداد **پایه** در محاسبه بی‌اثر است. +- invariant همیشگی: `کل = سهم پایه + سهم تکمیلی + سهم بیمار`. نمونه‌ها: -- کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰. +- کل ۱۰٬۰۰۰٬۰۰۰ · پایه ۳۰٪ → ۳٬۰۰۰٬۰۰۰ · تکمیلی ۹۰٪ با فرانشیز ۱۰٪ روی باقیماندهٔ ۷٬۰۰۰٬۰۰۰ → ۵٬۶۰۰٬۰۰۰ · بیمار ۱٬۴۰۰٬۰۰۰. - ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰. +- بدون پایه · تکمیلی ۹۰٪ با فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ → بیمه ۸۰۰٬۰۰۰ · بیمار ۲۰۰٬۰۰۰. + +### مطالبهٔ خودکار + +مطالبهٔ بیمه اثر جانبیِ **نهایی‌شدن صورتحساب** است، نه کارِ یک endpoint خاص: `InvoiceService::finalize()` +رویداد `InvoiceFinalized` می‌فرستد و `CreateClaimsOnInvoiceFinalized` مطالبات جاافتاده را می‌سازد +(idempotent — مطالبهٔ موجود دست‌نخورده می‌ماند، و بیمه‌ای که سهمی نبرده مطالبه نمی‌گیرد). + +هر مراجعهٔ بیمه‌دار هم — چه از «ثبت مراجعه»، چه از ویرایش مراجعه، چه از **قطعی‌کردن نوبت** — +با `SessionBillingService::ensureFinalizedInvoice()` صورتحساب نهایی می‌گیرد. پیش از این فقط +مسیر «ثبت مراجعه» مطالبه می‌ساخت و بقیهٔ مسیرها بی‌صدا بدون مطالبه می‌ماندند. + +جبران داده‌های قدیمی: + +```bash +ddev exec php bin/console app:billing:backfill-claims --dry-run # گزارش +ddev exec php bin/console app:billing:backfill-claims # ساخت مطالبات جاافتاده +``` --- @@ -216,9 +239,10 @@ | `limit` | int | 20 | حداکثر ۱۰۰ | | `sort` | string | `last_activity_at` | `full_name` \| `claims_count` \| `total_services_rials` \| `total_insurance_rials` \| `last_activity_at` | | `dir` | string | `desc` | `asc` \| `desc` | -| `search` | string | — | نام، موبایل یا کد ملی بیمار | +| `search` | string | — | نام، موبایل، کد ملی بیمار **یا نام بیمه** | | `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` | | `insurance_id` | int | — | شناسه بیمه | +| `kind` | string | — | نوع بیمه: `base` \| `supplementary` (واژگان مطالبه `base` است، نه `basic`). مقدار نامعتبر → `422 ERR_VALIDATION_001` با `field: kind` | | `doctor_id` | int | — | پزشکِ نوبتِ مراجعه | | `payment_status` | string | — | `paid` (وصول‌شده) \| `unpaid` | | `from` / `to` | int | — | بازه‌ی `claims.created_at` (unix ثانیه) | @@ -230,25 +254,31 @@ "success": true, "data": [ { - "patient_uuid": "45064492-...", - "record_uuid": "ad0a3d0e-...", - "full_name": "تست جراحی بینی", - "mobile": "09370671756", + "patient_uuid": "649e24ae-08e2-489d-b502-af07c59dfa2d", + "record_uuid": "8e36035c-d234-42aa-858b-2427918e9c4a", + "full_name": "تست بیمه ۱", + "mobile": "09243243534", "national_code": null, - "claims_count": 2, - "total_services_rials": 81500000, - "total_insurance_rials": 57050000, - "total_patient_rials": 24450000, - "total_approved_rials": 28000000, - "total_paid_rials": 28000000, - "overall_status": "mixed", - "last_activity_at": 1784404115 + "claims_count": 3, + "total_services_rials": 91900000, + "total_insurance_rials": 9877000, + "total_patient_rials": 82023000, + "total_approved_rials": 0, + "total_paid_rials": 0, + "overall_status": "pending", + "insurances": [ + { "insurance_id": 176, "insurance_name": "تامین اجتماعی", "kind": "base" }, + { "insurance_id": 182, "insurance_name": "بیمه ایران", "kind": "supplementary" } + ], + "last_activity_at": 1785341593 } ], - "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 } + "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1, "limit": 1 } } ``` +- `insurances`: بیمه‌هایی که این بیمار زیرشان مطالبه دارد — یک بیمار می‌تواند هم‌زمان پایه و تکمیلی داشته باشد، پس ستون «بیمه» یک لیست است نه یک مقدار. + - `overall_status`: اگر همه‌ی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنه `mixed`. - ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`. - **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحساب‌های یکتا** جمع می‌شوند، نه از مطالبات. یک صورتحساب می‌تواند هم‌زمان مطالبه‌ی پایه و مکمل داشته باشد؛ جمع‌زدن از سمت مطالبه مبلغ خدمات را دوبار می‌شمرد. diff --git a/docs/api/insurance.md b/docs/api/insurance.md index 4e8c619b..5c03545c 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -33,7 +33,7 @@ hardcode نمی‌شود. ویزیت همیشه `outpatient` است. | ۴ | `coverage_percent` قرارداد (سازگاری با ردیف‌های قدیمی) | `tenant_insurances` | **fallback زنده است، نه کپی:** قراردادی که ردیف سطح ۲ ندارد، با تغییر پیش‌فرض ادمین -خودبه‌خود به‌روز می‌شود. `franchise_rials` فقط در قراردادهای `supplementary` اثر دارد. +خودبه‌خود به‌روز می‌شود. `franchise_percent` فقط در قراردادهای `supplementary` اثر دارد. --- @@ -527,7 +527,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv "version": 1, "is_active": true, "coverage_percent": 70, - "franchise_rials": 0, + "franchise_percent": 0, "annual_ceiling_rials": null, "kind": "basic", "effective_from": 1718900000, @@ -545,7 +545,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv | `category_coverages` | درصد **مؤثر** هر نوع خدمت پس از اجرای زنجیرهٔ resolve | | `category_coverage_source` | منبع هر درصد: `override` (خودِ قرارداد) · `admin_default` (تنظیمات مرکزی) · `contract` (ستون قدیمی `coverage_percent`) | | `coverage_percent` | ستون قدیمی قرارداد؛ فقط آخرین سطح fallback است | -| `franchise_rials` | فقط در قرارداد `supplementary` معنا دارد | +| `franchise_percent` | فقط در قرارداد `supplementary` معنا دارد | ### POST `/api/v1/billing/tenant-insurances` فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید. @@ -555,7 +555,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv |------|-----|-------| | `insurance_id` | int | الزامی | | `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام می‌فرستد | -| `franchise_rials` | int | فرانشیز — فقط در قرارداد `supplementary` اثر دارد | +| `franchise_percent` | float | فرانشیز **درصدی** (۰ تا ۱۰۰) — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد `supplementary` اثر دارد. خارج از بازه → `422` با `field: franchise_percent` | | `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) | | `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ | | `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون | @@ -563,6 +563,17 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv | `category_coverages` | array \| null | اختیاری — override درصد به تفکیک نوع خدمت. **نیامدنش** یعنی قرارداد روی پیش‌فرض مرکزی ادمین می‌ماند (fallback زنده) | | `category_coverages[].key` | string | یکی از مقادیر `GET /api/v1/service-categories` | | `category_coverages[].coverage_percent` | number \| null | ۰ تا ۱۰۰؛ `null` → override آن نوع حذف و به پیش‌فرض ادمین برمی‌گردد | + +**اجبار درصد برای نوع خدمتِ فعال:** با ارسال `category_coverages`، هر نوع خدمتی که در تنظیمات +همین tenant فعال است باید درصد مؤثر بزرگ‌تر از صفر داشته باشد — از خود payload، از override +قبلی، یا از پیش‌فرض مرکزی ادمین. ستون قدیمی `coverage_percent` قرارداد اینجا fallback حساب +نمی‌شود، وگرنه نوع خدمتی که درصدش نیامده بی‌صدا نرخ نوع دیگر را ارث می‌برد. + +```json +{ "success": false, "errors": [ + { "code": "ERR_VALIDATION_001", "field": "category_coverages", "message": "درصد پوشش خدمات بستری الزامی است" } +] } +``` | `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را به‌ازای پزشک هدف ذخیره می‌کند (نگاه کنید به «تنظیمات per-doctor» بالا) | ```json @@ -582,7 +593,7 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv ### PATCH `/api/v1/billing/tenant-insurances/{uuid}` ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری. -**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `category_coverages` (همان ساختار `POST`؛ ارسالش نیازمند مجوز `insurances.update` است وگرنه `403 ERR_FORBIDDEN_001`) · `doctor_uuid` (اختیاری، برای هدف‌گیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا). +**Body:** `coverage_percent` · `franchise_percent` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `category_coverages` (همان ساختار `POST`؛ ارسالش نیازمند مجوز `insurances.update` است وگرنه `403 ERR_FORBIDDEN_001`) · `doctor_uuid` (اختیاری، برای هدف‌گیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا). - `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate). - قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`. @@ -623,7 +634,7 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی "service_item_uuid": "…", "covered": true, "coverage_percent": 80, - "franchise_rials": null, + "franchise_percent": null, "ceiling_rials": null } ] @@ -641,7 +652,7 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی | `service_item_id` | int | جایگزین `service_item_uuid` (id داخلی) — یکی از این دو الزامی | | `covered` | bool | پیش‌فرض true | | `coverage_percent` | float \| null | null = ارث از قرارداد | -| `franchise_rials` | int \| null | null = ارث از قرارداد | +| `franchise_percent` | int \| null | null = ارث از قرارداد | | `ceiling_rials` | int \| null | null = ارث از قرارداد | | `doctor_uuid` | string (UUID) \| null | اختیاری — قراردادِ متعلق به پزشک هدف (نگاه کنید به «تنظیمات per-doctor» بالا) | diff --git a/docs/architecture/insurance-billing-system.md b/docs/architecture/insurance-billing-system.md index 95be6eac..36206bc9 100644 --- a/docs/architecture/insurance-billing-system.md +++ b/docs/architecture/insurance-billing-system.md @@ -107,7 +107,7 @@ final readonly class CoverageRule | version | int | نسخه‌ی قرارداد (۱، ۲، …) | | is_active | bool | غیرفعال بدون حذف | | coverage_percent | decimal(5,2) | پوشش پیش‌فرض قرارداد | -| franchise_rials | int | فرانشیز پیش‌فرض | +| franchise_percent | decimal(5,2) | فرانشیز **درصدی** — سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد | | annual_ceiling_rials | int null | سقف تعهد سالانه‌ی بیمار/بیمه | | effective_from | int (unix) | شروع اعتبار قرارداد | | effective_to | int null | پایان (null = جاری) | @@ -125,7 +125,7 @@ override پوشش یک خدمت خاص تحت یک بیمه‌ی tenant (آیا | service_item_id | int FK→service_items | | covered | bool | | coverage_percent | decimal(5,2) null (null=ارث از قرارداد) | -| franchise_rials | int null | +| franchise_percent | decimal(5,2) null (null=ارث از قرارداد) | | ceiling_rials | int null | UniqueConstraint: `(tenant_insurance_id, service_item_id)`. @@ -176,10 +176,6 @@ UniqueConstraint: `(service_item_id, year)`. `ServiceItem.priceRials` به‌ع | tariff_rials | int (تعرفه‌ی واحد) | | quantity | int | | total_rials | int (= tariff × qty) | -| base_coverage_percent | decimal(5,2) | -| supp_coverage_percent | decimal(5,2) | -| franchise_rials | int | -| ceiling_rials | int null | | base_insurance_rials | int | | supplementary_rials | int | | patient_rials | int | diff --git a/migrations/Version20260729155428.php b/migrations/Version20260729155428.php new file mode 100644 index 00000000..6233d3a8 --- /dev/null +++ b/migrations/Version20260729155428.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE appointments ADD insurance_supplementary_id INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE appointments DROP insurance_supplementary_id'); + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7e7cafe3..986afcfa 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -84,16 +84,10 @@ parameters: count: 1 path: src/Billing/Controller/BillingController.php - - - message: '#^Using nullsafe property access "\?\-\>franchiseRials" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' - identifier: nullsafe.neverNull - count: 1 - path: src/Billing/Service/BillingCalculator.php - - message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\\.$#' identifier: method.protected - count: 1 + count: 5 path: src/Billing/Service/ClaimService.php - diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index ce155dcd..961055fe 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -1059,6 +1059,7 @@ class AppointmentController extends BaseController // تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده. 'insurance_service_category' => $session->getInsuranceServiceCategory()?->value, 'insurance_base_id' => $session->getInsuranceBaseId(), + 'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(), 'gross_total_rials' => $session->getGrossTotalRials(), 'base_insurance_rials' => $session->getBaseInsuranceRials(), 'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(), diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 1552654b..06fe321f 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -195,6 +195,10 @@ class Appointment #[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)] private ?int $insuranceBaseId = null; + /** بیمهٔ تکمیلی روی باقیماندهٔ بعد از بیمهٔ پایه محاسبه می‌شود، نه روی کل مبلغ. */ + #[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)] + private ?int $insuranceSupplementaryId = null; + /** * Reserve-list entry (نوبت رزرو): booked for a day, not a time slot. * slotStart/slotEnd hold that day's midnight so date queries keep working. @@ -291,6 +295,7 @@ class Appointment public function getVisitPriceRials(): ?int { return $this->visitPriceRials; } public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; } public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; } + public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; } public function isReserve(): bool { return $this->isReserve; } public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; } @@ -301,6 +306,7 @@ class Appointment public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; } public function setInsuranceServiceCategory(?ServiceCategory $v): self { $this->insuranceServiceCategory = $v; $this->updatedAt = time(); return $this; } public function setInsuranceBaseId(?int $v): self { $this->insuranceBaseId = $v; $this->updatedAt = time(); return $this; } + public function setInsuranceSupplementaryId(?int $v): self { $this->insuranceSupplementaryId = $v; $this->updatedAt = time(); return $this; } /** * Move the appointment to a new slot (جا به جایی نوبت) and/or flip its @@ -422,6 +428,7 @@ class Appointment 'insurance_service_category' => $this->insuranceServiceCategory?->value, 'insurance_service_category_label' => $this->insuranceServiceCategory?->label(), 'insurance_base_id' => $this->insuranceBaseId, + 'insurance_supplementary_id' => $this->insuranceSupplementaryId, 'is_reserve' => $this->isReserve, 'version' => $this->version, 'created_at' => $this->createdAt, diff --git a/src/Appointment/Service/AppointmentInsuranceService.php b/src/Appointment/Service/AppointmentInsuranceService.php index bc91fe0c..0cf7bdea 100644 --- a/src/Appointment/Service/AppointmentInsuranceService.php +++ b/src/Appointment/Service/AppointmentInsuranceService.php @@ -73,7 +73,13 @@ class AppointmentInsuranceService if (array_key_exists('insurance_base_id', $data)) { $appointment->setInsuranceBaseId( - $this->resolveBaseInsuranceId($data['insurance_base_id'], $entityType, $entityId) + $this->resolveInsuranceId($data['insurance_base_id'], $entityType, $entityId, false) + ); + } + + if (array_key_exists('insurance_supplementary_id', $data)) { + $appointment->setInsuranceSupplementaryId( + $this->resolveInsuranceId($data['insurance_supplementary_id'], $entityType, $entityId, true) ); } } @@ -106,8 +112,15 @@ class AppointmentInsuranceService return $category; } - private function resolveBaseInsuranceId(mixed $raw, string $entityType, int $entityId): ?int + /** + * شناسهٔ بیمهٔ معتبر برای این نوبت، یا null وقتی انتخاب پاک شده است. + * قرارداد باید فعال باشد و نوعش با جایگاهی که در آن انتخاب شده بخواند — + * بیمهٔ تکمیلی نمی‌تواند جای بیمهٔ پایه بنشیند و برعکس. + */ + private function resolveInsuranceId(mixed $raw, string $entityType, int $entityId, bool $supplementary): ?int { + $field = $supplementary ? 'insurance_supplementary_id' : 'insurance_base_id'; + if ($raw === null || $raw === '' || (int) $raw <= 0) { return null; } @@ -119,18 +132,20 @@ class AppointmentInsuranceService ErrorCodes::ERR_VALIDATION_001, 'این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد', 422, - 'insurance_base_id', + $field, ); } // نوعِ قرارداد بر نوع کاتالوگ اولویت دارد — همان قاعدهٔ TenantInsuranceService. - $kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value; - if ($kind === InsuranceType::Supplementary->value) { + $kind = $contract->getKind() ?? $this->insuranceRepo->find($insuranceId)?->getType()->value; + $isSupp = $kind === InsuranceType::Supplementary->value; + + if ($isSupp !== $supplementary) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, - 'برای نوبت فقط بیمهٔ پایه قابل انتخاب است', + $supplementary ? 'اینجا فقط بیمهٔ تکمیلی قابل انتخاب است' : 'اینجا فقط بیمهٔ پایه قابل انتخاب است', 422, - 'insurance_base_id', + $field, ); } diff --git a/src/Billing/Command/BackfillMissingClaimsCommand.php b/src/Billing/Command/BackfillMissingClaimsCommand.php new file mode 100644 index 00000000..157eaffa --- /dev/null +++ b/src/Billing/Command/BackfillMissingClaimsCommand.php @@ -0,0 +1,74 @@ +addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی نساز'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + + $invoices = $this->invoiceRepo->findFinalizedInsuredWithoutClaims(); + if ($invoices === []) { + $io->success('همه‌ی صورتحساب‌های بیمه‌دار مطالبه دارند.'); + return Command::SUCCESS; + } + + $rows = []; + $created = 0; + foreach ($invoices as $invoice) { + $claims = $dryRun ? [] : $this->claimService->syncFromInvoice($invoice); + $created += count($claims); + + $rows[] = [ + $invoice->getUuid(), + $invoice->getEntityType() . '#' . $invoice->getEntityId(), + number_format($invoice->getTotalRials()), + number_format($invoice->getTotalRials() - $invoice->getPatientRials()), + $dryRun ? '—' : count($claims), + ]; + } + + $io->table(['صورتحساب', 'محیط', 'کل', 'سهم بیمه', 'مطالبهٔ ساخته‌شده'], $rows); + $io->success($dryRun + ? sprintf('%d صورتحساب بدون مطالبه پیدا شد (dry-run).', count($invoices)) + : sprintf('%d مطالبه برای %d صورتحساب ساخته شد.', $created, count($invoices))); + + return Command::SUCCESS; + } +} diff --git a/src/Billing/Event/InvoiceFinalized.php b/src/Billing/Event/InvoiceFinalized.php new file mode 100644 index 00000000..2ec35d4b --- /dev/null +++ b/src/Billing/Event/InvoiceFinalized.php @@ -0,0 +1,15 @@ +claimService->syncFromInvoice($event->invoice); + } catch (\Throwable $e) { + $this->logger->error('claim sync failed', [ + 'invoice' => $event->invoice->getUuid(), + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/Billing/Repository/ClaimRepository.php b/src/Billing/Repository/ClaimRepository.php index 19c5e2c9..7202926f 100644 --- a/src/Billing/Repository/ClaimRepository.php +++ b/src/Billing/Repository/ClaimRepository.php @@ -390,6 +390,26 @@ class ClaimRepository extends ServiceEntityRepository return $count > 0; } + /** + * نوع مطالبه‌هایی که برای این صورتحساب از قبل ثبت شده‌اند (`base` / `supplementary`). + * مبنای idempotent بودنِ ساخت خودکار مطالبه است. + * + * @return list + */ + public function kindsForInvoice(int $invoiceId): array + { + $rows = $this->createQueryBuilder('c') + ->select('DISTINCT c.insuranceKind AS kind') + ->join('c.items', 'ci') + ->join('App\Billing\Entity\InvoiceItem', 'ii', 'WITH', 'ii.id = ci.invoiceItemId') + ->where('IDENTITY(ii.invoice) = :invoiceId') + ->setParameter('invoiceId', $invoiceId) + ->getQuery() + ->getArrayResult(); + + return array_column($rows, 'kind'); + } + public function save(Claim $entity, bool $flush = true): void { $this->getEntityManager()->persist($entity); diff --git a/src/Billing/Repository/InvoiceRepository.php b/src/Billing/Repository/InvoiceRepository.php index 7bf46053..a9fcfc88 100644 --- a/src/Billing/Repository/InvoiceRepository.php +++ b/src/Billing/Repository/InvoiceRepository.php @@ -29,6 +29,28 @@ class InvoiceRepository extends ServiceEntityRepository return $this->findOneBy(['patientSessionId' => $patientSessionId]); } + /** + * صورتحساب‌های نهایی‌شده‌ای که بیمه‌ای روی خودشان دارند ولی هیچ مطالبه‌ای برایشان + * ثبت نشده — بازمانده‌های دورانی که مطالبه فقط در یک مسیر ساخته می‌شد. + * + * @return Invoice[] + */ + public function findFinalizedInsuredWithoutClaims(): array + { + return $this->createQueryBuilder('i') + ->where('i.status = :finalized') + ->andWhere('i.baseInsuranceId IS NOT NULL OR i.supplementaryInsuranceId IS NOT NULL') + ->andWhere('NOT EXISTS ( + SELECT 1 FROM App\Billing\Entity\ClaimItem ci + JOIN App\Billing\Entity\InvoiceItem ii WITH ii.id = ci.invoiceItemId + WHERE IDENTITY(ii.invoice) = i.id + )') + ->setParameter('finalized', Invoice::STATUS_FINALIZED) + ->orderBy('i.id', 'ASC') + ->getQuery() + ->getResult(); + } + /** * A flat, newest-first page of a tenant's recorded (finalized/paid) invoices, * one row per invoice with the patient's name and national code joined in. diff --git a/src/Billing/Service/ClaimService.php b/src/Billing/Service/ClaimService.php index 15568532..f0f3e23e 100644 --- a/src/Billing/Service/ClaimService.php +++ b/src/Billing/Service/ClaimService.php @@ -72,6 +72,53 @@ class ClaimService return $claims; } + /** + * مطالبات جاافتاده‌ی یک صورتحساب نهایی‌شده را می‌سازد و مطالبات موجود را دست نمی‌زند. + * برخلاف createFromInvoice که یک عملِ کاربر است و با خطا جواب می‌دهد، این متد + * روی رویدادِ نهایی‌شدن صدا زده می‌شود و «کاری برای انجام نبود» حالت عادی‌اش است. + * + * @return Claim[] مطالبات تازه‌ساخته‌شده + */ + public function syncFromInvoice(Invoice $invoice): array + { + if ($invoice->getStatus() !== Invoice::STATUS_FINALIZED || $invoice->getId() === null) { + return []; + } + + $existingKinds = $this->claimRepo->kindsForInvoice($invoice->getId()); + + $claims = []; + foreach ([ + Claim::KIND_BASE => $invoice->getBaseInsuranceId(), + Claim::KIND_SUPPLEMENTARY => $invoice->getSupplementaryInsuranceId(), + ] as $kind => $insuranceId) { + if ($insuranceId === null || in_array($kind, $existingKinds, true)) { + continue; + } + + $claim = $this->buildClaim($invoice, $insuranceId, $kind); + if ($claim !== null) { + $claims[] = $claim; + } + } + + if ($claims === []) { + return []; + } + + foreach ($claims as $claim) { + $this->claimRepo->save($claim, false); + } + $this->claimRepo->getEntityManager()->flush(); + + foreach ($claims as $claim) { + $this->logTransition($claim, null, $claim->getStatus(), 'ایجاد مطالبه', null); + } + $this->claimRepo->getEntityManager()->flush(); + + return $claims; + } + private function buildClaim(Invoice $invoice, int $insuranceId, string $kind): ?Claim { $claim = new Claim($invoice->getEntityType(), $invoice->getEntityId(), $insuranceId, $kind); diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index 81adc0ce..2e75ccf4 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -4,6 +4,7 @@ namespace App\Billing\Service; use App\Billing\Entity\Invoice; use App\Billing\Entity\InvoiceItem; +use App\Billing\Event\InvoiceFinalized; use App\Billing\Repository\InvoiceRepository; use App\Billing\ValueObject\Money; use App\ClinicService\Service\TariffService; @@ -12,6 +13,7 @@ use App\Insurance\Repository\InsuranceRepository; use App\Insurance\Service\TenantInsuranceService; use App\Patient\Entity\PatientSession; use App\Patient\Repository\PatientSessionRepository; +use Psr\EventDispatcher\EventDispatcherInterface; class InvoiceService { @@ -22,6 +24,7 @@ class InvoiceService private readonly BillingCalculator $calculator, private readonly PatientSessionRepository $sessionRepo, private readonly InsuranceRepository $insuranceRepo, + private readonly EventDispatcherInterface $events, ) {} /** @var array نام بیمه‌ها، یک‌بار در هر درخواست. */ @@ -89,10 +92,13 @@ class InvoiceService return $invoice; } + /** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */ public function finalize(Invoice $invoice): void { $invoice->finalize(); $this->invoiceRepo->save($invoice); + + $this->events->dispatch(new InvoiceFinalized($invoice)); } /** diff --git a/src/Billing/Service/SessionBillingService.php b/src/Billing/Service/SessionBillingService.php new file mode 100644 index 00000000..1b6614d1 --- /dev/null +++ b/src/Billing/Service/SessionBillingService.php @@ -0,0 +1,48 @@ +getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) { + return null; + } + + try { + $invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId); + $this->invoiceService->finalize($invoice); + + return $invoice; + } catch (\Throwable $e) { + $this->logger->error('finalizing the insured session invoice failed', [ + 'session' => $session->getUuid(), + 'error' => $e->getMessage(), + ]); + + return null; + } + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index a707823a..21161994 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -5,9 +5,7 @@ namespace App\Patient\Controller; use App\Auth\Entity\User; use App\Auth\Repository\UserRepository; use App\Billing\Repository\InvoiceRepository; -use App\Billing\Service\ClaimService; -use App\Billing\Service\InvoiceService; -use Psr\Log\LoggerInterface; +use App\Billing\Service\SessionBillingService; use App\Patient\Entity\PatientRecord; use App\Patient\Repository\PatientRecordRepository; use App\Patient\Repository\PatientSessionRepository; @@ -40,8 +38,7 @@ class PatientController extends BaseController private readonly PatientRecordScopeResolver $scopeResolver, private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, private readonly \App\Insurance\Repository\InsuranceRepository $insuranceRepo, - private readonly InvoiceService $invoiceService, - private readonly ClaimService $claimService, + private readonly SessionBillingService $sessionBilling, private readonly InvoiceRepository $invoiceRepo, private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo, private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo, @@ -60,7 +57,6 @@ class PatientController extends BaseController private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo, private readonly SecretaryAccessChecker $secretaryAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, - private readonly LoggerInterface $logger, ) {} // ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ──────────────────────────── @@ -1058,34 +1054,11 @@ class PatientController extends BaseController $data = json_decode($request->getContent(), true) ?? []; $session = $this->patientService->createSession($record, $data, $entityType, $entityId); - $this->autoCreateClaim($session, $entityType, $entityId); + $this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId); return $this->success($this->sessionWithBilling($session), 201); } - /** - * اتصال خودکار مطالبه‌ی بیمه: اگر session بیمه‌ی پایه یا مکمل داشته باشد، - * صورتحساب ساخته و نهایی می‌شود و مطالبه (در وضعیت pending) ایجاد می‌گردد. - * ارسال مطالبه دستی از صفحه‌ی مطالبات انجام می‌شود. - * خطا در این مرحله نباید ثبت session را خراب کند. - */ - private function autoCreateClaim(\App\Patient\Entity\PatientSession $session, string $entityType, int $entityId): void - { - if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) { - return; - } - - try { - $invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId); - $this->invoiceService->finalize($invoice); - $this->claimService->createFromInvoice($invoice); - } catch (\App\Shared\Exception\AppException $e) { - // سهم بیمه‌ای صفر بود یا صورتحساب از قبل وجود داشت — قابل چشم‌پوشی. - $this->logger->info('auto claim skipped', ['session' => $session->getUuid(), 'reason' => $e->getMessage()]); - } catch (\Throwable $e) { - $this->logger->error('auto claim failed', ['session' => $session->getUuid(), 'error' => $e->getMessage()]); - } - } #[Route('/api/v1/session/{uuid}', methods: ['PATCH'])] public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse @@ -1112,6 +1085,10 @@ class PatientController extends BaseController || array_key_exists('visit_price_rials', $data) || array_key_exists('insurance_base_id', $data) || array_key_exists('base_insurance_discount_percent', $data)) { $this->patientService->updateSessionServices($session, $data, $entityType, $entityId, $user); + + // بیمه ممکن است همین حالا به مراجعه اضافه شده باشد؛ بدون این، مراجعه‌ای که + // بیمه‌اش بعداً ثبت می‌شود هرگز صورتحساب و مطالبه نمی‌گیرد. + $this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId); } // تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع. diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index c8cd7a22..6ea32927 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -52,6 +52,7 @@ class PatientService private readonly TenantInsuranceService $tenantInsuranceService, private readonly AppointmentInsuranceService $appointmentInsurance, private readonly BillingCalculator $billingCalculator, + private readonly \App\Billing\Service\SessionBillingService $sessionBilling, private readonly WalletService $walletService, private readonly EntityInsurancePricingRepository $pricingRepo, private readonly \App\Discount\Service\DiscountEngine $discountEngine, @@ -238,9 +239,13 @@ class PatientService $category = $this->appointmentInsurance->effectiveCategory($appointment); $session->setInsuranceServiceCategory($category); $session->setInsuranceBaseId($appointment->getInsuranceBaseId()); + $session->setInsuranceSupplementaryId($appointment->getInsuranceSupplementaryId()); $session->setBaseInsuranceDiscountPercent( $this->contractPercent($entityType, $entityId, $appointment->getInsuranceBaseId(), $category) ); + $session->setSupplementaryDiscountPercent( + $this->contractPercent($entityType, $entityId, $appointment->getInsuranceSupplementaryId(), $category) + ); $shares = $this->calculateFinalPrice( $visitPrice, @@ -251,7 +256,7 @@ class PatientService $entityType, $entityId, $appointment->getInsuranceBaseId(), - null, + $appointment->getInsuranceSupplementaryId(), $category, ); @@ -269,6 +274,10 @@ class PatientService $session->addService($line); } + // مراجعهٔ بیمه‌دارِ آمده از نوبت هم باید صورتحساب و مطالبه بگیرد؛ بدون این، + // نوبت‌های قطعی‌شده هرگز به داشبورد مطالبات نمی‌رسیدند. + $this->sessionBilling->ensureFinalizedInvoice($session, $entityType, $entityId); + return $session; } diff --git a/tests/Billing/ClaimAutoCreationTest.php b/tests/Billing/ClaimAutoCreationTest.php new file mode 100644 index 00000000..7f9f1a0d --- /dev/null +++ b/tests/Billing/ClaimAutoCreationTest.php @@ -0,0 +1,125 @@ +createUser(['ROLE_DOCTOR']); + $this->doctor = new Doctor($owner, 'دکتر مطالبه'); + $this->em->persist($this->doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $this->record = new PatientRecord('doctor', $this->doctor->getId(), $patient, 'doctor', $this->doctor->getId()); + $this->em->persist($this->record); + $this->em->flush(); + } + + /** صورتحساب draft با سهم بیمهٔ پایه/تکمیلی روی تنها آیتمش. */ + private function draftInvoice(?int $baseId, ?int $suppId, int $baseShare, int $suppShare): Invoice + { + $total = 10_000_000; + $invoice = new Invoice('doctor', $this->doctor->getId()); + $invoice->setPatientRecordId((int) $this->record->getId()) + ->setBaseInsuranceId($baseId) + ->setSupplementaryInsuranceId($suppId); + + $item = new InvoiceItem( + $invoice, + 'ویزیت', + $total, + 1, + new ShareBreakdown($total, $baseShare, $suppShare, $total - $baseShare - $suppShare), + ); + $invoice->addItem($item); + $invoice->recalculateTotals(); + + $this->em->persist($invoice); + $this->em->persist($item); + $this->em->flush(); + + return $invoice; + } + + private function claimRepo(): ClaimRepository + { + return static::getContainer()->get(ClaimRepository::class); + } + + private function invoiceService(): InvoiceService + { + return static::getContainer()->get(InvoiceService::class); + } + + public function testFinalizingAnInsuredInvoiceCreatesItsClaims(): void + { + $invoice = $this->draftInvoice(1, 2, 6_000_000, 2_000_000); + + $this->invoiceService()->finalize($invoice); + + $kinds = $this->claimRepo()->kindsForInvoice((int) $invoice->getId()); + sort($kinds); + self::assertSame(['base', 'supplementary'], $kinds); + } + + public function testASupplementaryOnlyInvoiceStillGetsItsClaim(): void + { + // همان حالتی که در پنل دیده شد: بیمار بیمهٔ پایه ندارد، فقط تکمیلی. + $invoice = $this->draftInvoice(null, 2, 0, 4_760_000); + + $this->invoiceService()->finalize($invoice); + + self::assertSame(['supplementary'], $this->claimRepo()->kindsForInvoice((int) $invoice->getId())); + } + + public function testFinalizingTwiceDoesNotDuplicateClaims(): void + { + $invoice = $this->draftInvoice(1, null, 3_000_000, 0); + + $this->invoiceService()->finalize($invoice); + $this->invoiceService()->finalize($invoice); + + self::assertCount(1, $this->claimRepo()->kindsForInvoice((int) $invoice->getId())); + } + + public function testAnInvoiceWithoutAnyInsuranceShareGetsNoClaim(): void + { + $invoice = $this->draftInvoice(null, null, 0, 0); + + $this->invoiceService()->finalize($invoice); + + self::assertSame([], $this->claimRepo()->kindsForInvoice((int) $invoice->getId())); + } + + /** بیمه‌ای که فقط روی صورتحساب ثبت شده ولی سهمی نبرده، مطالبه نمی‌سازد. */ + public function testAZeroShareInsuranceIsNotClaimed(): void + { + $invoice = $this->draftInvoice(1, 2, 6_000_000, 0); + + $this->invoiceService()->finalize($invoice); + + self::assertSame(['base'], $this->claimRepo()->kindsForInvoice((int) $invoice->getId())); + } +} diff --git a/tests/Patient/AppointmentSupplementaryInsuranceTest.php b/tests/Patient/AppointmentSupplementaryInsuranceTest.php new file mode 100644 index 00000000..9e012ff7 --- /dev/null +++ b/tests/Patient/AppointmentSupplementaryInsuranceTest.php @@ -0,0 +1,153 @@ +doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر بیمه تکمیلی'); + $this->em->persist($this->doctor); + + $this->basic = new Insurance('پایهٔ تست ' . random_int(1000, 9999), InsuranceType::Basic); + $this->supplementary = new Insurance('تکمیلی تست ' . random_int(1000, 9999), InsuranceType::Supplementary); + $this->em->persist($this->basic); + $this->em->persist($this->supplementary); + $this->em->flush(); + } + + private function contract(Insurance $insurance, float $percent, float $franchise = 0.0): TenantInsurance + { + $service = static::getContainer()->get(TenantInsuranceService::class); + $contract = $service->activate( + TenantInsurance::TYPE_DOCTOR, + (int) $this->doctor->getId(), + (int) $insurance->getId(), + $percent, + $franchise, + ); + $service->setCategoryCoverages($contract, [ + ['key' => 'outpatient', 'coverage_percent' => $percent], + ['key' => 'inpatient', 'coverage_percent' => $percent], + ]); + + return $contract; + } + + private function appointmentWithVisitPrice(int $rials): Appointment + { + $appointment = $this->newAppointment($this->doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800); + $appointment->setVisitPriceRials($rials); + $appointment->setInsuranceServiceCategory(ServiceCategory::Outpatient); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + public function testTheSessionBillsBaseThenSupplementaryOnTheRemainder(): void + { + $this->contract($this->basic, 30.0); + $this->contract($this->supplementary, 90.0, 10.0); + + $appointment = $this->appointmentWithVisitPrice(10_000_000); + static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [ + 'insurance_base_id' => $this->basic->getId(), + 'insurance_supplementary_id' => $this->supplementary->getId(), + ]); + $this->em->flush(); + + $session = static::getContainer()->get(PatientService::class)->autoCreateOnAppointmentConfirm($appointment); + + // پایه ۳۰٪ از ۱۰,۰۰۰,۰۰۰ → ۳,۰۰۰,۰۰۰؛ باقیمانده ۷,۰۰۰,۰۰۰؛ + // تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۵,۶۰۰,۰۰۰؛ سهم بیمار ۱,۴۰۰,۰۰۰. + self::assertSame((int) $this->supplementary->getId(), $session->getInsuranceSupplementaryId()); + self::assertSame(3_000_000, $session->getBaseInsuranceRials()); + self::assertSame(5_600_000, $session->getSupplementaryInsuranceRials()); + self::assertSame(1_400_000, $session->getPatientShareRials()); + self::assertSame( + $session->getGrossTotalRials(), + $session->getBaseInsuranceRials() + $session->getSupplementaryInsuranceRials() + $session->getPatientShareRials(), + ); + } + + public function testASupplementaryInsuranceCannotBePickedAsTheBasicOne(): void + { + $this->contract($this->supplementary, 90.0); + $appointment = $this->appointmentWithVisitPrice(1_000_000); + + $this->expectExceptionMessage('اینجا فقط بیمهٔ پایه قابل انتخاب است'); + static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [ + 'insurance_base_id' => $this->supplementary->getId(), + ]); + } + + public function testABasicInsuranceCannotBePickedAsTheSupplementaryOne(): void + { + $this->contract($this->basic, 30.0); + $appointment = $this->appointmentWithVisitPrice(1_000_000); + + $this->expectExceptionMessage('اینجا فقط بیمهٔ تکمیلی قابل انتخاب است'); + static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [ + 'insurance_supplementary_id' => $this->basic->getId(), + ]); + } + + public function testAnInsuranceWithoutAnActiveContractIsRefused(): void + { + $appointment = $this->appointmentWithVisitPrice(1_000_000); + + $this->expectExceptionMessage('این بیمه برای این پزشک/کلینیک قرارداد فعال ندارد'); + static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [ + 'insurance_supplementary_id' => $this->supplementary->getId(), + ]); + } + + /** مراجعهٔ تکمیلی‌دار باید مطالبهٔ تکمیلی هم بگیرد، نه فقط پایه. */ + public function testTheConfirmedVisitProducesBothClaims(): void + { + $this->contract($this->basic, 30.0); + $this->contract($this->supplementary, 90.0, 10.0); + + $appointment = $this->appointmentWithVisitPrice(10_000_000); + static::getContainer()->get(AppointmentInsuranceService::class)->apply($appointment, [ + 'insurance_base_id' => $this->basic->getId(), + 'insurance_supplementary_id' => $this->supplementary->getId(), + ]); + $this->em->flush(); + + // قطعی‌کردن به‌تنهایی باید صورتحساب و مطالبه بسازد — بدون هیچ گام دستی. + $session = static::getContainer()->get(PatientService::class)->autoCreateOnAppointmentConfirm($appointment); + + $invoice = static::getContainer()->get(\App\Billing\Repository\InvoiceRepository::class) + ->findBySession((int) $session->getId()); + self::assertNotNull($invoice, 'مراجعهٔ بیمه‌دارِ آمده از نوبت باید صورتحساب داشته باشد'); + + $kinds = static::getContainer()->get(ClaimRepository::class)->kindsForInvoice((int) $invoice->getId()); + sort($kinds); + self::assertSame(['base', 'supplementary'], $kinds); + } +}