Files
clinicpro/docs/api/clinic-services.md
T
hamedandClaude Opus 5 58c6d9ac18 feat(insurance): resolve coverage percent per service category
Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:21:19 +03:30

17 KiB
Raw Blame History

Clinic Services API

مدیریت بخش‌ها و سرویس‌های کلینیک/مطب.

نیاز به پنل: Basic یا بالاتر (ERR_SUBSCRIPTION_REQUIRED اگر نداشت)

دسترسیِ منشی: همهٔ endpointها با مجوزِ منشیِ services گِیت می‌شوند (view/create/update/delete)؛ نبودِ مجوز → 403 ERR_FORBIDDEN_001 و این گیت پیش از گیتِ اشتراک اجرا می‌شود. owner از محیطِ فعالِ منشی با SecretaryAccessChecker::resolveOwnerEntity حل می‌شود (چون EntityContextResolver منشی را مالک نمی‌شناسد). نقش‌های owner/پزشک/ادمین بدون تغییر عبور می‌کنند. جزئیات: secretary.md.


GET /api/v1/service-categories

لیست انواع خدمت (سرپایی/بستری/…). تنها منبع این لیست برای کلاینت‌ها؛ افزودن نوع تازه در بک‌اند یک case است و بدون تغییر فرانت اینجا ظاهر می‌شود. درصد پوشش بیمه به ازای همین نوع‌ها تعیین می‌شود (insurance.md).

Permission: IS_AUTHENTICATED_FULLY

Response 200:

{
  "success": true,
  "data": [
    { "key": "outpatient", "label": "خدمات سرپایی" },
    { "key": "inpatient",  "label": "خدمات بستری" }
  ]
}

GET /api/v1/service-sections

لیست بخش‌های سرویس entity جاری.

Permission: IS_AUTHENTICATED_FULLY + پنل Basic+

Response 200:

{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "entity_type": "clinic",
      "entity_id": 5,
      "name": "آزمایشگاه",
      "active": true,
      "items_count": 10,
      "created_at": 1718000000,
      "updated_at": 1718000000
    }
  ]
}

items_count تعداد سرویس‌های همان بخش است (فقط در این endpoint لیستی برگردانده می‌شود).


POST /api/v1/service-section

ایجاد بخش جدید.

Permission: IS_AUTHENTICATED_FULLY + پنل Basic+

Request Body:

{ "name": "رادیولوژی" }

Response 201: ServiceSection object

Errors:

Code HTTP توضیح
ERR_SUBSCRIPTION_REQUIRED 403 نیاز به پنل Basic+
ERR_VALIDATION_001 422 name خالی است

PATCH /api/v1/service-section/{uuid}

ویرایش بخش.

Permission: owner یا ROLE_ADMIN

{ "name": "رادیولوژی دیجیتال", "active": true }

DELETE /api/v1/service-section/{uuid}

غیرفعال — همیشه 409 برمی‌گرداند. حذفِ بخش، سرویس‌های زیرمجموعه را هم پاک می‌کرد و سوابق پرداخت/فاکتور به همان سرویس‌ها ارجاع دارند. برای برداشتنِ بخش از پذیرشِ جدید، آن را غیرفعال کنید: PATCH /api/v1/service-section/{uuid} با {"active": false}.

پاسخ: 409 ERR_SERVICE_ITEM_IN_USE — «حذف بخش ممکن نیست؛ برای حفظ سوابق پرداخت فقط می‌توانید آن را غیرفعال کنید.»


GET /api/v1/service-items

همه‌ی سرویس‌های owner در همه‌ی بخش‌ها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایه‌ی ServiceItem::toArray)، مرتب بر نام.

Errors:

Code HTTP توضیح
ERR_FORBIDDEN_001 403 کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابل‌تعیین نیست (ادمین، منشی، نماینده، کاربر عادی)

GET /api/v1/service-items/{sectionUuid}

لیست سرویس‌های یک بخش.

Response 200:

