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. - 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. - 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. - 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 ### 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. - 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. - 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. - 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 ### Segurança
- Registo público desativado. - Registo público desativado.
- Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores. - 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 ### 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. - 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. - 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. - 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 ## 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. 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 ## Comandos frequentes
```bash ```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 lint
docker compose exec app composer types:check docker compose exec app composer types:check
docker compose exec node npm run 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 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: Para repor apenas dados de desenvolvimento:
```bash ```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::PendingApproval => [self::AwaitingInformation, self::Approved, self::Rejected, self::Cancelled, self::Expired],
self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired], self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired],
self::Approved => [self::Ready, self::Cancelled], 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::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::Returned => [self::Completed, self::Incident],
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [], self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
}; };

View File

@@ -3,10 +3,12 @@
namespace App\Domain\Reservations\Models; namespace App\Domain\Reservations\Models;
use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Operations\Models\HandoverRecord;
use App\Domain\Reservations\Enums\ReservationItemStatus; use App\Domain\Reservations\Enums\ReservationItemStatus;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property ReservationItemStatus $status * @property ReservationItemStatus $status
@@ -44,4 +46,10 @@ class ReservationItem extends Model
{ {
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id'); 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' => [ 'can' => [
'administer' => fn (): bool => $request->user()?->can('administer') ?? false, 'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? 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, 'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0,
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', '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\ConnectionException;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable; use Throwable;
@@ -20,6 +21,11 @@ final class SnipeItClient
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token')); 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 public function configuredUrl(): ?string
{ {
$url = trim((string) config('services.snipe_it.url')); $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 = $limit ?? (int) config('services.snipe_it.page_size', 100);
$pageSize = max(1, min(500, $pageSize)); $pageSize = max(1, min(500, $pageSize));
$response = $this->request()->get('/hardware', [ $response = $this->readRequest()->get('/hardware', [
'limit' => $pageSize, 'limit' => $pageSize,
'offset' => max(0, $offset), 'offset' => max(0, $offset),
'sort' => 'id', 'sort' => 'id',
@@ -92,7 +98,61 @@ final class SnipeItClient
} while ($received > 0 && $offset < $page->total); } 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()) { if (! $this->isConfigured()) {
throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.'); 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 private function apiBaseUrl(): string
{ {
$baseUrl = $this->configuredUrl(); $baseUrl = $this->configuredUrl();

View File

@@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_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'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements PasskeyUser class User extends Authenticatable implements PasskeyUser
{ {

View File

@@ -37,6 +37,11 @@ class AppServiceProvider extends ServiceProvider
fn (User $user): bool => $user->active fn (User $user): bool => $user->active
&& ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)), && ($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/Inventory`: cache local de ativos e consultas do catálogo.
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições. - `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação. - `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/Domain/Audit`: logs imutáveis pela interface.
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT. - `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. 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. 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. 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 ## Segurança

View File

@@ -69,3 +69,12 @@ A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou
**Estado:** aceite **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. 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 # Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026 **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 **Último agente:** Codex
## Resultado disponível ## Resultado disponível
@@ -34,44 +34,51 @@
- Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas. - 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. - 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. - 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 ## 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. - 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. - 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 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 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 ## 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; 1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT;
2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais; 2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias;
3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico; 3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados;
4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída; 4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado;
5. implementar incidentes, atrasos e bloqueios associados a dano; 5. criar consulta de auditoria apenas de leitura para o perfil `auditor`;
6. testar API simulada, autorização, idempotência e auditoria; 6. adicionar métricas operacionais e gestão de modelos de email;
7. atualizar este documento e o changelog. 7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação.
## Testes da última sessão ## Testes da última sessão
Validação concluída em 4 de agosto de 2026: Validação concluída em 4 de agosto de 2026:
- `docker compose exec -T app composer test`: passou; - `docker compose exec -T app composer test`: passou;
- Laravel Pint: 111 ficheiros sem desvios; - Laravel Pint: 117 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 84 ficheiros, zero erros; - PHPStan/Larastan nível 7: 89 ficheiros, zero erros;
- Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- TypeScript (`tsc --noEmit`): passou; - TypeScript (`tsc --noEmit`): passou;
- ESLint: passou; - ESLint: passou;
- Prettier: 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: `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: 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: 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 `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; - 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`; - 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; - 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 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. 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: 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 ```dotenv
SNIPE_IT_URL=https://inventario.exemplo.pt SNIPE_IT_URL=https://inventario.exemplo.pt
SNIPE_IT_READ_TOKEN= SNIPE_IT_READ_TOKEN=
SNIPE_IT_OPERATIONAL_TOKEN=
SNIPE_IT_TIMEOUT=10 SNIPE_IT_TIMEOUT=10
SNIPE_IT_PAGE_SIZE=100 SNIPE_IT_PAGE_SIZE=100
SNIPE_IT_RETRIES=3 SNIPE_IT_RETRIES=3
SNIPE_IT_RETRY_DELAY_MS=250 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: 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. 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 ## 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: 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, LayoutGrid,
ListChecks, ListChecks,
PackageSearch, PackageSearch,
PackageCheck,
PlusCircle, PlusCircle,
ServerCog, ServerCog,
ShieldCheck, ShieldCheck,
@@ -71,16 +72,26 @@ export function AppSidebar() {
}, },
] ]
: userNavItems; : userNavItems;
const visibleNavItems = can.administer const operationalNavItems = can.operateInventory
? [ ? [
...roleNavItems, ...roleNavItems,
{
title: 'Operações',
href: '/operacoes',
icon: PackageCheck,
},
]
: roleNavItems;
const visibleNavItems = can.administer
? [
...operationalNavItems,
{ {
title: 'Integração Snipe-IT', title: 'Integração Snipe-IT',
href: '/administracao/snipe-it', href: '/administracao/snipe-it',
icon: ServerCog, icon: ServerCog,
}, },
] ]
: roleNavItems; : operationalNavItems;
return ( return (
<Sidebar collapsible="icon" variant="inset"> <Sidebar collapsible="icon" variant="inset">

View File

@@ -33,6 +33,11 @@ const statuses: Record<string, { label: string; className: string }> = {
label: 'Devolução atrasada', label: 'Devolução atrasada',
className: 'bg-red-100 text-red-800', 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 }) { 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: { can: {
administer: boolean; administer: boolean;
approveReservations: boolean; approveReservations: boolean;
operateInventory: boolean;
}; };
unreadNotifications: number; unreadNotifications: number;
sidebarOpen: boolean; sidebarOpen: boolean;

View File

@@ -5,6 +5,7 @@ use App\Http\Controllers\ApprovalController;
use App\Http\Controllers\AssetController; use App\Http\Controllers\AssetController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\OperationController;
use App\Http\Controllers\ReservationController; use App\Http\Controllers\ReservationController;
use Illuminate\Support\Facades\Route; 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::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::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index'); Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test'); 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', [ config()->set('services.snipe_it', [
'url' => 'https://inventory.example.test', 'url' => 'https://inventory.example.test',
'read_token' => 'read-token-for-tests', 'read_token' => 'read-token-for-tests',
'operational_token' => 'operational-token-for-tests',
'timeout' => 2, 'timeout' => 2,
'page_size' => 2, 'page_size' => 2,
'retries' => 1, 'retries' => 1,
@@ -61,6 +62,56 @@ it('recusa erros transportados num HTTP 200', function () {
->toThrow(SnipeItApiException::class, 'recusou a consulta'); ->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> */ /** @return array<string, mixed> */
function snipeClientRow(int $id): array function snipeClientRow(int $id): array
{ {