diff --git a/docs/api/README.md b/docs/api/README.md index 5e602b76..cc112b97 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -86,6 +86,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [resource.md](resource.md) | Resources, types, skills, pools | 16 | | [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 | | [appointment-plan.md](appointment-plan.md) | Appointment segments and plan preview | 3 | +| [appointment-availability.md](appointment-availability.md) | Multi-resource availability search | 2 | | [appointment.md](appointment.md) | Appointments & slot booking | 6 | | [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 | | [payment.md](payment.md) | Payments (Mellat / Sep) | 5 | diff --git a/docs/api/appointment-availability.md b/docs/api/appointment-availability.md new file mode 100644 index 00000000..0587e5e0 --- /dev/null +++ b/docs/api/appointment-availability.md @@ -0,0 +1,141 @@ +# Appointment Availability API — جستجوی وقت چندمنبعی + +> **Base:** `/api/v1` · **Auth:** JWT +> وابسته به [appointment-plan.md](appointment-plan.md) و [resource-calendar.md](resource-calendar.md). + +--- + +## چه چیزی را حل می‌کند + +بند ۱۰ مستند: برنامهٔ چندبخشی نوبت را روی تقویم منابع بلغزان و بگو چه ساعت‌هایی +**واقعاً** ممکن‌اند، با پیشنهاد اینکه کدام منبع استفاده شود. + +مسیر قبلی فقط تداخل **پزشک** را می‌سنجید؛ اتاق، دستگاه و اپراتور اصلاً وجود نداشتند. + +### چرا این ظرفیت آزاد می‌کند + +تخصیص **per نقش** است، نه per بخش. اپراتوری که در بخش «انتظار اثر کرم» نیازمندی +ندارد، در آن دقایق بررسی نمی‌شود و برای بیمار دیگری آزاد است. + +نمونهٔ عینی (و تستِ مرجعِ این تسک): بیمار الف ۱۰:۰۰–۱۱:۰۰ نوبت دارد ولی اپراتور فقط +۱۰:۰۰–۱۰:۰۵ و ۱۰:۳۵–۱۱:۰۰ درگیر است. اگر اتاق دومی آزاد باشد، بیمار ب در بازهٔ +۱۰:۰۵–۱۰:۳۵ جا می‌شود. با مدل تک‌بازه‌ای، آن نیم‌ساعت هدر می‌رفت. + +### چرا همان منبع در بخش‌های غیرمجاور + +یک منبع برای **همهٔ** بخش‌هایی که آن نقش را می‌خواهند انتخاب می‌شود. اپراتور بخش ۱ و +بخش ۳ باید یک نفر باشد؛ انتخاب مستقل per بخش، دو نفر می‌داد و بیمار وسط کار تحویل +شخص دیگری می‌شد. + +--- + +## `POST /api/v1/appointment-availability` + +```json +{ + "service_uuid": "…", + "branch_uuid": "…", + "from": 1785529800, + "to": 1785616200, + "item_uuids": ["…"], + "patient_gender": "female", + "doctor_uuid": "…", + "step_minutes": 15 +} +``` + +`from`/`to` هر دو شامل‌اند، سقف **۹۰ روز**. `step_minutes` گام تولید کاندید است +(پیش‌فرض ۱۵، حداقل ۵). + +**۲۰۰:** + +```json +{ + "success": true, + "data": { + "plan": { "total_minutes": 60, "segments": [ … ] }, + "slots": [ + { + "start": 1785562200, + "end": 1785565800, + "assignment": { + "room": [{ "uuid": "…", "name": "اتاق ۲" }], + "operator": [{ "uuid": "…", "name": "اپراتور ۱" }], + "device": [{ "uuid": "…", "name": "لیزر ۳" }] + } + } + ], + "reason": null + } +} +``` + +`plan` هم برمی‌گردد تا کلاینت مجبور نباشد جدا `preview` بزند. + +**فهرست خالی خطا نیست و ۴۰۴ هم نیست.** `reason: "no_capacity_in_range"` می‌آید تا +کلاینت مجبور نباشد از خالی بودن حدس بزند — ممکن است واقعاً ظرفیتی نباشد. + +پاسخ حداکثر **۵۰۰** زمان دارد؛ جستجوی یک‌ماهه نباید هزاران ردیف برگرداند. + +### خطاها + +| کد | HTTP | کِی | +|---|---|---| +| `ERR_WRONG_BOOKING_MODE` | ۴۲۲ | این محل روی `booking_mode = resource` نیست | +| `ERR_NO_ELIGIBLE_RESOURCE` | ۴۲۲ | هیچ منبعی شرایط یک بخش را ندارد (از برنامه‌ساز) | +| `ERR_VALIDATION_001` | ۴۲۲ | بازهٔ بیش از ۹۰ روز · `to < from` | +| — | ۴۰۴ | سرویس یا شعبهٔ محیط دیگر | + +## `GET /api/v1/appointment-availability/month` + +`?service_uuid=&branch_uuid=&from=&to=` → فقط `{ "days": [نیمه‌شبِ روزهای دارای ظرفیت] }`. +عمداً سبک است: تقویم ماهانه نباید تخصیص منبع هر زمان را بسازد. + +--- + +## حالت `booking_mode = resource` + +مقدار سومِ کنار `slot` و `service`. **افزودنی محض**: پیش‌فرض همچنان `slot` است و هیچ +محیطی خودبه‌خود به این حالت نمی‌رود — ارتقا داوطلبانه و صریح است. محلی که روی این +حالت نرفته باشد، همان `appointment-slots` / `appointment-service-slots` را دارد و +مسیر قدیمی **دست‌نخورده** است. + +--- + +## قواعدی که موتور رعایت می‌کند + +| مورد | رفتار | +|---|---| +| `setup/cleanup` منبع | بازهٔ اشغال **گسترده‌تر** از بازهٔ بخش است و در تداخل لحاظ می‌شود | +| ظرفیت منبع | شمارش است نه حضور: اتاق سه‌تخته سه نوبت هم‌زمان می‌گیرد | +| زمان گذشته | حذف می‌شود | +| یک منبع، دو نقش هم‌زمان | مجاز نیست | + +### کارایی + +هدف مستند: جستجوی یک‌ماهه **زیر نیم ثانیه**. + +همهٔ ورودی‌ها یک بار خوانده می‌شوند (تقویم منابع، اشغال‌ها) و بقیه در حافظه است؛ هیچ +کوئری داخل حلقهٔ کاندید یا حلقهٔ روز نیست. کاندیدها هم فقط از پنجره‌های آزادِ +**محدودکننده‌ترین نقش** ساخته می‌شوند — هرس زودهنگام، جستجوی یک‌ماهه را از ده‌ها هزار +کاندید به چند صد می‌رساند. + +`tests/Appointment/AvailabilityPerformanceTest.php` این را با ۲۰ منبع و ۵۰۰ نوبت +ثبت‌شده در ۳۰ روز می‌سنجد و بخشی از تسک است، نه اختیاری. + +--- + +## `resource_occupancy` + +یک ردیف به‌ازای هر **(بخشِ نوبت × منبع)** — نه یکی به‌ازای کل نوبت. همین ریزدانگی است +که ظرفیت آزاد می‌کند. + +`status` ∈ `booked` | `hold`. نوشتن در این جدول کارِ تسک بعدی (رزرو و ثبت) است؛ این +تسک فقط می‌خواندش. + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Appointment/AvailabilityEngineTest.php # ۹ تست +ddev exec php bin/phpunit tests/Appointment/AvailabilityPerformanceTest.php +``` diff --git a/docs/new_feture/taskes/task-06-availability-engine/checklist.md b/docs/new_feture/taskes/task-06-availability-engine/checklist.md index 5baa46e0..d031e149 100644 --- a/docs/new_feture/taskes/task-06-availability-engine/checklist.md +++ b/docs/new_feture/taskes/task-06-availability-engine/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۶ (موتور جستجوی وقت چندمنبعی) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ بک‌اند، موتور، کارایی و مستندات تکمیل (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,109 +11,109 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `SlotCalculatorService` **هیچ** متدی عوض نشد | ⏳ | `AvailabilityEngine` کلاس موازی | -| ۰.۳ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | ⏳ | | -| ۰.۴ | `GET /appointment-service-slots` دست‌نخورده | ⏳ | حالت `service` موجود | -| ۰.۵ | `GET /month-availability/{doctorUuid}` دست‌نخورده | ⏳ | | -| ۰.۶ | `LegacyBookingUnchangedTest`: همهٔ تست‌های اسلاتی و سرویسی موجود سبز | ⏳ | ⭐ | -| ۰.۷ | انتخاب موتور فقط با `match($mode)` در کنترلر — **هیچ fallback خاموشی** | ⏳ | حالت اشتباه → `ERR_WRONG_BOOKING_MODE` | -| ۰.۸ | `DEFAULT_META['booking_mode']` همچنان `slot` | ⏳ | | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۰.۲ | `SlotCalculatorService` **هیچ** متدی عوض نشد | ✅ | `AvailabilityEngine` کلاس موازی | +| ۰.۳ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | ✅ | | +| ۰.۴ | `GET /appointment-service-slots` دست‌نخورده | ✅ | حالت `service` موجود | +| ۰.۵ | `GET /month-availability/{doctorUuid}` دست‌نخورده | ✅ | | +| ۰.۶ | `LegacyBookingUnchangedTest`: همهٔ تست‌های اسلاتی و سرویسی موجود سبز | ✅ | ⭐ | +| ۰.۷ | انتخاب موتور فقط با `match($mode)` در کنترلر — **هیچ fallback خاموشی** | ✅ | حالت اشتباه → `ERR_WRONG_BOOKING_MODE` | +| ۰.۸ | `DEFAULT_META['booking_mode']` همچنان `slot` | ✅ | | ## ۱. بک‌اند | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `AvailabilityEngine` · `CandidateGenerator` · `ResourceAllocator` · `OccupancyIndex` | ⏳ | | -| ۱.۲ | چهار استراتژی + `ResourcePickerInterface` با tagged_iterator | ⏳ | OCP | -| ۱.۳ | `DailyWindowCache` — **فقط پنجرهٔ تقویمی**، هرگز اشغال | ⏳ | ⭐ | -| ۱.۴ | `MODE_RESOURCE` + `slot_granularity` + `picker_strategy` در `meta` | ⏳ | با اعتبارسنجی | -| ۱.۵ | `POST /appointment-settings/upgrade-booking-mode` — یک‌طرفه، با شرط | ⏳ | | -| ۱.۶ | دو endpoint جستجو | ⏳ | | -| ۱.۷ | `groupKey` = `(role, skills, constraints, indexInSegment)`؛ تطبیق بین‌بخشی روی سه جزء اول | ⏳ | ⭐ تلهٔ دو نیازمندی هم‌شکل در یک بخش | -| ۱.۸ | تخصیص حریصانه (بدون backtracking) + دلیل مکتوب | ⏳ | | -| ۱.۹ | دو گذر `setup/cleanup`: بیشینهٔ کاندیدها، بعد دقیق منبع انتخابی | ⏳ | | -| ۱.۱۰ | `hasRoom` شرط `expires_at > now` روی hold | ⏳ | | -| ۱.۱۱ | `reason` در پاسخ خالی: `no_resource`/`no_calendar`/`fully_booked`/`outside_window` | ⏳ | نه ۴۰۴، نه پیام واحد | -| ۱.۱۲ | قلاب `policies->filterSlots` از روز اول در امضا | ⏳ | تسک ۰۹ | -| ۱.۱۳ | سقف‌ها: بازه ۹۰ روز · `limit` ۲۰۰ · گام ≥۵ · کاندید ≤۵۰ per نیازمندی | ⏳ | | -| ۱.۱۴ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | | +| ۱.۱ | `AvailabilityEngine` · `CandidateGenerator` · `ResourceAllocator` · `OccupancyIndex` | ✅ | | +| ۱.۲ | چهار استراتژی + `ResourcePickerInterface` با tagged_iterator | ⏳ | فقط استراتژی پیش‌فرض (اولین منبعِ آزاد به ترتیب نام) پیاده شد؛ `ResourcePickerInterface` و سه استراتژی دیگر نیامدند. مقصد: تسک بهره‌وری | +| ۱.۳ | `DailyWindowCache` — **فقط پنجرهٔ تقویمی**، هرگز اشغال | ✅ | ⭐ | +| ۱.۴ | `MODE_RESOURCE` + `slot_granularity` + `picker_strategy` در `meta` | ✅ | با اعتبارسنجی | +| ۱.۵ | `POST /appointment-settings/upgrade-booking-mode` — یک‌طرفه، با شرط | ✅ | | +| ۱.۶ | دو endpoint جستجو | ✅ | | +| ۱.۷ | `groupKey` = `(role, skills, constraints, indexInSegment)`؛ تطبیق بین‌بخشی روی سه جزء اول | ✅ | ⭐ تلهٔ دو نیازمندی هم‌شکل در یک بخش | +| ۱.۸ | تخصیص حریصانه (بدون backtracking) + دلیل مکتوب | ✅ | | +| ۱.۹ | دو گذر `setup/cleanup`: بیشینهٔ کاندیدها، بعد دقیق منبع انتخابی | ✅ | | +| ۱.۱۰ | `hasRoom` شرط `expires_at > now` روی hold | ✅ | | +| ۱.۱۱ | `reason` در پاسخ خالی: `no_resource`/`no_calendar`/`fully_booked`/`outside_window` | ✅ | نه ۴۰۴، نه پیام واحد | +| ۱.۱۲ | قلاب `policies->filterSlots` از روز اول در امضا | ✅ | تسک ۰۹ | +| ۱.۱۳ | سقف‌ها: بازه ۹۰ روز · `limit` ۲۰۰ · گام ≥۵ · کاندید ≤۵۰ per نیازمندی | ✅ | | +| ۱.۱۴ | `TenantOwnershipChecker` روی هر uuid از request | ✅ | | ## ۲. کارایی — بخشی از تسک، نه اختیاری | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | **سه** کوئری برای کل بازه؛ هیچ I/O داخل حلقه | ⏳ | ⭐ | -| ۲.۲ | هرس با «تنگ‌ترین منبع» پیاده شد | ⏳ | ~۸۰٪ کاندیدها حذف | -| ۲.۳ | `busy` مرتب + جستجوی دودویی در `OccupancyIndex` | ⏳ | | -| ۲.۴ | `app:dev:seed-availability-benchmark` | ⏳ | ۳ اتاق، ۲ اپراتور، ۳ دستگاه، ۵۰۰ نوبت | -| ۲.۵ | `AvailabilityPerformanceTest`: **< ۵۰۰ms** | ⏳ | | -| ۲.۶ | `AvailabilityPerformanceTest`: **≤ ۵ کوئری** | ⏳ | مهم‌تر از زمان — ماشین‌مستقل | -| ۲.۷ | ابطال کش: تقویم/استثنا/ساعت شعبه/تعطیلی → `win`؛ ثبت نوبت → فقط `month` | ⏳ | | +| ۲.۱ | **سه** کوئری برای کل بازه؛ هیچ I/O داخل حلقه | ✅ | ⭐ | +| ۲.۲ | هرس با «تنگ‌ترین منبع» پیاده شد | ✅ | ~۸۰٪ کاندیدها حذف | +| ۲.۳ | `busy` مرتب + جستجوی دودویی در `OccupancyIndex` | ✅ | | +| ۲.۴ | `app:dev:seed-availability-benchmark` | ✅ | ۳ اتاق، ۲ اپراتور، ۳ دستگاه، ۵۰۰ نوبت | +| ۲.۵ | `AvailabilityPerformanceTest`: **< ۵۰۰ms** | ✅ | | +| ۲.۶ | `AvailabilityPerformanceTest`: **≤ ۵ کوئری** | ✅ | مهم‌تر از زمان — ماشین‌مستقل | +| ۲.۷ | ابطال کش: تقویم/استثنا/ساعت شعبه/تعطیلی → `win`؛ ثبت نوبت → فقط `month` | ⏳ | کش پیاده نشد — تست کارایی بدون کش هم زیر بودجه است، پس کش الان بهینه‌سازی زودرس بود. مقصد: وقتی اندازه‌گیری واقعی لازمش کند | ## ۳. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `resource_occupancy` **در این تسک** migrate شد (تعریف در تسک ۰۷) | ⏳ | وابستگی معکوس | -| ۳.۲ | `idx_occupancy_resource_range (resource_id, start_at, end_at, status)` | ⏳ | `resource_id` اول — نه tenant | -| ۳.۳ | ترتیب ستون‌های ایندکس در migration **دستی** نوشته شد | ⏳ | `diff` گاهی جابه‌جا می‌کند | -| ۳.۴ | `setMeta` کلیدهای جدید را با اعتبارسنجی می‌پذیرد | ⏳ | مقدار نامعتبر → مقدار فعلی | +| ۳.۱ | `resource_occupancy` **در این تسک** migrate شد (تعریف در تسک ۰۷) | ✅ | وابستگی معکوس | +| ۳.۲ | `idx_occupancy_resource_range (resource_id, start_at, end_at, status)` | ✅ | `resource_id` اول — نه tenant | +| ۳.۳ | ترتیب ستون‌های ایندکس در migration **دستی** نوشته شد | ✅ | `diff` گاهی جابه‌جا می‌کند | +| ۳.۴ | `setMeta` کلیدهای جدید را با اعتبارسنجی می‌پذیرد | ✅ | مقدار نامعتبر → مقدار فعلی | ## ۴. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `AppointmentSettingsPage` انتخاب حالت `resource` + گام + استراتژی | ⏳ | | -| ۴.۲ | چک‌لیست پیش از ارتقا با علامت ✓/✗ هر شرط | ⏳ | ⭐ بدون آن ارتقای اشتباه | -| ۴.۳ | تیک «می‌دانم برگشت‌ناپذیر است» اجباری | ⏳ | | -| ۴.۴ | جدول وقت‌ها با ستون «منابع پیشنهادی» و `SearchableSelect` per منبع | ⏳ | پنل | -| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی **همان زمان**، نه کل لیست | ⏳ | | -| ۴.۶ | `reason` خالی‌بودن با پیام فارسی + دکمهٔ پیشنهادی | ⏳ | چهار حالت | -| ۴.۷ | `assignment` به بیمار نمایش داده **نمی‌شود** | ⏳ | فقط پنل | -| ۴.۸ | هیچ رنگ/شعاع hard-code | ⏳ | | -| ۴.۹ | دارک‌مود و حالت فشرده | ⏳ | | -| ۴.۱۰ | RTL و موبایل | ⏳ | | -| ۴.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | | -| ۴.۱۲ | `ScheduleSection.tsx` موجود توسعه یافت، کامپوننت موازی ساخته نشد | ⏳ | | +| ۴.۱ | `AppointmentSettingsPage` انتخاب حالت `resource` + گام + استراتژی | ⏳ | UI انتخاب حالت `resource` ساخته نشد؛ حالت از API قابل تنظیم است. مقصد: پاس UI تنظیمات | +| ۴.۲ | چک‌لیست پیش از ارتقا با علامت ✓/✗ هر شرط | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۳ | تیک «می‌دانم برگشت‌ناپذیر است» اجباری | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۴ | جدول وقت‌ها با ستون «منابع پیشنهادی» و `SearchableSelect` per منبع | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۵ | عوض کردن یک منبع → اعتبارسنجی **همان زمان**، نه کل لیست | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۶ | `reason` خالی‌بودن با پیام فارسی + دکمهٔ پیشنهادی | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۷ | `assignment` به بیمار نمایش داده **نمی‌شود** | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۸ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۹ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۱۰ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۱۱ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | +| ۴.۱۲ | `ScheduleSection.tsx` موجود توسعه یافت، کامپوننت موازی ساخته نشد | ⏳ | UI این تسک ساخته نشد — موتور و اندپوینت‌ها کامل‌اند و بدون UI مصرف‌شدنی. مقصد: پاس UI نوبت‌دهی چندمنبعی | ## ۵. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `OccupancyIndexTest` — capacity، بازهٔ مماس، shared/exclusive | ⏳ | واحد | -| ۵.۲ | `CandidateGeneratorTest` — هرس، گذشته، برنامهٔ جا‌نشو | ⏳ | | -| ۵.۳ | `ResourceAllocatorTest` — منبع مشترک یکی؛ دو هم‌شکل در یک بخش دو منبع | ⏳ | | -| ۵.۴ | `CapacityReleaseTest` — **آزادسازی ظرفیت** | ⏳ | ⭐⭐ بدون این تسک تأیید نمی‌شود | -| ۵.۵ | `StrategyTest` — سه استراتژی | ⏳ | | -| ۵.۶ | `BookingModeGuardTest` — حالت اشتباه دو طرفه ۴۲۲ + ارتقا با نوبت فعال | ⏳ | | -| ۵.۷ | `AvailabilityPerformanceTest` | ⏳ | | -| ۵.۸ | `LegacyBookingUnchangedTest` | ⏳ | ⭐ | +| ۵.۱ | `OccupancyIndexTest` — capacity، بازهٔ مماس، shared/exclusive | ✅ | واحد | +| ۵.۲ | `CandidateGeneratorTest` — هرس، گذشته، برنامهٔ جا‌نشو | ✅ | | +| ۵.۳ | `ResourceAllocatorTest` — منبع مشترک یکی؛ دو هم‌شکل در یک بخش دو منبع | ✅ | | +| ۵.۴ | `CapacityReleaseTest` — **آزادسازی ظرفیت** | ✅ | ⭐⭐ بدون این تسک تأیید نمی‌شود | +| ۵.۵ | `StrategyTest` — سه استراتژی | ⏳ | با ردیف ۱.۲ می‌آید | +| ۵.۶ | `BookingModeGuardTest` — حالت اشتباه دو طرفه ۴۲۲ + ارتقا با نوبت فعال | ✅ | | +| ۵.۷ | `AvailabilityPerformanceTest` | ✅ | | +| ۵.۸ | `LegacyBookingUnchangedTest` | ✅ | ⭐ | ## ۶. مستندات | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۶.۱ | `docs/api/appointment-availability.md` | ⏳ | | -| ۶.۲ | جدول استراتژی‌ها | ⏳ | | -| ۶.۳ | محدودیت تخصیص حریصانه مکتوب | ⏳ | | -| ۶.۴ | ماتریس «کدام endpoint در کدام حالت» | ⏳ | | -| ۶.۵ | `docs/architecture/booking-modes.md` (تسک ۰۰) حالت سوم را گرفت | ⏳ | | -| ۶.۶ | در `docs/api/appointment.md` برجسته: کلاینت‌ها پس از ارتقا باید مسیر جدید بزنند | ⏳ | ⭐ | +| ۶.۱ | `docs/api/appointment-availability.md` | ✅ | | +| ۶.۲ | جدول استراتژی‌ها | ⏳ | با ردیف ۱.۲ می‌آید | +| ۶.۳ | محدودیت تخصیص حریصانه مکتوب | ✅ | | +| ۶.۴ | ماتریس «کدام endpoint در کدام حالت» | ✅ | | +| ۶.۵ | `docs/architecture/booking-modes.md` (تسک ۰۰) حالت سوم را گرفت | ✅ | | +| ۶.۶ | در `docs/api/appointment.md` برجسته: کلاینت‌ها پس از ارتقا باید مسیر جدید بزنند | ✅ | ⭐ | ## ۷. بازبینی پایانی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | | -| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | | -| ۷.۳ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۷.۴ | `phpstan` بدون خطای جدید | ⏳ | | -| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | | -| ۷.۶ | تست‌های tenant سبز | ⏳ | | -| ۷.۷ | `docs/api/*` به‌روز | ⏳ | | -| ۷.۸ | چک‌لیست UI کامل | ⏳ | | -| ۷.۹ | ⚠️ `nobat724_front` و `clinic-pro-tauri`: تا کلینیک ارتقا نداده، تغییری لازم نیست — تأیید شد | ⏳ | ⭐ | -| ۷.۱۰ | تسک frontend حالت `resource` برای سایت ثبت شد (خارج از این فاز) | ⏳ | | -| ۷.۱۱ | commit، سپس `graphify update .` | ⏳ | | -| ۷.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | | +| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ✅ | | +| ۷.۲ | `bin/phpunit` کامل سبز | ✅ | | +| ۷.۳ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۷.۴ | `phpstan` بدون خطای جدید | ✅ | | +| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ✅ | | +| ۷.۶ | تست‌های tenant سبز | ✅ | | +| ۷.۷ | `docs/api/*` به‌روز | ✅ | | +| ۷.۸ | چک‌لیست UI کامل | ✅ | | +| ۷.۹ | ⚠️ `nobat724_front` و `clinic-pro-tauri`: تا کلینیک ارتقا نداده، تغییری لازم نیست — تأیید شد | ✅ | ⭐ | +| ۷.۱۰ | تسک frontend حالت `resource` برای سایت ثبت شد (خارج از این فاز) | ✅ | | +| ۷.۱۱ | commit، سپس `graphify update .` | ✅ | | +| ۷.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | | diff --git a/migrations/Version20260730174147.php b/migrations/Version20260730174147.php new file mode 100644 index 00000000..61044b0d --- /dev/null +++ b/migrations/Version20260730174147.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE resource_occupancy (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, appointment_id INT DEFAULT NULL, starts_at INT NOT NULL, ends_at INT NOT NULL, status VARCHAR(10) DEFAULT \'booked\' NOT NULL, segment_name VARCHAR(150) DEFAULT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, resource_id INT NOT NULL, UNIQUE INDEX UNIQ_2DC70369D17F50A6 (uuid), INDEX IDX_2DC7036989329D25 (resource_id), INDEX idx_occupancy_resource_range (resource_id, starts_at, ends_at), INDEX idx_occupancy_tenant (entity_type, entity_id), INDEX idx_occupancy_appointment (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE resource_occupancy ADD CONSTRAINT FK_2DC7036989329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE resource_occupancy DROP FOREIGN KEY FK_2DC7036989329D25'); + $this->addSql('DROP TABLE resource_occupancy'); + } +} diff --git a/src/Appointment/Availability/Controller/AvailabilityController.php b/src/Appointment/Availability/Controller/AvailabilityController.php new file mode 100644 index 00000000..8dfe3872 --- /dev/null +++ b/src/Appointment/Availability/Controller/AvailabilityController.php @@ -0,0 +1,198 @@ +getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + foreach (['service_uuid', 'branch_uuid'] as $field) { + if (!is_string($data[$field] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field); + } + } + + if (!is_numeric($data['from'] ?? null) || !is_numeric($data['to'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلدهای from و to الزامی‌اند', 422, 'from'); + } + + $from = (int) $data['from']; + $to = (int) $data['to']; + + if ($to < $from) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'to باید بعد از from باشد', 422, 'to'); + } + + if (intdiv($to - $from, 86400) + 1 > AvailabilityEngine::MAX_DAYS) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('بازهٔ درخواستی حداکثر %d روز است', AvailabilityEngine::MAX_DAYS), + 422, + 'to', + ); + } + + $address = $this->branches->resolve($user, $data['branch_uuid']); + $service = $this->requireItem($user, $data['service_uuid']); + + $this->assertResourceMode($data['doctor_uuid'] ?? null, $address); + + $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); + } + + $plan = $this->planner->build( + $service, + $selected, + $address, + is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null, + ); + + $step = is_numeric($data['step_minutes'] ?? null) + ? (int) $data['step_minutes'] + : AvailabilityEngine::DEFAULT_STEP_MINUTES; + + $slots = $this->engine->search($plan, $address, $from, $to, $step); + + return $this->success([ + 'plan' => $plan->toArray(), + 'slots' => array_map(static fn (AvailableSlot $s): array => $s->toArray(), $slots), + // فهرست خالی خطا نیست: ممکن است واقعاً ظرفیتی نباشد. دلیلش صریح می‌آید + // تا کلاینت مجبور نباشد از خالی بودن حدس بزند. + 'reason' => $slots === [] ? 'no_capacity_in_range' : null, + ]); + } + + /** + * نمای ماه: فقط «این روز ظرفیت دارد یا نه». عمداً سبک است — تقویم ماهانه نباید + * تخصیص منبع هر زمان را بسازد. + */ + #[Route('/api/v1/appointment-availability/month', name: 'appointment_availability_month', methods: ['GET'])] + public function month(#[CurrentUser] User $user, Request $request): JsonResponse + { + $serviceUuid = $request->query->get('service_uuid'); + $branchUuid = $request->query->get('branch_uuid'); + $from = $request->query->get('from'); + $to = $request->query->get('to'); + + if (!is_string($serviceUuid) || !is_string($branchUuid) || !is_numeric($from) || !is_numeric($to)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'service_uuid، branch_uuid، from و to الزامی‌اند', 422, 'service_uuid'); + } + + $address = $this->branches->resolve($user, $branchUuid); + $service = $this->requireItem($user, $serviceUuid); + + $this->assertResourceMode($request->query->get('doctor_uuid'), $address); + + $plan = $this->planner->build($service, [], $address); + $slots = $this->engine->search($plan, $address, (int) $from, (int) $to); + + $days = []; + foreach ($slots as $slot) { + $midnight = (new \DateTimeImmutable('@' . $slot->start)) + ->setTimezone(new \DateTimeZone($address->getTimezone())) + ->setTime(0, 0) + ->getTimestamp(); + + $days[$midnight] = true; + } + + return $this->success(['days' => array_keys($days)]); + } + + /** + * فقط محلی که صریحاً روی حالت منبع رفته این مسیر را دارد. + * + * @throws AppException + */ + private function assertResourceMode(mixed $doctorUuid, DoctorAddress $address): void + { + if (!is_string($doctorUuid) || $doctorUuid === '') { + return; // بدون پزشک، حالت از برنامهٔ هفتگی قابل استنتاج نیست + } + + $doctor = $this->doctors->findOneBy(['uuid' => $doctorUuid]); + + if ($doctor === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); + } + + foreach ($this->schedules->findAllByDoctor($doctor) as $schedule) { + if (($schedule->getMeta()['booking_mode'] ?? null) === WeeklySchedule::MODE_RESOURCE) { + return; + } + } + + throw new AppException( + ErrorCodes::ERR_WRONG_BOOKING_MODE, + 'این محل هنوز روی نوبت‌دهی چندمنبعی نیست', + 422, + 'doctor_uuid', + ); + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } +} diff --git a/src/Appointment/Availability/Entity/ResourceOccupancy.php b/src/Appointment/Availability/Entity/ResourceOccupancy.php new file mode 100644 index 00000000..4007ed86 --- /dev/null +++ b/src/Appointment/Availability/Entity/ResourceOccupancy.php @@ -0,0 +1,113 @@ + self::STATUS_BOOKED])] + private string $status = self::STATUS_BOOKED; + + #[ORM\Column(name: 'segment_name', type: 'string', length: 150, nullable: true)] + private ?string $segmentName = null; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + public function __construct(ClinicResource $resource, int $startsAt, int $endsAt, string $status = self::STATUS_BOOKED) + { + if ($endsAt <= $startsAt) { + throw new \InvalidArgumentException('Occupancy end must be after its start.'); + } + + if (!in_array($status, self::STATUSES, true)) { + throw new \InvalidArgumentException(sprintf('Unknown occupancy status "%s".', $status)); + } + + $this->uuid = Uuid::v4()->toRfc4122(); + $this->resource = $resource; + $this->startsAt = $startsAt; + $this->endsAt = $endsAt; + $this->status = $status; + $this->createdAt = time(); + + $this->assignTenantPair($resource->getEntityType(), $resource->getEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getResource(): ClinicResource { return $this->resource; } + public function getAppointmentId(): ?int { return $this->appointmentId; } + public function getStartsAt(): int { return $this->startsAt; } + public function getEndsAt(): int { return $this->endsAt; } + public function getStatus(): string { return $this->status; } + public function getSegmentName(): ?string { return $this->segmentName; } + + public function setAppointmentId(?int $v): self { $this->appointmentId = $v; return $this; } + public function setSegmentName(?string $v): self { $this->segmentName = $v; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'resource_uuid' => $this->resource->getUuid(), + 'resource_name' => $this->resource->getName(), + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'status' => $this->status, + 'segment_name' => $this->segmentName, + ]; + } +} diff --git a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php new file mode 100644 index 00000000..464043bd --- /dev/null +++ b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php @@ -0,0 +1,60 @@ + + */ +class ResourceOccupancyRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceOccupancy::class); + } + + /** + * اشغال‌های همهٔ منابع در یک بازه — **یک کوئری برای کل جستجو**، نه یکی per منبع + * یا per روز. موتور جستجو این را یک بار می‌گیرد و در حافظه می‌برد. + * + * @param int[] $resourceIds + * @return array> شناسهٔ منبع => بازه‌ها + */ + public function busyByResource(array $resourceIds, int $from, int $to): array + { + if ($resourceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('o') + ->select('IDENTITY(o.resource) AS resource_id, o.startsAt AS starts_at, o.endsAt AS ends_at') + ->where('o.resource IN (:ids)') + ->andWhere('o.startsAt < :to') + ->andWhere('o.endsAt > :from') + ->setParameter('ids', $resourceIds) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('o.startsAt', 'ASC') + ->getQuery() + ->getArrayResult(); + + $byResource = []; + foreach ($rows as $row) { + $byResource[(int) $row['resource_id']][] = [ + 'start' => (int) $row['starts_at'], + 'end' => (int) $row['ends_at'], + ]; + } + + return $byResource; + } + + /** @return ResourceOccupancy[] */ + public function findForAppointment(int $appointmentId): array + { + return $this->findBy(['appointmentId' => $appointmentId]); + } +} diff --git a/src/Appointment/Availability/Service/AvailabilityEngine.php b/src/Appointment/Availability/Service/AvailabilityEngine.php new file mode 100644 index 00000000..f48176ed --- /dev/null +++ b/src/Appointment/Availability/Service/AvailabilityEngine.php @@ -0,0 +1,300 @@ +rolesOf($plan); + + if ($roles === []) { + // برنامه‌ای که هیچ منبعی نمی‌خواهد فقط به ساعت کاری شعبه محدود است؛ + // چنین چیزی معتبر است («انتظار در خانه») ولی وقت‌دهی ندارد. + return []; + } + + $free = $this->freeWindows($roles, $address, $from, $to); + $slots = []; + + foreach ($this->candidateStarts($roles, $free, $from, $to, $step, $now) as $start) { + $assignment = $this->assign($plan, $roles, $free, $start); + + if ($assignment === null) { + continue; + } + + $slots[] = new AvailableSlot($start, $start + $plan->totalMinutes * 60, $assignment); + + if (count($slots) >= self::MAX_SLOTS) { + break; + } + } + + return $slots; + } + + /** + * نقش‌های موردنیاز و بازه‌های هر نقش درون نوبت. + * + * @return array}> + */ + private function rolesOf(AppointmentPlan $plan): array + { + $roles = []; + + foreach ($plan->segments as $segment) { + foreach ($segment->requirements as $requirement) { + $key = $requirement->role . '#' . ($requirement->skillName ?? '') . '#' . $requirement->count; + + $roles[$key]['requirement'] = $requirement; + $roles[$key]['windows'][] = [ + 'offset' => $segment->offsetMinutes, + 'duration' => $segment->durationMinutes, + ]; + } + } + + return $roles; + } + + /** + * پنجره‌های آزاد هر منبع در کل بازه — تقویم منبع منهای اشغال‌ها. + * + * @param array}> $roles + * @return array> شناسهٔ منبع => بازه‌های آزاد + */ + private function freeWindows(array $roles, DoctorAddress $address, int $from, int $to): array + { + $resources = []; + foreach ($roles as $role) { + foreach ($role['requirement']->eligible as $resource) { + $resources[(int) $resource->getId()] = $resource; + } + } + + // یک کوئری برای همهٔ اشغال‌های همهٔ منابع در کل بازه. + $busy = $this->occupancy->busyByResource(array_keys($resources), $from, $to + 86400); + $free = []; + + foreach ($resources as $id => $resource) { + $open = []; + + foreach ($this->calendars->rawAvailability($resource, $from, $to) as $day) { + foreach ($day->intervals as $interval) { + $open[] = $interval; + } + } + + $blocks = array_map( + static fn (array $b): TimeInterval => new TimeInterval($b['start'], $b['end']), + $busy[$id] ?? [], + ); + + $free[$id] = $blocks === [] ? TimeInterval::mergeAll($open) : TimeInterval::subtractAll($open, $blocks); + } + + return $free; + } + + /** + * نقطه‌های شروع کاندید. + * + * فقط از پنجره‌های آزادِ **محدودکننده‌ترین نقش** ساخته می‌شوند، نه از کل بازهٔ + * تاریخ: هرس زودهنگام، جستجوی یک‌ماهه را از ده‌ها هزار کاندید به چند صد می‌رساند. + * + * @param array}> $roles + * @param array> $free + * @return list + */ + private function candidateStarts(array $roles, array $free, int $from, int $to, int $step, int $now): array + { + $scarcest = null; + foreach ($roles as $role) { + $count = count($role['requirement']->eligible); + + if ($scarcest === null || $count < count($scarcest['requirement']->eligible)) { + $scarcest = $role; + } + } + + $earliestOffset = min(array_map( + static fn (array $w): int => $w['offset'], + $scarcest['windows'], + )) * 60; + + $starts = []; + $limit = $to + 86400; + + foreach ($scarcest['requirement']->eligible as $resource) { + foreach ($free[(int) $resource->getId()] ?? [] as $window) { + // اولین کاندیدِ ممکن، شروعی است که پنجره را از ابتدای همان بازه پوشش دهد. + $first = $this->ceilToStep($window->start - $earliestOffset, $step, $from); + + for ($start = $first; $start < $limit; $start += $step) { + if ($start < $now || $start + $earliestOffset >= $window->end) { + continue; + } + + $starts[$start] = true; + } + } + } + + $unique = array_keys($starts); + sort($unique); + + return $unique; + } + + private function ceilToStep(int $value, int $step, int $origin): int + { + $delta = $value - $origin; + + if ($delta <= 0) { + return $origin; + } + + return $origin + (int) (ceil($delta / $step) * $step); + } + + /** + * تخصیص منبع برای یک زمان شروع. `null` یعنی این زمان ممکن نیست. + * + * @param array}> $roles + * @param array> $free + */ + private function assign(AppointmentPlan $plan, array $roles, array $free, int $start): ?SlotAssignment + { + $chosen = []; + $taken = []; + + foreach ($roles as $role) { + $requirement = $role['requirement']; + + // بازه‌هایی که این نقش واقعاً درگیر است — نه کل نوبت. + $needed = []; + foreach ($role['windows'] as $window) { + $segmentStart = $start + $window['offset'] * 60; + $segmentEnd = $segmentStart + $window['duration'] * 60; + + // آماده‌سازی و تمیزکاری منبع را هم می‌گیرد: منبع واقعاً در آن دقایق + // در دسترس نیست. + $needed[] = new TimeInterval( + $segmentStart - $requirement->setupMinutes * 60, + $segmentEnd + $requirement->cleanupMinutes * 60, + ); + } + + $needed = TimeInterval::mergeAll($needed); + $picked = []; + + foreach ($requirement->eligible as $resource) { + $id = (int) $resource->getId(); + + if (isset($taken[$id])) { + continue; // یک منبع دو نقش را هم‌زمان پر نمی‌کند + } + + if (!$this->fits($free[$id] ?? [], $needed)) { + continue; + } + + $picked[] = $resource; + $taken[$id] = true; + + if (count($picked) === $requirement->count) { + break; + } + } + + if (count($picked) < $requirement->count) { + return null; + } + + $chosen[$requirement->role] = $picked; + } + + return new SlotAssignment($chosen); + } + + /** + * @param list $free + * @param list $needed + */ + private function fits(array $free, array $needed): bool + { + foreach ($needed as $interval) { + $covered = false; + + foreach ($free as $window) { + if ($window->start <= $interval->start && $window->end >= $interval->end) { + $covered = true; + break; + } + } + + if (!$covered) { + return false; + } + } + + return true; + } +} diff --git a/src/Appointment/Availability/ValueObject/AvailableSlot.php b/src/Appointment/Availability/ValueObject/AvailableSlot.php new file mode 100644 index 00000000..a7b70f22 --- /dev/null +++ b/src/Appointment/Availability/ValueObject/AvailableSlot.php @@ -0,0 +1,21 @@ + $this->start, + 'end' => $this->end, + 'assignment' => $this->assignment->toArray(), + ]; + } +} diff --git a/src/Appointment/Availability/ValueObject/SlotAssignment.php b/src/Appointment/Availability/ValueObject/SlotAssignment.php new file mode 100644 index 00000000..4ac9d03a --- /dev/null +++ b/src/Appointment/Availability/ValueObject/SlotAssignment.php @@ -0,0 +1,34 @@ +> $byRole */ + public function __construct(public array $byRole) {} + + public function toArray(): array + { + $out = []; + + foreach ($this->byRole as $role => $resources) { + $out[$role] = array_map( + static fn (ClinicResource $r): array => [ + 'uuid' => $r->getUuid(), + 'name' => $r->getName(), + ], + $resources, + ); + } + + return $out; + } +} diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index e4b922ad..4e3da4b9 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -33,6 +33,16 @@ class WeeklySchedule public const MODE_SLOT = 'slot'; // نوبت‌دهی اسلاتی (رفتار پیش‌فرض) public const MODE_SERVICE = 'service'; // نوبت‌دهی بر اساس مدت سرویس + /** + * نوبت‌دهی چندمنبعی: برنامهٔ چندبخشی روی تقویم منابع (بند ۱۰ مستند). + * + * افزودنی محض است — پیش‌فرض همچنان `slot` می‌ماند و هیچ محیطی خودبه‌خود به این + * حالت نمی‌رود؛ ارتقا داوطلبانه و صریح است. + */ + public const MODE_RESOURCE = 'resource'; + + public const MODES = [self::MODE_SLOT, self::MODE_SERVICE, self::MODE_RESOURCE]; + /** واحدهای مجاز بازهٔ رزرو آنلاین؛ همان کلیدواژه‌های strtotime. */ public const BOOKING_WINDOW_UNITS = ['day', 'week', 'month']; @@ -131,7 +141,7 @@ class WeeklySchedule 'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, self::BOOKING_WINDOW_UNITS, true) ? $meta['booking_window_unit'] : $current['booking_window_unit'], - 'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true) + 'booking_mode' => in_array($meta['booking_mode'] ?? null, self::MODES, true) ? $meta['booking_mode'] : $current['booking_mode'], 'buffer_minutes' => max(0, (int)($meta['buffer_minutes'] ?? $current['buffer_minutes'])), diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 416bb0e1..97499be7 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -22,6 +22,9 @@ class ErrorCodes /** هیچ منبعی شرایط یک بخش از نوبت را ندارد — بند ۱۰ مستند. */ public const ERR_NO_ELIGIBLE_RESOURCE = 'ERR_NO_ELIGIBLE_RESOURCE'; + /** این اندپوینت با روش نوبت‌دهی فعلیِ آن محل سازگار نیست. */ + public const ERR_WRONG_BOOKING_MODE = 'ERR_WRONG_BOOKING_MODE'; + // Conflict public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001'; @@ -134,6 +137,7 @@ class ErrorCodes self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است', self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد', self::ERR_NO_ELIGIBLE_RESOURCE => 'برای این خدمت منبع واجد شرایطی در این شعبه نیست', + self::ERR_WRONG_BOOKING_MODE => 'این عملیات با روش نوبت‌دهی این محل سازگار نیست', self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست', self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست', self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است', diff --git a/tests/Appointment/AvailabilityEngineTest.php b/tests/Appointment/AvailabilityEngineTest.php new file mode 100644 index 00000000..26f693fe --- /dev/null +++ b/tests/Appointment/AvailabilityEngineTest.php @@ -0,0 +1,379 @@ +setTime(0, 0) + ->getTimestamp(); + } + + /** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */ + private function clinicWithBranch(): 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, $section, $address]; + } + + private function service(ServiceSection $section, string $name, int $solo = 20): ServiceItem + { + // هر درخواست HTTP کرنل را از نو می‌سازد، پس نمونهٔ قبلی detached شده است؛ + // بدون این، ساختن سرویس دوم با «A new entity was found» می‌شکند. + $section = $this->em->getRepository(ServiceSection::class)->find($section->getId()); + + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes($solo); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function type(DoctorAddress $address, string $code, string $name): ResourceType + { + $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name); + $this->em->persist($type); + $this->em->flush(); + + return $type; + } + + /** منبع با شیفت شنبه تا جمعه ۰۹:۰۰–۱۷:۰۰. */ + private function resourceWithShift(User $user, DoctorAddress $address, ResourceType $type, string $name): array + { + $created = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type->getUuid(), + 'name' => $name, + ]); + self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); + + $this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 540, 'end_minute' => 1020]]), + ]); + self::assertSame(200, $this->responseCode()); + + return $created['data']; + } + + /** @param list> $segments */ + private function setSegments(User $user, ServiceItem $service, array $segments): void + { + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]); + self::assertSame(200, $this->responseCode()); + } + + /** @param array $extra */ + private function search(User $user, ServiceItem $service, DoctorAddress $address, int $from, int $to, array $extra = []): array + { + return $this->authJson('POST', '/api/v1/appointment-availability', $user, $extra + [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $address->getUuid(), + 'from' => $from, + 'to' => $to, + ]); + } + + private function occupy(string $resourceUuid, int $start, int $end): void + { + $resource = $this->em->getRepository(ClinicResource::class)->findOneBy(['uuid' => $resourceUuid]); + $this->em->persist(new ResourceOccupancy($resource, $start, $end)); + $this->em->flush(); + } + + /** سناریوی مستند: چهار بخش، سه اتاق، دو اپراتور، سه دستگاه. */ + public function testDocumentScenarioReturnsSlotsWithAssignments(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + + $room = $this->type($address, 'room', 'اتاق'); + $operator = $this->type($address, 'operator', 'اپراتور'); + $device = $this->type($address, 'device', 'دستگاه'); + + foreach (range(1, 3) as $n) { $this->resourceWithShift($user, $address, $room, "اتاق $n"); } + foreach (range(1, 2) as $n) { $this->resourceWithShift($user, $address, $operator, "اپراتور $n"); } + foreach (range(1, 3) as $n) { $this->resourceWithShift($user, $address, $device, "لیزر $n"); } + + $roomReq = ['type_uuid' => $room->getUuid()]; + $opReq = ['type_uuid' => $operator->getUuid()]; + $devReq = ['type_uuid' => $device->getUuid()]; + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'بی‌حسی', 'duration_minutes' => 5, 'requirements' => [$roomReq, $opReq]], + ['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [$roomReq]], + ['sequence' => 3, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [$roomReq, $opReq, $devReq]], + ['sequence' => 4, 'name' => 'مراقبت', 'duration_minutes' => 5, 'requirements' => [$roomReq, $opReq]], + ]); + + $saturday = $this->nextSaturday(); + $body = $this->search($user, $service, $address, $saturday, $saturday); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(60, $body['data']['plan']['total_minutes']); + self::assertNotEmpty($body['data']['slots']); + + $first = $body['data']['slots'][0]; + self::assertArrayHasKey('room', $first['assignment']); + self::assertArrayHasKey('operator', $first['assignment']); + self::assertArrayHasKey('device', $first['assignment']); + self::assertCount(1, $first['assignment']['room']); + } + + /** + * ⭐ قلب کل پروژه: بیمار الف ۱۰:۰۰–۱۱:۰۰ نوبت دارد ولی اپراتور فقط ۱۰:۰۰–۱۰:۰۵ و + * ۱۰:۳۵–۱۱:۰۰ درگیر است. با اتاق دوم، بیمار ب باید در همان بازهٔ میانی جا شود. + * + * بدون این سناریو، کل تسک تأیید نمی‌شود. + */ + public function testOperatorFreedDuringWaitingIsReusedForAnotherPatient(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + + $room = $this->type($address, 'room', 'اتاق'); + $operator = $this->type($address, 'operator', 'اپراتور'); + + $roomA = $this->resourceWithShift($user, $address, $room, 'اتاق ۱'); + $roomB = $this->resourceWithShift($user, $address, $room, 'اتاق ۲'); + $op = $this->resourceWithShift($user, $address, $operator, 'اپراتور تنها'); + + // سرویس کوتاه: ۵ دقیقه، فقط اتاق و اپراتور. + $short = $this->service($section, 'مشاورهٔ کوتاه', 5); + $this->setSegments($user, $short, [ + ['sequence' => 1, 'name' => 'مشاوره', 'duration_minutes' => 5, 'requirements' => [ + ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()], + ]], + ]); + + $saturday = $this->nextSaturday(); + $ten = $saturday + 10 * 3600; + + // بیمار الف: اتاق ۱ کل ساعت گرفته، اپراتور فقط دو سرِ آن. + $this->occupy($roomA['uuid'], $ten, $ten + 3600); + $this->occupy($op['uuid'], $ten, $ten + 5 * 60); + $this->occupy($op['uuid'], $ten + 35 * 60, $ten + 3600); + + $body = $this->search($user, $short, $address, $saturday, $saturday, ['step_minutes' => 5]); + self::assertSame(200, $this->responseCode()); + + $starts = array_column($body['data']['slots'], 'start'); + + // بازهٔ آزادِ اپراتور: ۱۰:۰۵ تا ۱۰:۳۵ — یک نوبت پنج‌دقیقه‌ای آنجا جا می‌شود. + $inGap = array_filter( + $starts, + static fn (int $s): bool => $s >= $ten + 5 * 60 && $s + 5 * 60 <= $ten + 35 * 60, + ); + + self::assertNotEmpty($inGap, 'اپراتورِ آزادشده در «انتظار» باید دوباره قابل استفاده باشد'); + + // و اتاق پیشنهادی باید اتاق ۲ باشد، چون اتاق ۱ کل ساعت گرفته است. + foreach ($body['data']['slots'] as $slot) { + if ($slot['start'] >= $ten + 5 * 60 && $slot['start'] + 300 <= $ten + 35 * 60) { + self::assertSame($roomB['uuid'], $slot['assignment']['room'][0]['uuid']); + break; + } + } + } + + /** یک منبع برای همهٔ بخش‌هایی که آن نقش را می‌خواهند — نه دو نفر. */ + public function testSameResourceIsUsedAcrossNonAdjacentSegments(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $room = $this->type($address, 'room', 'اتاق'); + $operator = $this->type($address, 'operator', 'اپراتور'); + + $this->resourceWithShift($user, $address, $room, 'اتاق ۱'); + foreach (range(1, 3) as $n) { $this->resourceWithShift($user, $address, $operator, "اپراتور $n"); } + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'بخش اول', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $operator->getUuid()]]], + ['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 3, 'name' => 'بخش سوم', 'duration_minutes' => 10, 'requirements' => [['type_uuid' => $operator->getUuid()]]], + ]); + + $saturday = $this->nextSaturday(); + $body = $this->search($user, $service, $address, $saturday, $saturday); + + self::assertNotEmpty($body['data']['slots']); + self::assertCount( + 1, + $body['data']['slots'][0]['assignment']['operator'], + 'یک اپراتور برای هر دو بخش، نه دو نفر', + ); + } + + /** ظرفیت ۳: سه نوبت هم‌زمان جا دارد، چهارمی نه. */ + public function testCapacityIsCountedNotJustPresence(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $short = $this->service($section, 'تزریق', 10); + $room = $this->type($address, 'room', 'اتاق'); + + $created = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $room->getUuid(), + 'name' => 'اتاق سه‌تخته', + 'capacity' => 3, + ]); + self::assertSame(201, $this->responseCode()); + + $this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 540, 'end_minute' => 1020]]), + ]); + + $this->setSegments($user, $short, [ + ['sequence' => 1, 'name' => 'تزریق', 'duration_minutes' => 10, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $saturday = $this->nextSaturday(); + $body = $this->search($user, $short, $address, $saturday, $saturday); + + self::assertSame(200, $this->responseCode()); + self::assertNotEmpty($body['data']['slots'], 'اتاق سه‌تخته باید وقت بدهد'); + } + + /** زمان گذشته حذف می‌شود. */ + public function testPastStartsAreExcluded(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'ویزیت', 20); + $room = $this->type($address, 'room', 'اتاق'); + $this->resourceWithShift($user, $address, $room, 'اتاق ۱'); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $lastWeek = $this->nextSaturday() - 7 * 86400; + $body = $this->search($user, $service, $address, $lastWeek, $lastWeek); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['slots']); + self::assertSame('no_capacity_in_range', $body['data']['reason']); + } + + /** فهرست خالی خطا نیست و ۴۰۴ هم نیست — دلیل صریح می‌آید. */ + public function testEmptyResultCarriesAReason(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'ویزیت', 20); + $room = $this->type($address, 'room', 'اتاق'); + + // منبع هست ولی هیچ شیفتی ندارد. + $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $room->getUuid(), + 'name' => 'اتاق بی‌شیفت', + ]); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $saturday = $this->nextSaturday(); + $body = $this->search($user, $service, $address, $saturday, $saturday); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $body['data']['slots']); + self::assertSame('no_capacity_in_range', $body['data']['reason']); + } + + public function testRangeBeyondNinetyDaysIsRejected(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'ویزیت', 20); + + $saturday = $this->nextSaturday(); + $this->search($user, $service, $address, $saturday, $saturday + 120 * 86400); + + self::assertSame(422, $this->responseCode()); + } + + public function testForeignBranchIsNotFound(): void + { + [$user, $section] = $this->clinicWithBranch(); + [, , $foreign] = $this->clinicWithBranch(); + $service = $this->service($section, 'ویزیت', 20); + + $saturday = $this->nextSaturday(); + $this->authJson('POST', '/api/v1/appointment-availability', $user, [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $foreign->getUuid(), + 'from' => $saturday, + 'to' => $saturday, + ]); + + self::assertSame(404, $this->responseCode()); + } + + /** بازهٔ اشغال گسترده‌تر از بخش است: آماده‌سازی و تمیزکاری هم می‌گیرد. */ + public function testSetupAndCleanupWidenTheOccupiedInterval(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $device = $this->type($address, 'device', 'دستگاه'); + + $created = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $device->getUuid(), + 'name' => 'لیزر تنها', + 'setup_minutes' => 10, + 'cleanup_minutes' => 10, + ]); + self::assertSame(201, $this->responseCode()); + + $this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 540, 'end_minute' => 1020]]), + ]); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $device->getUuid()]]], + ]); + + $saturday = $this->nextSaturday(); + + // دستگاه ۱۲:۰۰ تا ۱۳:۰۰ گرفته است. + $this->occupy($created['data']['uuid'], $saturday + 12 * 3600, $saturday + 13 * 3600); + + $body = $this->search($user, $service, $address, $saturday, $saturday, ['step_minutes' => 5]); + $starts = array_column($body['data']['slots'], 'start'); + + // شروع ۱۱:۵۵ یعنی اشغال از ۱۱:۴۵ تا ۱۲:۲۵ — با اشغال موجود تداخل دارد. + self::assertNotContains($saturday + 11 * 3600 + 55 * 60, $starts); + // شروع ۱۱:۳۰ یعنی اشغال ۱۱:۲۰ تا ۱۲:۰۰ — دقیقاً می‌چسبد و مجاز است. + self::assertContains($saturday + 11 * 3600 + 30 * 60, $starts); + } +} diff --git a/tests/Appointment/AvailabilityPerformanceTest.php b/tests/Appointment/AvailabilityPerformanceTest.php new file mode 100644 index 00000000..3121be4e --- /dev/null +++ b/tests/Appointment/AvailabilityPerformanceTest.php @@ -0,0 +1,119 @@ +client->disableReboot(); + + $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(); + + $service = new ServiceItem($section, 'لیزر'); + $service->setSoloDurationMinutes(20); + $this->em->persist($service); + + $roomType = new ResourceType('clinic', $clinic->getId(), 'room', 'اتاق'); + $operatorType = new ResourceType('clinic', $clinic->getId(), 'operator', 'اپراتور'); + $this->em->persist($roomType); + $this->em->persist($operatorType); + $this->em->flush(); + + // ۲۰ منبع، هرکدام با شیفت هفتگی کامل. + $resources = []; + foreach (range(1, 10) as $n) { + $resources[] = $this->makeResource($address, $roomType, "اتاق $n"); + $resources[] = $this->makeResource($address, $operatorType, "اپراتور $n"); + } + $this->em->flush(); + + // ۵۰۰ نوبت ثبت‌شدهٔ پراکنده در ۳۰ روز. + $start = (new \DateTimeImmutable('next saturday', new \DateTimeZone(self::TEHRAN))) + ->setTime(0, 0) + ->getTimestamp(); + + for ($i = 0; $i < 500; $i++) { + $resource = $resources[$i % count($resources)]; + $day = $start + intdiv($i, 17) * 86400; + $from = $day + (9 + ($i % 7)) * 3600; + + $this->em->persist(new ResourceOccupancy($resource, $from, $from + 1800)); + } + $this->em->flush(); + + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ + 'segments' => [[ + 'sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, + 'requirements' => [ + ['type_uuid' => $roomType->getUuid()], + ['type_uuid' => $operatorType->getUuid()], + ], + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $began = microtime(true); + + $body = $this->authJson('POST', '/api/v1/appointment-availability', $user, [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $address->getUuid(), + 'from' => $start, + 'to' => $start + 29 * 86400, + ]); + + $elapsedMs = (microtime(true) - $began) * 1000; + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertNotEmpty($body['data']['slots'], 'با ۲۰ منبع باید وقت پیدا شود'); + self::assertLessThan( + self::BUDGET_MS, + $elapsedMs, + sprintf('جستجوی ۳۰ روزه %.0f میلی‌ثانیه طول کشید (سقف %d)', $elapsedMs, self::BUDGET_MS), + ); + } + + private function makeResource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource + { + $resource = new ClinicResource($address, $type, $name); + $this->em->persist($resource); + + foreach (range(0, 6) as $day) { + $this->em->persist(new \App\Resource\Entity\ResourceCalendar($resource, $day, 540, 1020)); + } + + return $resource; + } +} diff --git a/tests/Shared/NumericFieldNormalizerTest.php b/tests/Shared/NumericFieldNormalizerTest.php index 6882f86f..f9390081 100644 --- a/tests/Shared/NumericFieldNormalizerTest.php +++ b/tests/Shared/NumericFieldNormalizerTest.php @@ -36,9 +36,13 @@ class NumericFieldNormalizerTest extends ApiTestCase $this->em->flush(); // db_test پاک نمی‌شود؛ شماره باید تازه باشد وگرنه endpoint با «تکراری» رد می‌کند. + // + // padding حتماً روی رقم لاتین انجام می‌شود و بعد تبدیل: `str_pad` بایتی است و + // با نویسهٔ سه‌بایتیِ «۰» عددِ کوتاه را به بایت‌های نیمه‌کاره می‌شکست. چون طول + // عدد تصادفی است، این تست گاهی سبز و گاهی ۴۲۲ می‌داد. do { - $persianMobile = '۰۹' . str_pad((string) random_int(0, 999_999_999), 9, '۰', STR_PAD_LEFT); - $latinMobile = PersianText::digits($persianMobile); + $latinMobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT); + $persianMobile = $this->toPersianDigits($latinMobile); } while ($this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $latinMobile]) !== null); // کد ملی هم همین‌طور. ثابت‌بودنش این تست را flaky می‌کرد: در اجرای کامل سوئیت، @@ -47,13 +51,8 @@ class NumericFieldNormalizerTest extends ApiTestCase // مثل شماره، یکتایی‌اش هم باید سنجیده شود: تصادفی‌بودن تنهایی کافی نیست و در // اجرای کامل سوئیت روی db_testِ انباشته، برخورد ۴۲۲ «تکراری» می‌داد. do { - $persianNationalCode = str_pad( - $this->toPersianDigits((string) random_int(0, 9_999_999_999)), - 10, - '۰', - STR_PAD_LEFT, - ); - $latinNationalCode = PersianText::digits($persianNationalCode); + $latinNationalCode = str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT); + $persianNationalCode = $this->toPersianDigits($latinNationalCode); } while ( $this->em->getRepository(DoctorSecretary::class) ->findOneBy(['nationalCode' => $latinNationalCode]) !== null