The approve() and bulkApprove() methods in ShiftAssignmentService hard-blocked with a 422 when all slots were filled. This was incorrect for organiser actions — only volunteer claims (portal self-service) should enforce capacity limits. Organiser assign() already allowed overbooking, making the approve block inconsistent. Changes: - Remove capacity hard-block from approve() and bulkApprove(), replace with audit log entry (shift.overbooked_approval) - Add overbook confirmation dialog in ShiftDetailPanel before approving a full shift (single + bulk approve) - Add onError handlers to all mutations in ShiftDetailPanel (approve, reject, cancel, bulk-approve) so errors display in the snackbar - Add global 422 validation error display in axios interceptor via useNotificationStore as safety net for all components - Add PHPUnit test for approve-when-full scenario Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
463 lines
16 KiB
PHP
463 lines
16 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\CancellationSource;
|
||
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,
|
||
]);
|
||
|
||
$assignment->auto_approved = $autoApprove;
|
||
$assignment->assigned_at = now();
|
||
$assignment->approved_at = $autoApprove ? now() : null;
|
||
$assignment->save();
|
||
|
||
$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);
|
||
|
||
// Check for existing cancelled/rejected assignment on THIS shift
|
||
$existing = ShiftAssignment::where('shift_id', $shift->id)
|
||
->where('person_id', $person->id)
|
||
->whereIn('status', [
|
||
ShiftAssignmentStatus::CANCELLED,
|
||
ShiftAssignmentStatus::REJECTED,
|
||
])
|
||
->first();
|
||
|
||
if ($existing) {
|
||
$previousStatus = $existing->status->value;
|
||
|
||
$existing->status = ShiftAssignmentStatus::APPROVED;
|
||
$existing->assigned_by = $assignedBy->id;
|
||
$existing->assigned_at = now();
|
||
$existing->approved_by = $assignedBy->id;
|
||
$existing->approved_at = now();
|
||
$existing->rejection_reason = null;
|
||
$existing->cancelled_by = null;
|
||
$existing->cancellation_source = null;
|
||
$existing->cancelled_at = null;
|
||
$existing->save();
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($assignedBy)
|
||
->performedOn($existing)
|
||
->withProperties([
|
||
'previous_status' => $previousStatus,
|
||
'person_name' => $person->full_name,
|
||
])
|
||
->log('shift_assignment.reactivated');
|
||
|
||
$this->updateShiftStatusIfFull($shift);
|
||
|
||
return $existing->fresh();
|
||
}
|
||
|
||
// 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->full_name,
|
||
])
|
||
->log('shift.overbooked_assignment');
|
||
}
|
||
|
||
$assignment = $shift->shiftAssignments()->create([
|
||
'person_id' => $person->id,
|
||
'time_slot_id' => $shift->time_slot_id,
|
||
'status' => ShiftAssignmentStatus::APPROVED,
|
||
]);
|
||
|
||
$assignment->auto_approved = false;
|
||
$assignment->assigned_by = $assignedBy->id;
|
||
$assignment->assigned_at = now();
|
||
$assignment->approved_by = $assignedBy->id;
|
||
$assignment->approved_at = now();
|
||
$assignment->save();
|
||
|
||
$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;
|
||
$oldStatus = $assignment->status;
|
||
|
||
// Log overbooking for audit trail (organisers may intentionally overbook)
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount >= $shift->slots_total) {
|
||
activity('shift_assignment')
|
||
->causedBy($approvedBy)
|
||
->performedOn($shift)
|
||
->withProperties([
|
||
'filled_slots' => $approvedCount,
|
||
'slots_total' => $shift->slots_total,
|
||
'assignment_id' => $assignment->id,
|
||
'person_id' => $assignment->person_id,
|
||
])
|
||
->log('shift.overbooked_approval');
|
||
}
|
||
|
||
$assignment->status = ShiftAssignmentStatus::APPROVED;
|
||
$assignment->approved_by = $approvedBy->id;
|
||
$assignment->approved_at = now();
|
||
$assignment->save();
|
||
|
||
$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->status = ShiftAssignmentStatus::REJECTED;
|
||
$assignment->rejection_reason = $reason;
|
||
$assignment->save();
|
||
|
||
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,
|
||
CancellationSource $source = CancellationSource::ORGANISER,
|
||
): ShiftAssignment {
|
||
return DB::transaction(function () use ($assignment, $cancelledBy, $source): ShiftAssignment {
|
||
$this->validateStatusTransition($assignment, ShiftAssignmentStatus::CANCELLED);
|
||
|
||
$wasApproved = $assignment->status === ShiftAssignmentStatus::APPROVED;
|
||
$oldStatus = $assignment->status;
|
||
|
||
$assignment->status = ShiftAssignmentStatus::CANCELLED;
|
||
$assignment->cancelled_by = $cancelledBy->id;
|
||
$assignment->cancellation_source = $source;
|
||
$assignment->cancelled_at = now();
|
||
$assignment->save();
|
||
|
||
if ($wasApproved) {
|
||
$this->updateShiftStatusAfterCancellation($assignment->shift);
|
||
}
|
||
|
||
activity('shift_assignment')
|
||
->causedBy($cancelledBy)
|
||
->performedOn($assignment)
|
||
->withProperties([
|
||
'old_status' => $oldStatus->value,
|
||
'new_status' => ShiftAssignmentStatus::CANCELLED->value,
|
||
'source' => $source->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;
|
||
|
||
// Log overbooking for audit trail
|
||
$approvedCount = $shift->shiftAssignments()
|
||
->where('status', ShiftAssignmentStatus::APPROVED)
|
||
->count();
|
||
|
||
if ($approvedCount >= $shift->slots_total) {
|
||
activity('shift_assignment')
|
||
->causedBy($approvedBy)
|
||
->performedOn($shift)
|
||
->withProperties([
|
||
'filled_slots' => $approvedCount,
|
||
'slots_total' => $shift->slots_total,
|
||
'assignment_id' => $assignment->id,
|
||
])
|
||
->log('shift.overbooked_approval');
|
||
}
|
||
|
||
$assignment->status = ShiftAssignmentStatus::APPROVED;
|
||
$assignment->approved_by = $approvedBy->id;
|
||
$assignment->approved_at = now();
|
||
$assignment->save();
|
||
|
||
$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']);
|
||
}
|
||
}
|
||
}
|