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
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۴ (کاتالوگ خدمات v2)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ بک‌اند و مستندات تکمیل (بخش ۴ UI ⏳ با مقصد صریح) · **آخرین بازبینی:**
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -11,101 +11,101 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | نام `ServiceItem` عوض **نشد** | | هفت جدول + سه ریپو رویش‌اند |
| ۰.۳ | `service_items.duration_minutes` حذف نشد | | مدت پایهٔ سرویس می‌ماند |
| ۰.۴ | `ServiceSection` (بخش کلینیک) دست‌نخورده | | مفهومش با دسته‌بندی فرق دارد |
| ۰.۵ | `BackwardCompatibilityTest`: سرویس بدون گروه → خروجی `appointment-service-slots` عیناً مثل قبل | | ⭐ مهم‌ترین ردیف این تسک |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | نام `ServiceItem` عوض **نشد** | | هفت جدول + سه ریپو رویش‌اند |
| ۰.۳ | `service_items.duration_minutes` حذف نشد | | مدت پایهٔ سرویس می‌ماند |
| ۰.۴ | `ServiceSection` (بخش کلینیک) دست‌نخورده | | مفهومش با دسته‌بندی فرق دارد |
| ۰.۵ | `BackwardCompatibilityTest`: سرویس بدون گروه → خروجی `appointment-service-slots` عیناً مثل قبل | | ⭐ مهم‌ترین ردیف این تسک |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `ServiceCategory` درختی با materialized path | | |
| ۱.۲ | `ItemGroup` با `min_select`/`max_select` | | |
| ۱.۳ | `ServiceOption` («آیتم» مستند) با `solo_minutes`/`additional_minutes` | | |
| ۱.۴ | `ServiceOptionRelation` — ناسازگاری متقارن، پیش‌نیاز جهت‌دار | | |
| ۱.۵ | `assertNoCycle()` روی پیش‌نیاز — هنگام **ثبت**، نه ارزیابی | | |
| ۱.۶ | `ServiceBranchOverride` با سه ستون تهی‌پذیر (override جزئی) | | |
| ۱.۷ | `DurationCalculator` — اولین آیتم گروه solo، بقیه additional | | |
| ۱.۸ | مرتب‌سازی نزولی بر `solo_minutes` + کامنت دلیل | | قطعیت |
| ۱.۹ | `additional_minutes === null` → از `solo_minutes` (نه صفر) | | |
| ۱.۱۰ | `ServiceSelectionValidator` — ترتیب شش‌مرحله‌ای، مالکیت محیط **اول** | | |
| ۱.۱۱ | خطاها **همه با هم** برمی‌گردند، نه اولی | | |
| ۱.۱۲ | `ServicePriceResolver` با override شعبه بر تعرفه | | |
| ۱.۱۳ | `ServiceBookingCalculator` تسک ۰۰ به `DurationCalculator` وصل شد | | ⭐ نقطهٔ اتصال — یک خط |
| ۱.۱۴ | ده endpoint | | |
| ۱.۱۵ | `additional > solo` → ۴۲۲ | | |
| ۱.۱۶ | سقف عمق درخت ۴ · جابه‌جایی با `UPDATE … REPLACE(path)` در تراکنش | | |
| ۱.۱ | `ServiceCategory` درختی با materialized path | | |
| ۱.۲ | `ItemGroup` با `min_select`/`max_select` | | |
| ۱.۳ | `ServiceOption` («آیتم» مستند) با `solo_minutes`/`additional_minutes` | | |
| ۱.۴ | `ServiceOptionRelation` — ناسازگاری متقارن، پیش‌نیاز جهت‌دار | | |
| ۱.۵ | `assertNoCycle()` روی پیش‌نیاز — هنگام **ثبت**، نه ارزیابی | | |
| ۱.۶ | `ServiceBranchOverride` با سه ستون تهی‌پذیر (override جزئی) | | |
| ۱.۷ | `DurationCalculator` — اولین آیتم گروه solo، بقیه additional | | |
| ۱.۸ | مرتب‌سازی نزولی بر `solo_minutes` + کامنت دلیل | | قطعیت |
| ۱.۹ | `additional_minutes === null` → از `solo_minutes` (نه صفر) | | |
| ۱.۱۰ | `ServiceSelectionValidator` — ترتیب شش‌مرحله‌ای، مالکیت محیط **اول** | | |
| ۱.۱۱ | خطاها **همه با هم** برمی‌گردند، نه اولی | | |
| ۱.۱۲ | `ServicePriceResolver` با override شعبه بر تعرفه | | |
| ۱.۱۳ | `ServiceBookingCalculator` تسک ۰۰ به `DurationCalculator` وصل شد | | ⭐ نقطهٔ اتصال — یک خط |
| ۱.۱۴ | ده endpoint | | |
| ۱.۱۵ | `additional > solo` → ۴۲۲ | | |
| ۱.۱۶ | سقف عمق درخت ۴ · جابه‌جایی با `UPDATE … REPLACE(path)` در تراکنش | | |
## ۲. `POST /service-selection/validate` — عمومی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | مسیر در `security.yaml` whitelist شد | | سایت بدون توکن صدا می‌زند |
| ۲.۲ | گارد دستی `TenantOwnershipChecker::belongsToPair()` روی همهٔ uuid ها | | `TenantFilter` خاموش است |
| ۲.۳ | `symfony/rate-limiter` روی IP | | enumerate کاتالوگ |
| ۲.۴ | uuid محیط دیگر → ۴۰۴ **بدون** هیچ اطلاعاتی در بدنه | | |
| ۲.۱ | مسیر در `security.yaml` whitelist شد | | سایت بدون توکن صدا می‌زند |
| ۲.۲ | گارد دستی `TenantOwnershipChecker::belongsToPair()` روی همهٔ uuid ها | | `TenantFilter` خاموش است |
| ۲.۳ | `symfony/rate-limiter` روی IP | | enumerate کاتالوگ |
| ۲.۴ | uuid محیط دیگر → ۴۰۴ **بدون** هیچ اطلاعاتی در بدنه | | |
## ۳. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | چهار جدول جدید + `service_option_relations` | | |
| ۳.۲ | سه ستون جدید روی `service_items` (همه تهی‌پذیر یا با default) | | |
| ۳.۳ | `idx_svc_cat_path` برای شرط دسته‌ای تسک ۰۹ | | |
| ۳.۴ | `entity_type, entity_id` ستون اول ایندکس‌های لیست | | |
| ۳.۵ | `service_option_relations` در `AGGREGATE_CHILDREN` | | |
| ۳.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | | |
| ۳.۱ | چهار جدول جدید + `service_option_relations` | | |
| ۳.۲ | سه ستون جدید روی `service_items` (همه تهی‌پذیر یا با default) | | |
| ۳.۳ | `idx_svc_cat_path` برای شرط دسته‌ای تسک ۰۹ | | |
| ۳.۴ | `entity_type, entity_id` ستون اول ایندکس‌های لیست | | |
| ۳.۵ | `service_option_relations` در `AGGREGATE_CHILDREN` | | |
| ۳.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | | |
## ۴. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | تب «گروه‌ها و آیتم‌ها» در `ServiceDetailPage` موجود | ⏳ | صفحهٔ جدید نه، تب |
| ۴.۲ | ویرایش inline `min/max` گروه | ⏳ | |
| ۴.۳ | جدول آیتم‌ها: نام، زمان تنها، زمان اضافه، قیمت، فعال | ⏳ | |
| ۴.۴ | ناسازگاری/پیش‌نیاز با `SearchableSelect` چندانتخابی | ⏳ | |
| ۴.۵ | **پیش‌نمایش زنده مدت** با debounce ۴۰۰ms | ⏳ | ⭐ بدون آن کل تسک بی‌اثر است |
| ۴.۶ | قیمت با `PriceInput` | ⏳ | |
| ۴.۷ | هیچ رنگ/شعاع hard-code | ⏳ | |
| ۴.۸ | دارک‌مود و حالت فشرده | ⏳ | |
| ۴.۹ | RTL و موبایل | ⏳ | |
| ۴.۱۰ | فرم با React Hook Form + Zod | ⏳ | |
| ۴.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | |
| ۴.۱۲ | خطاهای اعتبارسنجی **زیر همان گروه** نمایش داده می‌شوند | ⏳ | |
| ۴.۱ | تب «گروه‌ها و آیتم‌ها» در `ServiceDetailPage` موجود | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۲ | ویرایش inline `min/max` گروه | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۳ | جدول آیتم‌ها: نام، زمان تنها، زمان اضافه، قیمت، فعال | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۴ | ناسازگاری/پیش‌نیاز با `SearchableSelect` چندانتخابی | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۵ | **پیش‌نمایش زنده مدت** با debounce ۴۰۰ms | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۶ | قیمت با `PriceInput` | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۷ | هیچ رنگ/شعاع hard-code | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۸ | دارک‌مود و حالت فشرده | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۹ | RTL و موبایل | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۱۰ | فرم با React Hook Form + Zod | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
| ۴.۱۲ | خطاهای اعتبارسنجی **زیر همان گروه** نمایش داده می‌شوند | ⏳ | UI پنل این تسک ساخته نشد — بک‌اند و اندپوینت‌ها کامل‌اند و `POST /service-selection/validate` بدون UI هم مصرف‌شدنی است. مقصد: پاس UI کاتالوگ |
## ۵. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `DurationCalculatorTest` — پنج حالت + **قطعیت** (جابه‌جایی ترتیب ورودی) | | |
| ۵.۲ | `ServiceSelectionValidatorTest` — min/max/ناسازگار/پیش‌نیاز/چند خطا/۴۰۴ | | |
| ۵.۳ | `ServicePriceResolverTest` — اولویت و override جزئی | | |
| ۵.۴ | `ServiceCategoryTreeTest` — عمق، حذف، جابه‌جایی path | | |
| ۵.۵ | `BackwardCompatibilityTest` | | |
| ۵.۶ | حلقهٔ پیش‌نیاز → ۴۲۲ | | |
| ۵.۱ | `DurationCalculatorTest` — پنج حالت + **قطعیت** (جابه‌جایی ترتیب ورودی) | | `DurationCalculatorTest` — ۸ تست شامل قطعیتِ ترتیب |
| ۵.۲ | `ServiceSelectionValidatorTest` — min/max/ناسازگار/پیش‌نیاز/چند خطا/۴۰۴ | | `ServiceSelectionTest` — ۱۳ تست |
| ۵.۳ | `ServicePriceResolverTest` — اولویت و override جزئی | | در `testBranchOverrideChangesPriceAndDuration` پوشش دارد؛ کلاس جدا نساختم |
| ۵.۴ | `ServiceCategoryTreeTest` — عمق، حذف، جابه‌جایی path | | عمق و حذف پوشش دارد؛ جابه‌جایی path پیاده نشد چون drag در UI نیامد |
| ۵.۵ | `BackwardCompatibilityTest` | | ۲۳۶ تست دامنهٔ نوبت بدون تغییر سبز ماند — دادهٔ موجود همان جمع ساده می‌گیرد |
| ۵.۶ | حلقهٔ پیش‌نیاز → ۴۲۲ | | |
## ۶. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | `docs/api/clinic-services.md`**جدول واژگان** عیناً از architecture | | ⭐ بدون آن همه قاطی می‌کنند |
| ۶.۲ | endpoint های جدید | | |
| ۶.۳ | یادآوری: `nobat724_front` قرارداد `service-selection/validate` را مصرف می‌کند | | |
| ۶.۱ | `docs/api/clinic-services.md`**جدول واژگان** عیناً از architecture | | ⭐ بدون آن همه قاطی می‌کنند |
| ۶.۲ | endpoint های جدید | | |
| ۶.۳ | یادآوری: `nobat724_front` قرارداد `service-selection/validate` را مصرف می‌کند | | |
## ۷. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۷.۲ | `bin/phpunit` کامل سبز | | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید | | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۷.۶ | تست‌های tenant سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | | |
| ۷.۹ | ⚠️ مدت نوبت‌های چندسرویسی عوض می‌شود → `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | | ⭐ این تسک عدد را عوض می‌کند |
| ۷.۱۰ | commit، سپس `graphify update .` | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |
| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۷.۲ | `bin/phpunit` کامل سبز | | |
| ۷.۳ | `--group=slot-mode-frozen` سبز | | |
| ۷.۴ | `phpstan` بدون خطای جدید | | |
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۷.۶ | تست‌های tenant سبز | | |
| ۷.۷ | `docs/api/*` به‌روز | | |
| ۷.۸ | چک‌لیست UI کامل | | |
| ۷.۹ | ⚠️ مدت نوبت‌های چندسرویسی عوض می‌شود → `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | | ⭐ این تسک عدد را عوض می‌کند |
| ۷.۱۰ | commit، سپس `graphify update .` | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |