Files
clinicpro/docs/api/clinic-services.md
T
hamedandClaude Opus 5 52c45443c5 fix(security): gate every ServiceCatalogController route on the services permission
The controller carried only IS_AUTHENTICATED_FULLY on the class and none of its
15 routes checked a permission. A secretary whose owner had turned `services`
fully off could still create, rename and delete service categories, build item
groups, replace group members, and rewrite service relations and per-branch
overrides.

Scope is intra-tenant privilege escalation, not IDOR: owned() and requireItem()
already resolve every uuid against the caller's active environment, so no data
crossed tenants.

Gating is per-action (view/create/update/delete) and reuses denyServices() from
ClinicServiceController in the same domain, so a secretary with `update` cannot
create or delete. The call is the first statement in every action, before
requireCategory/requireItem — placed after, an unknown uuid would answer 404 and
leak whether the record exists.

An earlier note claimed these endpoints were consumed by the booking flow and so
could not be closed. That was wrong. service-selection/validate, the group routes
and the relation routes have no consumer in any of the three API clients, and the
sibling controller already puts every service read behind services.view — the
booking modal reads service-items through it — so any flow needing services
already needed the permission.

The docs claimed appointment_settings.* for the includes routes, which was never
enforced either; corrected to services.*.

The test loops the whole route list rather than sampling, and a guard asserts the
count of #[Route( equals the count of denyServices( so a future ungated route
fails here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:46:16 +03:30

28 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 حذف مجاز نیست — فقط غیرفعال‌کردن ممکن است

قیمت خدمت — تنها یک منبع

قیمت هر خدمت فقط ServiceItem.price_rials است و با PATCH /api/v1/service-item/{uuid} عوض می‌شود. تعرفهٔ نسخه‌دار سالانه (GET|PUT /api/v1/service-items/{uuid}/tariffs[/{year}]) حذف شده و آن مسیرها 404 می‌دهند؛ جزئیات زنجیرهٔ محاسبه در pricing.md.


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.


کاتالوگ نسخهٔ ۲ — دسته، گروه انتخاب، رابطه و دو نوع زمان

اندپوینت‌های بالا دست‌نخورده‌اند؛ آنچه در ادامه می‌آید افزوده است.

چرا جمع ساده رد شد

بند ۵ مستند: «صورت + بیکینی» ۱۵+۱۲=۲۷ دقیقه نیست، ۱۵+۸=۲۳ است — آماده‌سازی و استقرار بیمار دو بار انجام نمی‌شود. هفت دقیقهٔ هدررفته ضرب در روزی ۲۰ نوبت یعنی یک ساعت ظرفیت در روز.

پس هر آیتم دو زمان دارد:

ستون یعنی
solo_duration_minutes وقتی این آیتم تنها انجام شود
additional_duration_minutes وقتی کنار آیتم دیگری در همان نوبت باشد

فرمول: یک آیتم با مدت تنها حساب می‌شود و بقیه با مدت اضافه. لنگر آنکه بزرگ‌ترین مدت تنها را دارد — نه «اولین انتخاب‌شده»، چون آن‌وقت همان سبد با ترتیب دیگر مدت دیگری می‌گرفت و بیمار با جابه‌جا کردن کلیک‌ها وقت کوتاه‌تر می‌خرید. انتخاب بزرگ‌ترین، محافظه‌کارانه هم هست: هیچ ترکیبی کم‌تخمین نمی‌شود.

additional تهی یعنی «همان مدت تنها» — پس دادهٔ موجود دقیقاً مثل قبل (جمع ساده) حساب می‌شود و این تغییر افزایشی است. duration_minutes قدیمی حذف نشده و هم‌گام نوشته می‌شود.

POST /api/v1/service-selection/validate

مهم‌ترین اندپوینت این بخش؛ سایت عمومی و پنل هر دو پیش از مرحلهٔ انتخاب زمان صدایش می‌زنند.

{
  "item_uuids": ["…صورت", "…بیکینی"],
  "service_uuid": "…لیزر",      // اختیاری — گروه‌های کدام سرویس سنجیده شوند
  "branch_uuid": "…شعبه"        // اختیاری — قیمت/مدت اختصاصی شعبه اعمال شود
}

۲۰۰:

{
  "success": true,
  "data": {
    "valid": true,
    "errors": [],
    "total_duration_minutes": 23,
    "total_price_rials": 800000,
    "breakdown": [
      { "item_uuid": "…", "item_name": "صورت",   "counted_as": "solo",       "minutes": 15, "price_rials": 500000 },
      { "item_uuid": "…", "item_name": "بیکینی", "counted_as": "additional", "minutes": 8,  "price_rials": 300000 }
    ]
  }
}

breakdown هست تا UI بتواند نشان دهد چرا جمع با انتظار کاربر فرق دارد.

همهٔ خطاها با هم برمی‌گردند، نه اولی — کاربری که سه مشکل دارد نباید سه بار رفت‌وبرگشت کند.

