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

@@ -29,6 +29,12 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Resposta auditada a pedidos de esclarecimento e renovação do prazo de decisão.
- Substituição controlada por equipamento disponível da mesma categoria, preservando o item original no histórico.
- Filtros da fila de aprovação por categoria/período e histórico de ações apresentado na interface.
- Cliente operacional Snipe-IT com token separado, checkout para utilizador, check-in para localização habitual e validação defensiva de respostas.
- Domínio `Operations` com registos de entrega/devolução, condição física, operador, destinatário e resposta externa.
- Área diária do operador com pesquisa/leitura por scanner, preparação, entrega, devolução, conclusão, faltas, atrasos e incidentes.
- Associação do utilizador local ao ID Snipe-IT confirmada durante a entrega.
- Bloqueios temporais de equipamento criados a partir de incidentes e considerados pelo motor de disponibilidade.
- Tratamento e auditoria de falhas parciais em movimentos com vários equipamentos.
### Alterado
@@ -38,11 +44,13 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- As mensagens flash globais recebem agora as propriedades diretamente de `withApp`, evitando o uso de `usePage()` fora do contexto Inertia que interrompia o arranque do React.
- SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite.
- Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo.
- Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução.
### Segurança
- Registo público desativado.
- Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores.
- Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático.
### Verificado
@@ -56,3 +64,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Comando de expiração executado em PostgreSQL e agendamento a cada cinco minutos confirmado.
- Builds Vite cliente e SSR com as páginas de disponibilidade, cancelamento e calendário concluídos.
- Migration da caixa interna de notificações aplicada em PostgreSQL 17.
- Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada.

View File

