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:
hamed
2026-07-18 14:35:31 +03:30
co-authored by Claude Opus 4.8
parent 8d31ebb3cb
commit 7a0654f8ba
15 changed files with 906 additions and 42 deletions
+3 -1
View File
@@ -38,6 +38,7 @@ class AdminApiController extends BaseController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
private readonly \App\Payment\Service\PaymentManager $paymentManager,
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
@@ -930,7 +931,8 @@ class AdminApiController extends BaseController
foreach ($serviceItems as $si) {
$appointment->addServiceItem($si);
}
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
if ($locationId !== null) $appointment->setAddressId($locationId);
try {
@@ -0,0 +1,111 @@
<?php
namespace App\Appointment\Command;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Doctor\Repository\DoctorAddressRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Finds active shifts whose location_id is empty or points outside their own
* booking context.
*
* The API rejects such shifts today (validateSessions), but rows created before
* that validation are still around. They are invisible in the panel yet make a
* location look bookable when nothing can actually be reserved there — the
* public site used to advertise a "personal practice" built entirely out of one.
*/
#[AsCommand(name: 'app:schedule:audit-locations', description: 'Report (and optionally disable) schedule shifts pointing at no valid address')]
class AuditScheduleLocationsCommand extends Command
{
public function __construct(
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly DoctorAddressRepository $addressRepo,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Deactivate the offending shifts instead of only reporting them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$fix = (bool) $input->getOption('fix');
$rows = [];
$touched = 0;
foreach ($this->scheduleRepo->findAll() as $schedule) {
$clinic = $schedule->getClinic();
$allowed = [];
foreach ($this->addressRepo->findForContext($schedule->getDoctor(), $clinic?->getId()) as $address) {
$allowed[(int) $address->getId()] = true;
}
$setting = $schedule->getSetting();
$changed = false;
foreach ($setting as $dayKey => $day) {
foreach (($day['sessions'] ?? []) as $index => $session) {
if (!($session['active'] ?? false)) {
continue;
}
$locationId = (int) ($session['location_id'] ?? 0);
if ($locationId !== 0 && isset($allowed[$locationId])) {
continue;
}
$rows[] = [
$schedule->getDoctor()->getUuid(),
$clinic === null ? 'personal' : ($clinic->getName() ?? 'clinic'),
WeeklySchedule::DAYS[(int) $dayKey] ?? (string) $dayKey,
$locationId === 0 ? '—' : (string) $locationId,
$locationId === 0 ? 'no address' : 'address outside context',
];
if ($fix) {
$setting[$dayKey]['sessions'][$index]['active'] = false;
$changed = true;
}
}
}
if ($changed) {
// داده‌ی کاربر حذف نمی‌شود؛ فقط غیرفعال می‌شود تا قابل بازیابی بماند.
$schedule->setSetting($setting);
$touched++;
}
}
if ($rows === []) {
$io->success('No schedule shift points at a missing or foreign address.');
return Command::SUCCESS;
}
$io->table(['doctor', 'context', 'day', 'location_id', 'problem'], $rows);
if (!$fix) {
$io->warning(sprintf('%d offending shift(s). Re-run with --fix to deactivate them.', count($rows)));
return Command::SUCCESS;
}
$this->em->flush();
$io->success(sprintf('Deactivated %d shift(s) across %d schedule(s).', count($rows), $touched));
return Command::SUCCESS;
}
}
@@ -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,
];
}
}
@@ -37,6 +37,7 @@ class MyAppointmentsController extends BaseController
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly SlotCalculatorService $slotCalculator,
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
@@ -141,7 +142,10 @@ class MyAppointmentsController extends BaseController
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
$appointment->setPatientNationalCode($nationalCode);
if (!empty($data['note'])) $appointment->setNote($data['note']);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
// محل نوبت باید از همان محیطی بیاید که نوبت در آن ثبت می‌شود؛ بدون clinic_uuid
// یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد».
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
if ($locationId !== null) $appointment->setAddressId($locationId);
// Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422.
@@ -0,0 +1,36 @@
<?php
namespace App\Appointment\Service;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* محل نوبت‌دهی یک درخواست: کلینیکِ داده‌شده، یا null یعنی مطب شخصی پزشک.
*
* نبودِ clinic_uuid هرگز به معنی «هر محلی که پیدا شد» نیست. با چند برنامهٔ هم‌زمان،
* حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای اشتباه — پس یا محل صریح است، یا شخصی.
*/
class BookingContextResolver
{
public function __construct(
private readonly ClinicRepository $clinicRepo,
) {}
public function resolve(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 AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبت‌دهی یافت نشد', 404);
}
return $clinic;
}
}
@@ -13,6 +13,12 @@ use App\Doctor\Entity\Doctor;
class SlotCalculatorService
{
/** دلایل خالی‌بودن یک روز — برای پیام دقیق در پنل. */
public const EMPTY_NO_SCHEDULE = 'no_schedule';
public const EMPTY_HOLIDAY = 'holiday';
public const EMPTY_DAY_OFF = 'day_off';
public const EMPTY_OUTSIDE_WINDOW = 'outside_window';
public function __construct(
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly DateOverrideRepository $overrideRepo,
@@ -135,6 +141,37 @@ class SlotCalculatorService
return $result;
}
/**
* چرا این روز اسلاتی ندارد. null یعنی اسلات دارد.
*
* پنل نمی‌تواند خالی‌بودن را به «تعطیل» ترجمه کند: نبودِ برنامه، تعطیلی، روزِ
* بدون شیفت و خارج‌بودن از بازهٔ نوبت‌دهی چهار چیز متفاوت‌اند و کاربر باید
* بداند کدام‌یک رخ داده تا بداند چه کاری باید بکند.
*/
public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null): ?string
{
if ($this->buildAllSessions($doctor, $date, $clinic) !== []) {
return null;
}
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
if ($schedule === null) {
return self::EMPTY_NO_SCHEDULE;
}
$dayStart = (int) strtotime($date . ' 00:00:00');
if ($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayStart + 86399, $clinic) !== []) {
return self::EMPTY_HOLIDAY;
}
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
return self::EMPTY_OUTSIDE_WINDOW;
}
return self::EMPTY_DAY_OFF;
}
/**
* زودترین اسلات آزاد در $daysAhead روز آینده، یا null اگر ظرفیتی نباشد.
*