Writing the query-count test that task 14 owed showed the growth was real: one resource cost 10 queries, six cost 33 — about five per resource, because the available-minutes figure walked each resource's calendar on its own. Holidays, tenant overrides and branch hours are identical for every resource in a report, so they now load once outside the loop; shifts and exceptions load for all resources in one query each. The batched path is a new method rather than a change to rawAvailability, which the booking engine also calls. The test pins the shape of the growth, not an exact count. Also landed: - app:segment:seed-templates with beauty, dental and physio presets. Building four segments and their requirements by hand is the first thing a new clinic must do and the most tedious; this gives them something to edit instead of an empty page. It refuses to touch a service that already has segments unless --force, and it will not invent resource types the tenant never defined. - book-all is all-or-nothing, proven rather than asserted: with a calendar open one day a week and a 1-2 day protocol gap, session one finds a slot and session two cannot, and every session must come back planned. - credit_refundable: false takes the credit back with a negative adjustment and deletes nothing — the ledger stays append-only. - the segments editor has frontend tests, including that it sends back what the user sees and renders read-only without the permission. useBranches now returns [] for a non-array payload instead of throwing "branches.map is not a function" and taking the page down with it. BookingLocationsScanTest built a Clinic around a Doctor loaded from a different manager, which Doctrine treats as a new entity; it flushed fine most runs and failed on cascade in others. It now loads the doctor from the same manager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
4.9 KiB
PHP
132 lines
4.9 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* `next_available_at` scans ahead for the first free slot per location.
|
|
*
|
|
* The scan prefetches the schedule, holidays, overrides and taken appointments
|
|
* once per location and resolves the rest in memory, so its cost must not grow
|
|
* with the number of days walked or slots inspected — only with the number of
|
|
* locations, by a small constant.
|
|
*/
|
|
class BookingLocationsScanTest extends ApiTestCase
|
|
{
|
|
private function weekOfSessions(int $locationId, string $start, string $end): array
|
|
{
|
|
$day = ['sessions' => [[
|
|
'active' => true,
|
|
'location_id' => $locationId,
|
|
'start_time' => $start,
|
|
'end_time' => $end,
|
|
'duration_per_patient' => 20,
|
|
]]];
|
|
|
|
return array_fill_keys(array_map('strval', range(0, 6)), $day);
|
|
}
|
|
|
|
private function makeDoctorWithSchedules(int $clinicCount): Doctor
|
|
{
|
|
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($doctorUser, 'دکتر پرمحل');
|
|
$doctor->setMobileNumber($doctorUser->getMobileNumber());
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
$personalAddress = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($personalAddress);
|
|
$this->em->flush();
|
|
|
|
$this->em->persist($this->newWeeklySchedule(
|
|
$doctor,
|
|
$this->weekOfSessions($personalAddress->getId(), '09:00', '13:00')
|
|
));
|
|
|
|
for ($i = 0; $i < $clinicCount; $i++) {
|
|
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
|
$clinic->setName("کلینیک $i");
|
|
// پزشک را از همین EM میگیریم: اگر نمونهٔ دیگری باشد، Doctrine او را
|
|
// «موجودیت تازه» میبیند و flush با خطای cascade میشکند.
|
|
$clinic->getDoctors()->add($this->em->getRepository(Doctor::class)->find($doctor->getId()));
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
$this->em->persist($this->newWeeklySchedule(
|
|
$doctor,
|
|
$this->weekOfSessions($address->getId(), '16:00', '20:00'),
|
|
$clinic
|
|
));
|
|
}
|
|
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
public function testQueryCountGrowsOnlyPerLocation(): void
|
|
{
|
|
// Keep one kernel so the shared query logger stays consistent.
|
|
$this->client->disableReboot();
|
|
|
|
$few = $this->makeDoctorWithSchedules(1);
|
|
$many = $this->makeDoctorWithSchedules(5);
|
|
|
|
$qFew = $this->countQueries(fn () => $this->client->request(
|
|
'GET', '/api/v1/appointment-booking-locations/' . $few->getUuid()
|
|
));
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$qMany = $this->countQueries(fn () => $this->client->request(
|
|
'GET', '/api/v1/appointment-booking-locations/' . $many->getUuid()
|
|
));
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
// 2 locations -> 6 locations. Each extra one costs a fixed handful of
|
|
// queries (its schedule, holidays, overrides, blocking intervals,
|
|
// address). The regression this guards against is a per-day or per-slot
|
|
// query, which on a schedule active every day would add hundreds.
|
|
$extraLocations = 4;
|
|
$budgetEach = 8;
|
|
self::assertLessThanOrEqual(
|
|
$qFew + $extraLocations * $budgetEach,
|
|
$qMany,
|
|
"next_available_at scales badly: $qFew queries for 2 locations, $qMany for 6"
|
|
);
|
|
}
|
|
|
|
public function testNextAvailableIsReportedPerLocation(): void
|
|
{
|
|
$doctor = $this->makeDoctorWithSchedules(1);
|
|
|
|
$body = $this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid(), $doctor->getUser());
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$locations = $body['data']['booking_locations'] ?? [];
|
|
self::assertCount(2, $locations);
|
|
|
|
foreach ($locations as $location) {
|
|
self::assertNotNull(
|
|
$location['next_available_at'],
|
|
'a schedule active every day must expose a next free slot'
|
|
);
|
|
self::assertGreaterThanOrEqual(time(), $location['next_available_at']);
|
|
}
|
|
|
|
// Sorted by earliest opening — the site relies on booking_locations[0].
|
|
$starts = array_column($locations, 'next_available_at');
|
|
$sorted = $starts;
|
|
sort($sorted);
|
|
self::assertSame($sorted, $starts);
|
|
}
|
|
}
|