authorizeOperator($operator); return DB::transaction(function () use ($reservation, $operator): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $this->assertTransition($locked, ReservationStatus::Ready, 'A requisição não pode ser preparada neste estado.'); $locked->update(['status' => ReservationStatus::Ready]); $locked->items()->where('status', ReservationItemStatus::Reserved)->update([ 'status' => ReservationItemStatus::Ready, ]); $this->audit($locked, $operator, 'reservation.ready', ReservationStatus::Approved, ReservationStatus::Ready); return $locked->load('requester', 'items.asset'); }, 3); } public function deliver( Reservation $reservation, User $operator, int $snipeUserId, string $condition, ?string $notes = null, ): Reservation { $this->authorizeOperator($operator); $this->assertOperationalConfiguration(); $reservation = Reservation::query() ->with(['requester', 'items.asset']) ->findOrFail($reservation->id); if (! in_array($reservation->status, [ReservationStatus::Ready, ReservationStatus::Incident], true)) { throw ValidationException::withMessages(['operation' => 'Apenas requisições preparadas podem ser entregues.']); } $items = $reservation->items->where('status', ReservationItemStatus::Ready); if ($items->isEmpty()) { throw ValidationException::withMessages(['operation' => 'A requisição não tem equipamentos preparados para entrega.']); } $completed = []; foreach ($items as $item) { try { $asset = $item->asset; if ($asset->status_id === null) { throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]); } $response = $this->snipeIt->checkoutHardware( (int) $asset->snipe_asset_id, (int) $asset->status_id, $snipeUserId, $reservation->ends_at->toDateString(), $this->externalNote($reservation, $notes), ); DB::transaction(function () use ($item, $reservation, $operator, $condition, $notes, $response): void { $lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail(); if ($lockedItem->status !== ReservationItemStatus::Ready) { throw ValidationException::withMessages(['operation' => 'O estado local do equipamento mudou durante a entrega.']); } $lockedItem->update([ 'status' => ReservationItemStatus::Delivered, 'delivered_at' => now(), 'checkout_reference' => 'snipe-hardware:'.$lockedItem->asset->snipe_asset_id, 'condition_out' => $condition, 'notes' => $notes, ]); HandoverRecord::create([ 'reservation_item_id' => $lockedItem->id, 'type' => HandoverType::Checkout, 'operator_id' => $operator->id, 'recipient_id' => $reservation->requester_id, 'condition' => $condition, 'notes' => $notes, 'snipe_response' => $response, ]); }, 3); $completed[] = $item->id; } catch (Throwable $exception) { $this->recordPartialFailure($reservation, $operator, 'delivery', $completed, $item, $exception); throw ValidationException::withMessages([ 'operation' => "A entrega parou no equipamento {$item->asset->asset_tag}. ".$exception->getMessage(), ]); } } return DB::transaction(function () use ($reservation, $operator, $snipeUserId, $completed): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); if ($locked->items()->where('status', ReservationItemStatus::Ready)->exists()) { throw ValidationException::withMessages(['operation' => 'Nem todos os equipamentos ficaram entregues.']); } $previous = $locked->status; $locked->requester()->update(['snipe_user_id' => $snipeUserId]); $locked->update(['status' => ReservationStatus::Delivered]); $this->audit( $locked, $operator, 'reservation.delivered', $previous, ReservationStatus::Delivered, ['item_ids' => $completed, 'snipe_user_id' => $snipeUserId], ); return $locked->load('requester', 'items.asset', 'items.handoverRecords'); }, 3); } public function returnEquipment( Reservation $reservation, User $operator, string $condition, ?string $notes = null, ): Reservation { $this->authorizeOperator($operator); $this->assertOperationalConfiguration(); $reservation = Reservation::query()->with(['requester', 'items.asset'])->findOrFail($reservation->id); if (! in_array($reservation->status, [ ReservationStatus::Delivered, ReservationStatus::InUse, ReservationStatus::Overdue, ReservationStatus::Incident, ], true)) { throw ValidationException::withMessages(['operation' => 'Esta requisição não está em condições de ser devolvida.']); } $items = $reservation->items->where('status', ReservationItemStatus::Delivered); if ($items->isEmpty()) { throw ValidationException::withMessages(['operation' => 'Não existem equipamentos entregues por devolver.']); } $completed = []; foreach ($items as $item) { try { $asset = $item->asset; if ($asset->status_id === null) { throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]); } if ($reservation->requester->snipe_user_id === null) { throw ValidationException::withMessages(['operation' => 'O destinatário não tem associação Snipe-IT confirmada.']); } $hasPortalCheckout = $item->handoverRecords() ->where('type', HandoverType::Checkout->value) ->where('recipient_id', $reservation->requester_id) ->exists(); if (! $hasPortalCheckout) { throw ValidationException::withMessages([ 'operation' => "O equipamento {$asset->asset_tag} não tem um checkout confirmado pelo portal; o check-in foi bloqueado.", ]); } $response = $this->snipeIt->checkinHardware( (int) $asset->snipe_asset_id, (int) $asset->status_id, (int) $reservation->requester->snipe_user_id, $asset->location_id === null ? null : (int) $asset->location_id, $this->externalNote($reservation, $notes), ); DB::transaction(function () use ($item, $operator, $condition, $notes, $response): void { $lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail(); if ($lockedItem->status !== ReservationItemStatus::Delivered) { throw ValidationException::withMessages(['operation' => 'O estado local do equipamento mudou durante a devolução.']); } $lockedItem->update([ 'status' => ReservationItemStatus::Returned, 'returned_at' => now(), 'checkin_reference' => 'snipe-hardware:'.$lockedItem->asset->snipe_asset_id, 'condition_in' => $condition, 'notes' => $notes, ]); HandoverRecord::create([ 'reservation_item_id' => $lockedItem->id, 'type' => HandoverType::Checkin, 'operator_id' => $operator->id, 'recipient_id' => null, 'condition' => $condition, 'notes' => $notes, 'snipe_response' => $response, ]); }, 3); $completed[] = $item->id; } catch (Throwable $exception) { $this->recordPartialFailure($reservation, $operator, 'return', $completed, $item, $exception); throw ValidationException::withMessages([ 'operation' => "A devolução parou no equipamento {$item->asset->asset_tag}. ".$exception->getMessage(), ]); } } return DB::transaction(function () use ($reservation, $operator, $completed): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $previous = $locked->status; if ($locked->items()->where('status', ReservationItemStatus::Delivered)->exists()) { throw ValidationException::withMessages(['operation' => 'Nem todos os equipamentos ficaram devolvidos.']); } if ($previous === ReservationStatus::Incident) { $locked->items()->where('status', ReservationItemStatus::Ready)->update([ 'status' => ReservationItemStatus::Cancelled, ]); } $locked->update(['status' => ReservationStatus::Returned]); $this->audit($locked, $operator, 'reservation.returned', $previous, ReservationStatus::Returned, ['item_ids' => $completed]); return $locked->load('requester', 'items.asset', 'items.handoverRecords'); }, 3); } public function complete(Reservation $reservation, User $operator): Reservation { $this->authorizeOperator($operator); return DB::transaction(function () use ($reservation, $operator): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $previous = $locked->status; $this->assertTransition($locked, ReservationStatus::Completed, 'Apenas requisições devolvidas podem ser concluídas.'); if ($locked->items()->whereNotIn('status', [ ReservationItemStatus::Returned, ReservationItemStatus::Cancelled, ])->exists()) { throw ValidationException::withMessages(['operation' => 'Ainda existem equipamentos por devolver ou reconciliar.']); } $locked->update(['status' => ReservationStatus::Completed]); $this->audit($locked, $operator, 'reservation.completed', $previous, ReservationStatus::Completed); return $locked->load('requester', 'items.asset'); }, 3); } public function markNoShow(Reservation $reservation, User $operator, string $notes): Reservation { $this->authorizeOperator($operator); return DB::transaction(function () use ($reservation, $operator, $notes): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $this->assertTransition($locked, ReservationStatus::NoShow, 'A requisição não pode ser marcada como falta neste estado.'); $locked->update(['status' => ReservationStatus::NoShow]); $locked->items()->where('status', ReservationItemStatus::Ready)->update(['status' => ReservationItemStatus::Cancelled]); $this->audit($locked, $operator, 'reservation.no_show', ReservationStatus::Ready, ReservationStatus::NoShow, ['notes' => $notes]); return $locked->load('requester', 'items.asset'); }, 3); } public function markOverdue(Reservation $reservation, User $operator, string $notes): Reservation { $this->authorizeOperator($operator); return DB::transaction(function () use ($reservation, $operator, $notes): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $previous = $locked->status; $this->assertTransition($locked, ReservationStatus::Overdue, 'A requisição não pode ser marcada como atrasada neste estado.'); $locked->update(['status' => ReservationStatus::Overdue]); $this->audit($locked, $operator, 'reservation.overdue', $previous, ReservationStatus::Overdue, ['notes' => $notes]); return $locked->load('requester', 'items.asset'); }, 3); } public function reportIncident( Reservation $reservation, ReservationItem $item, User $operator, string $notes, ?CarbonImmutable $blockedUntil = null, ): Reservation { $this->authorizeOperator($operator); return DB::transaction(function () use ($reservation, $item, $operator, $notes, $blockedUntil): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); if ($item->reservation_id !== $locked->id) { throw ValidationException::withMessages(['operation' => 'O equipamento não pertence a esta requisição.']); } $previous = $locked->status; if ($previous !== ReservationStatus::Incident) { $this->assertTransition($locked, ReservationStatus::Incident, 'Não é possível registar um incidente neste estado.'); $locked->update(['status' => ReservationStatus::Incident]); } if ($blockedUntil !== null) { if ($blockedUntil->lessThanOrEqualTo(now())) { throw ValidationException::withMessages(['blocked_until' => 'O bloqueio tem de terminar no futuro.']); } AssetBlock::create([ 'snipe_asset_id' => $item->snipe_asset_id, 'starts_at' => now(), 'ends_at' => $blockedUntil, 'reason' => $notes, 'created_by' => $operator->id, ]); } $this->audit($locked, $operator, 'reservation.incident_reported', $previous, ReservationStatus::Incident, [ 'reservation_item_id' => $item->id, 'notes' => $notes, 'blocked_until' => $blockedUntil?->toIso8601String(), ]); return $locked->load('requester', 'items.asset'); }, 3); } /** @param list $completed */ private function recordPartialFailure( Reservation $reservation, User $operator, string $operation, array $completed, ReservationItem $failedItem, Throwable $exception, ): void { DB::transaction(function () use ($reservation, $operator, $operation, $completed, $failedItem, $exception): void { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); $previous = $locked->status; if ($completed !== [] && $previous !== ReservationStatus::Incident && $previous->canTransitionTo(ReservationStatus::Incident)) { $locked->update(['status' => ReservationStatus::Incident]); } AuditLog::create([ 'actor_id' => $operator->id, 'event' => "reservation.{$operation}_failed", 'auditable_type' => $locked->getMorphClass(), 'auditable_id' => $locked->id, 'before' => ['status' => $previous->value], 'after' => ['status' => $locked->fresh()->status->value], 'metadata' => [ 'completed_item_ids' => $completed, 'failed_item_id' => $failedItem->id, 'error' => $exception->getMessage(), ], ]); }, 3); } private function assertOperationalConfiguration(): void { if (! $this->snipeIt->isOperationallyConfigured()) { throw ValidationException::withMessages(['operation' => 'Configure o token operacional do Snipe-IT antes de registar movimentos.']); } } private function authorizeOperator(User $operator): void { if (! $operator->active || (! $operator->hasRole(RoleName::InventoryOperator) && ! $operator->hasRole(RoleName::Administrator))) { throw new AuthorizationException('Esta operação exige o perfil de operador de inventário.'); } } private function assertTransition(Reservation $reservation, ReservationStatus $next, string $message): void { if (! $reservation->status->canTransitionTo($next)) { throw ValidationException::withMessages(['operation' => $message]); } } /** @param array $metadata */ private function audit( Reservation $reservation, User $operator, string $event, ReservationStatus $before, ReservationStatus $after, array $metadata = [], ): void { AuditLog::create([ 'actor_id' => $operator->id, 'event' => $event, 'auditable_type' => $reservation->getMorphClass(), 'auditable_id' => $reservation->id, 'before' => ['status' => $before->value], 'after' => ['status' => $after->value], 'metadata' => $metadata, ]); } private function externalNote(Reservation $reservation, ?string $notes): string { return trim("Portal {$reservation->reference}".($notes === null ? '' : ": {$notes}")); } }