Implements the complete ShiftAssignment lifecycle: - ShiftAssignmentStatus enum with allowed transitions - ShiftAssignmentService with claim/assign/approve/reject/cancel/bulkApprove - ShiftAssignmentController with event-scoped endpoints - ShiftAssignmentPolicy (organizer + volunteer self-cancel) - VolunteerAvailability model, controller, and sync endpoint - Refactored ShiftController to delegate to service layer - 31 workflow tests covering all paths and multi-tenancy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
378 lines
12 KiB
PHP
378 lines
12 KiB
PHP
<?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);
|
|
|
|
$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->validateShiftIsOpen($shift);
|
|
$this->validateAssignCapacity($shift);
|
|
$this->validateNoConflict($shift, $person);
|
|
|
|
$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): void
|
|
{
|
|
if ($shift->allow_overlap) {
|
|
return;
|
|
}
|
|
|
|
$conflict = ShiftAssignment::where('person_id', $person->id)
|
|
->where('time_slot_id', $shift->time_slot_id)
|
|
->active()
|
|
->exists();
|
|
|
|
if ($conflict) {
|
|
throw ValidationException::withMessages([
|
|
'person_id' => ['Deze persoon is al ingepland voor dit tijdslot.'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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']);
|
|
}
|
|
}
|
|
}
|