feat(catalog): manage global categories from settings

Categories are the taxonomy both services and resources select from, but
until now they could only be reached through the service catalog, so every
environment ended up with its own spelling of "whole body".

- Settings > Categories page: global CRUD plus the "includes" edge
- POST/GET/DELETE /api/v1/service-category/{uuid}/includes — a DAG, kept
  separate from `parent` because "hand" sits under both "whole body" and
  "upper limb"; a cycle is refused with 422
- PUT /api/v1/resource/{uuid}/categories — full replacement, and a category
  from another environment is rejected explicitly since the uuid arrives in
  the request body where TenantFilter does not reach

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 12:58:26 +03:30
co-authored by Claude Opus 5
parent f8e8a63ae8
commit 03d8d7d68a
13 changed files with 865 additions and 0 deletions
+56
View File
@@ -499,6 +499,62 @@ override فقط وقتی اعمال می‌شود که `branch_uuid` به `valid
است (`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`، حلقه، یا دستهٔ محیط دیگر.
### دستهٔ منبع — `PUT /api/v1/resource/{uuid}/categories`
مستند کامل در [`resource.md`](resource.md).
## تست‌ها
```bash