{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "section_uuid": "...",
      "staff_uuid": "...",
      "staff_name": "علی محمدی",
      "staff": { "uuid": "...", "full_name": "علی محمدی" },
      "staff_members": [
        { "uuid": "...", "full_name": "علی محمدی" },
        { "uuid": "...", "full_name": "سحر رحمانی" }
      ],
      "name": "رادیوگرافی مستقیم",
      "price_rials": 500000,
      "active": true,
      "insurance_covered": false,
      "service_category": "outpatient",
      "service_category_label": "خدمات سرپایی",
      "duration_minutes": 50,
      "bookable": true,
      "created_at": 1718000000,
      "updated_at": 1718000000
    }
  ]
}

GET /api/v1/service-item/{uuid}

یک سرویس مشخص — پشتیبان صفحه‌ی اختصاصی «جزئیات سرویس» (/admin/clinic-services/{uuid}) که باید با refresh مستقیم هم کار کند، بنابراین فیلترکردن سمت کلاینت از فهرست کامل کافی نبود.

Permission: IS_AUTHENTICATED_FULLY + پنل Basic+ · فقط سرویس‌های همان مطب/کلینیک (ServiceItem→section→entity_type/entity_id).

Response 200: یک ServiceItem object (ساختار یکسان با آیتم‌های فهرست، شامل section_uuid، section_name، staff_members، created_at و updated_at):

{
  "success": true,
  "data": {
    "uuid": "...",
    "section_uuid": "...",
    "section_name": "تزریقات",
    "name": "سرم ۵۰۰cc",
    "price_rials": 850000,
    "active": true,
    "insurance_covered": true,
    "duration_minutes": 30,
    "bookable": true,
    "staff": { "uuid": "...", "full_name": "مریم امینی" },
    "staff_members": [{ "uuid": "...", "full_name": "مریم امینی" }],
    "inventory_package_uuid": "...",
    "inventory_package_title": "پکیج سرم",
    "consumables": [
      { "item_uuid": "...", "name": "گاز استریل", "unit": "عدد", "price": 50000, "stock": 100, "amount": 2 }
    ],
    "created_at": 1718000000,
    "updated_at": 1718000000
  }
}

خطاها: 404 ERR_SERVICE_NOT_FOUND — هم برای uuid ناموجود و هم برای سرویس متعلق به tenant دیگر (وجود سرویس نباید لو برود) · 401 بدون احراز هویت.

section_name، inventory_package_id، inventory_package_uuid، inventory_package_title و consumables در همه‌ی پاسخ‌های سرویس این فایل هستند، نه فقط این endpoint. دو فیلد آخر توسط کنترلر اضافه می‌شوند (نه toArray()) و پکیج‌ها با یک کوئری batch واکشی می‌شوند تا فهرست سرویس‌ها به N+1 نیفتد.


GET /api/v1/service-item/{uuid}/audit-logs

تاریخچه‌ی تغییرات یک خدمت — تازه‌ترین رویداد اول، حداکثر ۱۰۰ ردیف.

Permission: IS_AUTHENTICATED_FULLY + پنل Basic+ · فقط سرویس‌های همان مطب/کلینیک.

Response 200:

{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "field": "price_rials",
      "operation": "update",
      "old_value": "700000",
      "new_value": "850000",
      "actor_name": "دکتر رضایی",
      "created_at": 1718000000
    }
  ]
}

فیلدهای رهگیری‌شده (ServiceItemAuditService::TRACKED): name، price_rials، active، duration_minutes، bookable، insurance_covered، inventory_package، consumables.

  • هر فیلدِ تغییریافته یک ردیف جدا می‌سازد؛ فیلد بدون تغییر ردیف نمی‌سازد.
  • operation: create (هنگام ساخت خدمت، فقط یک ردیف روی فیلد name) یا update.
  • مقادیر به‌صورت رشته ذخیره می‌شوند: بولین‌ها "1"/"0"، مبالغ ریال، و null یعنی «بدون مقدار». ترجمه‌ی نمایشی سمت پنل انجام می‌شود (FIELD_LABELS و auditValue در ServiceDetailPage.tsx).
  • جدول service_item_audit_logs با ON DELETE CASCADE به service_items وصل است.

