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
+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();
}
}