From d829f3458075583f55e60e87273ca255a7f88f7f Mon Sep 17 00:00:00 2001 From: joaomiranda Date: Tue, 4 Aug 2026 17:15:51 +0100 Subject: [PATCH] feat: complete equipment handover workflow --- CHANGELOG.md | 9 + README.md | 10 +- app/Domain/Operations/Enums/HandoverType.php | 9 + .../Operations/Models/HandoverRecord.php | 46 ++ .../Operations/Services/HandoverService.php | 401 +++++++++++ .../Reservations/Enums/ReservationStatus.php | 6 +- .../Reservations/Models/ReservationItem.php | 8 + app/Http/Controllers/OperationController.php | 138 ++++ app/Http/Middleware/HandleInertiaRequests.php | 1 + app/Integrations/SnipeIt/SnipeItClient.php | 112 +++- app/Models/User.php | 2 +- app/Providers/AppServiceProvider.php | 5 + ...00600_add_snipe_user_id_to_users_table.php | 22 + docs/ARCHITECTURE.md | 8 +- docs/DECISIONS.md | 11 +- docs/PROJECT_STATUS.md | 37 +- docs/RUNBOOK.md | 27 +- resources/js/components/app-sidebar.tsx | 15 +- .../js/components/reservation-status.tsx | 5 + resources/js/pages/operations/index.tsx | 620 ++++++++++++++++++ resources/js/types/global.d.ts | 1 + routes/web.php | 12 + tests/Feature/OperationsWorkflowTest.php | 272 ++++++++ tests/Feature/SnipeItClientTest.php | 51 ++ 24 files changed, 1800 insertions(+), 28 deletions(-) create mode 100644 app/Domain/Operations/Enums/HandoverType.php create mode 100644 app/Domain/Operations/Models/HandoverRecord.php create mode 100644 app/Domain/Operations/Services/HandoverService.php create mode 100644 app/Http/Controllers/OperationController.php create mode 100644 database/migrations/2026_08_04_000600_add_snipe_user_id_to_users_table.php create mode 100644 resources/js/pages/operations/index.tsx create mode 100644 tests/Feature/OperationsWorkflowTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2143825..b9f1d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Resposta auditada a pedidos de esclarecimento e renovação do prazo de decisão. - Substituição controlada por equipamento disponível da mesma categoria, preservando o item original no histórico. - Filtros da fila de aprovação por categoria/período e histórico de ações apresentado na interface. +- Cliente operacional Snipe-IT com token separado, checkout para utilizador, check-in para localização habitual e validação defensiva de respostas. +- Domínio `Operations` com registos de entrega/devolução, condição física, operador, destinatário e resposta externa. +- Área diária do operador com pesquisa/leitura por scanner, preparação, entrega, devolução, conclusão, faltas, atrasos e incidentes. +- Associação do utilizador local ao ID Snipe-IT confirmada durante a entrega. +- Bloqueios temporais de equipamento criados a partir de incidentes e considerados pelo motor de disponibilidade. +- Tratamento e auditoria de falhas parciais em movimentos com vários equipamentos. ### Alterado @@ -38,11 +44,13 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - As mensagens flash globais recebem agora as propriedades diretamente de `withApp`, evitando o uso de `usePage()` fora do contexto Inertia que interrompia o arranque do React. - SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite. - Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo. +- Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução. ### Segurança - Registo público desativado. - Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores. +- Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático. ### Verificado @@ -56,3 +64,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Comando de expiração executado em PostgreSQL e agendamento a cada cinco minutos confirmado. - Builds Vite cliente e SSR com as páginas de disponibilidade, cancelamento e calendário concluídos. - Migration da caixa interna de notificações aplicada em PostgreSQL 17. +- Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada. diff --git a/README.md b/README.md index a7ea847..32ad845 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame ## Estado atual -O projeto concluiu as **Fases 1 a 4**: fundação técnica, integração de leitura Snipe-IT, motor de reservas e aprovações. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, cancelamento e expiração, calendário pessoal, decisões transacionais, esclarecimentos, substituição equivalente, notificações por email/caixa interna, dashboard e diagnóstico administrativo. A próxima entrega implementa o fluxo físico de entrega e devolução. +O projeto concluiu as **Fases 1 a 5**: fundação técnica, integração Snipe-IT, motor de reservas, aprovações e operações físicas. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, calendário pessoal, decisões e notificações, checkout/check-in Snipe-IT, área diária do operador, entrega/devolução auditada, incidentes, atrasos e bloqueios. A próxima entrega é a administração e os relatórios. Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada. @@ -34,7 +34,7 @@ Estas credenciais existem apenas no `DatabaseSeeder` e nunca devem ser usadas em ## Comandos frequentes ```bash -docker compose exec app php artisan test +docker compose exec app composer test docker compose exec app composer lint docker compose exec app composer types:check docker compose exec node npm run types:check @@ -42,6 +42,12 @@ docker compose exec node npm run lint:check docker compose exec node npm run build ``` +Como o serviço Docker define variáveis de desenvolvimento, use o script Composer para testes. O script força de forma isolada `APP_ENV=testing`, SQLite em memória, cache/sessão em memória e fila síncrona, evitando locks Redis ou middleware CSRF do ambiente local: + +```bash +docker compose exec app composer test +``` + Para repor apenas dados de desenvolvimento: ```bash diff --git a/app/Domain/Operations/Enums/HandoverType.php b/app/Domain/Operations/Enums/HandoverType.php new file mode 100644 index 0000000..34e04d5 --- /dev/null +++ b/app/Domain/Operations/Enums/HandoverType.php @@ -0,0 +1,9 @@ + HandoverType::class, + 'snipe_response' => 'array', + 'created_at' => 'immutable_datetime', + ]; + } + + /** @return BelongsTo */ + public function reservationItem(): BelongsTo + { + return $this->belongsTo(ReservationItem::class); + } + + /** @return BelongsTo */ + public function operator(): BelongsTo + { + return $this->belongsTo(User::class, 'operator_id'); + } + + /** @return BelongsTo */ + public function recipient(): BelongsTo + { + return $this->belongsTo(User::class, 'recipient_id'); + } +} diff --git a/app/Domain/Operations/Services/HandoverService.php b/app/Domain/Operations/Services/HandoverService.php new file mode 100644 index 0000000..d860368 --- /dev/null +++ b/app/Domain/Operations/Services/HandoverService.php @@ -0,0 +1,401 @@ +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('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."]); + } + + $response = $this->snipeIt->checkinHardware( + (int) $asset->snipe_asset_id, + (int) $asset->status_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}")); + } +} diff --git a/app/Domain/Reservations/Enums/ReservationStatus.php b/app/Domain/Reservations/Enums/ReservationStatus.php index bb6c5cf..50ed3a3 100644 --- a/app/Domain/Reservations/Enums/ReservationStatus.php +++ b/app/Domain/Reservations/Enums/ReservationStatus.php @@ -28,9 +28,11 @@ enum ReservationStatus: string self::PendingApproval => [self::AwaitingInformation, self::Approved, self::Rejected, self::Cancelled, self::Expired], self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired], self::Approved => [self::Ready, self::Cancelled], - self::Ready => [self::Delivered, self::NoShow, self::Cancelled], + self::Ready => [self::Delivered, self::Incident, self::NoShow, self::Cancelled], self::Delivered => [self::InUse, self::Returned, self::Overdue, self::Incident], - self::InUse, self::Overdue, self::Incident => [self::Returned], + self::InUse => [self::Returned, self::Overdue, self::Incident], + self::Overdue => [self::Returned, self::Incident], + self::Incident => [self::Delivered, self::Returned, self::Completed], self::Returned => [self::Completed, self::Incident], self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [], }; diff --git a/app/Domain/Reservations/Models/ReservationItem.php b/app/Domain/Reservations/Models/ReservationItem.php index 29b8bea..4b05948 100644 --- a/app/Domain/Reservations/Models/ReservationItem.php +++ b/app/Domain/Reservations/Models/ReservationItem.php @@ -3,10 +3,12 @@ namespace App\Domain\Reservations\Models; use App\Domain\Inventory\Models\SnipeAsset; +use App\Domain\Operations\Models\HandoverRecord; use App\Domain\Reservations\Enums\ReservationItemStatus; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property ReservationItemStatus $status @@ -44,4 +46,10 @@ class ReservationItem extends Model { return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id'); } + + /** @return HasMany */ + public function handoverRecords(): HasMany + { + return $this->hasMany(HandoverRecord::class); + } } diff --git a/app/Http/Controllers/OperationController.php b/app/Http/Controllers/OperationController.php new file mode 100644 index 0000000..acf182a --- /dev/null +++ b/app/Http/Controllers/OperationController.php @@ -0,0 +1,138 @@ +startOfDay(); + $end = now()->addDay()->startOfDay(); + + return Inertia::render('operations/index', [ + 'operationallyConfigured' => $snipeIt->isOperationallyConfigured(), + 'reservations' => Reservation::query() + ->with([ + 'requester:id,name,email,department,snipe_user_id', + 'items.asset:id,snipe_asset_id,name,asset_tag,status_id,status_name,location_id,location_name', + 'items.handoverRecords:id,reservation_item_id,type,operator_id,condition,notes,created_at', + 'items.handoverRecords.operator:id,name', + ]) + ->whereIn('status', [ + ReservationStatus::Approved, + ReservationStatus::Ready, + ReservationStatus::Delivered, + ReservationStatus::InUse, + ReservationStatus::Returned, + ReservationStatus::Overdue, + ReservationStatus::Incident, + ]) + ->where(function ($query) use ($start, $end) { + $query->where(function ($query) use ($start, $end) { + $query->where('starts_at', '<', $end)->where('ends_at', '>', $start); + })->orWhereIn('status', [ + ReservationStatus::Delivered, + ReservationStatus::InUse, + ReservationStatus::Returned, + ReservationStatus::Overdue, + ReservationStatus::Incident, + ]); + }) + ->orderByRaw("CASE WHEN status IN ('overdue', 'incident') THEN 0 ELSE 1 END") + ->orderBy('starts_at') + ->limit(50) + ->get(), + ]); + } + + public function prepare(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse + { + $service->markReady($reservation, $request->user()); + + return back()->with('success', "Requisição {$reservation->reference} preparada."); + } + + public function deliver(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse + { + $data = $request->validate([ + 'snipe_user_id' => ['required', 'integer', 'min:1'], + 'condition' => ['required', 'string', 'min:3', 'max:500'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]); + $service->deliver( + $reservation, + $request->user(), + (int) $data['snipe_user_id'], + $data['condition'], + $data['notes'] ?? null, + ); + + return back()->with('success', "Entrega de {$reservation->reference} registada no portal e no Snipe-IT."); + } + + public function returnEquipment(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse + { + $data = $request->validate([ + 'condition' => ['required', 'string', 'min:3', 'max:500'], + 'notes' => ['nullable', 'string', 'max:1000'], + ]); + $service->returnEquipment($reservation, $request->user(), $data['condition'], $data['notes'] ?? null); + + return back()->with('success', "Devolução de {$reservation->reference} registada no portal e no Snipe-IT."); + } + + public function complete(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse + { + $service->complete($reservation, $request->user()); + + return back()->with('success', "Requisição {$reservation->reference} concluída."); + } + + public function noShow(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse + { + $data = $request->validate(['notes' => ['required', 'string', 'min:3', 'max:1000']]); + $service->markNoShow($reservation, $request->user(), $data['notes']); + + return back()->with('success', "Falta registada em {$reservation->reference}."); + } + + public function overdue(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse + { + $data = $request->validate(['notes' => ['required', 'string', 'min:3', 'max:1000']]); + $service->markOverdue($reservation, $request->user(), $data['notes']); + + return back()->with('success', "Atraso registado em {$reservation->reference}."); + } + + public function incident( + Request $request, + Reservation $reservation, + ReservationItem $item, + HandoverService $service, + ): RedirectResponse { + $data = $request->validate([ + 'notes' => ['required', 'string', 'min:5', 'max:1000'], + 'blocked_until' => ['nullable', 'date', 'after:now'], + ]); + $service->reportIncident( + $reservation, + $item, + $request->user(), + $data['notes'], + isset($data['blocked_until']) ? CarbonImmutable::parse($data['blocked_until']) : null, + ); + + return back()->with('success', "Incidente registado em {$reservation->reference}."); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0c26e1e..b2744d4 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -48,6 +48,7 @@ class HandleInertiaRequests extends Middleware 'can' => [ 'administer' => fn (): bool => $request->user()?->can('administer') ?? false, 'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false, + 'operateInventory' => fn (): bool => $request->user()?->can('operate-inventory') ?? false, ], 'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0, 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', diff --git a/app/Integrations/SnipeIt/SnipeItClient.php b/app/Integrations/SnipeIt/SnipeItClient.php index d6067f8..3784ca5 100644 --- a/app/Integrations/SnipeIt/SnipeItClient.php +++ b/app/Integrations/SnipeIt/SnipeItClient.php @@ -9,6 +9,7 @@ use Generator; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Throwable; @@ -20,6 +21,11 @@ final class SnipeItClient return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token')); } + public function isOperationallyConfigured(): bool + { + return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.operational_token')); + } + public function configuredUrl(): ?string { $url = trim((string) config('services.snipe_it.url')); @@ -32,7 +38,7 @@ final class SnipeItClient $pageSize = $limit ?? (int) config('services.snipe_it.page_size', 100); $pageSize = max(1, min(500, $pageSize)); - $response = $this->request()->get('/hardware', [ + $response = $this->readRequest()->get('/hardware', [ 'limit' => $pageSize, 'offset' => max(0, $offset), 'sort' => 'id', @@ -92,7 +98,61 @@ final class SnipeItClient } while ($received > 0 && $offset < $page->total); } - private function request(): PendingRequest + /** @return array */ + public function checkoutHardware( + int $hardwareId, + int $statusId, + int $assignedUserId, + string $expectedCheckin, + ?string $note = null, + ): array { + $this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId); + + $payload = [ + 'status_id' => $statusId, + 'checkout_to_type' => 'user', + 'assigned_user' => $assignedUserId, + 'expected_checkin' => $expectedCheckin, + ]; + + if (filled($note)) { + $payload['note'] = $note; + } + + $response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkout", $payload); + + return $this->operationPayload($response, 'entrega'); + } + + /** @return array */ + public function checkinHardware( + int $hardwareId, + int $statusId, + ?int $locationId = null, + ?string $note = null, + ): array { + $this->assertPositiveIdentifiers($hardwareId, $statusId); + + if ($locationId !== null && $locationId < 1) { + throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.'); + } + + $payload = ['status_id' => $statusId]; + + if ($locationId !== null) { + $payload['location_id'] = $locationId; + } + + if (filled($note)) { + $payload['note'] = $note; + } + + $response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkin", $payload); + + return $this->operationPayload($response, 'devolução'); + } + + private function readRequest(): PendingRequest { if (! $this->isConfigured()) { throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.'); @@ -116,6 +176,54 @@ final class SnipeItClient ); } + private function operationalRequest(): PendingRequest + { + if (! $this->isOperationallyConfigured()) { + throw new SnipeItConfigurationException('A URL e o token operacional do Snipe-IT ainda não estão configurados.'); + } + + return Http::baseUrl($this->apiBaseUrl()) + ->acceptJson() + ->asJson() + ->withToken((string) config('services.snipe_it.operational_token')) + ->withUserAgent(Str::slug((string) config('app.name')).'/inventory-operations') + ->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10)))) + ->timeout(max(1, (int) config('services.snipe_it.timeout', 10))); + } + + /** @return array */ + private function operationPayload(Response $response, string $operation): array + { + if (! $response->successful()) { + throw new SnipeItApiException( + "O Snipe-IT respondeu com HTTP {$response->status()} durante a {$operation}.", + $response->status(), + ); + } + + $payload = $response->json(); + + if (! is_array($payload)) { + throw new SnipeItApiException("O Snipe-IT devolveu uma resposta inválida durante a {$operation}."); + } + + if (($payload['status'] ?? null) !== 'success') { + throw new SnipeItApiException("O Snipe-IT recusou a {$operation} apesar de responder com HTTP 200."); + } + + /** @var array $payload */ + return $payload; + } + + private function assertPositiveIdentifiers(int ...$identifiers): void + { + foreach ($identifiers as $identifier) { + if ($identifier < 1) { + throw new SnipeItConfigurationException('Os identificadores do Snipe-IT têm de ser positivos.'); + } + } + } + private function apiBaseUrl(): string { $baseUrl = $this->configuredUrl(); diff --git a/app/Models/User.php b/app/Models/User.php index 07e9c78..04d023d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; * @property Carbon|null $created_at * @property Carbon|null $updated_at */ -#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'active', 'last_login_at'])] +#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements PasskeyUser { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a92f8b3..5e4ee98 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -37,6 +37,11 @@ class AppServiceProvider extends ServiceProvider fn (User $user): bool => $user->active && ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)), ); + Gate::define( + 'operate-inventory', + fn (User $user): bool => $user->active + && ($user->hasRole(RoleName::InventoryOperator) || $user->hasRole(RoleName::Administrator)), + ); } /** diff --git a/database/migrations/2026_08_04_000600_add_snipe_user_id_to_users_table.php b/database/migrations/2026_08_04_000600_add_snipe_user_id_to_users_table.php new file mode 100644 index 0000000..9b2bbbb --- /dev/null +++ b/database/migrations/2026_08_04_000600_add_snipe_user_id_to_users_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('snipe_user_id')->nullable()->index()->after('department'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('snipe_user_id'); + }); + } +}; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6097256..20d8813 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,7 +33,7 @@ Nginx ──► Laravel + Inertia/React - `app/Domain/Inventory`: cache local de ativos e consultas do catálogo. - `app/Domain/Reservations`: disponibilidade, estados, regras e requisições. - `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação. -- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar). +- `app/Domain/Operations`: entrega, devolução, faltas, atrasos e incidentes. - `app/Domain/Audit`: logs imutáveis pela interface. - `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT. @@ -65,9 +65,13 @@ Notificações implementam `ShouldQueue`, usam `afterCommit` e seguem dois canai 5. Antes de aprovação ou entrega, o ativo é validado novamente. 6. Checkout/check-in usam um token operacional separado e guardam apenas a resposta necessária para auditoria. +`HandoverService` mantém a ordem explícita “Snipe-IT primeiro, confirmação local depois” para cada equipamento. Uma requisição com vários itens não é apresentada como concluída se apenas parte dos movimentos for aceite: os itens confirmados preservam o seu `HandoverRecord`, os restantes mantêm o estado anterior e a requisição passa a `incident`. As escritas externas não têm retry automático porque um timeout pode ocorrer depois de o Snipe-IT executar a operação; repetir sem reconciliação poderia criar um resultado enganador. + +O checkout associa o ativo a um utilizador Snipe-IT. O operador confirma o ID remoto na primeira entrega e o portal guarda-o em `users.snipe_user_id` para utilizações futuras. O check-in repõe o estado e a localização habitual sincronizados do ativo. As respostas externas ficam em `handover_records.snipe_response`; tokens nunca são persistidos. + Uma falha intermédia pode deixar upserts válidos das páginas anteriores, mas nunca executa a desativação global. A sincronização seguinte converge o cache. Um lock distribuído e um job único impedem duas sincronizações simultâneas. -Referências oficiais usadas para o contrato: [autenticação da API](https://snipe-it.readme.io/reference/authenticating-with-the-api), [listagem de hardware](https://snipe-it.readme.io/reference/hardware-list) e [limites/paginação](https://snipe-it.readme.io/reference/api-throttling). +Referências oficiais usadas para o contrato: [autenticação da API](https://snipe-it.readme.io/reference/authenticating-with-the-api), [listagem de hardware](https://snipe-it.readme.io/reference/hardware-list), [limites/paginação](https://snipe-it.readme.io/reference/api-throttling), [checkout de hardware](https://snipe-it.readme.io/reference/hardware-checkout), [check-in de hardware](https://snipe-it.readme.io/reference/hardware-checkin) e [formato de erros da API](https://snipe-it.readme.io/reference/api-overview). ## Segurança diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5e12794..57cdfec 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -9,7 +9,7 @@ Usar Laravel 13 com React/TypeScript através de Inertia no mesmo projeto. Mant ## ADR-002 — PostgreSQL como garantia de conflitos -**Data:** 2026-08-04 +**Data:** 2026-08-04 **Estado:** aceite Usar PostgreSQL e períodos semiabertos `[início, fim)`. A lógica aplicacional melhora a mensagem ao utilizador; uma restrição de exclusão GiST garante que concorrência ou um erro aplicacional não cria sobreposição bloqueante. @@ -69,3 +69,12 @@ A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou **Estado:** aceite Notificações funcionais são colocadas na fila apenas depois da confirmação da transação e enviadas por email e caixa interna. A submissão e uma resposta a esclarecimentos notificam aprovadores ativos; decisões e substituições notificam o requisitante. Uma substituição não altera automaticamente a decisão: exige justificação, equipamento livre da mesma categoria, bloqueio de ambas as linhas, novo item `held` e preservação do item original como `cancelled` para auditoria. + +## ADR-011 — Escritas Snipe-IT sem retry e reconciliação explícita + +**Data:** 2026-08-04 +**Estado:** aceite + +Checkout e check-in usam um token operacional distinto do token de leitura e nunca escrevem diretamente na base de dados do Snipe-IT. Cada equipamento é movimentado remotamente antes de o respetivo `reservation_item` e `handover_record` serem confirmados localmente. Escritas não têm retry automático: uma falha de ligação ou HTTP 5xx pode ser posterior à execução remota e a repetição cega não é segura. Numa operação com vários itens, sucessos confirmados são preservados e uma falha posterior coloca a requisição em `incident`, com IDs concluídos, item falhado e erro em auditoria. O operador reconcilia o ativo no Snipe-IT e repete apenas o movimento ainda pendente. + +O checkout é dirigido a `checkout_to_type=user`; o ID remoto confirmado pelo operador é guardado em `users.snipe_user_id`. O check-in usa o estado e a localização habitual provenientes da última sincronização. Esta opção segue os contratos oficiais de [checkout](https://snipe-it.readme.io/reference/hardware-checkout), [check-in](https://snipe-it.readme.io/reference/hardware-checkin) e o comportamento documentado de [erros em HTTP 200](https://snipe-it.readme.io/reference/api-overview). diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 3fe641d..afe0d21 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,7 +1,7 @@ # Estado do projeto e handoff **Atualizado em:** 4 de agosto de 2026 -**Fase:** Fases 1, 2, 3 e 4 concluídas +**Fase:** Fases 1 a 5 concluídas; próxima entrega: Fase 6 **Último agente:** Codex ## Resultado disponível @@ -34,44 +34,51 @@ - Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas. - Substituição de equipamento apenas por ativo livre da mesma categoria, mantendo a pré-reserva e todo o histórico. - Fila de aprovação com filtros por referência, requisitante, categoria e período, incluindo histórico de ações. +- Cliente Snipe-IT operacional com token separado, checkout para utilizador, check-in para a localização habitual e validação de erros transportados em HTTP 200. +- Área diária do operador protegida pelo perfil `inventory_operator`/`administrator`, com leitura por scanner-teclado ou pesquisa de referência, requisitante, etiqueta e ID Snipe-IT. +- Preparação, entrega, devolução e conclusão com máquina de estados, condição física, observações, operador, destinatário e resposta externa em `handover_records`. +- Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida. +- Tratamento explícito de falhas parciais: movimentos já aceites são preservados, a requisição passa a `incident` e a falha fica em `audit_logs` para reconciliação. +- Registo de falta, atraso e incidente; um incidente pode criar um bloqueio temporal do equipamento que entra imediatamente no motor de disponibilidade. ## Limites conhecidos - A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder. - Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas. -- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface. - As notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto. -- As políticas de autorização finas ainda não foram ligadas às rotas; nesta fase todas as rotas funcionais exigem utilizador autenticado, verificado e ativo. - O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa. +- O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada. +- Uma falha depois de o Snipe-IT confirmar um movimento e antes do commit local é uma transação distribuída ambígua; não existe retry automático de escritas. O operador deve reconciliar pelos registos Snipe-IT e `audit_logs` conforme o runbook. ## Próxima tarefa recomendada -Implementar a **Fase 5 — entrega e devolução**: +Implementar a **Fase 6 — administração e relatórios**: -1. abrir `app/Integrations/SnipeIt/SnipeItClient.php` e acrescentar checkout/check-in com o token operacional separado; -2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais; -3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico; -4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída; -5. implementar incidentes, atrasos e bloqueios associados a dano; -6. testar API simulada, autorização, idempotência e auditoria; -7. atualizar este documento e o changelog. +1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT; +2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias; +3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados; +4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado; +5. criar consulta de auditoria apenas de leitura para o perfil `auditor`; +6. adicionar métricas operacionais e gestão de modelos de email; +7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação. ## Testes da última sessão Validação concluída em 4 de agosto de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 111 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 84 ficheiros, zero erros; -- Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 117 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 89 ficheiros, zero erros; +- Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - TypeScript (`tsc --noEmit`): passou; - ESLint: passou; - Prettier: passou; -- Vite: builds cliente e SSR concluídos, 2308 módulos cliente e 97 módulos SSR transformados; +- Vite: builds cliente e SSR concluídos, 2309 módulos cliente e 98 módulos SSR transformados; - PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal; - PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso; - PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados; - PostgreSQL 17: migration `notifications` aplicada com sucesso; +- PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada; - smoke test HTTP: `/login` respondeu com estado 200 através do Nginx; - Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`; - Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia; diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index c818325..88da11b 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -30,6 +30,8 @@ curl -I -H 'Origin: http://localhost:8000' http://localhost:5173/@vite/client curl -I -H 'Origin: http://localhost:8000' http://localhost:5173/resources/js/app.tsx ``` +Para executar a validação backend use `docker compose exec app composer test`, não `php artisan test` diretamente. O script isola as variáveis de teste das variáveis `local` injetadas pelo Docker Compose e evita que jobs únicos deixem locks no Redis de desenvolvimento. + Ambos devem responder com estado 200 e `Access-Control-Allow-Origin: http://localhost:8000`. Reiniciar o serviço com `docker compose restart node` se o ficheiro `public/hot` estiver desatualizado. Confirmar também que o HTML inicial já contém conteúdo SSR: @@ -57,13 +59,14 @@ Definir no ambiente, sem colocar tokens no repositório: ```dotenv SNIPE_IT_URL=https://inventario.exemplo.pt SNIPE_IT_READ_TOKEN= +SNIPE_IT_OPERATIONAL_TOKEN= SNIPE_IT_TIMEOUT=10 SNIPE_IT_PAGE_SIZE=100 SNIPE_IT_RETRIES=3 SNIPE_IT_RETRY_DELAY_MS=250 ``` -O token deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém. +O token de leitura deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. O token operacional deve ser separado e ter apenas as permissões necessárias para checkout/check-in. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém. Operações: @@ -80,6 +83,28 @@ docker compose exec app php artisan schedule:list Administradores podem testar a ligação, iniciar uma execução e consultar as últimas dez sincronizações em `http://localhost:8000/administracao/snipe-it`. Uma execução falhada mantém o último catálogo válido e não desativa registos ausentes. Consultar `sync_runs`, os logs do worker e o painel antes de repetir. +## Entregas, devoluções e reconciliação + +Utilizadores com perfil `inventory_operator` ou `administrator` acedem a `http://localhost:8000/operacoes`. A fila inclui as reservas do dia e todos os processos entregues, atrasados, devolvidos ou com incidente ainda pendente. O campo inicial aceita referência, nome, email, etiqueta, ID Snipe-IT ou a saída de um leitor QR/código de barras configurado como teclado. + +Procedimento de entrega: + +1. preparar a requisição; +2. confirmar fisicamente todos os equipamentos e o estado à saída; +3. confirmar o ID do destinatário no Snipe-IT; +4. registar a entrega e verificar a mensagem de sucesso; +5. se existir erro, não voltar a submeter antes de consultar o estado do ativo no Snipe-IT. + +Procedimento de devolução: + +1. localizar a requisição ou ler a etiqueta; +2. verificar equipamento, acessórios, danos e localização; +3. registar a devolução; +4. criar um incidente e bloqueio temporal quando o ativo não deva voltar ao catálogo; +5. concluir o processo apenas depois de todos os itens estarem devolvidos. + +Uma operação parcial fica em `incident`. Consultar `handover_records` para movimentos confirmados e os eventos `reservation.delivery_failed`/`reservation.return_failed` em `audit_logs`. Comparar cada item com o estado atual no Snipe-IT; corrigir apenas os itens ainda `ready` ou `delivered`. Não alterar estados diretamente na base de dados nem repetir em bloco, porque as escritas externas são deliberadamente não idempotentes e não têm retry automático. + ## Ciclo de vida das pré-reservas As requisições pendentes têm, por defeito, um prazo de decisão de 48 horas. O scheduler coloca a expiração na fila a cada cinco minutos. Para operar ou diagnosticar manualmente: diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 312cf06..d006a95 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { LayoutGrid, ListChecks, PackageSearch, + PackageCheck, PlusCircle, ServerCog, ShieldCheck, @@ -71,16 +72,26 @@ export function AppSidebar() { }, ] : userNavItems; - const visibleNavItems = can.administer + const operationalNavItems = can.operateInventory ? [ ...roleNavItems, + { + title: 'Operações', + href: '/operacoes', + icon: PackageCheck, + }, + ] + : roleNavItems; + const visibleNavItems = can.administer + ? [ + ...operationalNavItems, { title: 'Integração Snipe-IT', href: '/administracao/snipe-it', icon: ServerCog, }, ] - : roleNavItems; + : operationalNavItems; return ( diff --git a/resources/js/components/reservation-status.tsx b/resources/js/components/reservation-status.tsx index 79d2296..6d87fdc 100644 --- a/resources/js/components/reservation-status.tsx +++ b/resources/js/components/reservation-status.tsx @@ -33,6 +33,11 @@ const statuses: Record = { label: 'Devolução atrasada', className: 'bg-red-100 text-red-800', }, + no_show: { + label: 'Não compareceu', + className: 'bg-slate-100 text-slate-700', + }, + incident: { label: 'Incidente', className: 'bg-red-100 text-red-800' }, }; export function ReservationStatus({ status }: { status: string }) { diff --git a/resources/js/pages/operations/index.tsx b/resources/js/pages/operations/index.tsx new file mode 100644 index 0000000..578c3e3 --- /dev/null +++ b/resources/js/pages/operations/index.tsx @@ -0,0 +1,620 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { + AlertTriangle, + CheckCircle2, + ClipboardCheck, + PackageCheck, + RotateCcw, + ScanLine, +} from 'lucide-react'; +import { useState } from 'react'; +import InputError from '@/components/input-error'; +import { PageHeading } from '@/components/page-heading'; +import { ReservationStatus } from '@/components/reservation-status'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; + +type HandoverRecord = { + id: number; + type: 'checkout' | 'checkin'; + condition: string | null; + notes: string | null; + created_at: string; + operator: { name: string }; +}; + +type OperationReservation = { + id: number; + uuid: string; + reference: string; + purpose: string; + usage_location: string; + starts_at: string; + ends_at: string; + status: string; + requester: { + name: string; + email: string; + department: string | null; + snipe_user_id: number | null; + }; + items: { + id: number; + status: string; + asset: { + snipe_asset_id: number; + name: string; + asset_tag: string; + status_name: string | null; + location_name: string | null; + }; + handover_records: HandoverRecord[]; + }[]; +}; + +type FormState = { + snipeUserId: string; + condition: string; + notes: string; +}; + +export default function OperationsIndex({ + reservations, + operationallyConfigured, +}: { + reservations: OperationReservation[]; + operationallyConfigured: boolean; +}) { + const { errors } = usePage().props; + const [forms, setForms] = useState>({}); + const [incidents, setIncidents] = useState< + Record + >({}); + const [search, setSearch] = useState(''); + const normalizedSearch = search.trim().toLocaleLowerCase('pt-PT'); + const visibleReservations = reservations.filter((reservation) => { + if (!normalizedSearch) { + return true; + } + + return [ + reservation.reference, + reservation.requester.name, + reservation.requester.email, + ...reservation.items.flatMap((item) => [ + item.asset.asset_tag, + item.asset.snipe_asset_id.toString(), + ]), + ].some((value) => + value.toLocaleLowerCase('pt-PT').includes(normalizedSearch), + ); + }); + + function formFor(reservation: OperationReservation): FormState { + return ( + forms[reservation.id] ?? { + snipeUserId: + reservation.requester.snipe_user_id?.toString() ?? '', + condition: 'Sem danos visíveis', + notes: '', + } + ); + } + + function updateForm( + reservation: OperationReservation, + key: keyof FormState, + value: string, + ) { + setForms((current) => ({ + ...current, + [reservation.id]: { ...formFor(reservation), [key]: value }, + })); + } + + function post( + reservation: OperationReservation, + action: string, + data = {}, + ) { + router.post(`/operacoes/${reservation.uuid}/${action}`, data, { + preserveScroll: true, + }); + } + + return ( + <> + +
+ + + + + {!operationallyConfigured && ( +
+ +
+

+ Movimentos externos indisponíveis +

+

+ Configure SNIPE_IT_OPERATIONAL_TOKEN para ativar + entregas e devoluções. A preparação continua + disponível. +

+
+
+ )} + + + +
+ {visibleReservations.length === 0 && ( + + + +

+ {search + ? 'Nenhuma operação corresponde à leitura ou pesquisa.' + : 'Não há operações pendentes para hoje.'} +

+
+
+ )} + + {visibleReservations.map((reservation) => { + const form = formFor(reservation); + const canReturn = + [ + 'delivered', + 'in_use', + 'overdue', + 'incident', + ].includes(reservation.status) && + reservation.items.some( + (item) => item.status === 'delivered', + ); + const canDeliver = reservation.items.some( + (item) => item.status === 'ready', + ); + const canComplete = + reservation.status === 'returned' || + (reservation.status === 'incident' && + reservation.items.every((item) => + ['returned', 'cancelled'].includes( + item.status, + ), + )); + + return ( + + +
+ + {reservation.reference} + +

+ {reservation.requester.name} ·{' '} + {reservation.usage_location} +

+

+ {formatPeriod( + reservation.starts_at, + reservation.ends_at, + )} +

+
+ +
+ + +
+ {reservation.items + .filter( + (item) => + item.status !== 'cancelled', + ) + .map((item) => ( +
+
+
+

+ { + item.asset + .name + } +

+

+ { + item.asset + .asset_tag + }{' '} + ·{' '} + {item.asset + .location_name ?? + 'Sem localização'} +

+
+ + {item.status} + +
+ {item.handover_records + .length > 0 && ( +
+ {item.handover_records.map( + (record) => ( +

+ {record.type === + 'checkout' + ? 'Entregue' + : 'Devolvido'}{' '} + por{' '} + { + record + .operator + .name + }{' '} + ·{' '} + { + record.condition + } +

+ ), + )} +
+ )} + {[ + 'ready', + 'delivered', + 'in_use', + 'overdue', + 'returned', + ].includes( + reservation.status, + ) && ( + + setIncidents( + ( + current, + ) => ({ + ...current, + [item.id]: + value, + }), + ) + } + onSubmit={() => { + const incident = + incidents[ + item.id + ] ?? { + notes: '', + blockedUntil: + '', + }; + router.post( + `/operacoes/${reservation.uuid}/itens/${item.id}/incidente`, + { + notes: incident.notes, + blocked_until: + incident.blockedUntil || + null, + }, + { + preserveScroll: true, + }, + ); + }} + /> + )} +
+ ))} +
+ + {reservation.status === 'approved' && ( + + )} + + {canDeliver && ( +
+ + + +
+ + +
+
+ )} + + {canReturn && ( +
+ + +
+ + {[ + 'delivered', + 'in_use', + ].includes( + reservation.status, + ) && ( + + )} +
+
+ )} + + {canComplete && ( + + )} +
+
+ ); + })} +
+
+ + ); +} + +function IncidentForm({ + value, + onChange, + onSubmit, +}: { + itemId: number; + value: { notes: string; blockedUntil: string }; + onChange: (value: { notes: string; blockedUntil: string }) => void; + onSubmit: () => void; +}) { + return ( +
+ + Registar incidente + +
+ + onChange({ ...value, notes: event.target.value }) + } + /> + + +
+
+ ); +} + +function formatPeriod(startsAt: string, endsAt: string) { + const formatter = new Intl.DateTimeFormat('pt-PT', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); + + return `${formatter.format(new Date(startsAt))} — ${formatter.format(new Date(endsAt))}`; +} + +OperationsIndex.layout = { + breadcrumbs: [{ title: 'Operações', href: '/operacoes' }], +}; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index ebc808d..fa3cd85 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -19,6 +19,7 @@ declare module '@inertiajs/core' { can: { administer: boolean; approveReservations: boolean; + operateInventory: boolean; }; unreadNotifications: number; sidebarOpen: boolean; diff --git a/routes/web.php b/routes/web.php index 5bc95a3..bd4890d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\ApprovalController; use App\Http\Controllers\AssetController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\NotificationController; +use App\Http\Controllers\OperationController; use App\Http\Controllers\ReservationController; use Illuminate\Support\Facades\Route; @@ -31,6 +32,17 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('{reservation:uuid}/itens/{item}/substituir', [ApprovalController::class, 'substitute'])->name('substitute'); }); + Route::middleware('can:operate-inventory')->prefix('operacoes')->name('operations.')->group(function () { + Route::get('/', [OperationController::class, 'index'])->name('index'); + Route::post('{reservation:uuid}/preparar', [OperationController::class, 'prepare'])->name('prepare'); + Route::post('{reservation:uuid}/entregar', [OperationController::class, 'deliver'])->name('deliver'); + Route::post('{reservation:uuid}/devolver', [OperationController::class, 'returnEquipment'])->name('return'); + Route::post('{reservation:uuid}/concluir', [OperationController::class, 'complete'])->name('complete'); + Route::post('{reservation:uuid}/falta', [OperationController::class, 'noShow'])->name('no-show'); + Route::post('{reservation:uuid}/atraso', [OperationController::class, 'overdue'])->name('overdue'); + Route::post('{reservation:uuid}/itens/{item}/incidente', [OperationController::class, 'incident'])->name('incident'); + }); + Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () { Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index'); Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test'); diff --git a/tests/Feature/OperationsWorkflowTest.php b/tests/Feature/OperationsWorkflowTest.php new file mode 100644 index 0000000..15ba7dd --- /dev/null +++ b/tests/Feature/OperationsWorkflowTest.php @@ -0,0 +1,272 @@ +set('services.snipe_it', [ + 'url' => 'https://inventory.example.test', + 'read_token' => 'read-token-for-tests', + 'operational_token' => 'operational-token-for-tests', + 'timeout' => 2, + 'page_size' => 100, + 'retries' => 1, + 'retry_delay_ms' => 0, + ]); +}); + +function inventoryOperator(): User +{ + $operator = User::factory()->create(); + $role = Role::firstOrCreate(['name' => RoleName::InventoryOperator->value]); + $operator->roles()->attach($role); + + return $operator; +} + +/** @param list $assets */ +function approvedOperationReservation(User $requester, array $assets, string $reference): Reservation +{ + $reservation = Reservation::create([ + 'reference' => $reference, + 'requester_id' => $requester->id, + 'purpose' => 'Aula prática com equipamento audiovisual.', + 'usage_location' => 'Sala 12', + 'starts_at' => now()->subHour(), + 'ends_at' => now()->addHours(3), + 'status' => ReservationStatus::Approved, + 'approved_at' => now()->subDay(), + ]); + + foreach ($assets as $asset) { + $reservation->items()->create([ + 'snipe_asset_id' => $asset->id, + 'starts_at' => $reservation->starts_at, + 'ends_at' => $reservation->ends_at, + 'status' => ReservationItemStatus::Reserved, + ]); + } + + return $reservation; +} + +it('restringe e apresenta a fila diária aos operadores', function () { + $reservation = approvedOperationReservation( + User::factory()->create(), + [SnipeAsset::factory()->create()], + 'REQ-OPS-QUEUE', + ); + + $this->actingAs(User::factory()->create())->get('/operacoes')->assertForbidden(); + + $this->actingAs(inventoryOperator()) + ->get('/operacoes') + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('operations/index') + ->where('operationallyConfigured', true) + ->has('reservations', 1) + ->where('reservations.0.reference', $reservation->reference)); +}); + +it('prepara e entrega todos os equipamentos com registo auditável', function () { + $operator = inventoryOperator(); + $requester = User::factory()->create(); + $asset = SnipeAsset::factory()->create(['snipe_asset_id' => 501, 'status_id' => 2]); + $reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-DELIVER'); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/preparar") + ->assertRedirect() + ->assertSessionHas('success'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready) + ->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready); + + Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 501]])]); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/entregar", [ + 'snipe_user_id' => 77, + 'condition' => 'Sem danos visíveis', + 'notes' => 'Carregador incluído.', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered) + ->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered) + ->and($requester->fresh()->snipe_user_id)->toBe(77); + $this->assertDatabaseHas('handover_records', [ + 'reservation_item_id' => $reservation->items()->firstOrFail()->id, + 'type' => 'checkout', + 'operator_id' => $operator->id, + 'recipient_id' => $requester->id, + ]); + $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivered']); + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/501/checkout')); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/entregar", [ + 'snipe_user_id' => 77, + 'condition' => 'Sem danos visíveis', + ]) + ->assertSessionHasErrors('operation'); + Http::assertSentCount(1); +}); + +it('regista a devolução no Snipe-IT e conclui o processo', function () { + $operator = inventoryOperator(); + $asset = SnipeAsset::factory()->create([ + 'snipe_asset_id' => 601, + 'status_id' => 2, + 'location_id' => 9, + ]); + $reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-RETURN'); + $reservation->update(['status' => ReservationStatus::Delivered]); + $reservation->items()->update([ + 'status' => ReservationItemStatus::Delivered, + 'delivered_at' => now()->subHours(2), + ]); + + Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 601]])]); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/devolver", [ + 'condition' => 'Bom estado', + 'notes' => 'Conferido pelo operador.', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Returned) + ->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Returned); + $this->assertDatabaseHas('handover_records', ['type' => 'checkin', 'operator_id' => $operator->id]); + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/601/checkin') + && $request['location_id'] === 9); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/concluir") + ->assertRedirect(); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Completed); + $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.completed']); +}); + +it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', function () { + $operator = inventoryOperator(); + $first = SnipeAsset::factory()->create(['snipe_asset_id' => 701, 'status_id' => 2]); + $second = SnipeAsset::factory()->create(['snipe_asset_id' => 702, 'status_id' => 2]); + $reservation = approvedOperationReservation(User::factory()->create(), [$first, $second], 'REQ-OPS-PARTIAL'); + $reservation->update(['status' => ReservationStatus::Ready]); + $reservation->items()->update(['status' => ReservationItemStatus::Ready]); + + Http::fakeSequence() + ->push(['status' => 'success', 'payload' => ['id' => 701]]) + ->push(['status' => 'error', 'messages' => 'Asset is not deployable']) + ->push(['status' => 'success', 'payload' => ['id' => 702]]); + + $this->actingAs($operator) + ->from('/operacoes') + ->post("/operacoes/{$reservation->uuid}/entregar", [ + 'snipe_user_id' => 88, + 'condition' => 'Verificado', + ]) + ->assertRedirect('/operacoes') + ->assertSessionHasErrors('operation'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident) + ->and($reservation->items()->orderBy('id')->pluck('status')->all())->toBe([ + ReservationItemStatus::Delivered, + ReservationItemStatus::Ready, + ]); + $this->assertDatabaseCount('handover_records', 1); + $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivery_failed']); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/entregar", [ + 'snipe_user_id' => 88, + 'condition' => 'Verificado após reconciliação', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered) + ->and($reservation->items()->where('status', ReservationItemStatus::Delivered)->count())->toBe(2); + $this->assertDatabaseCount('handover_records', 2); +}); + +it('regista incidentes e bloqueia o equipamento pelo período indicado', function () { + $operator = inventoryOperator(); + $asset = SnipeAsset::factory()->create(); + $reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-INCIDENT'); + $reservation->update(['status' => ReservationStatus::Delivered]); + $item = $reservation->items()->firstOrFail(); + $item->update(['status' => ReservationItemStatus::Delivered]); + + $this->actingAs($operator) + ->post("/operacoes/{$reservation->uuid}/itens/{$item->id}/incidente", [ + 'notes' => 'Objetiva danificada durante a utilização.', + 'blocked_until' => now()->addDays(7)->toDateTimeLocalString(), + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident); + $this->assertDatabaseHas('asset_blocks', [ + 'snipe_asset_id' => $asset->id, + 'created_by' => $operator->id, + 'reason' => 'Objetiva danificada durante a utilização.', + ]); + $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.incident_reported']); +}); + +it('regista faltas e atrasos sem movimentos externos', function () { + $operator = inventoryOperator(); + $requester = User::factory()->create(); + + $noShow = approvedOperationReservation( + $requester, + [SnipeAsset::factory()->create()], + 'REQ-OPS-NO-SHOW', + ); + $noShow->update(['status' => ReservationStatus::Ready]); + $noShow->items()->update(['status' => ReservationItemStatus::Ready]); + + $this->actingAs($operator) + ->post("/operacoes/{$noShow->uuid}/falta", ['notes' => 'O requisitante não compareceu.']) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($noShow->fresh()->status)->toBe(ReservationStatus::NoShow) + ->and($noShow->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Cancelled); + $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.no_show']); + + $overdue = approvedOperationReservation( + $requester, + [SnipeAsset::factory()->create()], + 'REQ-OPS-OVERDUE', + ); + $overdue->update(['status' => ReservationStatus::Delivered]); + $overdue->items()->update(['status' => ReservationItemStatus::Delivered]); + + $this->actingAs($operator) + ->post("/operacoes/{$overdue->uuid}/atraso", ['notes' => 'Prazo de devolução ultrapassado.']) + ->assertRedirect() + ->assertSessionHas('success'); + + expect($overdue->fresh()->status)->toBe(ReservationStatus::Overdue); + $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.overdue']); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/SnipeItClientTest.php b/tests/Feature/SnipeItClientTest.php index 60e0ea3..9f2435f 100644 --- a/tests/Feature/SnipeItClientTest.php +++ b/tests/Feature/SnipeItClientTest.php @@ -9,6 +9,7 @@ beforeEach(function () { config()->set('services.snipe_it', [ 'url' => 'https://inventory.example.test', 'read_token' => 'read-token-for-tests', + 'operational_token' => 'operational-token-for-tests', 'timeout' => 2, 'page_size' => 2, 'retries' => 1, @@ -61,6 +62,56 @@ it('recusa erros transportados num HTTP 200', function () { ->toThrow(SnipeItApiException::class, 'recusou a consulta'); }); +it('regista uma entrega com o token operacional e o destinatário Snipe-IT', function () { + Http::fake([ + '*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200), + ]); + + $payload = app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10', 'Reserva RR-001'); + + expect($payload['status'])->toBe('success'); + + Http::assertSent(function (Request $request): bool { + return $request->method() === 'POST' + && $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkout' + && $request->hasHeader('Authorization', 'Bearer operational-token-for-tests') + && $request->data() === [ + 'status_id' => 2, + 'checkout_to_type' => 'user', + 'assigned_user' => 73, + 'expected_checkin' => '2026-08-10', + 'note' => 'Reserva RR-001', + ]; + }); +}); + +it('regista uma devolução com estado e localização no Snipe-IT', function () { + Http::fake([ + '*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200), + ]); + + app(SnipeItClient::class)->checkinHardware(41, 2, 9, 'Devolvido sem danos'); + + Http::assertSent(function (Request $request): bool { + return $request->method() === 'POST' + && $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkin' + && $request->data() === [ + 'status_id' => 2, + 'location_id' => 9, + 'note' => 'Devolvido sem danos', + ]; + }); +}); + +it('recusa erros operacionais transportados num HTTP 200', function () { + Http::fake([ + '*' => Http::response(['status' => 'error', 'messages' => 'Asset is not deployable'], 200), + ]); + + expect(fn () => app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10')) + ->toThrow(SnipeItApiException::class, 'recusou a entrega'); +}); + /** @return array */ function snipeClientRow(int $id): array {