Files
radicall/app/Services/Shift/ShiftScheduleService.php
T

228 lines
7.3 KiB
PHP

<?php
namespace App\Services\Shift;
use App\Enums\DocumentStatusEnum;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Models\Shift;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ShiftScheduleService
{
public function shiftsCoveringMoment(Carbon $moment): Collection
{
$minutes = $moment->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 autoSelectDoctor(Carbon $moment): ?User
{
$doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique();
if ($doctorIds->isEmpty()) {
return null;
}
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();
}
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);
}
}