Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
566 lines
26 KiB
Markdown
566 lines
26 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` از این اندپوینت حذف شده؛ ارسالش نادیده گرفته میشود
|
||
و در پاسخ هم نمیآید.
|
||
|
||
## دستهٔ درختی
|
||
|
||
`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)
|