From 76d2463f367e225eddbfb51a380cf44d9bc3de61 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 18:13:17 +0200 Subject: [PATCH] Reune etapas documentos e decisoes na visao do processo de compra --- .../Controllers/PurchaseProcessController.php | 117 +++++++ docs/validacao-tecnica.md | 46 +++ resources/js/pages/purchases/process.tsx | 292 ++++++++++++++++++ resources/js/pages/requisitions/show.tsx | 11 +- routes/web.php | 2 + tests/Feature/PurchaseProcessTest.php | 68 ++++ 6 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/PurchaseProcessController.php create mode 100644 resources/js/pages/purchases/process.tsx create mode 100644 tests/Feature/PurchaseProcessTest.php diff --git a/app/Http/Controllers/PurchaseProcessController.php b/app/Http/Controllers/PurchaseProcessController.php new file mode 100644 index 0000000..cda5fa3 --- /dev/null +++ b/app/Http/Controllers/PurchaseProcessController.php @@ -0,0 +1,117 @@ +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; + } +} diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md index 74eea8f..efe0912 100644 --- a/docs/validacao-tecnica.md +++ b/docs/validacao-tecnica.md @@ -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 diff --git a/resources/js/pages/purchases/process.tsx b/resources/js/pages/purchases/process.tsx new file mode 100644 index 0000000..191e121 --- /dev/null +++ b/resources/js/pages/purchases/process.tsx @@ -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 = { + 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 = { + '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 ( + <> + +
+ + + Abrir requisição + + + + } + /> +
+

+ Estado da requisição. As restantes etapas têm estados + próprios. +

+ +
+ {cancellation && ( +
+

+ Cancelamento da adjudicação:{' '} + {labels[cancellation.status]}.{' '} + {cancellation.status === 'pending' && + 'Novas decisões finais e emissão estão suspensas.'} +

+ + Consultar cancelamento e revisão + +
+ )} +
+ {stages.map((stage, index) => ( +
+
+ + {String(index + 1).padStart(2, '0')} + +

+ {stage.label} +

+
+

+ {labels[stage.state] ?? stage.state} +

+

+ Responsável: {stage.owner} +

+

+ {stage.detail} +

+ {stage.href && ( + + Consultar etapa + + + )} +
+ ))} +
+
+ +
    + {documents.map((document, index) => ( +
  • + + + + + {document.label} + + + {document.kind} + + + + +
  • + ))} +
+
+ + {!decisions.length && ( +

+ Ainda não existem etapas de decisão registadas. +

+ )} +
    + {decisions.map((decision, index) => ( +
  1. +
    + + {decision.area} ·{' '} + {roleLabels[decision.role] ?? + decision.role} + + + {labels[decision.decision] ?? + decision.decision} + +
    +

    + {decision.actor ?? + 'Por designar na decisão'} + {decision.date + ? ` · ${date(decision.date)}` + : ''} +

    +
  2. + ))} +
+
+
+ {related.length > 0 && ( + +
+ {related.map((link) => ( + + + {link.label} + + ))} +
+
+ )} + + {history.length ? ( +
    + {history.map((event) => ( +
  1. +

    + {events[event.action] ?? + 'Evento do processo'} +

    +

    + {date(event.created_at)} +

    +
  2. + ))} +
+ ) : ( +

+ Sem eventos disponíveis. +

+ )} +
+ +
    + {future.map((stage) => ( +
  • {stage}
  • + ))} +
+
+
+ + ); +} +PurchaseProcess.layout = { + breadcrumbs: [ + { title: 'Requisições', href: '/requisicoes' }, + { title: 'Processo de compra', href: '#' }, + ], +}; diff --git a/resources/js/pages/requisitions/show.tsx b/resources/js/pages/requisitions/show.tsx index 6a872fb..449c422 100644 --- a/resources/js/pages/requisitions/show.tsx +++ b/resources/js/pages/requisitions/show.tsx @@ -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={} + action={ +
+ + +
+ } /> {r.items.some((i) => i.purchase_type === 'equipment') && ( diff --git a/routes/web.php b/routes/web.php index f6b5fc8..7925bea 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); diff --git a/tests/Feature/PurchaseProcessTest.php b/tests/Feature/PurchaseProcessTest.php new file mode 100644 index 0000000..9eb1023 --- /dev/null +++ b/tests/Feature/PurchaseProcessTest.php @@ -0,0 +1,68 @@ +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); +});