hour * 60 + $moment->minute; $today = ShiftDayEnum::fromDate($moment)->value; $yesterday = ShiftDayEnum::fromDate($moment->copy()->subDay())->value; return Shift::active() ->where(function ($query) use ($moment, $today, $yesterday) { $query->whereDate('date', $moment->toDateString()) ->orWhereDate('date', $moment->copy()->subDay()->toDateString()) ->orWhere(function ($subQuery) use ($today, $yesterday) { $subQuery->whereNull('date')->whereIn('day_of_week', [$today, $yesterday]); }); }) ->get() ->filter(function (Shift $shift) use ($moment, $minutes) { $start = $this->toMinutes($shift->start_time); $end = $this->toMinutes($shift->end_time); if ($end > $start) { return $this->coversDate($shift, $moment) && $minutes >= $start && $minutes < $end; } // overnight shift: covers [start, 24:00) on its day and [00:00, end) on the next day if ($this->coversDate($shift, $moment) && $minutes >= $start) { return true; } return $this->coversDate($shift, $moment->copy()->subDay()) && $minutes < $end; }) ->values(); } public function availableDoctorsAt(Carbon $moment): Collection { $doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique(); if ($doctorIds->isEmpty()) { return collect(); } return User::where('level', UserEnum::Doctor->value) ->whereIn('id', $doctorIds) ->get(); } public function isOnShift(User $doctor, Carbon $moment): bool { return $this->shiftsCoveringMoment($moment)->contains('user_id', $doctor->id); } public function autoSelectDoctor(Carbon $moment): ?User { $doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique(); if ($doctorIds->isEmpty()) { return $this->doctorWithNearestShift($moment); } return User::where('level', UserEnum::Doctor->value) ->whereIn('id', $doctorIds) ->withCount(['documents as open_documents_count' => function ($query) { $query->whereIn('status', [ DocumentStatusEnum::Registered->value, DocumentStatusEnum::InProgress->value, ]); }]) ->orderBy('open_documents_count') ->orderBy('id') ->first(); } // fallback when nobody is on shift: the doctor whose next shift starts soonest public function doctorWithNearestShift(Carbon $from): ?User { $doctorIds = Shift::active()->whereNotNull('user_id')->distinct()->pluck('user_id'); return User::where('level', UserEnum::Doctor->value) ->whereIn('id', $doctorIds) ->get() ->map(fn (User $doctor) => ['doctor' => $doctor, 'start' => $this->nextShiftStart($doctor, $from)]) ->filter(fn (array $item) => $item['start'] !== null) ->sortBy('start') ->first()['doctor'] ?? null; } public function nextShiftStart(User $doctor, Carbon $from): ?Carbon { $shifts = Shift::active()->where('user_id', $doctor->id)->get(); return $shifts ->map(function (Shift $shift) use ($from) { for ($offset = 0; $offset <= 7; $offset++) { $day = $from->copy()->addDays($offset)->startOfDay(); if (!$this->coversDate($shift, $day)) { continue; } $start = $day->copy()->addMinutes($this->toMinutes($shift->start_time)); if ($start->greaterThanOrEqualTo($from)) { return $start; } } return null; }) ->filter() ->sort() ->first(); } public function hasConflict(array $data, ?int $ignoreId = null): bool { if (empty($data['user_id'])) { return false; } $candidate = new Shift($data); return Shift::active() ->where('user_id', $data['user_id']) ->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId)) ->get() ->contains(fn (Shift $existing) => $this->overlaps($existing, $candidate)); } public function claim(int $shiftId, User $doctor): Shift { return DB::transaction(function () use ($shiftId, $doctor) { $shift = Shift::whereKey($shiftId)->lockForUpdate()->firstOrFail(); if (!$shift->is_active || $shift->user_id !== null) { throw ValidationException::withMessages([ 'shift' => 'این شیفت قابل انتخاب نیست.', ]); } $data = array_merge( $shift->only(['day_of_week', 'date', 'start_time', 'end_time']), ['user_id' => $doctor->id] ); if ($this->hasConflict($data, $shift->id)) { throw ValidationException::withMessages([ 'shift' => 'این شیفت با برنامه فعلی شما تداخل دارد.', ]); } $shift->update(['user_id' => $doctor->id]); return $shift; }); } public function release(Shift $shift, User $doctor): Shift { if ($shift->user_id !== $doctor->id) { throw ValidationException::withMessages([ 'shift' => 'شما مجاز به تغییر این شیفت نیستید.', ]); } $shift->update(['user_id' => null]); return $shift; } public function overlaps(Shift $first, Shift $second): bool { $byWeekday = !$first->date || !$second->date; foreach ($this->daySegments($first, $byWeekday) as [$dayA, $startA, $endA]) { foreach ($this->daySegments($second, $byWeekday) as [$dayB, $startB, $endB]) { if ($dayA === $dayB && $startA < $endB && $startB < $endA) { return true; } } } return false; } protected function coversDate(Shift $shift, Carbon $date): bool { if ($shift->date) { return $shift->date->isSameDay($date); } return $shift->day_of_week === ShiftDayEnum::fromDate($date)->value; } protected function daySegments(Shift $shift, bool $byWeekday): array { $start = $this->toMinutes($shift->start_time); $end = $this->toMinutes($shift->end_time); if ($shift->date) { $day = $byWeekday ? ShiftDayEnum::fromDate($shift->date)->value : $shift->date->toDateString(); $nextDay = $byWeekday ? ShiftDayEnum::fromDate($shift->date->copy()->addDay())->value : $shift->date->copy()->addDay()->toDateString(); } else { $day = $shift->day_of_week; $nextDay = $this->nextDay($shift->day_of_week); } if ($end > $start) { return [[$day, $start, $end]]; } return [[$day, $start, 1440], [$nextDay, 0, $end]]; } protected function nextDay(string $day): string { $days = ShiftDayEnum::values(); $index = array_search($day, $days); return $days[($index + 1) % count($days)]; } protected function toMinutes(string $time): int { [$hours, $minutes] = explode(':', $time); return ((int) $hours) * 60 + ((int) $minutes); } }