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>
464 lines
19 KiB
PHP
464 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Waitlist;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Auth\Entity\User;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Patient\Entity\PatientRecord;
|
|
use App\Tests\ApiTestCase;
|
|
use App\Waitlist\Entity\WaitlistEntry;
|
|
use App\Waitlist\Repository\WaitlistEntryRepository;
|
|
use App\Waitlist\Service\WaitlistNotifier;
|
|
|
|
/**
|
|
* لیست انتظار — تسک ۱۳.
|
|
*
|
|
* تصمیم معماری: ظرفیت آزادشده **به همه** خبر داده میشود و اولین رزروکننده میبرد.
|
|
* صف انحصاری یعنی وقتی که کسی جوابش را نمیدهد نیم ساعت قفل بماند، و ظرفیتِ دو ساعت
|
|
* مانده به نوبت آن نیم ساعت را ندارد.
|
|
*/
|
|
class WaitlistTest extends ApiTestCase
|
|
{
|
|
private int $slotCursor = 0;
|
|
|
|
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
|
private function clinicWithPatient(): array
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
|
$clinic = new Clinic($user);
|
|
$clinic->setName('کلینیک انتظار');
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
|
|
$this->em->persist($section);
|
|
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$address->setName('شعبهٔ مرکزی');
|
|
$this->em->persist($address);
|
|
|
|
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($doctorUser, 'دکتر انتظار');
|
|
$this->em->persist($doctor);
|
|
|
|
$patientUser = $this->createUser(['ROLE_USER']);
|
|
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
|
$this->em->persist($patient);
|
|
$this->em->flush();
|
|
|
|
return [$user, $section, $address, $doctor, $patient];
|
|
}
|
|
|
|
private function extraPatient(int $clinicId): PatientRecord
|
|
{
|
|
$user = $this->createUser(['ROLE_USER']);
|
|
$patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId);
|
|
$this->em->persist($patient);
|
|
$this->em->flush();
|
|
|
|
return $patient;
|
|
}
|
|
|
|
private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
|
|
{
|
|
$item = new ServiceItem($section, $name);
|
|
$item->setSoloDurationMinutes(30);
|
|
$item->setPriceRials(4_000_000);
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
return $item;
|
|
}
|
|
|
|
/** @param array<string, mixed> $extra */
|
|
private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array
|
|
{
|
|
$body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [
|
|
'patient_uuid' => $patient->getUuid(),
|
|
'service_uuid' => $service->getUuid(),
|
|
'desired_from' => $from,
|
|
'desired_to' => $to,
|
|
]);
|
|
|
|
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
|
|
return $body['data'];
|
|
}
|
|
|
|
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment
|
|
{
|
|
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
|
|
|
$start += (++$this->slotCursor) * 60;
|
|
|
|
$appointment = new Appointment(
|
|
$em->getRepository(Doctor::class)->find($doctor->getId()),
|
|
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
|
|
$start,
|
|
$start + 1800,
|
|
);
|
|
$appointment->assignTenantPair('clinic', (int) $address->getClinicId());
|
|
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
|
|
$appointment->setAddressId($address->getId());
|
|
$appointment->setPatientName('بیمار نوبت');
|
|
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
|
|
$em->persist($appointment);
|
|
$em->flush();
|
|
|
|
return $appointment;
|
|
}
|
|
|
|
private function notifier(): WaitlistNotifier
|
|
{
|
|
return static::getContainer()->get(WaitlistNotifier::class);
|
|
}
|
|
|
|
// ── ثبت ─────────────────────────────────────────────────────────────────
|
|
|
|
public function testJoiningTheWaitlistStoresTheWindow(): void
|
|
{
|
|
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
|
$service = $this->service($section);
|
|
|
|
$from = time() + 86400;
|
|
$to = $from + 3 * 86400;
|
|
$entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]);
|
|
|
|
self::assertSame('waiting', $entry['status']);
|
|
self::assertSame($from, $entry['desired_from']);
|
|
self::assertSame(['evening'], $entry['preferred_day_parts']);
|
|
self::assertSame(0, $entry['notify_count']);
|
|
|
|
$list = $this->authJson('GET', '/api/v1/waitlist', $user);
|
|
self::assertCount(1, $list['data']);
|
|
}
|
|
|
|
/** انتظار برای بازهٔ گذشته هرگز به نتیجه نمیرسد. */
|
|
public function testAPastWindowIsRejected(): 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() - 5 * 86400,
|
|
'desired_to' => time() - 86400,
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
public function testAnInvertedWindowIsRejected(): 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() + 5 * 86400,
|
|
'desired_to' => time() + 86400,
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
// ── تطبیق و اطلاع ───────────────────────────────────────────────────────
|
|
|
|
public function testMatchesFindsEveryoneWaitingForThatMoment(): void
|
|
{
|
|
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
|
|
$service = $this->service($section);
|
|
|
|
$from = time() + 86400;
|
|
$to = $from + 3 * 86400;
|
|
|
|
$this->join($user, $patient, $service, $from, $to);
|
|
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
|
|
|
|
// کسی که بازهاش پوشش نمیدهد نباید بیاید.
|
|
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400);
|
|
|
|
$start = $from + 3600;
|
|
$matches = $this->authJson(
|
|
'GET',
|
|
sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start),
|
|
$user,
|
|
);
|
|
|
|
self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE));
|
|
self::assertCount(2, $matches['data']);
|
|
}
|
|
|
|
/** ⭐ همه خبر میشوند — نه فقط نفر اول. */
|
|
public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void
|
|
{
|
|
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
|
$service = $this->service($section);
|
|
|
|
$slot = time() + 2 * 86400;
|
|
$from = $slot - 86400;
|
|
$to = $slot + 86400;
|
|
|
|
$first = $this->join($user, $patient, $service, $from, $to);
|
|
$second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
|
|
|
|
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
|
|
|
|
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
|
|
|
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
self::assertSame(2, $body['data']['waitlist_notified']);
|
|
|
|
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
|
|
|
|
foreach ([$first['uuid'], $second['uuid']] as $uuid) {
|
|
$entry = $repo->findByUuid($uuid);
|
|
self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus());
|
|
self::assertNotNull($entry->getNotifiedAt());
|
|
self::assertSame(1, $entry->getNotifyCount());
|
|
}
|
|
}
|
|
|
|
/** سقف اطلاعرسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمیکند. */
|
|
public function testNotificationsStopAtTheCap(): void
|
|
{
|
|
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
|
$service = $this->service($section);
|
|
|
|
$slot = time() + 2 * 86400;
|
|
$entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400);
|
|
|
|
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
|
|
|
|
for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) {
|
|
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
|
|
$this->notifier()->notifyForFreedSlot($appointment);
|
|
}
|
|
|
|
self::assertSame(
|
|
WaitlistEntry::MAX_NOTIFICATIONS,
|
|
$repo->findByUuid($entry['uuid'])->getNotifyCount(),
|
|
);
|
|
}
|
|
|
|
/** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمیشود. */
|
|
public function testAnEntryForAnotherBranchIsNotNotified(): void
|
|
{
|
|
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
|
$service = $this->service($section);
|
|
|
|
$otherBranch = DoctorAddress::forClinic((int) $address->getClinicId());
|
|
$otherBranch->setName('شعبهٔ دوم');
|
|
$this->em->persist($otherBranch);
|
|
$this->em->flush();
|
|
|
|
$slot = time() + 2 * 86400;
|
|
|
|
$this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [
|
|
'branch_uuid' => $otherBranch->getUuid(),
|
|
]);
|
|
|
|
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
|
|
|
|
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
|
|
}
|
|
|
|
public function testDeletingAnEntryRemovesItFromTheList(): void
|
|
{
|
|
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
|
$service = $this->service($section);
|
|
|
|
$entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400);
|
|
|
|
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']);
|
|
}
|
|
|
|
public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void
|
|
{
|
|
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
|
[$other] = $this->clinicWithPatient();
|
|
|
|
$service = $this->service($section);
|
|
$entry = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400);
|
|
|
|
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']);
|
|
|
|
$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();
|
|
}
|
|
}
|