Two faults, one root: the per-context booking work updated ScheduleSection but left the rest of the panel calling slot endpoints without clinic_uuid. Absent clinic_uuid means the personal practice, so the panel asked about a schedule the doctor barely uses and got nothing back. - useClinicContext() resolves the current environment once and is used by the appointments page, useDoctorBookingServices, ServiceSlotPicker and both queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It returns null in a doctor's personal environment so the mirror-image bug — a doctor seeing the clinic's schedule at their own practice — cannot appear. clinicUuid is part of every query key; without it the cache leaks across environments. - appointment-slots returns empty_reason (no_schedule | holiday | day_off | outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day, which is what the bug report actually saw; it now says which of the four it is. - booking-locations lists a location only when the context has an address and an active shift points at it. The dev data had three "personal" schedules whose shifts referenced the clinic's address, so the public site advertised a personal practice that could never be booked. - ?date= adds available_on_date per location, validated as a real calendar date. - MyAppointmentsController and AdminApiController resolved the appointment address with no context and could store the wrong one. Both now go through the new BookingContextResolver, which also replaces AppointmentController's private copy of the same membership check. - app:schedule:audit-locations reports shifts pointing at a missing or foreign address; --fix deactivates them rather than deleting. Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions, with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu. Suite: 417 tests, 2 failures — both pre-existing and unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
7.6 KiB
PHP
199 lines
7.6 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;
|
|
|
|
/**
|
|
* یک «محل نوبتدهی» فقط وقتی وجود دارد که هم آدرس داشته باشد و هم شیفتی روی همان
|
|
* آدرس. برنامهای که به آدرسِ محیط دیگر (یا هیچ آدرسی) اشاره میکند قابل رزرو
|
|
* نیست و نباید به بیمار پیشنهاد شود.
|
|
*
|
|
* همچنین خالیبودن یک روز چهار دلیل متفاوت دارد و پنل باید بتواند تفکیکشان کند.
|
|
*/
|
|
class BookingLocationValidityTest extends ApiTestCase
|
|
{
|
|
private function makeDoctor(): Doctor
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($user, 'دکتر محل');
|
|
$doctor->setMobileNumber($user->getMobileNumber());
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
private function makeClinicWith(Doctor $doctor): Clinic
|
|
{
|
|
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
|
$clinic->setName('کلینیک محل');
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
return $clinic;
|
|
}
|
|
|
|
/** برنامهای که همهٔ روزها یک شیفت روی $locationId دارد. */
|
|
private function scheduleFor(Doctor $doctor, ?Clinic $clinic, ?int $locationId): WeeklySchedule
|
|
{
|
|
$day = ['sessions' => [array_filter([
|
|
'active' => true,
|
|
'location_id' => $locationId,
|
|
'start_time' => '09:00',
|
|
'end_time' => '13:00',
|
|
'duration_per_patient' => 20,
|
|
], fn($v) => $v !== null)]];
|
|
|
|
$schedule = new WeeklySchedule(
|
|
$doctor,
|
|
array_fill_keys(array_map('strval', range(0, 6)), $day),
|
|
$clinic
|
|
);
|
|
$this->em->persist($schedule);
|
|
$this->em->flush();
|
|
|
|
return $schedule;
|
|
}
|
|
|
|
private function locations(Doctor $doctor, string $query = ''): array
|
|
{
|
|
$body = $this->authJson(
|
|
'GET',
|
|
'/api/v1/appointment-booking-locations/' . $doctor->getUuid() . $query,
|
|
$doctor->getUser()
|
|
);
|
|
|
|
return $body['data']['booking_locations'] ?? [];
|
|
}
|
|
|
|
public function testLocationWithoutAnyAddressIsNotReturned(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$this->scheduleFor($doctor, null, null);
|
|
|
|
self::assertSame([], $this->locations($doctor), 'محلی که آدرس ندارد نباید محل به حساب بیاید');
|
|
}
|
|
|
|
public function testLocationWhoseShiftsPointOutsideItsContextIsNotReturned(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinicWith($doctor);
|
|
|
|
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
|
|
$personal = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($clinicAddress);
|
|
$this->em->persist($personal);
|
|
$this->em->flush();
|
|
|
|
// برنامهٔ شخصی که شیفتش روی آدرس کلینیک نشسته — دقیقاً حالتی که در dev دیده شد.
|
|
$this->scheduleFor($doctor, null, $clinicAddress->getId());
|
|
|
|
self::assertSame([], $this->locations($doctor));
|
|
}
|
|
|
|
public function testValidLocationIsReturnedWithItsOpeningHours(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$address = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
$this->scheduleFor($doctor, null, $address->getId());
|
|
|
|
$locations = $this->locations($doctor);
|
|
self::assertCount(1, $locations);
|
|
self::assertSame('personal', $locations[0]['type']);
|
|
self::assertSame($address->getUuid(), $locations[0]['location_uuid']);
|
|
self::assertCount(7, $locations[0]['opening_hours']);
|
|
self::assertSame($address->getId(), $locations[0]['opening_hours'][0]['location_id']);
|
|
}
|
|
|
|
public function testDateParameterReportsPerDayAvailability(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$address = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
// فقط شنبه (اندیس 0) شیفت فعال دارد.
|
|
$active = ['sessions' => [[
|
|
'active' => true, 'location_id' => $address->getId(),
|
|
'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20,
|
|
]]];
|
|
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
|
|
$setting['0'] = $active;
|
|
$this->em->persist(new WeeklySchedule($doctor, $setting));
|
|
$this->em->flush();
|
|
|
|
$saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday
|
|
$sunday = $this->nextWeekday(0);
|
|
|
|
$onSaturday = $this->locations($doctor, '?date=' . $saturday);
|
|
$onSunday = $this->locations($doctor, '?date=' . $sunday);
|
|
|
|
self::assertTrue($onSaturday[0]['available_on_date'] ?? null);
|
|
self::assertFalse($onSunday[0]['available_on_date'] ?? null);
|
|
}
|
|
|
|
public function testInvalidDateIsRejected(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
|
|
$this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid() . '?date=2026-13-99', $doctor->getUser());
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
public function testEmptyReasonDistinguishesTheCauses(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$today = date('Y-m-d');
|
|
|
|
// بدون هیچ برنامهای
|
|
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser());
|
|
self::assertSame('no_schedule', $body['data']['empty_reason'] ?? null);
|
|
|
|
// برنامه هست ولی این روز شیفت ندارد
|
|
$address = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
|
|
$this->em->persist(new WeeklySchedule($doctor, $setting));
|
|
$this->em->flush();
|
|
|
|
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser());
|
|
self::assertSame('day_off', $body['data']['empty_reason'] ?? null);
|
|
|
|
// تاریخ خارج از بازهٔ نوبتدهی
|
|
$farFuture = date('Y-m-d', strtotime('+2 years'));
|
|
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$farFuture}", $doctor->getUser());
|
|
self::assertSame('outside_window', $body['data']['empty_reason'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* اولین وقوعِ آن روز هفته در آینده. از فردا شروع میشود چون شیفتهای امروز
|
|
* ممکن است گذشته باشند و getAvailableSlots اسلات گذشته را برنمیگرداند —
|
|
* وگرنه تست بسته به ساعت اجرا نتیجهٔ متفاوت میدهد.
|
|
*
|
|
* @param int $phpDow خروجی date('w') — 0=یکشنبه ... 6=شنبه
|
|
*/
|
|
private function nextWeekday(int $phpDow): string
|
|
{
|
|
for ($i = 1; $i <= 7; $i++) {
|
|
$ts = strtotime("+{$i} day");
|
|
if ((int) date('w', $ts) === $phpDow) {
|
|
return date('Y-m-d', $ts);
|
|
}
|
|
}
|
|
|
|
return date('Y-m-d');
|
|
}
|
|
}
|