# Clinic Services API

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

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

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

---

## GET /api/v1/service-categories

لیست انواع خدمت (سرپایی/بستری/…). **تنها منبع** این لیست برای کلاینت‌ها؛ افزودن نوع تازه در
بک‌اند یک `case` است و بدون تغییر فرانت اینجا ظاهر می‌شود. درصد پوشش بیمه به ازای همین
نوع‌ها تعیین می‌شود ([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)).

**Permission:** `IS_AUTHENTICATED_FULLY`

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

---

## GET /api/v1/service-sections

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

**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+

**Response 200:**
```json
{
  "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:**
```json
{ "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

```json
{ "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:**
```json
{
  "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`):

```json
{
  "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:**
```json
{
  "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:**
```json
{
  "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](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را می‌پذیرد تا کلاینت‌های قدیمی نشکنند، ولی ذخیره‌ی پوشش بعداً آن را بازنویسی می‌کند |
| service_category | string | ❌ (پیش‌فرض `outpatient`) — «نوع خدمت»؛ یکی از مقادیر [`GET /api/v1/service-categories`](#get-apiv1service-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](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}

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

```json
{
  "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}` — سرویسِ غیرفعال در پذیرشِ جدید نمایش داده نمی‌شود ولی سوابق حفظ می‌مانند.

```json
{
  "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](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`

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

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

**۲۰۰:**

```json
{
  "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` — جایگزینی کامل.

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

`type` ∈ `incompatible_with` | `requires`.

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

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

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

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

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

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

## دستهٔ درختی

`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>" }` — الزامی.

```jsonc
// 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**:

```json
{
  "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`](resource.md).

## تست‌ها

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

---

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

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

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

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

جزئیات: [policy.md](policy.md)
