Files
clinicpro/src/Appointment/Service/SlotCalculatorService.php
T
hamed ed516c81a8 feat: Enhance appointment management by decoupling online booking toggle for admin context
- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
2026-07-22 16:43:56 +03:30

498 lines
21 KiB
PHP

<?php
namespace App\Appointment\Service;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\DateOverrideRepository;
use App\Appointment\Repository\HolidayRepository;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
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,
private readonly HolidayRepository $holidayRepo,
private readonly AppointmentRepository $appointmentRepo,
) {}
/**
* Returns available slots (flat array) for booking conflict checks.
* Day index convention: 0=Saturday(شنبه), 1=Sunday, ..., 6=Friday(جمعه)
*
* @return array[] [{start, end, start_time, end_time, location_id}]
*/
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$sessions = $this->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<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
*/
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<array{start:int,end:int}> $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);
}
}