feat: add shift management functionality
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<?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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user