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
+13 -1
View File
@@ -9,6 +9,16 @@ import { usePermissions } from '../hooks/usePermissions';
import { useWaitlist } from '../hooks/useCancellation';
import type { WaitlistEntry } from '../types';
/**
* مرزهای ساعتش در بک‌اند است (`WaitlistEntry::DAY_PARTS`)؛ اینجا فقط ترجمهٔ نمایش.
* تعریف مرز در دو جا یعنی روزی که یکی عوض شود، بیمار برای ساعتی خبر شود که رد کرده بود.
*/
const DAY_PART_LABELS: Record<string, string> = {
morning: 'صبح',
afternoon: 'بعدازظهر',
evening: 'عصر',
};
const STATUS: Record<WaitlistEntry['status'], { label: string; className: string }> = {
waiting: { label: 'در انتظار', className: 'badge' },
notified: { label: 'خبر داده شد', className: 'badge amber' },
@@ -53,7 +63,9 @@ export default function WaitlistPage() {
header: 'زمان ترجیحی',
render: (e) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{e.preferred_day_parts.length === 0 ? 'بی‌تفاوت' : e.preferred_day_parts.join('، ')}
{e.preferred_day_parts.length === 0
? 'بی‌تفاوت'
: e.preferred_day_parts.map((p) => DAY_PART_LABELS[p] ?? p).join('، ')}
</span>
),
},
+1
View File
@@ -24,6 +24,7 @@ framework:
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
'App\Shared\Event\Message\PublishDomainEventsMessage': scheduler_default
'App\Waitlist\Message\ExpireWaitlistMessage': scheduler_default
# مصرف‌کنندهٔ رویداد async است، وگرنه یک consumer کند خودِ تخلیهٔ صندوق را
# کند می‌کند و شکستش ردیفی را «ناموفق» علامت می‌زند که در واقع تحویل شده بود.
'App\Shared\Event\Message\DomainEventMessage': async
+44 -4
View File
@@ -57,15 +57,15 @@
|---|---|---|---|
| `patient_uuid` | string | ✅ | |
| `service_uuid` | string | ✅ | |
| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده باشد |
| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده و حداکثر **۹۰ روز** باشد |
| `branch_uuid` | string | — | نبودنش یعنی «هر شعبه» |
| `preferred_day_parts` | string[] | — | `["morning","evening"]` |
| `preferred_day_parts` | string[] | — | فهرست **بسته**: `morning` · `afternoon` · `evening` |
| `priority` | int | — | بزرگ‌تر زودتر خبر می‌شود |
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته یا پایانِ قبل از شروع |
| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته · پایانِ قبل از شروع · بازهٔ بیش از ۹۰ روز · بخش روز ناشناخته |
| `ERR_VALIDATION_002` | 422 | فیلد الزامی غایب |
| `ERR_NOT_FOUND_001` | 404 | بیمار یا سرویس خارج از محیط جاری |
@@ -84,6 +84,46 @@
---
## بخش روز — کجا معنا می‌شود
مرزها در `WaitlistEntry::DAY_PARTS` است و **در تطبیق اعمال می‌شود**، نه فقط ذخیره:
| کلید | برچسب | ساعت محلی |
|---|---|---|
| `morning` | صبح | `[۶, ۱۲)` |
| `afternoon` | بعدازظهر | `[۱۲, ۱۷)` |
| `evening` | عصر | `[۱۷, ۲۲)` |
ساعت به وقت **محلی شعبه** (`DoctorAddress::getTimezone()`) حساب می‌شود؛ بیمار «عصر» را
با ساعت خودش می‌فهمد نه با UTC. شعبهٔ نامشخص به `Asia/Tehran` برمی‌گردد.
فیلتر **پیش از** بریدن به ده نفر اجرا می‌شود — وگرنه ده جای اول را کسانی پر می‌کنند که
این ساعت را نمی‌خواستند و نفر یازدهمِ واقعی خبر نمی‌شود. نداشتن ترجیح یعنی «هر ساعتی».
## چرخهٔ عمر ردیف
| گذار | کِی |
|---|---|
| `waiting → notified` | اطلاع‌رسانی هنگام آزاد شدن ظرفیت (سقف ۳ بار) |
| `* → converted` | همان بیمار **همان سرویس** را در بازهٔ خواسته‌شده رزرو کرد |
| `* → expired` | `desired_to` گذشت |
`converted` از رویداد `AppointmentBooked` می‌آید، نه از داخل `BookingService`: تبدیل اثر
جانبیِ رزرو است و اگر داخل تراکنش رزرو می‌نشست، یک خطای لیست انتظار می‌توانست نوبت واقعی
بیمار را برگرداند. تطبیق عمداً تنگ است (همان بیمار + همان سرویس + زمان داخل بازه)؛ تطبیق
شل، ردیفی را می‌بندد که برای خدمت دیگری بود و بیمار دیگر هرگز خبر نمی‌شود. **idempotent**
است، پس تحویل دوبارهٔ پیام بی‌خطر است.
انقضا هر روز با زمان‌بند (`ExpireWaitlistMessage`) یا دستی:
```bash
ddev exec php bin/console app:waitlist:expire
```
ردیف منقضی از قبل هم در تطبیق نمی‌آمد (`desiredTo >= now`)، پس این پاکسازیِ **نمایش**
است: بدون آن صفحهٔ لیست انتظار پر می‌شود از انتظارهای مرده. حذف نمی‌کند، وضعیت را عوض
می‌کند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است.
## اطلاع خودکار هنگام لغو
`POST /api/v1/appointment/{uuid}/cancel` بعد از آزادسازی ظرفیت، لیست انتظار را خبر
@@ -93,5 +133,5 @@
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Waitlist # ۹ تست
ddev exec php bin/phpunit tests/Waitlist # ۱۶ تست
```
+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();
+163
View File
@@ -297,4 +297,167 @@ class WaitlistTest extends ApiTestCase
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other);
self::assertSame(404, $this->responseCode());
}
// ── بخش روز ─────────────────────────────────────────────────────────────
/**
* ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره.
*
* ذخیره‌کردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار
* فکر می‌کند سیستم حرفش را شنیده.
*/
public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$morning = $this->localHour(9);
$this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [
'preferred_day_parts' => ['evening'],
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $morning);
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$evening = $this->localHour(19);
$this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [
'preferred_day_parts' => ['evening'],
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $evening);
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
}
/** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */
public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$dawn = $this->localHour(5);
$this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400);
$appointment = $this->appointment($doctor, $patient, $service, $address, $dawn);
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testAnUnknownDayPartIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() + 86400,
'desired_to' => time() + 3 * 86400,
'preferred_day_parts' => ['midnight'],
]);
self::assertSame(422, $this->responseCode());
}
// ── مرزها و چرخهٔ عمر ───────────────────────────────────────────────────
public function testARangeLongerThanNinetyDaysIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => $from,
'desired_to' => $from + 91 * 86400,
]);
self::assertSame(422, $this->responseCode());
}
/**
* ⭐ ردیفِ منقضی از قبل هم در تطبیق نمی‌آمد؛ این پاکسازیِ **نمایش** است تا اپراتور
* بفهمد کدام انتظار هنوز زنده است.
*/
public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400);
$dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400);
// بازهٔ ردیف دوم را به گذشته می‌بریم — API عمداً بازهٔ گذشته را نمی‌پذیرد.
$this->em->getConnection()->executeStatement(
'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?',
[time() - 10 * 86400, time() - 86400, $dead['uuid']],
);
$expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire();
self::assertSame(1, $expired);
// خواندن مستقیم از دیتابیس: `expire()` با SQL خام می‌نویسد، پس هر نقشهٔ هویتِ
// باز، نسخهٔ کهنه را برمی‌گرداند.
self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid']));
self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid']));
}
/**
* ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای
* چیزی که هنوز منتظرش است دیگر هرگز خبر نمی‌شود.
*/
public function testBookingConvertsOnlyTheMatchingEntry(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$other = $this->service($section, 'بوتاکس');
$start = time() + 2 * 86400;
$mine = $this->join($user, $patient, $service, $start - 86400, $start + 86400);
$unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400);
$appointment = $this->appointment($doctor, $patient, $service, $address, $start);
$converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class);
self::assertSame(1, $converter->convertFor($appointment));
// اجرای دوباره چیزی را دوباره نمی‌بندد — تحویل دوبارهٔ پیام بی‌خطر است.
self::assertSame(0, $converter->convertFor($appointment));
$this->em->clear();
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus());
self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus());
}
private function statusOf(string $uuid): string
{
return (string) $this->em->getConnection()->fetchOne(
'SELECT status FROM waitlist_entries WHERE uuid = ?',
[$uuid],
);
}
/** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */
private function localHour(int $hour): int
{
return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE)))
->setTime($hour, 0)
->getTimestamp();
}
}