feat(availability): resource ordering strategies, and a real fix for the flaky suite

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 20:21:16 +03:30
co-authored by Claude Opus 5
parent 62f18b3c0d
commit aa6ea45a57
17 changed files with 811 additions and 6 deletions
@@ -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<int>
*/
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 === '') {