authorize($actor); $approved = 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); $approved->requester->notify(new ReservationDecisionNotification($approved, 'approved', $comment)); return $approved; } public function reject(Reservation $reservation, User $actor, string $reason): Reservation { $this->authorize($actor); $rejected = 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); $rejected->requester->notify(new ReservationDecisionNotification($rejected, 'rejected', $reason)); return $rejected; } public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation { $this->authorize($actor); $awaitingInformation = 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); $awaitingInformation->requester->notify( new ReservationDecisionNotification($awaitingInformation, 'information_requested', $comment) ); return $awaitingInformation; } public function substituteAsset( Reservation $reservation, ReservationItem $item, SnipeAsset $replacement, User $actor, string $reason, ): Reservation { $this->authorize($actor); $updated = DB::transaction(function () use ($reservation, $item, $replacement, $actor, $reason): Reservation { $locked = $this->lockPending($reservation); $lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail(); if ($lockedItem->reservation_id !== $locked->id || $lockedItem->status !== ReservationItemStatus::Held) { throw ValidationException::withMessages(['replacement_asset_id' => 'O item já não pode ser substituído.']); } $assets = SnipeAsset::query() ->whereKey([$lockedItem->snipe_asset_id, $replacement->id]) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $original = $assets->get($lockedItem->snipe_asset_id); $newAsset = $assets->get($replacement->id); if ($original === null || $newAsset === null || ! $this->sameCategory($original, $newAsset)) { throw ValidationException::withMessages(['replacement_asset_id' => 'Selecione um equipamento da mesma categoria.']); } if ($locked->items()->where('snipe_asset_id', $newAsset->id)->exists() || ! $this->availability->isAvailable($newAsset, $lockedItem->starts_at, $lockedItem->ends_at)) { throw ValidationException::withMessages(['replacement_asset_id' => 'O equipamento substituto não está disponível.']); } $lockedItem->update(['status' => ReservationItemStatus::Cancelled]); $locked->items()->create([ 'snipe_asset_id' => $newAsset->id, 'starts_at' => $lockedItem->starts_at, 'ends_at' => $lockedItem->ends_at, 'status' => ReservationItemStatus::Held, 'notes' => "Substitui {$original->asset_tag}: {$reason}", ]); ApprovalAction::create([ 'reservation_id' => $locked->id, 'actor_id' => $actor->id, 'action' => 'asset_substituted', 'previous_status' => $locked->status->value, 'new_status' => $locked->status->value, 'comment' => $reason, ]); AuditLog::create([ 'actor_id' => $actor->id, 'event' => 'reservation.asset_substituted', 'auditable_type' => $locked->getMorphClass(), 'auditable_id' => $locked->id, 'metadata' => [ 'original_asset_id' => $original->id, 'replacement_asset_id' => $newAsset->id, 'reason' => $reason, ], ]); return $locked->load('requester', 'items.asset'); }, 3); $updated->requester->notify(new ReservationDecisionNotification($updated, 'asset_substituted', $reason)); return $updated; } 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 sameCategory(SnipeAsset $original, SnipeAsset $replacement): bool { if ($original->category_id !== null && $replacement->category_id !== null) { return $original->category_id === $replacement->category_id; } return filled($original->category_name) && $original->category_name === $replacement->category_name; } 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], ]); } }