feat: complete reservation lifecycle

This commit is contained in:
2026-08-04 16:30:11 +01:00
parent 077015f17a
commit e667d05bc1
24 changed files with 1096 additions and 46 deletions

View File

@@ -18,6 +18,10 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Sincronização horária e manual de equipamentos, job único, lock distribuído, histórico de execuções e desativação segura de ausentes.
- Área administrativa de diagnóstico Snipe-IT com teste de conectividade, métricas do cache e histórico de sincronizações.
- Autorização administrativa através de Gate e navegação condicional por perfil.
- Pesquisa de disponibilidade por período no catálogo e formulário, incluindo reservas, atribuições atuais e bloqueios administrativos.
- Cancelamento pelo requisitante com validação da máquina de estados, libertação transacional dos equipamentos e auditoria.
- Expiração automática e manual de pré-reservas, com job único agendado a cada cinco minutos e comando `reservations:expire`.
- Calendário mensal pessoal com isolamento por utilizador e navegação entre meses.
### Alterado
@@ -26,6 +30,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Configuração de desenvolvimento Vite passa a publicar módulos e HMR em `localhost:5173` e a aceitar explicitamente a origem do portal em `localhost:8000`, corrigindo a página em branco em Docker.
- 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.
### Segurança
@@ -35,9 +40,11 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
### Verificado
- Migrations e seeders executados em PostgreSQL 17 real.
- Suite backend: Pint, PHPStan nível 7 e 53 testes Pest (201 asserções).
- Suite backend: Pint em 102 ficheiros, PHPStan nível 7 em 77 ficheiros e 60 testes Pest (270 asserções).
- Frontend: TypeScript, ESLint, Prettier e build Vite de produção.
- Stack Docker operacional e endpoint de login acessível através do Nginx.
- Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”.
- API Snipe-IT simulada em testes: paginação, headers, retry, erro em HTTP 200, falha parcial, idempotência e desativação de ausentes.
- Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17.
- 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.

View File

@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
## Estado atual
O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e o primeiro fluxo vertical da Fase 3. Estão disponíveis autenticação local, perfis base, catálogo sincronizável, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, histórico pessoal, dashboard e diagnóstico administrativo da integração. A próxima entrega completa o motor de reservas com pesquisa temporal, cancelamento e expiração.
O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e a **Fase 3 — Motor de reservas**. Estão disponíveis autenticação local, perfis base, catálogo sincronizável com pesquisa temporal, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, cancelamento auditado, expiração automática, calendário e histórico pessoal, dashboard e diagnóstico administrativo da integração. A próxima entrega implementa o fluxo de aprovação.
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.

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Domain\Reservations\Services\ReservationService;
use App\Jobs\ExpirePendingReservations;
use Illuminate\Console\Command;
class ExpirePendingReservationsCommand extends Command
{
protected $signature = 'reservations:expire {--queue : Colocar a expiração na fila em vez de a executar agora}';
protected $description = 'Expira pré-reservas cujo prazo de aprovação terminou';
public function handle(ReservationService $reservations): int
{
if ($this->option('queue')) {
ExpirePendingReservations::dispatch();
$this->info('Expiração de pré-reservas colocada na fila.');
return self::SUCCESS;
}
$expired = $reservations->expirePending();
$this->info("{$expired} pré-reserva(s) expirada(s).");
return self::SUCCESS;
}
}

View File

