290 lines
9.9 KiB
PHP
290 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace App\Integrations\SnipeIt;
|
|
|
|
use App\Integrations\SnipeIt\Data\AssetData;
|
|
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\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
final class SnipeItClient
|
|
{
|
|
public function __construct(private readonly SnipeItAssetMapper $mapper) {}
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
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'));
|
|
|
|
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->readRequest()->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);
|
|
}
|
|
|
|
public function getHardware(int $hardwareId): AssetData
|
|
{
|
|
$this->assertPositiveIdentifiers($hardwareId);
|
|
|
|
$response = $this->readRequest()->get("/hardware/{$hardwareId}");
|
|
if (! $response->successful()) {
|
|
throw new SnipeItApiException(
|
|
"O Snipe-IT respondeu com HTTP {$response->status()} ao consultar o equipamento.",
|
|
$response->status(),
|
|
);
|
|
}
|
|
|
|
$payload = $response->json();
|
|
if (! is_array($payload) || ($payload['status'] ?? null) === 'error') {
|
|
throw new SnipeItApiException('O Snipe-IT não devolveu um estado válido para o equipamento.');
|
|
}
|
|
|
|
/** @var array<string, mixed> $payload */
|
|
return $this->mapper->map($payload);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function checkoutHardware(
|
|
int $hardwareId,
|
|
int $statusId,
|
|
int $assignedUserId,
|
|
string $expectedCheckin,
|
|
?string $note = null,
|
|
): array {
|
|
$this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId);
|
|
|
|
$current = $this->getHardware($hardwareId);
|
|
if ($current->isAssigned()) {
|
|
throw new SnipeItApiException(
|
|
"O equipamento {$current->assetTag} já está atribuído no Snipe-IT; o checkout não foi executado.",
|
|
);
|
|
}
|
|
|
|
$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<string, mixed> */
|
|
public function checkinHardware(
|
|
int $hardwareId,
|
|
int $statusId,
|
|
int $expectedAssignedUserId,
|
|
?int $locationId = null,
|
|
?string $note = null,
|
|
): array {
|
|
$this->assertPositiveIdentifiers($hardwareId, $statusId, $expectedAssignedUserId);
|
|
|
|
if ($locationId !== null && $locationId < 1) {
|
|
throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.');
|
|
}
|
|
|
|
$current = $this->getHardware($hardwareId);
|
|
if (! $current->isAssignedToUser($expectedAssignedUserId)) {
|
|
throw new SnipeItApiException(
|
|
"O equipamento {$current->assetTag} não está atribuído ao destinatário esperado; o check-in não foi executado.",
|
|
);
|
|
}
|
|
|
|
$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.');
|
|
}
|
|
|
|
$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 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<string, mixed> */
|
|
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<string, mixed> $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();
|
|
|
|
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();
|
|
}
|
|
}
|