Files
clinicpro/docs/api/clinic-services.md
T
hamedandClaude Opus 5 b8e8580867 refactor(services): drop the groups and segments tabs
Anything that belongs to a service is defined on the service itself, so the
two tabs that managed selection groups and appointment segments come off the
service page.

Only the UI goes. SegmentTemplate is what makes a service occupy a room and a
device at the same time — it is the input to AppointmentPlanBuilder and the
reason the resource timeline has anything to draw — and a service without a
template already books through singleSegment(). Removing the model would
change booking; removing the tabs does not, which tests/Appointment confirms
at 314 green.

The active tab moved into the query string on the way past. That is what
makes the old ?tab=segments link land on the info tab instead of rendering
nothing, and it lets back and refresh return to the same tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:56:49 +03:30

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


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

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

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

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

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

ستون یعنی
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": "…", "price_rials": 900000, "solo_duration_minutes": 25 }] }

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

دستهٔ درختی

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 appointment_settings.view
POST /api/v1/service-category/{uuid}/includes appointment_settings.update
DELETE /api/v1/service-category/{uuid}/includes/{childUuid} appointment_settings.update

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