feat: complete equipment handover workflow

This commit is contained in:
2026-08-04 17:15:51 +01:00
parent 809eab9d4a
commit d829f34580
24 changed files with 1800 additions and 28 deletions

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domain\Operations\Enums;
enum HandoverType: string
{
case Checkout = 'checkout';
case Checkin = 'checkin';
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domain\Operations\Models;
use App\Domain\Operations\Enums\HandoverType;
use App\Domain\Reservations\Models\ReservationItem;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HandoverRecord extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'reservation_item_id', 'type', 'operator_id', 'recipient_id',
'condition', 'notes', 'snipe_response', 'created_at',
];
protected function casts(): array
{
return [
'type' => HandoverType::class,
'snipe_response' => 'array',
'created_at' => 'immutable_datetime',
];
}
/** @return BelongsTo<ReservationItem, $this> */
public function reservationItem(): BelongsTo
{
return $this->belongsTo(ReservationItem::class);
}
/** @return BelongsTo<User, $this> */
public function operator(): BelongsTo
{
return $this->belongsTo(User::class, 'operator_id');
}
/** @return BelongsTo<User, $this> */
public function recipient(): BelongsTo
{
return $this->belongsTo(User::class, 'recipient_id');
}
}

View File

@@ -0,0 +1,401 @@
<?php
namespace App\Domain\Operations\Services;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Operations\Enums\HandoverType;
use App\Domain\Operations\Models\HandoverRecord;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\AssetBlock;
use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Models\ReservationItem;
use App\Integrations\SnipeIt\SnipeItClient;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Throwable;
final class HandoverService
{
public function __construct(private readonly SnipeItClient $snipeIt) {}
public function markReady(Reservation $reservation, User $operator): Reservation
{
$this->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<int> $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<string, mixed> $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}"));
}
}

View File

@@ -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 => [],
};

View File

@@ -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<HandoverRecord, $this> */
public function handoverRecords(): HasMany
{
return $this->hasMany(HandoverRecord::class);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Operations\Services\HandoverService;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Models\ReservationItem;
use App\Integrations\SnipeIt\SnipeItClient;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OperationController extends Controller
{
public function index(Request $request, SnipeItClient $snipeIt): Response
{
$start = now()->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}.");
}
}

View File

@@ -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',

View File

@@ -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<string, mixed> */
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<string, mixed> */
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<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();

View File

@@ -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
{

View File

@@ -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)),
);
}
/**