code کِی
min_select از گروهی با حداقلِ n، کمتر انتخاب شده
max_select از گروهی با سقفِ n، بیشتر انتخاب شده
incompatible دو آیتم ناسازگار با هم انتخاب شده‌اند (یک جفت = یک خطا، نه دو)
missing_prerequisite آیتمی انتخاب شده که پیش‌نیازش نیست

⚠️ آیتم محیط دیگر ۴۰۴ می‌دهد نه ۴۲۲ — وجودش نباید لو برود.

پنل: تب‌های «گروه‌ها و آیتم‌ها» و «بخش‌های نوبت» از صفحهٔ سرویس برداشته شدند — هر چیزی که به یک سرویس مربوط است در خودِ سرویس تعریف می‌شود. مدل و اندپوینت‌ها دست‌نخورده‌اند و AppointmentPlanBuilder همچنان از همان‌ها می‌خواند؛ سرویسی که اتاق و دستگاه را با هم می‌گیرد بدونشان می‌شکست.

گروه انتخاب

GET/POST /api/v1/service-item/{uuid}/groups · PATCH/DELETE /api/v1/item-group/{uuid} · PUT /api/v1/item-group/{uuid}/items

فیلد یعنی
min_select 0 یعنی گروه اختیاری
max_select null یعنی نامحدود — نه صفر

«حتماً یک سطح انرژی، فقط یکی» = min=1, max=1. این یک عدد است نه یک قانون؛ سپردنش به موتور قوانین یعنی هر سرویس چند قانون و هیچ‌کس نمی‌فهمد چرا انتخابش رد شد.

رابطهٔ آیتم‌ها

PUT /api/v1/service-item/{uuid}/relations — جایگزینی کامل.

{ "relations": [{ "related_item_uuid": "…", "type": "incompatible_with" }] }

typeincompatible_with | requires.

  • ناسازگاری متقارن است و فقط وقتی خطاست که هر دو انتخاب شده باشند.
  • پیش‌نیاز جهت‌دار است.
  • حلقهٔ پیش‌نیاز هنگام ثبت 422 می‌گیرد، نه در اعتبارسنجی انتخاب: «الف نیازمند ب» و «ب نیازمند الف» اگر هر دو ذخیره می‌شدند، هیچ انتخابی هرگز معتبر نمی‌شد.

مدت اختصاصی شعبه

PUT /api/v1/service-item/{uuid}/branch-overrides — جایگزینی کامل.

{ "overrides": [{ "address_uuid": "…", "solo_duration_minutes": 25, "additional_duration_minutes": 10 }] }

هر فیلد تهی‌پذیر است و null یعنی «همان مقدار خودِ سرویس» — نه صفر. override فقط وقتی اعمال می‌شود که branch_uuid به validate داده شود.

قیمت اینجا نیست. price_rials از این اندپوینت حذف شده؛ ارسالش نادیده گرفته می‌شود و در پاسخ هم نمی‌آید.

مجوزهای کاتالوگ — ServiceCatalogController

هر ۱۵ routeِ این کنترلر پشت منبعِ services است، per-action و با همان denyServices() کنترلرِ خواهر (ClinicServiceController). منشی و پزشکِ عضوِ کلینیک هرکدام با مجوزِ خودشان سنجیده می‌شوند؛ مالکِ کلینیک، پزشکِ مطبِ شخصی و ادمین عبور می‌کنند.

تا پیش از این کنترلر هیچ گِیتی نداشت و فقط IS_AUTHENTICATED_FULLY روی کلاس بود؛ منشی با services کاملاً خاموش هم می‌توانست کاتالوگِ محیط خودش را بنویسد. مالکیتِ محیط همیشه enforce بوده (owned() / requireItem())، پس دادهٔ محیط دیگری در دسترس نبوده — مسئله بالا رفتن سطح دسترسی داخل همان محیط بود.

متد مسیر مجوز
GET /api/v1/service-categories/tree services.view
GET /api/v1/service-category/{uuid}/includes services.view
GET /api/v1/service-item/{uuid}/groups services.view
POST /api/v1/service-selection/validate services.view
POST /api/v1/service-category services.create
POST /api/v1/service-category/{uuid}/includes services.create
POST /api/v1/service-item/{uuid}/groups services.create
PATCH /api/v1/service-category/{uuid} services.update
PATCH /api/v1/item-group/{uuid} services.update
PUT /api/v1/item-group/{uuid}/items services.update
PUT /api/v1/service-item/{uuid}/relations services.update
PUT /api/v1/service-item/{uuid}/branch-overrides services.update
DELETE /api/v1/service-category/{uuid} services.delete
DELETE /api/v1/service-category/{uuid}/includes/{childUuid} services.delete
DELETE /api/v1/item-group/{uuid} services.delete

service-selection/validate عمداً view است نه create: چیزی نمی‌سازد و فقط یک انتخاب را اعتبارسنجی می‌کند؛ POST بودنش به‌خاطر حجمِ بدنه است.