@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
## Estado atual
O projeto concluiu as **Fases 1 a 4**: fundação técnica, integração de leitura Snipe-IT, motor de reservas e aprovações. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, cancelamento e expiração, calendário pessoal, decisões transacionais, esclarecimentos, substituição equivalente, notificações por email/caixa interna, dashboard e diagnóstico administrativo. A próxima entrega implementa o fluxo físico de entrega e devolução.
O projeto concluiu as **Fases 1 a 5**: fundação técnica, integração Snipe-IT, motor de reservas, aprovações e operações físicas. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, calendário pessoal, decisões e notificações, checkout/check-in Snipe-IT, área diária do operador, entrega/devolução auditada, incidentes, atrasos e bloqueios. A próxima entrega é a administração e os relatórios.
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.
@@ -34,7 +34,7 @@ Estas credenciais existem apenas no `DatabaseSeeder` e nunca devem ser usadas em
## Comandos frequentes
```bash
docker compose exec app php artisan test
docker compose exec app composer test
docker compose exec app composer lint
docker compose exec app composer types:check
docker compose exec node npm run types:check
@@ -42,6 +42,12 @@ docker compose exec node npm run lint:check
docker compose exec node npm run build
```
Como o serviço Docker define variáveis de desenvolvimento, use o script Composer para testes. O script força de forma isolada `APP_ENV=testing`, SQLite em memória, cache/sessão em memória e fila síncrona, evitando locks Redis ou middleware CSRF do ambiente local:
```bash
docker compose exec app composer test
```
Para repor apenas dados de desenvolvimento:
```bash

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

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('snipe_user_id')->nullable()->index()->after('department');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('snipe_user_id');
});
}
};

View File

@@ -33,7 +33,7 @@ Nginx ──► Laravel + Inertia/React
- `app/Domain/Inventory`: cache local de ativos e consultas do catálogo.
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação.
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
- `app/Domain/Operations`: entrega, devolução, faltas, atrasos e incidentes.
- `app/Domain/Audit`: logs imutáveis pela interface.
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.
@@ -65,9 +65,13 @@ Notificações implementam `ShouldQueue`, usam `afterCommit` e seguem dois canai
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.
`HandoverService` mantém a ordem explícita “Snipe-IT primeiro, confirmação local depois” para cada equipamento. Uma requisição com vários itens não é apresentada como concluída se apenas parte dos movimentos for aceite: os itens confirmados preservam o seu `HandoverRecord`, os restantes mantêm o estado anterior e a requisição passa a `incident`. As escritas externas não têm retry automático porque um timeout pode ocorrer depois de o Snipe-IT executar a operação; repetir sem reconciliação poderia criar um resultado enganador.
O checkout associa o ativo a um utilizador Snipe-IT. O operador confirma o ID remoto na primeira entrega e o portal guarda-o em `users.snipe_user_id` para utilizações futuras. O check-in repõe o estado e a localização habitual sincronizados do ativo. As respostas externas ficam em `handover_records.snipe_response`; tokens nunca são persistidos.
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).
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), [limites/paginação](https://snipe-it.readme.io/reference/api-throttling), [checkout de hardware](https://snipe-it.readme.io/reference/hardware-checkout), [check-in de hardware](https://snipe-it.readme.io/reference/hardware-checkin) e [formato de erros da API](https://snipe-it.readme.io/reference/api-overview).
## Segurança

View File

@@ -9,7 +9,7 @@ Usar Laravel 13 com React/TypeScript através de Inertia no mesmo projeto. Mant
## ADR-002 — PostgreSQL como garantia de conflitos
**Data:** 2026-08-04
**Data:** 2026-08-04
**Estado:** aceite
Usar PostgreSQL e períodos semiabertos `[início, fim)`. A lógica aplicacional melhora a mensagem ao utilizador; uma restrição de exclusão GiST garante que concorrência ou um erro aplicacional não cria sobreposição bloqueante.
@@ -69,3 +69,12 @@ A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou
**Estado:** aceite
Notificações funcionais são colocadas na fila apenas depois da confirmação da transação e enviadas por email e caixa interna. A submissão e uma resposta a esclarecimentos notificam aprovadores ativos; decisões e substituições notificam o requisitante. Uma substituição não altera automaticamente a decisão: exige justificação, equipamento livre da mesma categoria, bloqueio de ambas as linhas, novo item `held` e preservação do item original como `cancelled` para auditoria.
## ADR-011 — Escritas Snipe-IT sem retry e reconciliação explícita
**Data:** 2026-08-04
**Estado:** aceite
Checkout e check-in usam um token operacional distinto do token de leitura e nunca escrevem diretamente na base de dados do Snipe-IT. Cada equipamento é movimentado remotamente antes de o respetivo `reservation_item` e `handover_record` serem confirmados localmente. Escritas não têm retry automático: uma falha de ligação ou HTTP 5xx pode ser posterior à execução remota e a repetição cega não é segura. Numa operação com vários itens, sucessos confirmados são preservados e uma falha posterior coloca a requisição em `incident`, com IDs concluídos, item falhado e erro em auditoria. O operador reconcilia o ativo no Snipe-IT e repete apenas o movimento ainda pendente.
O checkout é dirigido a `checkout_to_type=user`; o ID remoto confirmado pelo operador é guardado em `users.snipe_user_id`. O check-in usa o estado e a localização habitual provenientes da última sincronização. Esta opção segue os contratos oficiais de [checkout](https://snipe-it.readme.io/reference/hardware-checkout), [check-in](https://snipe-it.readme.io/reference/hardware-checkin) e o comportamento documentado de [erros em HTTP 200](https://snipe-it.readme.io/reference/api-overview).

View File

@@ -1,7 +1,7 @@
# Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026
**Fase:** Fases 1, 2, 3 e 4 concluídas
**Fase:** Fases 1 a 5 concluídas; próxima entrega: Fase 6
**Último agente:** Codex
## Resultado disponível
@@ -34,44 +34,51 @@
- Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas.
- Substituição de equipamento apenas por ativo livre da mesma categoria, mantendo a pré-reserva e todo o histórico.
- Fila de aprovação com filtros por referência, requisitante, categoria e período, incluindo histórico de ações.
- Cliente Snipe-IT operacional com token separado, checkout para utilizador, check-in para a localização habitual e validação de erros transportados em HTTP 200.
- Área diária do operador protegida pelo perfil `inventory_operator`/`administrator`, com leitura por scanner-teclado ou pesquisa de referência, requisitante, etiqueta e ID Snipe-IT.
- Preparação, entrega, devolução e conclusão com máquina de estados, condição física, observações, operador, destinatário e resposta externa em `handover_records`.
- Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida.
- Tratamento explícito de falhas parciais: movimentos já aceites são preservados, a requisição passa a `incident` e a falha fica em `audit_logs` para reconciliação.
- Registo de falta, atraso e incidente; um incidente pode criar um bloqueio temporal do equipamento que entra imediatamente no motor de disponibilidade.
## Limites conhecidos
- 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.
- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
- As notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto.
- As políticas de autorização finas ainda não foram ligadas às rotas; nesta fase todas as rotas funcionais exigem utilizador autenticado, verificado e ativo.
- O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa.
- O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada.
- Uma falha depois de o Snipe-IT confirmar um movimento e antes do commit local é uma transação distribuída ambígua; não existe retry automático de escritas. O operador deve reconciliar pelos registos Snipe-IT e `audit_logs` conforme o runbook.
## Próxima tarefa recomendada
Implementar a **Fase 5 — entrega e devolução**:
Implementar a **Fase 6 — administração e relatórios**:
1. abrir `app/Integrations/SnipeIt/SnipeItClient.php` e acrescentar checkout/check-in com o token operacional separado;
2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais;
3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico;
4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída;
5. implementar incidentes, atrasos e bloqueios associados a dano;
6. testar API simulada, autorização, idempotência e auditoria;
7. atualizar este documento e o changelog.
1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT;
2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias;
3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados;
4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado;
5. criar consulta de auditoria apenas de leitura para o perfil `auditor`;
6. adicionar métricas operacionais e gestão de modelos de email;
7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação.
## Testes da última sessão
Validação concluída em 4 de agosto de 2026:
- `docker compose exec -T app composer test`: passou;
- Laravel Pint: 111 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 84 ficheiros, zero erros;
- Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- Laravel Pint: 117 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 89 ficheiros, zero erros;
- Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- TypeScript (`tsc --noEmit`): passou;
- ESLint: passou;
- Prettier: passou;
- Vite: builds cliente e SSR concluídos, 2308 módulos cliente e 97 módulos SSR transformados;
- Vite: builds cliente e SSR concluídos, 2309 módulos cliente e 98 módulos SSR 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;
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
- PostgreSQL 17: migration `notifications` aplicada com sucesso;
- PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada;
- 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;

View File

@@ -30,6 +30,8 @@ curl -I -H 'Origin: http://localhost:8000' http://localhost:5173/@vite/client
curl -I -H 'Origin: http://localhost:8000' http://localhost:5173/resources/js/app.tsx
```
Para executar a validação backend use `docker compose exec app composer test`, não `php artisan test` diretamente. O script isola as variáveis de teste das variáveis `local` injetadas pelo Docker Compose e evita que jobs únicos deixem locks no Redis de desenvolvimento.
Ambos devem responder com estado 200 e `Access-Control-Allow-Origin: http://localhost:8000`. Reiniciar o serviço com `docker compose restart node` se o ficheiro `public/hot` estiver desatualizado.
Confirmar também que o HTML inicial já contém conteúdo SSR:
@@ -57,13 +59,14 @@ Definir no ambiente, sem colocar tokens no repositório:
```dotenv
SNIPE_IT_URL=https://inventario.exemplo.pt
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
```
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.
O token de leitura deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. O token operacional deve ser separado e ter apenas as permissões necessárias para checkout/check-in. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém.
Operações:
@@ -80,6 +83,28 @@ 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.
## Entregas, devoluções e reconciliação
Utilizadores com perfil `inventory_operator` ou `administrator` acedem a `http://localhost:8000/operacoes`. A fila inclui as reservas do dia e todos os processos entregues, atrasados, devolvidos ou com incidente ainda pendente. O campo inicial aceita referência, nome, email, etiqueta, ID Snipe-IT ou a saída de um leitor QR/código de barras configurado como teclado.
Procedimento de entrega:
1. preparar a requisição;
2. confirmar fisicamente todos os equipamentos e o estado à saída;
3. confirmar o ID do destinatário no Snipe-IT;
4. registar a entrega e verificar a mensagem de sucesso;
5. se existir erro, não voltar a submeter antes de consultar o estado do ativo no Snipe-IT.
Procedimento de devolução:
1. localizar a requisição ou ler a etiqueta;
2. verificar equipamento, acessórios, danos e localização;
3. registar a devolução;
4. criar um incidente e bloqueio temporal quando o ativo não deva voltar ao catálogo;
5. concluir o processo apenas depois de todos os itens estarem devolvidos.
Uma operação parcial fica em `incident`. Consultar `handover_records` para movimentos confirmados e os eventos `reservation.delivery_failed`/`reservation.return_failed` em `audit_logs`. Comparar cada item com o estado atual no Snipe-IT; corrigir apenas os itens ainda `ready` ou `delivered`. Não alterar estados diretamente na base de dados nem repetir em bloco, porque as escritas externas são deliberadamente não idempotentes e não têm retry automático.
## Ciclo de vida das pré-reservas
As requisições pendentes têm, por defeito, um prazo de decisão de 48 horas. O scheduler coloca a expiração na fila a cada cinco minutos. Para operar ou diagnosticar manualmente:

View File

@@ -6,6 +6,7 @@ import {
LayoutGrid,
ListChecks,
PackageSearch,
PackageCheck,
PlusCircle,
ServerCog,
ShieldCheck,
@@ -71,16 +72,26 @@ export function AppSidebar() {
},
]
: userNavItems;
const visibleNavItems = can.administer
const operationalNavItems = can.operateInventory
? [
...roleNavItems,
{
title: 'Operações',
href: '/operacoes',
icon: PackageCheck,
},
]
: roleNavItems;
const visibleNavItems = can.administer
? [
...operationalNavItems,
{
title: 'Integração Snipe-IT',
href: '/administracao/snipe-it',
icon: ServerCog,
},
]
: roleNavItems;
: operationalNavItems;
return (
<Sidebar collapsible="icon" variant="inset">

View File

@@ -33,6 +33,11 @@ const statuses: Record<string, { label: string; className: string }> = {
label: 'Devolução atrasada',
className: 'bg-red-100 text-red-800',
},
no_show: {
label: 'Não compareceu',
className: 'bg-slate-100 text-slate-700',
},
incident: { label: 'Incidente', className: 'bg-red-100 text-red-800' },
};
export function ReservationStatus({ status }: { status: string }) {

View File

@@ -0,0 +1,620 @@
import { Head, router, usePage } from '@inertiajs/react';
import {
AlertTriangle,
CheckCircle2,
ClipboardCheck,
PackageCheck,
RotateCcw,
ScanLine,
} from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeading } from '@/components/page-heading';
import { ReservationStatus } from '@/components/reservation-status';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
type HandoverRecord = {
id: number;
type: 'checkout' | 'checkin';
condition: string | null;
notes: string | null;
created_at: string;
operator: { name: string };
};
type OperationReservation = {
id: number;
uuid: string;
reference: string;
purpose: string;
usage_location: string;
starts_at: string;
ends_at: string;
status: string;
requester: {
name: string;
email: string;
department: string | null;
snipe_user_id: number | null;
};
items: {
id: number;
status: string;
asset: {
snipe_asset_id: number;
name: string;
asset_tag: string;
status_name: string | null;
location_name: string | null;
};
handover_records: HandoverRecord[];
}[];
};
type FormState = {
snipeUserId: string;
condition: string;
notes: string;
};
export default function OperationsIndex({
reservations,
operationallyConfigured,
}: {
reservations: OperationReservation[];
operationallyConfigured: boolean;
}) {
const { errors } = usePage().props;
const [forms, setForms] = useState<Record<number, FormState>>({});
const [incidents, setIncidents] = useState<
Record<number, { notes: string; blockedUntil: string }>
>({});
const [search, setSearch] = useState('');
const normalizedSearch = search.trim().toLocaleLowerCase('pt-PT');
const visibleReservations = reservations.filter((reservation) => {
if (!normalizedSearch) {
return true;
}
return [
reservation.reference,
reservation.requester.name,
reservation.requester.email,
...reservation.items.flatMap((item) => [
item.asset.asset_tag,
item.asset.snipe_asset_id.toString(),
]),
].some((value) =>
value.toLocaleLowerCase('pt-PT').includes(normalizedSearch),
);
});
function formFor(reservation: OperationReservation): FormState {
return (
forms[reservation.id] ?? {
snipeUserId:
reservation.requester.snipe_user_id?.toString() ?? '',
condition: 'Sem danos visíveis',
notes: '',
}
);
}
function updateForm(
reservation: OperationReservation,
key: keyof FormState,
value: string,
) {
setForms((current) => ({
...current,
[reservation.id]: { ...formFor(reservation), [key]: value },
}));
}
function post(
reservation: OperationReservation,
action: string,
data = {},
) {
router.post(`/operacoes/${reservation.uuid}/${action}`, data, {
preserveScroll: true,
});
}
return (
<>
<Head title="Operações" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Área do operador"
title="Entregas e devoluções"
description="Fila diária com registo simultâneo no portal e no Snipe-IT. As ocorrências ficam associadas ao operador e ao equipamento."
/>
<label className="relative block">
<ScanLine className="absolute top-2.5 left-3 size-5 text-muted-foreground" />
<Input
autoFocus
className="h-10 pl-11"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Ler QR/etiqueta ou pesquisar referência, nome e email…"
/>
<span className="mt-1 block text-xs text-muted-foreground">
Compatível com leitores QR/ código de barras que
funcionam como teclado; também aceita o ID Snipe-IT.
</span>
</label>
{!operationallyConfigured && (
<div className="flex gap-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
<div>
<p className="font-semibold">
Movimentos externos indisponíveis
</p>
<p>
Configure SNIPE_IT_OPERATIONAL_TOKEN para ativar
entregas e devoluções. A preparação continua
disponível.
</p>
</div>
</div>
)}
<InputError message={errors.operation} />
<section className="space-y-4">
{visibleReservations.length === 0 && (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
<CheckCircle2 className="size-9 text-emerald-600" />
<p className="font-medium">
{search
? 'Nenhuma operação corresponde à leitura ou pesquisa.'
: 'Não há operações pendentes para hoje.'}
</p>
</CardContent>
</Card>
)}
{visibleReservations.map((reservation) => {
const form = formFor(reservation);
const canReturn =
[
'delivered',
'in_use',
'overdue',
'incident',
].includes(reservation.status) &&
reservation.items.some(
(item) => item.status === 'delivered',
);
const canDeliver = reservation.items.some(
(item) => item.status === 'ready',
);
const canComplete =
reservation.status === 'returned' ||
(reservation.status === 'incident' &&
reservation.items.every((item) =>
['returned', 'cancelled'].includes(
item.status,
),
));
return (
<Card
key={reservation.id}
className="gap-4 shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-4">
<div>
<CardTitle className="font-mono text-base">
{reservation.reference}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{reservation.requester.name} ·{' '}
{reservation.usage_location}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatPeriod(
reservation.starts_at,
reservation.ends_at,
)}
</p>
</div>
<ReservationStatus
status={reservation.status}
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2">
{reservation.items
.filter(
(item) =>
item.status !== 'cancelled',
)
.map((item) => (
<div
key={item.id}
className="rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium">
{
item.asset
.name
}
</p>
<p className="font-mono text-xs text-muted-foreground">
{
item.asset
.asset_tag
}{' '}
·{' '}
{item.asset
.location_name ??
'Sem localização'}
</p>
</div>
<span className="rounded bg-muted px-2 py-0.5 text-xs">
{item.status}
</span>
</div>
{item.handover_records
.length > 0 && (
<div className="mt-2 border-t pt-2 text-xs text-muted-foreground">
{item.handover_records.map(
(record) => (
<p
key={
record.id
}
>
{record.type ===
'checkout'
? 'Entregue'
: 'Devolvido'}{' '}
por{' '}
{
record
.operator
.name
}{' '}
·{' '}
{
record.condition
}
</p>
),
)}
</div>
)}
{[
'ready',
'delivered',
'in_use',
'overdue',
'returned',
].includes(
reservation.status,
) && (
<IncidentForm
itemId={item.id}
value={
incidents[
item.id
] ?? {
notes: '',
blockedUntil:
'',
}
}
onChange={(value) =>
setIncidents(
(
current,
) => ({
...current,
[item.id]:
value,
}),
)
}
onSubmit={() => {
const incident =
incidents[
item.id
] ?? {
notes: '',
blockedUntil:
'',
};
router.post(
`/operacoes/${reservation.uuid}/itens/${item.id}/incidente`,
{
notes: incident.notes,
blocked_until:
incident.blockedUntil ||
null,
},
{
preserveScroll: true,
},
);
}}
/>
)}
</div>
))}
</div>
{reservation.status === 'approved' && (
<Button
onClick={() =>
post(reservation, 'preparar')
}
>
<ClipboardCheck /> Preparar
equipamentos
</Button>
)}
{canDeliver && (
<div className="grid gap-3 rounded-xl bg-muted/50 p-4 md:grid-cols-3">
<label className="space-y-1 text-xs font-medium">
ID do destinatário no Snipe-IT
<Input
type="number"
min={1}
value={form.snipeUserId}
onChange={(event) =>
updateForm(
reservation,
'snipeUserId',
event.target.value,
)
}
/>
</label>
<label className="space-y-1 text-xs font-medium">
Estado à saída
<Input
value={form.condition}
onChange={(event) =>
updateForm(
reservation,
'condition',
event.target.value,
)
}
/>
</label>
<label className="space-y-1 text-xs font-medium">
Observações
<Input
value={form.notes}
onChange={(event) =>
updateForm(
reservation,
'notes',
event.target.value,
)
}
/>
</label>
<div className="flex flex-wrap gap-2 md:col-span-3">
<Button
disabled={
!operationallyConfigured ||
!form.snipeUserId
}
onClick={() =>
post(
reservation,
'entregar',
{
snipe_user_id:
Number(
form.snipeUserId,
),
condition:
form.condition,
notes:
form.notes ||
null,
},
)
}
>
<PackageCheck /> Registar
entrega
</Button>
<Button
variant="outline"
disabled={
form.notes.trim()
.length < 3
}
onClick={() =>
post(
reservation,
'falta',
{
notes: form.notes,
},
)
}
>
Registar falta
</Button>
</div>
</div>
)}
{canReturn && (
<div className="grid gap-3 rounded-xl bg-muted/50 p-4 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium">
Estado à entrada
<Input
value={form.condition}
onChange={(event) =>
updateForm(
reservation,
'condition',
event.target.value,
)
}
/>
</label>
<label className="space-y-1 text-xs font-medium">
Observações
<Input
value={form.notes}
onChange={(event) =>
updateForm(
reservation,
'notes',
event.target.value,
)
}
/>
</label>
<div className="flex flex-wrap gap-2 md:col-span-2">
<Button
disabled={
!operationallyConfigured
}
onClick={() =>
post(
reservation,
'devolver',
{
condition:
form.condition,
notes:
form.notes ||
null,
},
)
}
>
<RotateCcw /> Registar
devolução
</Button>
{[
'delivered',
'in_use',
].includes(
reservation.status,
) && (
<Button
variant="outline"
disabled={
form.notes.trim()
.length < 3
}
onClick={() =>
post(
reservation,
'atraso',
{
notes: form.notes,
},
)
}
>
Marcar atraso
</Button>
)}
</div>
</div>
)}
{canComplete && (
<Button
onClick={() =>
post(reservation, 'concluir')
}
>
<CheckCircle2 /> Concluir processo
</Button>
)}
</CardContent>
</Card>
);
})}
</section>
</main>
</>
);
}
function IncidentForm({
value,
onChange,
onSubmit,
}: {
itemId: number;
value: { notes: string; blockedUntil: string };
onChange: (value: { notes: string; blockedUntil: string }) => void;
onSubmit: () => void;
}) {
return (
<details className="mt-3 border-t pt-2">
<summary className="cursor-pointer text-xs font-medium text-amber-700">
Registar incidente
</summary>
<div className="mt-2 space-y-2">
<Input
placeholder="Descrição do incidente"
value={value.notes}
onChange={(event) =>
onChange({ ...value, notes: event.target.value })
}
/>
<label className="block text-xs text-muted-foreground">
Bloquear equipamento até (opcional)
<Input
className="mt-1"
type="datetime-local"
value={value.blockedUntil}
onChange={(event) =>
onChange({
...value,
blockedUntil: event.target.value,
})
}
/>
</label>
<Button
size="sm"
variant="outline"
disabled={value.notes.trim().length < 5}
onClick={onSubmit}
>
<AlertTriangle /> Registar
</Button>
</div>
</details>
);
}
function formatPeriod(startsAt: string, endsAt: string) {
const formatter = new Intl.DateTimeFormat('pt-PT', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
return `${formatter.format(new Date(startsAt))} — ${formatter.format(new Date(endsAt))}`;
}
OperationsIndex.layout = {
breadcrumbs: [{ title: 'Operações', href: '/operacoes' }],
};

View File

@@ -19,6 +19,7 @@ declare module '@inertiajs/core' {
can: {
administer: boolean;
approveReservations: boolean;
operateInventory: boolean;
};
unreadNotifications: number;
sidebarOpen: boolean;

View File

@@ -5,6 +5,7 @@ use App\Http\Controllers\ApprovalController;
use App\Http\Controllers\AssetController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\OperationController;
use App\Http\Controllers\ReservationController;
use Illuminate\Support\Facades\Route;
@@ -31,6 +32,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('{reservation:uuid}/itens/{item}/substituir', [ApprovalController::class, 'substitute'])->name('substitute');
});
Route::middleware('can:operate-inventory')->prefix('operacoes')->name('operations.')->group(function () {
Route::get('/', [OperationController::class, 'index'])->name('index');
Route::post('{reservation:uuid}/preparar', [OperationController::class, 'prepare'])->name('prepare');
Route::post('{reservation:uuid}/entregar', [OperationController::class, 'deliver'])->name('deliver');
Route::post('{reservation:uuid}/devolver', [OperationController::class, 'returnEquipment'])->name('return');
Route::post('{reservation:uuid}/concluir', [OperationController::class, 'complete'])->name('complete');
Route::post('{reservation:uuid}/falta', [OperationController::class, 'noShow'])->name('no-show');
Route::post('{reservation:uuid}/atraso', [OperationController::class, 'overdue'])->name('overdue');
Route::post('{reservation:uuid}/itens/{item}/incidente', [OperationController::class, 'incident'])->name('incident');
});
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');

