fix(booking): carry the clinic context through the panel and drop phantom locations
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>
This commit is contained in:
@@ -33,7 +33,7 @@ class AppointmentController extends BaseController
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Clinic\Repository\ClinicRepository $clinicRepo,
|
||||
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
|
||||
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
|
||||
@@ -164,6 +164,10 @@ class AppointmentController extends BaseController
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'date' => $date,
|
||||
'sessions' => $sessions,
|
||||
// خالیبودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
|
||||
'empty_reason' => $sessions === []
|
||||
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -267,32 +271,58 @@ class AppointmentController extends BaseController
|
||||
* GET /api/v1/appointment-booking-locations/{doctorUuid}
|
||||
*/
|
||||
#[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])]
|
||||
public function bookingLocations(string $doctorUuid): JsonResponse
|
||||
public function bookingLocations(string $doctorUuid, Request $request): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$date = trim((string) $request->query->get('date', ''));
|
||||
if ($date !== '' && !$this->isCalendarDate($date)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$locations = [];
|
||||
foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) {
|
||||
$clinic = $schedule->getClinic();
|
||||
$clinic = $schedule->getClinic();
|
||||
$addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId());
|
||||
|
||||
// محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد.
|
||||
if ($addresses === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byId = [];
|
||||
foreach ($addresses as $a) {
|
||||
$byId[(int) $a->getId()] = $a;
|
||||
}
|
||||
|
||||
// و برنامهای که هیچ شیفتش روی آدرسهای همین محیط ننشسته، قابل رزرو نیست.
|
||||
$hours = $this->openingHours($schedule, $byId);
|
||||
if ($hours === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = $schedule->getMeta();
|
||||
$address = $this->addressRepo->findForContext($doctor, $clinic?->getId())[0] ?? null;
|
||||
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
|
||||
|
||||
$locations[] = [
|
||||
'location_uuid' => $address?->getUuid(),
|
||||
'location_uuid' => $address->getUuid(),
|
||||
'type' => $clinic === null ? 'personal' : 'clinic',
|
||||
'title' => $clinic?->getName() ?? ($address?->getName() ?: 'مطب شخصی'),
|
||||
'address' => $address?->getAddress(),
|
||||
'title' => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'),
|
||||
'address' => $address->getAddress(),
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'booking_mode' => $meta['booking_mode'],
|
||||
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
||||
'opening_hours' => $this->openingHours($schedule),
|
||||
'opening_hours' => $hours,
|
||||
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
|
||||
? $this->bookableServices($doctor, $clinic)
|
||||
: [],
|
||||
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic),
|
||||
'available_on_date' => $date === ''
|
||||
? null
|
||||
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic) !== [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -301,6 +331,7 @@ class AppointmentController extends BaseController
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date !== '' ? $date : null,
|
||||
'booking_locations' => $locations,
|
||||
]);
|
||||
}
|
||||
@@ -672,16 +703,7 @@ class AppointmentController extends BaseController
|
||||
*/
|
||||
private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
|
||||
{
|
||||
if ($clinicUuid === null || trim($clinicUuid) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid(trim($clinicUuid));
|
||||
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبتدهی یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $clinic;
|
||||
return $this->bookingContext->resolve($doctor, $clinicUuid);
|
||||
}
|
||||
|
||||
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
|
||||
@@ -700,6 +722,17 @@ class AppointmentController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99»
|
||||
* الگو را پاس میکند ولی روزی نیست.
|
||||
*/
|
||||
private function isCalendarDate(string $date): bool
|
||||
{
|
||||
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
|
||||
return $parsed !== false && $parsed->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function bookableServices(Doctor $doctor, ?Clinic $clinic): array
|
||||
{
|
||||
@@ -723,9 +756,13 @@ class AppointmentController extends BaseController
|
||||
* شیفتهای فعال هفته بهصورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به
|
||||
* openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است.
|
||||
*
|
||||
* @return array<int, array{day: string, opens: string, closes: string}>
|
||||
* فقط شیفتهایی برمیگردند که آدرسشان در $allowedAddressIds باشد.
|
||||
*
|
||||
* @param array<int, \App\Doctor\Entity\DoctorAddress> $allowedAddressIds
|
||||
*
|
||||
* @return array<int, array{day: string, day_index: int, location_id: int, opens: string, closes: string}>
|
||||
*/
|
||||
private function openingHours(WeeklySchedule $schedule): array
|
||||
private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array
|
||||
{
|
||||
$hours = [];
|
||||
|
||||
@@ -740,6 +777,13 @@ class AppointmentController extends BaseController
|
||||
continue;
|
||||
}
|
||||
|
||||
// شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره میکند،
|
||||
// قابل رزرو نیست و نباید ساعت کاری تولید کند.
|
||||
$locationId = (int) ($session['location_id'] ?? 0);
|
||||
if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$opens = $session['start_time'] ?? null;
|
||||
$closes = $session['end_time'] ?? null;
|
||||
if ($opens === null || $closes === null) {
|
||||
@@ -747,9 +791,11 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
|
||||
$hours[] = [
|
||||
'day' => ucfirst($dayName),
|
||||
'opens' => $opens,
|
||||
'closes' => $closes,
|
||||
'day' => ucfirst($dayName),
|
||||
'day_index' => (int) $dayIndex,
|
||||
'location_id' => $locationId,
|
||||
'opens' => $opens,
|
||||
'closes' => $closes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user