Reune etapas documentos e decisoes na visao do processo de compra
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user