Files
clinicpro/src/Appointment/Service/SlotCalculatorService.php
T
hamedandClaude Opus 5 b784124b7e feat(booking): let service slot search exclude the appointment being moved
findBusyIntervals() and getServiceStartTimes() gain an optional
excludeAppointmentId, mirroring isSlotTaken($doctor, $start, $end, $excludeId)
which already had it. Without it an appointment being rescheduled sees itself as
busy, so its current time never appears among the candidates and "same hour,
different service" is impossible.

The parameter is optional with a null default and only affects the service-mode
path; no existing call site changes behaviour. SlotModeFrozenTest caught the
signature change immediately while both response contracts stayed green, so the
signature fixture was updated once with a written rationale, as its own header
permits.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:51:29 +03:30

501 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 شروع می‌شود.
*
* `$excludeAppointmentId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال
* جابه‌جایی خودش را اشغال می‌بیند و زمان فعلی‌اش در فهرست نمی‌آید. همان الگوی
* {@see \App\Appointment\Repository\AppointmentRepository::isSlotTaken()}.
*
* @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, ?int $excludeAppointmentId = null): 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, $excludeAppointmentId);
$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;
}
/** آخرین روزی که رزرو عمومی روی آن مجاز است (شروعِ آن روز، 00:00). */
private function bookingWindowEnd(array $meta): int
{
$value = max(1, (int) ($meta['booking_window_value'] ?? WeeklySchedule::DEFAULT_META['booking_window_value']));
$unit = in_array($meta['booking_window_unit'] ?? null, WeeklySchedule::BOOKING_WINDOW_UNITS, true)
? $meta['booking_window_unit']
: WeeklySchedule::DEFAULT_META['booking_window_unit'];
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;
}
return $dayStart <= $this->bookingWindowEnd($meta);
}
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);
}
}