From aa6ea45a5767b2cd3e270b7fd3cbefc8feaf0344 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 31 Jul 2026 20:21:16 +0330 Subject: [PATCH] feat(availability): resource ordering strategies, and a real fix for the flaky suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strategies (task 06 debt, task 12 dependency) - ResourcePicker orders candidates; it deliberately does not choose. Only the engine knows which resource actually fits this slot and which was already taken by another role, and a strategy that picked would have to duplicate both checks - Four implementations behind a tagged iterator: first_available (name order, the previous behaviour and still the default because it is predictable), least_gap, least_loaded, same_as_previous - least_gap and least_loaded are deliberate opposites and both are correct; choosing between them is a business decision, so it lives in settings - same_as_previous lifts a course's preferred resource to the front and keeps everyone else behind it. A preference, not a filter: forcing the same operator would make the patient wait two weeks, which is worse than a different operator - Availability accepts course_uuid to supply that preference, closing the dependency task 12 recorded against task 06 - An unknown strategy falls back at search time but is rejected at save time. Stale settings must not stop bookings; a user typing a wrong value must not believe it took effect Test suite flake createUser() retries on a mobile-number collision — db_test is never reset and holds tens of thousands of users, so the random draw does collide. The failed INSERT closes the EntityManager, and the retry asked the container for it again, which hands back the *same closed instance*. So the retry threw, and every later test in that process inherited a dead manager. That is the intermittent "EntityManager is closed" on an unrelated, always-different test that made roughly half of full runs red and never reproduced in a subset. Resetting the registry gives a live manager back. UserCollisionRetryTest pins it by closing the manager on purpose. Two consecutive full runs are green: 1334 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/schedule/ScheduleSection.tsx | 34 +++ config/services.yaml | 6 + docs/api/appointment-availability.md | 50 ++++ .../Controller/AvailabilityController.php | 73 +++++- .../Picker/FirstAvailablePicker.php | 28 +++ .../Availability/Picker/LeastGapPicker.php | 61 +++++ .../Availability/Picker/LeastLoadedPicker.php | 45 ++++ .../Availability/Picker/PickContext.php | 43 ++++ .../Availability/Picker/ResourcePicker.php | 27 +++ .../Picker/ResourcePickerRegistry.php | 48 ++++ .../Picker/SameAsPreviousPicker.php | 45 ++++ .../Service/AvailabilityEngine.php | 28 ++- .../AppointmentSettingsController.php | 40 ++++ src/Appointment/Entity/WeeklySchedule.php | 10 + tests/ApiTestCase.php | 9 +- tests/Appointment/ResourcePickerTest.php | 217 ++++++++++++++++++ tests/Shared/UserCollisionRetryTest.php | 53 +++++ 17 files changed, 811 insertions(+), 6 deletions(-) create mode 100644 src/Appointment/Availability/Picker/FirstAvailablePicker.php create mode 100644 src/Appointment/Availability/Picker/LeastGapPicker.php create mode 100644 src/Appointment/Availability/Picker/LeastLoadedPicker.php create mode 100644 src/Appointment/Availability/Picker/PickContext.php create mode 100644 src/Appointment/Availability/Picker/ResourcePicker.php create mode 100644 src/Appointment/Availability/Picker/ResourcePickerRegistry.php create mode 100644 src/Appointment/Availability/Picker/SameAsPreviousPicker.php create mode 100644 tests/Appointment/ResourcePickerTest.php create mode 100644 tests/Shared/UserCollisionRetryTest.php diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 32f9b2f7..8f705de4 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -62,6 +62,8 @@ interface BookingMeta { booking_mode: 'slot' | 'service' | 'resource'; /** گام جستجوی وقت در حالت منبع‌محور — دقیقه */ step_minutes?: number; + /** ترتیب امتحان‌کردن منابع در حالت منبع‌محور */ + resource_strategy?: string; buffer_minutes: number; } type BookingWindowUnit = 'day' | 'week' | 'month'; @@ -559,6 +561,19 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly * درخواست اضافه روی هر بازکردن تنظیمات، برای چیزی که اکثر کلینیک‌ها انتخابش * نمی‌کنند، هزینهٔ بی‌دلیل است. */ + /** فهرست استراتژی‌ها از سرور می‌آید، نه از فهرستی که در فرانت تکرار شود. */ + const strategiesQ = useQuery({ + queryKey: ['resource-strategies'], + queryFn: () => + api.get>( + '/api/v1/appointment-settings/resource-strategies', + ), + enabled: meta.booking_mode === 'resource', + staleTime: 300_000, + }); + + const strategies = strategiesQ.data?.data ?? []; + const readinessQ = useQuery({ queryKey: ['resource-mode-readiness'], queryFn: async () => { @@ -796,6 +811,25 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly )} + {meta.booking_mode === 'resource' && strategies.length > 0 && ( +
+ ترتیب انتخاب منابع +
+ + setMeta(m => ({ ...m, resource_strategy: String(v ?? 'first_available') })) + } + options={strategies.map(s => ({ value: s.code, label: s.label }))} + /> +
+

