Refactor insurance share calculation logic in PatientService
- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator. - Introduced new fields in PatientSession to store breakdown of insurance shares and patient share. - Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard. - Added migration to backfill existing sessions with appropriate values for the new fields. - Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total. - Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
This commit is contained in:
@@ -103,7 +103,6 @@
|
||||
"price_rials": 500000,
|
||||
"active": true,
|
||||
"insurance_covered": false,
|
||||
"insurance_price_rials": null,
|
||||
"duration_minutes": 50,
|
||||
"bookable": true,
|
||||
"created_at": 1718000000,
|
||||
@@ -137,7 +136,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
"price_rials": 850000,
|
||||
"active": true,
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": null,
|
||||
"duration_minutes": 30,
|
||||
"bookable": true,
|
||||
"staff": { "uuid": "...", "full_name": "مریم امینی" },
|
||||
@@ -213,7 +211,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
"price_rials": 500000,
|
||||
"staff_uuid": "...",
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": 200000,
|
||||
"duration_minutes": 50,
|
||||
"bookable": true
|
||||
}
|
||||
@@ -227,7 +224,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده میشود |
|
||||
| staff_uuid | UUID | ❌ — legacy تکپرسنل (اگر `staff_uuids` نباشد استفاده میشود) |
|
||||
| insurance_covered | boolean | ❌ (پیشفرض false) — **deprecated برای نوشتن.** پنل ادمین دیگر این فیلد را نمیفرستد؛ مقدارش بهصورت خودکار از ردیفهای پوشش بیمه همگام میشود (به [insurance.md](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را میپذیرد تا کلاینتهای قدیمی نشکنند، ولی ذخیرهی پوشش بعداً آن را بازنویسی میکند |
|
||||
| insurance_price_rials | integer\|null | ❌ — **deprecated.** سهم تقریبی بیمار؛ از فرم سرویس حذف شد. محاسبهی دقیق سهم بیمار از `TenantServiceCoverage` انجام میشود |
|
||||
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
|
||||
| bookable | boolean | ❌ (پیشفرض false) — «نمایش در نوبتدهی». فقط سرویسهای `bookable=true` در حالت نوبتدهی سرویسی قابلانتخاباند |
|
||||
| inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` |
|
||||
@@ -235,7 +231,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
|
||||
> `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته میشود.
|
||||
|
||||
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
|
||||
**Response 201:** ServiceItem object (شامل `insurance_covered`)
|
||||
|
||||
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` بهصورت خودکار ثبت میشود. قیمت سرویس = تعرفهی سال جاری است و همهجا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده میشود.
|
||||
|
||||
@@ -252,7 +248,6 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|
||||
"staff_uuid": null,
|
||||
"active": false,
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": 250000,
|
||||
"duration_minutes": 30
|
||||
}
|
||||
```
|
||||
|
||||
+23
-1
@@ -340,7 +340,12 @@ Returns paginated sessions for a patient record.
|
||||
"base_insurance_discount_percent": "10.00",
|
||||
"supplementary_discount_percent": "5.00",
|
||||
"services_total_rials": 50000,
|
||||
"gross_total_rials": 250000,
|
||||
"base_insurance_rials": 20000,
|
||||
"supplementary_insurance_rials": 0,
|
||||
"patient_share_rials": 230000,
|
||||
"final_price_rials": 230000,
|
||||
"remaining_rials": 0,
|
||||
"payment_method": "cash",
|
||||
"is_paid": true,
|
||||
"services": [
|
||||
@@ -378,6 +383,20 @@ Returns paginated sessions for a patient record.
|
||||
| `invoice_status` | string\|null | `draft`\|`finalized`\|`paid`\|`void` |
|
||||
| `patient_debt_rials` | int | مانده بدهی سهم بیمار؛ `0` اگر تسویه شده، وگرنه سهم بیمارِ فاکتور یا `final_price_rials` |
|
||||
|
||||
**تفکیک بیمه (پایا روی خود مراجعه):**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `gross_total_rials` | int | مبلغ کل پیش از بیمه: ویزیت + خدمات + کالاهای مصرفی |
|
||||
| `base_insurance_rials` | int | سهم بیمه پایه |
|
||||
| `supplementary_insurance_rials` | int | سهم بیمه تکمیلی (روی **باقیمانده پس از پایه** محاسبه میشود، نه روی کل) |
|
||||
| `patient_share_rials` | int | سهم بیمار پیش از تخفیف دستی؛ همیشه برابر `final_price_rials` |
|
||||
| `remaining_rials` | int | مانده: `max(0, patient_share − discount_rials − paid_total_rials)` |
|
||||
|
||||
ثابت همیشگی: `gross_total_rials = base_insurance_rials + supplementary_insurance_rials + patient_share_rials`
|
||||
|
||||
> این مقادیر را **سرور** با `BillingCalculator` محاسبه و روی `patient_sessions` ذخیره میکند (`PatientSession::applyShares`). کلاینت هرگز نباید سهمها یا مانده را خودش بسازد — صفحهی تکمیل پرداخت، مودال فاکتور و داشبورد Claims همگی باید همین فیلدها را بخوانند تا اختلاف محاسباتی ممکن نباشد. مراجعات پیش از این تغییر با `patient_share = final_price` و سهم بیمه صفر backfill شدهاند.
|
||||
|
||||
> **ثبت پرداخت («تکمیل پرداخت»):** از همان `PATCH /api/v1/session/{uuid}` با بدنهی `{ "payment_method": "cash" }` استفاده میشود؛ پس از آن `is_paid=true` و `patient_debt_rials=0` میشود. مشاهدهی فاکتور از `GET /api/v1/billing/invoices/{invoice_uuid}` (این endpoint اکنون برای منشیِ فعال هم در دسترس است).
|
||||
|
||||
**Errors:**
|
||||
@@ -483,10 +502,13 @@ Creates a new visit session for a patient record.
|
||||
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته میشود، وگرنه بیصدا نادیده گرفته میشود. روی قیمت اثری ندارد (فقط مرجع).
|
||||
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیشفرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه میشود. آیتم ناموجود یا متعلق به tenant دیگر بیصدا رد میشود (همرفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است.
|
||||
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیشفرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
|
||||
- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد قرارداد فعال (`TenantInsurance.coveragePercent`) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.
|
||||
- `final_price_rials` (سهم بیمار) به این صورت محاسبه میشود:
|
||||
- **ویزیت:** `round(visit_price × (1 - base%) × (1 - supp%))` با درصدهای انتخابشده در فرم.
|
||||
- **ویزیت:** با قاعدهی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` — همان مسیری که `InvoiceService` برای صدور فاکتور میرود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب میشد و با فاکتور واگرا میشد.)
|
||||
- **هر خدمت:** سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است.
|
||||
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی در `consumables_total_rials` جدا گزارش میشوند.
|
||||
- **گیت پوشش:** اگر `ServiceItem.insurance_covered` غیرفعال باشد یا برای tenant قرارداد فعالی نباشد، هیچ پوششی اعمال نمیشود و کل مبلغ سهم بیمار است. این پرچم دستی ست نمیشود؛ از ردیفهای `TenantServiceCoverage` سینک میشود ([insurance.md](insurance.md)).
|
||||
- **سقف:** `annual_ceiling_rials` با وجود نامش بهصورت **سقف هر قلم** اعمال میشود؛ انباشت سالانهای در کد وجود ندارد.
|
||||
- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیشنمایش پنل هم همین قاعده را سمت کلاینت آینه میکند.
|
||||
|
||||
**اتصال خودکار مطالبهی بیمه:** اگر session دارای `insurance_base_id` یا `insurance_supplementary_id` باشد، پس از ثبت بهصورت خودکار صورتحساب ساخته و نهایی میشود و مطالبه(های) بیمه در وضعیت `pending` ایجاد میگردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحهی [مطالبات بیمه](billing.md) قابل پیگیری و ارسالاند. خطا در این مرحله ثبت session را خراب نمیکند (لاگ میشود). برای هر صورتحساب فقط یکبار مطالبه ساخته میشود.
|
||||
|
||||
Reference in New Issue
Block a user