An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
674 lines
29 KiB
Markdown
674 lines
29 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 }
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
همچنین دو کلید سراسریِ tenant:
|
||
|
||
```json
|
||
{
|
||
"service_categories": [
|
||
{ "key": "outpatient", "label": "خدمات سرپایی", "enabled": true },
|
||
{ "key": "inpatient", "label": "خدمات بستری", "enabled": false }
|
||
],
|
||
"default_service_category": "outpatient"
|
||
}
|
||
```
|
||
|
||
| فیلد | توضیح |
|
||
|------|-------|
|
||
| `service_categories` | نوع خدماتی که بیمههای این پزشک/کلینیک پوشش میدهند — **سراسری برای همهٔ بیمههای همان tenant**، نه per-insurance. همیشه همهٔ نوعها برمیگردند؛ نبودِ ردیف در DB = `enabled: true`. پنل: کارت «نوع خدمات بیمه» در تنظیمات ← مدیریت بیمه (`/admin/insurance-pricing`) |
|
||
| `default_service_category` | اگر فقط یک نوع فعال باشد همان (مبنای خودکار محاسبه)؛ اگر بیش از یکی فعال باشد `null` و پنل باید سرِ پذیرش بپرسد |
|
||
|
||
- `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` → ردیف حذف میشود. |
|
||
| `service_categories` | array | اختیاری — `[{ "key": "inpatient", "enabled": false }]`. فقط نوعهای ارسالی تغییر میکنند؛ نیامدنِ کلید یعنی تنظیمات دستنخورده. |
|
||
|
||
اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیرهشده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیرهشده) `<= 0`، درخواست رد میشود.
|
||
اعتبارسنجی `service_categories`: `key ∈ ServiceCategory::values()` و پس از اعمالِ تغییر **حداقل یک نوع فعال بماند** — وگرنه `422 ERR_VALIDATION_001` با فیلد `service_categories`.
|
||
|
||
### Response `200`
|
||
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
|
||
|
||
### خطاها
|
||
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
|
||
- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`.
|
||
- `422` `ERR_VALIDATION_001` (field: `service_categories`) — نوع خدمت نامعتبر، یا غیرفعالکردن همهٔ نوعها.
|
||
|
||
---
|
||
|
||
## 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.
|