authorize($actor); return DB::transaction(function () use ($reservation, $actor, $comment): Reservation { $locked = $this->lockPending($reservation); $items = $locked->items()->with('asset')->get(); SnipeAsset::query()->whereKey($items->pluck('snipe_asset_id'))->orderBy('id')->lockForUpdate()->get(); $unavailable = $items->filter(fn ($item): bool => ! $this->availability->isAvailable( $item->asset, $item->starts_at, $item->ends_at, $locked->id, )); if ($unavailable->isNotEmpty()) { throw ValidationException::withMessages([ 'decision' => 'A disponibilidade mudou: '.$unavailable->pluck('asset.name')->join(', ').'.', ]); } $this->transition($locked, $actor, ReservationStatus::Approved, 'approved', $comment, [ 'approved_by' => $actor->id, 'approved_at' => now(), 'decision_reason' => $comment, ]); $locked->items()->where('status', ReservationItemStatus::Held)->update([ 'status' => ReservationItemStatus::Reserved, ]); return $locked->load('requester', 'items.asset'); }, 3); } public function reject(Reservation $reservation, User $actor, string $reason): Reservation { $this->authorize($actor); return DB::transaction(function () use ($reservation, $actor, $reason): Reservation { $locked = $this->lockPending($reservation); $this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [ 'rejected_by' => $actor->id, 'rejected_at' => now(), 'decision_reason' => $reason, ]); $locked->items()->whereIn('status', ReservationItemStatus::blockingValues())->update([ 'status' => ReservationItemStatus::Cancelled, ]); return $locked->load('requester', 'items.asset'); }, 3); } public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation { $this->authorize($actor); return DB::transaction(function () use ($reservation, $actor, $comment): Reservation { $locked = $this->lockPending($reservation); $this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [ 'decision_reason' => $comment, 'approval_deadline' => now()->addHours(48), ]); return $locked->load('requester', 'items.asset'); }, 3); } private function authorize(User $actor): void { if (! $actor->active || (! $actor->hasRole(RoleName::Approver) && ! $actor->hasRole(RoleName::Administrator))) { throw new AuthorizationException('Não tem permissão para decidir requisições.'); } } private function lockPending(Reservation $reservation): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); if ($locked->status !== ReservationStatus::PendingApproval) { throw ValidationException::withMessages(['decision' => 'A requisição já não aguarda uma decisão.']); } return $locked; } /** @param array $attributes */ private function transition(Reservation $reservation, User $actor, ReservationStatus $next, string $action, ?string $comment, array $attributes): void { $previous = $reservation->status; if (! $previous->canTransitionTo($next)) { throw ValidationException::withMessages(['decision' => 'Transição de estado inválida.']); } $reservation->update(['status' => $next, ...$attributes]); ApprovalAction::create([ 'reservation_id' => $reservation->id, 'actor_id' => $actor->id, 'action' => $action, 'previous_status' => $previous->value, 'new_status' => $next->value, 'comment' => $comment, ]); AuditLog::create([ 'actor_id' => $actor->id, 'event' => "reservation.{$action}", 'auditable_type' => $reservation->getMorphClass(), 'auditable_id' => $reservation->id, 'before' => ['status' => $previous->value], 'after' => ['status' => $next->value, 'comment' => $comment], ]); } }