Files
clinicpro/docs/api/insurance.md
T
hamed 934405c42d feat: Implement permission gate for appointment and billing controllers
- 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.
2026-08-08 10:27:13 +03:30

31 KiB
Raw Blame History

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:

  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

{
  "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": "بیمه حذف شد" } }

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_DOCTORdoctor، نقش ROLE_CLINICclinic. ذخیره‌سازی 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) رد می‌شوند.

خطاها

  • 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.

{
  "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)

{
  "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=falsenotCovered()؛ در غیر این صورت فیلدهای 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.