A doctor working both at their own practice and at a clinic had to write two independent schedules and neither panel could see the other, so the clinic showed an empty form even though the doctor had configured their practice. The schedule is now a single record owned by the doctor. What varies between days is the place: the context of a shift is read from its location_id, not from the record it lives in. Booking in a context therefore sees only that context's days, so a personal-practice secretary still cannot book a clinic day. The caller's own context decides which addresses they may assign: the doctor gets every place of theirs, a clinic manager only its own, and shifts outside their reach are returned for display but preserved verbatim on save. Existing per-clinic rows are merged by migration; location_id was already stored on every shift, so no context information is lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
5.2 KiB
PHP
131 lines
5.2 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();
|
|
|
|
// برنامه یکی است و شیفتِ همهٔ محلها داخل همان مینشیند؛ محیطِ هر شیفت از
|
|
// آدرسش خوانده میشود، نه از رکورد جدا.
|
|
$setting = $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();
|
|
|
|
$clinicWeek = $this->weekOfSessions($address->getId(), '16:00', '20:00');
|
|
foreach ($clinicWeek as $dayKey => $day) {
|
|
$setting[$dayKey]['sessions'] = array_merge($setting[$dayKey]['sessions'], $day['sessions']);
|
|
}
|
|
}
|
|
|
|
$this->em->persist($this->newWeeklySchedule($doctor, $setting));
|
|
$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);
|
|
}
|
|
}
|