View File

@@ -0,0 +1,272 @@
<?php
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('services.snipe_it', [
'url' => 'https://inventory.example.test',
'read_token' => 'read-token-for-tests',
'operational_token' => 'operational-token-for-tests',
'timeout' => 2,
'page_size' => 100,
'retries' => 1,
'retry_delay_ms' => 0,
]);
});
function inventoryOperator(): User
{
$operator = User::factory()->create();
$role = Role::firstOrCreate(['name' => RoleName::InventoryOperator->value]);
$operator->roles()->attach($role);
return $operator;
}
/** @param list<SnipeAsset> $assets */
function approvedOperationReservation(User $requester, array $assets, string $reference): Reservation
{
$reservation = Reservation::create([
'reference' => $reference,
'requester_id' => $requester->id,
'purpose' => 'Aula prática com equipamento audiovisual.',
'usage_location' => 'Sala 12',
'starts_at' => now()->subHour(),
'ends_at' => now()->addHours(3),
'status' => ReservationStatus::Approved,
'approved_at' => now()->subDay(),
]);
foreach ($assets as $asset) {
$reservation->items()->create([
'snipe_asset_id' => $asset->id,
'starts_at' => $reservation->starts_at,
'ends_at' => $reservation->ends_at,
'status' => ReservationItemStatus::Reserved,
]);
}
return $reservation;
}
it('restringe e apresenta a fila diária aos operadores', function () {
$reservation = approvedOperationReservation(
User::factory()->create(),
[SnipeAsset::factory()->create()],
'REQ-OPS-QUEUE',
);
$this->actingAs(User::factory()->create())->get('/operacoes')->assertForbidden();
$this->actingAs(inventoryOperator())
->get('/operacoes')
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('operations/index')
->where('operationallyConfigured', true)
->has('reservations', 1)
->where('reservations.0.reference', $reservation->reference));
});
it('prepara e entrega todos os equipamentos com registo auditável', function () {
$operator = inventoryOperator();
$requester = User::factory()->create();
$asset = SnipeAsset::factory()->create(['snipe_asset_id' => 501, 'status_id' => 2]);
$reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-DELIVER');
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/preparar")
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready);
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 501]])]);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 77,
'condition' => 'Sem danos visíveis',
'notes' => 'Carregador incluído.',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered)
->and($requester->fresh()->snipe_user_id)->toBe(77);
$this->assertDatabaseHas('handover_records', [
'reservation_item_id' => $reservation->items()->firstOrFail()->id,
'type' => 'checkout',
'operator_id' => $operator->id,
'recipient_id' => $requester->id,
]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivered']);
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/501/checkout'));
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 77,
'condition' => 'Sem danos visíveis',
])
->assertSessionHasErrors('operation');
Http::assertSentCount(1);
});
it('regista a devolução no Snipe-IT e conclui o processo', function () {
$operator = inventoryOperator();
$asset = SnipeAsset::factory()->create([
'snipe_asset_id' => 601,
'status_id' => 2,
'location_id' => 9,
]);
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-RETURN');
$reservation->update(['status' => ReservationStatus::Delivered]);
$reservation->items()->update([
'status' => ReservationItemStatus::Delivered,
'delivered_at' => now()->subHours(2),
]);
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 601]])]);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/devolver", [
'condition' => 'Bom estado',
'notes' => 'Conferido pelo operador.',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Returned)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Returned);
$this->assertDatabaseHas('handover_records', ['type' => 'checkin', 'operator_id' => $operator->id]);
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/601/checkin')
&& $request['location_id'] === 9);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/concluir")
->assertRedirect();
expect($reservation->fresh()->status)->toBe(ReservationStatus::Completed);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.completed']);
});
it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', function () {
$operator = inventoryOperator();
$first = SnipeAsset::factory()->create(['snipe_asset_id' => 701, 'status_id' => 2]);
$second = SnipeAsset::factory()->create(['snipe_asset_id' => 702, 'status_id' => 2]);
$reservation = approvedOperationReservation(User::factory()->create(), [$first, $second], 'REQ-OPS-PARTIAL');
$reservation->update(['status' => ReservationStatus::Ready]);
$reservation->items()->update(['status' => ReservationItemStatus::Ready]);
Http::fakeSequence()
->push(['status' => 'success', 'payload' => ['id' => 701]])
->push(['status' => 'error', 'messages' => 'Asset is not deployable'])
->push(['status' => 'success', 'payload' => ['id' => 702]]);
$this->actingAs($operator)
->from('/operacoes')
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 88,
'condition' => 'Verificado',
])
->assertRedirect('/operacoes')
->assertSessionHasErrors('operation');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident)
->and($reservation->items()->orderBy('id')->pluck('status')->all())->toBe([
ReservationItemStatus::Delivered,
ReservationItemStatus::Ready,
]);
$this->assertDatabaseCount('handover_records', 1);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivery_failed']);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 88,
'condition' => 'Verificado após reconciliação',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered)
->and($reservation->items()->where('status', ReservationItemStatus::Delivered)->count())->toBe(2);
$this->assertDatabaseCount('handover_records', 2);
});
it('regista incidentes e bloqueia o equipamento pelo período indicado', function () {
$operator = inventoryOperator();
$asset = SnipeAsset::factory()->create();
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-INCIDENT');
$reservation->update(['status' => ReservationStatus::Delivered]);
$item = $reservation->items()->firstOrFail();
$item->update(['status' => ReservationItemStatus::Delivered]);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/itens/{$item->id}/incidente", [
'notes' => 'Objetiva danificada durante a utilização.',
'blocked_until' => now()->addDays(7)->toDateTimeLocalString(),
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident);
$this->assertDatabaseHas('asset_blocks', [
'snipe_asset_id' => $asset->id,
'created_by' => $operator->id,
'reason' => 'Objetiva danificada durante a utilização.',
]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.incident_reported']);
});
it('regista faltas e atrasos sem movimentos externos', function () {
$operator = inventoryOperator();
$requester = User::factory()->create();
$noShow = approvedOperationReservation(
$requester,
[SnipeAsset::factory()->create()],
'REQ-OPS-NO-SHOW',
);
$noShow->update(['status' => ReservationStatus::Ready]);
$noShow->items()->update(['status' => ReservationItemStatus::Ready]);
$this->actingAs($operator)
->post("/operacoes/{$noShow->uuid}/falta", ['notes' => 'O requisitante não compareceu.'])
->assertRedirect()
->assertSessionHas('success');
expect($noShow->fresh()->status)->toBe(ReservationStatus::NoShow)
->and($noShow->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Cancelled);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.no_show']);
$overdue = approvedOperationReservation(
$requester,
[SnipeAsset::factory()->create()],
'REQ-OPS-OVERDUE',
);
$overdue->update(['status' => ReservationStatus::Delivered]);
$overdue->items()->update(['status' => ReservationItemStatus::Delivered]);
$this->actingAs($operator)
->post("/operacoes/{$overdue->uuid}/atraso", ['notes' => 'Prazo de devolução ultrapassado.'])
->assertRedirect()
->assertSessionHas('success');
expect($overdue->fresh()->status)->toBe(ReservationStatus::Overdue);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.overdue']);
Http::assertNothingSent();
});

