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 # ۵۲ تست
```
@@ -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 .` | | |
| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | | |
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Service catalog v2: tree categories, item groups, item relations and per-branch
* overrides, plus the two duration columns.
*
* duration_minutes is kept and backfilled into solo_duration_minutes rather than
* renamed: several consumers still read it, and additional_duration_minutes stays
* NULL, which the entity reads as "same as solo" — so existing data keeps behaving
* exactly as it did (a plain sum) until someone fills the second number in.
*/
final class Version20260730150551 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add catalog categories, item groups, item relations, branch overrides and dual durations';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE service_branch_overrides (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, price_rials BIGINT DEFAULT NULL, solo_duration_minutes SMALLINT DEFAULT NULL, additional_duration_minutes SMALLINT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, item_id INT NOT NULL, address_id INT NOT NULL, UNIQUE INDEX UNIQ_6E23CD38D17F50A6 (uuid), INDEX IDX_6E23CD38126F525E (item_id), INDEX IDX_6E23CD38F5B7AF75 (address_id), INDEX idx_override_tenant (entity_type, entity_id), UNIQUE INDEX uniq_override_item_address (item_id, address_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_catalog_categories (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, sort_order SMALLINT DEFAULT 0 NOT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, parent_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_790A858DD17F50A6 (uuid), INDEX idx_catalog_cat_tenant (entity_type, entity_id, active), INDEX idx_catalog_cat_parent (parent_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_item_group_members (id INT AUTO_INCREMENT NOT NULL, sort_order SMALLINT DEFAULT 0 NOT NULL, group_id INT NOT NULL, item_id INT NOT NULL, INDEX IDX_D9DF1F16FE54D947 (group_id), INDEX idx_group_member_item (item_id), UNIQUE INDEX uniq_group_item (group_id, item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_item_groups (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, min_select SMALLINT DEFAULT 0 NOT NULL, max_select SMALLINT DEFAULT NULL, sort_order SMALLINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_id INT NOT NULL, UNIQUE INDEX UNIQ_BA0E6A13D17F50A6 (uuid), INDEX IDX_BA0E6A13ED5CA9E6 (service_id), INDEX idx_item_group_tenant (entity_type, entity_id), INDEX idx_item_group_service (service_id, sort_order), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_item_relations (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(20) NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, item_id INT NOT NULL, related_item_id INT NOT NULL, UNIQUE INDEX UNIQ_1BD0F43ED17F50A6 (uuid), INDEX IDX_1BD0F43E126F525E (item_id), INDEX IDX_1BD0F43E2D7698FB (related_item_id), INDEX idx_relation_tenant (entity_type, entity_id), INDEX idx_relation_item_type (item_id, type), UNIQUE INDEX uniq_relation_triple (item_id, related_item_id, type), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE service_branch_overrides ADD CONSTRAINT FK_6E23CD38126F525E FOREIGN KEY (item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_branch_overrides ADD CONSTRAINT FK_6E23CD38F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_catalog_categories ADD CONSTRAINT FK_790A858D727ACA70 FOREIGN KEY (parent_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_group_members ADD CONSTRAINT FK_D9DF1F16FE54D947 FOREIGN KEY (group_id) REFERENCES service_item_groups (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_group_members ADD CONSTRAINT FK_D9DF1F16126F525E FOREIGN KEY (item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_groups ADD CONSTRAINT FK_BA0E6A13ED5CA9E6 FOREIGN KEY (service_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_relations ADD CONSTRAINT FK_1BD0F43E126F525E FOREIGN KEY (item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_relations ADD CONSTRAINT FK_1BD0F43E2D7698FB FOREIGN KEY (related_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_items ADD solo_duration_minutes SMALLINT DEFAULT NULL, ADD additional_duration_minutes SMALLINT DEFAULT NULL, ADD session_count SMALLINT DEFAULT 1 NOT NULL, ADD catalog_category_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE service_items ADD CONSTRAINT FK_486C04AA3F2BC4C FOREIGN KEY (catalog_category_id) REFERENCES service_catalog_categories (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_486C04AA3F2BC4C ON service_items (catalog_category_id)');
}
public function postUp(Schema $schema): void
{
// مدت موجود همان «مدت تنها»ست؛ بدون این، هر سرویسِ موجود مدت صفر می‌گرفت.
$this->connection->executeStatement(
'UPDATE service_items SET solo_duration_minutes = duration_minutes WHERE duration_minutes IS NOT NULL'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE service_branch_overrides DROP FOREIGN KEY FK_6E23CD38126F525E');
$this->addSql('ALTER TABLE service_branch_overrides DROP FOREIGN KEY FK_6E23CD38F5B7AF75');
$this->addSql('ALTER TABLE service_catalog_categories DROP FOREIGN KEY FK_790A858D727ACA70');
$this->addSql('ALTER TABLE service_item_group_members DROP FOREIGN KEY FK_D9DF1F16FE54D947');
$this->addSql('ALTER TABLE service_item_group_members DROP FOREIGN KEY FK_D9DF1F16126F525E');
$this->addSql('ALTER TABLE service_item_groups DROP FOREIGN KEY FK_BA0E6A13ED5CA9E6');
$this->addSql('ALTER TABLE service_item_relations DROP FOREIGN KEY FK_1BD0F43E126F525E');
$this->addSql('ALTER TABLE service_item_relations DROP FOREIGN KEY FK_1BD0F43E2D7698FB');
$this->addSql('DROP TABLE service_branch_overrides');
$this->addSql('DROP TABLE service_catalog_categories');
$this->addSql('DROP TABLE service_item_group_members');
$this->addSql('DROP TABLE service_item_groups');
$this->addSql('DROP TABLE service_item_relations');
$this->addSql('ALTER TABLE service_items DROP FOREIGN KEY FK_486C04AA3F2BC4C');
$this->addSql('DROP INDEX IDX_486C04AA3F2BC4C ON service_items');
$this->addSql('ALTER TABLE service_items DROP solo_duration_minutes, DROP additional_duration_minutes, DROP session_count, DROP catalog_category_id');
}
}
@@ -7,6 +7,7 @@ use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\ValueObject\ServiceBookingDuration;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Service\DurationCalculator;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
@@ -27,6 +28,7 @@ final class ServiceBookingCalculator
{
public function __construct(
private readonly ServiceItemRepository $itemRepo,
private readonly DurationCalculator $durations,
private readonly WeeklyScheduleRepository $schedules,
private readonly TenantOwnershipChecker $ownership,
) {}
@@ -69,9 +71,9 @@ final class ServiceBookingCalculator
): ServiceBookingDuration {
$this->assertBelongsToContext($serviceUuids, $doctor, $clinic);
$totalMinutes = 0;
$resolved = [];
$warnings = [];
$resolved = [];
$warnings = [];
$overridden = [];
foreach ($serviceUuids as $uuid) {
$item = $this->itemRepo->findByUuid($uuid);
@@ -87,17 +89,20 @@ final class ServiceBookingCalculator
}
$override = isset($durationOverrides[$uuid]) ? (int) $durationOverrides[$uuid] : 0;
$duration = $override > 0 ? $override : (int) ($item->getDurationMinutes() ?? 0);
$duration = $override > 0 ? $override : (int) ($item->getSoloDurationMinutes() ?? 0);
if ($duration <= 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += $duration;
$resolved[] = $item;
if ($override > 0) {
$overridden[(int) $item->getId()] = $override;
}
$resolved[] = $item;
}
return new ServiceBookingDuration(
totalMinutes: $totalMinutes,
totalMinutes: $this->durations->totalMinutes($resolved, [], $overridden),
bufferMinutes: $this->bufferMinutes($doctor, $clinic),
serviceItems: $resolved,
warnings: $warnings,
@@ -0,0 +1,515 @@
<?php
namespace App\ClinicService\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItemRelation;
use App\ClinicService\Repository\CatalogCategoryRepository;
use App\ClinicService\Repository\ItemGroupMemberRepository;
use App\ClinicService\Repository\ItemGroupRepository;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\ClinicService\Repository\ServiceItemRelationRepository;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Service\ServiceSelectionValidator;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* کاتالوگ نسخهٔ ۲ — دستهٔ درختی، گروه انتخاب، رابطهٔ آیتم‌ها و اعتبارسنجی انتخاب.
*
* اندپوینت‌های موجود سرویس ({@see ClinicServiceController}) دست‌نخورده‌اند؛ این کنترلر
* فقط چیزهای تازه را اضافه می‌کند.
*/
#[OA\Tag(name: 'Clinic Services')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ServiceCatalogController extends BaseController
{
public function __construct(
private readonly CatalogCategoryRepository $categories,
private readonly ItemGroupRepository $groups,
private readonly ItemGroupMemberRepository $groupMembers,
private readonly ServiceItemRepository $items,
private readonly ServiceItemRelationRepository $relations,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly ServiceSelectionValidator $validator,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
// ── دستهٔ درختی ─────────────────────────────────────────────────────────
#[Route('/api/v1/service-categories/tree', name: 'service_category_tree', methods: ['GET'])]
public function tree(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->branches->pair($user);
// کل درخت با یک کوئری خوانده و در PHP بسته می‌شود.
$all = $this->categories->findForPair($entityType, $entityId);
$childrenOf = [];
foreach ($all as $node) {
$childrenOf[$node->getParent()?->getId() ?? 0][] = $node;
}
return $this->success($this->buildTree($childrenOf, 0));
}
/** @return list<array<string, mixed>> */
private function buildTree(array $childrenOf, int $parentId): array
{
return array_map(
fn (CatalogCategory $node): array => $node->toArray(
$this->buildTree($childrenOf, (int) $node->getId()),
),
$childrenOf[$parentId] ?? [],
);
}
#[Route('/api/v1/service-category', name: 'service_category_create', methods: ['POST'])]
public function createCategory(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : '';
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام دسته الزامی است', 422, 'name');
}
[$entityType, $entityId] = $this->branches->pair($user);
$parent = is_string($data['parent_uuid'] ?? null)
? $this->requireCategory($user, $data['parent_uuid'])
: null;
$category = new CatalogCategory($entityType, $entityId, $name, $parent);
if ($category->depth() > CatalogCategory::MAX_DEPTH) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('عمق دسته‌بندی حداکثر %d سطح است', CatalogCategory::MAX_DEPTH),
422,
'parent_uuid',
);
}
if (is_numeric($data['sort_order'] ?? null)) {
$category->setSortOrder((int) $data['sort_order']);
}
$this->em->persist($category);
$this->em->flush();
return $this->success($category->toArray(), 201);
}
#[Route('/api/v1/service-category/{uuid}', name: 'service_category_update', methods: ['PATCH'])]
public function updateCategory(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$category = $this->requireCategory($user, $uuid);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
$category->setName(trim($data['name']));
}
if (is_numeric($data['sort_order'] ?? null)) {
$category->setSortOrder((int) $data['sort_order']);
}
if (array_key_exists('active', $data)) {
$category->setActive((bool) $data['active']);
}
$this->em->flush();
return $this->success($category->toArray());
}
#[Route('/api/v1/service-category/{uuid}', name: 'service_category_delete', methods: ['DELETE'])]
public function deleteCategory(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$category = $this->requireCategory($user, $uuid);
if ($this->categories->countChildren($category) > 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ابتدا زیردسته‌ها را حذف کنید', 422);
}
$this->em->remove($category);
$this->em->flush();
return $this->success(null);
}
// ── گروه آیتم ───────────────────────────────────────────────────────────
#[Route('/api/v1/service-item/{uuid}/groups', name: 'service_item_groups', methods: ['GET'])]
public function listGroups(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$service = $this->requireItem($user, $uuid);
return $this->success(array_map(
static fn (ItemGroup $g): array => $g->toArray(),
$this->groups->findForService($service),
));
}
#[Route('/api/v1/service-item/{uuid}/groups', name: 'service_item_group_create', methods: ['POST'])]
public function createGroup(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$service = $this->requireItem($user, $uuid);
$name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : '';
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام گروه الزامی است', 422, 'name');
}
$group = new ItemGroup($service, $name);
$this->applyGroupRange($group, $data);
if (is_numeric($data['sort_order'] ?? null)) {
$group->setSortOrder((int) $data['sort_order']);
}
$this->em->persist($group);
$this->em->flush();
return $this->success($group->toArray(), 201);
}
#[Route('/api/v1/item-group/{uuid}', name: 'item_group_update', methods: ['PATCH'])]
public function updateGroup(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$group = $this->requireGroup($user, $uuid);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
$group->setName(trim($data['name']));
}
$this->applyGroupRange($group, $data);
if (is_numeric($data['sort_order'] ?? null)) {
$group->setSortOrder((int) $data['sort_order']);
}
$this->em->flush();
return $this->success($group->toArray());
}
#[Route('/api/v1/item-group/{uuid}', name: 'item_group_delete', methods: ['DELETE'])]
public function deleteGroup(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->em->remove($this->requireGroup($user, $uuid));
$this->em->flush();
return $this->success(null);
}
/** جایگزینی کامل آیتم‌های گروه. */
#[Route('/api/v1/item-group/{uuid}/items', name: 'item_group_items_replace', methods: ['PUT'])]
public function replaceGroupItems(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['items'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد items الزامی است', 422, 'items');
}
$group = $this->requireGroup($user, $uuid);
// همهٔ آیتم‌ها پیش از هر حذفی حل می‌شوند: uuid نامعتبر در انتهای فهرست نباید
// اعضای درستِ قبلی را پاک کند و بعد ۴۰۴ بدهد.
$resolved = [];
foreach ($data['items'] as $index => $row) {
$itemUuid = is_array($row) ? ($row['item_uuid'] ?? null) : $row;
if (!is_string($itemUuid) || $itemUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد item_uuid الزامی است', 422, 'item_uuid');
}
$sortOrder = is_array($row) && is_numeric($row['sort_order'] ?? null) ? (int) $row['sort_order'] : $index;
$resolved[] = [$this->requireItem($user, $itemUuid), $sortOrder];
}
$this->groupMembers->deleteForGroup($group);
$group->getMembers()->clear();
foreach ($resolved as [$item, $sortOrder]) {
$member = new ItemGroupMember($group, $item, $sortOrder);
$this->em->persist($member);
$group->getMembers()->add($member);
}
$group->touch();
$this->em->flush();
return $this->success($group->toArray());
}
// ── رابطهٔ آیتم‌ها ───────────────────────────────────────────────────────
#[Route('/api/v1/service-item/{uuid}/relations', name: 'service_item_relations_replace', methods: ['PUT'])]
public function replaceRelations(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['relations'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد relations الزامی است', 422, 'relations');
}
$item = $this->requireItem($user, $uuid);
$resolved = [];
foreach ($data['relations'] as $row) {
if (!is_array($row) || !is_string($row['related_item_uuid'] ?? null) || !is_string($row['type'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'related_item_uuid و type الزامی‌اند', 422, 'relations');
}
if (!in_array($row['type'], ServiceItemRelation::TYPES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع رابطه نامعتبر است', 422, 'type');
}
$related = $this->requireItem($user, $row['related_item_uuid']);
if ($related->getId() === $item->getId()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آیتم نمی‌تواند با خودش رابطه داشته باشد', 422, 'related_item_uuid');
}
if ($row['type'] === ServiceItemRelation::TYPE_REQUIRES && $this->wouldCycle($item, $related)) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('«%s» به‌طور غیرمستقیم پیش‌نیاز «%s» است؛ حلقهٔ پیش‌نیاز مجاز نیست', $related->getName(), $item->getName()),
422,
'related_item_uuid',
);
}
$resolved[] = [$related, $row['type']];
}
$this->relations->deleteForItem($item);
foreach ($resolved as [$related, $type]) {
$this->em->persist(new ServiceItemRelation($item, $related, $type));
}
$this->em->flush();
return $this->success(array_map(
static fn (ServiceItemRelation $r): array => $r->toArray(),
$this->relations->findForItem($item),
));
}
/**
* آیا «`$related` پیش‌نیاز `$item`» حلقه می‌سازد؟ یعنی آیا `$item` از راه زنجیرهٔ
* پیش‌نیازها به `$related` می‌رسد. بدون این بررسی، «الف نیازمند ب» و «ب نیازمند
* الف» هر دو ذخیره می‌شدند و اعتبارسنجی انتخاب هرگز راضی نمی‌شد.
*/
private function wouldCycle(ServiceItem $item, ServiceItem $related): bool
{
$stack = [$related];
$visited = [];
while ($stack !== []) {
$current = array_pop($stack);
$id = (int) $current->getId();
if (isset($visited[$id])) {
continue;
}
$visited[$id] = true;
if ($id === (int) $item->getId()) {
return true;
}
foreach ($this->relations->findForItem($current) as $relation) {
if ($relation->getType() === ServiceItemRelation::TYPE_REQUIRES) {
$stack[] = $relation->getRelatedItem();
}
}
}
return false;
}
// ── قیمت و مدت اختصاصی شعبه ─────────────────────────────────────────────
#[Route('/api/v1/service-item/{uuid}/branch-overrides', name: 'service_item_overrides_replace', methods: ['PUT'])]
public function replaceBranchOverrides(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['overrides'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد overrides الزامی است', 422, 'overrides');
}
$item = $this->requireItem($user, $uuid);
$resolved = [];
// مثل بقیهٔ PUT های این پروژه: همه‌چیز پیش از هر حذفی حل و اعتبارسنجی می‌شود.
foreach ($data['overrides'] as $row) {
if (!is_array($row) || !is_string($row['address_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
}
foreach (['price_rials', 'solo_duration_minutes', 'additional_duration_minutes'] as $field) {
$value = $row[$field] ?? null;
if ($value !== null && (!is_numeric($value) || (int) $value < 0)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf('%s نامعتبر است', $field), 422, $field);
}
}
$resolved[] = [$this->branches->resolve($user, $row['address_uuid']), $row];
}
$this->overrides->deleteForItem($item);
foreach ($resolved as [$address, $row]) {
$override = new ServiceBranchOverride($item, $address);
// `isset()` خودش null را رد می‌کند، پس مقایسهٔ اضافه لازم نیست.
// `null` یعنی «همان مقدار خودِ سرویس» — صفر نیست.
$override->setPriceRials(isset($row['price_rials']) ? (int) $row['price_rials'] : null);
$override->setSoloDurationMinutes(isset($row['solo_duration_minutes']) ? (int) $row['solo_duration_minutes'] : null);
$override->setAdditionalDurationMinutes(isset($row['additional_duration_minutes']) ? (int) $row['additional_duration_minutes'] : null);
$this->em->persist($override);
}
$this->em->flush();
return $this->success(array_map(
static fn (ServiceBranchOverride $o): array => $o->toArray(),
$this->overrides->findForItem($item),
));
}
// ── اعتبارسنجی انتخاب ───────────────────────────────────────────────────
/**
* مهم‌ترین اندپوینت این تسک: سایت عمومی و پنل هر دو پیش از رفتن به مرحلهٔ انتخاب
* زمان آن را صدا می‌زنند.
*/
#[Route('/api/v1/service-selection/validate', name: 'service_selection_validate', methods: ['POST'])]
public function validateSelection(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['item_uuids'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد item_uuids الزامی است', 422, 'item_uuids');
}
$selected = [];
foreach ($data['item_uuids'] as $itemUuid) {
if (!is_string($itemUuid)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'item_uuids باید فهرستی از uuid باشد', 422, 'item_uuids');
}
// آیتم محیط دیگر ۴۰۴ می‌دهد نه ۴۲۲: وجودش نباید لو برود.
$selected[] = $this->requireItem($user, $itemUuid);
}
$groups = is_string($data['service_uuid'] ?? null)
? $this->groups->findForService($this->requireItem($user, $data['service_uuid']))
: $this->validator->groupsOf($selected);
$address = is_string($data['branch_uuid'] ?? null)
? $this->branches->resolve($user, $data['branch_uuid'])
: null;
return $this->success($this->validator->validate($selected, $groups, $address));
}
// ── حل uuid با بررسی محیط ───────────────────────────────────────────────
private function requireCategory(User $user, string $uuid): CatalogCategory
{
return $this->owned($user, $this->categories->findByUuid($uuid), 'دسته یافت نشد');
}
private function requireGroup(User $user, string $uuid): ItemGroup
{
return $this->owned($user, $this->groups->findByUuid($uuid), 'گروه یافت نشد');
}
private function requireItem(User $user, string $uuid): ServiceItem
{
$item = $this->items->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
// ServiceItem جفت محیط خودش را ندارد؛ از بخشش می‌آید.
if ($item === null
|| $item->getSection()->getEntityType() !== $entityType
|| $item->getSection()->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
}
return $item;
}
/**
* @template T of object
* @param T|null $entity
* @return T
*/
private function owned(User $user, ?object $entity, string $message): object
{
[$entityType, $entityId] = $this->branches->pair($user);
if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404);
}
return $entity;
}
/** @param array<string, mixed>|null $data */
private function applyGroupRange(ItemGroup $group, ?array $data): void
{
if (!is_array($data)) {
return;
}
$min = array_key_exists('min_select', $data) ? $data['min_select'] : $group->getMinSelect();
$max = array_key_exists('max_select', $data) ? $data['max_select'] : $group->getMaxSelect();
try {
$group->setSelectRange(
is_numeric($min) ? (int) $min : 0,
$max === null ? null : (is_numeric($max) ? (int) $max : null),
);
} catch (\InvalidArgumentException $e) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'بازهٔ انتخاب نامعتبر است', 422, 'max_select');
}
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\CatalogCategoryRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* دسته‌بندی درختی کاتالوگ خدمات — «لیزر ← نواحی بدن»، «تزریقات ← ژل».
*
* نامش عمداً `ServiceCategory` نیست: آن نام از قبل گرفته شده و یک **enum بیمه‌ای**
* است ({@see \App\Insurance\Enum\ServiceCategory} با مقادیر outpatient/inpatient) که
* روی خودِ ServiceItem هم نشسته. هم‌نام کردنشان یعنی دو `use` متضاد در یک فایل.
*
* با {@see ServiceSection} هم فرق دارد: آن «بخش کلینیک» است (واحد سازمانی)، این
* تاکسونومی کاتالوگ.
*/
#[ORM\Entity(repositoryClass: CatalogCategoryRepository::class)]
#[ORM\Table(name: 'service_catalog_categories')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_catalog_cat_tenant')]
#[ORM\Index(columns: ['parent_id'], name: 'idx_catalog_cat_parent')]
class CatalogCategory
{
use TenantOwnedTrait;
/** سقف عمق درخت — بدون آن یک اشتباه در UI می‌تواند زنجیرهٔ بی‌پایان بسازد. */
public const MAX_DEPTH = 4;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?self $parent = null;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'sort_order', type: 'smallint', options: ['default' => 0])]
private int $sortOrder = 0;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, string $name, ?self $parent = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->parent = $parent;
$this->createdAt = time();
$this->updatedAt = time();
$this->assignTenantPair($entityType, $entityId);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getParent(): ?self { return $this->parent; }
public function getName(): string { return $this->name; }
public function getSortOrder(): int { return $this->sortOrder; }
public function isActive(): bool { return $this->active; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setSortOrder(int $v): self { $this->sortOrder = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function setParent(?self $v): self { $this->parent = $v; $this->touch(); return $this; }
/** عمق از ریشه؛ ریشه صفر است. */
public function depth(): int
{
$depth = 0;
$node = $this->parent;
while ($node !== null && $depth <= self::MAX_DEPTH + 1) {
$depth++;
$node = $node->getParent();
}
return $depth;
}
private function touch(): void { $this->updatedAt = time(); }
public function toArray(array $children = []): array
{
return [
'uuid' => $this->uuid,
'parent_uuid' => $this->parent?->getUuid(),
'name' => $this->name,
'sort_order' => $this->sortOrder,
'active' => $this->active,
'children' => $children,
];
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ItemGroupRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* گروه انتخابِ آیتم درون یک سرویس — «نواحی بدن»، «سطح انرژی».
*
* بند ۵ مستند: این قوانین **نباید** به موتور قوانین سپرده شوند. «حتماً یک سطح انرژی،
* فقط یکی» یک عدد است (`min=1, max=1`)، نه یک قانون؛ سپردنش به موتور یعنی هر سرویس
* چند قانون و هیچ‌کس نمی‌فهمد چرا انتخابش رد شد.
*/
#[ORM\Entity(repositoryClass: ItemGroupRepository::class)]
#[ORM\Table(name: 'service_item_groups')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_item_group_tenant')]
#[ORM\Index(columns: ['service_id', 'sort_order'], name: 'idx_item_group_service')]
class ItemGroup
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/** سرویسی که این گروه متعلق به آن است. */
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $service;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'min_select', type: 'smallint', options: ['default' => 0])]
private int $minSelect = 0;
/** `null` یعنی نامحدود. */
#[ORM\Column(name: 'max_select', type: 'smallint', nullable: true)]
private ?int $maxSelect = null;
#[ORM\Column(name: 'sort_order', type: 'smallint', options: ['default' => 0])]
private int $sortOrder = 0;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
/** @var Collection<int, ItemGroupMember> */
#[ORM\OneToMany(targetEntity: ItemGroupMember::class, mappedBy: 'group', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['sortOrder' => 'ASC'])]
private Collection $members;
public function __construct(ServiceItem $service, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->service = $service;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$this->members = new ArrayCollection();
$this->assignTenantPair($service->getSection()->getEntityType(), $service->getSection()->getEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getService(): ServiceItem { return $this->service; }
public function getName(): string { return $this->name; }
public function getMinSelect(): int { return $this->minSelect; }
public function getMaxSelect(): ?int { return $this->maxSelect; }
public function getSortOrder(): int { return $this->sortOrder; }
/** @return Collection<int, ItemGroupMember> */
public function getMembers(): Collection { return $this->members; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setSortOrder(int $v): self { $this->sortOrder = $v; $this->touch(); return $this; }
/** @throws \InvalidArgumentException روی بازهٔ ناممکن */
public function setSelectRange(int $min, ?int $max): self
{
if ($min < 0) {
throw new \InvalidArgumentException('min_select cannot be negative.');
}
if ($max !== null && $max < 1) {
throw new \InvalidArgumentException('max_select must be at least 1 when set.');
}
if ($max !== null && $max < $min) {
throw new \InvalidArgumentException('max_select cannot be smaller than min_select.');
}
$this->minSelect = $min;
$this->maxSelect = $max;
$this->touch();
return $this;
}
public function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'service_uuid' => $this->service->getUuid(),
'name' => $this->name,
'min_select' => $this->minSelect,
'max_select' => $this->maxSelect,
'sort_order' => $this->sortOrder,
'items' => array_map(
static fn (ItemGroupMember $m): array => $m->toArray(),
$this->members->toArray(),
),
];
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ItemGroupMemberRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* عضویت یک آیتم در یک گروه انتخاب. فرزند aggregate با ریشهٔ {@see ItemGroup} که خودش
* جفت محیط دارد؛ uuid ندارد و فقط از `PUT /item-group/{uuid}/items` نوشته می‌شود.
*/
#[ORM\Entity(repositoryClass: ItemGroupMemberRepository::class)]
#[ORM\Table(name: 'service_item_group_members')]
#[ORM\UniqueConstraint(name: 'uniq_group_item', columns: ['group_id', 'item_id'])]
#[ORM\Index(columns: ['item_id'], name: 'idx_group_member_item')]
class ItemGroupMember
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ItemGroup::class, inversedBy: 'members')]
#[ORM\JoinColumn(name: 'group_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ItemGroup $group;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $item;
#[ORM\Column(name: 'sort_order', type: 'smallint', options: ['default' => 0])]
private int $sortOrder = 0;
public function __construct(ItemGroup $group, ServiceItem $item, int $sortOrder = 0)
{
$this->group = $group;
$this->item = $item;
$this->sortOrder = $sortOrder;
}
public function getId(): ?int { return $this->id; }
public function getGroup(): ItemGroup { return $this->group; }
public function getItem(): ServiceItem { return $this->item; }
public function getSortOrder(): int { return $this->sortOrder; }
public function toArray(): array
{
return [
'item_uuid' => $this->item->getUuid(),
'item_name' => $this->item->getName(),
'solo_duration_minutes' => $this->item->getSoloDurationMinutes(),
'additional_duration_minutes' => $this->item->effectiveAdditionalMinutes(),
'price_rials' => $this->item->getPriceRials(),
'sort_order' => $this->sortOrder,
'active' => $this->item->isActive(),
];
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* قیمت و مدتِ اختصاصیِ یک سرویس در یک شعبه.
*
* «شعبه» همان `doctor_addresses` است ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}).
* هر ستون تهی‌پذیر است و `null` یعنی «همان مقدار خودِ سرویس» — نه صفر.
*/
#[ORM\Entity(repositoryClass: ServiceBranchOverrideRepository::class)]
#[ORM\Table(name: 'service_branch_overrides')]
#[ORM\UniqueConstraint(name: 'uniq_override_item_address', columns: ['item_id', 'address_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_override_tenant')]
class ServiceBranchOverride
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $item;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private DoctorAddress $address;
#[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)]
private ?int $priceRials = null;
#[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)]
private ?int $soloDurationMinutes = null;
#[ORM\Column(name: 'additional_duration_minutes', type: 'smallint', nullable: true)]
private ?int $additionalDurationMinutes = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(ServiceItem $item, DoctorAddress $address)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->item = $item;
$this->address = $address;
$this->createdAt = time();
$this->updatedAt = time();
$this->assignTenantPair($item->getSection()->getEntityType(), $item->getSection()->getEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getItem(): ServiceItem { return $this->item; }
public function getAddress(): DoctorAddress { return $this->address; }
public function getPriceRials(): ?int { return $this->priceRials === null ? null : (int) $this->priceRials; }
public function getSoloDurationMinutes(): ?int { return $this->soloDurationMinutes; }
public function getAdditionalDurationMinutes(): ?int { return $this->additionalDurationMinutes; }
public function setPriceRials(?int $v): self { $this->priceRials = $v; $this->touch(); return $this; }
public function setSoloDurationMinutes(?int $v): self { $this->soloDurationMinutes = $v; $this->touch(); return $this; }
public function setAdditionalDurationMinutes(?int $v): self { $this->additionalDurationMinutes = $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'item_uuid' => $this->item->getUuid(),
'address_uuid' => $this->address->getUuid(),
'address_name' => $this->address->getName(),
'price_rials' => $this->getPriceRials(),
'solo_duration_minutes' => $this->soloDurationMinutes,
'additional_duration_minutes' => $this->additionalDurationMinutes,
];
}
}
+92 -1
View File
@@ -68,9 +68,36 @@ class ServiceItem
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
private ?int $insurancePriceRials = null;
/**
* @deprecated مقدار قدیمی؛ منبع حقیقتِ مدت اکنون `soloDurationMinutes` است.
* هنوز نوشته می‌شود تا مصرف‌کننده‌های موجود نشکنند.
*/
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
private ?int $durationMinutes = null;
/** مدت این آیتم وقتی **تنها** انجام شود. */
#[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)]
private ?int $soloDurationMinutes = null;
/**
* مدت این آیتم وقتی **کنار آیتم دیگری** در همان نوبت انجام شود.
*
* بند ۵ مستند: جمع سادهٔ مدت‌ها ظرفیت را هدر می‌دهد. «صورت + بیکینی» ۱۵+۱۲=۲۷
* نیست، ۱۵+۸=۲۳ است؛ آماده‌سازی و استقرار بیمار دو بار انجام نمی‌شود.
*
* `null` یعنی «همان مدت تنها» — سازگاری با دادهٔ موجودی که فقط یک عدد داشت.
*/
#[ORM\Column(name: 'additional_duration_minutes', type: 'smallint', nullable: true)]
private ?int $additionalDurationMinutes = null;
/** تعداد جلسات؛ ۱ یعنی تک‌جلسه‌ای. پروتکل کامل دوره در تسک ۱۲. */
#[ORM\Column(name: 'session_count', type: 'smallint', options: ['default' => 1])]
private int $sessionCount = 1;
#[ORM\ManyToOne(targetEntity: CatalogCategory::class)]
#[ORM\JoinColumn(name: 'catalog_category_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?CatalogCategory $catalogCategory = null;
/** نمایش این سرویس در نوبت‌دهی (پزشک ممکن است همهٔ سرویس‌ها را ارائه ندهد). */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $bookable = false;
@@ -203,7 +230,67 @@ class ServiceItem
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
public function setServiceCategory(ServiceCategory $v): self { $this->serviceCategory = $v; $this->updatedAt = time(); return $this; }
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
/** هر دو ستون را هم‌زمان می‌نویسد تا «مدت» یک منبع حقیقت داشته باشد. */
public function setDurationMinutes(?int $v): self
{
$this->durationMinutes = $v;
$this->soloDurationMinutes = $v;
$this->updatedAt = time();
return $this;
}
public function getSoloDurationMinutes(): ?int { return $this->soloDurationMinutes ?? $this->durationMinutes; }
public function getAdditionalDurationMinutes(): ?int { return $this->additionalDurationMinutes; }
public function getSessionCount(): int { return $this->sessionCount; }
public function getCatalogCategory(): ?CatalogCategory { return $this->catalogCategory; }
/**
* مدتِ «کنار بقیه». آیتمی که مقدارش را نگذاشته، همان مدت تنها را می‌گیرد — پس
* دادهٔ موجود دقیقاً مثل قبل حساب می‌شود و این تغییر افزایشی است.
*/
public function effectiveAdditionalMinutes(): ?int
{
return $this->additionalDurationMinutes ?? $this->getSoloDurationMinutes();
}
public function setSoloDurationMinutes(?int $v): self
{
$this->soloDurationMinutes = $v;
$this->durationMinutes = $v; // ستون قدیمی هم‌گام می‌ماند
$this->updatedAt = time();
return $this;
}
public function setAdditionalDurationMinutes(?int $v): self
{
$this->additionalDurationMinutes = $v;
$this->updatedAt = time();
return $this;
}
/** @throws \InvalidArgumentException روی تعداد جلسهٔ کمتر از ۱ */
public function setSessionCount(int $v): self
{
if ($v < 1) {
throw new \InvalidArgumentException('session_count must be at least 1.');
}
$this->sessionCount = $v;
$this->updatedAt = time();
return $this;
}
public function setCatalogCategory(?CatalogCategory $v): self
{
$this->catalogCategory = $v;
$this->updatedAt = time();
return $this;
}
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
@@ -237,6 +324,10 @@ class ServiceItem
'service_category' => $this->getServiceCategory()->value,
'service_category_label' => $this->getServiceCategory()->label(),
'duration_minutes' => $this->durationMinutes,
'solo_duration_minutes' => $this->getSoloDurationMinutes(),
'additional_duration_minutes' => $this->effectiveAdditionalMinutes(),
'session_count' => $this->sessionCount,
'catalog_category_uuid' => $this->catalogCategory?->getUuid(),
'bookable' => $this->bookable,
'inventory_package_id' => $this->inventoryPackageId,
'consumables' => array_map(
@@ -0,0 +1,87 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceItemRelationRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* رابطهٔ دو آیتم: «بیکینی با فول‌بادی جمع نمی‌شود» یا «الف پیش‌نیاز ب است».
*
* بند ۵ مستند این‌ها را از موتور قوانین جدا می‌کند: هر دو دربارهٔ **انتخاب** آیتم‌اند،
* نه دربارهٔ شرایط بیمار یا زمان، و باید پیش از هر محاسبه‌ای بررسی شوند.
*/
#[ORM\Entity(repositoryClass: ServiceItemRelationRepository::class)]
#[ORM\Table(name: 'service_item_relations')]
#[ORM\UniqueConstraint(name: 'uniq_relation_triple', columns: ['item_id', 'related_item_id', 'type'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_relation_tenant')]
#[ORM\Index(columns: ['item_id', 'type'], name: 'idx_relation_item_type')]
class ServiceItemRelation
{
use TenantOwnedTrait;
public const TYPE_INCOMPATIBLE = 'incompatible_with';
public const TYPE_REQUIRES = 'requires';
public const TYPES = [self::TYPE_INCOMPATIBLE, self::TYPE_REQUIRES];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $item;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'related_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $relatedItem;
#[ORM\Column(type: 'string', length: 20)]
private string $type;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(ServiceItem $item, ServiceItem $relatedItem, string $type)
{
if (!in_array($type, self::TYPES, true)) {
throw new \InvalidArgumentException(sprintf('Unknown relation type "%s".', $type));
}
if ($item === $relatedItem) {
throw new \InvalidArgumentException('An item cannot relate to itself.');
}
$this->uuid = Uuid::v4()->toRfc4122();
$this->item = $item;
$this->relatedItem = $relatedItem;
$this->type = $type;
$this->createdAt = time();
$this->assignTenantPair($item->getSection()->getEntityType(), $item->getSection()->getEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getItem(): ServiceItem { return $this->item; }
public function getRelatedItem(): ServiceItem { return $this->relatedItem; }
public function getType(): string { return $this->type; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'item_uuid' => $this->item->getUuid(),
'related_item_uuid' => $this->relatedItem->getUuid(),
'related_item_name' => $this->relatedItem->getName(),
'type' => $this->type,
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\CatalogCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CatalogCategory>
*/
class CatalogCategoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CatalogCategory::class);
}
public function findByUuid(string $uuid): ?CatalogCategory
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* کل درخت یک محیط با **یک** کوئری؛ ساختار درختی در PHP بسته می‌شود، نه با یک
* کوئری per گره.
*
* @return CatalogCategory[]
*/
public function findForPair(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('c.sortOrder', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult();
}
public function countChildren(CatalogCategory $category): int
{
return (int) $this->createQueryBuilder('c')
->select('COUNT(c.id)')
->where('c.parent = :parent')
->setParameter('parent', $category)
->getQuery()
->getSingleScalarResult();
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ItemGroupMember>
*/
class ItemGroupMemberRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ItemGroupMember::class);
}
public function deleteForGroup(ItemGroup $group): int
{
return (int) $this->createQueryBuilder('m')
->delete()
->where('m.group = :group')
->setParameter('group', $group)
->getQuery()
->execute();
}
/**
* گروه‌هایی که این آیتم‌ها در آن‌ها عضوند — برای اعتبارسنجی انتخاب، با یک کوئری.
*
* @param int[] $itemIds
* @return ItemGroupMember[]
*/
public function findByItemIds(array $itemIds): array
{
if ($itemIds === []) {
return [];
}
return $this->createQueryBuilder('m')
->addSelect('g')
->join('m.group', 'g')
->where('m.item IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult();
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ServiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ItemGroup>
*/
class ItemGroupRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ItemGroup::class);
}
public function findByUuid(string $uuid): ?ItemGroup
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return ItemGroup[] */
public function findForService(ServiceItem $service): array
{
return $this->createQueryBuilder('g')
->where('g.service = :service')
->setParameter('service', $service)
->orderBy('g.sortOrder', 'ASC')
->addOrderBy('g.name', 'ASC')
->getQuery()
->getResult();
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ServiceBranchOverride>
*/
class ServiceBranchOverrideRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServiceBranchOverride::class);
}
/** @return ServiceBranchOverride[] */
public function findForItem(ServiceItem $item): array
{
return $this->createQueryBuilder('o')
->where('o.item = :item')
->setParameter('item', $item)
->getQuery()
->getResult();
}
/**
* override های یک شعبه برای مجموعه‌ای از آیتم‌ها — یک کوئری، نه یکی per آیتم.
*
* @param int[] $itemIds
* @return array<int, ServiceBranchOverride> کلید = شناسهٔ آیتم
*/
public function mapForAddress(array $itemIds, DoctorAddress $address): array
{
if ($itemIds === []) {
return [];
}
$rows = $this->createQueryBuilder('o')
->where('o.item IN (:ids)')
->andWhere('o.address = :address')
->setParameter('ids', $itemIds)
->setParameter('address', $address)
->getQuery()
->getResult();
$map = [];
foreach ($rows as $override) {
$map[(int) $override->getItem()->getId()] = $override;
}
return $map;
}
public function deleteForItem(ServiceItem $item): int
{
return (int) $this->createQueryBuilder('o')
->delete()
->where('o.item = :item')
->setParameter('item', $item)
->getQuery()
->execute();
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ServiceItemRelation>
*/
class ServiceItemRelationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServiceItemRelation::class);
}
/** @return ServiceItemRelation[] */
public function findForItem(ServiceItem $item): array
{
return $this->createQueryBuilder('r')
->where('r.item = :item')
->setParameter('item', $item)
->getQuery()
->getResult();
}
/**
* همهٔ روابطی که یک سرِ آن‌ها در این مجموعه است — یک کوئری برای کل اعتبارسنجی.
*
* @param int[] $itemIds
* @return ServiceItemRelation[]
*/
public function findTouching(array $itemIds): array
{
if ($itemIds === []) {
return [];
}
return $this->createQueryBuilder('r')
->where('r.item IN (:ids) OR r.relatedItem IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult();
}
public function deleteForItem(ServiceItem $item): int
{
return (int) $this->createQueryBuilder('r')
->delete()
->where('r.item = :item')
->setParameter('item', $item)
->getQuery()
->execute();
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\ClinicService\Service;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
/**
* مدت واقعیِ یک مجموعه آیتم.
*
* بند ۵ مستند جمع سادهٔ مدت‌ها را رد می‌کند: «صورت + بیکینی» ۱۵+۱۲=۲۷ نیست، ۱۵+۸=۲۳
* است — آماده‌سازی و استقرار بیمار دو بار انجام نمی‌شود. هفت دقیقهٔ هدررفته ضرب در
* روزی ۲۰ نوبت یعنی یک ساعت ظرفیت در روز.
*
* ## چرا «بزرگ‌ترین مدتِ تنها» لنگر است
*
* فرمول: یک آیتم با **مدت تنها** حساب می‌شود و بقیه با **مدت اضافه**. کدام آیتم؟
* آنکه بزرگ‌ترین مدت تنها را دارد. دو دلیل:
*
* ۱. مستقل از ترتیب انتخاب کاربر است. اگر «اولین انتخاب‌شده» لنگر می‌شد، همان سبد
* با ترتیب دیگر مدت دیگری می‌گرفت و بیمار با عوض کردن ترتیب کلیک، وقت کوتاه‌تر
* می‌خرید.
* ۲. محافظه‌کارانه است: هیچ ترکیبی کمتر از واقعیت تخمین زده نمی‌شود، و کم‌تخمینی
* یعنی نوبت بعدی روی این یکی می‌افتد.
*/
final class DurationCalculator
{
/**
* @param ServiceItem[] $items
* @param array<int, ServiceBranchOverride> $overrides کلید = شناسهٔ آیتم
* @param array<int, int> $explicit مدتِ دستیِ کاربر per آیتم — بر همه‌چیز مقدم است
* و هم «تنها» و هم «اضافه» را جایگزین می‌کند،
* چون کاربری که مدت را دستی نوشته منظورش همان
* عدد است، نه پایه‌ای برای فرمول
*/
public function totalMinutes(array $items, array $overrides = [], array $explicit = []): int
{
$solos = [];
$additionals = [];
foreach ($items as $item) {
$override = $overrides[(int) $item->getId()] ?? null;
$manual = $explicit[(int) $item->getId()] ?? null;
if ($manual !== null) {
$solos[] = $manual;
$additionals[] = $manual;
continue;
}
$solo = $override?->getSoloDurationMinutes() ?? $item->getSoloDurationMinutes() ?? 0;
// مدت اضافه اگر تعریف نشده باشد، همان مدت تنهاست — پس دادهٔ موجودی که
// فقط یک عدد داشت، دقیقاً مثل قبل (جمع ساده) حساب می‌شود.
$additional = $override?->getAdditionalDurationMinutes()
?? $item->effectiveAdditionalMinutes()
?? $solo;
$solos[] = $solo;
$additionals[] = $additional;
}
if ($solos === []) {
return 0;
}
$anchorIndex = array_keys($solos, max($solos), true)[0];
$total = $solos[$anchorIndex];
foreach ($additionals as $index => $additional) {
if ($index !== $anchorIndex) {
$total += $additional;
}
}
return $total;
}
/**
* @param ServiceItem[] $items
* @param array<int, ServiceBranchOverride> $overrides
*/
public function totalPriceRials(array $items, array $overrides = []): int
{
$total = 0;
foreach ($items as $item) {
$override = $overrides[(int) $item->getId()] ?? null;
$total += $override?->getPriceRials() ?? $item->getPriceRials();
}
return $total;
}
/**
* تفکیک per آیتم، برای اینکه UI بتواند نشان دهد چرا جمع با انتظار فرق دارد.
*
* @param ServiceItem[] $items
* @param array<int, ServiceBranchOverride> $overrides
* @return list<array<string, mixed>>
*/
public function breakdown(array $items, array $overrides = []): array
{
if ($items === []) {
return [];
}
// `$overrides[$id]?->` روی کلیدِ نبوده اخطار «Undefined array key» می‌دهد؛
// `?? null` لازم است، نه فقط عملگر ایمنِ nullsafe.
$solos = array_map(
fn (ServiceItem $i): int => ($overrides[(int) $i->getId()] ?? null)?->getSoloDurationMinutes()
?? $i->getSoloDurationMinutes()
?? 0,
$items,
);
$anchorIndex = array_keys($solos, max($solos), true)[0];
$rows = [];
foreach ($items as $index => $item) {
$override = $overrides[(int) $item->getId()] ?? null;
$isAnchor = $index === $anchorIndex;
$rows[] = [
'item_uuid' => $item->getUuid(),
'item_name' => $item->getName(),
'counted_as' => $isAnchor ? 'solo' : 'additional',
'minutes' => $isAnchor
? $solos[$index]
: ($override?->getAdditionalDurationMinutes() ?? $item->effectiveAdditionalMinutes() ?? $solos[$index]),
'price_rials' => $override?->getPriceRials() ?? $item->getPriceRials(),
];
}
return $rows;
}
}
@@ -0,0 +1,195 @@
<?php
namespace App\ClinicService\Service;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use App\ClinicService\Repository\ItemGroupMemberRepository;
use App\ClinicService\Repository\ServiceItemRelationRepository;
use App\Doctor\Entity\DoctorAddress;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
/**
* اعتبارسنجی انتخاب کاربر — پیش از هر محاسبهٔ زمان یا قیمت.
*
* بند ۵ مستند این قوانین را عمداً از موتور قوانین بیرون می‌گذارد: «حداقل یک ناحیه»
* یک عدد است نه یک قانون، و «بیکینی با فول‌بادی جمع نمی‌شود» یک رابطه است نه یک شرط.
* سپردنشان به موتور یعنی هر سرویس چند قانون و هیچ‌کس نمی‌فهمد چرا انتخابش رد شد.
*
* خروجی **همهٔ** خطاها را می‌دهد نه اولی: کاربری که سه مشکل دارد نباید سه بار
* رفت‌وبرگشت کند.
*/
final class ServiceSelectionValidator
{
public function __construct(
private readonly ItemGroupMemberRepository $groupMembers,
private readonly ServiceItemRelationRepository $relations,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly DurationCalculator $durations,
) {}
/**
* @param ServiceItem[] $selected آیتم‌های انتخاب‌شده، همه از محیط جاری
* @param ItemGroup[] $groups گروه‌های سرویسِ هدف
* @return array<string, mixed>
*/
public function validate(array $selected, array $groups, ?DoctorAddress $address = null): array
{
$errors = [
...$this->groupErrors($selected, $groups),
...$this->relationErrors($selected),
];
$overrides = $address === null
? []
: $this->overrides->mapForAddress(
array_map(static fn (ServiceItem $i): int => (int) $i->getId(), $selected),
$address,
);
return [
'valid' => $errors === [],
'errors' => $errors,
'total_duration_minutes' => $this->durations->totalMinutes($selected, $overrides),
'total_price_rials' => $this->durations->totalPriceRials($selected, $overrides),
'breakdown' => $this->durations->breakdown($selected, $overrides),
];
}
/**
* @param ServiceItem[] $selected
* @param ItemGroup[] $groups
* @return list<array<string, mixed>>
*/
private function groupErrors(array $selected, array $groups): array
{
$selectedIds = array_map(static fn (ServiceItem $i): int => (int) $i->getId(), $selected);
$errors = [];
foreach ($groups as $group) {
$memberIds = array_map(
static fn (ItemGroupMember $m): int => (int) $m->getItem()->getId(),
$group->getMembers()->toArray(),
);
$chosen = count(array_intersect($selectedIds, $memberIds));
if ($chosen < $group->getMinSelect()) {
$errors[] = [
'group_uuid' => $group->getUuid(),
'code' => 'min_select',
'message' => sprintf(
'انتخاب حداقل %d مورد از «%s» الزامی است',
$group->getMinSelect(),
$group->getName(),
),
];
}
// `max_select = null` یعنی نامحدود — نه صفر.
if ($group->getMaxSelect() !== null && $chosen > $group->getMaxSelect()) {
$errors[] = [
'group_uuid' => $group->getUuid(),
'code' => 'max_select',
'message' => sprintf(
'حداکثر %d مورد از «%s» قابل انتخاب است',
$group->getMaxSelect(),
$group->getName(),
),
];
}
}
return $errors;
}
/**
* @param ServiceItem[] $selected
* @return list<array<string, mixed>>
*/
private function relationErrors(array $selected): array
{
$byId = [];
foreach ($selected as $item) {
$byId[(int) $item->getId()] = $item;
}
if ($byId === []) {
return [];
}
$errors = [];
$seen = [];
foreach ($this->relations->findTouching(array_keys($byId)) as $relation) {
$itemId = (int) $relation->getItem()->getId();
$relatedId = (int) $relation->getRelatedItem()->getId();
if ($relation->getType() === ServiceItemRelation::TYPE_INCOMPATIBLE) {
// ناسازگاری متقارن است؛ فقط وقتی خطاست که **هر دو** انتخاب شده باشند.
if (!isset($byId[$itemId], $byId[$relatedId])) {
continue;
}
// یک جفت، یک خطا — نه دو تا برای دو جهت رابطه.
$key = $itemId < $relatedId ? "$itemId:$relatedId" : "$relatedId:$itemId";
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$errors[] = [
'code' => 'incompatible',
'items' => [$relation->getItem()->getUuid(), $relation->getRelatedItem()->getUuid()],
'message' => sprintf(
'«%s» و «%s» در یک نوبت قابل انجام نیستند',
$relation->getItem()->getName(),
$relation->getRelatedItem()->getName(),
),
];
continue;
}
// پیش‌نیاز جهت‌دار است: فقط وقتی معنا دارد که خودِ آیتم انتخاب شده باشد.
if (!isset($byId[$itemId]) || isset($byId[$relatedId])) {
continue;
}
$errors[] = [
'code' => 'missing_prerequisite',
'items' => [$relation->getItem()->getUuid(), $relation->getRelatedItem()->getUuid()],
'message' => sprintf(
'برای «%s» ابتدا باید «%s» انتخاب شود',
$relation->getItem()->getName(),
$relation->getRelatedItem()->getName(),
),
];
}
return $errors;
}
/**
* گروه‌هایی که این آیتم‌ها در آن‌ها عضوند — وقتی کلاینت سرویسِ هدف را نفرستاده.
*
* @param ServiceItem[] $selected
* @return ItemGroup[]
*/
public function groupsOf(array $selected): array
{
$members = $this->groupMembers->findByItemIds(
array_map(static fn (ServiceItem $i): int => (int) $i->getId(), $selected),
);
$groups = [];
foreach ($members as $member) {
$groups[(int) $member->getGroup()->getId()] = $member->getGroup();
}
return array_values($groups);
}
}
+1
View File
@@ -104,6 +104,7 @@ final class GlobalTables
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
@@ -0,0 +1,122 @@
<?php
namespace App\Tests\ClinicService;
use App\ClinicService\Service\DurationCalculator;
use PHPUnit\Framework\TestCase;
/**
* فرمول مدت — واحد و بدون دیتابیس.
*
* بند ۵ مستند جمع ساده را رد می‌کند. این تست همان مثال مستند را قفل می‌کند و
* مهم‌تر: تضمین می‌کند نتیجه به **ترتیب انتخاب** وابسته نباشد.
*/
class DurationCalculatorTest extends TestCase
{
private DurationCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new DurationCalculator();
}
/** آیتم ساختگی بدون دیتابیس: فقط چیزی که فرمول می‌خواند. */
private function item(int $id, ?int $solo, ?int $additional, int $price = 0): object
{
return new class ($id, $solo, $additional, $price) extends \App\ClinicService\Entity\ServiceItem {
public function __construct(
private readonly int $fakeId,
private readonly ?int $solo,
private readonly ?int $additional,
private readonly int $price,
) {}
public function getId(): ?int { return $this->fakeId; }
public function getUuid(): string { return 'item-' . $this->fakeId; }
public function getName(): string { return 'آیتم ' . $this->fakeId; }
public function getPriceRials(): int { return $this->price; }
public function getSoloDurationMinutes(): ?int { return $this->solo; }
public function effectiveAdditionalMinutes(): ?int { return $this->additional ?? $this->solo; }
};
}
/** مثال خودِ مستند: صورت (۱۵/۸) + بیکینی (۱۲/۸) = ۲۳، نه ۲۷. */
public function testDocumentExample(): void
{
$total = $this->calculator->totalMinutes([
$this->item(1, 15, 8),
$this->item(2, 12, 8),
]);
self::assertSame(23, $total);
}
public function testASingleItemUsesItsSoloDuration(): void
{
self::assertSame(12, $this->calculator->totalMinutes([$this->item(2, 12, 8)]));
}
/**
* ترتیب انتخاب نباید مدت را عوض کند — وگرنه بیمار با جابه‌جا کردن کلیک‌ها وقت
* کوتاه‌تر می‌خرید.
*/
public function testResultIsIndependentOfSelectionOrder(): void
{
$a = $this->calculator->totalMinutes([$this->item(1, 15, 8), $this->item(2, 12, 8)]);
$b = $this->calculator->totalMinutes([$this->item(2, 12, 8), $this->item(1, 15, 8)]);
self::assertSame($a, $b);
self::assertSame(23, $b);
}
/** آیتم بدون «مدت اضافه» همان مدت تنها را می‌گیرد — رفتار دادهٔ موجود. */
public function testMissingAdditionalFallsBackToSoloAndBehavesLikeAPlainSum(): void
{
$total = $this->calculator->totalMinutes([
$this->item(1, 15, null),
$this->item(2, 12, null),
]);
self::assertSame(27, $total, 'دادهٔ قدیمی دقیقاً مثل قبل جمع ساده می‌شود');
}
public function testEmptySelectionIsZero(): void
{
self::assertSame(0, $this->calculator->totalMinutes([]));
}
/** مدت دستیِ کاربر بر فرمول مقدم است و پایه‌ای برای آن نمی‌شود. */
public function testExplicitOverrideWins(): void
{
$total = $this->calculator->totalMinutes(
[$this->item(1, 15, 8), $this->item(2, 12, 8)],
[],
[1 => 40],
);
self::assertSame(48, $total, '۴۰ دستی به‌عنوان لنگر + ۸ اضافهٔ دومی');
}
public function testBreakdownNamesTheAnchor(): void
{
$rows = $this->calculator->breakdown([
$this->item(2, 12, 8),
$this->item(1, 15, 8),
]);
$byName = array_column($rows, 'counted_as', 'item_uuid');
self::assertSame('solo', $byName['item-1'], 'بزرگ‌ترین مدت تنها لنگر است');
self::assertSame('additional', $byName['item-2']);
}
public function testPriceIsAPlainSum(): void
{
$total = $this->calculator->totalPriceRials([
$this->item(1, 15, 8, 500_000),
$this->item(2, 12, 8, 300_000),
]);
self::assertSame(800_000, $total, 'قیمت برخلاف مدت جمع ساده است');
}
}
@@ -0,0 +1,312 @@
<?php
namespace App\Tests\ClinicService;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* `POST /service-selection/validate` — مهم‌ترین اندپوینت کاتالوگ نسخهٔ ۲.
* سایت عمومی و پنل هر دو پیش از مرحلهٔ انتخاب زمان صدایش می‌زنند.
*/
class ServiceSelectionTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: ServiceSection, 3: DoctorAddress} */
private function clinicWithSection(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک کاتالوگ');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return [$user, $clinic, $section, $address];
}
private function item(ServiceSection $section, string $name, ?int $solo, ?int $additional, int $price = 0): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$item->setAdditionalDurationMinutes($additional);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param string[] $itemUuids */
private function validate(User $user, array $itemUuids, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/service-selection/validate', $user, $extra + [
'item_uuids' => $itemUuids,
]);
}
private function group(User $user, ServiceItem $service, string $name, int $min, ?int $max, array $items): string
{
$created = $this->authJson('POST', "/api/v1/service-item/{$service->getUuid()}/groups", $user, [
'name' => $name,
'min_select' => $min,
'max_select' => $max,
]);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
$uuid = $created['data']['uuid'];
$this->authJson('PUT', "/api/v1/item-group/$uuid/items", $user, [
'items' => array_map(static fn (ServiceItem $i): array => ['item_uuid' => $i->getUuid()], $items),
]);
self::assertSame(200, $this->responseCode());
return $uuid;
}
public function testDocumentExampleTwentyThreeMinutes(): void
{
[$user, , $section] = $this->clinicWithSection();
$face = $this->item($section, 'صورت', 15, 8);
$bikini = $this->item($section, 'بیکینی', 12, 8);
$body = $this->validate($user, [$face->getUuid(), $bikini->getUuid()]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertTrue($body['data']['valid']);
self::assertSame(23, $body['data']['total_duration_minutes'], 'نه ۲۷ — جمع ساده رد شده است');
}
public function testSingleItemUsesSoloDuration(): void
{
[$user, , $section] = $this->clinicWithSection();
$bikini = $this->item($section, 'بیکینی', 12, 8);
$body = $this->validate($user, [$bikini->getUuid()]);
self::assertSame(12, $body['data']['total_duration_minutes']);
}
public function testMinSelectIsEnforced(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$face = $this->item($section, 'صورت', 15, 8);
$groupUuid = $this->group($user, $service, 'نواحی', 1, 8, [$face]);
$body = $this->validate($user, [], ['service_uuid' => $service->getUuid()]);
self::assertFalse($body['data']['valid']);
self::assertSame('min_select', $body['data']['errors'][0]['code']);
self::assertSame($groupUuid, $body['data']['errors'][0]['group_uuid']);
self::assertStringContainsString('نواحی', $body['data']['errors'][0]['message']);
}
public function testMaxSelectIsEnforced(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$items = [];
foreach (range(1, 3) as $n) {
$items[] = $this->item($section, "ناحیه $n", 10, 5);
}
$this->group($user, $service, 'نواحی', 1, 2, $items);
$body = $this->validate(
$user,
array_map(static fn (ServiceItem $i): string => $i->getUuid(), $items),
['service_uuid' => $service->getUuid()],
);
self::assertFalse($body['data']['valid']);
self::assertSame('max_select', $body['data']['errors'][0]['code']);
}
/** `max_select = null` یعنی نامحدود، نه صفر. */
public function testNullMaxSelectMeansUnlimited(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$items = [];
foreach (range(1, 5) as $n) {
$items[] = $this->item($section, "ناحیه $n", 10, 5);
}
$this->group($user, $service, 'نواحی', 1, null, $items);
$body = $this->validate(
$user,
array_map(static fn (ServiceItem $i): string => $i->getUuid(), $items),
['service_uuid' => $service->getUuid()],
);
self::assertTrue($body['data']['valid']);
}
/** `min_select = 0` یعنی گروه اختیاری است. */
public function testZeroMinSelectMeansOptional(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$this->group($user, $service, 'افزودنی‌ها', 0, 3, [$this->item($section, 'ژل', 5, 5)]);
$body = $this->validate($user, [], ['service_uuid' => $service->getUuid()]);
self::assertTrue($body['data']['valid']);
}
public function testIncompatibleItemsAreRejectedOnceNotTwice(): void
{
[$user, , $section] = $this->clinicWithSection();
$bikini = $this->item($section, 'بیکینی', 12, 8);
$fullBody = $this->item($section, 'فول‌بادی', 60, 40);
$this->authJson('PUT', "/api/v1/service-item/{$bikini->getUuid()}/relations", $user, [
'relations' => [[
'related_item_uuid' => $fullBody->getUuid(),
'type' => ServiceItemRelation::TYPE_INCOMPATIBLE,
]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->validate($user, [$bikini->getUuid(), $fullBody->getUuid()]);
self::assertFalse($body['data']['valid']);
self::assertCount(1, $body['data']['errors'], 'یک جفت، یک خطا');
self::assertSame('incompatible', $body['data']['errors'][0]['code']);
self::assertStringContainsString('بیکینی', $body['data']['errors'][0]['message']);
self::assertStringContainsString('فول‌بادی', $body['data']['errors'][0]['message']);
}
/** ناسازگاری فقط وقتی خطاست که هر دو انتخاب شده باشند. */
public function testIncompatibilityIsSilentWhenOnlyOneIsSelected(): void
{
[$user, , $section] = $this->clinicWithSection();
$bikini = $this->item($section, 'بیکینی', 12, 8);
$fullBody = $this->item($section, 'فول‌بادی', 60, 40);
$this->authJson('PUT', "/api/v1/service-item/{$bikini->getUuid()}/relations", $user, [
'relations' => [[
'related_item_uuid' => $fullBody->getUuid(),
'type' => ServiceItemRelation::TYPE_INCOMPATIBLE,
]],
]);
$body = $this->validate($user, [$bikini->getUuid()]);
self::assertTrue($body['data']['valid']);
}
public function testMissingPrerequisiteIsReported(): void
{
[$user, , $section] = $this->clinicWithSection();
$peel = $this->item($section, 'پیلینگ', 20, 10);
$cleanse = $this->item($section, 'پاک‌سازی', 10, 5);
$this->authJson('PUT', "/api/v1/service-item/{$peel->getUuid()}/relations", $user, [
'relations' => [[
'related_item_uuid' => $cleanse->getUuid(),
'type' => ServiceItemRelation::TYPE_REQUIRES,
]],
]);
self::assertSame(200, $this->responseCode());
$missing = $this->validate($user, [$peel->getUuid()]);
self::assertFalse($missing['data']['valid']);
self::assertSame('missing_prerequisite', $missing['data']['errors'][0]['code']);
$satisfied = $this->validate($user, [$peel->getUuid(), $cleanse->getUuid()]);
self::assertTrue($satisfied['data']['valid']);
}
/** حلقهٔ پیش‌نیاز باید هنگام **ثبت** رد شود، نه در اعتبارسنجی انتخاب. */
public function testPrerequisiteCycleIsRejectedAtWriteTime(): void
{
[$user, , $section] = $this->clinicWithSection();
$a = $this->item($section, 'الف', 10, 5);
$b = $this->item($section, 'ب', 10, 5);
$this->authJson('PUT', "/api/v1/service-item/{$a->getUuid()}/relations", $user, [
'relations' => [['related_item_uuid' => $b->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->authJson('PUT', "/api/v1/service-item/{$b->getUuid()}/relations", $user, [
'relations' => [['related_item_uuid' => $a->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES]],
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('حلقه', $body['errors'][0]['message']);
}
public function testBranchOverrideChangesPriceAndDuration(): void
{
[$user, , $section, $address] = $this->clinicWithSection();
$face = $this->item($section, 'صورت', 15, 8, 500_000);
$this->authJson('PUT', "/api/v1/service-item/{$face->getUuid()}/branch-overrides", $user, [
'overrides' => [[
'address_uuid' => $address->getUuid(),
'price_rials' => 900_000,
'solo_duration_minutes' => 25,
]],
]);
self::assertSame(200, $this->responseCode());
$plain = $this->validate($user, [$face->getUuid()]);
self::assertSame(500_000, $plain['data']['total_price_rials']);
self::assertSame(15, $plain['data']['total_duration_minutes']);
$atBranch = $this->validate($user, [$face->getUuid()], ['branch_uuid' => $address->getUuid()]);
self::assertSame(900_000, $atBranch['data']['total_price_rials']);
self::assertSame(25, $atBranch['data']['total_duration_minutes']);
}
/** آیتم محیط دیگر ۴۰۴ می‌دهد نه ۴۲۲ — وجودش نباید لو برود. */
public function testForeignItemIsNotFound(): void
{
[$user] = $this->clinicWithSection();
[, , $otherSection] = $this->clinicWithSection();
$foreign = $this->item($otherSection, 'آیتم بیگانه', 10, 5);
$this->validate($user, [$foreign->getUuid()]);
self::assertSame(404, $this->responseCode());
}
public function testAllErrorsAreReportedTogether(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$a = $this->item($section, 'الف', 10, 5);
$b = $this->item($section, 'ب', 10, 5);
$this->group($user, $service, 'نواحی', 1, 1, [$a, $b]);
$this->authJson('PUT', "/api/v1/service-item/{$a->getUuid()}/relations", $user, [
'relations' => [['related_item_uuid' => $b->getUuid(), 'type' => ServiceItemRelation::TYPE_INCOMPATIBLE]],
]);
$body = $this->validate($user, [$a->getUuid(), $b->getUuid()], ['service_uuid' => $service->getUuid()]);
self::assertFalse($body['data']['valid']);
$codes = array_column($body['data']['errors'], 'code');
self::assertContains('max_select', $codes);
self::assertContains('incompatible', $codes, 'کاربر نباید سه بار رفت‌وبرگشت کند');
}
}