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); + } +}