feat(waitlist): make the day-part preference, the conversion and the expiry real

Three rows of task 13 were storing data nothing ever read.

`preferred_day_parts` was saved and displayed but never applied when matching.
It was deferred because "evening" has no fixed meaning — but branches already
carry a timezone (DoctorAddress::getTimezone), so the boundaries can be pinned:
morning [6,12), afternoon [12,17), evening [17,22), in the branch's local hour.
The list is now closed and validated; an unknown part is a 422 rather than a
preference that silently matches nothing. The filter runs *before* the cut to
ten recipients — otherwise the first ten slots go to people who did not want
that hour and the real eleventh person is never told.

`markConverted()` was dead code: nothing called it. It now runs off the
AppointmentBooked domain event rather than from inside BookingService, because
converting is a side effect of booking — inside the booking transaction a
waitlist error could roll back the patient's actual appointment. The match is
deliberately narrow (same patient, same service, start inside the window); a
loose match closes a row the patient is still waiting on. It is idempotent, so
redelivery is harmless.

Expiry now exists as a service, a daily scheduled message and
`app:waitlist:expire`. Expired rows were already excluded from matching, so
this is display hygiene, not a behaviour fix: without it the waitlist page
fills with dead entries and the operator cannot tell which are still live. It
sets a status rather than deleting — who waited and never got a slot is data.

