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()); + } +}