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 === '') {
@@ -0,0 +1,28 @@
<?php
namespace App\Appointment\Availability\Picker;
use App\Resource\Entity\ClinicResource;
/**
* ترتیب نام — همان رفتاری که موتور از روز اول داشت.
*
* پیش‌فرض است چون تنها استراتژی‌ای است که خروجی‌اش کاملاً قابل پیش‌بینی است: کلینیکی
* که هنوز تصمیم نگرفته، نباید تخصیصش هر روز عوض شود.
*/
final class FirstAvailablePicker implements ResourcePicker
{
public static function code(): string { return 'first_available'; }
public static function label(): string { return 'به ترتیب نام — ساده و قابل پیش‌بینی'; }
public function order(array $candidates, PickContext $context): array
{
$ordered = $candidates;
usort($ordered, static fn (ClinicResource $a, ClinicResource $b): int
=> strcmp($a->getName(), $b->getName()));
return $ordered;
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Appointment\Availability\Picker;
use App\Resource\Entity\ClinicResource;
use App\Shared\Time\TimeInterval;
/**
* منبعی که کمترین وقتِ مرده را جا می‌گذارد.
*
* اگر اپراتوری از ساعت ۱۰ تا ۱۲ آزاد است و نوبت ۱۰ تا ۱۱ می‌خواهد، یک ساعت خالی
* می‌ماند؛ اپراتوری که از ۱۰ تا ۱۱ آزاد است هیچ. دومی انتخاب می‌شود تا پنجره‌های
* بزرگ‌تر برای نوبت‌های طولانی‌تر باقی بمانند.
*
* این همان چیزی است که «تکه‌تکه شدن تقویم» را کم می‌کند — بدون آن، هر رزرو یک شکاف
* غیرقابل‌فروش وسط روز باز می‌کند.
*/
final class LeastGapPicker implements ResourcePicker
{
public static function code(): string { return 'least_gap'; }
public static function label(): string { return 'کمترین وقت مرده — تقویم کمتر تکه‌تکه می‌شود'; }
public function order(array $candidates, PickContext $context): array
{
$ordered = $candidates;
usort(
$ordered,
fn (ClinicResource $a, ClinicResource $b): int
=> $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;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Appointment\Availability\Picker;
use App\Resource\Entity\ClinicResource;
use App\Shared\Time\TimeInterval;
/**
* منبعی که بیشترین وقت آزاد را دارد — پخش بار.
*
* برخلاف `least_gap` که تقویم را فشرده می‌کند، این یکی عمداً پخش می‌کند: وقتی سه
* اپراتور هم‌ارزند، کلینیکی که نمی‌خواهد یکی‌شان تمام روز کار کند و دو تا بیکار
* بمانند همین را می‌خواهد.
*
* هر دو درست‌اند و انتخاب بینشان تصمیم کسب‌وکاری است، نه فنی.
*/
final class LeastLoadedPicker implements ResourcePicker
{
public static function code(): string { return 'least_loaded'; }
public static function label(): string { return 'پخش بار — منبعِ آزادتر زودتر'; }
public function order(array $candidates, PickContext $context): array
{
$ordered = $candidates;
usort(
$ordered,
fn (ClinicResource $a, ClinicResource $b): int
=> $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,
));
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Appointment\Availability\Picker;
use App\Shared\Time\TimeInterval;
/**
* هر چیزی که یک استراتژی برای مرتب‌کردن کاندیدها لازم دارد.
*
* عمداً readonly و بدون دسترسی به دیتابیس: استراتژی باید تابع خالصی از داده‌ای باشد
* که موتور از قبل خوانده — وگرنه هر استراتژی تازه یک کوئری تازه داخل حلقهٔ کاندید
* می‌آورد و همان چیزی را می‌شکند که تسک ۰۶ برایش بودجهٔ نیم‌ثانیه گذاشت.
*/
final readonly class PickContext
{
/**
* @param array<int, list<TimeInterval>> $freeWindows شناسهٔ منبع => پنجره‌های آزاد
* @param list<TimeInterval> $needed بازه‌هایی که این نقش لازم دارد
* @param list<int> $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);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Appointment\Availability\Picker;
use App\Resource\Entity\ClinicResource;
/**
* ترتیبی که کاندیدهای یک نقش امتحان می‌شوند.
*
* استراتژی **انتخاب نمی‌کند، مرتب می‌کند**. تصمیم نهایی همچنان با موتور است، چون فقط
* موتور می‌داند کدام منبع در این زمان جا دارد و کدام قبلاً برای نقش دیگری برداشته شده.
* استراتژی‌ای که خودش انتخاب کند، مجبور است همان منطق را تکرار کند.
*/
interface ResourcePicker
{
/** کلید پایدار — در تنظیمات محیط ذخیره می‌شود. */
public static function code(): string;
/** برچسب فارسی برای پنل. */
public static function label(): string;
/**
* @param list<ClinicResource> $candidates
* @return list<ClinicResource> همان مجموعه، با ترتیب تازه
*/
public function order(array $candidates, PickContext $context): array;
}
@@ -0,0 +1,48 @@
<?php
namespace App\Appointment\Availability\Picker;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
/**
* حل استراتژی از روی کلیدِ ذخیره‌شده در تنظیمات محیط.
*
* کلید ناشناخته **خطا نمی‌دهد** و به پیش‌فرض برمی‌گردد: تنظیماتی که با نسخهٔ قدیمی
* ذخیره شده نباید نوبت‌دهی یک کلینیک را بخواباند. اعتبارسنجی جای خودش هنگام ذخیره است.
*/
final class ResourcePickerRegistry
{
/** @var array<string, ResourcePicker> */
private array $byCode = [];
/** @param iterable<ResourcePicker> $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<array{code: string, label: string}> ورودی انتخابگر پنل */
public function describe(): array
{
$out = [];
foreach ($this->byCode as $code => $picker) {
$out[] = ['code' => $code, 'label' => $picker::label()];
}
return $out;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Appointment\Availability\Picker;
use App\Resource\Entity\ClinicResource;
/**
* همان منبعی که جلسهٔ قبل بود — ترجیح، نه الزام.
*
* بیمار دورهٔ لیزر ترجیح می‌دهد هر هشت جلسه را با همان اپراتور بگذراند. ولی اگر آن
* اپراتور آزاد نباشد، **رزرو رد نمی‌شود**: به ترتیب پایه برمی‌گردد. اجبار به همان
* منبع یعنی بیمار دو هفته منتظر بماند، و آن بدتر از عوض شدن اپراتور است.
*/
final class SameAsPreviousPicker implements ResourcePicker
{
public function __construct(
private readonly ResourcePicker $fallback = new LeastGapPicker(),
) {}
public static function code(): string { return 'same_as_previous'; }
public static function label(): string { return 'همان منبع جلسهٔ قبل — اگر آزاد باشد'; }
public function order(array $candidates, PickContext $context): array
{
$base = $this->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];
}
}
@@ -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<string, array{requirement: PlannedRequirement, windows: list<array{offset:int,duration:int}>}> $roles
* @param array<int, list<TimeInterval>> $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])) {
@@ -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();
}
+10
View File
@@ -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;