Also: a waitlist window is capped at 90 days, matching the booking horizon. An
unbounded window is a row that never expires and shows up in every match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 13:33:26 +03:30
co-authored by Claude Opus 5
parent 913713f632
commit 4bba322b8e
14 changed files with 549 additions and 8 deletions
+5
View File
@@ -6,6 +6,7 @@ use App\Appointment\Message\ExpireAppointmentsMessage;
use App\Blog\Message\PublishScheduledBlogsMessage;
use App\Shared\Event\Message\PublishDomainEventsMessage;
use App\Shared\Logging\Message\PruneLogsMessage;
use App\Waitlist\Message\ExpireWaitlistMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
@@ -39,6 +40,10 @@ class Schedule implements ScheduleProviderInterface
// هرچه در فاصله جمع شده برود.
->add(
RecurringMessage::every('1 minute', new PublishDomainEventsMessage())
)
// انتظارهای مرده — پاکسازیِ نمایش، نه اصلاح رفتار (تطبیق از قبل ردشان می‌کرد).
->add(
RecurringMessage::every('1 day', new ExpireWaitlistMessage())
);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Waitlist\Command;
use App\Waitlist\Service\WaitlistExpirer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* اجرای دستیِ انقضای لیست انتظار؛ زمان‌بند هر روز همین کار را می‌کند.
*/
#[AsCommand(name: 'app:waitlist:expire', description: 'Close waitlist entries whose desired window has passed.')]
class ExpireWaitlistCommand extends Command
{
public function __construct(private readonly WaitlistExpirer $expirer)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = $this->expirer->expire();
$io->success(sprintf('%d ردیف لیست انتظار منقضی شد.', $count));
return Command::SUCCESS;
}
}
+24 -1
View File
@@ -26,6 +26,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class WaitlistController extends BaseController
{
/** همان افق رزرو تسک ۱۲؛ بازهٔ بلندتر یعنی ردیفی که هرگز خودش را پاک نمی‌کند. */
private const MAX_RANGE_DAYS = 90;
public function __construct(
private readonly WaitlistEntryRepository $entries,
private readonly ServiceItemRepository $items,
@@ -79,6 +82,17 @@ class WaitlistController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'desired_to');
}
// بازهٔ باز تا ابد یعنی ردیفی که هرگز منقضی نمی‌شود و برای همیشه در هر تطبیقی
// می‌آید؛ سقف همان افق رزرو است.
if ($to - $from > self::MAX_RANGE_DAYS * 86400) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بازهٔ انتظار حداکثر %d روز است', self::MAX_RANGE_DAYS),
422,
'desired_to',
);
}
$patient = $this->requirePatient($user, $data['patient_uuid']);
$service = $this->requireItem($user, $data['service_uuid']);
@@ -91,7 +105,16 @@ class WaitlistController extends BaseController
$entry = new WaitlistEntry($patient, $service, $from, $to, $branchId);
if (is_array($data['preferred_day_parts'] ?? null)) {
$entry->setPreferredDayParts($data['preferred_day_parts']);
try {
$entry->setPreferredDayParts($data['preferred_day_parts']);
} catch (\InvalidArgumentException) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بخش روز باید یکی از این‌ها باشد: %s', implode('، ', array_keys(WaitlistEntry::DAY_PARTS))),
422,
'preferred_day_parts',
);
}
}
if (is_numeric($data['priority'] ?? null)) {
+56 -2
View File
@@ -4,6 +4,7 @@ namespace App\Waitlist\Entity;
use App\Appointment\Entity\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Waitlist\Repository\WaitlistEntryRepository;
@@ -33,6 +34,21 @@ class WaitlistEntry
/** سقف اطلاع‌رسانی — بدون آن، یک بازهٔ پرلغو به منبع اسپم تبدیل می‌شود. */
public const MAX_NOTIFICATIONS = 3;
/**
* بخش‌های روز — فهرست **بسته**، با مرز ساعت محلیِ شعبه.
*
* مرزها اینجاست نه در UI: «عصر» باید در تطبیق و در نمایش یک معنا داشته باشد،
* وگرنه بیمار برای ساعتی خبر می‌شود که خودش رد کرده بود. `[start, end)` است تا
* ساعت ۱۲ دقیقاً یک‌بار شمرده شود.
*
* @var array<string, array{label: string, from: int, to: int}>
*/
public const DAY_PARTS = [
'morning' => ['label' => 'صبح', 'from' => 6, 'to' => 12],
'afternoon' => ['label' => 'بعدازظهر', 'from' => 12, 'to' => 17],
'evening' => ['label' => 'عصر', 'from' => 17, 'to' => 22],
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -120,10 +136,21 @@ class WaitlistEntry
public function getNotifiedAt(): ?int { return $this->notifiedAt; }
public function getNotifyCount(): int { return $this->notifyCount; }
/** @param list<string> $parts */
/**
* @param list<string> $parts
* @throws \InvalidArgumentException روی بخشی که در فهرست بسته نیست
*/
public function setPreferredDayParts(array $parts): self
{
$this->preferredDayParts = $parts === [] ? null : array_values(array_filter($parts, 'is_string'));
$clean = array_values(array_unique(array_filter($parts, 'is_string')));
foreach ($clean as $part) {
if (!isset(self::DAY_PARTS[$part])) {
throw new \InvalidArgumentException(sprintf('Unknown day part "%s".', $part));
}
}
$this->preferredDayParts = $clean === [] ? null : $clean;
return $this->touch();
}
@@ -169,6 +196,33 @@ class WaitlistEntry
return $start >= $this->desiredFrom && $start <= $this->desiredTo;
}
/**
* آیا این زمان در یکی از بخش‌های روزِ خواسته‌شده می‌افتد؟
*
* نداشتنِ ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». ساعت به وقت **محلی شعبه**
* حساب می‌شود، چون بیمار «عصر» را با ساعت خودش می‌فهمد نه با UTC.
*/
public function coversDayPart(int $start, string $timezone = DoctorAddress::DEFAULT_TIMEZONE): bool
{
if ($this->preferredDayParts === null || $this->preferredDayParts === []) {
return true;
}
$hour = (int) (new \DateTimeImmutable('@' . $start))
->setTimezone(new \DateTimeZone($timezone))
->format('G');
foreach ($this->preferredDayParts as $part) {
$range = self::DAY_PARTS[$part] ?? null;
if ($range !== null && $hour >= $range['from'] && $hour < $range['to']) {
return true;
}
}
return false;
}
private function touch(): self
{
$this->updatedAt = time();
@@ -0,0 +1,10 @@
<?php
namespace App\Waitlist\Message;
/**
* پیام نشانه‌ای روزانه — «چه ردیف‌هایی منقضی‌اند» از جدول خوانده می‌شود، نه از پیام.
*/
final class ExpireWaitlistMessage
{
}
@@ -0,0 +1,18 @@
<?php
namespace App\Waitlist\MessageHandler;
use App\Waitlist\Message\ExpireWaitlistMessage;
use App\Waitlist\Service\WaitlistExpirer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class ExpireWaitlistHandler
{
public function __construct(private readonly WaitlistExpirer $expirer) {}
public function __invoke(ExpireWaitlistMessage $message): void
{
$this->expirer->expire();
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Waitlist\MessageHandler;
use App\Appointment\Entity\Appointment;
use App\Shared\Event\DomainEvents;
use App\Shared\Event\Message\DomainEventMessage;
use App\Waitlist\Service\WaitlistConverter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* وقتی بیمار بالاخره رزرو کرد، ردیف انتظارش `converted` می‌شود.
*
* از رویداد می‌آید نه از خودِ `BookingService`: تبدیل، اثر جانبیِ رزرو است نه بخشی از
* آن، و اگر داخل تراکنش رزرو می‌نشست یک خطای لیست انتظار می‌توانست نوبت واقعی بیمار
* را برگرداند.
*
* **idempotent** است: `WaitlistConverter` ردیفِ از قبل تبدیل‌شده را رد می‌کند، پس تحویل
* دوبارهٔ پیام چیزی را خراب نمی‌کند.
*/
#[AsMessageHandler]
final class WaitlistConversionHandler
{
public function __construct(
private readonly WaitlistConverter $converter,
private readonly EntityManagerInterface $em,
) {}
public function __invoke(DomainEventMessage $message): void
{
if ($message->name !== DomainEvents::APPOINTMENT_BOOKED) {
return;
}
$uuid = $message->payload['appointment_uuid'] ?? null;
if (!is_string($uuid)) {
return;
}
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
// نوبتِ لغوشده بین انتشار و مصرف: چیزی برای تبدیل نمانده.
if ($appointment !== null) {
$this->converter->convertFor($appointment);
}
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Waitlist\Service;
use App\Appointment\Entity\Appointment;
use App\Patient\Repository\PatientRecordRepository;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* بستن ردیف انتظار وقتی همان بیمار همان سرویس را رزرو کرد.
*
* تطبیق عمداً **تنگ** است: همان بیمار، همان سرویس، و زمان نوبت داخل بازهٔ خواسته‌شده.
* تطبیق شل («هر انتظاری از این بیمار») ردیفی را می‌بندد که برای خدمت دیگری بود و بیمار
* هنوز منتظرش است — و او دیگر هرگز خبر نمی‌شود.
*/
final class WaitlistConverter
{
public function __construct(
private readonly WaitlistEntryRepository $entries,
private readonly PatientRecordRepository $patients,
private readonly EntityManagerInterface $em,
) {}
/**
* @return int تعداد ردیف‌هایی که بسته شد
*/
public function convertFor(Appointment $appointment): int
{
$service = $appointment->getServiceItem();
if ($service === null) {
return 0;
}
$patient = $this->patients->findOneBy([
'user' => $appointment->getUser(),
'entityType' => $appointment->getEntityType(),
'entityId' => $appointment->getEntityId(),
]);
if ($patient === null) {
return 0;
}
$converted = 0;
foreach ($this->entries->findForPatient($patient) as $entry) {
// ردیفِ بسته دوباره بسته نمی‌شود — همین idempotency تحویل دوبارهٔ پیام است.
if (!in_array($entry->getStatus(), [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED], true)) {
continue;
}
if ($entry->getServiceItem()->getId() !== $service->getId()) {
continue;
}
if (!$entry->covers($appointment->getSlotStart())) {
continue;
}
$entry->markConverted($appointment);
$converted++;
}
if ($converted > 0) {
$this->em->flush();
}
return $converted;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Waitlist\Service;
use App\Waitlist\Entity\WaitlistEntry;
use Doctrine\DBAL\Connection;
/**
* بستن ردیف‌هایی که بازهٔ خواسته‌شده‌شان گذشته است.
*
* ردیف منقضی از قبل هم در تطبیق نمی‌آمد (`desiredTo >= now`)، پس این پاکسازیِ **نمایش**
* است نه اصلاح رفتار: بدون آن، صفحهٔ لیست انتظار پر می‌شود از انتظارهای مرده و اپراتور
* نمی‌فهمد کدام‌شان هنوز زنده است.
*
* حذف نمی‌کند، وضعیت را عوض می‌کند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است.
*/
final class WaitlistExpirer
{
public function __construct(private readonly Connection $connection) {}
/**
* @return int تعداد ردیف‌های منقضی‌شده
*/
public function expire(?int $now = null): int
{
return (int) $this->connection->executeStatement(
'UPDATE waitlist_entries
SET status = :expired, updated_at = :now
WHERE status IN (:open)
AND desired_to < :now',
[
'expired' => WaitlistEntry::STATUS_EXPIRED,
'now' => $now ?? time(),
'open' => [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED],
],
['open' => \Doctrine\DBAL\ArrayParameterType::STRING],
);
}
}
+22
View File
@@ -3,6 +3,8 @@
namespace App\Waitlist\Service;
use App\Appointment\Entity\Appointment;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Representation\Service\JalaliDateService;
use App\Sms\Service\SmsService;
use App\Waitlist\Entity\WaitlistEntry;
@@ -27,6 +29,7 @@ final class WaitlistNotifier
public function __construct(
private readonly WaitlistEntryRepository $entries,
private readonly DoctorAddressRepository $addresses,
private readonly SmsService $sms,
private readonly JalaliDateService $jalali,
private readonly EntityManagerInterface $em,
@@ -50,6 +53,15 @@ final class WaitlistNotifier
$now,
);
$timezone = $this->timezoneOf($appointment->getAddressId());
// فیلتر بخش روز **قبل از** بریدن به ده نفر اعمال می‌شود، وگرنه ده جای اول را
// کسانی پر می‌کنند که این ساعت را نمی‌خواستند و نفر یازدهمِ واقعی خبر نمی‌شود.
$matches = array_values(array_filter(
$matches,
static fn (WaitlistEntry $e): bool => $e->coversDayPart($appointment->getSlotStart(), $timezone),
));
$notified = 0;
foreach (array_slice($matches, 0, self::MAX_RECIPIENTS) as $entry) {
@@ -68,6 +80,16 @@ final class WaitlistNotifier
return $notified;
}
/** شعبهٔ ناشناخته به منطقهٔ زمانی پیش‌فرض برمی‌گردد؛ نبودِ شعبه نباید تطبیق را بخواباند. */
private function timezoneOf(?int $addressId): string
{
if ($addressId === null) {
return DoctorAddress::DEFAULT_TIMEZONE;
}
return $this->addresses->find($addressId)?->getTimezone() ?? DoctorAddress::DEFAULT_TIMEZONE;
}
private function notify(WaitlistEntry $entry, int $slotStart): void
{
$mobile = $entry->getPatientRecord()->getUser()->getMobileNumber();