diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index 0be760de..a45f935e 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -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 # ۵۲ تست +``` diff --git a/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md b/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md index 2d7777f5..d695966e 100644 --- a/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md +++ b/docs/new_feture/taskes/task-04-service-catalog-v2/checklist.md @@ -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 .` | ✅ | | +| ۷.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | | diff --git a/migrations/Version20260730150551.php b/migrations/Version20260730150551.php new file mode 100644 index 00000000..10552948 --- /dev/null +++ b/migrations/Version20260730150551.php @@ -0,0 +1,73 @@ +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'); + } +} diff --git a/src/Appointment/Service/ServiceBookingCalculator.php b/src/Appointment/Service/ServiceBookingCalculator.php index 93cdeb46..a7806082 100644 --- a/src/Appointment/Service/ServiceBookingCalculator.php +++ b/src/Appointment/Service/ServiceBookingCalculator.php @@ -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, diff --git a/src/ClinicService/Controller/ServiceCatalogController.php b/src/ClinicService/Controller/ServiceCatalogController.php new file mode 100644 index 00000000..da64bb2a --- /dev/null +++ b/src/ClinicService/Controller/ServiceCatalogController.php @@ -0,0 +1,515 @@ +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> */ + 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|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'); + } + } +} diff --git a/src/ClinicService/Entity/CatalogCategory.php b/src/ClinicService/Entity/CatalogCategory.php new file mode 100644 index 00000000..1006053d --- /dev/null +++ b/src/ClinicService/Entity/CatalogCategory.php @@ -0,0 +1,108 @@ + 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, + ]; + } +} diff --git a/src/ClinicService/Entity/ItemGroup.php b/src/ClinicService/Entity/ItemGroup.php new file mode 100644 index 00000000..64838110 --- /dev/null +++ b/src/ClinicService/Entity/ItemGroup.php @@ -0,0 +1,129 @@ + 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 */ + #[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 */ + 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(), + ), + ]; + } +} diff --git a/src/ClinicService/Entity/ItemGroupMember.php b/src/ClinicService/Entity/ItemGroupMember.php new file mode 100644 index 00000000..1e08f06c --- /dev/null +++ b/src/ClinicService/Entity/ItemGroupMember.php @@ -0,0 +1,58 @@ + 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(), + ]; + } +} diff --git a/src/ClinicService/Entity/ServiceBranchOverride.php b/src/ClinicService/Entity/ServiceBranchOverride.php new file mode 100644 index 00000000..d6f15975 --- /dev/null +++ b/src/ClinicService/Entity/ServiceBranchOverride.php @@ -0,0 +1,93 @@ +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, + ]; + } +} diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 3250af88..d7d4a04c 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -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( diff --git a/src/ClinicService/Entity/ServiceItemRelation.php b/src/ClinicService/Entity/ServiceItemRelation.php new file mode 100644 index 00000000..e0a9ce2d --- /dev/null +++ b/src/ClinicService/Entity/ServiceItemRelation.php @@ -0,0 +1,87 @@ +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, + ]; + } +} diff --git a/src/ClinicService/Repository/CatalogCategoryRepository.php b/src/ClinicService/Repository/CatalogCategoryRepository.php new file mode 100644 index 00000000..b059d13e --- /dev/null +++ b/src/ClinicService/Repository/CatalogCategoryRepository.php @@ -0,0 +1,52 @@ + + */ +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(); + } +} diff --git a/src/ClinicService/Repository/ItemGroupMemberRepository.php b/src/ClinicService/Repository/ItemGroupMemberRepository.php new file mode 100644 index 00000000..62dfb183 --- /dev/null +++ b/src/ClinicService/Repository/ItemGroupMemberRepository.php @@ -0,0 +1,50 @@ + + */ +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(); + } +} diff --git a/src/ClinicService/Repository/ItemGroupRepository.php b/src/ClinicService/Repository/ItemGroupRepository.php new file mode 100644 index 00000000..f8f0d906 --- /dev/null +++ b/src/ClinicService/Repository/ItemGroupRepository.php @@ -0,0 +1,36 @@ + + */ +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(); + } +} diff --git a/src/ClinicService/Repository/ServiceBranchOverrideRepository.php b/src/ClinicService/Repository/ServiceBranchOverrideRepository.php new file mode 100644 index 00000000..4d0bd80b --- /dev/null +++ b/src/ClinicService/Repository/ServiceBranchOverrideRepository.php @@ -0,0 +1,68 @@ + + */ +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 کلید = شناسهٔ آیتم + */ + 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(); + } +} diff --git a/src/ClinicService/Repository/ServiceItemRelationRepository.php b/src/ClinicService/Repository/ServiceItemRelationRepository.php new file mode 100644 index 00000000..71b6f88f --- /dev/null +++ b/src/ClinicService/Repository/ServiceItemRelationRepository.php @@ -0,0 +1,58 @@ + + */ +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(); + } +} diff --git a/src/ClinicService/Service/DurationCalculator.php b/src/ClinicService/Service/DurationCalculator.php new file mode 100644 index 00000000..67e38e96 --- /dev/null +++ b/src/ClinicService/Service/DurationCalculator.php @@ -0,0 +1,137 @@ + $overrides کلید = شناسهٔ آیتم + * @param array $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 $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 $overrides + * @return list> + */ + 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; + } +} diff --git a/src/ClinicService/Service/ServiceSelectionValidator.php b/src/ClinicService/Service/ServiceSelectionValidator.php new file mode 100644 index 00000000..b92ead61 --- /dev/null +++ b/src/ClinicService/Service/ServiceSelectionValidator.php @@ -0,0 +1,195 @@ + + */ + 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> + */ + 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> + */ + 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); + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 586cb611..6424a4e3 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -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, diff --git a/tests/ClinicService/DurationCalculatorTest.php b/tests/ClinicService/DurationCalculatorTest.php new file mode 100644 index 00000000..49272df8 --- /dev/null +++ b/tests/ClinicService/DurationCalculatorTest.php @@ -0,0 +1,122 @@ +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, 'قیمت برخلاف مدت جمع ساده است'); + } +} diff --git a/tests/ClinicService/ServiceSelectionTest.php b/tests/ClinicService/ServiceSelectionTest.php new file mode 100644 index 00000000..388565c0 --- /dev/null +++ b/tests/ClinicService/ServiceSelectionTest.php @@ -0,0 +1,312 @@ +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, 'کاربر نباید سه بار رفت‌وبرگشت کند'); + } +}