View File

@@ -9,6 +9,7 @@ beforeEach(function () {
config()->set('services.snipe_it', [
'url' => 'https://inventory.example.test',
'read_token' => 'read-token-for-tests',
'operational_token' => 'operational-token-for-tests',
'timeout' => 2,
'page_size' => 2,
'retries' => 1,
@@ -61,6 +62,56 @@ it('recusa erros transportados num HTTP 200', function () {
->toThrow(SnipeItApiException::class, 'recusou a consulta');
});
it('regista uma entrega com o token operacional e o destinatário Snipe-IT', function () {
Http::fake([
'*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200),
]);
$payload = app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10', 'Reserva RR-001');
expect($payload['status'])->toBe('success');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkout'
&& $request->hasHeader('Authorization', 'Bearer operational-token-for-tests')
&& $request->data() === [
'status_id' => 2,
'checkout_to_type' => 'user',
'assigned_user' => 73,
'expected_checkin' => '2026-08-10',
'note' => 'Reserva RR-001',
];
});
});
it('regista uma devolução com estado e localização no Snipe-IT', function () {
Http::fake([
'*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200),
]);
app(SnipeItClient::class)->checkinHardware(41, 2, 9, 'Devolvido sem danos');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkin'
&& $request->data() === [
'status_id' => 2,
'location_id' => 9,
'note' => 'Devolvido sem danos',
];
});
});
it('recusa erros operacionais transportados num HTTP 200', function () {
Http::fake([
'*' => Http::response(['status' => 'error', 'messages' => 'Asset is not deployable'], 200),
]);
expect(fn () => app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10'))
->toThrow(SnipeItApiException::class, 'recusou a entrega');
});
/** @return array<string, mixed> */
function snipeClientRow(int $id): array
{