Files
crewli/api/app/Services/ShiftAssignmentService.php
bert.hausmans 78cc19373e feat: allow organizer overbooking with confirmation dialog
Remove capacity and status validation from organizer assign flow so
organizers can intentionally overbook shifts. Log overbooked assignments
for audit trail. Volunteer claims still enforce hard limits. Frontend
shows a warning banner when a shift is full and requires confirmation
before overbooking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:09:11 +02:00

404 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\ShiftAssignmentStatus;
use App\Models\Person;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class ShiftAssignmentService
{
/**
* @throws ValidationException
*/
public function claim(Shift $shift, Person $person): ShiftAssignment
{
return DB::transaction(function () use ($shift, $person): ShiftAssignment {
$this->validateShiftIsOpen($shift);
$this->validatePersonApproved($person);
$this->validateClaimCapacity($shift);
$this->validateNoConflict($shift, $person, isClaim: true);
$autoApprove = $shift->festivalSection->crew_auto_accepts;
$status = $autoApprove
? ShiftAssignmentStatus::APPROVED
: ShiftAssignmentStatus::PENDING_APPROVAL;
$assignment = $shift->shiftAssignments()->create([
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => $status,
'auto_approved' => $autoApprove,
'assigned_at' => now(),
'approved_at' => $autoApprove ? now() : null,
]);
$this->updateShiftStatusIfFull($shift);
activity('shift_assignment')
->causedBy(auth()->user())
->performedOn($assignment)
->withProperties([
'shift_id' => $shift->id,
'person_id' => $person->id,
'auto_approved' => $autoApprove,
])
->log('shift_assignment.claimed');
if ($autoApprove) {
activity('shift_assignment')
->causedBy(auth()->user())
->performedOn($assignment)
->withProperties(['shift_id' => $shift->id])
->log('shift_assignment.auto_approved');
}
return $assignment;
});
}
/**
* @throws ValidationException
*/
public function assign(Shift $shift, Person $person, User $assignedBy): ShiftAssignment
{
return DB::transaction(function () use ($shift, $person, $assignedBy): ShiftAssignment {
$this->validateNoConflict($shift, $person);
// Log overbooking for audit trail (organizers may intentionally overbook)
$filledSlots = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($filledSlots >= $shift->slots_total) {
activity('shift_assignment')
->causedBy($assignedBy)
->performedOn($shift)
->withProperties([
'filled_slots' => $filledSlots,
'slots_total' => $shift->slots_total,
'person_id' => $person->id,
'person_name' => $person->name,
])
->log('shift.overbooked_assignment');
}
$assignment = $shift->shiftAssignments()->create([
'person_id' => $person->id,
'time_slot_id' => $shift->time_slot_id,
'status' => ShiftAssignmentStatus::APPROVED,
'auto_approved' => false,
'assigned_by' => $assignedBy->id,
'assigned_at' => now(),
'approved_by' => $assignedBy->id,
'approved_at' => now(),
]);
$this->updateShiftStatusIfFull($shift);
activity('shift_assignment')
->causedBy($assignedBy)
->performedOn($assignment)
->withProperties([
'shift_id' => $shift->id,
'person_id' => $person->id,
'assigned_by' => $assignedBy->id,
])
->log('shift_assignment.assigned');
return $assignment;
});
}
/**
* @throws ValidationException
*/
public function approve(ShiftAssignment $assignment, User $approvedBy): ShiftAssignment
{
return DB::transaction(function () use ($assignment, $approvedBy): ShiftAssignment {
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::APPROVED);
$shift = $assignment->shift;
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
throw ValidationException::withMessages([
'shift' => ['Shift is vol — alle slots zijn bezet.'],
]);
}
$oldStatus = $assignment->status;
$assignment->update([
'status' => ShiftAssignmentStatus::APPROVED,
'approved_by' => $approvedBy->id,
'approved_at' => now(),
]);
$this->updateShiftStatusIfFull($shift);
activity('shift_assignment')
->causedBy($approvedBy)
->performedOn($assignment)
->withProperties([
'old_status' => $oldStatus->value,
'new_status' => ShiftAssignmentStatus::APPROVED->value,
])
->log('shift_assignment.approved');
return $assignment->fresh();
});
}
/**
* @throws ValidationException
*/
public function reject(ShiftAssignment $assignment, User $rejectedBy, ?string $reason = null): ShiftAssignment
{
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::REJECTED);
$oldStatus = $assignment->status;
$assignment->update([
'status' => ShiftAssignmentStatus::REJECTED,
'rejection_reason' => $reason,
]);
activity('shift_assignment')
->causedBy($rejectedBy)
->performedOn($assignment)
->withProperties([
'old_status' => $oldStatus->value,
'new_status' => ShiftAssignmentStatus::REJECTED->value,
'reason' => $reason,
])
->log('shift_assignment.rejected');
return $assignment->fresh();
}
/**
* @throws ValidationException
*/
public function cancel(ShiftAssignment $assignment, User $cancelledBy): ShiftAssignment
{
return DB::transaction(function () use ($assignment, $cancelledBy): ShiftAssignment {
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED);
$wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED;
$oldStatus = $assignment->status;
$assignment->update([
'status' => ShiftAssignmentStatus::CANCELLED,
]);
if ($wasApproved) {
$this->updateShiftStatusAfterCancellation($assignment->shift);
}
activity('shift_assignment')
->causedBy($cancelledBy)
->performedOn($assignment)
->withProperties([
'old_status' => $oldStatus->value,
'new_status' => ShiftAssignmentStatus::CANCELLED->value,
])
->log('shift_assignment.cancelled');
return $assignment->fresh();
});
}
/**
* @return Collection<int, array{assignment_id: string, status: string}>
*/
public function bulkApprove(Collection $assignments, User $approvedBy): Collection
{
return DB::transaction(function () use ($assignments, $approvedBy): Collection {
return $assignments->map(function (ShiftAssignment $assignment) use ($approvedBy): array {
if ($assignment->status !== ShiftAssignmentStatus::PENDING_APPROVAL) {
return [
'assignment_id' => $assignment->id,
'status' => 'skipped',
'reason' => "Status is {$assignment->status->value}, not pending_approval.",
];
}
$shift = $assignment->shift;
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
return [
'assignment_id' => $assignment->id,
'status' => 'skipped',
'reason' => 'Shift is vol.',
];
}
$assignment->update([
'status' => ShiftAssignmentStatus::APPROVED,
'approved_by' => $approvedBy->id,
'approved_at' => now(),
]);
$this->updateShiftStatusIfFull($shift);
activity('shift_assignment')
->causedBy($approvedBy)
->performedOn($assignment)
->withProperties([
'old_status' => ShiftAssignmentStatus::PENDING_APPROVAL->value,
'new_status' => ShiftAssignmentStatus::APPROVED->value,
])
->log('shift_assignment.approved');
return [
'assignment_id' => $assignment->id,
'status' => 'approved',
];
});
});
}
/**
* @throws ValidationException
*/
private function validateShiftIsOpen(Shift $shift): void
{
if ($shift->status !== 'open') {
throw ValidationException::withMessages([
'shift' => ['Shift is niet open voor inschrijvingen.'],
]);
}
}
/**
* @throws ValidationException
*/
private function validatePersonApproved(Person $person): void
{
if ($person->status !== 'approved') {
throw ValidationException::withMessages([
'person' => ['Persoon is nog niet goedgekeurd.'],
]);
}
}
/**
* @throws ValidationException
*/
private function validateClaimCapacity(Shift $shift): void
{
if ($shift->slots_open_for_claiming <= 0) {
throw ValidationException::withMessages([
'shift' => ['Geen claimbare slots beschikbaar voor deze shift.'],
]);
}
$activeCount = $shift->shiftAssignments()
->whereNotIn('status', [
ShiftAssignmentStatus::REJECTED,
ShiftAssignmentStatus::CANCELLED,
])
->count();
if ($activeCount >= $shift->slots_open_for_claiming) {
throw ValidationException::withMessages([
'shift' => ['Geen claimbare slots beschikbaar voor deze shift.'],
]);
}
}
/**
* @throws ValidationException
*/
private function validateAssignCapacity(Shift $shift): void
{
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total) {
throw ValidationException::withMessages([
'shift' => ['Shift is vol — alle slots zijn bezet.'],
]);
}
}
/**
* @throws ValidationException
*/
private function validateNoConflict(Shift $shift, Person $person, bool $isClaim = false): void
{
if ($shift->allow_overlap) {
return;
}
$existingAssignment = ShiftAssignment::where('person_id', $person->id)
->where('time_slot_id', $shift->time_slot_id)
->active()
->with(['shift.festivalSection', 'shift.timeSlot'])
->first();
if ($existingAssignment) {
$section = $existingAssignment->shift->festivalSection->name;
$timeSlot = $existingAssignment->shift->timeSlot->name;
$time = $existingAssignment->shift->timeSlot->start_time
. '' . $existingAssignment->shift->timeSlot->end_time;
$message = $isClaim
? "Je bent al ingepland bij \"{$section}\" voor {$timeSlot} ({$time})."
: "Deze persoon is al ingepland bij \"{$section}\" voor {$timeSlot} ({$time}).";
throw ValidationException::withMessages([
'person_id' => [$message],
]);
}
}
/**
* @throws ValidationException
*/
private function validateStatusTransition(ShiftAssignment $assignment, ShiftAssignmentStatus $target): void
{
if (! $assignment->status->canTransitionTo($target)) {
throw ValidationException::withMessages([
'status' => ["Statusovergang van '{$assignment->status->value}' naar '{$target->value}' is niet toegestaan."],
]);
}
}
private function updateShiftStatusIfFull(Shift $shift): void
{
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount >= $shift->slots_total && $shift->status === 'open') {
$shift->update(['status' => 'full']);
}
}
private function updateShiftStatusAfterCancellation(Shift $shift): void
{
$approvedCount = $shift->shiftAssignments()
->where('status', ShiftAssignmentStatus::APPROVED)
->count();
if ($approvedCount < $shift->slots_total && $shift->status === 'full') {
$shift->update(['status' => 'open']);
}
}
}