feat(catalog): dual durations, item groups, relations and branch overrides

Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.

Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.

additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.

ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.

Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.

Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.

Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.

1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.

The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 18:44:02 +03:30
co-authored by Claude Opus 5
parent 0f4db93fb8
commit b1b06c1b36
21 changed files with 2388 additions and 72 deletions
+126
View File
@@ -378,3 +378,129 @@ 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 # ۵۲ تست
```