From 077015f17a6c87a650b0a7ce5ecdcbde7365ada1 Mon Sep 17 00:00:00 2001 From: joaomiranda Date: Tue, 4 Aug 2026 16:15:09 +0100 Subject: [PATCH] feat: add Snipe-IT asset synchronization --- .env.example | 3 + CHANGELOG.md | 8 +- README.md | 2 +- .../Commands/SyncSnipeAssetsCommand.php | 45 ++++ app/Domain/Inventory/Enums/SyncRunStatus.php | 10 + .../SyncAlreadyRunningException.php | 13 + app/Domain/Inventory/Models/SnipeAsset.php | 1 + app/Domain/Inventory/Models/SyncRun.php | 45 ++++ .../Inventory/Services/AssetSyncService.php | 120 +++++++++ .../Controllers/Admin/SnipeItController.php | 70 +++++ app/Http/Middleware/HandleInertiaRequests.php | 3 + app/Integrations/SnipeIt/Data/AssetData.php | 62 +++++ .../SnipeIt/Data/HardwarePage.php | 16 ++ .../Exceptions/SnipeItApiException.php | 11 + .../SnipeItConfigurationException.php | 5 + .../SnipeIt/Exceptions/SnipeItException.php | 7 + .../Exceptions/SnipeItMappingException.php | 5 + .../SnipeIt/SnipeItAssetMapper.php | 86 ++++++ app/Integrations/SnipeIt/SnipeItClient.php | 142 ++++++++++ app/Jobs/SyncSnipeAssets.php | 32 +++ app/Providers/AppServiceProvider.php | 8 + bootstrap/app.php | 1 + config/services.php | 3 + ...add_last_seen_sync_run_to_snipe_assets.php | 26 ++ docs/ARCHITECTURE.md | 10 +- docs/DECISIONS.md | 7 + docs/PROJECT_STATUS.md | 32 ++- docs/RUNBOOK.md | 30 +++ phpunit.xml | 10 +- resources/js/components/app-sidebar.tsx | 17 +- resources/js/pages/admin/snipe-it.tsx | 246 ++++++++++++++++++ resources/js/types/global.d.ts | 3 + routes/console.php | 7 + routes/web.php | 7 + tests/Feature/AssetSyncServiceTest.php | 106 ++++++++ tests/Feature/SnipeItAdminTest.php | 57 ++++ tests/Feature/SnipeItClientTest.php | 72 +++++ 37 files changed, 1302 insertions(+), 26 deletions(-) create mode 100644 app/Console/Commands/SyncSnipeAssetsCommand.php create mode 100644 app/Domain/Inventory/Enums/SyncRunStatus.php create mode 100644 app/Domain/Inventory/Exceptions/SyncAlreadyRunningException.php create mode 100644 app/Domain/Inventory/Models/SyncRun.php create mode 100644 app/Domain/Inventory/Services/AssetSyncService.php create mode 100644 app/Http/Controllers/Admin/SnipeItController.php create mode 100644 app/Integrations/SnipeIt/Data/AssetData.php create mode 100644 app/Integrations/SnipeIt/Data/HardwarePage.php create mode 100644 app/Integrations/SnipeIt/Exceptions/SnipeItApiException.php create mode 100644 app/Integrations/SnipeIt/Exceptions/SnipeItConfigurationException.php create mode 100644 app/Integrations/SnipeIt/Exceptions/SnipeItException.php create mode 100644 app/Integrations/SnipeIt/Exceptions/SnipeItMappingException.php create mode 100644 app/Integrations/SnipeIt/SnipeItAssetMapper.php create mode 100644 app/Integrations/SnipeIt/SnipeItClient.php create mode 100644 app/Jobs/SyncSnipeAssets.php create mode 100644 database/migrations/2026_08_04_000400_add_last_seen_sync_run_to_snipe_assets.php create mode 100644 resources/js/pages/admin/snipe-it.tsx create mode 100644 tests/Feature/AssetSyncServiceTest.php create mode 100644 tests/Feature/SnipeItAdminTest.php create mode 100644 tests/Feature/SnipeItClientTest.php diff --git a/.env.example b/.env.example index 03ab78b..d09a5ad 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,9 @@ SNIPE_IT_URL= 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 MICROSOFT_CLIENT_ID= MICROSOFT_CLIENT_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index b52bfa6..e388390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Dashboard, catálogo filtrável, formulário de requisição multi-equipamento e histórico pessoal. - Seeders com perfis, ativos fictícios e contas de demonstração. - Documentação de arquitetura, requisitos, decisões, operação, estado e handoff. +- Cliente de leitura Snipe-IT com autenticação Bearer, paginação, timeout, retries seletivos, DTOs e mapper defensivo. +- Sincronização horária e manual de equipamentos, job único, lock distribuído, histórico de execuções e desativação segura de ausentes. +- Área administrativa de diagnóstico Snipe-IT com teste de conectividade, métricas do cache e histórico de sincronizações. +- Autorização administrativa através de Gate e navegação condicional por perfil. ### Alterado @@ -31,7 +35,9 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue ### Verificado - Migrations e seeders executados em PostgreSQL 17 real. -- Suite backend: Pint, PHPStan nível 7 e 44 testes Pest (157 asserções). +- Suite backend: Pint, PHPStan nível 7 e 53 testes Pest (201 asserções). - Frontend: TypeScript, ESLint, Prettier e build Vite de produção. - Stack Docker operacional e endpoint de login acessível através do Nginx. - Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”. +- API Snipe-IT simulada em testes: paginação, headers, retry, erro em HTTP 200, falha parcial, idempotência e desativação de ausentes. +- Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17. diff --git a/README.md b/README.md index db83304..29601b8 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 a **Fase 1 — Fundação técnica** e o primeiro fluxo vertical da Fase 3. Estão disponíveis autenticação local, perfis base, catálogo sincronizável, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, histórico pessoal e dashboard. A próxima entrega é a integração de leitura e sincronização com o Snipe-IT. +O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e o primeiro fluxo vertical da Fase 3. Estão disponíveis autenticação local, perfis base, catálogo sincronizável, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, histórico pessoal, dashboard e diagnóstico administrativo da integração. A próxima entrega completa o motor de reservas com pesquisa temporal, cancelamento e expiração. 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. diff --git a/app/Console/Commands/SyncSnipeAssetsCommand.php b/app/Console/Commands/SyncSnipeAssetsCommand.php new file mode 100644 index 0000000..a76c157 --- /dev/null +++ b/app/Console/Commands/SyncSnipeAssetsCommand.php @@ -0,0 +1,45 @@ +option('queue')) { + SyncSnipeAssets::dispatch(); + $this->info('Sincronização Snipe-IT colocada na fila.'); + + return self::SUCCESS; + } + + try { + $run = $syncService->sync(); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->table( + ['Processados', 'Criados', 'Atualizados', 'Desativados'], + [[ + $run->records_processed, + $run->records_created, + $run->records_updated, + $run->records_disabled, + ]], + ); + + return self::SUCCESS; + } +} diff --git a/app/Domain/Inventory/Enums/SyncRunStatus.php b/app/Domain/Inventory/Enums/SyncRunStatus.php new file mode 100644 index 0000000..5789745 --- /dev/null +++ b/app/Domain/Inventory/Enums/SyncRunStatus.php @@ -0,0 +1,10 @@ + 'datetime', + 'finished_at' => 'datetime', + 'status' => SyncRunStatus::class, + ]; + } +} diff --git a/app/Domain/Inventory/Services/AssetSyncService.php b/app/Domain/Inventory/Services/AssetSyncService.php new file mode 100644 index 0000000..1abdddf --- /dev/null +++ b/app/Domain/Inventory/Services/AssetSyncService.php @@ -0,0 +1,120 @@ +get()) { + throw new SyncAlreadyRunningException; + } + + try { + return $this->runSync(); + } finally { + $lock->release(); + } + } + + private function runSync(): SyncRun + { + $startedAt = now(); + $run = SyncRun::query()->create([ + 'type' => 'assets_full', + 'started_at' => $startedAt, + 'status' => SyncRunStatus::Running, + ]); + + $processed = 0; + $created = 0; + $updated = 0; + + try { + foreach ($this->client->hardwarePages() as $page) { + DB::transaction(function () use ($page, $run, $startedAt, &$processed, &$created, &$updated): void { + foreach ($page->rows as $row) { + $assetData = $this->mapper->map($row); + $hash = $assetData->contentHash(); + $asset = SnipeAsset::query() + ->where('snipe_asset_id', $assetData->snipeAssetId) + ->first(); + + $attributes = [ + ...$assetData->toModelAttributes(), + 'synced_at' => $startedAt, + 'last_seen_sync_run_id' => $run->id, + 'content_hash' => $hash, + 'active' => true, + ]; + + if ($asset === null) { + SnipeAsset::query()->create($attributes); + $created++; + } else { + $changed = $asset->content_hash !== $hash || ! $asset->active; + $asset->update($attributes); + + if ($changed) { + $updated++; + } + } + + $processed++; + } + }); + + } + + $disabled = DB::transaction(fn (): int => SnipeAsset::query() + ->where('active', true) + ->where(fn ($query) => $query + ->whereNull('last_seen_sync_run_id') + ->orWhere('last_seen_sync_run_id', '!=', $run->id)) + ->update(['active' => false])); + + $run->update([ + 'finished_at' => now(), + 'status' => SyncRunStatus::Succeeded, + 'records_processed' => $processed, + 'records_created' => $created, + 'records_updated' => $updated, + 'records_disabled' => $disabled, + ]); + + return $run->refresh(); + } catch (Throwable $exception) { + $message = preg_replace('/Bearer\s+\S+/i', 'Bearer [redacted]', $exception->getMessage()) + ?? 'Falha desconhecida durante a sincronização.'; + + $run->update([ + 'finished_at' => now(), + 'status' => SyncRunStatus::Failed, + 'records_processed' => $processed, + 'records_created' => $created, + 'records_updated' => $updated, + 'error_message' => Str::limit($message, 2000), + ]); + + throw $exception; + } + } +} diff --git a/app/Http/Controllers/Admin/SnipeItController.php b/app/Http/Controllers/Admin/SnipeItController.php new file mode 100644 index 0000000..0a6831c --- /dev/null +++ b/app/Http/Controllers/Admin/SnipeItController.php @@ -0,0 +1,70 @@ + $client->isConfigured(), + 'baseUrl' => $client->configuredUrl(), + 'assets' => [ + 'total' => SnipeAsset::query()->count(), + 'active' => SnipeAsset::query()->where('active', true)->count(), + 'requestable' => SnipeAsset::query() + ->where('active', true) + ->where('requestable', true) + ->count(), + ], + 'syncRuns' => SyncRun::query() + ->latest('started_at') + ->limit(10) + ->get() + ->map(fn (SyncRun $run): array => [ + 'id' => $run->id, + 'status' => $run->status->value, + 'started_at' => $run->started_at->toIso8601String(), + 'finished_at' => $run->finished_at?->toIso8601String(), + 'records_processed' => $run->records_processed, + 'records_created' => $run->records_created, + 'records_updated' => $run->records_updated, + 'records_disabled' => $run->records_disabled, + 'error_message' => $run->error_message, + ]), + ]); + } + + public function testConnection(SnipeItClient $client): RedirectResponse + { + $startedAt = hrtime(true); + + try { + $page = $client->listHardwarePage(0, 1); + $duration = (int) round((hrtime(true) - $startedAt) / 1_000_000); + + return back()->with('success', "Ligação confirmada em {$duration} ms. O Snipe-IT reporta {$page->total} equipamentos."); + } catch (Throwable $exception) { + report($exception); + + return back()->with('error', $exception->getMessage()); + } + } + + public function sync(): RedirectResponse + { + SyncSnipeAssets::dispatch(); + + return back()->with('success', 'A sincronização de equipamentos foi colocada na fila.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 95a5727..0684740 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -45,6 +45,9 @@ class HandleInertiaRequests extends Middleware 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), ], + 'can' => [ + 'administer' => fn (): bool => $request->user()?->can('administer') ?? false, + ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', ]; } diff --git a/app/Integrations/SnipeIt/Data/AssetData.php b/app/Integrations/SnipeIt/Data/AssetData.php new file mode 100644 index 0000000..6540d65 --- /dev/null +++ b/app/Integrations/SnipeIt/Data/AssetData.php @@ -0,0 +1,62 @@ + */ + public function toModelAttributes(): array + { + return [ + 'snipe_asset_id' => $this->snipeAssetId, + 'asset_tag' => $this->assetTag, + 'name' => $this->name, + 'serial' => $this->serial, + 'model_id' => $this->modelId, + 'model_name' => $this->modelName, + 'category_id' => $this->categoryId, + 'category_name' => $this->categoryName, + 'manufacturer' => $this->manufacturer, + 'status_id' => $this->statusId, + 'status_name' => $this->statusName, + 'location_id' => $this->locationId, + 'location_name' => $this->locationName, + 'requestable' => $this->requestable, + 'assigned_type' => $this->assignedType, + 'assigned_id' => $this->assignedId, + 'image_url' => $this->imageUrl, + 'source_updated_at' => $this->sourceUpdatedAt, + ]; + } + + public function contentHash(): string + { + $attributes = $this->toModelAttributes(); + $attributes['source_updated_at'] = $this->sourceUpdatedAt?->toIso8601String(); + + return hash('sha256', json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + } +} diff --git a/app/Integrations/SnipeIt/Data/HardwarePage.php b/app/Integrations/SnipeIt/Data/HardwarePage.php new file mode 100644 index 0000000..a571167 --- /dev/null +++ b/app/Integrations/SnipeIt/Data/HardwarePage.php @@ -0,0 +1,16 @@ +> $rows + */ + public function __construct( + public int $total, + public int $offset, + public int $limit, + public array $rows, + ) {} +} diff --git a/app/Integrations/SnipeIt/Exceptions/SnipeItApiException.php b/app/Integrations/SnipeIt/Exceptions/SnipeItApiException.php new file mode 100644 index 0000000..da5b0c3 --- /dev/null +++ b/app/Integrations/SnipeIt/Exceptions/SnipeItApiException.php @@ -0,0 +1,11 @@ + $row */ + public function map(array $row): AssetData + { + $id = $this->integer($row['id'] ?? null); + $assetTag = $this->text($row['asset_tag'] ?? null); + + if ($id === null || $id < 1 || $assetTag === null) { + throw new SnipeItMappingException('A resposta do Snipe-IT contém um ativo sem ID ou etiqueta válida.'); + } + + $modelName = $this->text(Arr::get($row, 'model.name')); + + return new AssetData( + snipeAssetId: $id, + assetTag: $assetTag, + name: $this->text($row['name'] ?? null) ?? $modelName ?? $assetTag, + serial: $this->text($row['serial'] ?? null), + modelId: $this->integer(Arr::get($row, 'model.id')), + modelName: $modelName, + categoryId: $this->integer(Arr::get($row, 'category.id')), + categoryName: $this->text(Arr::get($row, 'category.name')), + manufacturer: $this->text(Arr::get($row, 'manufacturer.name')), + statusId: $this->integer(Arr::get($row, 'status_label.id')), + statusName: $this->text(Arr::get($row, 'status_label.name')), + locationId: $this->integer(Arr::get($row, 'rtd_location.id') ?? Arr::get($row, 'location.id')), + locationName: $this->text(Arr::get($row, 'rtd_location.name') ?? Arr::get($row, 'location.name')), + requestable: $this->boolean($row['requestable'] ?? false), + assignedType: $this->text(Arr::get($row, 'assigned_to.type')), + assignedId: $this->integer(Arr::get($row, 'assigned_to.id')), + imageUrl: $this->text($row['image'] ?? $row['image_url'] ?? null), + sourceUpdatedAt: $this->date(Arr::get($row, 'updated_at.datetime') ?? Arr::get($row, 'updated_at.date') ?? $row['updated_at'] ?? null), + ); + } + + private function text(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + $decoded = trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + return $decoded === '' ? null : $decoded; + } + + private function integer(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private function boolean(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'sim'], true); + } + + private function date(mixed $value): ?CarbonImmutable + { + $text = $this->text($value); + + if ($text === null) { + return null; + } + + try { + return CarbonImmutable::parse($text); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Integrations/SnipeIt/SnipeItClient.php b/app/Integrations/SnipeIt/SnipeItClient.php new file mode 100644 index 0000000..d6067f8 --- /dev/null +++ b/app/Integrations/SnipeIt/SnipeItClient.php @@ -0,0 +1,142 @@ +request()->get('/hardware', [ + 'limit' => $pageSize, + 'offset' => max(0, $offset), + 'sort' => 'id', + 'order' => 'asc', + ]); + + if (! $response->successful()) { + throw new SnipeItApiException( + "O Snipe-IT respondeu com HTTP {$response->status()} ao consultar equipamentos.", + $response->status(), + ); + } + + $payload = $response->json(); + + if (! is_array($payload)) { + throw new SnipeItApiException('O Snipe-IT devolveu uma resposta que não é JSON válido.'); + } + + if (($payload['status'] ?? null) === 'error') { + throw new SnipeItApiException('O Snipe-IT recusou a consulta de equipamentos apesar de responder com HTTP 200.'); + } + + $total = $payload['total'] ?? null; + $rows = $payload['rows'] ?? null; + + if (! is_numeric($total) || ! is_array($rows)) { + throw new SnipeItApiException('A resposta do Snipe-IT não contém os campos de paginação esperados.'); + } + + /** @var list> $normalizedRows */ + $normalizedRows = []; + + foreach (array_values($rows) as $row) { + if (! is_array($row)) { + throw new SnipeItApiException('A resposta do Snipe-IT contém um equipamento num formato inválido.'); + } + + /** @var array $row */ + $normalizedRows[] = $row; + } + + return new HardwarePage((int) $total, max(0, $offset), $pageSize, $normalizedRows); + } + + /** @return Generator */ + public function hardwarePages(): Generator + { + $offset = 0; + + do { + $page = $this->listHardwarePage($offset); + yield $page; + + $received = count($page->rows); + $offset += $received; + } while ($received > 0 && $offset < $page->total); + } + + private function request(): PendingRequest + { + if (! $this->isConfigured()) { + throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.'); + } + + $retries = max(1, (int) config('services.snipe_it.retries', 3)); + $retryDelay = max(0, (int) config('services.snipe_it.retry_delay_ms', 250)); + + return Http::baseUrl($this->apiBaseUrl()) + ->acceptJson() + ->asJson() + ->withToken((string) config('services.snipe_it.read_token')) + ->withUserAgent(Str::slug((string) config('app.name')).'/inventory-sync') + ->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10)))) + ->timeout(max(1, (int) config('services.snipe_it.timeout', 10))) + ->retry( + $retries, + $retryDelay, + fn (Throwable $exception): bool => $this->shouldRetry($exception), + throw: false, + ); + } + + private function apiBaseUrl(): string + { + $baseUrl = $this->configuredUrl(); + + if ($baseUrl === null || ! filter_var($baseUrl, FILTER_VALIDATE_URL)) { + throw new SnipeItConfigurationException('A URL configurada para o Snipe-IT não é válida.'); + } + + return str_ends_with($baseUrl, '/api/v1') ? $baseUrl : $baseUrl.'/api/v1'; + } + + private function shouldRetry(Throwable $exception): bool + { + if ($exception instanceof ConnectionException) { + return true; + } + + if (! $exception instanceof RequestException || $exception->response === null) { + return false; + } + + return $exception->response->status() === 429 || $exception->response->serverError(); + } +} diff --git a/app/Jobs/SyncSnipeAssets.php b/app/Jobs/SyncSnipeAssets.php new file mode 100644 index 0000000..2437961 --- /dev/null +++ b/app/Jobs/SyncSnipeAssets.php @@ -0,0 +1,32 @@ + */ + public array $backoff = [30, 120, 300]; + + public function uniqueId(): string + { + return 'snipe-it-assets'; + } + + public function handle(AssetSyncService $syncService): void + { + $syncService->sync(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..0edba79 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,12 @@ namespace App\Providers; +use App\Domain\Identity\Enums\RoleName; +use App\Models\User; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -24,6 +27,11 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureDefaults(); + + Gate::define( + 'administer', + fn (User $user): bool => $user->active && $user->hasRole(RoleName::Administrator), + ); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index ba5cf4c..a7cb00e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', health: '/up', ) + ->withCommands() ->withMiddleware(function (Middleware $middleware): void { $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); diff --git a/config/services.php b/config/services.php index 9b630bf..1c4ba44 100644 --- a/config/services.php +++ b/config/services.php @@ -40,6 +40,9 @@ return [ 'read_token' => env('SNIPE_IT_READ_TOKEN'), 'operational_token' => env('SNIPE_IT_OPERATIONAL_TOKEN'), 'timeout' => (int) env('SNIPE_IT_TIMEOUT', 10), + 'page_size' => (int) env('SNIPE_IT_PAGE_SIZE', 100), + 'retries' => (int) env('SNIPE_IT_RETRIES', 3), + 'retry_delay_ms' => (int) env('SNIPE_IT_RETRY_DELAY_MS', 250), ], 'microsoft' => [ diff --git a/database/migrations/2026_08_04_000400_add_last_seen_sync_run_to_snipe_assets.php b/database/migrations/2026_08_04_000400_add_last_seen_sync_run_to_snipe_assets.php new file mode 100644 index 0000000..8716bb5 --- /dev/null +++ b/database/migrations/2026_08_04_000400_add_last_seen_sync_run_to_snipe_assets.php @@ -0,0 +1,26 @@ +foreignId('last_seen_sync_run_id') + ->nullable() + ->after('synced_at') + ->constrained('sync_runs') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('snipe_assets', function (Blueprint $table) { + $table->dropConstrainedForeignId('last_seen_sync_run_id'); + }); + } +}; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e04f343..d5322b5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ Nginx ──► Laravel + Inertia/React - `app/Domain/Approvals`: decisões e concorrência de aprovação (a criar). - `app/Domain/Handovers`: entrega, devolução e incidentes (a criar). - `app/Domain/Audit`: logs imutáveis pela interface. -- `app/Integrations/SnipeIt`: único ponto de comunicação com Snipe-IT (a criar). +- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT. ## Consistência temporal @@ -52,11 +52,15 @@ Uma criação ou aprovação deve bloquear as linhas dos ativos, voltar a valida 1. Um job consulta a API paginada com token de leitura. 2. Um mapper reduz a resposta aos campos documentados. -3. `AssetSyncService` faz upsert por `snipe_asset_id` e calcula `content_hash`. -4. Ativos ausentes numa sincronização completa ficam `active = false`; não são apagados para preservar histórico. +3. `AssetSyncService` faz upsert por `snipe_asset_id`, calcula `content_hash` e marca cada ativo com `last_seen_sync_run_id`. +4. Apenas após todas as páginas serem recebidas com sucesso, ativos cujo marcador não corresponde à execução atual ficam `active = false`; não são apagados para preservar histórico. 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. +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). + ## Segurança - Sem registo público. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4e7970a..821d7d2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -41,3 +41,10 @@ Desenvolvimento e primeira instalação utilizam serviços separados para Nginx, **Estado:** aceite Textos funcionais são escritos em português europeu. Nomes de código, eventos e tabelas permanecem em inglês para consistência técnica. + +## ADR-007 — Sincronização Snipe-IT convergente por execução + +**Data:** 2026-08-04 +**Estado:** aceite + +Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis até ao máximo oficial de 500 registos. Cada ativo visto recebe o ID da execução em `last_seen_sync_run_id`; a desativação de ausentes só ocorre depois de todas as páginas terminarem com sucesso. Upserts de páginas já válidas podem permanecer após uma falha, permitindo convergência na execução seguinte sem manter uma transação aberta durante chamadas HTTP. Repetir apenas falhas de ligação, HTTP 429 e HTTP 5xx. Usar lock distribuído, job único e token exclusivo de leitura. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 9fc16c1..1bc4bfb 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:** Fase 1 concluída; primeiro fluxo vertical da Fase 3 concluído +**Fase:** Fases 1 e 2 concluídas; primeiro fluxo vertical da Fase 3 concluído **Último agente:** Codex ## Resultado disponível @@ -18,10 +18,13 @@ - Dashboard pessoal, catálogo com pesquisa/filtros, formulário de nova requisição e histórico. - Docker Compose com Nginx, PHP-FPM, PostgreSQL, Redis, Node/Vite, filas, scheduler e Mailpit. - Dados fictícios e duas contas locais para desenvolvimento. +- Cliente Snipe-IT autenticado com paginação, timeout, retry seletivo e validação de respostas. +- Sincronização convergente de ativos com histórico, hash de conteúdo, lock, job único e desativação apenas após execução completa. +- Comando `snipe:sync-assets`, execução horária e painel de diagnóstico exclusivo para administradores. ## Limites conhecidos -- A API real do Snipe-IT ainda não está implementada. O catálogo utiliza dados fictícios do seeder. +- 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. - Aprovação, rejeição, entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface. - As notificações e jobs de expiração ainda não foram implementados. @@ -30,14 +33,14 @@ ## Próxima tarefa recomendada -Implementar a **Fase 2 — cliente e sincronização Snipe-IT**: +Completar a **Fase 3 — motor de reservas**: -1. abrir `docs/ARCHITECTURE.md` e `config/services.php`; -2. criar `app/Integrations/SnipeIt/SnipeItClient.php`, DTOs, mapper e exceções; -3. implementar paginação, timeout, retry limitado e token de leitura; -4. criar `AssetSyncService`, job e comando `snipe:sync-assets`; -5. testar com `Http::fake()` paginação, falhas e desativação de ativos ausentes; -6. criar página administrativa de diagnóstico apenas para administradores; +1. abrir `app/Domain/Reservations/Services/AvailabilityService.php` e `ReservationService.php`; +2. acrescentar pesquisa do catálogo por período e apresentar disponibilidade real; +3. implementar cancelamento pelo requisitante com transição explícita e auditoria; +4. criar expiração automática de pré-reservas pendentes e libertação dos itens; +5. adicionar uma vista de calendário pessoal; +6. testar limites temporais, cancelamento, expiração e autorização; 7. atualizar este documento e o changelog. ## Testes da última sessão @@ -45,18 +48,19 @@ Implementar a **Fase 2 — cliente e sincronização Snipe-IT**: Validação concluída em 4 de agosto de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 78 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 59 ficheiros, zero erros; -- Pest: 44 testes passaram, 157 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 97 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 75 ficheiros, zero erros; +- Pest: 53 testes passaram, 201 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - TypeScript (`tsc --noEmit`): passou; - ESLint: passou; - Prettier: passou; -- Vite: build de produção concluído, 2304 módulos transformados; +- Vite: builds cliente e SSR concluídos, 2305 módulos cliente 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; - 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; -- verificação visual no browser integrado: não executada devido a uma política administrativa temporariamente indisponível no browser desta sessão; não foi detetado erro da aplicação. +- verificação visual no browser integrado: a inspeção controlada continua impedida por uma política administrativa temporariamente indisponível; o painel administrativo foi validado por teste Inertia, TypeScript e builds cliente/SSR. ## Decisões pendentes do contexto institucional diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 0738e78..079ce69 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -50,6 +50,36 @@ docker compose exec app php artisan optimize:clear docker compose restart queue scheduler ``` +## Integração Snipe-IT + +Definir no ambiente, sem colocar tokens no repositório: + +```dotenv +SNIPE_IT_URL=https://inventario.exemplo.pt +SNIPE_IT_READ_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. + +Operações: + +```bash +# Sincronização imediata e resultado no terminal +docker compose exec app php artisan snipe:sync-assets + +# Colocar na fila +docker compose exec app php artisan snipe:sync-assets --queue + +# Confirmar o agendamento horário +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. + ## Produção Antes de disponibilizar: diff --git a/phpunit.xml b/phpunit.xml index dd27106..72779bf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,17 +18,17 @@ - + - + - - - + + + diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 9e3d9e4..c7db145 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,9 +1,10 @@ -import { Link } from '@inertiajs/react'; +import { Link, usePage } from '@inertiajs/react'; import { CalendarDays, LayoutGrid, PackageSearch, PlusCircle, + ServerCog, ShieldCheck, } from 'lucide-react'; import AppLogo from '@/components/app-logo'; @@ -45,6 +46,18 @@ const footerNavItems: NavItem[] = [ ]; export function AppSidebar() { + const { can } = usePage().props; + const visibleNavItems = can.administer + ? [ + ...mainNavItems, + { + title: 'Integração Snipe-IT', + href: '/administracao/snipe-it', + icon: ServerCog, + }, + ] + : mainNavItems; + return ( @@ -60,7 +73,7 @@ export function AppSidebar() { - + diff --git a/resources/js/pages/admin/snipe-it.tsx b/resources/js/pages/admin/snipe-it.tsx new file mode 100644 index 0000000..f6e43c8 --- /dev/null +++ b/resources/js/pages/admin/snipe-it.tsx @@ -0,0 +1,246 @@ +import { Head, router } from '@inertiajs/react'; +import { Database, PlugZap, RefreshCw, ServerCog } from 'lucide-react'; +import { useState } from 'react'; +import { PageHeading } from '@/components/page-heading'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import AppLayout from '@/layouts/app-layout'; +import type { BreadcrumbItem } from '@/types'; + +type SyncRun = { + id: number; + status: 'running' | 'succeeded' | 'failed'; + started_at: string | null; + finished_at: string | null; + records_processed: number; + records_created: number; + records_updated: number; + records_disabled: number; + error_message: string | null; +}; + +type Props = { + configured: boolean; + baseUrl: string | null; + assets: { + total: number; + active: number; + requestable: number; + }; + syncRuns: SyncRun[]; +}; + +const breadcrumbs: BreadcrumbItem[] = [ + { title: 'Administração', href: '/administracao/snipe-it' }, + { title: 'Snipe-IT', href: '/administracao/snipe-it' }, +]; + +const statusLabel: Record = { + running: 'Em curso', + succeeded: 'Concluída', + failed: 'Falhou', +}; + +function formatDate(value: string | null) { + if (!value) { + return '—'; + } + + return new Intl.DateTimeFormat('pt-PT', { + dateStyle: 'short', + timeStyle: 'short', + }).format(new Date(value)); +} + +export default function SnipeItAdmin({ + configured, + baseUrl, + assets, + syncRuns, +}: Props) { + const [action, setAction] = useState<'test' | 'sync' | null>(null); + + const postAction = (url: string, currentAction: 'test' | 'sync') => { + router.post( + url, + {}, + { + preserveScroll: true, + onStart: () => setAction(currentAction), + onFinish: () => setAction(null), + }, + ); + }; + + return ( + + + +
+ + + +
+ } + /> + +
+ + + + + Configuração + + + + + {configured ? 'Configurada' : 'Por configurar'} + +