خطاها: 404 ERR_SERVICE_NOT_FOUND · 401.


POST /api/v1/service-item

ایجاد سرویس جدید.

Permission: IS_AUTHENTICATED_FULLY + پنل Basic+

Request Body:

{
  "section_uuid": "...",
  "name": "رادیوگرافی مستقیم",
  "price_rials": 500000,
  "staff_uuid": "...",
  "insurance_covered": true,
  "service_category": "outpatient",
  "duration_minutes": 50,
  "bookable": true
}
فیلد نوع الزامی
section_uuid UUID
name string
price_rials integer (پیش‌فرض 0) — «قیمت پایه»
staff_uuids UUID[] — پرسنل مسئول (چند نفر). ترجیح داده می‌شود
staff_uuid UUID — legacy تک‌پرسنل (اگر staff_uuids نباشد استفاده می‌شود)
insurance_covered boolean (پیش‌فرض false) — deprecated برای نوشتن. پنل ادمین دیگر این فیلد را نمی‌فرستد؛ مقدارش به‌صورت خودکار از ردیف‌های پوشش بیمه همگام می‌شود (به insurance.md نگاه کن). endpoint هنوز آن را می‌پذیرد تا کلاینت‌های قدیمی نشکنند، ولی ذخیره‌ی پوشش بعداً آن را بازنویسی می‌کند
service_category string (پیش‌فرض outpatient) — «نوع خدمت»؛ یکی از مقادیر GET /api/v1/service-categories. درصد پوشش بیمهٔ این خدمت از همین نوع resolve می‌شود. مقدار نامعتبر → 422 ERR_VALIDATION_001 با فیلد service_category
duration_minutes integer|null — «زمان متوسط» انجام خدمت به دقیقه (""/null = بدون مقدار)
bookable boolean (پیش‌فرض false) — «نمایش در نوبت‌دهی». فقط سرویس‌های bookable=true در حالت نوبت‌دهی سرویسی قابل‌انتخاب‌اند
inventory_package_uuid UUID|null — پکیج کالای مصرفی این خدمت (inventory.md). null/"" یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه 422 ERR_VALIDATION_001 با فیلد inventory_package_uuid
consumables array|null — کالاهای تکی این خدمت: [{ "item_uuid": "…", "amount": 2 }]. مکمل پکیج است، نه جایگزین آن — یک خدمت می‌تواند هم‌زمان پکیج و کالای تکی داشته باشد. ارسال این فیلد کل فهرست را جایگزین می‌کند ([] = حذف همه). هر کالا باید متعلق به همان مطب/کلینیک باشد وگرنه 422 ERR_VALIDATION_001 با فیلد consumables. amount حداقل ۱ است

bookable و service_category در PATCH /api/v1/service-item/{uuid} هم به همین شکل پذیرفته می‌شوند؛ تغییر نوع خدمت در audit-log با برچسب «نوع خدمت» ثبت می‌شود.

Response 201: ServiceItem object (شامل insurance_covered)

قیمت واحد: هنگام ساخت سرویس، یک تعرفه برای سال جاری با همان price_rials به‌صورت خودکار ثبت می‌شود. قیمت سرویس = تعرفه‌ی سال جاری است و همه‌جا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده می‌شود.


PATCH /api/v1/service-item/{uuid}

ویرایش سرویس.

{
  "name": "رادیوگرافی دیجیتال",
  "price_rials": 600000,
  "staff_uuid": null,
  "active": false,
  "insurance_covered": true,
  "duration_minutes": 30
}

فیلد duration_minutes (زمان متوسط، دقیقه) در پاسخِ toArray و در ساخت/ویرایش پشتیبانی می‌شود؛ ""/null آن را پاک می‌کند.

