Reune etapas documentos e decisoes na visao do processo de compra

This commit is contained in:
2026-10-28 18:13:17 +02:00
parent 0f32e517da
commit 76d2463f36
6 changed files with 535 additions and 1 deletions
@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\AwardCancellation;
use App\Models\BudgetEntry;
use App\Models\ExternalPayment;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\TechnicalReview;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class PurchaseProcessController extends Controller
{
public function show(Request $request, Requisition $requisition): Response
{
$actor = $request->user();
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$requisition->load(['requester:id,name', 'department:id,name', 'steps']);
$role = $actor->purchasing_role;
$purchases = in_array($role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$finance = in_array($role, [PurchasingRole::Admin, PurchasingRole::Finance]);
$inventory = $purchases || $role === PurchasingRole::Warehouse;
$base = '/requisicoes/'.$requisition->id;
$stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
$decisions = [];
$stages[] = ['label' => 'Requisição e aprovação da necessidade', 'state' => $requisition->status, 'owner' => 'Solicitante e aprovadores', 'detail' => 'Estado do pedido; as decisões individuais constam abaixo.', 'href' => $base];
foreach ($requisition->steps as $step) {
$decisions[] = ['area' => 'Estimativa', 'role' => $step->role, 'decision' => $step->decision, 'actor_id' => $step->approver_id, 'date' => $step->decided_at];
}
$equipment = $requisition->items()->where('purchase_type', 'equipment')->exists();
$reviews = TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get();
$stages[] = ['label' => 'Validação técnica', 'state' => $reviews->isNotEmpty() ? 'recorded' : ($equipment ? 'not_started' : 'not_required'), 'owner' => 'Responsável da área', 'detail' => 'O histórico não comprova validade da base actual; cada operação volta a verificá-la.', 'href' => $base];
foreach ($reviews as $review) {
$decisions[] = ['area' => 'Técnica', 'role' => 'department_head', 'decision' => $review->decision, 'actor_id' => $review->reviewed_by, 'date' => $review->reviewed_at];
}
$stages[] = ['label' => 'Controlo orçamental', 'state' => $finance ? (BudgetEntry::query()->where('requisition_id', $requisition->id)->exists() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças', 'detail' => 'Lançamentos associados ao processo. A presença de registos não certifica o orçamento nem a política da empresa.', 'href' => $finance ? '/orcamentos' : null];
$award = $purchases ? PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first() : null;
$order = $inventory ? PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $requisition->id)->first(['id', 'number']) : null;
$quoteCount = $purchases ? Quotation::query()->where('requisition_id', $requisition->id)->count() : 0;
$stages[] = ['label' => 'Propostas e comparação', 'state' => $purchases ? ($quoteCount ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Compras e avaliadores', 'detail' => $purchases ? $quoteCount.' propostas registadas. Quantidade não significa elegibilidade ou avaliação concluída.' : 'Disponível aos perfis autorizados para compras.', 'href' => $purchases ? $base.'/cotacoes' : null];
$stages[] = ['label' => 'Adjudicação', 'state' => $purchases ? ($award->status ?? 'not_started') : 'restricted', 'owner' => 'Compras e aprovadores do valor final', 'detail' => 'A decisão sobre a estimativa e a decisão sobre o valor adjudicado são distintas.', 'href' => $purchases ? $base.'/adjudicacao' : null];
if ($purchases && $quoteCount) {
$documents[] = ['label' => 'Propostas registadas', 'kind' => 'Comparação', 'href' => $base.'/cotacoes'];
}
if ($award) {
$documents[] = ['label' => 'Adjudicação #'.$award->id, 'kind' => 'Adjudicação', 'href' => $base.'/adjudicacao'];
foreach ($award->approvals as $step) {
$decisions[] = ['area' => 'Adjudicação', 'role' => $step->role, 'decision' => $step->decision, 'actor_id' => $step->approver_id, 'date' => $step->decided_at];
}
}
$stages[] = ['label' => 'Ordem de compra', 'state' => $inventory ? ($order ? 'issued' : 'not_started') : 'restricted', 'owner' => 'Compras', 'detail' => 'Documento emitido com os dados acordados preservados.', 'href' => $order && $purchases ? '/ordens/'.$order->id : null];
if ($order && $purchases) {
$documents[] = ['label' => $order->number, 'kind' => 'Ordem de compra', 'href' => '/ordens/'.$order->id];
}
$receipts = $order ? GoodsReceipt::query()->where('purchase_order_id', $order->id)->orderBy('id')->get(['id']) : collect();
$stages[] = ['label' => 'Recepção', 'state' => $inventory ? ($receipts->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Armazém', 'detail' => $inventory ? $receipts->count().' recepções registadas. Podem existir entregas parciais ou rejeições.' : 'Disponível aos perfis autorizados para recepção.', 'href' => $order ? '/ordens/'.$order->id.'/recepcoes' : null];
foreach ($receipts as $receipt) {
$documents[] = ['label' => 'Recepção #'.$receipt->id, 'kind' => 'Recepção', 'href' => '/recepcoes/'.$receipt->id];
}
$invoices = $finance && $order ? Invoice::query()->where('purchase_order_id', $order->id)->get(['id', 'number', 'status']) : collect();
$payments = $finance ? ExternalPayment::query()->whereIn('invoice_id', $invoices->pluck('id'))->get(['id', 'status']) : collect();
$stages[] = ['label' => 'Facturas', 'state' => $finance ? ($invoices->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $invoices->count().' registadas; '.$invoices->where('status', 'validated')->count().' validadas. O registo não equivale a validação.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/facturas' : null];
$stages[] = ['label' => 'Pagamentos externos', 'state' => $finance ? ($payments->isNotEmpty() ? 'recorded' : 'not_started') : 'restricted', 'owner' => 'Finanças e conferente independente', 'detail' => $finance ? $payments->count().' registos; '.$payments->where('status', 'confirmed')->count().' confirmados. Não comprova liquidação integral e não executa transferências.' : 'Disponível a Finanças e Administração.', 'href' => $finance ? '/pagamentos' : null];
foreach ($invoices as $invoice) {
$documents[] = ['label' => $invoice->number, 'kind' => 'Factura', 'href' => '/facturas'];
}
$cancellation = $award ? AwardCancellation::query()->where('purchase_award_id', $award->id)->first(['id', 'status']) : null;
$actors = User::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', array_column($decisions, 'actor_id'))->pluck('name', 'id');
foreach ($decisions as &$decision) {
$decision['actor'] = $decision['actor_id'] ? ($actors[$decision['actor_id']] ?? 'Utilizador do processo') : null;
unset($decision['actor_id']);
}
unset($decision);
$related = [];
foreach (PurchaseRevision::query()->where(fn ($query) => $query->where('source_requisition_id', $requisition->id)->orWhere('requisition_id', $requisition->id))->get() as $link) {
$otherId = $link->requisition_id === $requisition->id ? $link->source_requisition_id : $link->requisition_id;
$other = Requisition::query()->visibleTo($actor)->find($otherId, ['id', 'number']);
if ($other) {
$related[] = ['label' => $other->number, 'href' => '/requisicoes/'.$other->id.'/processo'];
}
}
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) {
$actions = [...$actions, 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
}
if ($inventory) {
$actions[] = 'receipt.confirmed';
}
if ($finance) {
$actions = [...$actions, 'invoice.created', 'invoice.validated', 'payment.registered', 'payment.confirmed', 'payment.rejected', 'budget.reserve', 'budget.award', 'budget.commit', 'budget.release'];
}
Inertia::encryptHistory();
$response = Inertia::render('purchases/process', [
'process' => ['number' => $requisition->number, 'status' => $requisition->status, 'requester' => $requisition->requester?->name, 'department' => $requisition->department?->name, 'needed_by' => $requisition->needed_by, 'href' => $base],
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
'future' => ['Plano de compras e marcos previstos/realizados', 'Sessão de abertura e acta', 'Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
}
+46
View File
@@ -1,5 +1,51 @@
# Validação técnica do SGC — 28 de Outubro de 2026
## Incremento: visão única do processo de compra
O detalhe de uma requisição inclui o acesso «Ver processo completo», em
`/requisicoes/{requisition}/processo`. A página reúne as etapas já suportadas,
os responsáveis por função, decisões com autores e datas, atalhos para os
registos e o histórico permitido ao perfil. Inclui ligação entre processos
originais e revisões quando ambos são visíveis ao utilizador. O cabeçalho e
rodapé partilhados mantêm-se neste ecrã.
Esta é uma consulta dos registos existentes. Não cria etapas obrigatórias,
aprovações, documentos, reservas ou pagamentos. Planeamento, acta de abertura,
qualificação específica do concurso e relatório por etapas aparecem apenas
como funcionalidades futuras, sem se presumirem aplicáveis a todas as compras.
Os quatro exemplos locais continuam a ser referências, não regras impostas.
O estado da requisição é separado dos restantes estados. Existência de propostas
não significa elegibilidade; histórico técnico não garante validade da base
actual; existência de lançamentos não certifica o orçamento; recepções podem
ser parciais; registo de factura não equivale a validação; pagamentos externos
confirmados não comprovam liquidação integral nem executam transferências.
A rota exige autenticação, empresa configurada e visibilidade da requisição.
Solicitantes não obtêm detalhes de propostas, adjudicações, ordens ou finanças
por esta consulta. Chefias conservam o âmbito departamental; Finanças e
Administração consultam os resumos financeiros. O Armazém não obtém acesso a
requisições de terceiros por esta nova rota. Documentos e eventos são filtrados
no servidor, sem snapshots privados, anexos ou dados bancários. A resposta usa
`private, no-store` e histórico Inertia cifrado.
Cinco testes específicos cobrem registos e estados parciais, ocultação de
compras/finanças ao solicitante, propriedade e departamento, isolamento entre
empresas e convidados, actualização da validação de factura e leitura sem
novos eventos de auditoria. Não há migração nem alteração de dados de negócio.
TypeScript, lint/formatação, build, PHPStan e Pint passaram.
Bateria completa deste incremento: **419 testes aprovados, 2 ignorados,
3180 asserções**. Os testes ignorados continuam condicionados à activação de
MFA; não são apresentados como aprovados. Os seis cenários concorrentes do
incremento anterior não foram repetidos, pois esta entrega não altera as
operações transaccionais que verificam.
O navegador voltou a devolver `The browser access host is unavailable`.
A revisão visual pública, autenticada e de impressão continua pendente;
testes HTTP e compilação não a substituem. O cancelamento de ordens emitidas
continua sujeito à proposta de regras ainda não validada.
## Incremento: navegação partilhada e revisão sem ordem emitida
O cabeçalho e rodapé do SGC são partilhados pelo acesso autenticado e pela
+292
View File
@@ -0,0 +1,292 @@
import { Head, Link } from '@inertiajs/react';
import { ArrowRight, FileText, Link2 } from 'lucide-react';
import { PageTitle, Panel, Status } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { date, roleLabels } from '@/types/procurement';
type Stage = {
label: string;
state: string;
owner: string;
detail: string;
href: string | null;
};
type Document = { label: string; kind: string; href: string };
type Props = {
process: {
number: string;
status: string;
requester: string;
department: string;
needed_by: string;
href: string;
};
stages: Stage[];
documents: Document[];
decisions: {
area: string;
role: string;
decision: string;
actor: string | null;
date: string | null;
}[];
history: { id: number; action: string; created_at: string }[];
related: { label: string; href: string }[];
cancellation: { status: string; href: string } | null;
future: string[];
};
const labels: Record<string, string> = {
draft: 'Rascunho',
pending: 'Em aprovação',
approved: 'Aprovada',
rejected: 'Rejeitada',
cancelled: 'Cancelada',
not_started: 'Sem registos',
not_required: 'Não aplicável aos artigos actuais',
restricted: 'Acesso reservado',
recorded: 'Com registos',
issued: 'Emitida',
};
const events: Record<string, string> = {
'requisition.created': 'Requisição criada',
'requisition.updated': 'Requisição actualizada',
'requisition.submitted': 'Requisição submetida',
'requisition.revision_created': 'Revisão criada em novo processo',
'approval.approved': 'Etapa da estimativa aprovada',
'approval.rejected': 'Estimativa rejeitada',
'equipment.technical_approved': 'Validação técnica aprovada',
'equipment.technical_rejected': 'Validação técnica rejeitada',
'award.created': 'Adjudicação proposta',
'award.approved': 'Etapa da adjudicação aprovada',
'award.rejected': 'Adjudicação rejeitada',
'purchase_order.issued': 'Ordem emitida',
'receipt.confirmed': 'Recepção registada',
'invoice.created': 'Factura registada',
'invoice.validated': 'Factura validada',
'payment.registered': 'Pagamento externo registado',
'payment.confirmed': 'Pagamento externo confirmado',
'payment.rejected': 'Pagamento externo rejeitado',
'budget.reserve': 'Reserva orçamental registada',
'budget.award': 'Reserva ajustada à adjudicação',
'budget.commit': 'Compromisso registado',
'budget.release': 'Reserva libertada',
'cancellation.requested': 'Cancelamento solicitado',
'cancellation.approved': 'Cancelamento aprovado',
'cancellation.rejected': 'Cancelamento rejeitado',
};
export default function PurchaseProcess({
process,
stages,
documents,
decisions,
history,
related,
cancellation,
future,
}: Props) {
return (
<>
<Head title={`Processo · ${process.number}`} />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Processo de compra"
title={process.number}
description={`${process.requester} · ${process.department} · Necessário em ${date(process.needed_by)}`}
action={
<Button asChild variant="outline">
<Link href={process.href}>
Abrir requisição
<ArrowRight className="size-4" />
</Link>
</Button>
}
/>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground">
Estado da requisição. As restantes etapas têm estados
próprios.
</p>
<Status value={process.status} />
</div>
{cancellation && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
<p>
Cancelamento da adjudicação:{' '}
<strong>{labels[cancellation.status]}</strong>.{' '}
{cancellation.status === 'pending' &&
'Novas decisões finais e emissão estão suspensas.'}
</p>
<Link
className="mt-2 inline-flex underline"
href={cancellation.href}
>
Consultar cancelamento e revisão
</Link>
</div>
)}
<section
aria-label="Etapas do processo"
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
{stages.map((stage, index) => (
<article
key={stage.label}
className="flex flex-col rounded-xl border bg-card p-5"
>
<div className="flex items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-xs font-semibold">
{String(index + 1).padStart(2, '0')}
</span>
<h2 className="text-sm font-semibold">
{stage.label}
</h2>
</div>
<p
className={`mt-4 text-sm font-medium ${stage.state === 'restricted' ? 'text-muted-foreground' : stage.state === 'rejected' || stage.state === 'cancelled' ? 'text-red-700 dark:text-red-400' : 'text-foreground'}`}
>
{labels[stage.state] ?? stage.state}
</p>
<p className="mt-2 text-xs font-medium text-muted-foreground">
Responsável: {stage.owner}
</p>
<p className="mt-3 flex-1 text-sm leading-6 text-muted-foreground">
{stage.detail}
</p>
{stage.href && (
<Link
href={stage.href}
className="mt-4 inline-flex items-center gap-2 text-sm font-medium underline underline-offset-4"
>
Consultar etapa
<ArrowRight className="size-3.5" />
</Link>
)}
</article>
))}
</section>
<div className="grid items-start gap-6 lg:grid-cols-2">
<Panel
title="Documentos e registos"
description="Atalhos para os registos disponíveis ao seu perfil; não inclui anexos privados nem dados bancários."
>
<ul className="divide-y">
{documents.map((document, index) => (
<li key={`${document.kind}-${index}`}>
<Link
href={document.href}
className="flex items-center gap-3 py-3 text-sm"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1">
<span className="block font-medium">
{document.label}
</span>
<span className="text-xs text-muted-foreground">
{document.kind}
</span>
</span>
<ArrowRight className="size-4" />
</Link>
</li>
))}
</ul>
</Panel>
<Panel
title="Decisões registadas"
description="Histórico por etapa. Uma aprovação anterior não substitui as verificações actuais."
>
{!decisions.length && (
<p className="text-sm text-muted-foreground">
Ainda não existem etapas de decisão registadas.
</p>
)}
<ol className="divide-y">
{decisions.map((decision, index) => (
<li key={index} className="py-3 text-sm">
<div className="flex flex-wrap justify-between gap-2">
<strong className="font-medium">
{decision.area} ·{' '}
{roleLabels[decision.role] ??
decision.role}
</strong>
<span>
{labels[decision.decision] ??
decision.decision}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{decision.actor ??
'Por designar na decisão'}
{decision.date
? ` · ${date(decision.date)}`
: ''}
</p>
</li>
))}
</ol>
</Panel>
</div>
{related.length > 0 && (
<Panel title="Processos ligados">
<div className="flex flex-wrap gap-4">
{related.map((link) => (
<Link
key={link.href}
href={link.href}
className="inline-flex items-center gap-2 text-sm underline"
>
<Link2 className="size-4" />
{link.label}
</Link>
))}
</div>
</Panel>
)}
<Panel
title="Histórico do processo"
description="Eventos disponíveis ao seu perfil, sem expor o conteúdo privado dos registos."
>
{history.length ? (
<ol className="grid gap-3 sm:grid-cols-2">
{history.map((event) => (
<li
key={event.id}
className="border-l-2 border-[#ed1c24] pl-3 text-sm"
>
<p>
{events[event.action] ??
'Evento do processo'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{date(event.created_at)}
</p>
</li>
))}
</ol>
) : (
<p className="text-sm text-muted-foreground">
Sem eventos disponíveis.
</p>
)}
</Panel>
<Panel
title="Etapas previstas no roteiro"
description="Ainda não implementadas. Não são tarefas obrigatórias deste pedido e não indicam validação concluída."
>
<ul className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
{future.map((stage) => (
<li key={stage}>{stage}</li>
))}
</ul>
</Panel>
</div>
</>
);
}
PurchaseProcess.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Processo de compra', href: '#' },
],
};
+10 -1
View File
@@ -69,7 +69,16 @@ export default function Show({
eyebrow="Requisição de compra"
title={r.number}
description={`${r.requester?.name} · ${r.department?.name} · ${date(r.created_at)}`}
action={<Status value={r.status} />}
action={
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link href={`/requisicoes/${r.id}/processo`}>
Ver processo completo
</Link>
</Button>
<Status value={r.status} />
</div>
}
/>
<FormErrors errors={submission.errors} />
{r.items.some((i) => i.purchase_type === 'equipment') && (
+2
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\InvestmentPolicyController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\PurchaseControlController;
use App\Http\Controllers\PurchaseController;
use App\Http\Controllers\PurchaseProcessController;
use App\Http\Controllers\QuotationController;
use App\Http\Controllers\RequisitionController;
use App\Http\Controllers\StockController;
@@ -30,6 +31,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
Route::get('requisicoes/{requisition}/processo', [PurchaseProcessController::class, 'show'])->name('purchases.process');
Route::get('revisao-compras', [PurchaseControlController::class, 'index'])->name('purchase-controls.index');
Route::post('revisao-compras/politicas', [PurchaseControlController::class, 'propose']);
Route::post('revisao-compras/politicas/{policy}/decidir', [PurchaseControlController::class, 'policyReview']);
+68
View File
@@ -0,0 +1,68 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\Department;
use App\Models\Tenant;
use App\Models\User;
use App\Services\InvoiceWorkflow;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/InvoiceFixture.php';
test('process overview links records without declaring partial execution complete', function () {
$f = invoiceFixture();
$this->actingAs($f['author'])->get('/requisicoes/'.$f['r']->id.'/processo')
->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')
->assertInertia(fn (Assert $page) => $page->component('purchases/process')
->where('process.number', $f['r']->number)
->where('stages.5.state', 'issued')
->where('stages.6.state', 'recorded')
->where('stages.7.state', 'recorded')
->where('stages.7.detail', '1 registadas; 0 validadas. O registo não equivale a validação.')
->where('stages.8.state', 'not_started')
->has('future', 4)
->where('documents', fn ($documents) => collect($documents)->contains('label', $f['invoice']->number)));
});
test('requester process view conceals purchase and finance details', function () {
$f = invoiceFixture();
$f['issuer']->forceFill(['purchasing_role' => PurchasingRole::Requester])->save();
$this->actingAs($f['issuer'])->get('/requisicoes/'.$f['r']->id.'/processo')->assertOk()
->assertInertia(fn (Assert $page) => $page->component('purchases/process')
->where('stages.2.state', 'restricted')->where('stages.3.state', 'restricted')
->where('stages.4.state', 'restricted')->where('stages.5.state', 'restricted')
->where('stages.7.state', 'restricted')->has('documents', 1)
->where('history', fn ($history) => collect($history)->every(fn ($event) => ! str_starts_with($event['action'], 'invoice.') && ! str_starts_with($event['action'], 'budget.')))
->missing('invoices')->missing('payments')->missing('bank_snapshot'));
});
test('process view does not expand request ownership or department access', function () {
$f = invoiceFixture();
$this->actingAs($f['receiver'])->get('/requisicoes/'.$f['r']->id.'/processo')->assertNotFound();
$department = Department::query()->create(['tenant_id' => $f['tenant']->id, 'name' => 'Outro departamento']);
$head = User::factory()->create(['tenant_id' => $f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$this->actingAs($head)->get('/requisicoes/'.$f['r']->id.'/processo')->assertNotFound();
$head->forceFill(['department_id' => $f['r']->department_id])->save();
$this->actingAs($head)->get('/requisicoes/'.$f['r']->id.'/processo')->assertOk()
->assertInertia(fn (Assert $page) => $page->where('stages.3.state', 'recorded')->where('stages.7.state', 'restricted')->where('documents', fn ($documents) => ! collect($documents)->contains('kind', 'Factura')));
});
test('process view is protected across companies and for guests', function () {
$f = invoiceFixture();
$otherCompany = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$this->actingAs(User::factory()->create(['tenant_id' => $otherCompany->id, 'purchasing_role' => PurchasingRole::Admin]))->get('/requisicoes/'.$f['r']->id.'/processo')->assertNotFound();
auth()->logout();
$this->get('/requisicoes/'.$f['r']->id.'/processo')->assertRedirect('/login');
});
test('process overview reads current invoice state and never exposes audit snapshots', function () {
$f = invoiceFixture();
$this->actingAs($f['validator']);
app(InvoiceWorkflow::class)->validate($f['validator'], $f['invoice']);
$before = AuditLog::query()->count();
$this->get('/requisicoes/'.$f['r']->id.'/processo')->assertOk()
->assertInertia(fn (Assert $page) => $page->where('stages.7.detail', '1 registadas; 1 validadas. O registo não equivale a validação.')
->where('history', fn ($history) => collect($history)->every(fn ($event) => array_keys($event) === ['id', 'action', 'created_at'])));
expect(AuditLog::query()->count())->toBe($before);
});