diff --git a/CHANGELOG.md b/CHANGELOG.md index 222d7f8..9f16a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. +- 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 @@ -40,7 +42,7 @@ 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 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. - 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”. diff --git a/README.md b/README.md index 3db276c..ac784c3 100644 --- a/README.md +++ b/README.md @@ -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 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. diff --git a/app/Domain/Approvals/Models/ApprovalAction.php b/app/Domain/Approvals/Models/ApprovalAction.php new file mode 100644 index 0000000..101e23b --- /dev/null +++ b/app/Domain/Approvals/Models/ApprovalAction.php @@ -0,0 +1,30 @@ + */ + public function reservation(): BelongsTo + { + return $this->belongsTo(Reservation::class); + } + + /** @return BelongsTo */ + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/app/Domain/Approvals/Services/ApprovalService.php b/app/Domain/Approvals/Services/ApprovalService.php new file mode 100644 index 0000000..e5a9d2c --- /dev/null +++ b/app/Domain/Approvals/Services/ApprovalService.php @@ -0,0 +1,133 @@ +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 $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], + ]); + } +} diff --git a/app/Domain/Reservations/Models/ReservationItem.php b/app/Domain/Reservations/Models/ReservationItem.php index 1a325c9..29b8bea 100644 --- a/app/Domain/Reservations/Models/ReservationItem.php +++ b/app/Domain/Reservations/Models/ReservationItem.php @@ -4,9 +4,16 @@ namespace App\Domain\Reservations\Models; use App\Domain\Inventory\Models\SnipeAsset; use App\Domain\Reservations\Enums\ReservationItemStatus; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property ReservationItemStatus $status + * @property CarbonImmutable $starts_at + * @property CarbonImmutable $ends_at + * @property SnipeAsset $asset + */ class ReservationItem extends Model { protected $fillable = [ diff --git a/app/Domain/Reservations/Services/AvailabilityService.php b/app/Domain/Reservations/Services/AvailabilityService.php index 2bf72b5..218afe1 100644 --- a/app/Domain/Reservations/Services/AvailabilityService.php +++ b/app/Domain/Reservations/Services/AvailabilityService.php @@ -25,14 +25,19 @@ class AvailabilityService ->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) { return false; } $hasReservationConflict = $asset->reservationItems() ->whereIn('status', ReservationItemStatus::blockingValues()) + ->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId)) ->where('starts_at', '<', $endsAt) ->where('ends_at', '>', $startsAt) ->exists(); diff --git a/app/Http/Controllers/ApprovalController.php b/app/Http/Controllers/ApprovalController.php new file mode 100644 index 0000000..63a0dce --- /dev/null +++ b/app/Http/Controllers/ApprovalController.php @@ -0,0 +1,57 @@ +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}."); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0684740..5f9872b 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -47,6 +47,7 @@ class HandleInertiaRequests extends Middleware ], 'can' => [ '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', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0edba79..a92f8b3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -32,6 +32,11 @@ class AppServiceProvider extends ServiceProvider 'administer', 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)), + ); } /** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f896344..e58d7e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,6 +33,7 @@ Nginx ──► Laravel + Inertia/React - `app/Domain/Inventory`: cache local de ativos e consultas do catálogo. - `app/Domain/Reservations`: disponibilidade, estados, regras e requisições. - `app/Domain/Approvals`: decisões 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/Audit`: logs imutáveis pela interface. - `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3f13195..e942267 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -55,3 +55,10 @@ Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis at **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)`. + +## 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. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 2b4a8d1..574a4dd 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,7 +1,7 @@ # Estado do projeto e handoff **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 ## Resultado disponível @@ -26,26 +26,29 @@ - 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. +- Á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 - 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 ainda não foram implementadas. +- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface. +- 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. - 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 -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; -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; +1. abrir `app/Domain/Approvals/Services/ApprovalService.php` e criar notificações assíncronas para cada decisão; +2. permitir ao requisitante responder ao pedido de esclarecimentos e voltar a colocar o pedido em `pending_approval`; +3. acrescentar filtros da fila por data e categoria e uma vista de detalhe com histórico; +4. 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. concluir a documentação da Fase 4; 7. atualizar este documento e o changelog. ## 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: - `docker compose exec -T app composer test`: passou; -- 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; +- Laravel Pint: 106 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 80 ficheiros, zero erros; +- Pest: 65 testes passaram, 289 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, 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: 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; diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index d2c1669..23b4bf7 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -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. +## 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 Antes de disponibilizar: diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index b35c53b..f3375e6 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import { CalendarDays, CalendarRange, LayoutGrid, + ListChecks, PackageSearch, PlusCircle, ServerCog, @@ -53,16 +54,26 @@ const footerNavItems: NavItem[] = [ export function AppSidebar() { const { can } = usePage().props; - const visibleNavItems = can.administer + const roleNavItems = can.approveReservations ? [ ...mainNavItems, + { + title: 'Aprovações', + href: '/aprovacoes', + icon: ListChecks, + }, + ] + : mainNavItems; + const visibleNavItems = can.administer + ? [ + ...roleNavItems, { title: 'Integração Snipe-IT', href: '/administracao/snipe-it', icon: ServerCog, }, ] - : mainNavItems; + : roleNavItems; return ( diff --git a/resources/js/pages/approvals/index.tsx b/resources/js/pages/approvals/index.tsx new file mode 100644 index 0000000..8670182 --- /dev/null +++ b/resources/js/pages/approvals/index.tsx @@ -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; + filters: { search?: string }; +}) { + const [search, setSearch] = useState(filters.search ?? ''); + const [comments, setComments] = useState>({}); + 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 ( + <> + +
+ +
+
+ + setSearch(event.target.value)} + placeholder="Referência ou requisitante…" + className="pl-9" + /> +
+ +
+ +
+ {reservations.data.map((reservation) => ( + + +
+ + {reservation.reference} + +

+ {reservation.requester.name} ·{' '} + {reservation.requester.department ?? + reservation.requester.email} +

+
+ +
+ +
+
+

+ Equipamentos +

+

+ {reservation.items + .map((item) => item.asset.name) + .join(', ')} +

+
+
+

+ Período +

+

+ {formatPeriod(reservation)} +

+
+
+

+ {reservation.purpose} +

+ {reservation.status === 'pending_approval' ? ( + <> +