Files
clinicpro/tests/Appointment/BookingLocationValidityTest.php
T
hamedandClaude Opus 5 a0b4a969fc fix(appointment): one weekly schedule per doctor, place chosen per shift
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>
2026-08-20 17:20:53 +03:30

207 lines
8.1 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 = $this->newWeeklySchedule(
$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 testShiftDecidesItsPlaceByItsAddressNotByTheRecord(): 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();
$this->scheduleFor($doctor, null, $clinicAddress->getId());
$locations = $this->locations($doctor);
self::assertCount(1, $locations);
self::assertSame('clinic', $locations[0]['type']);
self::assertSame($clinicAddress->getUuid(), $locations[0]['location_uuid']);
}
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($this->newWeeklySchedule($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($this->newWeeklySchedule($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');
}
}