Prepara circuito configuravel de admissao e exclusao para validacao

This commit is contained in:
2026-10-28 19:51:03 +02:00
parent 2751c6468d
commit 03ab308e02
3 changed files with 611 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
# Proposta de âmbito para admissão e exclusão de propostas
Estado: proposta para validação. Não implementa decisões formais na base de
negócio e não activa regras de exclusão ou adjudicação. Data: 28 de Outubro
de 2026.
## Resultado preparado para revisão
`/proposta/admissao` demonstra configuração de conferentes e quantidade de
confirmações, proposta manual por Compras, conferência fundamentada por
pessoas distintas e conservação local de tentativas anteriores. Os dados são
fictícios e desaparecem ao recarregar. Trocar o conferente na simulação não
autentica pessoas diferentes e não comprova independência real.
O número de confirmações e os perfis começam por definir. A demonstração
oferece três conferentes fictícios, mas essa quantidade não é um limite nem
um quórum imposto às empresas. Nenhum valor financeiro dos exemplos locais
é utilizado. Os casos A/B/C são exemplos de análise sem pendências, requisito
obrigatório não cumprido e pendência/divergência, respectivamente.
## Primeiro circuito proposto
1. Administração da empresa configura e activa expressamente uma política
versionada: perfis de conferência e quantidade de pessoas distintas exigida.
No primeiro âmbito, os perfis suportados são Finanças e chefia autorizada do
departamento. A quantidade é definida pela empresa, sem valor universal.
A activação pelo administrador fica identificada e fundamentada na auditoria;
não representa uma validação automática da política fora do software.
2. Um processo adopta expressamente esse regime antes da adjudicação, guardando
a política aplicável. Sem essa adopção, mantém-se o circuito actual. A
política fica preservada para esse processo; uma alteração geral não reescreve
os seus registos. Aplicar outra versão ao processo exige adopção expressa,
antes de adjudicar, e novas propostas de decisão e conferências.
3. Compras propõe, por proposta recebida, admitir, excluir ou solicitar
esclarecimento, com fundamento e referência à versão dos requisitos,
propostas e análises apreciadas. O proponente deve ser distinto do
solicitante. Esclarecimento é um registo interno; não inclui envio de email,
publicação, comunicação ao concorrente ou prazos legais presumidos.
4. A admissão neste primeiro âmbito exige base documental actual e análise
sem pendências ou divergências não resolvidas. Requisito obrigatório não
cumprido ou declarado não aplicável sem resolução impede admitir neste
circuito inicial. Não se propõe uma dispensa de requisito por simples
justificação, maioria de votos ou alteração de pontuação. Excepções e
dispensas ficam fora do âmbito, sujeitas a política própria futura.
5. Os conferentes devem continuar elegíveis no momento de actuar: mesma
empresa, perfil permitido, departamento quando aplicável, ausência de
conflito e identidade distinta do solicitante, do proponente e de outros
conferentes contabilizados. Uma pessoa conta uma vez. Não se pressupõe
que dois perfis correspondam a duas pessoas.
6. O número de conferências favoráveis da política conclui a decisão proposta.
Uma devolução impede a conclusão dessa tentativa; não exclui a proposta por
si. Corrigir exige outra tentativa fundamentada, preservando a anterior.
Bases de propostas/requisitos alteradas exigem reapreciação; confirmações
antigas não são copiadas para uma base nova. A decisão concluída conserva
a identidade e elegibilidade verificadas na sua data, sem reescrever história.
7. Nos processos que adoptem o regime, só propostas admitidas sob a base
aplicável podem entrar na selecção de adjudicação. Uma exclusão ou pedido
de esclarecimento não substitui admissão. Admissão não autoriza compra:
mantêm-se pontuação, conflitos, orçamento e aprovação do valor final.
A implementação deverá guardar a base de admissão na adjudicação e
verificar coerência antes da emissão. Não se alteram ordens já emitidas.
Estas são escolhas propostas para o primeiro circuito suportado, não normas
legais ou obrigações universais deduzidas dos modelos históricos. Cada empresa
continua responsável pela activação, pelos seus intervenientes e regras. A
validação solicitada é do âmbito do produto; não publica políticas reais.
## Fora do âmbito
- Aplicação retroactiva a adjudicações ou ordens existentes.
- Exclusão automática por resultado da qualificação ou por pontuação.
- Dispensa de requisitos obrigatórios, tratamento jurídico de recursos ou
prazos, publicação e comunicação externa aos concorrentes.
- Substituir a comparação ponderada por outro método de classificação.
- Alterar preços, reservas, compromissos, facturas ou pagamentos por uma
decisão de admissão/exclusão.
- Cancelamento de ordens, devoluções, estornos ou reversões financeiras.
## Verificações previstas para a implementação
Identidade e segregação no servidor; política/base preservadas; requisitos
pendentes ou obrigatórios não resolvidos; conferências repetidas; devolução;
perfil/departamento revogado; empresas diferentes; tentativas desactualizadas;
admissão versus adjudicação/emissão; rollback de auditoria e concorrência
PostgreSQL. A demonstração não substitui estes testes da futura implementação.
## Decisão solicitada
Validar o circuito inicial: **decisão manual por Compras, confirmações
independentes em quantidade/perfis definidos pela empresa e integração na
adjudicação apenas por adopção explícita do regime**, sem admitir propostas
com pendências ou requisitos obrigatórios não resolvidos neste primeiro âmbito.
Pausa-se a implementação com dados reais para esta validação, conforme a
orientação de continuidade: «definir com a empresa o próximo âmbito pendente
antes de o implementar», em `docs/orientacoes-continuidade.md`. A autorização
anterior da qualificação era documental e não activava admissão/exclusão.
As alterações locais dos três documentos de continuidade foram preservadas.
## Verificações da preparação
TypeScript, build, Pint e lint/formatação dos ficheiros desta proposta
passaram. Os três testes existentes de consulta pública passaram, com 39
asserções. A rota respondeu HTTP 200 com componente `preview/admission`,
utilizador nulo e apenas propriedades gerais da consulta pública. Não houve
migração, escrita na base de negócio ou publicação de política real.
Não se repetiu a bateria completa dos módulos de negócio, que não foram
alterados. O navegador devolveu `The browser access host is unavailable`;
a revisão visual e os testes de interacção no navegador continuam pendentes.
A formatação global de `docs/compatibilidade.md`, já modificado fora deste
âmbito, não foi corrigida nem incluída nesta preparação.
+498
View File
@@ -0,0 +1,498 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
import { PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
type Outcome = 'admit' | 'exclude' | 'clarify';
type Review = { actor: number; decision: 'approve' | 'return'; reason: string };
type Attempt = {
number: number;
supplier: string;
outcome: Outcome;
reason: string;
roles: string[];
required: number;
reviews: Review[];
};
const reviewers = [
{ id: 1, name: 'Conferente financeiro A', role: 'finance' },
{ id: 2, name: 'Conferente financeiro B', role: 'finance' },
{ id: 3, name: 'Chefia do departamento', role: 'department_head' },
];
const roles: Record<string, string> = {
finance: 'Finanças',
department_head: 'Chefia do departamento',
};
const outcomes: Record<Outcome, string> = {
admit: 'Admitir a proposta',
exclude: 'Excluir a proposta',
clarify: 'Solicitar esclarecimento',
};
const selectClass = 'h-10 w-full rounded-md border bg-background px-3 text-sm';
const state = (attempt: Attempt) =>
attempt.reviews.some((review) => review.decision === 'return')
? 'Devolvida para revisão'
: attempt.reviews.length >= attempt.required
? 'Decisão confirmada na simulação'
: 'Aguarda conferência independente';
export default function AdmissionProposal() {
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const [required, setRequired] = useState('');
const [supplier, setSupplier] = useState('Concorrente A');
const [outcome, setOutcome] = useState<Outcome>('admit');
const [reason, setReason] = useState('');
const [considered, setConsidered] = useState(false);
const [attempt, setAttempt] = useState<Attempt | null>(null);
const [history, setHistory] = useState<Attempt[]>([]);
const [reviewReasons, setReviewReasons] = useState<Record<number, string>>(
{},
);
const eligible = reviewers.filter((reviewer) =>
selectedRoles.includes(reviewer.role),
);
const admissionReady = supplier === 'Concorrente A';
const valid =
selectedRoles.length > 0 &&
Number(required) >= 1 &&
Number(required) <= eligible.length &&
reason.trim().length >= 10 &&
considered &&
(outcome !== 'admit' || admissionReady);
const final =
attempt !== null &&
(attempt.reviews.some((review) => review.decision === 'return') ||
attempt.reviews.length >= attempt.required);
const propose = () => {
if (valid) {
setAttempt({
number: history.length + 1,
supplier,
outcome,
reason,
roles: [...selectedRoles],
required: Number(required),
reviews: [],
});
setReviewReasons({});
}
};
const review = (actor: number, decision: Review['decision']) => {
if (
!attempt ||
final ||
attempt.reviews.some((entry) => entry.actor === actor) ||
!reviewers.some(
(entry) =>
entry.id === actor && attempt.roles.includes(entry.role),
) ||
(reviewReasons[actor] ?? '').trim().length < 10
)
return;
setAttempt({
...attempt,
reviews: [
...attempt.reviews,
{ actor, decision, reason: reviewReasons[actor].trim() },
],
});
};
const next = () => {
if (attempt) setHistory([...history, structuredClone(attempt)]);
setAttempt(null);
setReason('');
setConsidered(false);
setReviewReasons({});
};
return (
<>
<Head title="Proposta de admissão e exclusão" />
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Proposta para validação"
title="Admissão e exclusão de propostas"
description="Experimente uma decisão manual com conferência independente e configuração própria da empresa."
/>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
Demonstração com intervenientes e concorrentes fictícios.
Não guarda dados, publica políticas, exclui fornecedores ou
altera adjudicações. Simular outro conferente não autentica
outra pessoa.
</div>
<Panel
title="1. Circuito escolhido pela empresa"
description="Não existe número de confirmações ou perfil seleccionado por defeito. Nesta demonstração existem três conferentes fictícios, distintos do operador de Compras que propõe a decisão."
>
<fieldset className="space-y-3">
<legend className="mb-3 text-sm font-medium">
Perfis de conferência admitidos pela política
</legend>
{Object.entries(roles).map(([value, label]) => (
<Label
key={value}
className="flex items-center gap-3"
>
<input
type="checkbox"
disabled={attempt !== null}
checked={selectedRoles.includes(value)}
onChange={(event) =>
setSelectedRoles(
event.target.checked
? [...selectedRoles, value]
: selectedRoles.filter(
(entry) =>
entry !== value,
),
)
}
/>
{label}
</Label>
))}
</fieldset>
<Label className="mt-4 max-w-md">
Confirmações de pessoas distintas necessárias
<select
disabled={attempt !== null}
className={selectClass}
value={required}
onChange={(event) =>
setRequired(event.target.value)
}
>
<option value="">A empresa deve definir</option>
{[1, 2, 3].map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
</Label>
<p className="mt-3 text-xs text-muted-foreground">
{eligible.length} intervenientes fictícios elegíveis com
os perfis seleccionados. Uma mesma pessoa conta apenas
uma vez.
</p>
{required && Number(required) > eligible.length && (
<p className="mt-3 text-sm text-red-700">
Não existem intervenientes elegíveis suficientes
para a quantidade escolhida.
</p>
)}
</Panel>
<Panel
title="2. Proposta manual de decisão por Compras"
description="A decisão deverá referenciar a versão dos requisitos, as propostas e as análises apreciadas. As pendências não são resolvidas por um cálculo automático."
>
<div className="grid gap-4 sm:grid-cols-2">
<Label>
Proposta
<select
disabled={attempt !== null}
className={selectClass}
value={supplier}
onChange={(event) =>
setSupplier(event.target.value)
}
>
{[
'Concorrente A',
'Concorrente B',
'Concorrente C',
].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</Label>
<Label>
Decisão proposta
<select
disabled={attempt !== null}
className={selectClass}
value={outcome}
onChange={(event) =>
setOutcome(event.target.value as Outcome)
}
>
{Object.entries(outcomes).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</Label>
</div>
<p className="mt-4 rounded-lg border p-3 text-sm text-muted-foreground">
Base fictícia: requisitos v1. O caso A tem análise sem
pendências assinaladas; o caso B tem requisito
obrigatório não cumprido; o caso C tem pendência ou
divergência. “Solicitar esclarecimento” não envia
mensagens.
</p>
{outcome === 'admit' && !admissionReady && (
<p className="mt-3 text-sm text-red-700">
Neste primeiro âmbito proposto, a admissão aguarda
resolução das pendências, divergências ou requisitos
obrigatórios não cumpridos. Não se propõe uma
dispensa automática.
</p>
)}
<Label className="mt-4">
Fundamento da decisão e apreciação das pendências
<textarea
disabled={attempt !== null}
className="min-h-28 rounded-md border bg-background p-3 text-sm"
maxLength={4000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</Label>
<Label className="mt-4 flex items-start gap-3">
<input
type="checkbox"
disabled={attempt !== null}
checked={considered}
onChange={(event) =>
setConsidered(event.target.checked)
}
/>
<span>
Na simulação, o fundamento aprecia os resultados,
pendências e divergências relevantes. Isto não
representa uma dispensa de requisitos obrigatórios.
</span>
</Label>
{!attempt && (
<Button
className="mt-5"
disabled={!valid}
onClick={propose}
>
Simular proposta de decisão
</Button>
)}
</Panel>
{attempt && (
<Panel
title={`3. Conferência independente · Tentativa ${attempt.number}`}
description={`${attempt.supplier} · ${outcomes[attempt.outcome]} · ${state(attempt)}`}
>
<p className="text-sm whitespace-pre-wrap">
{attempt.reason}
</p>
<p className="mt-3 text-sm">
{
attempt.reviews.filter(
(entry) => entry.decision === 'approve',
).length
}
/{attempt.required} confirmações independentes.
</p>
<div className="mt-5 grid gap-4 lg:grid-cols-3">
{reviewers
.filter((entry) =>
attempt.roles.includes(entry.role),
)
.map((reviewer) => {
const existing = attempt.reviews.find(
(entry) => entry.actor === reviewer.id,
);
return (
<div
key={reviewer.id}
className="space-y-3 rounded-xl border p-4"
>
<h3 className="text-sm font-semibold">
{reviewer.name}
</h3>
<p className="text-xs text-muted-foreground">
{roles[reviewer.role]} ·
Interveniente fictício
</p>
{existing ? (
<>
<p className="text-sm font-medium">
{existing.decision ===
'approve'
? 'Conferência favorável'
: 'Devolvido para revisão'}
</p>
<p className="text-sm whitespace-pre-wrap">
{existing.reason}
</p>
</>
) : (
<>
<Label>
Fundamento da
conferência
<textarea
disabled={final}
className="min-h-24 rounded-md border bg-background p-3 text-sm"
value={
reviewReasons[
reviewer.id
] ?? ''
}
maxLength={2000}
onChange={(event) =>
setReviewReasons(
{
...reviewReasons,
[reviewer.id]:
event
.target
.value,
},
)
}
/>
</Label>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={
final ||
(
reviewReasons[
reviewer
.id
] ?? ''
).trim()
.length < 10
}
onClick={() =>
review(
reviewer.id,
'approve',
)
}
>
Simular confirmação
</Button>
<Button
variant="ghost"
disabled={
final ||
(
reviewReasons[
reviewer
.id
] ?? ''
).trim()
.length < 10
}
onClick={() =>
review(
reviewer.id,
'return',
)
}
>
Simular devolução
</Button>
</div>
</>
)}
</div>
);
})}
</div>
<p className="mt-4 text-sm text-muted-foreground">
Uma devolução rejeita esta proposta de decisão; não
exclui o concorrente por si. Admissão também não
significa adjudicação.
</p>
<Button
className="mt-5"
variant="outline"
onClick={next}
>
Preservar esta tentativa e preparar outra
</Button>
</Panel>
)}
<Panel
title="Efeitos propostos para a aplicação"
description="Pendentes de validação; nenhum está activo nesta demonstração."
>
<ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground">
<li>
Política explícita e versionada por empresa, sem
aplicar um quórum universal.
</li>
<li>
Proponente, solicitante e conferentes sujeitos a
identidade, elegibilidade, departamento e conflitos
verificados no servidor.
</li>
<li>
Requisitos ou propostas alterados exigem nova
apreciação; confirmações anteriores não são
copiadas.
</li>
<li>
Integração com a adjudicação apenas em processos que
adoptem expressamente o regime; nenhuma alteração
retroactiva de ordens existentes.
</li>
<li>
Excepções a requisitos obrigatórios e conflitos de
interesses não são dispensados por esta simulação.
</li>
</ul>
</Panel>
{history.length > 0 && (
<Panel
title="Tentativas anteriores desta simulação"
description="Conservadas apenas enquanto esta página estiver aberta. Recarregar elimina os dados locais."
>
{history.map((entry) => (
<details
key={entry.number}
className="border-b py-3"
>
<summary className="cursor-pointer text-sm font-medium">
Tentativa {entry.number} · {entry.supplier}{' '}
· {outcomes[entry.outcome]} · {state(entry)}
</summary>
<p className="mt-3 text-sm whitespace-pre-wrap">
{entry.reason}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Política simulada:{' '}
{entry.roles
.map((role) => roles[role])
.join(', ')}{' '}
· {entry.required} confirmações.
</p>
<ul className="mt-3 space-y-2 text-sm">
{entry.reviews.map((entryReview) => (
<li key={entryReview.actor}>
{
reviewers.find(
(actor) =>
actor.id ===
entryReview.actor,
)?.name
}
:{' '}
{entryReview.decision === 'approve'
? 'Favorável'
: 'Devolução'}{' '}
· {entryReview.reason}
</li>
))}
</ul>
</details>
))}
</Panel>
)}
</div>
</>
);
}
+1
View File
@@ -7,6 +7,7 @@ use Inertia\Inertia;
// Public visual consultation. No business records or authenticated identities.
Route::inertia('proposta/principal', 'preview/principal', ['auth' => ['user' => null]])->name('preview.principal');
Route::inertia('proposta/qualificacao', 'preview/qualification', ['auth' => ['user' => null]])->name('preview.qualification');
Route::inertia('proposta/admissao', 'preview/admission', ['auth' => ['user' => null]])->name('preview.admission');
Route::get('consulta', function (Request $request) {
$screen = $request->query('ecra', 'overview');