feat: complete reservation lifecycle
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
29
app/Console/Commands/ExpirePendingReservationsCommand.php
Normal file
29
app/Console/Commands/ExpirePendingReservationsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Jobs/ExpirePendingReservations.php
Normal file
32
app/Jobs/ExpirePendingReservations.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
209
resources/js/pages/reservations/calendar.tsx
Normal file
209
resources/js/pages/reservations/calendar.tsx
Normal 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' },
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
51
tests/Feature/ReservationCalendarTest.php
Normal file
51
tests/Feature/ReservationCalendarTest.php
Normal 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'));
|
||||
});
|
||||
95
tests/Feature/ReservationCancellationTest.php
Normal file
95
tests/Feature/ReservationCancellationTest.php
Normal 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);
|
||||
});
|
||||
53
tests/Feature/ReservationExpirationTest.php
Normal file
53
tests/Feature/ReservationExpirationTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user