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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 13:59:14 +03:30
co-authored by Claude Opus 4.8
parent ae6ecc5cf7
commit 63ce0f81ad
5 changed files with 228 additions and 21 deletions
@@ -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<array{start:int,end:int}> $busy