fix(booking): aggregate public booking state across all schedules

The public doctor payload built `active`/`free_turn`/`hours_of_work` from the
personal schedule alone, so a doctor bookable only at a clinic was reported as
"نوبت‌دهی غیرفعال". Aggregate over every schedule instead: any schedule with
online booking on and an active day makes the doctor bookable, and the disabled
label only appears when all of them are off.

Three admin-panel fixes for the same class of bug:

- AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's
  uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`.
- TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری
  ندارد". Errors now surface as errors and unknown reasons get a neutral message;
  the day-off wording is reserved for an explicit day_off from the backend.
- Admins have no clinic context, so slots fell back to the personal schedule.
  They now pick a location from `appointment-booking-locations` and that choice
  drives the slot, service and create-appointment requests.

Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list
covering only Saturday, which read as day-off for the rest of the week.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 16:25:24 +03:30
co-authored by Claude Fable 5
parent 63bf2cdd12
commit 7baa4df3d4
15 changed files with 631 additions and 69 deletions
@@ -0,0 +1,119 @@
<?php
namespace App\Tests\Doctor;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* The public doctor payload must aggregate booking state across every weekly
* schedule (personal + each clinic). A doctor whose personal schedule is empty
* but who is bookable at a clinic used to be reported as «نوبت‌دهی غیرفعال».
*/
class DoctorBookingStateAggregationTest extends ApiTestCase
{
/** پاسخ عمومی doctor به شکل {data:{data:{…}}} است. */
private function doctorPayload(): array
{
$json = json_decode($this->client->getResponse()->getContent(), true) ?? [];
return $json['data']['data'] ?? [];
}
private function week(array $session): array
{
$days = array_fill_keys(range(0, 6), ['sessions' => []]);
$days[0] = ['sessions' => [$session]];
return $days;
}
private function session(bool $active, int $locationId): array
{
return [
'active' => $active,
'location_id' => $locationId,
'start_time' => '09:00',
'end_time' => '13:00',
'duration_per_patient' => 20,
];
}
/** @return array{doctor: Doctor, personal: WeeklySchedule, clinic: WeeklySchedule} */
private function makeDoctorWithInactivePersonalAndClinic(): array
{
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر تجمیع');
$this->em->persist($doctor);
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
$clinic->setName('کلینیک تجمیع');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
$personalAddress = DoctorAddress::forDoctor($doctor);
$this->em->persist($personalAddress);
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
$this->em->persist($clinicAddress);
$this->em->flush();
$personal = new WeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId())));
$clinicSchedule = new WeeklySchedule(
$doctor,
$this->week($this->session(true, $clinicAddress->getId())),
$clinic
);
$this->em->persist($personal);
$this->em->persist($clinicSchedule);
$this->em->flush();
return ['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule];
}
public function testClinicScheduleKeepsDoctorBookableDespiteEmptyPersonalSchedule(): void
{
['doctor' => $doctor] = $this->makeDoctorWithInactivePersonalAndClinic();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertTrue($data['active']);
$this->assertStringContainsString('شنبه', $data['free_turn']);
}
public function testAllSchedulesDisabledReportsBookingDisabled(): void
{
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
= $this->makeDoctorWithInactivePersonalAndClinic();
$personal->setMeta(['online_booking_enabled' => false]);
$clinicSchedule->setMeta(['online_booking_enabled' => false]);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertFalse($data['active']);
$this->assertSame('نوبت‌دهی آنلاین غیرفعال است', $data['free_turn']);
}
public function testDisabledClinicScheduleDoesNotMaskActivePersonalSchedule(): void
{
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
= $this->makeDoctorWithInactivePersonalAndClinic();
// برعکسِ سناریوی اول: شخصی فعال، کلینیکی خاموش — ترتیب ردیف‌ها نباید مهم باشد.
$personal->setSetting($this->week($this->session(true, 0)));
$clinicSchedule->setMeta(['online_booking_enabled' => false]);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertTrue($data['active']);
$this->assertStringContainsString('شنبه', $data['free_turn']);
}
}