- Implement PriceInput component tests to validate Persian and Arabic numeral handling, input formatting, and controlled behavior. - Create ServiceDetailPage component with detailed service information, including pricing, insurance coverage, and editing capabilities. - Add API tests for service item detail retrieval and coverage synchronization with insurance contracts. - Ensure proper error handling and user feedback for service item retrieval and coverage management.
18 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 از #[CurrentUser] (ROLE_DOCTOR→doctor، ROLE_CLINIC→clinic). جدول tenant_insurances.
GET /api/v1/billing/tenant-insurances
لیست قراردادهای tenant جاری — آخرین نسخهٔ هر بیمه، فعال یا غیرفعال (برای toggle فعال/غیرفعال در UI مدیریت بیمه). insurance_kind = kind قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
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 → نامحدود |
پاسخ 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.
is_active(bool): toggle فعال/غیرفعال. برخلافDELETE، مقدارeffective_toِ تعیینشدهٔ کاربر را دستنخورده نگه میدارد (برای reactivate).
DELETE /api/v1/billing/tenant-insurances/{uuid}
غیرفعالسازی نرم (soft) — is_active=false و effective_to=now. داده حذف نمیشود.
{ "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های پوشش خدمات یک قرارداد.
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 = ارث از قرارداد |
سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (ServiceItem→section→entity_type/entity_id).
اثر جانبی — همگامسازی 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.