feat: add Snipe-IT asset synchronization
This commit is contained in:
45
app/Console/Commands/SyncSnipeAssetsCommand.php
Normal file
45
app/Console/Commands/SyncSnipeAssetsCommand.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Domain\Inventory\Services\AssetSyncService;
|
||||
use App\Jobs\SyncSnipeAssets;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class SyncSnipeAssetsCommand extends Command
|
||||
{
|
||||
protected $signature = 'snipe:sync-assets {--queue : Colocar a sincronização na fila em vez de a executar agora}';
|
||||
|
||||
protected $description = 'Sincroniza o cache local de equipamentos com a API do Snipe-IT';
|
||||
|
||||
public function handle(AssetSyncService $syncService): int
|
||||
{
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
10
app/Domain/Inventory/Enums/SyncRunStatus.php
Normal file
10
app/Domain/Inventory/Enums/SyncRunStatus.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Inventory\Enums;
|
||||
|
||||
enum SyncRunStatus: string
|
||||
{
|
||||
case Running = 'running';
|
||||
case Succeeded = 'succeeded';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Inventory\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SyncAlreadyRunningException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Já existe uma sincronização de equipamentos em curso.');
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class SnipeAsset extends Model
|
||||
'category_id', 'category_name', 'manufacturer', 'status_id', 'status_name',
|
||||
'location_id', 'location_name', 'requestable', 'assigned_type', 'assigned_id',
|
||||
'image_url', 'source_updated_at', 'synced_at', 'content_hash', 'active',
|
||||
'last_seen_sync_run_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
||||
45
app/Domain/Inventory/Models/SyncRun.php
Normal file
45
app/Domain/Inventory/Models/SyncRun.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Inventory\Models;
|
||||
|
||||
use App\Domain\Inventory\Enums\SyncRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $type
|
||||
* @property CarbonImmutable $started_at
|
||||
* @property CarbonImmutable|null $finished_at
|
||||
* @property SyncRunStatus $status
|
||||
* @property int $records_processed
|
||||
* @property int $records_created
|
||||
* @property int $records_updated
|
||||
* @property int $records_disabled
|
||||
* @property string|null $error_message
|
||||
*/
|
||||
class SyncRun extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'status',
|
||||
'records_processed',
|
||||
'records_created',
|
||||
'records_updated',
|
||||
'records_disabled',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'status' => SyncRunStatus::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
120
app/Domain/Inventory/Services/AssetSyncService.php
Normal file
120
app/Domain/Inventory/Services/AssetSyncService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Inventory\Services;
|
||||
|
||||
use App\Domain\Inventory\Enums\SyncRunStatus;
|
||||
use App\Domain\Inventory\Exceptions\SyncAlreadyRunningException;
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Inventory\Models\SyncRun;
|
||||
use App\Integrations\SnipeIt\SnipeItAssetMapper;
|
||||
use App\Integrations\SnipeIt\SnipeItClient;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
final readonly class AssetSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private SnipeItClient $client,
|
||||
private SnipeItAssetMapper $mapper,
|
||||
) {}
|
||||
|
||||
public function sync(): SyncRun
|
||||
{
|
||||
$lock = Cache::lock('snipe-it:asset-sync', 900);
|
||||
|
||||
if (! $lock->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Admin/SnipeItController.php
Normal file
70
app/Http/Controllers/Admin/SnipeItController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Inventory\Models\SyncRun;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Integrations\SnipeIt\SnipeItClient;
|
||||
use App\Jobs\SyncSnipeAssets;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Throwable;
|
||||
|
||||
class SnipeItController extends Controller
|
||||
{
|
||||
public function index(SnipeItClient $client): Response
|
||||
{
|
||||
return Inertia::render('admin/snipe-it', [
|
||||
'configured' => $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.');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
62
app/Integrations/SnipeIt/Data/AssetData.php
Normal file
62
app/Integrations/SnipeIt/Data/AssetData.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt\Data;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final readonly class AssetData
|
||||
{
|
||||
public function __construct(
|
||||
public int $snipeAssetId,
|
||||
public string $assetTag,
|
||||
public string $name,
|
||||
public ?string $serial,
|
||||
public ?int $modelId,
|
||||
public ?string $modelName,
|
||||
public ?int $categoryId,
|
||||
public ?string $categoryName,
|
||||
public ?string $manufacturer,
|
||||
public ?int $statusId,
|
||||
public ?string $statusName,
|
||||
public ?int $locationId,
|
||||
public ?string $locationName,
|
||||
public bool $requestable,
|
||||
public ?string $assignedType,
|
||||
public ?int $assignedId,
|
||||
public ?string $imageUrl,
|
||||
public ?CarbonImmutable $sourceUpdatedAt,
|
||||
) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
16
app/Integrations/SnipeIt/Data/HardwarePage.php
Normal file
16
app/Integrations/SnipeIt/Data/HardwarePage.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt\Data;
|
||||
|
||||
final readonly class HardwarePage
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
public function __construct(
|
||||
public int $total,
|
||||
public int $offset,
|
||||
public int $limit,
|
||||
public array $rows,
|
||||
) {}
|
||||
}
|
||||
11
app/Integrations/SnipeIt/Exceptions/SnipeItApiException.php
Normal file
11
app/Integrations/SnipeIt/Exceptions/SnipeItApiException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt\Exceptions;
|
||||
|
||||
class SnipeItApiException extends SnipeItException
|
||||
{
|
||||
public function __construct(string $message, public readonly ?int $statusCode = null)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt\Exceptions;
|
||||
|
||||
class SnipeItConfigurationException extends SnipeItException {}
|
||||
7
app/Integrations/SnipeIt/Exceptions/SnipeItException.php
Normal file
7
app/Integrations/SnipeIt/Exceptions/SnipeItException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SnipeItException extends RuntimeException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt\Exceptions;
|
||||
|
||||
class SnipeItMappingException extends SnipeItException {}
|
||||
86
app/Integrations/SnipeIt/SnipeItAssetMapper.php
Normal file
86
app/Integrations/SnipeIt/SnipeItAssetMapper.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt;
|
||||
|
||||
use App\Integrations\SnipeIt\Data\AssetData;
|
||||
use App\Integrations\SnipeIt\Exceptions\SnipeItMappingException;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
final class SnipeItAssetMapper
|
||||
{
|
||||
/** @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Integrations/SnipeIt/SnipeItClient.php
Normal file
142
app/Integrations/SnipeIt/SnipeItClient.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\SnipeIt;
|
||||
|
||||
use App\Integrations\SnipeIt\Data\HardwarePage;
|
||||
use App\Integrations\SnipeIt\Exceptions\SnipeItApiException;
|
||||
use App\Integrations\SnipeIt\Exceptions\SnipeItConfigurationException;
|
||||
use Generator;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
final class SnipeItClient
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'));
|
||||
}
|
||||
|
||||
public function configuredUrl(): ?string
|
||||
{
|
||||
$url = trim((string) config('services.snipe_it.url'));
|
||||
|
||||
return $url === '' ? null : rtrim($url, '/');
|
||||
}
|
||||
|
||||
public function listHardwarePage(int $offset = 0, ?int $limit = null): HardwarePage
|
||||
{
|
||||
$pageSize = $limit ?? (int) config('services.snipe_it.page_size', 100);
|
||||
$pageSize = max(1, min(500, $pageSize));
|
||||
|
||||
$response = $this->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<array<string, mixed>> $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<string, mixed> $row */
|
||||
$normalizedRows[] = $row;
|
||||
}
|
||||
|
||||
return new HardwarePage((int) $total, max(0, $offset), $pageSize, $normalizedRows);
|
||||
}
|
||||
|
||||
/** @return Generator<int, HardwarePage> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
32
app/Jobs/SyncSnipeAssets.php
Normal file
32
app/Jobs/SyncSnipeAssets.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Domain\Inventory\Services\AssetSyncService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class SyncSnipeAssets implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 900;
|
||||
|
||||
public int $uniqueFor = 900;
|
||||
|
||||
/** @var list<int> */
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return 'snipe-it-assets';
|
||||
}
|
||||
|
||||
public function handle(AssetSyncService $syncService): void
|
||||
{
|
||||
$syncService->sync();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user