- Updated InsuranceModal to include doctorUuid in the payload for insurance contracts. - Enhanced TenantInsuranceContracts to allow selection of doctors and pass doctorUuid in API requests. - Modified InsuranceController to handle doctorUuid for tenant insurance endpoints, ensuring contracts are stored per doctor. - Updated API documentation to reflect the new optional doctor_uuid parameter for tenant insurance endpoints. - Added tests to verify the functionality of per-doctor insurance contracts and ensure isolation of contracts between doctors.
21 KiB
Insurance API
Prefix:
/api/v1/insurances,/api/v1/insurance,/api/v1/admin/insurance
Two resource types:
- Insurance — master list of insurance companies managed by admin
- DoctorInsurance — a doctor's acceptance of a specific insurance (with optional price)
GET /api/v1/insurances
List all active insurances.
Permission: PUBLIC
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
type |
string | ❌ | "basic" or "supplementary" |
Response 200
{
"success": true,
"data": [
{
"id": 1,
"name": "بیمه تأمین اجتماعی",
"type": "basic",
"logo_url": "https://...",
"status": "active"
},
{
"id": 2,
"name": "بیمه ایران",
"type": "supplementary",
"logo_url": "https://...",
"status": "active"
}
]
}
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
{
"success": true,
"data": [ ... ],
"meta": { "totalRecords": 15, "totalPages": 1, "currentPage": 1 }
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
POST /api/v1/admin/insurance
Create a new insurance.
Permission: ROLE_ADMIN
Request Body (application/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
{ "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
{
"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)
{
"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
{
"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
{ "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
{ "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
{
"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
},
{
"insurance_id": 9,
"insurance_name": "دانا",
"type": "supplementary",
"patient_share_rials": null
}
]
}
}
patient_share_rials = nullیعنی این بیمه پذیرفته نمیشود (قیمتگذاری ندارد).require_visit_price— فلگ «الزامی کردن هزینه ویزیت». وقتیtrueباشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (> 0) رد میشوند.
خطاها
403ERR_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.
{
"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 (وضعیت پس از ذخیره).
خطاها
403ERR_FORBIDDEN_001— پروفایل یافت نشد.422ERR_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)
{
"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
}
]
}
}
POST /api/v1/billing/tenant-insurances
فعالسازی/بهروزرسانی قرارداد. اگر قرارداد فعالی برای آن بیمه باشد ویرایش میشود، وگرنه نسخهی جدید.
Body:
| فیلد | نوع | توضیح |
|---|---|---|
insurance_id |
int | الزامی |
coverage_percent |
float | درصد پوشش (۰–۱۰۰) |
franchise_rials |
int | فرانشیز ثابت سهم بیمار |
annual_ceiling_rials |
int | null | سقف تعهد (null = بینهایت) |
kind |
string | null | نوع بیمه قرارداد (basic/supplementary); خالی → پیشفرض نوع کاتالوگ |
effective_from |
int | null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
effective_to |
int | null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود |
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 پروفایل یافت نشد.
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» بالا).
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 (اختیاری) — برای غیرفعالسازی قرارداد یک پزشک خاص.
{ "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 (مالک قرارداد)
{
"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.
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.