Files
clinicpro/src/Appointment/Entity/WeeklySchedule.php
T
hamedandClaude Opus 5 aa6ea45a57 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>
2026-07-31 20:21:16 +03:30

186 lines
8.1 KiB
PHP

<?php
namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\WeeklyScheduleRepository;
use Symfony\Component\Uid\Uuid;
/**
* برنامهٔ هفتگی نوبت‌دهی یک پزشک در یک context مشخص.
*
* context با ستون clinic_id بیان می‌شود: NULL یعنی مطب شخصی پزشک، و مقدار غیر-NULL
* یعنی همان پزشک در آن کلینیک. یک پزشک می‌تواند هم‌زمان چند برنامه داشته باشد
* (شخصی + یکی به ازای هر کلینیک) و این برنامه‌ها کاملاً مستقل‌اند.
*/
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
#[ORM\Table(name: 'weekly_schedules')]
// doctor_id در کلید می‌ماند: در یک کلینیک چند پزشک هستند و هر کدام برنامهٔ خودش را
// دارد، پس (entity_type, entity_id) به‌تنهایی برای پزشک دوم نقض یکتایی می‌سازد.
#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant', columns: ['doctor_id', 'entity_type', 'entity_id'])]
class WeeklySchedule
{
use TenantOwnedTrait;
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
public const META_KEY = 'meta';
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'];
public const DEFAULT_META = [
'online_booking_enabled' => true,
'booking_window_value' => 3,
'booking_window_unit' => 'month',
'booking_mode' => self::MODE_SLOT,
'buffer_minutes' => 0,
// فقط در حالت منبع‌محور معنا دارند؛ در بقیهٔ حالت‌ها خوانده نمی‌شوند.
'step_minutes' => 15,
'resource_strategy' => 'first_available',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
/** NULL = مطب شخصی پزشک. */
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;
#[ORM\Column(type: 'json')]
private array $setting = [];
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, array $setting, ?Clinic $clinic = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->clinic = $clinic;
$this->setting = $setting;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getClinic(): ?Clinic { return $this->clinic; }
/** فقط برای انتقال دستی برنامه‌های قدیمی به محیط کلینیک (app:schedule:assign-clinic). */
public function setClinic(?Clinic $clinic): self
{
$this->clinic = $clinic;
$this->updatedAt = time();
$this->assignTenant(EntityContext::forBooking($this->doctor, $clinic));
return $this;
}
public function getSetting(): array { return $this->setting; }
public function setSetting(array $setting): self
{
$meta = $this->setting[self::META_KEY] ?? null;
unset($setting[self::META_KEY]);
if ($meta !== null) {
$setting[self::META_KEY] = $meta;
}
$this->setting = $setting;
$this->updatedAt = time();
return $this;
}
public function getMeta(): array
{
return array_merge(self::DEFAULT_META, $this->setting[self::META_KEY] ?? []);
}
/**
* booking_mode ذخیره‌شده به‌صورت خام (بدون merge پیش‌فرض). null یعنی هنوز
* صریحاً ثبت نشده — تا وقتی null است، انتخاب روش قابل‌تغییر است؛ پس از اولین
* ثبت، قفل می‌شود.
*/
public function getStoredBookingMode(): ?string
{
return $this->setting[self::META_KEY]['booking_mode'] ?? null;
}
public function setMeta(array $meta): self
{
$current = $this->getMeta();
$this->setting[self::META_KEY] = [
'online_booking_enabled' => (bool)($meta['online_booking_enabled'] ?? $current['online_booking_enabled']),
'booking_window_value' => max(1, (int)($meta['booking_window_value'] ?? $current['booking_window_value'])),
'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::MODES, true)
? $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;
}
public function getDaySchedule(): array
{
$schedule = $this->setting;
unset($schedule[self::META_KEY]);
return $schedule;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'doctor_uuid' => $this->doctor->getUuid(),
'clinic_uuid' => $this->clinic?->getUuid(),
'context' => $this->clinic === null ? 'personal' : 'clinic',
'schedule' => $this->getDaySchedule(),
'meta' => $this->getMeta(),
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود (پنل توگل را غیرفعال می‌کند).
'booking_mode_locked' => $this->getStoredBookingMode() !== null,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}