Rules become data instead of code: a clinic can say "laser under 18 requires parental consent" without a deploy. Engine - Policy / PolicyVersionLog entities, closed field/operator/effect lists per category (PolicySchema), condition validation at write time - PolicyResolver: priority -> specificity -> age, combining effects by veto / max / sum / union - A missing fact fails its clause instead of silently passing it - Policies are drafts until activated, and are versioned rather than edited Wiring - selection -> ServiceSelectionValidator - eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time - resource + timing -> AppointmentPlanBuilder, including template-less services - pricing -> PricingEngine, alongside (not replacing) the manual discount The condition column is named condition_json: `condition` is a MariaDB keyword and broke every INSERT. Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a clinic with no policies sees byte-identical output to task 08. Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
523 lines
23 KiB
Markdown
523 lines
23 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 | حذف مجاز نیست — فقط غیرفعالکردن ممکن است |
|
||
|
||
---
|
||
|
||
## تعرفهی نسخهدار سالانه (Tariff) — فاز ۳ سیستم صورتحساب
|
||
|
||
هر خدمت میتواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفهی سالی ثبت نشود، به `price_rials` خود خدمت fallback میشود (`TariffService::resolvePrice`). سال جاری شمسی سمت سرور با `IntlDateFormatter` (تقویم persian) محاسبه میشود.
|
||
|
||
### GET /api/v1/service-items/{uuid}/tariffs
|
||
|
||
لیست تعرفههای یک خدمت + قیمت پیشفرض + سال جاری.
|
||
|
||
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک خدمت)
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"current_year": 1405,
|
||
"default_price_rials": 500000,
|
||
"data": [
|
||
{ "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true },
|
||
{ "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### PUT /api/v1/service-items/{uuid}/tariffs/{year}
|
||
|
||
ثبت/بهروزرسانی تعرفهی یک سال (upsert). `year` بین ۱۳۹۰ تا ۱۵۰۰.
|
||
|
||
**Body:**
|
||
```json
|
||
{ "price_rials": 600000 }
|
||
```
|
||
|
||
**Response 200:** `{ success, data: { …tariff } }`
|
||
|
||
> اگر `year` برابر **سال جاری** باشد، `ServiceItem.price_rials` هم با همین مقدار همگام میشود (قیمت واحد). تعرفهی سالهای دیگر فقط برای محاسبهی صورتحساب همان سال (`TariffService::resolvePrice`) بهکار میرود و قیمت پایهی سرویس را تغییر نمیدهد. همچنین `PATCH /service-item/{uuid}` با تغییر `price_rials`، تعرفهی سال جاری را upsert میکند.
|
||
|
||
**Errors:**
|
||
| Code | HTTP | توضیح |
|
||
|------|------|-------|
|
||
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
|
||
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
|
||
|
||
|
||
---
|
||
|
||
## 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` | آیتمی انتخاب شده که پیشنیازش نیست |
|
||
|
||
⚠️ آیتم محیط دیگر **۴۰۴** میدهد نه ۴۲۲ — وجودش نباید لو برود.
|
||
|
||
## گروه انتخاب
|
||
|
||
`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": "…", "price_rials": 900000, "solo_duration_minutes": 25 }] }
|
||
```
|
||
|
||
هر فیلد تهیپذیر است و `null` یعنی «همان مقدار خودِ سرویس» — **نه صفر**.
|
||
override فقط وقتی اعمال میشود که `branch_uuid` به `validate` داده شود.
|
||
|
||
## دستهٔ درختی
|
||
|
||
`GET /api/v1/service-categories/tree` · `POST/PATCH/DELETE /api/v1/service-category[/{uuid}]`
|
||
|
||
نامش در کد `CatalogCategory` است، نه `ServiceCategory`: آن نام از قبل یک **enum بیمهای**
|
||
است (`outpatient`/`inpatient`) که روی خودِ `ServiceItem` هم نشسته. با `ServiceSection` هم
|
||
فرق دارد — آن «بخش کلینیک» است، این تاکسونومی کاتالوگ. عمق حداکثر ۴ سطح.
|
||
|
||
## تستها
|
||
|
||
```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)
|