diff --git a/assets/admin/pages/WaitlistPage.tsx b/assets/admin/pages/WaitlistPage.tsx index a7b019d2..1f1c02b1 100644 --- a/assets/admin/pages/WaitlistPage.tsx +++ b/assets/admin/pages/WaitlistPage.tsx @@ -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 = { + morning: 'صبح', + afternoon: 'بعدازظهر', + evening: 'عصر', +}; + const STATUS: Record = { waiting: { label: 'در انتظار', className: 'badge' }, notified: { label: 'خبر داده شد', className: 'badge amber' }, @@ -53,7 +63,9 @@ export default function WaitlistPage() { header: 'زمان ترجیحی', render: (e) => ( - {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('، ')} ), }, diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index c1a452fe..b72649d9 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -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 diff --git a/docs/api/waitlist.md b/docs/api/waitlist.md index cefc815e..5b855477 100644 --- a/docs/api/waitlist.md +++ b/docs/api/waitlist.md @@ -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 # ۱۶ تست ``` diff --git a/src/Schedule.php b/src/Schedule.php index 47462393..ca52e170 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -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()) ); } } diff --git a/src/Waitlist/Command/ExpireWaitlistCommand.php b/src/Waitlist/Command/ExpireWaitlistCommand.php new file mode 100644 index 00000000..f222d1b2 --- /dev/null +++ b/src/Waitlist/Command/ExpireWaitlistCommand.php @@ -0,0 +1,32 @@ +expirer->expire(); + + $io->success(sprintf('%d ردیف لیست انتظار منقضی شد.', $count)); + + return Command::SUCCESS; + } +} diff --git a/src/Waitlist/Controller/WaitlistController.php b/src/Waitlist/Controller/WaitlistController.php index f9dab259..845825ca 100644 --- a/src/Waitlist/Controller/WaitlistController.php +++ b/src/Waitlist/Controller/WaitlistController.php @@ -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)) { diff --git a/src/Waitlist/Entity/WaitlistEntry.php b/src/Waitlist/Entity/WaitlistEntry.php index 3e58a34e..de549513 100644 --- a/src/Waitlist/Entity/WaitlistEntry.php +++ b/src/Waitlist/Entity/WaitlistEntry.php @@ -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 + */ + 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 $parts */ + /** + * @param list $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(); diff --git a/src/Waitlist/Message/ExpireWaitlistMessage.php b/src/Waitlist/Message/ExpireWaitlistMessage.php new file mode 100644 index 00000000..780448ce --- /dev/null +++ b/src/Waitlist/Message/ExpireWaitlistMessage.php @@ -0,0 +1,10 @@ +expirer->expire(); + } +} diff --git a/src/Waitlist/MessageHandler/WaitlistConversionHandler.php b/src/Waitlist/MessageHandler/WaitlistConversionHandler.php new file mode 100644 index 00000000..5c87c223 --- /dev/null +++ b/src/Waitlist/MessageHandler/WaitlistConversionHandler.php @@ -0,0 +1,49 @@ +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); + } + } +} diff --git a/src/Waitlist/Service/WaitlistConverter.php b/src/Waitlist/Service/WaitlistConverter.php new file mode 100644 index 00000000..f7bfd894 --- /dev/null +++ b/src/Waitlist/Service/WaitlistConverter.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/src/Waitlist/Service/WaitlistExpirer.php b/src/Waitlist/Service/WaitlistExpirer.php new file mode 100644 index 00000000..92ff3a3c --- /dev/null +++ b/src/Waitlist/Service/WaitlistExpirer.php @@ -0,0 +1,39 @@ += 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], + ); + } +} diff --git a/src/Waitlist/Service/WaitlistNotifier.php b/src/Waitlist/Service/WaitlistNotifier.php index 1925b1a6..f5547212 100644 --- a/src/Waitlist/Service/WaitlistNotifier.php +++ b/src/Waitlist/Service/WaitlistNotifier.php @@ -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(); diff --git a/tests/Waitlist/WaitlistTest.php b/tests/Waitlist/WaitlistTest.php index f885796c..659ecc42 100644 --- a/tests/Waitlist/WaitlistTest.php +++ b/tests/Waitlist/WaitlistTest.php @@ -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(); + } }