@@ -4,11 +4,18 @@ namespace App\Domain\Reservations\Models;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property ReservationStatus $status
* @property CarbonImmutable $starts_at
* @property CarbonImmutable $ends_at
* @property CarbonImmutable|null $approval_deadline
*/
class Reservation extends Model
{
use HasUuids;

View File

@@ -5,9 +5,26 @@ namespace App\Domain\Reservations\Services;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
class AvailabilityService
{
/** @return Builder<SnipeAsset> */
public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder
{
return SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query
->whereIn('status', ReservationItemStatus::blockingValues())
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt))
->whereDoesntHave('blocks', fn (Builder $query): Builder => $query
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt));
}
public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool
{
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {

View File

@@ -9,6 +9,7 @@ use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -78,6 +79,107 @@ class ReservationService
}, 3);
}
public function cancel(Reservation $reservation, User $actor, string $reason): Reservation
{
if ($reservation->requester_id !== $actor->id) {
throw new AuthorizationException('Apenas o requisitante pode cancelar este pedido.');
}
return DB::transaction(function () use ($reservation, $actor, $reason): Reservation {
$lockedReservation = Reservation::query()
->whereKey($reservation->id)
->lockForUpdate()
->firstOrFail();
if (! $lockedReservation->status->canTransitionTo(ReservationStatus::Cancelled)) {
throw ValidationException::withMessages([
'reason' => 'Esta requisição já não pode ser cancelada.',
]);
}
$previousStatus = $lockedReservation->status->value;
$lockedReservation->update([
'status' => ReservationStatus::Cancelled,
'cancelled_by' => $actor->id,
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
$lockedReservation->items()
->whereIn('status', ReservationItemStatus::blockingValues())
->update(['status' => ReservationItemStatus::Cancelled]);
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'reservation.cancelled',
'auditable_type' => $lockedReservation->getMorphClass(),
'auditable_id' => $lockedReservation->id,
'before' => ['status' => $previousStatus],
'after' => [
'status' => ReservationStatus::Cancelled->value,
'cancellation_reason' => $reason,
],
]);
return $lockedReservation->load('items.asset');
}, 3);
}
public function expirePending(int $limit = 100): int
{
$reservationIds = Reservation::query()
->whereIn('status', [
ReservationStatus::PendingApproval,
ReservationStatus::AwaitingInformation,
])
->whereNotNull('approval_deadline')
->where('approval_deadline', '<=', now())
->oldest('approval_deadline')
->limit($limit)
->pluck('id');
$expired = 0;
foreach ($reservationIds as $reservationId) {
$didExpire = DB::transaction(function () use ($reservationId): bool {
$reservation = Reservation::query()
->whereKey($reservationId)
->lockForUpdate()
->first();
if ($reservation === null
|| $reservation->approval_deadline === null
|| $reservation->approval_deadline->isFuture()
|| ! $reservation->status->canTransitionTo(ReservationStatus::Expired)) {
return false;
}
$previousStatus = $reservation->status->value;
$reservation->update(['status' => ReservationStatus::Expired]);
$reservation->items()
->whereIn('status', ReservationItemStatus::blockingValues())
->update(['status' => ReservationItemStatus::Cancelled]);
AuditLog::create([
'actor_id' => null,
'event' => 'reservation.expired',
'auditable_type' => $reservation->getMorphClass(),
'auditable_id' => $reservation->id,
'before' => ['status' => $previousStatus],
'after' => ['status' => ReservationStatus::Expired->value],
'metadata' => ['approval_deadline' => $reservation->approval_deadline->toIso8601String()],
]);
return true;
}, 3);
if ($didExpire) {
$expired++;
}
}
return $expired;
}
private function nextReference(): string
{
do {

View File

@@ -3,23 +3,36 @@
namespace App\Http\Controllers;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Services\AvailabilityService;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AssetController extends Controller
{
public function index(Request $request): Response
public function index(Request $request, AvailabilityService $availability): Response
{
$filters = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'category' => ['nullable', 'string', 'max:100'],
'location' => ['nullable', 'string', 'max:100'],
'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'],
'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'],
]);
$assets = SnipeAsset::query()
->where('active', true)
->where('requestable', true)
$periodSelected = isset($filters['starts_at'], $filters['ends_at']);
$query = $periodSelected
? $availability->availableAssets(
CarbonImmutable::parse($filters['starts_at']),
CarbonImmutable::parse($filters['ends_at']),
)
: SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id');
$assets = $query
->when($filters['search'] ?? null, function ($query, string $search) {
$query->where(function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%")
@@ -36,8 +49,9 @@ class AssetController extends Controller
return Inertia::render('assets/index', [
'assets' => $assets,
'filters' => $filters,
'categories' => SnipeAsset::query()->where('active', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'),
'locations' => SnipeAsset::query()->where('active', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'),
'periodSelected' => $periodSelected,
'categories' => SnipeAsset::query()->where('active', true)->where('requestable', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'),
'locations' => SnipeAsset::query()->where('active', true)->where('requestable', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'),
'lastSyncedAt' => SnipeAsset::max('synced_at'),
]);
}

View File

@@ -3,8 +3,11 @@
namespace App\Http\Controllers;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Services\AvailabilityService;
use App\Domain\Reservations\Services\ReservationService;
use App\Http\Requests\StoreReservationRequest;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -22,16 +25,54 @@ class ReservationController extends Controller
]);
}
public function create(Request $request): Response
public function calendar(Request $request): Response
{
return Inertia::render('reservations/create', [
'assets' => SnipeAsset::query()
$filters = $request->validate([
'month' => ['nullable', 'date_format:Y-m'],
]);
$month = isset($filters['month'])
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
: now()->startOfMonth();
$windowEnd = $month->addMonth();
return Inertia::render('reservations/calendar', [
'month' => $month->format('Y-m'),
'reservations' => $request->user()->reservations()
->with('items.asset:id,name,asset_tag')
->where('starts_at', '<', $windowEnd)
->where('ends_at', '>', $month)
->orderBy('starts_at')
->get(),
]);
}
public function create(Request $request, AvailabilityService $availability): Response
{
$period = $request->validate([
'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'],
'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'],
]);
$periodSelected = isset($period['starts_at'], $period['ends_at']);
$query = $periodSelected
? $availability->availableAssets(
CarbonImmutable::parse($period['starts_at']),
CarbonImmutable::parse($period['ends_at']),
)
: SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->orderBy('name')
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']),
'selectedAssetId' => $request->integer('asset'),
->whereNull('assigned_id');
$assets = $query
->orderBy('name')
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']);
$requestedAssetId = $request->integer('asset');
return Inertia::render('reservations/create', [
'assets' => $assets,
'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0,
'period' => $period,
'periodSelected' => $periodSelected,
]);
}
@@ -43,4 +84,15 @@ class ReservationController extends Controller
return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação.");
}
public function cancel(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse
{
$data = $request->validate([
'reason' => ['required', 'string', 'min:5', 'max:1000'],
]);
$service->cancel($reservation, $request->user(), $data['reason']);
return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis.");
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Jobs;
use App\Domain\Reservations\Services\ReservationService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ExpirePendingReservations implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
public int $uniqueFor = 240;
/** @var list<int> */
public array $backoff = [10, 30, 60];
public function uniqueId(): string
{
return 'pending-reservations';
}
public function handle(ReservationService $reservations): void
{
$reservations->expirePending();
}
}

View File

@@ -48,6 +48,10 @@ A defesa ocorre em duas camadas:
Uma criação ou aprovação deve bloquear as linhas dos ativos, voltar a validar dentro da transação e tolerar corrida através da restrição da base de dados.
`AvailabilityService::availableAssets()` aplica as mesmas condições em SQL para alimentar catálogo e formulário sem carregar todo o inventário em memória. O período consultado é propagado até à criação da requisição; a validação transacional continua a ser a garantia aplicacional final.
Pré-reservas ficam bloqueantes em `held` enquanto aguardam decisão. `ReservationService` centraliza criação, cancelamento e expiração: todas as transições bloqueiam a requisição, atualizam os itens bloqueantes e criam um `AuditLog`. Um job único procura prazos vencidos a cada cinco minutos. A restrição GiST continua a proteger contra corridas entre estes ciclos de vida.
## Fluxo de dados Snipe-IT
1. Um job consulta a API paginada com token de leitura.

View File

@@ -2,7 +2,7 @@
## ADR-001 — Monólito modular Laravel/Inertia
**Data:** 2026-08-04
**Data:** 2026-08-04
**Estado:** aceite
Usar Laravel 13 com React/TypeScript através de Inertia no mesmo projeto. Mantém autenticação, autorização, filas e transações numa única unidade operacional, sem impedir separação por domínio.
@@ -48,3 +48,10 @@ Textos funcionais são escritos em português europeu. Nomes de código, eventos
**Estado:** aceite
Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis até ao máximo oficial de 500 registos. Cada ativo visto recebe o ID da execução em `last_seen_sync_run_id`; a desativação de ausentes só ocorre depois de todas as páginas terminarem com sucesso. Upserts de páginas já válidas podem permanecer após uma falha, permitindo convergência na execução seguinte sem manter uma transação aberta durante chamadas HTTP. Repetir apenas falhas de ligação, HTTP 429 e HTTP 5xx. Usar lock distribuído, job único e token exclusivo de leitura.
## ADR-008 — Pré-reserva bloqueante com expiração auditada
**Data:** 2026-08-04
**Estado:** aceite
Uma requisição submetida cria itens em `held` e bloqueia o período durante 48 horas, até decisão, cancelamento ou expiração. O próprio requisitante pode cancelar apenas enquanto a máquina de estados admite a transição. Um job único executado a cada cinco minutos expira pedidos `pending_approval` ou `awaiting_information` com prazo vencido. Cancelamento e expiração usam bloqueio transacional da requisição, libertam todos os itens bloqueantes e produzem eventos de auditoria; intervalos que se tocam continuam permitidos pela semântica `[início, fim)`.

View File

@@ -1,7 +1,7 @@
# Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026
**Fase:** Fases 1 e 2 concluídas; primeiro fluxo vertical da Fase 3 concluído
**Fase:** Fases 1, 2 e 3 concluídas
**Último agente:** Codex
## Resultado disponível
@@ -21,26 +21,31 @@
- Cliente Snipe-IT autenticado com paginação, timeout, retry seletivo e validação de respostas.
- Sincronização convergente de ativos com histórico, hash de conteúdo, lock, job único e desativação apenas após execução completa.
- Comando `snipe:sync-assets`, execução horária e painel de diagnóstico exclusivo para administradores.
- Pesquisa do catálogo por início/fim que exclui atribuições atuais, reservas bloqueantes e bloqueios administrativos.
- Propagação do período consultado do catálogo para o formulário e consulta atualizável de equipamentos livres.
- Cancelamento pelo próprio requisitante, com validação de estado, libertação transacional dos itens e auditoria.
- Expiração de pré-reservas vencidas através do comando `reservations:expire` e job único agendado a cada cinco minutos.
- Calendário mensal pessoal, isolado por utilizador e com navegação entre meses.
## 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.
- Aprovação, rejeição, entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
- As notificações e jobs de expiração ainda não foram implementados.
- As notificações ainda não foram implementadas.
- 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.
- A disponibilidade apresentada no catálogo ainda não recebe um período; a validação real ocorre ao submeter.
- 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.
## Próxima tarefa recomendada
Completar a **Fase 3 — motor de reservas**:
Implementar a **Fase 4 — aprovações**:
1. abrir `app/Domain/Reservations/Services/AvailabilityService.php` e `ReservationService.php`;
2. acrescentar pesquisa do catálogo por período e apresentar disponibilidade real;
3. implementar cancelamento pelo requisitante com transição explícita e auditoria;
4. criar expiração automática de pré-reservas pendentes e libertação dos itens;
5. adicionar uma vista de calendário pessoal;
6. testar limites temporais, cancelamento, expiração e autorização;
1. criar `app/Domain/Approvals/Services/ApprovalService.php` e policies para o perfil aprovador;
2. criar fila e detalhe de requisições pendentes com filtros por data, categoria e requisitante;
3. implementar aprovação com nova validação transacional de disponibilidade e promoção dos itens para `reserved`;
4. implementar rejeição e pedido de esclarecimentos, com justificação e registo em `approval_actions`/auditoria;
5. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos;
6. emitir notificações assíncronas ao requisitante e ao aprovador;
7. atualizar este documento e o changelog.
## Testes da última sessão
@@ -48,19 +53,20 @@ Completar a **Fase 3 — motor de reservas**:
Validação concluída em 4 de agosto de 2026:
- `docker compose exec -T app composer test`: passou;
- Laravel Pint: 97 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 75 ficheiros, zero erros;
- Pest: 53 testes passaram, 201 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- Laravel Pint: 102 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 77 ficheiros, zero erros;
- Pest: 60 testes passaram, 270 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, 2305 módulos cliente transformados;
- Vite: builds cliente e SSR concluídos, 2306 módulos cliente e 95 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;
- 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;
- verificação visual no browser integrado: a inspeção controlada continua impedida por uma política administrativa temporariamente indisponível; o painel administrativo foi validado por teste Inertia, TypeScript e builds cliente/SSR.
- verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; catálogo, cancelamento e calendário foram validados por testes Inertia, TypeScript e builds cliente/SSR.
## Decisões pendentes do contexto institucional

View File

@@ -80,6 +80,23 @@ 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.
## 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:
```bash
# Expirar imediatamente os pedidos vencidos
docker compose exec app php artisan reservations:expire
# Colocar a operação na fila
docker compose exec app php artisan reservations:expire --queue
# Confirmar ambos os agendamentos
docker compose exec app php artisan schedule:list
```
Uma expiração ou cancelamento muda itens `held`, `reserved`, `ready` e `delivered` para `cancelled`, libertando o período. Confirmar os eventos `reservation.expired` ou `reservation.cancelled` em `audit_logs` quando existir uma divergência operacional.
## Produção
Antes de disponibilizar:

View File

@@ -1,6 +1,7 @@
import { Link, usePage } from '@inertiajs/react';
import {
CalendarDays,
CalendarRange,
LayoutGrid,
PackageSearch,
PlusCircle,
@@ -35,6 +36,11 @@ const mainNavItems: NavItem[] = [
href: '/requisicoes',
icon: CalendarDays,
},
{
title: 'O meu calendário',
href: '/requisicoes/calendario',
icon: CalendarRange,
},
];
const footerNavItems: NavItem[] = [

View File

@@ -1,6 +1,7 @@
import { Head, Link, router } from '@inertiajs/react';
import {
Boxes,
CalendarClock,
MapPin,
PackageCheck,
Search,
@@ -24,7 +25,14 @@ import type { Asset, Paginated } from '@/types';
type Props = {
assets: Paginated<Asset>;
filters: { search?: string; category?: string; location?: string };
filters: {
search?: string;
category?: string;
location?: string;
starts_at?: string;
ends_at?: string;
};
periodSelected: boolean;
categories: string[];
locations: string[];
lastSyncedAt: string | null;
@@ -36,10 +44,13 @@ export default function AssetsIndex({
categories,
locations,
lastSyncedAt,
periodSelected,
}: Props) {
const [search, setSearch] = useState(filters.search ?? '');
const [category, setCategory] = useState(filters.category ?? 'all');
const [location, setLocation] = useState(filters.location ?? 'all');
const [startsAt, setStartsAt] = useState(filters.starts_at ?? '');
const [endsAt, setEndsAt] = useState(filters.ends_at ?? '');
function submit(event: FormEvent) {
event.preventDefault();
@@ -49,6 +60,8 @@ export default function AssetsIndex({
search: search || undefined,
category: category === 'all' ? undefined : category,
location: location === 'all' ? undefined : location,
starts_at: startsAt || undefined,
ends_at: endsAt || undefined,
},
{ preserveState: true },
);
@@ -66,9 +79,9 @@ export default function AssetsIndex({
<form
onSubmit={submit}
className="grid gap-3 rounded-xl border bg-card p-4 md:grid-cols-[1fr_220px_220px_auto]"
className="grid gap-3 rounded-xl border bg-card p-4 lg:grid-cols-6"
>
<div className="relative">
<div className="relative lg:col-span-2">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
value={search}
@@ -92,6 +105,26 @@ export default function AssetsIndex({
))}
</SelectContent>
</Select>
<div className="relative">
<CalendarClock className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
type="datetime-local"
aria-label="Início do período"
value={startsAt}
onChange={(event) =>
setStartsAt(event.target.value)
}
className="pl-9"
required={Boolean(endsAt)}
/>
</div>
<Input
type="datetime-local"
aria-label="Fim do período"
value={endsAt}
onChange={(event) => setEndsAt(event.target.value)}
required={Boolean(startsAt)}
/>
<Select value={location} onValueChange={setLocation}>
<SelectTrigger>
<SelectValue placeholder="Localização" />
@@ -131,7 +164,13 @@ export default function AssetsIndex({
{assets.data.length > 0 ? (
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{assets.data.map((asset) => (
<AssetCard key={asset.id} asset={asset} />
<AssetCard
key={asset.id}
asset={asset}
periodSelected={periodSelected}
startsAt={filters.starts_at}
endsAt={filters.ends_at}
/>
))}
</section>
) : (
@@ -180,7 +219,24 @@ export default function AssetsIndex({
);
}
function AssetCard({ asset }: { asset: Asset }) {
function AssetCard({
asset,
periodSelected,
startsAt,
endsAt,
}: {
asset: Asset;
periodSelected: boolean;
startsAt?: string;
endsAt?: string;
}) {
const requestParams = new URLSearchParams({ asset: String(asset.id) });
if (startsAt && endsAt) {
requestParams.set('starts_at', startsAt);
requestParams.set('ends_at', endsAt);
}
return (
<Card className="group gap-4 overflow-hidden border-slate-200 py-0 shadow-none transition hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md dark:border-slate-800">
<div className="flex h-36 items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
@@ -203,7 +259,7 @@ function AssetCard({ asset }: { asset: Asset }) {
</p>
</div>
<Badge variant="outline" className="text-emerald-700">
Disponível
{periodSelected ? 'Livre no período' : 'Requisitável'}
</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
@@ -217,7 +273,9 @@ function AssetCard({ asset }: { asset: Asset }) {
)}
</div>
<Button asChild className="w-full">
<Link href={`/requisicoes/nova?asset=${asset.id}`}>
<Link
href={`/requisicoes/nova?${requestParams.toString()}`}
>
Requisitar equipamento
</Link>
</Button>

View File

@@ -0,0 +1,209 @@
import { Head, Link } from '@inertiajs/react';
import { ArrowLeft, ArrowRight, CalendarDays, Plus } from 'lucide-react';
import { PageHeading } from '@/components/page-heading';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Reservation } from '@/types';
const weekdays = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
export default function ReservationCalendar({
month,
reservations,
}: {
month: string;
reservations: Reservation[];
}) {
const [year, monthNumber] = month.split('-').map(Number);
const monthStart = new Date(year, monthNumber - 1, 1);
const firstWeekday = (monthStart.getDay() + 6) % 7;
const daysInMonth = new Date(year, monthNumber, 0).getDate();
const cellCount = Math.ceil((firstWeekday + daysInMonth) / 7) * 7;
const gridStart = new Date(year, monthNumber - 1, 1 - firstWeekday);
const days = Array.from({ length: cellCount }, (_, index) => {
const date = new Date(gridStart);
date.setDate(gridStart.getDate() + index);
return date;
});
const title = new Intl.DateTimeFormat('pt-PT', {
month: 'long',
year: 'numeric',
}).format(monthStart);
const previousMonth = monthKey(new Date(year, monthNumber - 2, 1));
const nextMonth = monthKey(new Date(year, monthNumber, 1));
return (
<>
<Head title="O meu calendário" />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
<PageHeading
eyebrow="Planeamento pessoal"
title="O meu calendário"
description="Consulte as suas requisições no mês e identifique rapidamente períodos sobrepostos."
action={
<Button asChild>
<Link href="/requisicoes/nova">
<Plus /> Nova requisição
</Link>
</Button>
}
/>
<section className="overflow-hidden rounded-xl border bg-card">
<header className="flex items-center justify-between border-b p-4">
<Button asChild variant="outline" size="sm">
<Link
href={`/requisicoes/calendario?month=${previousMonth}`}
>
<ArrowLeft /> Anterior
</Link>
</Button>
<h2 className="text-lg font-semibold capitalize">
{title}
</h2>
<Button asChild variant="outline" size="sm">
<Link
href={`/requisicoes/calendario?month=${nextMonth}`}
>
Seguinte <ArrowRight />
</Link>
</Button>
</header>
<div className="grid grid-cols-7 border-b bg-slate-50 text-center text-xs font-medium text-muted-foreground uppercase dark:bg-slate-900">
{weekdays.map((day) => (
<div key={day} className="px-1 py-3">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{days.map((day) => {
const dayStart = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
);
const dayEnd = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate() + 1,
);
const entries = reservations.filter(
(reservation) =>
new Date(reservation.starts_at) < dayEnd &&
new Date(reservation.ends_at) > dayStart,
);
const isCurrentMonth =
day.getMonth() === monthNumber - 1;
const isToday = sameDay(day, new Date());
return (
<div
key={day.toISOString()}
className={cn(
'min-h-28 border-r border-b p-1.5 last:border-r-0 sm:min-h-36 sm:p-2',
!isCurrentMonth &&
'bg-slate-50/70 text-muted-foreground dark:bg-slate-950/40',
)}
>
<span
className={cn(
'inline-flex size-7 items-center justify-center rounded-full text-xs',
isToday &&
'bg-primary font-semibold text-primary-foreground',
)}
>
{day.getDate()}
</span>
<div className="mt-1 space-y-1">
{entries
.slice(0, 3)
.map((reservation) => (
<div
key={reservation.id}
title={`${reservation.reference} — ${reservation.items.map((item) => item.asset.name).join(', ')}`}
className={cn(
'truncate rounded border-l-2 bg-slate-100 px-1.5 py-1 text-[10px] font-medium sm:text-xs dark:bg-slate-900',
statusBorder(
reservation.status,
),
)}
>
{reservation.reference}
</div>
))}
{entries.length > 3 && (
<p className="px-1 text-[10px] text-muted-foreground">
+{entries.length - 3} pedido(s)
</p>
)}
</div>
</div>
);
})}
</div>
</section>
<div className="flex flex-wrap justify-center gap-4 text-xs text-muted-foreground">
<Legend color="bg-amber-500" label="Pendente" />
<Legend
color="bg-emerald-500"
label="Confirmada ou em curso"
/>
<Legend
color="bg-slate-400"
label="Fechada sem utilização"
/>
</div>
{reservations.length === 0 && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<CalendarDays className="size-4" /> Não existem
requisições neste mês.
</div>
)}
</main>
</>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1.5">
<span className={cn('size-2 rounded-full', color)} /> {label}
</span>
);
}
function monthKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function statusBorder(status: string): string {
if (['cancelled', 'rejected', 'expired', 'no_show'].includes(status)) {
return 'border-slate-400 opacity-60';
}
if (['approved', 'ready', 'delivered', 'in_use'].includes(status)) {
return 'border-emerald-500';
}
return 'border-amber-500';
}
ReservationCalendar.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Calendário', href: '/requisicoes/calendario' },
],
};

View File

@@ -1,4 +1,4 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Head, Link, router, useForm } from '@inertiajs/react';
import {
ArrowLeft,
CalendarClock,
@@ -19,15 +19,19 @@ import type { Asset } from '@/types';
export default function CreateReservation({
assets,
selectedAssetId,
period,
periodSelected,
}: {
assets: Asset[];
selectedAssetId: number;
period: { starts_at?: string; ends_at?: string };
periodSelected: boolean;
}) {
const form = useForm({
purpose: '',
usage_location: '',
starts_at: '',
ends_at: '',
starts_at: period.starts_at ?? '',
ends_at: period.ends_at ?? '',
asset_ids: selectedAssetId ? [selectedAssetId] : ([] as number[]),
terms_accepted: false,
});
@@ -43,6 +47,12 @@ export default function CreateReservation({
: form.data.asset_ids.filter((item) => item !== id),
);
}
function refreshAvailability() {
router.get('/requisicoes/nova', {
starts_at: form.data.starts_at,
ends_at: form.data.ends_at,
});
}
return (
<>
@@ -117,6 +127,24 @@ export default function CreateReservation({
/>
</Field>
</div>
<div className="sm:col-span-2">
<Button
type="button"
variant="secondary"
onClick={refreshAvailability}
disabled={
!form.data.starts_at ||
!form.data.ends_at
}
>
<CalendarClock /> Verificar equipamentos
livres
</Button>
<p className="mt-2 text-xs text-muted-foreground">
Confirme a disponibilidade sempre que
alterar o período.
</p>
</div>
<div className="sm:col-span-2">
<Label htmlFor="purpose">Finalidade</Label>
<textarea
@@ -140,10 +168,18 @@ export default function CreateReservation({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PackageCheck className="size-5 text-primary" />{' '}
Equipamentos
{periodSelected
? 'Equipamentos livres no período'
: 'Equipamentos requisitáveis'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{assets.length === 0 && (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
Não existem equipamentos livres para o
período escolhido.
</p>
)}
{assets.map((asset) => {
const checked =
form.data.asset_ids.includes(asset.id);

View File

@@ -1,8 +1,19 @@
import { Head, Link } from '@inertiajs/react';
import { CalendarDays, Plus } from 'lucide-react';
import { Head, Link, useForm } from '@inertiajs/react';
import { Ban, CalendarDays, Plus } 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import type { Paginated, Reservation } from '@/types';
export default function ReservationsIndex({
@@ -10,10 +21,38 @@ export default function ReservationsIndex({
}: {
reservations: Paginated<Reservation>;
}) {
const [selected, setSelected] = useState<Reservation | null>(null);
const cancelForm = useForm({ reason: '' });
const format = new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'medium',
timeStyle: 'short',
});
const cancellableStatuses = [
'draft',
'pending_approval',
'awaiting_information',
'approved',
'ready',
];
function openCancellation(reservation: Reservation) {
cancelForm.reset();
cancelForm.clearErrors();
setSelected(reservation);
}
function submitCancellation(event: React.FormEvent) {
event.preventDefault();
if (!selected) {
return;
}
cancelForm.post(`/requisicoes/${selected.uuid}/cancelar`, {
preserveScroll: true,
onSuccess: () => setSelected(null),
});
}
return (
<>
@@ -33,16 +72,17 @@ export default function ReservationsIndex({
/>
{reservations.data.length ? (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="hidden grid-cols-[150px_1fr_220px_170px] gap-4 border-b bg-slate-50 px-5 py-3 text-xs font-medium text-muted-foreground uppercase md:grid dark:bg-slate-900">
<div className="hidden grid-cols-[150px_1fr_220px_150px_110px] gap-4 border-b bg-slate-50 px-5 py-3 text-xs font-medium text-muted-foreground uppercase md:grid dark:bg-slate-900">
<span>Referência</span>
<span>Equipamentos</span>
<span>Período</span>
<span>Estado</span>
<span>Ações</span>
</div>
{reservations.data.map((reservation) => (
<div
key={reservation.id}
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_170px] md:items-center"
className="grid gap-3 border-b px-5 py-4 last:border-0 md:grid-cols-[150px_1fr_220px_150px_110px] md:items-center"
>
<span className="font-mono text-sm font-medium">
{reservation.reference}
@@ -72,6 +112,22 @@ export default function ReservationsIndex({
<ReservationStatus
status={reservation.status}
/>
<div>
{cancellableStatuses.includes(
reservation.status,
) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
openCancellation(reservation)
}
>
<Ban /> Cancelar
</Button>
)}
</div>
</div>
))}
</div>
@@ -88,6 +144,94 @@ export default function ReservationsIndex({
</Button>
</div>
)}
{reservations.last_page > 1 && (
<nav className="flex justify-center gap-2">
{reservations.links.map((link, index) => (
<Button
key={index}
asChild={Boolean(link.url)}
disabled={!link.url}
variant={link.active ? 'default' : 'outline'}
size="sm"
>
{link.url ? (
<Link
href={link.url}
preserveScroll
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
) : (
<span
dangerouslySetInnerHTML={{
__html: link.label,
}}
/>
)}
</Button>
))}
</nav>
)}
<Dialog
open={selected !== null}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent>
<form onSubmit={submitCancellation}>
<DialogHeader>
<DialogTitle>Cancelar requisição</DialogTitle>
<DialogDescription>
{selected?.reference}: indique o motivo. O
cancelamento liberta imediatamente os
equipamentos reservados.
</DialogDescription>
</DialogHeader>
<div className="my-5">
<Label htmlFor="cancellation-reason">
Motivo do cancelamento
</Label>
<textarea
id="cancellation-reason"
value={cancelForm.data.reason}
onChange={(event) =>
cancelForm.setData(
'reason',
event.target.value,
)
}
rows={4}
className="mt-2 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoFocus
/>
<InputError
message={cancelForm.errors.reason}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setSelected(null)}
>
Manter requisição
</Button>
<Button
type="submit"
variant="destructive"
disabled={cancelForm.processing}
>
<Ban />
{cancelForm.processing
? 'A cancelar…'
: 'Confirmar cancelamento'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main>
</>
);

View File

@@ -1,5 +1,6 @@
<?php
use App\Jobs\ExpirePendingReservations;
use App\Jobs\SyncSnipeAssets;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
@@ -13,3 +14,7 @@ Schedule::job(new SyncSnipeAssets)
->hourly()
->when(fn (): bool => filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token')))
->withoutOverlapping();
Schedule::job(new ExpirePendingReservations)
->everyFiveMinutes()
->withoutOverlapping();

View File

@@ -12,8 +12,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('equipamentos', [AssetController::class, 'index'])->name('assets.index');
Route::get('requisicoes', [ReservationController::class, 'index'])->name('reservations.index');
Route::get('requisicoes/calendario', [ReservationController::class, 'calendar'])->name('reservations.calendar');
Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create');
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');

View File

@@ -7,6 +7,7 @@ use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Services\AvailabilityService;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
@@ -45,3 +46,99 @@ it('recusa ativos atribuídos ou não requisitáveis', function () {
expect($service->isAvailable($assigned, ...$period))->toBeFalse()
->and($service->isAvailable($notRequestable, ...$period))->toBeFalse();
});
it('produz uma consulta de equipamentos livres para um período', function () {
$free = SnipeAsset::factory()->create();
$busy = SnipeAsset::factory()->create();
$touching = SnipeAsset::factory()->create();
$blocked = SnipeAsset::factory()->create();
$user = User::factory()->create();
foreach ([$busy, $touching] as $index => $asset) {
$reservation = Reservation::create([
'reference' => "REQ-QUERY-00{$index}",
'requester_id' => $user->id,
'purpose' => 'Reserva existente para testar disponibilidade.',
'usage_location' => 'Estúdio',
'starts_at' => '2026-09-10 09:00:00',
'ends_at' => '2026-09-10 11:00:00',
'status' => ReservationStatus::Approved,
]);
$reservation->items()->create([
'snipe_asset_id' => $asset->id,
'starts_at' => '2026-09-10 09:00:00',
'ends_at' => '2026-09-10 11:00:00',
'status' => ReservationItemStatus::Reserved,
]);
}
$blocked->blocks()->create([
'starts_at' => '2026-09-10 10:45:00',
'ends_at' => '2026-09-10 11:30:00',
'reason' => 'Manutenção preventiva programada.',
'created_by' => $user->id,
]);
$ids = app(AvailabilityService::class)
->availableAssets(
new DateTimeImmutable('2026-09-10 10:30:00'),
new DateTimeImmutable('2026-09-10 12:00:00'),
)
->pluck('id');
expect($ids)->toContain($free->id)
->not->toContain($busy->id, $touching->id, $blocked->id);
$boundaryIds = app(AvailabilityService::class)
->availableAssets(
new DateTimeImmutable('2026-09-10 11:00:00'),
new DateTimeImmutable('2026-09-10 12:00:00'),
)
->pluck('id');
expect($boundaryIds)->toContain($free->id, $busy->id, $touching->id)
->not->toContain($blocked->id);
});
it('filtra o catálogo pelo período solicitado', function () {
$user = User::factory()->create();
$free = SnipeAsset::factory()->create();
$busy = SnipeAsset::factory()->create();
$reservation = Reservation::create([
'reference' => 'REQ-CATALOG-001',
'requester_id' => $user->id,
'purpose' => 'Reserva existente para testar o catálogo público.',
'usage_location' => 'Estúdio',
'starts_at' => '2026-09-10 09:00:00',
'ends_at' => '2026-09-10 11:00:00',
'status' => ReservationStatus::Approved,
]);
$reservation->items()->create([
'snipe_asset_id' => $busy->id,
'starts_at' => '2026-09-10 09:00:00',
'ends_at' => '2026-09-10 11:00:00',
'status' => ReservationItemStatus::Reserved,
]);
$query = http_build_query([
'starts_at' => '2026-09-10T10:00',
'ends_at' => '2026-09-10T12:00',
]);
$this->actingAs($user)
->get("/equipamentos?{$query}")
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('assets/index')
->where('periodSelected', true)
->has('assets.data', 1)
->where('assets.data.0.id', $free->id));
$this->get("/requisicoes/nova?asset={$busy->id}&{$query}")
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('reservations/create')
->where('periodSelected', true)
->where('selectedAssetId', 0)
->has('assets', 1)
->where('assets.0.id', $free->id));
});

View File

@@ -0,0 +1,51 @@
<?php
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
it('mostra no calendário apenas as requisições do utilizador que intersectam o mês', function () {
$user = User::factory()->create();
$other = User::factory()->create();
Reservation::create([
'reference' => 'REQ-CALENDAR-001',
'requester_id' => $user->id,
'purpose' => 'Reserva que atravessa o início do mês consultado.',
'usage_location' => 'Auditório',
'starts_at' => '2026-08-31 22:00:00',
'ends_at' => '2026-09-01 10:00:00',
'status' => ReservationStatus::Approved,
]);
Reservation::create([
'reference' => 'REQ-CALENDAR-OUTSIDE',
'requester_id' => $user->id,
'purpose' => 'Reserva fora do mês que está a ser consultado.',
'usage_location' => 'Estúdio',
'starts_at' => '2026-10-02 09:00:00',
'ends_at' => '2026-10-02 11:00:00',
'status' => ReservationStatus::PendingApproval,
]);
Reservation::create([
'reference' => 'REQ-CALENDAR-OTHER',
'requester_id' => $other->id,
'purpose' => 'Reserva pertencente a um utilizador diferente.',
'usage_location' => 'Sala 2',
'starts_at' => '2026-09-10 09:00:00',
'ends_at' => '2026-09-10 11:00:00',
'status' => ReservationStatus::Approved,
]);
$this->actingAs($user)
->get('/requisicoes/calendario?month=2026-09')
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('reservations/calendar')
->where('month', '2026-09')
->has('reservations', 1)
->where('reservations.0.reference', 'REQ-CALENDAR-001'));
});

View File

@@ -0,0 +1,95 @@
<?php
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;
uses(RefreshDatabase::class);
it('permite ao requisitante cancelar e liberta os equipamentos com auditoria', function () {
$user = User::factory()->create();
$asset = SnipeAsset::factory()->create();
$reservation = Reservation::create([
'reference' => 'REQ-CANCEL-001',
'requester_id' => $user->id,
'purpose' => 'Captação de uma sessão para arquivo institucional.',
'usage_location' => 'Auditório',
'starts_at' => now()->addDays(3),
'ends_at' => now()->addDays(3)->addHours(2),
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->addDay(),
]);
$item = $reservation->items()->create([
'snipe_asset_id' => $asset->id,
'starts_at' => $reservation->starts_at,
'ends_at' => $reservation->ends_at,
'status' => ReservationItemStatus::Held,
]);
$this->actingAs($user)
->post("/requisicoes/{$reservation->uuid}/cancelar", [
'reason' => 'A sessão foi adiada pela organização.',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Cancelled)
->and($item->fresh()->status)->toBe(ReservationItemStatus::Cancelled);
$this->assertDatabaseHas('reservations', [
'id' => $reservation->id,
'cancelled_by' => $user->id,
'cancellation_reason' => 'A sessão foi adiada pela organização.',
]);
$this->assertDatabaseHas('audit_logs', [
'event' => 'reservation.cancelled',
'actor_id' => $user->id,
'auditable_id' => $reservation->id,
]);
});
it('impede outro utilizador de cancelar a requisição', function () {
$owner = User::factory()->create();
$reservation = Reservation::create([
'reference' => 'REQ-CANCEL-002',
'requester_id' => $owner->id,
'purpose' => 'Apoio técnico a uma atividade previamente agendada.',
'usage_location' => 'Sala 4',
'starts_at' => now()->addDays(3),
'ends_at' => now()->addDays(3)->addHour(),
'status' => ReservationStatus::PendingApproval,
]);
$this->actingAs(User::factory()->create())
->post("/requisicoes/{$reservation->uuid}/cancelar", [
'reason' => 'Tentativa por outro utilizador.',
])
->assertForbidden();
expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval);
});
it('não cancela uma requisição num estado terminal', function () {
$user = User::factory()->create();
$reservation = Reservation::create([
'reference' => 'REQ-CANCEL-003',
'requester_id' => $user->id,
'purpose' => 'Pedido já concluído e sem operações pendentes.',
'usage_location' => 'Biblioteca',
'starts_at' => now()->subDays(3),
'ends_at' => now()->subDays(3)->addHour(),
'status' => ReservationStatus::Completed,
]);
$this->actingAs($user)
->from('/requisicoes')
->post("/requisicoes/{$reservation->uuid}/cancelar", [
'reason' => 'Já não preciso deste equipamento.',
])
->assertRedirect('/requisicoes')
->assertSessionHasErrors('reason');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Completed);
});

View File

@@ -0,0 +1,53 @@
<?php
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;
uses(RefreshDatabase::class);
it('expira apenas pré-reservas cujo prazo terminou e liberta os equipamentos', function () {
$user = User::factory()->create();
$due = Reservation::create([
'reference' => 'REQ-EXPIRE-001',
'requester_id' => $user->id,
'purpose' => 'Pedido sem decisão dentro do prazo previsto.',
'usage_location' => 'Auditório',
'starts_at' => now()->addDays(2),
'ends_at' => now()->addDays(2)->addHours(2),
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->subMinute(),
]);
$dueItem = $due->items()->create([
'snipe_asset_id' => SnipeAsset::factory()->create()->id,
'starts_at' => $due->starts_at,
'ends_at' => $due->ends_at,
'status' => ReservationItemStatus::Held,
]);
$future = Reservation::create([
'reference' => 'REQ-EXPIRE-002',
'requester_id' => $user->id,
'purpose' => 'Pedido ainda dentro do prazo de decisão definido.',
'usage_location' => 'Estúdio',
'starts_at' => now()->addDays(4),
'ends_at' => now()->addDays(4)->addHour(),
'status' => ReservationStatus::AwaitingInformation,
'approval_deadline' => now()->addHour(),
]);
$this->artisan('reservations:expire')
->expectsOutput('1 pré-reserva(s) expirada(s).')
->assertSuccessful();
expect($due->fresh()->status)->toBe(ReservationStatus::Expired)
->and($dueItem->fresh()->status)->toBe(ReservationItemStatus::Cancelled)
->and($future->fresh()->status)->toBe(ReservationStatus::AwaitingInformation);
$this->assertDatabaseHas('audit_logs', [
'event' => 'reservation.expired',
'actor_id' => null,
'auditable_id' => $due->id,
]);
});