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
|
# 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
|
## 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
|
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"
|
eyebrow="Requisição de compra"
|
||||||
title={r.number}
|
title={r.number}
|
||||||
description={`${r.requester?.name} · ${r.department?.name} · ${date(r.created_at)}`}
|
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} />
|
<FormErrors errors={submission.errors} />
|
||||||
{r.items.some((i) => i.purchase_type === 'equipment') && (
|
{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\InvoiceController;
|
||||||
use App\Http\Controllers\PurchaseControlController;
|
use App\Http\Controllers\PurchaseControlController;
|
||||||
use App\Http\Controllers\PurchaseController;
|
use App\Http\Controllers\PurchaseController;
|
||||||
|
use App\Http\Controllers\PurchaseProcessController;
|
||||||
use App\Http\Controllers\QuotationController;
|
use App\Http\Controllers\QuotationController;
|
||||||
use App\Http\Controllers\RequisitionController;
|
use App\Http\Controllers\RequisitionController;
|
||||||
use App\Http\Controllers\StockController;
|
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::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||||
|
|
||||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
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::get('revisao-compras', [PurchaseControlController::class, 'index'])->name('purchase-controls.index');
|
||||||
Route::post('revisao-compras/politicas', [PurchaseControlController::class, 'propose']);
|
Route::post('revisao-compras/politicas', [PurchaseControlController::class, 'propose']);
|
||||||
Route::post('revisao-compras/politicas/{policy}/decidir', [PurchaseControlController::class, 'policyReview']);
|
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