feat: protect production Snipe-IT operations
This commit is contained in:
@@ -144,7 +144,7 @@ final class HandoverService
|
||||
$this->authorizeOperator($operator);
|
||||
$this->assertOperationalConfiguration();
|
||||
|
||||
$reservation = Reservation::query()->with('items.asset')->findOrFail($reservation->id);
|
||||
$reservation = Reservation::query()->with(['requester', 'items.asset'])->findOrFail($reservation->id);
|
||||
if (! in_array($reservation->status, [
|
||||
ReservationStatus::Delivered,
|
||||
ReservationStatus::InUse,
|
||||
@@ -166,10 +166,23 @@ final class HandoverService
|
||||
if ($asset->status_id === null) {
|
||||
throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]);
|
||||
}
|
||||
if ($reservation->requester->snipe_user_id === null) {
|
||||
throw ValidationException::withMessages(['operation' => 'O destinatário não tem associação Snipe-IT confirmada.']);
|
||||
}
|
||||
$hasPortalCheckout = $item->handoverRecords()
|
||||
->where('type', HandoverType::Checkout->value)
|
||||
->where('recipient_id', $reservation->requester_id)
|
||||
->exists();
|
||||
if (! $hasPortalCheckout) {
|
||||
throw ValidationException::withMessages([
|
||||
'operation' => "O equipamento {$asset->asset_tag} não tem um checkout confirmado pelo portal; o check-in foi bloqueado.",
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->snipeIt->checkinHardware(
|
||||
(int) $asset->snipe_asset_id,
|
||||
(int) $asset->status_id,
|
||||
(int) $reservation->requester->snipe_user_id,
|
||||
$asset->location_id === null ? null : (int) $asset->location_id,
|
||||
$this->externalNote($reservation, $notes),
|
||||
);
|
||||
|
||||
@@ -27,6 +27,22 @@ final readonly class AssetData
|
||||
public ?CarbonImmutable $sourceUpdatedAt,
|
||||
) {}
|
||||
|
||||
public function isAssigned(): bool
|
||||
{
|
||||
return $this->assignedType !== null || $this->assignedId !== null;
|
||||
}
|
||||
|
||||
public function isAssignedToUser(int $userId): bool
|
||||
{
|
||||
if ($this->assignedId !== $userId || $this->assignedType === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalizedType = strtolower(str_replace(['\\', '_', '-', ' '], '', $this->assignedType));
|
||||
|
||||
return str_ends_with($normalizedType, 'user');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toModelAttributes(): array
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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;
|
||||
@@ -16,6 +17,8 @@ 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'));
|
||||
@@ -98,6 +101,27 @@ final class SnipeItClient
|
||||
} 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,
|
||||
@@ -108,6 +132,13 @@ final class SnipeItClient
|
||||
): 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',
|
||||
@@ -128,15 +159,23 @@ final class SnipeItClient
|
||||
public function checkinHardware(
|
||||
int $hardwareId,
|
||||
int $statusId,
|
||||
int $expectedAssignedUserId,
|
||||
?int $locationId = null,
|
||||
?string $note = null,
|
||||
): array {
|
||||
$this->assertPositiveIdentifiers($hardwareId, $statusId);
|
||||
$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) {
|
||||
|
||||
Reference in New Issue
Block a user