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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user