# 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` از این اندپوینت حذف شده؛ ارسالش نادیده گرفته می‌شود و در پاسخ هم نمی‌آید. ## مجوزهای کاتالوگ — `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": "" }` — الزامی. ```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` سرویس این فیلد را می‌پذیرد. | مقدار | اثر | |---|---| | `""` | دسته ست می‌شود | | `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": [""], "message": "این خدمت موقتاً متوقف است" } ``` گروه و رابطه ساختار ثابت کاتالوگ‌اند؛ قانون چیزی است که کلینیک بدون دست زدن به کاتالوگ روشن و خاموش می‌کند. اجرا فقط وقتی است که `branch_uuid` بیاید — محیط از شعبه می‌آید. جزئیات: [policy.md](policy.md)