گِیت اولین دستور هر action است، پیش از requireCategory/requireItem. اگر بعد از آن می‌آمد، uuidِ ناشناخته ۴۰۴ می‌داد و وجود/نبودِ رکورد لو می‌رفت. نبودِ مجوز → 403 ERR_FORBIDDEN_001.

تست: tests/ClinicService/ServiceCatalogPermissionTest.php — روی کل فهرست routeها حلقه می‌زند و یک تستِ نگهبان دارد که تعداد #[Route( و denyServices( را برابر می‌خواهد، تا routeِ تازهٔ بدون گِیت قرمز شود.

دستهٔ درختی

GET /api/v1/service-categories/tree · POST/PATCH/DELETE /api/v1/service-category[/{uuid}]

نامش در کد CatalogCategory است، نه ServiceCategory: آن نام از قبل یک enum بیمه‌ای است (outpatient/inpatient) که روی خودِ ServiceItem هم نشسته. با ServiceSection هم فرق دارد — آن «بخش کلینیک» است، این تاکسونومی کاتالوگ. عمق حداکثر ۴ سطح.

دسته سراسری است: یک بار در «تنظیمات ← دسته‌بندی‌ها» (/admin/service-categories) ساخته می‌شود و سرویس و منبع فقط از همان انتخاب می‌کنند. صفحهٔ سرویس و منبع عمداً امکان ساخت دسته ندارند، وگرنه هر کاربر «تمام بدن» خودش را با املای خودش می‌سازد.

یال «شامل بودن» — CatalogCategoryInclude

جدا از parent. parent سلسله‌مراتب نمایشی است و هر دسته فقط یک والد دارد؛ یال شامل‌بودن یک DAG است، چون «دست» هم زیر «تمام بدن» است و هم زیر «اندام فوقانی». موتور انتخاب سرویس از همین یال‌ها استفاده می‌کند تا رزرو هم‌زمان «لیزر تمام بدن» و «لیزر دست» را رد کند.

متد مسیر مجوز
GET /api/v1/service-category/{uuid}/includes services.view
POST /api/v1/service-category/{uuid}/includes services.create
DELETE /api/v1/service-category/{uuid}/includes/{childUuid} services.delete

POST body: { "child_category_uuid": "<uuid>" } — الزامی.

// POST /api/v1/service-category/{whole}/includes  → 201 (تکرار همان یال → 200، نه خطا)
{
  "success": true,
  "data": {
    "uuid": "f06c1e09-00d8-4f6f-b63b-c41efd5291f1",
    "parent_uuid": null,
    "name": "دست (doc)",
    "sort_order": 0,
    "active": true,
    "children": []
  }
}

// GET → آرایه‌ای از همان شکل
// DELETE → { "success": true, "data": null }

حلقه رد می‌شود — «تمام بدن شامل دست» و بعد «دست شامل تمام بدن» → 422:

{
  "success": false,
  "data": null,
  "errors": [{
    "code": "ERR_VALIDATION_001",
    "message": "«دست (doc)» از قبل زیرمجموعهٔ «تمام بدن (doc)» است؛ این دو نمی‌توانند شامل هم باشند",
    "field": "child_category_uuid"
  }]
}

خطاها: 404 دستهٔ نامعتبر · 422 نبودِ child_category_uuid، حلقه، یا دستهٔ محیط دیگر.

دستهٔ سرویس — catalog_category_uuid روی POST/PATCH /api/v1/service-item[/{uuid}]

سرویس یک دسته دارد (ManyToOne)، برخلاف منبع که چند دسته می‌گیرد؛ اندپوینت جدایی ندارد و همان PATCH سرویس این فیلد را می‌پذیرد.

مقدار اثر
"<uuid>" دسته ست می‌شود
null یا "" دسته پاک می‌شود
فیلد در بدنه نباشد دستهٔ فعلی دست‌نخورده می‌ماند

پاسخ ۲۰۰ کلِ سرویس است و catalog_category_uuid را برمی‌گرداند. ۴۲۲: دستهٔ محیط دیگر ({"code":"ERR_VALIDATION_002","message":"دسته‌بندی یافت نشد","field":"catalog_category_uuid"}).

در پنل، تب «دسته‌بندی‌ها»ی /admin/service/{uuid} فقط همین انتخاب را انجام می‌دهد؛ دکمهٔ ساخت دسته عمداً آنجا نیست.

دستهٔ منبع — PUT /api/v1/resource/{uuid}/categories

مستند کامل در resource.md.

تست‌ها

ddev exec php bin/phpunit tests/ClinicService     # ۵۲ تست

قوانین دستهٔ «انتخاب»

POST /api/v1/service-selection/validate علاوه بر گروه و رابطه، ممنوعیت‌های دستهٔ selection را هم برمی‌گرداند:

{ "code": "policy_forbidden", "items": ["<service-uuid>"], "message": "این خدمت موقتاً متوقف است" }

گروه و رابطه ساختار ثابت کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن به کاتالوگ روشن و خاموش می‌کند. اجرا فقط وقتی است که branch_uuid بیاید — محیط از شعبه می‌آید.

جزئیات: policy.md