- 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.
31 KiB
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.
Two resource types:
- Insurance — master list of insurance companies managed by admin
- 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
{
"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).
{
"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
{
"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)
{
"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)
{
"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,
"coverage_defaults": { "outpatient": 70, "inpatient": 30 }
},
{
"insurance_id": 9,
"insurance_name": "دانا",
"type": "supplementary",
"patient_share_rials": null,
"coverage_defaults": { "outpatient": 0, "inpatient": 0 }
}
]
}
}
همچنین دو کلید سراسریِ tenant:
{
"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) رد میشوند.
خطاها
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 → ردیف حذف میشود. |
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 (وضعیت پس از ذخیره).
خطاها
403ERR_FORBIDDEN_001— پروفایل یافت نشد.422ERR_VALIDATION_001(field:free_visit_price_rials) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد<= 0.422ERR_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)
{
"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 حساب
نمیشود، وگرنه نوع خدمتی که درصدش نیامده بیصدا نرخ نوع دیگر را ارث میبرد.
{ "success": false, "errors": [
{ "code": "ERR_VALIDATION_001", "field": "category_coverages", "message": "درصد پوشش خدمات بستری الزامی است" }
] }
| doctor_uuid | string (UUID) | null | اختیاری — قرارداد را بهازای پزشک هدف ذخیره میکند (نگاه کنید به «تنظیمات per-doctor» بالا) |
{
"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 (اختیاری) — برای غیرفعالسازی قرارداد یک پزشک خاص.
{ "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_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.
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.