feat(insurance): resolve coverage percent per service category

Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 16:21:19 +03:30
co-authored by Claude Opus 5
parent 1a9eda3576
commit 58c6d9ac18
41 changed files with 2558 additions and 143 deletions
+147 -12
View File
@@ -10,6 +10,33 @@ Two resource types:
---
## قاعدهٔ درصد پوشش (Coverage percent model)
سهم بیمهٔ پایه فقط درصدی است:
```
سهم بیمهٔ پایه = round(مبلغ کل × درصد پوشش ÷ 100)
سهم بیمار = مبلغ کل − سهم بیمهٔ پایه (فرانشیز در بیمهٔ پایه دخالت ندارد)
```
درصد پوشش به تفکیک **نوع خدمت** تعیین می‌شود. لیست انواع از `GET /api/v1/service-categories`
می‌آید (فعلاً `outpatient` = خدمات سرپایی و `inpatient` = خدمات بستری) و هرگز در کلاینت
hardcode نمی‌شود. ویزیت همیشه `outpatient` است.
درصد مؤثر به این ترتیب resolve می‌شود (اولین مقدار موجود برنده است):
| اولویت | منبع | جدول |
|---|---|---|
| ۱ | override همان خدمت | `tenant_service_coverage.coverage_percent` |
| ۲ | override قرارداد برای نوع خدمت | `tenant_insurance_category_coverage` |
| ۳ | پیش‌فرض مرکزی ادمین (اگر > ۰ باشد) | `insurance_coverage_defaults` |
| ۴ | `coverage_percent` قرارداد (سازگاری با ردیف‌های قدیمی) | `tenant_insurances` |
**fallback زنده است، نه کپی:** قراردادی که ردیف سطح ۲ ندارد، با تغییر پیش‌فرض ادمین
خودبه‌خود به‌روز می‌شود. `franchise_rials` فقط در قراردادهای `supplementary` اثر دارد.
---
## GET `/api/v1/insurances`
List all active insurances.
@@ -31,19 +58,24 @@ List all active insurances.
"name": "بیمه تأمین اجتماعی",
"type": "basic",
"logo_url": "https://...",
"status": "active"
"status": "active",
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
},
{
"id": 2,
"name": "بیمه ایران",
"type": "supplementary",
"logo_url": "https://...",
"status": "active"
"status": "active",
"coverage_defaults": { "outpatient": 0, "inpatient": 0 }
}
]
}
```
`coverage_defaults` درصدهای مرکزی ادمین به تفکیک نوع خدمت است؛ همیشه همهٔ نوع‌ها حاضرند
(نبودِ ردیف = `0`). پنل پزشک هنگام ساخت قرارداد همین مقادیر را پیش‌فرض بار می‌کند.
---
## GET `/api/v1/admin/insurances`
@@ -61,10 +93,21 @@ List all insurances with pagination (admin view — includes inactive).
| `type` | string | ❌ | `"basic"` or `"supplementary"` |
### Response `200`
هر ردیف علاوه بر فیلدهای بیمه، `coverage_defaults` خود را هم دارد (یک کوئری برای کل صفحه، بدون N+1).
```json
{
"success": true,
"data": [ ... ],
"data": [
{
"id": 1,
"name": "بیمه تأمین اجتماعی",
"type": "basic",
"logo_url": "https://...",
"status": "active",
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
}
],
"meta": { "totalRecords": 15, "totalPages": 1, "currentPage": 1 }
}
```
@@ -77,6 +120,72 @@ List all insurances with pagination (admin view — includes inactive).
---
## GET `/api/v1/admin/insurance/{id}/coverage-defaults`
درصدهای پوشش مرکزی یک بیمه به تفکیک نوع خدمت. همیشه **همهٔ** نوع‌ها برمی‌گردند
(ردیف نداشته = `0`)، تا پنل ادمین جدول کامل نشان دهد.
**Permission:** `ROLE_ADMIN`
### Response `200`
```json
{
"success": true,
"data": {
"insurance_id": 3,
"categories": [
{ "key": "outpatient", "label": "خدمات سرپایی", "coverage_percent": 70 },
{ "key": "inpatient", "label": "خدمات بستری", "coverage_percent": 30 }
]
}
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_VALIDATION_002` | 404 | بیمه یافت نشد |
---
## PUT `/api/v1/admin/insurance/{id}/coverage-defaults`
ذخیرهٔ درصدهای مرکزی. تغییر این مقادیر بی‌درنگ روی همهٔ قراردادهایی که برای همان نوع خدمت
override ندارند اثر می‌گذارد.
**Permission:** `ROLE_ADMIN`
### Request Body (`application/json`)
```json
{
"categories": [
{ "key": "outpatient", "coverage_percent": 70 },
{ "key": "inpatient", "coverage_percent": 30 }
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `categories` | array | ✅ | ردیف‌هایی که باید ذخیره شوند؛ ردیف‌های نیامده دست‌نخورده می‌مانند |
| `categories[].key` | string | ✅ | یکی از مقادیر `GET /api/v1/service-categories` |
| `categories[].coverage_percent` | number | ✅ | ۰ تا ۱۰۰ |
### Response `200`
همان ساختار پاسخِ `GET` (وضعیت پس از ذخیره).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_VALIDATION_002` | 404 | بیمه یافت نشد |
| `ERR_VALIDATION_001` | 422 | `key` نامعتبر یا درصد خارج از بازهٔ ۰ تا ۱۰۰ |
---
## POST `/api/v1/admin/insurance`
Create a new insurance.
@@ -307,20 +416,23 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
"insurance_id": 3,
"insurance_name": "تأمین اجتماعی",
"type": "basic",
"patient_share_rials": 1500000
"patient_share_rials": 1500000,
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
},
{
"insurance_id": 9,
"insurance_name": "دانا",
"type": "supplementary",
"patient_share_rials": null
"patient_share_rials": null,
"coverage_defaults": { "outpatient": 0, "inpatient": 0 }
}
]
}
}
```
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد).
- `coverage_defaults` — درصدهای مرکزی ادمین؛ پنل پزشک هنگام افزودن قرارداد از همین پر می‌کند.
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد). این مقدار **ورودی هیچ محاسبه‌ای نیست**؛ محاسبهٔ سهم فقط از درصد پوشش انجام می‌شود.
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند.
### خطاها
@@ -399,13 +511,22 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
"annual_ceiling_rials": null,
"kind": "basic",
"effective_from": 1718900000,
"effective_to": null
"effective_to": null,
"category_coverages": { "outpatient": 70, "inpatient": 30 },
"category_coverage_source": { "outpatient": "override", "inpatient": "admin_default" }
}
]
}
}
```
| فیلد | توضیح |
|------|-------|
| `category_coverages` | درصد **مؤثر** هر نوع خدمت پس از اجرای زنجیرهٔ resolve |
| `category_coverage_source` | منبع هر درصد: `override` (خودِ قرارداد) · `admin_default` (تنظیمات مرکزی) · `contract` (ستون قدیمی `coverage_percent`) |
| `coverage_percent` | ستون قدیمی قرارداد؛ فقط آخرین سطح fallback است |
| `franchise_rials` | فقط در قرارداد `supplementary` معنا دارد |
### POST `/api/v1/billing/tenant-insurances`
فعال‌سازی/به‌روزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش می‌شود، وگرنه نسخه‌ی جدید.
@@ -413,21 +534,35 @@ tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolv
| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_id` | int | الزامی |
| `coverage_percent` | float | درصد پوشش (۰–۱۰۰) |
| `franchise_rials` | int | فرانشیز ثابت سهم بیمار |
| `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام می‌فرستد |
| `franchise_rials` | int | فرانشیز — فقط در قرارداد `supplementary` اثر دارد |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
| `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود |
| `category_coverages` | array \| null | اختیاری — override درصد به تفکیک نوع خدمت. **نیامدنش** یعنی قرارداد روی پیش‌فرض مرکزی ادمین می‌ماند (fallback زنده) |
| `category_coverages[].key` | string | یکی از مقادیر `GET /api/v1/service-categories` |
| `category_coverages[].coverage_percent` | number \| null | ۰ تا ۱۰۰؛ `null` → override آن نوع حذف و به پیش‌فرض ادمین برمی‌گردد |
| `doctor_uuid` | string (UUID) \| null | اختیاری — قرارداد را به‌ازای پزشک هدف ذخیره می‌کند (نگاه کنید به «تنظیمات per-doctor» بالا) |
پاسخ `201`: `{ success, data: { …contract } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
```json
{
"insurance_id": 3,
"kind": "basic",
"category_coverages": [
{ "key": "outpatient", "coverage_percent": 70 },
{ "key": "inpatient", "coverage_percent": 30 }
]
}
```
پاسخ `201`: `{ success, data: { …contract, category_coverages, category_coverage_source } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی، یا `key` نامعتبر / درصد خارج از ۰–۱۰۰ · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد، یا ارسال `category_coverages` بدون مجوز `insurances.update`.
### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری.
**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active` · `doctor_uuid` (اختیاری، برای هدف‌گیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا).
**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» بالا).
- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate).
- قرارداد باید به همان موجودیتِ resolve‌شده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`.