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:
hamed
2026-07-29 19:57:02 +03:30
parent 9b05c6d1ff
commit e6267080b2
25 changed files with 876 additions and 126 deletions
+25 -10
View File
@@ -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.
+51 -21
View File
@@ -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:** مبالغ خدمات/سهم بیمار از **صورتحساب‌های یکتا** جمع می‌شوند، نه از مطالبات. یک صورتحساب می‌تواند هم‌زمان مطالبه‌ی پایه و مکمل داشته باشد؛ جمع‌زدن از سمت مطالبه مبلغ خدمات را دوبار می‌شمرد.
+18 -7
View File
@@ -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» بالا) |