- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController. - Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments. - Updated existing methods in both controllers to utilize the new permission checks. - Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management. - Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
693 lines
31 KiB
Markdown
693 lines
31 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_percent` فقط در قراردادهای `supplementary` اثر دارد.
|
||
|
||
---
|
||
|
||
## GET `/api/v1/insurances`
|
||
|
||
List all active insurances.
|
||
|
||
**Permission:** `AUTH` — بدون مجوزِ رجیستری، و این عمدی است.
|
||
|
||
> سند تا ۲۰۲۶-۰۸-۰۸ اینجا `PUBLIC` نوشته بود که با رفتار نمیخواند: مسیر پشت firewall
|
||
> است و درخواستِ بدون توکن `401` میگیرد.
|
||
>
|
||
> کاتالوگ سراسری بیمههاست — `findActive()` بدون فیلترِ محیط، جدا از قرارداد بیمهٔ
|
||
> tenant (`TenantInsurance`) که مجوز خودش را دارد. گِیتزدنش با `insurances.view` فرمِ
|
||
> ثبت بیمار را برای منشیِ دارای `patients.create` با کمبوی خالی میشکست. در
|
||
> `ApiLeastPrivilegeTest::ALLOWED_200` با همین دلیل ثبت است.
|
||
|
||
### 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_percent": 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_percent` | فقط در قرارداد `supplementary` معنا دارد |
|
||
|
||
### POST `/api/v1/billing/tenant-insurances`
|
||
فعالسازی/بهروزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش میشود، وگرنه نسخهی جدید.
|
||
|
||
**Body:**
|
||
| فیلد | نوع | توضیح |
|
||
|------|-----|-------|
|
||
| `insurance_id` | int | الزامی |
|
||
| `coverage_percent` | float | ستون قدیمی قرارداد (آخرین سطح fallback)؛ پنل آن را با درصد سرپایی همگام میفرستد |
|
||
| `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 → اکنون |
|
||
| `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 آن نوع حذف و به پیشفرض ادمین برمیگردد |
|
||
|
||
**اجبار درصد برای نوع خدمتِ فعال:** با ارسال `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
|
||
{
|
||
"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_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`.
|
||
|
||
### 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_percent": 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_percent` | 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.
|