+ این فقط ترتیب امتحان‌کردن منابع را عوض می‌کند؛ اگر منبعی آزاد نباشد در هر + حالت رد می‌شود و وقت از دست نمی‌رود. +

+
+ )} + {meta.booking_mode === 'resource' && (
گام جستجوی وقت diff --git a/config/services.yaml b/config/services.yaml index 5a892271..e1c93b6b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,6 +30,12 @@ services: App\Branch\Service\RoomDeletionGuardInterface: tags: ['app.room_deletion_guard'] + # Ordering strategies for resource assignment (task 06). The engine asks the + # registry by code, so adding a strategy means adding one class — nothing + # in the engine or the settings controller changes. + App\Appointment\Availability\Picker\ResourcePicker: + tags: ['app.resource_picker'] + # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: diff --git a/docs/api/appointment-availability.md b/docs/api/appointment-availability.md index 0587e5e0..fbb9d774 100644 --- a/docs/api/appointment-availability.md +++ b/docs/api/appointment-availability.md @@ -139,3 +139,53 @@ ddev exec php bin/phpunit tests/Appointment/AvailabilityEngineTest.php # ۹ تست ddev exec php bin/phpunit tests/Appointment/AvailabilityPerformanceTest.php ``` + +--- + +## استراتژی ترتیب منابع + +وقتی چند منبع برای یک نقش واجد شرایط‌اند، **ترتیب امتحان‌کردنشان** از تنظیمات محیط +می‌آید (`meta.resource_strategy`). استراتژی فقط مرتب می‌کند؛ تصمیم نهایی همچنان با +موتور است، چون فقط موتور می‌داند کدام منبع در این زمان جا دارد و کدام برای نقش دیگری +برداشته شده. + +| کلید | رفتار | کِی مناسب است | +|---|---|---| +| `first_available` | ترتیب نام (پیش‌فرض) | خروجی کاملاً قابل پیش‌بینی | +| `least_gap` | کمترین وقت مردهٔ باقی‌مانده | تقویم کمتر تکه‌تکه شود | +| `least_loaded` | منبعِ آزادتر زودتر | بار بین چند اپراتور پخش شود | +| `same_as_previous` | منبع ترجیحی جلو، بقیه پشت آن | دورهٔ درمان با همان اپراتور | + +`least_gap` و `least_loaded` عکس هم عمل می‌کنند و **هر دو درست‌اند**؛ انتخاب بینشان +تصمیم کسب‌وکاری است نه فنی. + +### ترجیح منبع دوره + +`POST /api/v1/appointment-availability` یک فیلد اختیاری `course_uuid` می‌گیرد. با آن، +`preferred_resource` همان دوره به بالای فهرست می‌رود. + +**ترجیح است نه فیلتر:** اگر آن منبع آزاد نباشد، رزرو رد نمی‌شود و به ترتیب پایه +برمی‌گردد — اجبار یعنی بیمار دو هفته منتظر بماند، و آن بدتر از عوض شدن اپراتور است. + +### فهرست استراتژی‌ها + +`GET /api/v1/appointment-settings/resource-strategies` + +```json +{ + "success": true, + "data": [ + { "code": "first_available", "label": "به ترتیب نام — ساده و قابل پیش‌بینی" }, + { "code": "least_gap", "label": "کمترین وقت مرده — تقویم کمتر تکه‌تکه می‌شود" } + ] +} +``` + +انتخابگر پنل از همین ساخته می‌شود؛ افزودن استراتژی تازه یعنی افزودن **یک کلاس** با تگ +`app.resource_picker` — نه تغییر موتور، نه تغییر فرانت. + +### رفتار با کلید ناشناخته + +هنگام **جستجو** به پیش‌فرض برمی‌گردد (تنظیماتِ قدیمی نباید نوبت‌دهی را بخواباند)، ولی +هنگام **ذخیرهٔ تنظیمات** `422` می‌گیرد — وگرنه کاربر فکر می‌کند استراتژی‌اش اعمال +می‌شود در حالی که نمی‌شود. diff --git a/src/Appointment/Availability/Controller/AvailabilityController.php b/src/Appointment/Availability/Controller/AvailabilityController.php index 8dfe3872..eecfa159 100644 --- a/src/Appointment/Availability/Controller/AvailabilityController.php +++ b/src/Appointment/Availability/Controller/AvailabilityController.php @@ -40,6 +40,8 @@ class AvailabilityController extends BaseController private readonly WeeklyScheduleRepository $schedules, private readonly DoctorRepository $doctors, private readonly BranchResolver $branches, + private readonly \App\Course\Repository\TreatmentCourseRepository $courses, + private readonly \App\Appointment\Availability\Picker\ResourcePickerRegistry $pickers, ) {} #[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])] @@ -102,7 +104,16 @@ class AvailabilityController extends BaseController ? (int) $data['step_minutes'] : AvailabilityEngine::DEFAULT_STEP_MINUTES; - $slots = $this->engine->search($plan, $address, $from, $to, $step); + $slots = $this->engine->search( + $plan, + $address, + $from, + $to, + $step, + null, + $this->strategyFor($data['doctor_uuid'] ?? null), + $this->preferredResourceIds($user, $data['course_uuid'] ?? null), + ); return $this->success([ 'plan' => $plan->toArray(), @@ -155,6 +166,66 @@ class AvailabilityController extends BaseController * * @throws AppException */ + /** + * استراتژی ترتیب منابع از تنظیمات همان محل می‌آید. + * + * کلید ناشناخته یا نبودِ برنامهٔ هفتگی به پیش‌فرض برمی‌گردد — تنظیماتِ ناقص نباید + * جستجوی وقت را بخواباند. + */ + private function strategyFor(mixed $doctorUuid): ?string + { + if (!is_string($doctorUuid) || $doctorUuid === '') { + return null; + } + + $doctor = $this->doctors->findOneBy(['uuid' => $doctorUuid]); + + if ($doctor === null) { + return null; + } + + foreach ($this->schedules->findAllByDoctor($doctor) as $schedule) { + $meta = $schedule->getMeta(); + + if (($meta['booking_mode'] ?? null) === WeeklySchedule::MODE_RESOURCE) { + return is_string($meta['resource_strategy'] ?? null) ? $meta['resource_strategy'] : null; + } + } + + return null; + } + + /** + * منبع ترجیحیِ یک دورهٔ درمان — «همان اپراتور جلسهٔ قبل». + * + * ترجیح است نه فیلتر: اگر آزاد نباشد، استراتژی به ترتیب پایه برمی‌گردد و رزرو + * انجام می‌شود. اجبار یعنی بیمار دو هفته منتظر بماند. + * + * @return list + */ + private function preferredResourceIds(User $user, mixed $courseUuid): array + { + if (!is_string($courseUuid) || $courseUuid === '') { + return []; + } + + $course = $this->courses->findByUuid($courseUuid); + + if ($course === null) { + return []; + } + + [$entityType, $entityId] = $this->branches->pair($user); + + if ($course->getEntityType() !== $entityType || $course->getEntityId() !== $entityId) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404); + } + + $preferred = $course->getPreferredResource(); + + return $preferred === null ? [] : [(int) $preferred->getId()]; + } + private function assertResourceMode(mixed $doctorUuid, DoctorAddress $address): void { if (!is_string($doctorUuid) || $doctorUuid === '') { diff --git a/src/Appointment/Availability/Picker/FirstAvailablePicker.php b/src/Appointment/Availability/Picker/FirstAvailablePicker.php new file mode 100644 index 00000000..027d572e --- /dev/null +++ b/src/Appointment/Availability/Picker/FirstAvailablePicker.php @@ -0,0 +1,28 @@ + strcmp($a->getName(), $b->getName())); + + return $ordered; + } +} diff --git a/src/Appointment/Availability/Picker/LeastGapPicker.php b/src/Appointment/Availability/Picker/LeastGapPicker.php new file mode 100644 index 00000000..b8e66634 --- /dev/null +++ b/src/Appointment/Availability/Picker/LeastGapPicker.php @@ -0,0 +1,61 @@ + $this->wasteOf($a, $context) <=> $this->wasteOf($b, $context), + ); + + return $ordered; + } + + /** + * دقایقی از پنجرهٔ میزبان که بعد از این نوبت هدر می‌رود. + * + * منبعی که هیچ پنجرهٔ پوشش‌دهنده‌ای ندارد بزرگ‌ترین عدد می‌گیرد و ته صف می‌رود — + * حذفش اینجا کار موتور است نه استراتژی. + */ + private function wasteOf(ClinicResource $resource, PickContext $context): int + { + $windows = $context->freeWindows[(int) $resource->getId()] ?? []; + $start = $context->neededStart(); + $end = $context->neededEnd(); + + $best = PHP_INT_MAX; + + foreach ($windows as $window) { + if ($window->start > $start || $window->end < $end) { + continue; + } + + $best = min($best, ($start - $window->start) + ($window->end - $end)); + } + + return $best; + } +} diff --git a/src/Appointment/Availability/Picker/LeastLoadedPicker.php b/src/Appointment/Availability/Picker/LeastLoadedPicker.php new file mode 100644 index 00000000..d9cc6d84 --- /dev/null +++ b/src/Appointment/Availability/Picker/LeastLoadedPicker.php @@ -0,0 +1,45 @@ + $this->freeMinutes($b, $context) <=> $this->freeMinutes($a, $context), + ); + + return $ordered; + } + + private function freeMinutes(ClinicResource $resource, PickContext $context): int + { + $windows = $context->freeWindows[(int) $resource->getId()] ?? []; + + return array_sum(array_map( + static fn (TimeInterval $w): int => intdiv($w->end - $w->start, 60), + $windows, + )); + } +} diff --git a/src/Appointment/Availability/Picker/PickContext.php b/src/Appointment/Availability/Picker/PickContext.php new file mode 100644 index 00000000..b65ded89 --- /dev/null +++ b/src/Appointment/Availability/Picker/PickContext.php @@ -0,0 +1,43 @@ +> $freeWindows شناسهٔ منبع => پنجره‌های آزاد + * @param list $needed بازه‌هایی که این نقش لازم دارد + * @param list $preferredResourceIds ترجیح — نه الزام + */ + public function __construct( + public array $freeWindows, + public array $needed, + public int $slotStart, + public array $preferredResourceIds = [], + ) {} + + /** زودترین لحظه‌ای که این نقش لازم دارد. */ + public function neededStart(): int + { + $starts = array_map(static fn (TimeInterval $i): int => $i->start, $this->needed); + + return $starts === [] ? $this->slotStart : min($starts); + } + + /** دیرترین لحظه‌ای که این نقش لازم دارد. */ + public function neededEnd(): int + { + $ends = array_map(static fn (TimeInterval $i): int => $i->end, $this->needed); + + return $ends === [] ? $this->slotStart : max($ends); + } +} diff --git a/src/Appointment/Availability/Picker/ResourcePicker.php b/src/Appointment/Availability/Picker/ResourcePicker.php new file mode 100644 index 00000000..22b1a65f --- /dev/null +++ b/src/Appointment/Availability/Picker/ResourcePicker.php @@ -0,0 +1,27 @@ + $candidates + * @return list همان مجموعه، با ترتیب تازه + */ + public function order(array $candidates, PickContext $context): array; +} diff --git a/src/Appointment/Availability/Picker/ResourcePickerRegistry.php b/src/Appointment/Availability/Picker/ResourcePickerRegistry.php new file mode 100644 index 00000000..c83199df --- /dev/null +++ b/src/Appointment/Availability/Picker/ResourcePickerRegistry.php @@ -0,0 +1,48 @@ + */ + private array $byCode = []; + + /** @param iterable $pickers */ + public function __construct( + #[AutowireIterator('app.resource_picker')] iterable $pickers, + ) { + foreach ($pickers as $picker) { + $this->byCode[$picker::code()] = $picker; + } + } + + public function get(?string $code): ResourcePicker + { + return $this->byCode[$code ?? ''] ?? $this->byCode[FirstAvailablePicker::code()]; + } + + public function has(string $code): bool + { + return isset($this->byCode[$code]); + } + + /** @return list ورودی انتخابگر پنل */ + public function describe(): array + { + $out = []; + + foreach ($this->byCode as $code => $picker) { + $out[] = ['code' => $code, 'label' => $picker::label()]; + } + + return $out; + } +} diff --git a/src/Appointment/Availability/Picker/SameAsPreviousPicker.php b/src/Appointment/Availability/Picker/SameAsPreviousPicker.php new file mode 100644 index 00000000..797030fb --- /dev/null +++ b/src/Appointment/Availability/Picker/SameAsPreviousPicker.php @@ -0,0 +1,45 @@ +fallback->order($candidates, $context); + + if ($context->preferredResourceIds === []) { + return $base; + } + + $preferred = []; + $rest = []; + + foreach ($base as $candidate) { + if (in_array((int) $candidate->getId(), $context->preferredResourceIds, true)) { + $preferred[] = $candidate; + } else { + $rest[] = $candidate; + } + } + + return [...$preferred, ...$rest]; + } +} diff --git a/src/Appointment/Availability/Service/AvailabilityEngine.php b/src/Appointment/Availability/Service/AvailabilityEngine.php index f48176ed..6df15e84 100644 --- a/src/Appointment/Availability/Service/AvailabilityEngine.php +++ b/src/Appointment/Availability/Service/AvailabilityEngine.php @@ -5,6 +5,8 @@ namespace App\Appointment\Availability\Service; use App\Appointment\Availability\Repository\ResourceOccupancyRepository; use App\Appointment\Availability\ValueObject\AvailableSlot; use App\Appointment\Availability\ValueObject\SlotAssignment; +use App\Appointment\Availability\Picker\PickContext; +use App\Appointment\Availability\Picker\ResourcePickerRegistry; use App\Appointment\Plan\ValueObject\AppointmentPlan; use App\Appointment\Plan\ValueObject\PlannedRequirement; use App\Appointment\Plan\ValueObject\PlannedSegment; @@ -46,6 +48,7 @@ final class AvailabilityEngine public function __construct( private readonly ResourceAvailabilityService $calendars, private readonly ResourceOccupancyRepository $occupancy, + private readonly ResourcePickerRegistry $pickers, ) {} /** @@ -58,6 +61,8 @@ final class AvailabilityEngine int $to, int $stepMinutes = self::DEFAULT_STEP_MINUTES, ?int $now = null, + ?string $strategy = null, + array $preferredResourceIds = [], ): array { $now = $now ?? time(); $step = max(5, $stepMinutes) * 60; @@ -73,8 +78,10 @@ final class AvailabilityEngine $free = $this->freeWindows($roles, $address, $from, $to); $slots = []; + $picker = $this->pickers->get($strategy); + foreach ($this->candidateStarts($roles, $free, $from, $to, $step, $now) as $start) { - $assignment = $this->assign($plan, $roles, $free, $start); + $assignment = $this->assign($plan, $roles, $free, $start, $picker, $preferredResourceIds); if ($assignment === null) { continue; @@ -220,8 +227,14 @@ final class AvailabilityEngine * @param array}> $roles * @param array> $free */ - private function assign(AppointmentPlan $plan, array $roles, array $free, int $start): ?SlotAssignment - { + private function assign( + AppointmentPlan $plan, + array $roles, + array $free, + int $start, + \App\Appointment\Availability\Picker\ResourcePicker $picker, + array $preferredResourceIds, + ): ?SlotAssignment { $chosen = []; $taken = []; @@ -245,7 +258,14 @@ final class AvailabilityEngine $needed = TimeInterval::mergeAll($needed); $picked = []; - foreach ($requirement->eligible as $resource) { + // استراتژی فقط **ترتیب** را تعیین می‌کند؛ شرط جا داشتن و برداشته‌نشدن + // همچنان اینجاست، چون فقط موتور هر دو را می‌داند. + $ordered = $picker->order( + array_values($requirement->eligible), + new PickContext($free, $needed, $start, $preferredResourceIds), + ); + + foreach ($ordered as $resource) { $id = (int) $resource->getId(); if (isset($taken[$id])) { diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 36700934..97ce10c0 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -39,6 +39,7 @@ class AppointmentSettingsController extends BaseController private readonly ClinicRepository $clinicRepo, private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo, + private readonly \App\Appointment\Availability\Picker\ResourcePickerRegistry $pickers, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, ) {} @@ -108,6 +109,37 @@ class AppointmentSettingsController extends BaseController return $this->resourceRepo->countActiveForPair($type, (int) $id) === 0; } + /** + * استراتژی ناشناخته هنگام **ذخیره** رد می‌شود، نه هنگام اجرا. + * + * موتور در زمان جستجو به پیش‌فرض برمی‌گردد تا تنظیماتِ قدیمی نوبت‌دهی را نخواباند؛ + * ولی کاربری که همین حالا مقدار غلط می‌فرستد باید بداند، وگرنه فکر می‌کند + * استراتژی‌اش اعمال می‌شود در حالی که نمی‌شود. + */ + private function invalidStrategy(array $meta): bool + { + $code = $meta['resource_strategy'] ?? null; + + return is_string($code) && $code !== '' && !$this->pickers->has($code); + } + + private function invalidStrategyError(): JsonResponse + { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + 'استراتژی انتخاب منبع شناخته نمی‌شود', + 422, + 'resource_strategy', + ); + } + + /** فهرست استراتژی‌های موجود — ورودی انتخابگر پنل، نه فهرستی که در فرانت تکرار شود. */ + #[Route('/api/v1/appointment-settings/resource-strategies', name: 'resource_strategies', methods: ['GET'])] + public function resourceStrategies(): JsonResponse + { + return $this->success($this->pickers->describe()); + } + private function noResourceError(): JsonResponse { return $this->error( @@ -191,6 +223,10 @@ class AppointmentSettingsController extends BaseController return $err; } + if ($this->invalidStrategy($schedule->getMeta())) { + return $this->invalidStrategyError(); + } + if ($this->resourceModeHasNoResources($schedule->getMeta(), $doctor, $clinic)) { return $this->noResourceError(); } @@ -245,6 +281,10 @@ class AppointmentSettingsController extends BaseController return $err; } + if ($this->invalidStrategy($schedule->getMeta())) { + return $this->invalidStrategyError(); + } + if ($this->resourceModeHasNoResources($schedule->getMeta(), $schedule->getDoctor(), $clinic)) { return $this->noResourceError(); } diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index 4e3da4b9..41f89764 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -52,6 +52,9 @@ class WeeklySchedule 'booking_window_unit' => 'month', 'booking_mode' => self::MODE_SLOT, 'buffer_minutes' => 0, + // فقط در حالت منبع‌محور معنا دارند؛ در بقیهٔ حالت‌ها خوانده نمی‌شوند. + 'step_minutes' => 15, + 'resource_strategy' => 'first_available', ]; #[ORM\Id] @@ -145,6 +148,13 @@ class WeeklySchedule ? $meta['booking_mode'] : $current['booking_mode'], 'buffer_minutes' => max(0, (int)($meta['buffer_minutes'] ?? $current['buffer_minutes'])), + // گام کمتر از پنج دقیقه، جستجو را بی‌دلیل سنگین می‌کند بی‌آنکه وقت تازه‌ای پیدا شود. + 'step_minutes' => max(5, (int)($meta['step_minutes'] ?? $current['step_minutes'])), + // اعتبارِ کلید در کنترلر سنجیده می‌شود؛ اینجا فقط نگه داشته می‌شود تا + // مقدارِ ناشناخته بی‌صدا به پیش‌فرض تبدیل نشود و کاربر خطایش را ببیند. + 'resource_strategy' => is_string($meta['resource_strategy'] ?? null) && $meta['resource_strategy'] !== '' + ? $meta['resource_strategy'] + : $current['resource_strategy'], ]; $this->updatedAt = time(); return $this; diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index 9780093c..4575c1d1 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -89,7 +89,14 @@ abstract class ApiTestCase extends WebTestCase if ($attempt >= 4) { throw $e; } - // The failed INSERT closed the EntityManager; reopen before retrying. + + // The failed INSERT closes the EntityManager, and asking the container + // for it again hands back the *same closed instance* — Doctrine only + // builds a fresh one when the registry is reset. Without this the retry + // throws EntityManagerClosed, and every later test in the same process + // inherits a dead manager: the intermittent, always-somewhere-else + // failure that made full runs flaky. + static::getContainer()->get('doctrine')->resetManager(); $this->em = static::getContainer()->get(EntityManagerInterface::class); } } diff --git a/tests/Appointment/ResourcePickerTest.php b/tests/Appointment/ResourcePickerTest.php new file mode 100644 index 00000000..50a596b1 --- /dev/null +++ b/tests/Appointment/ResourcePickerTest.php @@ -0,0 +1,217 @@ +em->persist($resource); + $this->em->flush(); + + return $resource; + } + + /** @return array{0: DoctorAddress, 1: ResourceType} */ + private function branch(): array + { + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new \App\Clinic\Entity\Clinic($user); + $clinic->setName('کلینیک استراتژی ' . (++$this->idCursor)); + $this->em->persist($clinic); + $this->em->flush(); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبه'); + $this->em->persist($address); + + $type = new ResourceType('clinic', (int) $clinic->getId(), 'operator', 'اپراتور'); + $this->em->persist($type); + $this->em->flush(); + + return [$address, $type]; + } + + private function namesOf(array $ordered): array + { + return array_map(static fn (ClinicResource $r): string => $r->getName(), $ordered); + } + + public function testFirstAvailableOrdersByName(): void + { + [$address, $type] = $this->branch(); + + $candidates = [ + $this->resource($address, $type, 'ج'), + $this->resource($address, $type, 'الف'), + $this->resource($address, $type, 'ب'), + ]; + + $ordered = (new FirstAvailablePicker())->order( + $candidates, + new PickContext([], [], time()), + ); + + self::assertSame(['الف', 'ب', 'ج'], $this->namesOf($ordered)); + } + + /** ⭐ منبعی که کمترین وقت مرده جا می‌گذارد، اول امتحان می‌شود. */ + public function testLeastGapPrefersTheTightestWindow(): void + { + [$address, $type] = $this->branch(); + + $tight = $this->resource($address, $type, 'دقیق'); + $loose = $this->resource($address, $type, 'گشاد'); + + $start = 1_800_000_000; + $end = $start + 3600; + + $free = [ + // پنجرهٔ دقیقاً به اندازهٔ نوبت — هیچ وقتی هدر نمی‌رود. + (int) $tight->getId() => [new TimeInterval($start, $end)], + // پنجرهٔ چهارساعته — سه ساعت خالی می‌ماند. + (int) $loose->getId() => [new TimeInterval($start - 3600, $end + 7200)], + ]; + + $ordered = (new LeastGapPicker())->order( + [$loose, $tight], + new PickContext($free, [new TimeInterval($start, $end)], $start), + ); + + self::assertSame(['دقیق', 'گشاد'], $this->namesOf($ordered)); + } + + /** پخش بار عکسِ کمترین‌شکاف عمل می‌کند — و هر دو درست‌اند. */ + public function testLeastLoadedPrefersTheEmptiestResource(): void + { + [$address, $type] = $this->branch(); + + $busy = $this->resource($address, $type, 'پرکار'); + $free = $this->resource($address, $type, 'خلوت'); + + $start = 1_800_000_000; + + $windows = [ + (int) $busy->getId() => [new TimeInterval($start, $start + 3600)], + (int) $free->getId() => [new TimeInterval($start, $start + 6 * 3600)], + ]; + + $ordered = (new LeastLoadedPicker())->order( + [$busy, $free], + new PickContext($windows, [new TimeInterval($start, $start + 1800)], $start), + ); + + self::assertSame(['خلوت', 'پرکار'], $this->namesOf($ordered)); + } + + /** ⭐ ترجیح است نه فیلتر: منبع ترجیحی جلو می‌آید، بقیه حذف نمی‌شوند. */ + public function testSameAsPreviousLiftsThePreferredResourceWithoutDroppingOthers(): void + { + [$address, $type] = $this->branch(); + + $other = $this->resource($address, $type, 'اپراتور دیگر'); + $preferred = $this->resource($address, $type, 'اپراتور جلسهٔ قبل'); + + $start = 1_800_000_000; + + $ordered = (new SameAsPreviousPicker())->order( + [$other, $preferred], + new PickContext([], [new TimeInterval($start, $start + 1800)], $start, [(int) $preferred->getId()]), + ); + + self::assertSame(['اپراتور جلسهٔ قبل', 'اپراتور دیگر'], $this->namesOf($ordered)); + self::assertCount(2, $ordered, 'هیچ کاندیدی نباید حذف شود'); + } + + /** بدون ترجیح، همان ترتیب استراتژی پایه می‌ماند. */ + public function testSameAsPreviousFallsBackWhenNothingIsPreferred(): void + { + [$address, $type] = $this->branch(); + + $a = $this->resource($address, $type, 'الف'); + $b = $this->resource($address, $type, 'ب'); + + $start = 1_800_000_000; + + $ordered = (new SameAsPreviousPicker())->order( + [$a, $b], + new PickContext([], [new TimeInterval($start, $start + 1800)], $start), + ); + + self::assertCount(2, $ordered); + } + + // ── رجیستری ───────────────────────────────────────────────────────────── + + public function testRegistryResolvesEveryStrategy(): void + { + $registry = static::getContainer()->get(ResourcePickerRegistry::class); + + foreach (['first_available', 'least_gap', 'least_loaded', 'same_as_previous'] as $code) { + self::assertTrue($registry->has($code), $code); + self::assertSame($code, $registry->get($code)::code()); + } + + self::assertCount(4, $registry->describe()); + } + + /** ⭐ کلید ناشناخته نوبت‌دهی را نمی‌خواباند؛ به پیش‌فرض برمی‌گردد. */ + public function testAnUnknownStrategyFallsBackToTheDefault(): void + { + $registry = static::getContainer()->get(ResourcePickerRegistry::class); + + self::assertSame('first_available', $registry->get('nonsense')::code()); + self::assertSame('first_available', $registry->get(null)::code()); + } + + /** ولی هنگام **ذخیره** رد می‌شود، وگرنه کاربر فکر می‌کند اعمال شده. */ + public function testSavingAnUnknownStrategyIsRejected(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($owner, 'دکتر استراتژی'); + $this->em->persist($doctor); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [], + 'meta' => ['booking_mode' => 'slot', 'resource_strategy' => 'nonsense'], + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testStrategyListIsExposedForThePanel(): void + { + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + + $body = $this->authJson('GET', '/api/v1/appointment-settings/resource-strategies', $user); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertCount(4, $body['data']); + self::assertContains('least_gap', array_column($body['data'], 'code')); + self::assertNotEmpty($body['data'][0]['label']); + } +} diff --git a/tests/Shared/UserCollisionRetryTest.php b/tests/Shared/UserCollisionRetryTest.php new file mode 100644 index 00000000..97731b4e --- /dev/null +++ b/tests/Shared/UserCollisionRetryTest.php @@ -0,0 +1,53 @@ +em; + + // بستن عمدی: درج تکراری روی کلید یکتای موبایل. + $existing = $this->createUser(['ROLE_USER']); + + try { + $clash = new User($existing->getMobileNumber()); + $clash->setRoles(['ROLE_USER']); + $clash->setStatus(1); + $this->em->persist($clash); + $this->em->flush(); + + self::fail('درج تکراری باید شکست بخورد'); + } catch (\Throwable) { + // همان چیزی که در قرعهٔ تصادفی اتفاق می‌افتد. + } + + self::assertFalse($before->isOpen(), 'INSERT شکست‌خورده باید EntityManager را ببندد'); + + static::getContainer()->get('doctrine')->resetManager(); + $this->em = static::getContainer()->get(EntityManagerInterface::class); + + // کانتینر یک proxy برمی‌گرداند، پس هویت شیء عوض نمی‌شود؛ چیزی که اهمیت دارد + // این است که پشتِ همان proxy یک EntityManager **باز** نشسته باشد. + self::assertTrue($this->em->isOpen(), 'پس از ریست رجیستری باید مدیر باز باشد'); + + // و کار عادی باید ادامه پیدا کند. + $fresh = $this->createUser(['ROLE_USER']); + self::assertNotNull($fresh->getId()); + } +}