پرسنل چندنفره: یک سرویس می‌تواند چند پرسنل داشته باشد. staff_uuids (آرایه) ترجیح داده می‌شود؛ در نبود آن، staff_uuid تک‌نفره به‌صورت backward-compatible پذیرفته می‌شود. پاسخ همیشه staff_members[] (کامل) و staff/staff_uuid/staff_name (نفر اول، برای سازگاری) را برمی‌گرداند. هر پرسنل باید متعلق به همان tenant (entity_type/entity_id) باشد؛ در غیر این صورت → 422 ERR_VALIDATION_001 (field: staff_uuids). همین قید روی POST /service-item نیز اعمال می‌شود.


DELETE /api/v1/service-item/{uuid}

غیرفعال — همیشه 409 برمی‌گرداند. سرویس‌ها هرگز حذف نمی‌شوند: نوبت‌ها، جلسات، فاکتورها و سوابق پرداخت به سرویس ارجاع دارند و حذف آن‌ها را ناسازگار می‌کرد. برای برداشتنِ سرویس از پذیرشِ جدید، آن را غیرفعال کنید: PATCH /api/v1/service-item/{uuid} با {"active": false} — سرویسِ غیرفعال در پذیرشِ جدید نمایش داده نمی‌شود ولی سوابق حفظ می‌مانند.

{
  "success": false,
  "errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "حذف سرویس ممکن نیست؛ برای حفظ سوابق پرداخت فقط می‌توانید آن را غیرفعال کنید." }]
}

Errors:

Code HTTP توضیح
ERR_SERVICE_ITEM_IN_USE 409 حذف مجاز نیست — فقط غیرفعال‌کردن ممکن است

تعرفه‌ی نسخه‌دار سالانه (Tariff) — فاز ۳ سیستم صورتحساب

هر خدمت می‌تواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفه‌ی سالی ثبت نشود، به price_rials خود خدمت fallback می‌شود (TariffService::resolvePrice). سال جاری شمسی سمت سرور با IntlDateFormatter (تقویم persian) محاسبه می‌شود.

GET /api/v1/service-items/{uuid}/tariffs

لیست تعرفه‌های یک خدمت + قیمت پیش‌فرض + سال جاری.

Permission: IS_AUTHENTICATED_FULLY (مالک خدمت)

{
  "success": true,
  "data": {
    "current_year": 1405,
    "default_price_rials": 500000,
    "data": [
      { "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true },
      { "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true }
    ]
  }
}

PUT /api/v1/service-items/{uuid}/tariffs/{year}

ثبت/به‌روزرسانی تعرفه‌ی یک سال (upsert). year بین ۱۳۹۰ تا ۱۵۰۰.

Body:

{ "price_rials": 600000 }

Response 200: { success, data: { …tariff } }

اگر year برابر سال جاری باشد، ServiceItem.price_rials هم با همین مقدار همگام می‌شود (قیمت واحد). تعرفه‌ی سال‌های دیگر فقط برای محاسبه‌ی صورتحساب همان سال (TariffService::resolvePrice) به‌کار می‌رود و قیمت پایه‌ی سرویس را تغییر نمی‌دهد. هم‌چنین PATCH /service-item/{uuid} با تغییر price_rials، تعرفه‌ی سال جاری را upsert می‌کند.

Errors:

Code HTTP توضیح
ERR_SERVICE_NOT_FOUND 404 سرویس یافت نشد
ERR_VALIDATION_001 422 سال نامعتبر

Owner resolution (2026-07)

Every endpoint in this file resolves its owner through App\Shared\Context\EntityContextResolver instead of reading the caller's role directly. Precedence:

  1. an explicit clinic_uuid on the request (query string, or body on POST/PATCH/PUT) — 403 if the caller may not act in that clinic;
  2. the caller's stored active context (user_active_context);
  3. their role.

This fixes a user who is both a doctor and a clinic owner: they used to always resolve as doctor and could never reach their own clinic's services.

GET /api/v1/service-items?clinic_uuid=… therefore returns that clinic's services rather than the caller's personal ones.

TODO: Inventory, Patient, Staff, Billing, Insurance, Subscription, Tag and Sms controllers still carry their own private resolveEntity() copy with the old role-first logic. They should be migrated to EntityContextResolver too.