buildAllSessions($doctor, $date, $clinic, $forManagement); if (empty($sessions)) return []; $flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions)); return $this->filterBookedSlots($doctor, $flat); } /** * آدرس (location_id) متناظر با اسلاتِ شروع‌شده در تاریخ مشخص. اگر پیدا نشد null. * * برای ثبتِ نوبت از پنل ($forManagement=true) نباید خاموش‌بودنِ نوبت‌دهی آنلاین * باعث گم‌شدنِ location شود؛ وگرنه نوبتِ دستی بدون آدرس ثبت می‌شد. */ public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null, bool $forManagement = false): ?int { $date = date('Y-m-d', $slotStart); $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement); foreach ($sessions as $session) { foreach (($session['slots'] ?? []) as $slot) { if ((int) ($slot['start'] ?? 0) === $slotStart) { $loc = $slot['location_id'] ?? null; return $loc !== null ? (int) $loc : null; } } } return null; } /** * Returns sessions grouped by shift, each slot tagged with is_available. * Used by the schedule view to show real shift boundaries. * * @return array[] [{start_time, end_time, slots: [{start, end, start_time, end_time, location_id, is_available}]}] */ public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array { $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement); $now = time(); return array_map(fn(array $session) => [ 'start_time' => $session['start_time'], 'end_time' => $session['end_time'], 'slots' => array_map(fn(array $slot) => array_merge($slot, [ 'is_available' => $slot['start'] >= $now && !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']), ]), $session['slots']), ], $sessions); } /** * Whether a doctor has at least one slot on the given date. * Lightweight check for the month-availability endpoint. */ public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): bool { return !empty($this->buildAllSessions($doctor, $date, $clinic, $forManagement)); } /** * حالت نوبت‌دهی سرویسی: زمان‌های شروعِ ممکن برای نوبتی به طول $durationMinutes * در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت * سرویس (+ بافر) پُر می‌کند: از ابتدای window شروع، بازه‌های اشغال‌شده را رد * می‌کند و اولین جای پیوستهٔ کافی را برمی‌گرداند، سپس نوبت‌های بعدی را پشت‌سرهم * (با فاصلهٔ بافر) می‌چیند. * * زمان پایانِ ذخیره‌شدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ * بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع می‌شود. * * @return array */ public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false): array { if ($durationMinutes <= 0) return []; $buffer = (int)($this->getBookingMeta($doctor, $clinic)['buffer_minutes'] ?? 0); $durSec = $durationMinutes * 60; $needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر $sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement); // window/holiday/override/booking-window رعایت می‌شود if (empty($sessions)) return []; $dayStart = (int) strtotime($date . ' 00:00:00'); $busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400); $now = time(); $result = []; foreach ($sessions as $session) { $winStart = $dayStart + $this->parseTime($session['start_time'] ?? '00:00'); $winEnd = $dayStart + $this->parseTime($session['end_time'] ?? '00:00'); $locationId = $session['slots'][0]['location_id'] ?? null; $t = max($winStart, $now); while ($t + $durSec <= $winEnd) { $end = $t + $durSec; $conflict = $this->firstOverlap($t, $t + $needSec, $busy); if ($conflict !== null) { $t = $conflict; // به انتهای بازهٔ اشغال‌شدهٔ متداخل بپر continue; } $result[] = [ 'start' => $t, 'end' => $end, 'start_time' => date('H:i', $t), 'end_time' => date('H:i', $end), 'location_id' => $locationId !== null ? (int) $locationId : null, ]; $t += $needSec; // نوبت بعدی پس از این نوبت + بافر } } return $result; } /** * چرا این روز اسلاتی ندارد. null یعنی اسلات دارد. * * پنل نمی‌تواند خالی‌بودن را به «تعطیل» ترجمه کند: نبودِ برنامه، تعطیلی، روزِ * بدون شیفت و خارج‌بودن از بازهٔ نوبت‌دهی چهار چیز متفاوت‌اند و کاربر باید * بداند کدام‌یک رخ داده تا بداند چه کاری باید بکند. */ public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): ?string { if ($this->buildAllSessions($doctor, $date, $clinic, $forManagement) !== []) { 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, $forManagement)) { return self::EMPTY_OUTSIDE_WINDOW; } return self::EMPTY_DAY_OFF; } /** * زودترین اسلات آزاد در $daysAhead روز آینده، یا null اگر ظرفیتی نباشد. * * برخلاف صدا زدن getAvailableSlots() به ازای هر روز، برنامه و تعطیلی و استثناها * و نوبت‌های اشغال یک‌بار برای کل بازه واکشی می‌شوند و بقیه در حافظه محاسبه * می‌شود: ۴ کوئری ثابت به‌جای رشدِ خطی با تعداد روز و اسلات. */ public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30, bool $forManagement = false): ?int { $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); if ($schedule === null) { return null; } $meta = $schedule->getMeta(); if (!$forManagement && !($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 */ private function firstOverlap(int $start, int $end, array $busy): ?int { foreach ($busy as $b) { if ($b['start'] < $end && $b['end'] > $start) { return $b['end']; } } return null; } /** * Booking is allowed only when online booking is enabled and the date is * today..(today + window). Past dates are always rejected. * * مدیریت پنل ($forManagement=true): خاموش‌بودنِ نوبت‌دهی آنلاین و سقفِ بازهٔ * مجاز رزرو (advance window) فقط قواعد رزرو عمومی از سایت‌اند و نباید جلوی * نمایش/ثبتِ نوبت توسط پزشک/منشی/ادمین را بگیرند. تاریخِ گذشته همچنان رد می‌شود. */ private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic, bool $forManagement = false): bool { $todayStart = (int) strtotime('today 00:00:00'); if ($dayStart < $todayStart) { return false; } if ($forManagement) { return true; } $meta = $this->getBookingMeta($doctor, $clinic); if (!($meta['online_booking_enabled'] ?? true)) { return false; } $value = max(1, (int)($meta['booking_window_value'] ?? 1)); $unit = ($meta['booking_window_unit'] ?? 'month') === 'week' ? 'week' : 'month'; $maxStart = (int) strtotime("today +{$value} {$unit} 00:00:00"); return $dayStart <= $maxStart; } private function getBookingMeta(Doctor $doctor, ?Clinic $clinic): array { $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); return $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; } /** * Core: build all sessions with their slots, grouped by shift. * * @return array[] [{start_time: string, end_time: string, slots: array[]}] */ private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array { $dayStart = (int) strtotime($date . ' 00:00:00'); $dayEnd = $dayStart + 86400; // 0. Online booking disabled or date outside the booking window // (در کانتکست مدیریت این دو نادیده گرفته می‌شوند — رجوع به isWithinBookingWindow) if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) { return []; } // 1. Blocked by holiday if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1, $clinic))) { return []; } // 2. Date override takes precedence over weekly schedule foreach ($this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) as $override) { if (date('Y-m-d', $override->getDate()) === $date) { if (!$override->isActive()) return []; return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart); } } // 3. Weekly schedule $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); if ($schedule === null) return []; // Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday) $phpDow = (int) date('w', $dayStart); $dayKey = (string)(($phpDow + 1) % 7); $dayConf = $schedule->getSetting()[$dayKey] ?? null; if ($dayConf === null) return []; $sessionConfigs = $dayConf['sessions'] ?? []; $activeSessions = array_filter($sessionConfigs, fn($s) => $s['active'] ?? false); usort($activeSessions, fn($a, $b) => $this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00') ); $result = []; $prevEnd = 0; foreach ($activeSessions as $session) { $sessionStart = $this->parseTime($session['start_time'] ?? '00:00'); if ($sessionStart < $prevEnd) continue; // skip overlapping session $slots = $this->buildSessionSlots($session, $dayStart); if (!empty($slots)) { $result[] = [ 'start_time' => $session['start_time'] ?? '00:00', 'end_time' => $session['end_time'] ?? '00:00', 'slots' => $slots, ]; } $prevEnd = $this->parseTime($session['end_time'] ?? '00:00'); } return $result; } /** * Build sessions from date-override config. * Supports new SessionConfig format {start_time, end_time, ...} and legacy {start, end, duration}. */ private function buildSessionsFromOverride(array $slotConfigs, int $dayStart): array { $sessions = []; foreach ($slotConfigs as $config) { if (isset($config['start_time'])) { $slots = $this->buildSessionSlots(array_merge(['active' => true], $config), $dayStart); if (!empty($slots)) { $sessions[] = [ 'start_time' => $config['start_time'], 'end_time' => $config['end_time'] ?? '00:00', 'slots' => $slots, ]; } } else { // Legacy flat format: {start, end, duration} $startSec = $this->parseTime($config['start'] ?? '00:00'); $endSec = $this->parseTime($config['end'] ?? '00:00'); $duration = (int)($config['duration'] ?? 30) * 60; if ($duration <= 0 || $endSec <= $startSec) continue; $slots = []; for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) { $slots[] = [ 'start' => $dayStart + $t, 'end' => $dayStart + $t + $duration, 'start_time' => gmdate('H:i', $t), 'end_time' => gmdate('H:i', $t + $duration), 'location_id' => null, ]; } if (!empty($slots)) { $sessions[] = [ 'start_time' => $config['start'] ?? '00:00', 'end_time' => $config['end'] ?? '00:00', 'slots' => $slots, ]; } } } return $sessions; } /** * Build individual slot entries from a session config (weekly or override). * Handles rest breaks and patient limits. */ private function buildSessionSlots(array $session, int $dayStart): array { $startSec = $this->parseTime($session['start_time'] ?? '00:00'); $endSec = $this->parseTime($session['end_time'] ?? '00:00'); $dur = (int)($session['duration_per_patient'] ?? 20) * 60; $hasRest = (bool)($session['has_rest'] ?? false); $restInt = (int)($session['rest_interval'] ?? 60) * 60; $restDur = (int)($session['time_to_rest'] ?? 10) * 60; $limit = isset($session['patient_limit']) && $session['patient_limit'] !== null ? (int)$session['patient_limit'] : null; $locationId = isset($session['location_id']) ? (int)$session['location_id'] : null; if ($dur <= 0 || $endSec <= $startSec) return []; $slots = []; $currentSec = $startSec; $elapsedWork = 0; $patientCount = 0; while ($currentSec + $dur <= $endSec) { if ($limit !== null && $patientCount >= $limit) break; if ($hasRest && $restInt > 0 && $elapsedWork > 0 && $elapsedWork >= $restInt) { $currentSec += $restDur; $elapsedWork = 0; continue; } $slots[] = [ 'start' => $dayStart + $currentSec, 'end' => $dayStart + $currentSec + $dur, 'start_time' => gmdate('H:i', $currentSec), 'end_time' => gmdate('H:i', $currentSec + $dur), 'location_id' => $locationId, ]; $currentSec += $dur; $elapsedWork += $dur; $patientCount += 1; } return $slots; } private function filterBookedSlots(Doctor $doctor, array $slots): array { $now = time(); return array_values(array_filter($slots, fn(array $slot): bool => $slot['start'] >= $now && !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']) )); } private function parseTime(string $time): int { [$h, $m] = explode(':', $time, 2) + [0, 0]; return ((int)$h * 3600) + ((int)$m * 60); } }