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>
607 lines
28 KiB
Markdown
607 lines
28 KiB
Markdown
# 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": "<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)
|