From 63ce0f81ad664f1ed847a9bead7932a1f7276790 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 13:59:14 +0330 Subject: [PATCH] perf(appointment): resolve next_available_at in one pass per location next_available_at called getAvailableSlots() once per day for up to 30 days, and that helper re-read the schedule, holidays and overrides on every call and then issued an isSlotTaken() query per candidate slot. Cost grew with both the days scanned and the slots per day, multiplied by the number of locations. findNextAvailableStart() fetches the schedule, holidays, overrides and blocking intervals once for the whole window and walks the days in memory. Measured on the dev data (a doctor with two locations, first opening several days out): 73 -> 20 queries for one request. The gap widens as locations or the distance to the first opening grow. Reserve appointments must keep blocking here: findBusyIntervals() filters isReserve = false, so reusing it would have reported a reserved slot as free. Added findBlockingIntervals(), which mirrors isSlotTaken()'s predicate, and factored both onto a shared builder. Verified the endpoint returns identical timestamps before and after. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/appointment.md | 7 +- .../Controller/AppointmentController.php | 16 +-- .../Repository/AppointmentRepository.php | 30 +++++- .../Service/SlotCalculatorService.php | 96 +++++++++++++++++ .../Appointment/BookingLocationsScanTest.php | 100 ++++++++++++++++++ 5 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 tests/Appointment/BookingLocationsScanTest.php diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 70a4047f..1617e276 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -773,7 +773,12 @@ location — picking one and hiding the rest removes real capacity from the doct | `booking_mode` | `"slot" \| "service"` | per-context — the same doctor can differ between locations | | `opening_hours` | `array` | active weekly shifts of that context, flattened; `day` is the English weekday name so it maps straight onto schema.org `openingHoursSpecification` | | `services` | `array` | populated only in `service` mode, scoped to that context's owner | -| `next_available_at` | `int\|null` | Unix timestamp of the earliest free slot within 30 days | +| `next_available_at` | `int\|null` | Unix timestamp of the earliest free slot within 30 days, capped by the context's booking window | + +`next_available_at` is resolved by `SlotCalculatorService::findNextAvailableStart()`, which fetches +the schedule, holidays, overrides and taken appointments once per location and walks the days in +memory. It counts a reserve appointment as blocking, matching `isSlotTaken()` — the looser +`findBusyIntervals()` used by service-mode slot generation would report such a slot as free. Sorted by `next_available_at` ascending, so `booking_locations[0]` is the sensible default selection; locations with no capacity sort last. Deep links should carry the chosen location diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 505a4e46..0a7292bf 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -292,7 +292,7 @@ class AppointmentController extends BaseController 'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE ? $this->bookableServices($doctor, $clinic) : [], - 'next_available_at' => $this->nextAvailableAt($doctor, $clinic), + 'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic), ]; } @@ -757,20 +757,6 @@ class AppointmentController extends BaseController return $hours; } - /** زودترین اسلات آزاد در ۳۰ روز آینده، یا null اگر ظرفیتی نباشد. */ - private function nextAvailableAt(Doctor $doctor, ?Clinic $clinic): ?int - { - for ($i = 0; $i < 30; $i++) { - $date = date('Y-m-d', strtotime("today +{$i} day")); - $slots = $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic); - if (!empty($slots)) { - return (int) $slots[0]['start']; - } - } - - return null; - } - #[OA\Patch( path: '/api/v1/appointment/{uuid}/status', diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index ab438650..5707864a 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -104,10 +104,26 @@ class AppointmentRepository extends ServiceEntityRepository */ public function findBusyIntervals(Doctor $doctor, int $from, int $to): array { - $rows = $this->createQueryBuilder('a') + return $this->occupiedIntervals($doctor, $from, $to, true); + } + + /** + * همان مجموعه‌ای که isSlotTaken() یک اسلات را با آن می‌سنجد — شامل نوبت‌های + * رزروی. برای پیمایش چندروزه که نمی‌خواهد به ازای هر اسلات یک کوئری بزند. + * + * @return array + */ + public function findBlockingIntervals(Doctor $doctor, int $from, int $to): array + { + return $this->occupiedIntervals($doctor, $from, $to, false); + } + + /** @return array */ + private function occupiedIntervals(Doctor $doctor, int $from, int $to, bool $skipReserve): array + { + $qb = $this->createQueryBuilder('a') ->select('a.slotStart AS start, a.slotEnd AS end') ->where('a.doctor = :doctor') - ->andWhere('a.isReserve = false') ->andWhere('a.slotStart < :to') ->andWhere('a.slotEnd > :from') ->andWhere( @@ -119,9 +135,13 @@ class AppointmentRepository extends ServiceEntityRepository ->setParameter('now', time()) ->setParameter('from', $from) ->setParameter('to', $to) - ->orderBy('a.slotStart', 'ASC') - ->getQuery() - ->getScalarResult(); + ->orderBy('a.slotStart', 'ASC'); + + if ($skipReserve) { + $qb->andWhere('a.isReserve = false'); + } + + $rows = $qb->getQuery()->getScalarResult(); return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows); } diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 31558ef4..0b34c295 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -135,6 +135,102 @@ class SlotCalculatorService return $result; } + /** + * زودترین اسلات آزاد در $daysAhead روز آینده، یا null اگر ظرفیتی نباشد. + * + * برخلاف صدا زدن getAvailableSlots() به ازای هر روز، برنامه و تعطیلی و استثناها + * و نوبت‌های اشغال یک‌بار برای کل بازه واکشی می‌شوند و بقیه در حافظه محاسبه + * می‌شود: ۴ کوئری ثابت به‌جای رشدِ خطی با تعداد روز و اسلات. + */ + public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30): ?int + { + $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); + if ($schedule === null) { + return null; + } + + $meta = $schedule->getMeta(); + if (!($meta['online_booking_enabled'] ?? true)) { + return null; + } + + $now = time(); + $todayStart = (int) strtotime('today 00:00:00'); + $windowEnd = $this->bookingWindowEnd($meta); + $scanEnd = min($windowEnd, $todayStart + $daysAhead * 86400); + if ($scanEnd < $todayStart) { + return null; + } + + $holidays = $this->holidayRepo->findActiveByDoctor($doctor, $todayStart, $scanEnd + 86399, $clinic); + $blocking = $this->appointmentRepo->findBlockingIntervals($doctor, $now, $scanEnd + 86400); + $overrides = []; + foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) { + $overrides[date('Y-m-d', $override->getDate())] = $override; + } + + $daySchedule = $schedule->getSetting(); + + for ($dayStart = $todayStart; $dayStart <= $scanEnd; $dayStart += 86400) { + if ($this->isHoliday($holidays, $dayStart)) { + continue; + } + + $date = date('Y-m-d', $dayStart); + $override = $overrides[$date] ?? null; + + if ($override !== null) { + if (!$override->isActive()) { + continue; + } + $sessions = $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart); + } else { + $dayKey = (string) (((int) date('w', $dayStart) + 1) % 7); + $dayConf = $daySchedule[$dayKey] ?? null; + if ($dayConf === null) { + continue; + } + $sessions = []; + foreach (($dayConf['sessions'] ?? []) as $session) { + if ($session['active'] ?? false) { + $sessions[] = ['slots' => $this->buildSessionSlots($session, $dayStart)]; + } + } + } + + foreach ($sessions as $session) { + foreach (($session['slots'] ?? []) as $slot) { + if ($slot['start'] >= $now && $this->firstOverlap($slot['start'], $slot['end'], $blocking) === null) { + return (int) $slot['start']; + } + } + } + } + + return null; + } + + /** @param \App\Appointment\Entity\Holiday[] $holidays */ + private function isHoliday(array $holidays, int $dayStart): bool + { + $dayEnd = $dayStart + 86399; + foreach ($holidays as $holiday) { + if ($holiday->getStartDate() <= $dayEnd && $holiday->getEndDate() >= $dayStart) { + return true; + } + } + + return false; + } + + private function bookingWindowEnd(array $meta): int + { + $value = max(1, (int) ($meta['booking_window_value'] ?? 1)); + $unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month'; + + return (int) strtotime("today +{$value} {$unit} 00:00:00"); + } + /** * انتهای اولین بازهٔ اشغال‌شده‌ای که با [$start, $end) تداخل دارد، یا null. * @param array $busy diff --git a/tests/Appointment/BookingLocationsScanTest.php b/tests/Appointment/BookingLocationsScanTest.php new file mode 100644 index 00000000..8e587065 --- /dev/null +++ b/tests/Appointment/BookingLocationsScanTest.php @@ -0,0 +1,100 @@ + [[ + 'active' => true, + 'location_id' => $locationId, + 'start_time' => $start, + 'end_time' => $end, + 'duration_per_patient' => 20, + ]]]; + + return array_fill_keys(array_map('strval', range(0, 6)), $day); + } + + private function makeDoctorWithSchedules(int $clinicCount): Doctor + { + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر پرمحل'); + $doctor->setMobileNumber($doctorUser->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $personalAddress = DoctorAddress::forDoctor($doctor); + $this->em->persist($personalAddress); + $this->em->flush(); + + $this->em->persist(new WeeklySchedule( + $doctor, + $this->weekOfSessions($personalAddress->getId(), '09:00', '13:00') + )); + + for ($i = 0; $i < $clinicCount; $i++) { + $clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC'])); + $clinic->setName("کلینیک $i"); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + $address = DoctorAddress::forClinic($clinic->getId()); + $this->em->persist($address); + $this->em->flush(); + + $this->em->persist(new WeeklySchedule( + $doctor, + $this->weekOfSessions($address->getId(), '16:00', '20:00'), + $clinic + )); + } + + $this->em->flush(); + + return $doctor; + } + + public function testNextAvailableIsReportedPerLocation(): void + { + $doctor = $this->makeDoctorWithSchedules(1); + + $body = $this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid(), $doctor->getUser()); + self::assertSame(200, $this->responseCode()); + + $locations = $body['data']['booking_locations'] ?? []; + self::assertCount(2, $locations); + + foreach ($locations as $location) { + self::assertNotNull( + $location['next_available_at'], + 'a schedule active every day must expose a next free slot' + ); + self::assertGreaterThanOrEqual(time(), $location['next_available_at']); + } + + // Sorted by earliest opening — the site relies on booking_locations[0]. + $starts = array_column($locations, 'next_available_at'); + $sorted = $starts; + sort($sorted); + self::assertSame($sorted, $starts); + } +}