feat: add transactional approval workflow
This commit is contained in:
@@ -22,6 +22,8 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
|||||||
- Cancelamento pelo requisitante com validação da máquina de estados, libertação transacional dos equipamentos e auditoria.
|
- 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`.
|
- 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.
|
- Calendário mensal pessoal com isolamento por utilizador e navegação entre meses.
|
||||||
|
- Núcleo de aprovações com fila pesquisável, Gate por perfil, aprovação, rejeição e pedido de esclarecimentos.
|
||||||
|
- Revalidação transacional da disponibilidade antes da aprovação, promoção de itens para `reserved` e histórico em `approval_actions`/auditoria.
|
||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
|||||||
### Verificado
|
### Verificado
|
||||||
|
|
||||||
- Migrations e seeders executados em PostgreSQL 17 real.
|
- Migrations e seeders executados em PostgreSQL 17 real.
|
||||||
- Suite backend: Pint em 102 ficheiros, PHPStan nível 7 em 77 ficheiros e 60 testes Pest (270 asserções).
|
- Suite backend: Pint em 106 ficheiros, PHPStan nível 7 em 80 ficheiros e 65 testes Pest (289 asserções).
|
||||||
- Frontend: TypeScript, ESLint, Prettier e build Vite de produção.
|
- Frontend: TypeScript, ESLint, Prettier e build Vite de produção.
|
||||||
- Stack Docker operacional e endpoint de login acessível através do Nginx.
|
- 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”.
|
- Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
|
|||||||
|
|
||||||
## Estado atual
|
## Estado atual
|
||||||
|
|
||||||
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.
|
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**. A **Fase 4 — Aprovações** está em curso, já com fila protegida por perfil e decisões transacionais. Estão também disponíveis autenticação local, catálogo sincronizável com pesquisa temporal, pré-reservas, conflitos, cancelamento auditado, expiração automática, calendário pessoal, dashboard e diagnóstico administrativo da integraçã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.
|
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.
|
||||||
|
|
||||||
|
|||||||
30
app/Domain/Approvals/Models/ApprovalAction.php
Normal file
30
app/Domain/Approvals/Models/ApprovalAction.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Approvals\Models;
|
||||||
|
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ApprovalAction extends Model
|
||||||
|
{
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'reservation_id', 'actor_id', 'action', 'previous_status', 'new_status',
|
||||||
|
'comment', 'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Reservation, $this> */
|
||||||
|
public function reservation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Reservation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function actor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'actor_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/Domain/Approvals/Services/ApprovalService.php
Normal file
133
app/Domain/Approvals/Services/ApprovalService.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Approvals\Services;
|
||||||
|
|
||||||
|
use App\Domain\Approvals\Models\ApprovalAction;
|
||||||
|
use App\Domain\Audit\Models\AuditLog;
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
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\Domain\Reservations\Services\AvailabilityService;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ApprovalService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AvailabilityService $availability) {}
|
||||||
|
|
||||||
|
public function approve(Reservation $reservation, User $actor, ?string $comment = null): Reservation
|
||||||
|
{
|
||||||
|
$this->authorize($actor);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($reservation, $actor, $comment): Reservation {
|
||||||
|
$locked = $this->lockPending($reservation);
|
||||||
|
$items = $locked->items()->with('asset')->get();
|
||||||
|
SnipeAsset::query()->whereKey($items->pluck('snipe_asset_id'))->orderBy('id')->lockForUpdate()->get();
|
||||||
|
|
||||||
|
$unavailable = $items->filter(fn ($item): bool => ! $this->availability->isAvailable(
|
||||||
|
$item->asset,
|
||||||
|
$item->starts_at,
|
||||||
|
$item->ends_at,
|
||||||
|
$locked->id,
|
||||||
|
));
|
||||||
|
if ($unavailable->isNotEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'decision' => 'A disponibilidade mudou: '.$unavailable->pluck('asset.name')->join(', ').'.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->transition($locked, $actor, ReservationStatus::Approved, 'approved', $comment, [
|
||||||
|
'approved_by' => $actor->id,
|
||||||
|
'approved_at' => now(),
|
||||||
|
'decision_reason' => $comment,
|
||||||
|
]);
|
||||||
|
$locked->items()->where('status', ReservationItemStatus::Held)->update([
|
||||||
|
'status' => ReservationItemStatus::Reserved,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $locked->load('requester', 'items.asset');
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(Reservation $reservation, User $actor, string $reason): Reservation
|
||||||
|
{
|
||||||
|
$this->authorize($actor);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($reservation, $actor, $reason): Reservation {
|
||||||
|
$locked = $this->lockPending($reservation);
|
||||||
|
$this->transition($locked, $actor, ReservationStatus::Rejected, 'rejected', $reason, [
|
||||||
|
'rejected_by' => $actor->id,
|
||||||
|
'rejected_at' => now(),
|
||||||
|
'decision_reason' => $reason,
|
||||||
|
]);
|
||||||
|
$locked->items()->whereIn('status', ReservationItemStatus::blockingValues())->update([
|
||||||
|
'status' => ReservationItemStatus::Cancelled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $locked->load('requester', 'items.asset');
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestInformation(Reservation $reservation, User $actor, string $comment): Reservation
|
||||||
|
{
|
||||||
|
$this->authorize($actor);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($reservation, $actor, $comment): Reservation {
|
||||||
|
$locked = $this->lockPending($reservation);
|
||||||
|
$this->transition($locked, $actor, ReservationStatus::AwaitingInformation, 'information_requested', $comment, [
|
||||||
|
'decision_reason' => $comment,
|
||||||
|
'approval_deadline' => now()->addHours(48),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $locked->load('requester', 'items.asset');
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorize(User $actor): void
|
||||||
|
{
|
||||||
|
if (! $actor->active || (! $actor->hasRole(RoleName::Approver) && ! $actor->hasRole(RoleName::Administrator))) {
|
||||||
|
throw new AuthorizationException('Não tem permissão para decidir requisições.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lockPending(Reservation $reservation): Reservation
|
||||||
|
{
|
||||||
|
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||||
|
if ($locked->status !== ReservationStatus::PendingApproval) {
|
||||||
|
throw ValidationException::withMessages(['decision' => 'A requisição já não aguarda uma decisão.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attributes */
|
||||||
|
private function transition(Reservation $reservation, User $actor, ReservationStatus $next, string $action, ?string $comment, array $attributes): void
|
||||||
|
{
|
||||||
|
$previous = $reservation->status;
|
||||||
|
if (! $previous->canTransitionTo($next)) {
|
||||||
|
throw ValidationException::withMessages(['decision' => 'Transição de estado inválida.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update(['status' => $next, ...$attributes]);
|
||||||
|
ApprovalAction::create([
|
||||||
|
'reservation_id' => $reservation->id,
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'action' => $action,
|
||||||
|
'previous_status' => $previous->value,
|
||||||
|
'new_status' => $next->value,
|
||||||
|
'comment' => $comment,
|
||||||
|
]);
|
||||||
|
AuditLog::create([
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'event' => "reservation.{$action}",
|
||||||
|
'auditable_type' => $reservation->getMorphClass(),
|
||||||
|
'auditable_id' => $reservation->id,
|
||||||
|
'before' => ['status' => $previous->value],
|
||||||
|
'after' => ['status' => $next->value, 'comment' => $comment],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,16 @@ namespace App\Domain\Reservations\Models;
|
|||||||
|
|
||||||
use App\Domain\Inventory\Models\SnipeAsset;
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property ReservationItemStatus $status
|
||||||
|
* @property CarbonImmutable $starts_at
|
||||||
|
* @property CarbonImmutable $ends_at
|
||||||
|
* @property SnipeAsset $asset
|
||||||
|
*/
|
||||||
class ReservationItem extends Model
|
class ReservationItem extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|||||||
@@ -25,14 +25,19 @@ class AvailabilityService
|
|||||||
->where('ends_at', '>', $startsAt));
|
->where('ends_at', '>', $startsAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool
|
public function isAvailable(
|
||||||
{
|
SnipeAsset $asset,
|
||||||
|
DateTimeInterface $startsAt,
|
||||||
|
DateTimeInterface $endsAt,
|
||||||
|
?int $exceptReservationId = null,
|
||||||
|
): bool {
|
||||||
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {
|
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasReservationConflict = $asset->reservationItems()
|
$hasReservationConflict = $asset->reservationItems()
|
||||||
->whereIn('status', ReservationItemStatus::blockingValues())
|
->whereIn('status', ReservationItemStatus::blockingValues())
|
||||||
|
->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId))
|
||||||
->where('starts_at', '<', $endsAt)
|
->where('starts_at', '<', $endsAt)
|
||||||
->where('ends_at', '>', $startsAt)
|
->where('ends_at', '>', $startsAt)
|
||||||
->exists();
|
->exists();
|
||||||
|
|||||||
57
app/Http/Controllers/ApprovalController.php
Normal file
57
app/Http/Controllers/ApprovalController.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Domain\Approvals\Services\ApprovalService;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ApprovalController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filters = $request->validate(['search' => ['nullable', 'string', 'max:100']]);
|
||||||
|
|
||||||
|
return Inertia::render('approvals/index', [
|
||||||
|
'reservations' => Reservation::query()
|
||||||
|
->with(['requester:id,name,email,department', 'items.asset:id,name,asset_tag,category_name'])
|
||||||
|
->whereIn('status', [ReservationStatus::PendingApproval, ReservationStatus::AwaitingInformation])
|
||||||
|
->when($filters['search'] ?? null, fn ($query, string $search) => $query
|
||||||
|
->where(fn ($query) => $query
|
||||||
|
->where('reference', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('requester', fn ($query) => $query->where('name', 'like', "%{$search}%"))))
|
||||||
|
->oldest('starts_at')
|
||||||
|
->paginate(20)
|
||||||
|
->withQueryString(),
|
||||||
|
'filters' => $filters,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(Request $request, Reservation $reservation, ApprovalService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate(['comment' => ['nullable', 'string', 'max:1000']]);
|
||||||
|
$service->approve($reservation, $request->user(), $data['comment'] ?? null);
|
||||||
|
|
||||||
|
return back()->with('success', "Requisição {$reservation->reference} aprovada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(Request $request, Reservation $reservation, ApprovalService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate(['comment' => ['required', 'string', 'min:5', 'max:1000']]);
|
||||||
|
$service->reject($reservation, $request->user(), $data['comment']);
|
||||||
|
|
||||||
|
return back()->with('success', "Requisição {$reservation->reference} rejeitada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestInformation(Request $request, Reservation $reservation, ApprovalService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate(['comment' => ['required', 'string', 'min:5', 'max:1000']]);
|
||||||
|
$service->requestInformation($reservation, $request->user(), $data['comment']);
|
||||||
|
|
||||||
|
return back()->with('success', "Pedido de esclarecimentos enviado para {$reservation->reference}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
],
|
],
|
||||||
'can' => [
|
'can' => [
|
||||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
||||||
|
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false,
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
'administer',
|
'administer',
|
||||||
fn (User $user): bool => $user->active && $user->hasRole(RoleName::Administrator),
|
fn (User $user): bool => $user->active && $user->hasRole(RoleName::Administrator),
|
||||||
);
|
);
|
||||||
|
Gate::define(
|
||||||
|
'approve-reservations',
|
||||||
|
fn (User $user): bool => $user->active
|
||||||
|
&& ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Nginx ──► Laravel + Inertia/React
|
|||||||
- `app/Domain/Inventory`: cache local de ativos e consultas do catálogo.
|
- `app/Domain/Inventory`: cache local de ativos e consultas do catálogo.
|
||||||
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
|
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
|
||||||
- `app/Domain/Approvals`: decisões e concorrência de aprovação (a criar).
|
- `app/Domain/Approvals`: decisões e concorrência de aprovação (a criar).
|
||||||
|
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação.
|
||||||
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
|
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
|
||||||
- `app/Domain/Audit`: logs imutáveis pela interface.
|
- `app/Domain/Audit`: logs imutáveis pela interface.
|
||||||
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.
|
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.
|
||||||
|
|||||||
@@ -55,3 +55,10 @@ Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis at
|
|||||||
**Estado:** aceite
|
**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)`.
|
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)`.
|
||||||
|
|
||||||
|
## ADR-009 — Decisões com revalidação sob bloqueio
|
||||||
|
|
||||||
|
**Data:** 2026-08-04
|
||||||
|
**Estado:** aceite
|
||||||
|
|
||||||
|
A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou administrador ativo. Antes de aprovar, o serviço bloqueia a requisição e os ativos por ordem estável, ignora apenas os itens da própria pré-reserva e volta a validar estado, atribuição, bloqueios e conflitos externos. A decisão altera requisição e itens na mesma transação e escreve tanto `approval_actions` como `audit_logs`. Rejeição liberta os itens; um pedido de esclarecimentos mantém a pré-reserva e renova o prazo por 48 horas.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Estado do projeto e handoff
|
# Estado do projeto e handoff
|
||||||
|
|
||||||
**Atualizado em:** 4 de agosto de 2026
|
**Atualizado em:** 4 de agosto de 2026
|
||||||
**Fase:** Fases 1, 2 e 3 concluídas
|
**Fase:** Fases 1, 2 e 3 concluídas; Fase 4 em curso
|
||||||
**Último agente:** Codex
|
**Último agente:** Codex
|
||||||
|
|
||||||
## Resultado disponível
|
## Resultado disponível
|
||||||
@@ -26,26 +26,29 @@
|
|||||||
- Cancelamento pelo próprio requisitante, com validação de estado, libertação transacional dos itens e auditoria.
|
- 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.
|
- 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.
|
- Calendário mensal pessoal, isolado por utilizador e com navegação entre meses.
|
||||||
|
- Área de aprovação protegida por perfil, com pesquisa por referência/requisitante.
|
||||||
|
- Aprovação, rejeição e pedido de esclarecimentos transacionais, registados em `approval_actions` e `audit_logs`.
|
||||||
|
- Aprovação revalida disponibilidade e estado operacional do ativo antes de promover itens `held` para `reserved`.
|
||||||
|
|
||||||
## Limites conhecidos
|
## Limites conhecidos
|
||||||
|
|
||||||
- A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder.
|
- A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder.
|
||||||
- Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas.
|
- Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas.
|
||||||
- Aprovação, rejeição, entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
|
- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
|
||||||
- As notificações ainda não foram implementadas.
|
- As notificações de decisão, a resposta do requisitante aos esclarecimentos e a substituição por equipamento equivalente 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.
|
- As políticas de autorização finas ainda não foram ligadas às rotas; nesta fase todas as rotas funcionais exigem utilizador autenticado, verificado e ativo.
|
||||||
- O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa.
|
- O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa.
|
||||||
|
|
||||||
## Próxima tarefa recomendada
|
## Próxima tarefa recomendada
|
||||||
|
|
||||||
Implementar a **Fase 4 — aprovações**:
|
Completar a **Fase 4 — aprovações**:
|
||||||
|
|
||||||
1. criar `app/Domain/Approvals/Services/ApprovalService.php` e policies para o perfil aprovador;
|
1. abrir `app/Domain/Approvals/Services/ApprovalService.php` e criar notificações assíncronas para cada decisão;
|
||||||
2. criar fila e detalhe de requisições pendentes com filtros por data, categoria e requisitante;
|
2. permitir ao requisitante responder ao pedido de esclarecimentos e voltar a colocar o pedido em `pending_approval`;
|
||||||
3. implementar aprovação com nova validação transacional de disponibilidade e promoção dos itens para `reserved`;
|
3. acrescentar filtros da fila por data e categoria e uma vista de detalhe com histórico;
|
||||||
4. implementar rejeição e pedido de esclarecimentos, com justificação e registo em `approval_actions`/auditoria;
|
4. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos;
|
||||||
5. permitir substituição explícita por equipamento equivalente sem contornar a deteção de conflitos;
|
5. testar notificações, resposta, substituição e concorrência em PostgreSQL;
|
||||||
6. emitir notificações assíncronas ao requisitante e ao aprovador;
|
6. concluir a documentação da Fase 4;
|
||||||
7. atualizar este documento e o changelog.
|
7. atualizar este documento e o changelog.
|
||||||
|
|
||||||
## Testes da última sessão
|
## Testes da última sessão
|
||||||
@@ -53,13 +56,13 @@ Implementar a **Fase 4 — aprovações**:
|
|||||||
Validação concluída em 4 de agosto de 2026:
|
Validação concluída em 4 de agosto de 2026:
|
||||||
|
|
||||||
- `docker compose exec -T app composer test`: passou;
|
- `docker compose exec -T app composer test`: passou;
|
||||||
- Laravel Pint: 102 ficheiros sem desvios;
|
- Laravel Pint: 106 ficheiros sem desvios;
|
||||||
- PHPStan/Larastan nível 7: 77 ficheiros, zero erros;
|
- PHPStan/Larastan nível 7: 80 ficheiros, zero erros;
|
||||||
- Pest: 60 testes passaram, 270 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
- Pest: 65 testes passaram, 289 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
||||||
- TypeScript (`tsc --noEmit`): passou;
|
- TypeScript (`tsc --noEmit`): passou;
|
||||||
- ESLint: passou;
|
- ESLint: passou;
|
||||||
- Prettier: passou;
|
- Prettier: passou;
|
||||||
- Vite: builds cliente e SSR concluídos, 2306 módulos cliente e 95 módulos SSR transformados;
|
- Vite: builds cliente e SSR concluídos, 2307 módulos cliente e 96 módulos SSR transformados;
|
||||||
- PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal;
|
- PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal;
|
||||||
- PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
|
- PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
|
||||||
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
|
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ 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.
|
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.
|
||||||
|
|
||||||
|
## Aprovações
|
||||||
|
|
||||||
|
Utilizadores com perfil `approver` ou `administrator` acedem a `http://localhost:8000/aprovacoes`. Uma decisão válida deve criar uma linha em `approval_actions` e outra em `audit_logs`. Se a aprovação indicar que a disponibilidade mudou, confirmar primeiro a atribuição/estado sincronizado do ativo e conflitos entretanto criados; não alterar o estado diretamente na base de dados.
|
||||||
|
|
||||||
## Produção
|
## Produção
|
||||||
|
|
||||||
Antes de disponibilizar:
|
Antes de disponibilizar:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
ListChecks,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
@@ -53,16 +54,26 @@ const footerNavItems: NavItem[] = [
|
|||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { can } = usePage().props;
|
const { can } = usePage().props;
|
||||||
const visibleNavItems = can.administer
|
const roleNavItems = can.approveReservations
|
||||||
? [
|
? [
|
||||||
...mainNavItems,
|
...mainNavItems,
|
||||||
|
{
|
||||||
|
title: 'Aprovações',
|
||||||
|
href: '/aprovacoes',
|
||||||
|
icon: ListChecks,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: mainNavItems;
|
||||||
|
const visibleNavItems = can.administer
|
||||||
|
? [
|
||||||
|
...roleNavItems,
|
||||||
{
|
{
|
||||||
title: 'Integração Snipe-IT',
|
title: 'Integração Snipe-IT',
|
||||||
href: '/administracao/snipe-it',
|
href: '/administracao/snipe-it',
|
||||||
icon: ServerCog,
|
icon: ServerCog,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: mainNavItems;
|
: roleNavItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar collapsible="icon" variant="inset">
|
||||||
|
|||||||
204
resources/js/pages/approvals/index.tsx
Normal file
204
resources/js/pages/approvals/index.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { Head, router, usePage } from '@inertiajs/react';
|
||||||
|
import { Check, CircleHelp, Search, X } from 'lucide-react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeading } from '@/components/page-heading';
|
||||||
|
import { ReservationStatus } from '@/components/reservation-status';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { Paginated, Reservation } from '@/types';
|
||||||
|
|
||||||
|
type ApprovalReservation = Reservation & {
|
||||||
|
requester: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
department: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ApprovalsIndex({
|
||||||
|
reservations,
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
reservations: Paginated<ApprovalReservation>;
|
||||||
|
filters: { search?: string };
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState(filters.search ?? '');
|
||||||
|
const [comments, setComments] = useState<Record<number, string>>({});
|
||||||
|
const { errors } = usePage().props;
|
||||||
|
|
||||||
|
function submitSearch(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
router.get('/aprovacoes', { search: search || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(reservation: ApprovalReservation, action: string) {
|
||||||
|
router.post(
|
||||||
|
`/aprovacoes/${reservation.uuid}/${action}`,
|
||||||
|
{ comment: comments[reservation.id] ?? '' },
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Aprovações" />
|
||||||
|
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||||
|
<PageHeading
|
||||||
|
eyebrow="Área do aprovador"
|
||||||
|
title="Requisições por decidir"
|
||||||
|
description="Reveja o período, a finalidade e os equipamentos antes de tomar uma decisão."
|
||||||
|
/>
|
||||||
|
<form onSubmit={submitSearch} className="flex gap-2">
|
||||||
|
<div className="relative max-w-md flex-1">
|
||||||
|
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Referência ou requisitante…"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Pesquisar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
{reservations.data.map((reservation) => (
|
||||||
|
<Card key={reservation.id} className="shadow-none">
|
||||||
|
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="font-mono text-base">
|
||||||
|
{reservation.reference}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{reservation.requester.name} ·{' '}
|
||||||
|
{reservation.requester.department ??
|
||||||
|
reservation.requester.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReservationStatus
|
||||||
|
status={reservation.status}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase">
|
||||||
|
Equipamentos
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{reservation.items
|
||||||
|
.map((item) => item.asset.name)
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase">
|
||||||
|
Período
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{formatPeriod(reservation)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="rounded-lg bg-slate-50 p-3 text-sm dark:bg-slate-900">
|
||||||
|
{reservation.purpose}
|
||||||
|
</p>
|
||||||
|
{reservation.status === 'pending_approval' ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={
|
||||||
|
comments[reservation.id] ?? ''
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setComments((current) => ({
|
||||||
|
...current,
|
||||||
|
[reservation.id]:
|
||||||
|
event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Comentário opcional na aprovação; obrigatório para rejeitar ou pedir esclarecimentos…"
|
||||||
|
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
message={
|
||||||
|
errors.comment ??
|
||||||
|
errors.decision
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
decide(
|
||||||
|
reservation,
|
||||||
|
'pedir-esclarecimentos',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CircleHelp /> Pedir
|
||||||
|
esclarecimentos
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
decide(
|
||||||
|
reservation,
|
||||||
|
'rejeitar',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X /> Rejeitar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
decide(
|
||||||
|
reservation,
|
||||||
|
'aprovar',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check /> Aprovar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-amber-700">
|
||||||
|
A aguardar resposta do requisitante.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{reservations.data.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-dashed py-16 text-center text-sm text-muted-foreground">
|
||||||
|
Não existem requisições pendentes para os filtros
|
||||||
|
selecionados.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeriod(reservation: Reservation): string {
|
||||||
|
const format = new Intl.DateTimeFormat('pt-PT', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${format.format(new Date(reservation.starts_at))} — ${format.format(new Date(reservation.ends_at))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalsIndex.layout = {
|
||||||
|
breadcrumbs: [{ title: 'Aprovações', href: '/aprovacoes' }],
|
||||||
|
};
|
||||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -18,6 +18,7 @@ declare module '@inertiajs/core' {
|
|||||||
};
|
};
|
||||||
can: {
|
can: {
|
||||||
administer: boolean;
|
administer: boolean;
|
||||||
|
approveReservations: boolean;
|
||||||
};
|
};
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\SnipeItController;
|
use App\Http\Controllers\Admin\SnipeItController;
|
||||||
|
use App\Http\Controllers\ApprovalController;
|
||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\ReservationController;
|
use App\Http\Controllers\ReservationController;
|
||||||
@@ -17,6 +18,13 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
|
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
|
||||||
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
|
Route::post('requisicoes/{reservation:uuid}/cancelar', [ReservationController::class, 'cancel'])->name('reservations.cancel');
|
||||||
|
|
||||||
|
Route::middleware('can:approve-reservations')->prefix('aprovacoes')->name('approvals.')->group(function () {
|
||||||
|
Route::get('/', [ApprovalController::class, 'index'])->name('index');
|
||||||
|
Route::post('{reservation:uuid}/aprovar', [ApprovalController::class, 'approve'])->name('approve');
|
||||||
|
Route::post('{reservation:uuid}/rejeitar', [ApprovalController::class, 'reject'])->name('reject');
|
||||||
|
Route::post('{reservation:uuid}/pedir-esclarecimentos', [ApprovalController::class, 'requestInformation'])->name('request-information');
|
||||||
|
});
|
||||||
|
|
||||||
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
||||||
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
||||||
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
|
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
|
||||||
|
|||||||
121
tests/Feature/ApprovalWorkflowTest.php
Normal file
121
tests/Feature/ApprovalWorkflowTest.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domain\Identity\Enums\RoleName;
|
||||||
|
use App\Domain\Identity\Models\Role;
|
||||||
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||||
|
use App\Domain\Reservations\Models\Reservation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function pendingApprovalReservation(User $requester, SnipeAsset $asset, string $reference): Reservation
|
||||||
|
{
|
||||||
|
$reservation = Reservation::create([
|
||||||
|
'reference' => $reference,
|
||||||
|
'requester_id' => $requester->id,
|
||||||
|
'purpose' => 'Produção audiovisual para uma atividade pedagógica.',
|
||||||
|
'usage_location' => 'Auditório',
|
||||||
|
'starts_at' => now()->addDays(3),
|
||||||
|
'ends_at' => now()->addDays(3)->addHours(2),
|
||||||
|
'status' => ReservationStatus::PendingApproval,
|
||||||
|
'approval_deadline' => now()->addDay(),
|
||||||
|
]);
|
||||||
|
$reservation->items()->create([
|
||||||
|
'snipe_asset_id' => $asset->id,
|
||||||
|
'starts_at' => $reservation->starts_at,
|
||||||
|
'ends_at' => $reservation->ends_at,
|
||||||
|
'status' => ReservationItemStatus::Held,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function approverUser(): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$role = Role::create(['name' => RoleName::Approver->value]);
|
||||||
|
$user->roles()->attach($role);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('aprova uma requisição após revalidar disponibilidade', function () {
|
||||||
|
$approver = approverUser();
|
||||||
|
$reservation = pendingApprovalReservation(
|
||||||
|
User::factory()->create(),
|
||||||
|
SnipeAsset::factory()->create(),
|
||||||
|
'REQ-APPROVE-001',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($approver)
|
||||||
|
->post("/aprovacoes/{$reservation->uuid}/aprovar", ['comment' => 'Pedido adequado.'])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('success');
|
||||||
|
|
||||||
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Approved)
|
||||||
|
->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Reserved);
|
||||||
|
$this->assertDatabaseHas('approval_actions', [
|
||||||
|
'reservation_id' => $reservation->id,
|
||||||
|
'actor_id' => $approver->id,
|
||||||
|
'action' => 'approved',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.approved']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejeita uma requisição e liberta a pré-reserva', function () {
|
||||||
|
$approver = approverUser();
|
||||||
|
$reservation = pendingApprovalReservation(
|
||||||
|
User::factory()->create(),
|
||||||
|
SnipeAsset::factory()->create(),
|
||||||
|
'REQ-APPROVE-002',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($approver)
|
||||||
|
->post("/aprovacoes/{$reservation->uuid}/rejeitar", ['comment' => 'O equipamento não pode sair da escola.'])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Rejected)
|
||||||
|
->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regista um pedido de esclarecimentos e renova o prazo', function () {
|
||||||
|
$approver = approverUser();
|
||||||
|
$reservation = pendingApprovalReservation(
|
||||||
|
User::factory()->create(),
|
||||||
|
SnipeAsset::factory()->create(),
|
||||||
|
'REQ-APPROVE-003',
|
||||||
|
);
|
||||||
|
$oldDeadline = $reservation->approval_deadline;
|
||||||
|
|
||||||
|
$this->actingAs($approver)
|
||||||
|
->post("/aprovacoes/{$reservation->uuid}/pedir-esclarecimentos", [
|
||||||
|
'comment' => 'Indique quem ficará responsável pelo transporte.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::AwaitingInformation)
|
||||||
|
->and($reservation->fresh()->approval_deadline->isAfter($oldDeadline))->toBeTrue();
|
||||||
|
$this->assertDatabaseHas('approval_actions', ['action' => 'information_requested']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('impede requisitantes de aceder à área de aprovação', function () {
|
||||||
|
$this->actingAs(User::factory()->create())->get('/aprovacoes')->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recusa aprovação se o estado operacional do ativo mudou', function () {
|
||||||
|
$approver = approverUser();
|
||||||
|
$asset = SnipeAsset::factory()->create();
|
||||||
|
$reservation = pendingApprovalReservation(User::factory()->create(), $asset, 'REQ-APPROVE-004');
|
||||||
|
$asset->update(['assigned_id' => 99]);
|
||||||
|
|
||||||
|
$this->actingAs($approver)
|
||||||
|
->from('/aprovacoes')
|
||||||
|
->post("/aprovacoes/{$reservation->uuid}/aprovar")
|
||||||
|
->assertRedirect('/aprovacoes')
|
||||||
|
->assertSessionHasErrors('decision');
|
||||||
|
|
||||||
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user