+ {baseUrl ?? 'Defina URL e token no ambiente.'} +

+
+
+ {[ + ['Registos locais', assets.total], + ['Ativos', assets.active], + ['Requisitáveis', assets.requestable], + ].map(([label, value]) => ( + + + + + {label} + + + +

+ {value} +

+
+
+ ))} +
+ + + + Últimas sincronizações + + + {syncRuns.length === 0 ? ( +
+ Ainda não existem sincronizações registadas. +
+ ) : ( +
+ + + + + + + + + + + + + {syncRuns.map((run) => ( + + + + + + + + + ))} + +
+ Início + + Estado + + Processados + + Criados + + Atualizados + + Desativados +
+ {formatDate(run.started_at)} + + + { + statusLabel[ + run.status + ] + } + + + {run.records_processed} + + {run.records_created} + + {run.records_updated} + + {run.records_disabled} +
+
+ )} +
+
+ +
+ ); +} diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 88b706c..b13ee1a 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -16,6 +16,9 @@ declare module '@inertiajs/core' { success?: string | null; error?: string | null; }; + can: { + administer: boolean; + }; sidebarOpen: boolean; [key: string]: unknown; }; diff --git a/routes/console.php b/routes/console.php index 3c9adf1..e7dbab8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,15 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new SyncSnipeAssets) + ->hourly() + ->when(fn (): bool => filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'))) + ->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 22bff9d..593cded 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::get('requisicoes', [ReservationController::class, 'index'])->name('reservations.index'); Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create'); Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store'); + + 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'); + Route::post('snipe-it/sincronizar', [SnipeItController::class, 'sync'])->name('snipe-it.sync'); + }); }); require __DIR__.'/settings.php'; diff --git a/tests/Feature/AssetSyncServiceTest.php b/tests/Feature/AssetSyncServiceTest.php new file mode 100644 index 0000000..cdc0cf4 --- /dev/null +++ b/tests/Feature/AssetSyncServiceTest.php @@ -0,0 +1,106 @@ +set('services.snipe_it', [ + 'url' => 'https://inventory.example.test', + 'read_token' => 'read-token-for-tests', + 'timeout' => 2, + 'page_size' => 1, + 'retries' => 1, + 'retry_delay_ms' => 0, + ]); +}); + +it('sincroniza por páginas e desativa apenas ativos ausentes', function () { + $stale = SnipeAsset::factory()->create([ + 'snipe_asset_id' => 99, + 'asset_tag' => 'ANTIGO-99', + 'active' => true, + 'synced_at' => now()->subDay(), + ]); + + Http::fakeSequence() + ->push(['total' => 2, 'rows' => [snipeSyncRow(1, 'EQ-001')]]) + ->push(['total' => 2, 'rows' => [snipeSyncRow(2, 'EQ-002')]]); + + $run = app(AssetSyncService::class)->sync(); + + expect($run->status)->toBe(SyncRunStatus::Succeeded) + ->and($run->records_processed)->toBe(2) + ->and($run->records_created)->toBe(2) + ->and($run->records_disabled)->toBe(1) + ->and($stale->refresh()->active)->toBeFalse(); + + $this->assertDatabaseHas('snipe_assets', [ + 'snipe_asset_id' => 1, + 'asset_tag' => 'EQ-001', + 'name' => 'Câmara & tripé', + 'category_name' => 'Audiovisual', + 'requestable' => true, + 'active' => true, + ]); +}); + +it('não desativa ativos quando uma página remota falha', function () { + $existing = SnipeAsset::factory()->create([ + 'snipe_asset_id' => 99, + 'asset_tag' => 'PRESERVAR-99', + 'active' => true, + 'synced_at' => now()->subDay(), + ]); + + Http::fakeSequence() + ->push(['total' => 2, 'rows' => [snipeSyncRow(1, 'EQ-001')]]) + ->push(['message' => 'failure'], 500); + + expect(fn () => app(AssetSyncService::class)->sync()) + ->toThrow(SnipeItApiException::class, 'HTTP 500'); + + expect($existing->refresh()->active)->toBeTrue(); + + $run = SyncRun::query()->latest('id')->firstOrFail(); + expect($run->status)->toBe(SyncRunStatus::Failed) + ->and($run->records_processed)->toBe(1) + ->and($run->records_disabled)->toBe(0); +}); + +it('não conta como atualização um ativo cujo conteúdo não mudou', function () { + Http::fake([ + '*' => Http::response(['total' => 1, 'rows' => [snipeSyncRow(1, 'EQ-001')]]), + ]); + + app(AssetSyncService::class)->sync(); + $secondRun = app(AssetSyncService::class)->sync(); + + expect($secondRun->records_processed)->toBe(1) + ->and($secondRun->records_created)->toBe(0) + ->and($secondRun->records_updated)->toBe(0) + ->and($secondRun->records_disabled)->toBe(0); +}); + +/** @return array */ +function snipeSyncRow(int $id, string $tag): array +{ + return [ + 'id' => $id, + 'asset_tag' => $tag, + 'name' => 'Câmara & tripé', + 'serial' => "SERIAL-{$id}", + 'model' => ['id' => 20, 'name' => 'Lumix'], + 'category' => ['id' => 30, 'name' => 'Audiovisual'], + 'manufacturer' => ['id' => 40, 'name' => 'Panasonic'], + 'status_label' => ['id' => 2, 'name' => 'Pronto a utilizar'], + 'rtd_location' => ['id' => 5, 'name' => 'Armazém central'], + 'requestable' => true, + 'assigned_to' => null, + 'image' => 'https://inventory.example.test/uploads/asset.png', + 'updated_at' => ['datetime' => '2026-08-04T12:00:00+00:00'], + ]; +} diff --git a/tests/Feature/SnipeItAdminTest.php b/tests/Feature/SnipeItAdminTest.php new file mode 100644 index 0000000..90406b5 --- /dev/null +++ b/tests/Feature/SnipeItAdminTest.php @@ -0,0 +1,57 @@ +create(); + $administrator->roles()->attach(Role::query()->create([ + 'name' => RoleName::Administrator->value, + 'description' => 'Administração do portal.', + ])); + + config()->set('services.snipe_it.url', 'https://inventory.example.test'); + config()->set('services.snipe_it.read_token', 'configured-token'); + + $this->actingAs($administrator) + ->get(route('admin.snipe-it.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('admin/snipe-it') + ->where('configured', true) + ->where('assets.total', 0) + ->has('syncRuns', 0)); +}); + +it('impede um requisitante de consultar o diagnóstico', function () { + $requester = User::factory()->create(); + $requester->roles()->attach(Role::query()->create([ + 'name' => RoleName::Requester->value, + 'description' => 'Requisitante.', + ])); + + $this->actingAs($requester) + ->get(route('admin.snipe-it.index')) + ->assertForbidden(); +}); + +it('coloca a sincronização manual na fila', function () { + Queue::fake(); + + $administrator = User::factory()->create(); + $administrator->roles()->attach(Role::query()->create([ + 'name' => RoleName::Administrator->value, + 'description' => 'Administração do portal.', + ])); + + $this->actingAs($administrator) + ->post(route('admin.snipe-it.sync')) + ->assertRedirect() + ->assertSessionHas('success'); + + Queue::assertPushed(SyncSnipeAssets::class); +}); diff --git a/tests/Feature/SnipeItClientTest.php b/tests/Feature/SnipeItClientTest.php new file mode 100644 index 0000000..60e0ea3 --- /dev/null +++ b/tests/Feature/SnipeItClientTest.php @@ -0,0 +1,72 @@ +set('services.snipe_it', [ + 'url' => 'https://inventory.example.test', + 'read_token' => 'read-token-for-tests', + 'timeout' => 2, + 'page_size' => 2, + 'retries' => 1, + 'retry_delay_ms' => 0, + ]); +}); + +it('percorre todas as páginas de equipamentos com os cabeçalhos obrigatórios', function () { + Http::fakeSequence() + ->push(['total' => 3, 'rows' => [snipeClientRow(1), snipeClientRow(2)]]) + ->push(['total' => 3, 'rows' => [snipeClientRow(3)]]); + + $pages = iterator_to_array(app(SnipeItClient::class)->hardwarePages()); + + expect($pages)->toHaveCount(2) + ->and($pages[0]->offset)->toBe(0) + ->and($pages[1]->offset)->toBe(2) + ->and($pages[1]->rows[0]['id'])->toBe(3); + + Http::assertSentCount(2); + Http::assertSent(function (Request $request): bool { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return $request->hasHeader('Authorization', 'Bearer read-token-for-tests') + && $request->hasHeader('Accept', 'application/json') + && ($query['limit'] ?? null) === '2' + && in_array($query['offset'] ?? null, ['0', '2'], true); + }); +}); + +it('repete falhas transitórias e recupera', function () { + config()->set('services.snipe_it.retries', 2); + + Http::fakeSequence() + ->push(['message' => 'temporarily unavailable'], 503) + ->push(['total' => 1, 'rows' => [snipeClientRow(1)]]); + + $page = app(SnipeItClient::class)->listHardwarePage(); + + expect($page->total)->toBe(1); + Http::assertSentCount(2); +}); + +it('recusa erros transportados num HTTP 200', function () { + Http::fake([ + '*' => Http::response(['status' => 'error', 'messages' => 'Unauthorized'], 200), + ]); + + expect(fn () => app(SnipeItClient::class)->listHardwarePage()) + ->toThrow(SnipeItApiException::class, 'recusou a consulta'); +}); + +/** @return array */ +function snipeClientRow(int $id): array +{ + return [ + 'id' => $id, + 'asset_tag' => "EQ-{$id}", + 'name' => "Equipamento {$id}", + ]; +}