- 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.
498 lines
21 KiB
PHP
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);
|
|
}
|
|
}
|