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