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>
654 lines
28 KiB
Markdown
654 lines
28 KiB
Markdown
# Insurance API
|
||
|
||
> **Prefix:** `/api/v1/insurances`, `/api/v1/insurance`, `/api/v1/admin/insurance`
|
||
|
||
> **دسترسی منشی:** endpointهای غیرادمینِ بیمه (insurance-pricing, billing/tenant-insurances, service-coverage, `/api/v1/insurance/*`) برای `ROLE_SECRETARY` روی منبع `insurances` اعمال میشوند (`SecretaryAccessChecker`): GET→`view`, POST→`create`, PATCH/PUT→`update`, DELETE→`delete`؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md).
|
||
|
||
Two resource types:
|
||
1. **Insurance** — master list of insurance companies managed by admin
|
||
2. **DoctorInsurance** — a doctor's acceptance of a specific insurance (with optional price)
|
||
|
||
---
|
||
|
||
## قاعدهٔ درصد پوشش (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.
|
||
|
||
**Permission:** `PUBLIC`
|
||
|
||
### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `type` | string | ❌ | `"basic"` or `"supplementary"` |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"id": 1,
|
||
"name": "بیمه تأمین اجتماعی",
|
||
"type": "basic",
|
||
"logo_url": "https://...",
|
||
"status": "active",
|
||
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
|
||
},
|
||
{
|
||
"id": 2,
|
||
"name": "بیمه ایران",
|
||
"type": "supplementary",
|
||
"logo_url": "https://...",
|
||
"status": "active",
|
||
"coverage_defaults": { "outpatient": 0, "inpatient": 0 }
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`coverage_defaults` درصدهای مرکزی ادمین به تفکیک نوع خدمت است؛ همیشه همهٔ نوعها حاضرند
|
||
(نبودِ ردیف = `0`). پنل پزشک هنگام ساخت قرارداد همین مقادیر را پیشفرض بار میکند.
|
||
|
||
---
|
||
|
||
## GET `/api/v1/admin/insurances`
|
||
|
||
List all insurances with pagination (admin view — includes inactive).
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `page` | integer | ❌ | Default: 1 |
|
||
| `limit` | integer | ❌ | Default: 20 |
|
||
| `search` | string | ❌ | Search in name |
|
||
| `type` | string | ❌ | `"basic"` or `"supplementary"` |
|
||
|
||
### Response `200`
|
||
هر ردیف علاوه بر فیلدهای بیمه، `coverage_defaults` خود را هم دارد (یک کوئری برای کل صفحه، بدون N+1).
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"id": 1,
|
||
"name": "بیمه تأمین اجتماعی",
|
||
"type": "basic",
|
||
"logo_url": "https://...",
|
||
"status": "active",
|
||
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 15, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"name": "بیمه تأمین اجتماعی",
|
||
"type": "basic",
|
||
"logo_url": "https://...",
|
||
"status": "active"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `name` | string | ✅ | Insurance name |
|
||
| `type` | string | ✅ | `"basic"` or `"supplementary"` |
|
||
| `logo_url` | string | ❌ | Logo image URL |
|
||
| `status` | string | ❌ | `"active"` (default) or `"inactive"` |
|
||
|
||
### Response `201`
|
||
Insurance object.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_VALIDATION_002` | 422 | Missing required field |
|
||
|
||
---
|
||
|
||
## PATCH `/api/v1/admin/insurance/{id}`
|
||
|
||
Update an insurance.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `id` | integer | Insurance ID |
|
||
|
||
All body fields optional.
|
||
|
||
### Response `200`
|
||
Updated insurance object.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Insurance not found |
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/admin/insurance/{id}`
|
||
|
||
Delete an insurance.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "message": "بیمه حذف شد" } }
|
||
```
|
||
|
||
---
|
||
|
||
## POST `/api/v1/admin/insurance/{id}/upload-logo`
|
||
|
||
Upload insurance logo.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Request
|
||
`Content-Type: multipart/form-data`
|
||
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `file` | binary | ✅ |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"url": "https://...",
|
||
"uuid": "...",
|
||
"filename": "insurance_logo.png",
|
||
"filemime": "image/png",
|
||
"filesize": 51200
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## POST `/api/v1/insurance/`
|
||
|
||
Add an insurance to a doctor's accepted list.
|
||
|
||
**Permission:** `AUTH` — must be the doctor (or their secretary with `insurances.create` permission)
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"doctor_id": 42,
|
||
"insurance_id": 1,
|
||
"price": 150000
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `doctor_id` | integer | ✅ | Doctor's numeric ID |
|
||
| `insurance_id` | integer | ✅ | Insurance ID |
|
||
| `price` | integer | ❌ | Visit price for this insurance (Rials) |
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"id": 10,
|
||
"doctor_id": 42,
|
||
"insurance": { "id": 1, "name": "بیمه تأمین اجتماعی", "type": "basic" },
|
||
"price": 150000
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the doctor |
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor or insurance not found |
|
||
| `ERR_CONFLICT_001` | 409 | Insurance already added to doctor |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/insurance/{id}`
|
||
|
||
Get a doctor-insurance link.
|
||
|
||
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (otherwise `403 ERR_AUTH_006`). Prevents reading another doctor's negotiated price by id enumeration.
|
||
|
||
### Response `200`
|
||
DoctorInsurance object.
|
||
|
||
---
|
||
|
||
## PATCH `/api/v1/insurance/{id}`
|
||
|
||
Update a doctor-insurance (e.g., change price).
|
||
|
||
**Permission:** `AUTH` — must be the doctor (or their secretary with `insurances.update` permission)
|
||
|
||
### Request Body
|
||
```json
|
||
{ "price": 200000 }
|
||
```
|
||
|
||
### Response `200`
|
||
Updated DoctorInsurance object.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the doctor |
|
||
| `ERR_NOT_FOUND_001` | 404 | Link not found |
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/insurance/{id}`
|
||
|
||
Remove an insurance from a doctor's list.
|
||
|
||
**Permission:** `AUTH` — must be the doctor (or their secretary with `insurances.delete` permission)
|
||
|
||
### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "message": "بیمه از لیست حذف شد" } }
|
||
```
|
||
|
||
---
|
||
|
||
## EntityInsurancePricing — قیمتگذاری ویزیت بر اساس بیمه
|
||
|
||
قیمتگذاری ویزیت برای entity جاری (پزشک یا کلینیک)، با تفکیک:
|
||
- **ویزیت آزاد** (بدون بیمه) — یک مبلغ پایه (ردیفی با `insurance_id = null`)
|
||
- **سهم بیمار به ازای هر بیمه** پایه/مکمل
|
||
|
||
entity جاری از `#[CurrentUser]` resolve میشود: نقش `ROLE_DOCTOR` → `doctor`، نقش `ROLE_CLINIC` → `clinic`. ذخیرهسازی polymorphic در جدول `entity_insurance_pricing` (`entity_type`, `entity_id`, `insurance_id` nullable, `patient_share_rials`).
|
||
|
||
---
|
||
|
||
## GET `/api/v1/insurance-pricing`
|
||
|
||
قیمتگذاری بیمهی entity جاری + لیست همهی بیمههای فعال (با سهم بیمار اگر تعیین شده).
|
||
|
||
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
|
||
|
||
### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `doctor_uuid` | string (UUID) | ❌ | قیمتگذاری همان پزشک را برمیگرداند بهجای موجودیت کاربر جاری. برای تبهای نوبتدهی پنل کلینیک. |
|
||
|
||
با `doctor_uuid`، دسترسی اینگونه بررسی میشود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دستنخورده است.
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"entity_type": "doctor",
|
||
"entity_id": 7,
|
||
"free_visit_price_rials": 5000000,
|
||
"require_visit_price": false,
|
||
"insurances": [
|
||
{
|
||
"insurance_id": 3,
|
||
"insurance_name": "تأمین اجتماعی",
|
||
"type": "basic",
|
||
"patient_share_rials": 1500000,
|
||
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
|
||
},
|
||
{
|
||
"insurance_id": 9,
|
||
"insurance_name": "دانا",
|
||
"type": "supplementary",
|
||
"patient_share_rials": null,
|
||
"coverage_defaults": { "outpatient": 0, "inpatient": 0 }
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- `coverage_defaults` — درصدهای مرکزی ادمین؛ پنل پزشک هنگام افزودن قرارداد از همین پر میکند.
|
||
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمیشود (قیمتگذاری ندارد). این مقدار **ورودی هیچ محاسبهای نیست**؛ محاسبهٔ سهم فقط از درصد پوشش انجام میشود.
|
||
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد میشوند.
|
||
|
||
### خطاها
|
||
- `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد.
|
||
|
||
---
|
||
|
||
## PUT `/api/v1/insurance-pricing`
|
||
|
||
ذخیره/بهروزرسانی قیمت ویزیت آزاد و سهم بیمار هر بیمه. عملیات upsert؛ ردیفی که `patient_share_rials = null` بفرستد حذف میشود.
|
||
|
||
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
|
||
|
||
### Request Body
|
||
> `doctor_uuid` (اختیاری) در بدنه پذیرفته میشود و مثل نسخهٔ `GET` عمل میکند — همان قواعد دسترسی، با اکشن `services.update`.
|
||
|
||
```json
|
||
{
|
||
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||
"free_visit_price_rials": 5000000,
|
||
"require_visit_price": true,
|
||
"insurances": [
|
||
{ "insurance_id": 3, "patient_share_rials": 1500000 },
|
||
{ "insurance_id": 9, "patient_share_rials": null }
|
||
]
|
||
}
|
||
```
|
||
|
||
| فیلد | نوع | توضیح |
|
||
|------|-----|-------|
|
||
| `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمیکند. |
|
||
| `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیرهشده حفظ میشود. |
|
||
| `insurances[].insurance_id` | int | شناسهی بیمه (الزامی برای هر ردیف). |
|
||
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف میشود. |
|
||
|
||
اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیرهشده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیرهشده) `<= 0`، درخواست رد میشود.
|
||
|
||
### Response `200`
|
||
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
|
||
|
||
### خطاها
|
||
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
|
||
- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`.
|
||
|
||
---
|
||
|
||
## TenantInsurance — قراردادهای بیمهی tenant (فاز ۱ سیستم صورتحساب)
|
||
|
||
قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخهبندی و وضعیت فعال. مبنای محاسبهی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). جدول `tenant_insurances`.
|
||
|
||
tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve میشود — همان رزولور پروندهها و صورتحسابها، تا قرارداد بیمه و صورتحسابی که از آن ساخته میشود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیینکننده است، نه صرفاً ترتیب نقشها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را میبیند.
|
||
|
||
**تنظیمات per-doctor در کلینیک چندپزشکه:** درصد و شرایط هر بیمه میتواند برای هر پزشک متفاوت باشد. همهٔ اندپوینتهای زیر یک پارامتر اختیاری `doctor_uuid` میپذیرند (در `GET`/`DELETE` از query، در `POST`/`PATCH`/`PUT` از بدنه). با آن، قرارداد بهجای موجودیتِ tenantِ کاربر جاری، بهازای پزشک هدف (`entity_type='doctor'`) خوانده/نوشته میشود — دقیقاً مثل `insurance-pricing`. **بدون** آن، رفتار قبلی (tenant کاربر جاری) دستنخورده میماند (سازگاری عقبرو). دسترسی با `doctor_uuid` هم مثل `insurance-pricing` بررسی میشود: `ROLE_ADMIN`، خودِ پزشک، یا کاربرِ عضو/مالکِ کلینیکِ آن پزشک با مجوز `services.view` (برای نوشتن `services.update`)؛ در غیر این صورت `403 ERR_ACCESS_DENIED`، و پزشکِ ناموجود `404 ERR_NOT_FOUND_001`.
|
||
|
||
### GET `/api/v1/billing/tenant-insurances`
|
||
لیست قراردادهای tenant جاری — **آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
|
||
|
||
**Query:** `doctor_uuid` (اختیاری) — قراردادهای همان پزشک را برمیگرداند (نگاه کنید به «تنظیمات per-doctor» بالا).
|
||
|
||
**Permission:** `AUTH` (doctor/clinic)
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": [
|
||
{
|
||
"uuid": "…",
|
||
"insurance_id": 3,
|
||
"insurance_name": "تأمین اجتماعی",
|
||
"insurance_kind": "basic",
|
||
"version": 1,
|
||
"is_active": true,
|
||
"coverage_percent": 70,
|
||
"franchise_rials": 0,
|
||
"annual_ceiling_rials": null,
|
||
"kind": "basic",
|
||
"effective_from": 1718900000,
|
||
"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`
|
||
فعالسازی/بهروزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش میشود، وگرنه نسخهی جدید.
|
||
|
||
**Body:**
|
||
| فیلد | نوع | توضیح |
|
||
|------|-----|-------|
|
||
| `insurance_id` | 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» بالا) |
|
||
|
||
```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` · `category_coverages` (همان ساختار `POST`؛ ارسالش نیازمند مجوز `insurances.update` است وگرنه `403 ERR_FORBIDDEN_001`) · `doctor_uuid` (اختیاری، برای هدفگیری پزشک — نگاه کنید به «تنظیمات per-doctor» بالا).
|
||
|
||
- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیینشدهٔ کاربر را دستنخورده نگه میدارد (برای reactivate).
|
||
- قرارداد باید به همان موجودیتِ resolveشده (پزشک هدف یا tenant کاربر) تعلق داشته باشد، وگرنه `404`.
|
||
|
||
### DELETE `/api/v1/billing/tenant-insurances/{uuid}`
|
||
غیرفعالسازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمیشود.
|
||
|
||
**Query:** `doctor_uuid` (اختیاری) — برای غیرفعالسازی قرارداد یک پزشک خاص.
|
||
|
||
```json
|
||
{ "success": true, "data": { "message": "قرارداد بیمه غیرفعال شد" } }
|
||
```
|
||
|
||
> **Guard:** `TenantInsuranceService::assertActive()` هنگام پذیرش/صورتحساب فقط بیمههای فعالِ همان tenant را مجاز میداند؛ در غیر این صورت `422 ERR_VALIDATION_001` («این بیمه برای این کلینیک/پزشک فعال نیست»).
|
||
|
||
---
|
||
|
||
## TenantServiceCoverage — پوشش خدمت تحت یک قرارداد بیمه (فاز ۲)
|
||
|
||
override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فیلدهای `null` از خود قرارداد ارث میبرند. اگر `covered=false` → آن خدمت تحت آن بیمه پوشش ندارد. جدول `tenant_service_coverage`.
|
||
|
||
### GET `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
|
||
لیست overrideهای پوشش خدمات یک قرارداد.
|
||
|
||
**Query:** `doctor_uuid` (اختیاری) — برای قراردادِ متعلق به پزشک هدف در کلینیک چندپزشکه.
|
||
|
||
**Permission:** `AUTH` (مالک قرارداد)
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": [
|
||
{
|
||
"uuid": "…",
|
||
"tenant_insurance_id": 4,
|
||
"service_item_id": 12,
|
||
"service_item_uuid": "…",
|
||
"covered": true,
|
||
"coverage_percent": 80,
|
||
"franchise_rials": null,
|
||
"ceiling_rials": null
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### PUT `/api/v1/billing/tenant-insurances/{uuid}/service-coverage`
|
||
تنظیم/بهروزرسانی پوشش یک خدمت (upsert).
|
||
|
||
**Body:**
|
||
| فیلد | نوع | توضیح |
|
||
|------|-----|-------|
|
||
| `service_item_uuid` | string | شناسهی خدمت (ترجیحی؛ پنل ادمین فقط uuid دارد) |
|
||
| `service_item_id` | int | جایگزین `service_item_uuid` (id داخلی) — یکی از این دو الزامی |
|
||
| `covered` | bool | پیشفرض true |
|
||
| `coverage_percent` | float \| null | null = ارث از قرارداد |
|
||
| `franchise_rials` | int \| null | null = ارث از قرارداد |
|
||
| `ceiling_rials` | int \| null | null = ارث از قرارداد |
|
||
| `doctor_uuid` | string (UUID) \| null | اختیاری — قراردادِ متعلق به پزشک هدف (نگاه کنید به «تنظیمات per-doctor» بالا) |
|
||
|
||
سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (`ServiceItem→section→entity_type/entity_id`). با `doctor_uuid`، موجودیت هدف پزشک است، پس سرویس هم باید متعلق به همان پزشک باشد.
|
||
|
||
**اثر جانبی — همگامسازی `ServiceItem.insurance_covered`:** پس از ذخیرهی ردیف پوشش، پرچم
|
||
`insurance_covered` همان خدمت بازمحاسبه میشود: اگر زیر **هر** قرارداد بیمهای دستکم یک ردیف با
|
||
`covered=true` بماند ⇒ `true`، وگرنه `false`. پنل ادمین دیگر این پرچم را دستی نمیفرستد (سوییچ «این خدمت
|
||
شامل بیمه میشود» از فرم سرویس حذف شد)، پس این endpoint تنها منبع حقیقت آن است. پیادهسازی:
|
||
`TenantInsuranceService::syncServiceItemInsuranceFlag()` + `TenantServiceCoverageRepository::hasActiveCoverage()`.
|
||
|
||
پاسخ `200`: `{ success, data: { message } }`.
|
||
خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` سرویس یافت نشد · `403 ERR_FORBIDDEN_001` سرویس متعلق به شما نیست.
|
||
|
||
> منطق resolve: `TenantInsuranceService::coverageRuleForService()` ابتدا **پرچم `ServiceItem.insurance_covered`** را چک میکند؛ اگر این خدمت «شامل بیمه» نباشد، بدون توجه به override یا قرارداد، `CoverageRule::notCovered()` برمیگردد (gate نهایی). سپس override خدمت بررسی میشود؛ اگر `covered=false` → `notCovered()`؛ در غیر این صورت فیلدهای null از قرارداد پر میشوند. این `CoverageRule` ورودی `BillingCalculator` است و سهم بیمهی هر `InvoiceItem` را تعیین میکند؛ همان سهمها در `ClaimService::createFromInvoice()` به `ClaimItem` (مطالبات بیمه) تبدیل میشوند. پنل ادمین این endpoint را از مودال «پوشش بیمه» در صفحه سرویسهای کلینیک فراخوانی میکند.
|
||
|
||
---
|
||
|
||
## Bulk import / export
|
||
|
||
Full-table JSON export and strict wipe+replace import for this category live under
|
||
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
|
||
|
||
|
||
### Sorting by id
|
||
|
||
The admin list endpoint accepts `sort=id&order=asc|desc` to order by `id`
|
||
(used by the admin «دستهبندیها» page when clicking the «شناسه» column).
|
||
Without `sort`, the default ordering (weight/name) is unchanged.
|