Corrige validacao, recuperacao e acesso ao calculo do rascunho

This commit is contained in:
2026-10-08 18:07:29 +02:00
parent 1106509116
commit 51c7fb92b1
9 changed files with 1152 additions and 41 deletions
+16
View File
@@ -71,3 +71,19 @@ ClamD, identidade do operador, evidência dos links reais e integrações extern
O [registo do primeiro percurso](primeiro-percurso-ativo.md) identifica a pendência de sessão de navegador e dados reais. A leitura detalhada esclareceu o contador: os dois registos publicados são versões 1 e 2 de `project-preparation`, com o mesmo título, e não dois serviços distintos. Nenhuma versão foi alterada; nenhum dossier, pedido ou responsável foi criado para testar.
**Correção do feedback do painel (V2-03):** criação de dossier confirma resultado/próxima ação; erros preservam campos; clique duplicado bloqueado durante o envio. QA: 19 testes PHP/287 asserções, navegador com persistência/recuperação e um único POST na fase de sucesso, sem submissão de pedido. Leitura ativa final às 10:13:50 UTC continua com zero dossiers/pedidos; nenhuma escrita real repetida. O motivo da tentativa anterior não é determinável apenas pela ausência de registo.
# Atualização da revisão final — 8 de outubro de 2026
V2-03/V2-05: acrescentada ação «Validar este rascunho» na revisão final do simulador, onde antes só existia «Limpar este rascunho». Reutiliza validação e cálculo existentes e distingue verificação local de execução no servidor e de submissão ao Balcão. QA: 23 testes de regras, TypeScript e ensaio de navegador do botão/mensagem/ausência de submissão. Ver [registo do primeiro percurso](primeiro-percurso-ativo.md). Leitura ativa às 15:00:18 UTC: um dossier e zero pedidos; nenhuma escrita ativa nesta correção. Primeiro pedido real continua pendente.
# Validação visível do rascunho — 8 de outubro
V2-03/V2-05: resumo de verificação local com foco/deslocação, dados verificados, pendências/inconsistências e próxima ação. O botão final deixa de iniciar cálculo ou mudar de etapa silenciosamente; cálculo determinístico usa ação própria. QA de navegador em 1440/390 confirma correção e recuperação dos campos, foco visível e ausência de submissão; registos HTTP privados distinguem gravação e cálculo. Inspeção ativa `READ ONLY` às 15:14:26 UTC: um dossier e zero pedidos associados. Ver [registo do percurso](primeiro-percurso-ativo.md).
# Gravação e recuperação do rascunho — diagnóstico do 422
V2-03/V2-05: o 422 do fixture QA foi identificado pela resposta HTTP como idioma `pt` inválido; corrigido para a opção existente `pt-MZ`. Implementada apresentação dos erros de API junto aos campos e aviso de gravação recusada sem apagar o rascunho. QA confirmou criação no servidor e recuperação numa nova sessão autenticada sem rascunho local, com validação e cálculo testados separadamente. Evidência privada `draft-422-browser.json`; ver [registo do percurso](primeiro-percurso-ativo.md). Nenhuma escrita ou submissão na base ativa.
# Acesso direto às ferramentas — diagnóstico do clique
V2-03/V2-05: `SimulationForm` em `/simulador-fiscal` distingue «Validar este rascunho», «Abrir cálculo» e «Consultar parâmetros fiscais». As ações de abertura deixam de depender de uma revisão sem pendências; têm destino focável e aviso se indisponível. QA demonstra revisão → ferramenta → execução/resultado, em computador e móvel, sem erros JavaScript; consulta fiscal independente preserva o modo. GET ativo confirma assets atuais servidos, mas o cache da sessão real do utilizador não foi observado. Ver [registo detalhado](primeiro-percurso-ativo.md). Nenhuma criação de dossier ou submissão na base ativa.
@@ -53,3 +53,61 @@ Não foi repetida nenhuma escrita na base ativa. O novo feedback está no códig
A validação automatizada no navegador QA passou, sem erros JavaScript: validação, HTTP 409 e falha de ligação preservaram o título; duplo clique durante o envio originou **um único POST de criação bem-sucedida**; confirmação e próxima ação ficaram visíveis; sair para o painel e reabrir recuperou o mesmo dossier persistido; zero pedidos submetidos. Evidência: `panel-browser-evidence.json`. O contador de POST dessa evidência refere-se à fase de sucesso, não às tentativas de erro do teste.
Leitura ativa final às **10:13:50 UTC**: continuam zero dossiers e zero pedidos. O motivo concreto da tentativa real anterior permanece indeterminado sem captura da respetiva resposta na sessão do utilizador; não foi declarado como falha de consentimento ou de rede por inferência.
# Correção da revisão final do simulador — 8 de outubro de 2026
A indicação «só aparece Limpar este rascunho» identifica o rodapé de `SimulationForm`, distinto da criação de dossier no painel. Antes desta correção, a revisão final apenas permitia limpar; «Validar e calcular no servidor» aparecia apenas na etapa fiscal de cenários personalizados. A correção anterior do painel mantém-se.
Foi acrescentado «Validar este rascunho» na revisão final. Reutiliza as verificações existentes, conserva os campos e regressa à etapa com erros. A verificação local identifica explicitamente o seu âmbito e a próxima ação, sem afirmar gravação, elegibilidade ou aprovação. Em cenários personalizados encaminha para o cálculo determinístico existente, com bloqueio de execução duplicada; o resultado concluído indica a próxima ação. Nenhum botão desta correção submete um pedido ao Balcão.
QA isolada: 23 testes das regras do formulário passaram; verificação TypeScript passou. O ensaio de navegador em `investmoz_v2_20261008_test:55441` demonstrou o botão na revisão, mensagem de verificação local e próxima ação, com zero submissões ao Balcão. Evidências privadas: `review-rules.txt`, `review-browser-evidence.json` e `review-final-qa.png`. O ensaio final não certifica um cálculo fiscal legal nem o percurso autenticado real do utilizador.
Leitura ativa em **2026-10-08, 15:00:18 UTC**, em transação `READ ONLY`: um dossier, zero pedidos, 78 migrations; instalação anterior preservada. Não foi repetida qualquer escrita ativa e não é possível concluir, pelos totais, quem criou o dossier ou se a tentativa de validação chegou ao servidor. Continua pendente a preparação e confirmação explícita do primeiro pedido real. Restrições dos anexos/ClamD mantidas.
# Resumo visível e separação entre validação e cálculo — 8 de outubro
A implementação anterior podia deixar a mensagem fora do campo visível e, no modo personalizado, mudar para a etapa fiscal sem resumo. `SimulationForm` passa a apresentar um resumo focável, com deslocação até à mensagem, campos verificados, pendências/inconsistências com ligações para correção e próxima ação. Preserva os dados e mantém a revisão aberta. A validação é local: **não calcula valores e não envia HTTP de validação**; gravação automática, cálculo determinístico no servidor e submissão são operações distintas. O cálculo personalizado exige o botão próprio da etapa fiscal.
QA em navegador, larguras 1440 e 390: campo pendente, correção, nova validação, foco no resumo dentro do viewport e recuperação após guardar e recarregar demonstrados. O teste captura métodos, rotas e estados HTTP em prova privada `review-summary-browser.json`; nenhuma submissão ao Balcão. Regras do formulário: 23 testes passaram; TypeScript e verificação de código passaram. Evidências adicionais: `review-summary-*.txt` e capturas `review-summary-1440.png`/`review-summary-390.png`.
Inspeção ativa em **15:14:26 UTC**, exclusivamente `READ ONLY`: um dossier existente, com versão e referências preservadas, zero pedidos associados. Metadados privados em `active-dossier-inspection.json`; não foram repetidas escritas nem submetidos pedidos. Não houve acesso à sessão do navegador real do utilizador; as capturas HTTP correspondem a QA, não à tentativa ativa.
# Limite da prova de recuperação em QA
O registo HTTP do ensaio mostra `422` na criação automática do rascunho de teste: a recuperação demonstrada foi a dos dados locais da sessão, não uma gravação confirmada no servidor. Este resultado permanece explícito e não certifica recuperação numa nova sessão/dispositivo. O cálculo separado devolveu `202` e a consulta subsequente `200`, com resultado concluído. Nenhuma destas respostas corresponde a submissão ao Balcão.
# Diagnóstico e correção do HTTP 422 — 8 de outubro de 2026
Resposta real capturada em QA: `economic_snapshot.preferred_language` rejeitado com «The selected economic_snapshot.preferred_language is invalid.» O cenário de teste enviava `pt`; `StoreSimulationFormDraftRequest` e as opções do formulário aceitam `pt-MZ` ou `en`. Não se tratava de recusa da classificação aduaneira nem de falha do cálculo. O fixture anterior foi corrigido para `pt-MZ`, mantendo as regras do servidor.
Os erros de snapshots devolvidos pela API são agora associados aos campos de origem. A gravação recusada mostra um aviso, ligações para os campos e o erro junto ao respetivo controlo; não limpa os dados. Uma correção elimina o erro do campo editado e a confirmação de gravação elimina os erros anteriores. Há teste da extração de erros pessoais/económicos, índices de listas e respostas inválidas.
QA em navegador: reproduzido o 422 com `pt`, demonstrado `aria-invalid` no idioma, selecionado Português (Moçambique), confirmado POST `201`. Guardado também o snapshot numa conta QA existente com `If-Match`; uma nova sessão autenticada, sem armazenamento local do rascunho, recuperou do servidor a base introduzida. Validação local e cálculo continuam separados, sem submissão ao Balcão. Provas privadas: `draft-422-response.json`, `draft-422-browser.json`, `draft-422-rules.txt`, `draft-422-types.txt`, `draft-422-build.txt`. As provas anteriores de recuperação apenas local não foram convertidas retroativamente em sucesso no servidor.
Nenhuma escrita, submissão ou novo ensaio foi executado na base ativa neste diagnóstico.
# Próxima ação ajustada à finalidade
A revisão final já não encaminha uma simulação para preparar um pedido no Balcão. `simulate_only` orienta para ferramentas de cálculo e distingue consulta de parâmetros de cálculo; `request_facilitation` exige confirmação de gravação no servidor antes de continuar no Balcão. As restantes finalidades orientam para parceiros, financiamento, preparação da oportunidade ou dossier/plano, conforme a escolha. Pendências continuam a ter prioridade. Alteração de apresentação verificada por TypeScript e lint; nenhuma escrita ativa ou submissão.
# Resumo com nomes/valores e ação direta de cálculo
Os campos verificados são apresentados em pares de nome/valor, com texto legível, sem listas vazias. Valores pessoais não são repetidos: o resumo identifica que o conteúdo permanece disponível no campo original. Não envia nem publica esses valores. O resumo tem foco e deslocação própria, incluindo em ecrã móvel.
Para simulação, uma ação direta abre as ferramentas e a consulta fiscal sem alterar o modo escolhido. Se o utilizador já escolheu o modo personalizado, a ação calcula explicitamente a operação percentual com a base/taxa/redução declaradas; não calcula imposto legal. A consulta sem parâmetros aprovados identifica componente, período e âmbito ausentes e conserva o resultado indeterminado. Não inventa um catálogo de parâmetros obrigatórios nem preenche lacunas com zero.
QA de navegador: nomes e valores não vazios, correção e recuperação do rascunho, ação direta até ao resultado personalizado e consulta fiscal indeterminada sem mudança de modo. Evidências privadas: `review-summary-browser.json`, `summary-values-approved.json` e capturas `review-summary-*.png`. Nenhum pedido submetido nem alteração na base ativa. TypeScript e lint verificados.
# Ações exatas do formulário e compilação servida
Página do formulário: `/simulador-fiscal`; `portal/services.tsx` monta `SimulationForm`. «Validar este rascunho» chama `validateFinalReview`, uma verificação local; não é a ação de abertura/cálculo. A ação anterior estava condicionada a um resumo sem pendências e podia regressar silenciosamente quando não encontrava a etapa fiscal. Agora «Abrir cálculo» e «Consultar parâmetros fiscais» estão acessíveis independentemente das pendências da revisão, com eventos separados, destino focável e erro visível se a etapa não existir. Não mudam finalidade nem pressupostos; a consulta de parâmetros não converte o modo do rascunho. O cálculo continua a exigir execução explícita na ferramenta aberta.
GET ativo em **16:00:34 UTC** devolveu HTTP 200 e os assets atuais, incluindo `services-r6EkfdY-.js`, sem `public/hot`; evidência privada `exact-actions-active-assets.json`. Não foi possível observar o cache ou o clique do separador real do utilizador. Esta constatação valida o conteúdo servido, não certifica o estado da sessão do utilizador.
QA desde o ecrã de revisão: validação, clique em «Abrir cálculo», foco em `draft-tool-calculation`, execução explícita, resposta HTTP e resultado concluído; testado em 1440 e 390. Consulta fiscal desde a revisão também aberta, sem alteração de modo e com parâmetros ausentes/resultado indeterminado. Provas `review-summary-browser.json` e `summary-values-approved.json`; TypeScript/lint passaram. Nenhum dossier criado nem pedido submetido na base ativa durante este diagnóstico.
# Confirmação e consolidação das correções
Em 8 de outubro, o utilizador pediu o registo da confirmação e a consolidação das correções num commit. Fica confirmado o ensaio QA desde a revisão até à abertura da ferramenta e ao resultado, em computador e móvel, e a disponibilização da compilação atual pelo servidor. A consulta fiscal preserva o modo escolhido e mantém lacunas indeterminadas. A sessão/cache do navegador real do utilizador continua sem observação direta; não se declara por isso um percurso ativo concluído. Não foram criados dossiers ou submetidos pedidos ativos durante estas correções.
O commit reúne o formulário, a consulta fiscal, o tratamento de erros API, os testes e estes registos. As evidências privadas não entram no commit. A retoma do backlog V2 mantém separadas as pendências de ClamD real, identidade do operador, fontes/contactos institucionais e primeiro pedido real com confirmação do investidor; nenhum sucesso QA resolve automaticamente essas pendências.
@@ -198,7 +198,12 @@ export function SimulationFiscalStatus({
) : parameters.length === 0 ? (
<p className="text-sm">
Indeterminado — nenhum parâmetro publicado
aplicável.
aplicável. Faltam parâmetros aprovados para{' '}
{code}, no período {periodStart} a{' '}
{periodEnd} e no âmbito indicado. A consulta
não identifica um conjunto obrigatório de
parâmetros; não é possível determinar o
imposto nem substituir as lacunas por zero.
</p>
) : (
<div className="space-y-2 text-sm">
+407 -40
View File
@@ -57,6 +57,7 @@ import { ProjectReadiness } from '@/components/project-readiness';
import {
readSimulationApiJson,
simulationApiErrorMessage,
simulationApiFieldErrors,
simulationPollingFailureMessage,
simulationCatalogChoices,
} from '@/lib/simulation-api';
@@ -751,10 +752,59 @@ export function SimulationForm() {
const [ready, setReady] = useState(false);
const [step, setStep] = useState(0);
const [errors, setErrors] = useState<Record<string, string>>({});
const [serverFieldErrors, setServerFieldErrors] = useState<
Record<string, string>
>({});
const [saveStatus, setSaveStatus] = useState(
'Rascunho ainda não guardado.',
);
const [serverError, setServerError] = useState('');
const [reviewStatus, setReviewStatus] = useState('');
const [openedTool, setOpenedTool] = useState<
'calculation' | 'fiscal' | null
>(null);
const [toolError, setToolError] = useState('');
useEffect(() => {
if (!openedTool) return;
const destination = document.getElementById(`draft-tool-${openedTool}`);
if (!destination) {
setToolError(
'Não foi possível abrir a ferramenta. O rascunho foi preservado; volte a tentar.',
);
return;
}
destination.focus();
destination.scrollIntoView({ block: 'start' });
}, [openedTool, step]);
function openDraftTool(tool: 'calculation' | 'fiscal') {
setToolError('');
const index = journeySections.findIndex(
(section) => section.id === 'tax',
);
if (index < 0) {
setToolError(
'Esta etapa não disponibiliza a operação fiscal. Reveja o percurso escolhido; os dados foram preservados.',
);
return;
}
setStep(index);
setOpenedTool(tool);
}
const [reviewDetails, setReviewDetails] = useState<{
checked: { key: string; label: string; value: string }[];
pending: {
key: string;
label: string;
message: string;
step: number;
}[];
}>({ checked: [], pending: [] });
const reviewSummaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!reviewStatus) return;
reviewSummaryRef.current?.focus();
reviewSummaryRef.current?.scrollIntoView({ block: 'start' });
}, [reviewStatus, reviewDetails]);
const [isCalculating, setIsCalculating] = useState(false);
const [hasSaveConflict, setHasSaveConflict] = useState(false);
const [result, setResult] = useState<ApiSimulationExecutionV1 | null>(null);
@@ -1289,6 +1339,8 @@ export function SimulationForm() {
}
const payload: unknown =
await readSimulationApiJson(response);
if (response.status === 422)
setServerFieldErrors(simulationApiFieldErrors(payload));
if (!response.ok)
throw new Error(
simulationApiErrorMessage(payload) ??
@@ -1299,6 +1351,7 @@ export function SimulationForm() {
'O servidor devolveu uma confirmação de gravação inválida.',
);
draftIdRef.current = payload.data.id;
setServerFieldErrors({});
draftEtagRef.current = response.headers.get('ETag');
setDraftVersionNo(payload.data.version_no);
lastSavedFingerprintRef.current = fingerprint;
@@ -1719,6 +1772,12 @@ export function SimulationForm() {
label?: string | string[],
) {
setContinueLaterSaved(false);
setServerFieldErrors((previous) => {
const next = { ...previous };
delete next[key];
return next;
});
setReviewStatus('');
setDraft((currentDraft) => {
const next = replaceSimulationValue(currentDraft, key, value);
if (label) next[`${key}_label`] = label;
@@ -1835,6 +1894,7 @@ export function SimulationForm() {
}
function navigate(nextStep: number) {
setOpenedTool(null);
if (nextStep > step) {
for (let index = step; index < nextStep; index++) {
const validation = validateSimulationDraft(
@@ -1860,6 +1920,80 @@ export function SimulationForm() {
document.getElementById('simulator-step-title')?.focus();
}
function validateFinalReview() {
const checked: { key: string; label: string; value: string }[] = [];
const privateFields = partitionSimulationDraft(
draft,
true,
).personal_snapshot;
const pending: {
key: string;
label: string;
message: string;
step: number;
}[] = [];
const allErrors: Record<string, string> = {};
for (let index = 0; index < journeySections.length; index++) {
const validation = validateSimulationDraft(
draft,
journeySections[index].id,
);
if (
journeySections[index].id === 'tax' &&
draft.fiscal_scenario_mode === 'custom_planning'
) {
Object.assign(validation, validateCustomFiscalPlanning(draft));
}
Object.assign(allErrors, validation);
const fields =
simulationFieldGroups[journeySections[index].id] ?? [];
for (const field of fields) {
if (field.showWhen && !field.showWhen(draft)) continue;
const value = draft[field.key];
if (
!validation[field.key] &&
value !== undefined &&
value !== '' &&
(!Array.isArray(value) || value.length > 0)
)
checked.push({
key: field.key,
label: field.label || field.key,
value: Object.prototype.hasOwnProperty.call(
privateFields,
field.key,
)
? 'Dado pessoal preservado; consulte-o no campo original.'
: Array.isArray(value)
? value
.map((entry) => labelFor(String(entry)))
.join(', ')
: field.options
? labelFor(String(value))
: displayValue(
value,
'Valor não textual; consulte o campo original.',
),
});
}
for (const [key, message] of Object.entries(validation))
pending.push({
key,
label:
fields.find((field) => field.key === key)?.label ?? key,
message,
step: index,
});
}
setErrors(allErrors);
setReviewDetails({ checked, pending });
setReviewStatus(
pending.length
? 'Verificação concluída com campos pendentes ou inconsistências. Os dados foram preservados.'
: 'Verificação dos campos concluída neste navegador.',
);
}
async function runSimulation() {
if (calculationInFlightRef.current) return;
const validation = validateSimulationDraft(draft, 'tax');
@@ -2153,6 +2287,58 @@ export function SimulationForm() {
<output aria-live="polite" className="block text-sm">
{saveStatus}
</output>
{Object.keys(serverFieldErrors).length > 0 && (
<div
role="alert"
className="rounded border border-red-300 bg-red-50 p-3"
>
<p>
O servidor não confirmou a gravação. Os dados
preenchidos foram preservados. Corrija os campos
indicados:
</p>
<ul>
{Object.entries(serverFieldErrors).map(
([key, message]) => {
const index = journeySections.findIndex(
(section) =>
(
simulationFieldGroups[
section.id
] ?? []
).some(
(field) =>
field.key === key,
),
);
const label =
index >= 0
? simulationFieldGroups[
journeySections[index].id
].find(
(field) =>
field.key === key,
)?.label
: key;
return (
<li key={key}>
<button
type="button"
className="underline"
onClick={() => {
if (index >= 0)
setStep(index);
}}
>
{label}: {message}
</button>
</li>
);
},
)}
</ul>
</div>
)}
{draft.purpose && (
<button
type="button"
@@ -2796,7 +2982,8 @@ export function SimulationForm() {
: typeof field.required === 'function'
? field.required(draft)
: Boolean(field.required);
const fieldError = errors[field.key];
const fieldError =
serverFieldErrors[field.key] ?? errors[field.key];
if (field.options) {
const parentKey =
field.key === 'district_id'
@@ -2942,14 +3129,20 @@ export function SimulationForm() {
{(current.id === 'financial' ||
(draft.purpose === 'simulate_only' &&
current.id === 'tax')) && (
<SimulationFinancialTools
currency={String(draft.currency ?? '')}
snapshot={String(draft.financial_tools_snapshot ?? '')}
onSnapshotChange={(value) =>
change('financial_tools_snapshot', value)
}
onCurrencyChange={(value) => change('currency', value)}
/>
<div
id="draft-tool-calculation"
tabIndex={-1}
aria-label="Ferramentas de cálculo"
>
<SimulationFinancialTools
currency={String(draft.currency ?? '')}
snapshot={String(draft.financial_tools_snapshot ?? '')}
onSnapshotChange={(value) =>
change('financial_tools_snapshot', value)
}
onCurrencyChange={(value) => change('currency', value)}
/>
</div>
)}
{(current.id === 'financial' ||
(draft.purpose === 'simulate_only' &&
@@ -3172,30 +3365,41 @@ export function SimulationForm() {
/>
)}
{current.id === 'tax' && (
<SimulationFiscalStatus
salaryView={String(draft.irps_salary_view ?? '')}
scenarioMode={String(draft.fiscal_scenario_mode ?? '')}
component={String(draft.fiscal_component ?? '')}
periodStart={getFiscalPeriodRange(draft)?.start}
periodEnd={getFiscalPeriodRange(draft)?.end}
context={Object.fromEntries(
[
'fiscal_periodicity',
'fiscal_operation',
'fiscal_residency_assessment',
'fiscal_residency_country',
'fiscal_income_category',
'fiscal_establishments',
'fiscal_documentation',
'fiscal_customs_code',
'fiscal_vat_refund_state',
].flatMap((key) =>
typeof draft[key] === 'string' && draft[key] !== ''
? [[key, draft[key] as string]]
: [],
),
)}
/>
<div
id="draft-tool-fiscal"
tabIndex={-1}
aria-label="Consulta de parâmetros fiscais"
>
<SimulationFiscalStatus
salaryView={String(draft.irps_salary_view ?? '')}
scenarioMode={
openedTool === 'fiscal'
? 'approved_parameters'
: String(draft.fiscal_scenario_mode ?? '')
}
component={String(draft.fiscal_component ?? '')}
periodStart={getFiscalPeriodRange(draft)?.start}
periodEnd={getFiscalPeriodRange(draft)?.end}
context={Object.fromEntries(
[
'fiscal_periodicity',
'fiscal_operation',
'fiscal_residency_assessment',
'fiscal_residency_country',
'fiscal_income_category',
'fiscal_establishments',
'fiscal_documentation',
'fiscal_customs_code',
'fiscal_vat_refund_state',
].flatMap((key) =>
typeof draft[key] === 'string' &&
draft[key] !== ''
? [[key, draft[key] as string]]
: [],
),
)}
/>
</div>
)}
{current.id === 'tax' &&
draft.journey_type === 'investor' &&
@@ -3307,6 +3511,14 @@ export function SimulationForm() {
Simulação {simulationStatusLabel(result.status)}
.
</p>
{result.status === 'completed' && (
<p>
Cálculo concluído no servidor. Próxima ação:
reveja os pressupostos e o relatório antes
de associar o resultado ao dossier. Nenhum
pedido de apoio foi submetido.
</p>
)}
{result.status === 'completed' && (
<p>
Resultado aritmético indicativo:{' '}
@@ -3767,6 +3979,120 @@ export function SimulationForm() {
</button>
</p>
)}
{reviewStatus && (
<div
ref={reviewSummaryRef}
tabIndex={-1}
id="draft-validation-summary"
role="status"
aria-label="Resumo da validação do rascunho"
className="max-h-[70vh] overflow-y-auto rounded border border-sky-300 bg-sky-50 p-3"
>
<h3 className="font-semibold">
Resumo da validação do rascunho
</h3>
<p>{reviewStatus}</p>
<p>
Verificação local dos campos e regras disponíveis. Não
calcula valores nem envia um pedido HTTP de validação. A
gravação automática do rascunho tem um estado
independente. Não confirma viabilidade, elegibilidade ou
aprovação.
</p>
{reviewDetails.checked.length > 0 && (
<div>
<h4 className="font-semibold">
Dados verificados (
{reviewDetails.checked.length})
</h4>
<dl className="grid gap-2 text-slate-900">
{reviewDetails.checked.map((field) => (
<div
key={field.key}
className="rounded border bg-white p-2"
>
<dt className="font-medium">
{field.label}
</dt>
<dd className="break-words">
{field.value}
</dd>
</div>
))}
</dl>
</div>
)}
<p>
Campos pendentes e inconsistências:{' '}
{reviewDetails.pending.length}.
</p>
{reviewDetails.pending.length > 0 && (
<ul>
{reviewDetails.pending.map((item) => (
<li key={item.key}>
<button
type="button"
className="underline"
onClick={() => {
setStep(item.step);
setErrors(
Object.fromEntries(
reviewDetails.pending.map(
(entry) => [
entry.key,
entry.message,
],
),
),
);
window.requestAnimationFrame(() =>
document
.getElementById(
`field-${item.key}`,
)
?.focus(),
);
}}
>
{item.label}: {item.message}
</button>
</li>
))}
</ul>
)}
<p>
Próxima ação:{' '}
{reviewDetails.pending.length
? 'corrija os campos indicados e volte a validar.'
: draft.purpose === 'simulate_only' &&
draft.fiscal_scenario_mode === 'custom_planning'
? 'abra a etapa fiscal e use “Validar e calcular no servidor” para obter um resultado separado.'
: draft.purpose === 'simulate_only'
? 'use a ferramenta de cálculo adequada aos resultados que pretende obter. A consulta de parâmetros fiscais não calcula impostos; para um cenário percentual hipotético, selecione o modo de planeamento personalizado na etapa fiscal.'
: draft.purpose === 'request_facilitation'
? 'confirme que o rascunho está guardado no servidor e continue no Balcão para preparar o pedido de apoio.'
: draft.purpose === 'seek_partner'
? 'confirme a gravação do rascunho e explore parceiros na Bolsa de Parcerias, preservando o contexto do projecto.'
: draft.purpose === 'seek_investor'
? 'confirme a gravação do rascunho e reveja o financiamento e a informação a apresentar a potenciais investidores.'
: draft.purpose === 'present_opportunity'
? 'confirme a gravação e reveja os dados e a autorização de divulgação antes de preparar a oportunidade.'
: 'confirme a gravação e continue a preparar o dossier e o plano do seu investimento.'}{' '}
Nenhum pedido foi submetido.
</p>
{draft.purpose === 'simulate_only' &&
!reviewDetails.pending.length && (
<div className="space-y-2">
<p>
{draft.fiscal_scenario_mode ===
'custom_planning'
? 'Modo personalizado escolhido: calcula uma operação percentual com a base, taxa e redução que introduziu. Não calcula um imposto legal.'
: 'As ferramentas financeiras calculam prestações, ponto de equilíbrio e necessidades de financiamento com os seus pressupostos. A consulta fiscal identifica parâmetros e fontes; se faltarem parâmetros, o resultado fiscal permanece indeterminado. O modo escolhido será preservado.'}
</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap justify-between gap-3 border-t pt-4">
<button
type="button"
@@ -3785,15 +4111,56 @@ export function SimulationForm() {
Guardar e continuar
</button>
) : (
<button
type="button"
onClick={() => void clearDraft()}
className="rounded border px-4 py-3 focus-visible:outline-2"
>
Limpar este rascunho
</button>
<div className="flex flex-wrap gap-3">
<button
type="button"
disabled={isCalculating}
aria-busy={isCalculating}
onClick={validateFinalReview}
className="rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white disabled:opacity-60"
>
{isCalculating
? 'A validar…'
: 'Validar este rascunho'}
</button>
<button
type="button"
onClick={() => void clearDraft()}
className="rounded border px-4 py-3 focus-visible:outline-2"
>
Limpar este rascunho
</button>
</div>
)}
</div>
{draft.purpose === 'simulate_only' && (
<div
className="space-y-2 rounded border p-3"
aria-label="Ações do simulador"
>
<p>
Abrir cálculo apresenta as ferramentas financeiras e, se
já escolhido, o cálculo percentual personalizado.
Consultar parâmetros fiscais apresenta fontes e lacunas;
não determina imposto devido.
</p>
<button
type="button"
className="rounded bg-emerald-800 px-4 py-3 font-semibold text-white"
onClick={() => openDraftTool('calculation')}
>
Abrir cálculo
</button>{' '}
<button
type="button"
className="rounded border px-4 py-3"
onClick={() => openDraftTool('fiscal')}
>
Consultar parâmetros fiscais
</button>
{toolError && <p role="alert">{toolError}</p>}
</div>
)}
<p className="text-xs text-slate-600">
Os pressupostos não identificativos podem ser guardados de forma
cifrada para recuperar o rascunho. Os dados pessoais e contactos
+16
View File
@@ -29,6 +29,22 @@ export function simulationApiErrorMessage(value: unknown): string | undefined {
: value.message;
}
/** Keep snapshot field errors attached to their original form fields. */
export function simulationApiFieldErrors(
value: unknown,
): Record<string, string> {
if (!isApiErrorV1(value)) return {};
const fields: Record<string, string> = {};
for (const [path, messages] of Object.entries(value.errors ?? {})) {
const key = path
.replace(/^(economic_snapshot|personal_snapshot)\./, '')
.split('.')[0];
if (/^[a-z][a-z0-9_]*$/.test(key) && messages.length)
fields[key] = messages.join(' ');
}
return fields;
}
/** Explain a failed status check without implying the accepted job was lost. */
export function simulationPollingFailureMessage(value: unknown): string {
return (
+22
View File
@@ -3,10 +3,32 @@ import test from 'node:test';
import {
readSimulationApiJson,
simulationApiErrorMessage,
simulationApiFieldErrors,
simulationPollingFailureMessage,
simulationCatalogChoices,
} from '../../resources/js/lib/simulation-api.ts';
void test('snapshot API errors retain field identity and reject malformed responses', () => {
assert.deepEqual(
simulationApiFieldErrors({
message: 'Invalid',
errors: {
'economic_snapshot.preferred_language': [
'Unsupported language',
],
'personal_snapshot.email': ['Invalid email'],
'economic_snapshot.required_documents.0': ['Invalid document'],
},
}),
{
preferred_language: 'Unsupported language',
email: 'Invalid email',
required_documents: 'Invalid document',
},
);
assert.deepEqual(simulationApiFieldErrors({ errors: 'invalid' }), {});
});
void test('malformed and non-JSON API bodies do not leak parser exceptions', async () => {
assert.equal(
await readSimulationApiJson(
+259
View File
@@ -0,0 +1,259 @@
const {
chromium,
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const base = 'http://127.0.0.1:18092';
(async () => {
const b = await chromium.launch({
headless: true,
executablePath:
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
});
try {
const c = await b.newContext();
const p = await c.newPage();
let posts = 0;
let requestPayload;
const responses = [];
await c.route('**/*', (r) =>
r.request().url().startsWith(base) ? r.continue() : r.abort(),
);
p.on('response', async (r) => {
if (
r.url().includes('/simulation-drafts') &&
r.request().method() === 'POST'
) {
const body = await r.json();
responses.push({
status: r.status(),
body,
payload: r.request().postDataJSON(),
});
requestPayload = r.request().postDataJSON();
}
});
p.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/balcao-virtual'))
posts++;
});
await p.addInitScript(() => {
if (!sessionStorage.getItem('investmoz-simulation-draft-v1'))
sessionStorage.setItem(
'investmoz-simulation-draft-v1',
JSON.stringify({
journey_type: 'personal',
detail_level: 'simple',
purpose: 'simulate_only',
preferred_language: 'pt',
fiscal_period: '2026',
fiscal_periodicity: 'annual',
fiscal_component: 'customs_ice',
fiscal_customs_code: 'QA-NAO-CLASSIFICADO',
fiscal_operation: 'import',
fiscal_scenario_mode: 'custom_planning',
base: '1000',
rate: '5',
reduction: '0',
}),
);
});
const initial = await p.goto(base + '/simulador-fiscal');
assert.equal(
initial.headers()['x-investmoz-qa-target'],
'investmoz_v2_20261008_test:55441',
);
await p
.getByText('O servidor não confirmou a gravação.', { exact: false })
.waitFor();
await p
.getByRole('button', { name: '1. Finalidade', exact: true })
.click();
const field = p.getByRole('combobox').last();
assert.equal(await field.getAttribute('aria-invalid'), 'true');
await field.fill('');
await field.press('ArrowDown');
console.log(await p.getByRole('option').allTextContents());
await p
.getByRole('option', { name: /Portugu/ })
.first()
.click();
await p
.getByText('Rascunho guardado no servidor às', { exact: false })
.waitFor();
assert.equal(responses[0].status, 422);
assert.equal(
responses[0].body.errors['economic_snapshot.preferred_language'][0],
'The selected economic_snapshot.preferred_language is invalid.',
);
assert.equal(
requestPayload.economic_snapshot.preferred_language,
'pt-MZ',
);
await p
.getByRole('button', { name: '3. Revisão', exact: true })
.click();
await p
.getByRole('button', { name: 'Validar este rascunho', exact: true })
.click();
await p.locator('#draft-validation-summary').waitFor();
const saved = responses.find((x) => x.status === 201);
assert.ok(saved);
await c.close();
async function login(page) {
await page.goto(base + '/login');
await page
.locator('input[type=email]')
.fill('guidance-qa-investor@example.invalid');
await page
.locator('input[type=password]')
.fill('QA-Guidance-2026!');
await page.locator('button[type=submit]').click();
await page.waitForURL((u) => !u.pathname.startsWith('/login'));
}
const c2 = await b.newContext();
const p2 = await c2.newPage();
await login(p2);
const result = await p2.evaluate(async (snapshots) => {
const token = document.cookie
.split('; ')
.find((x) => x.startsWith('XSRF-TOKEN='))
?.slice(11);
const current = await fetch('/api/v1/simulation-drafts/current', {
headers: { Accept: 'application/json' },
});
const existing = await current.json();
const r = await fetch(
existing.data
? '/api/v1/simulation-drafts/' + existing.data.id
: '/api/v1/simulation-drafts',
{
method: existing.data ? 'PUT' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(token),
'Idempotency-Key': crypto.randomUUID(),
...(existing.data
? { 'If-Match': current.headers.get('ETag') }
: {}),
},
body: JSON.stringify(snapshots),
},
);
return { status: r.status, body: await r.json() };
}, requestPayload);
assert.ok([200, 201].includes(result.status), JSON.stringify(result));
const id = result.body.data.id;
await c2.close();
const c3 = await b.newContext();
const p3 = await c3.newPage();
await login(p3);
assert.equal(await p3.evaluate(() => sessionStorage.length), 0);
await p3.goto(base + '/simulador-fiscal?draft=' + id);
await p3
.getByText('Dossier privado recuperado da sua conta.', {
exact: false,
})
.waitFor();
await p3
.getByRole('button', { name: '2. Operação fiscal', exact: true })
.click();
assert.equal(
await p3
.getByLabel('Base hipotética para planeamento', {
exact: false,
})
.inputValue(),
'1000',
);
const uiSave = p3.waitForResponse(
(r) =>
r.request().method() === 'PUT' &&
r.url().includes('/simulation-drafts/' + id) &&
r.request().postDataJSON()?.economic_snapshot?.base === '1200',
);
await p3
.getByLabel('Base hipotética para planeamento', { exact: false })
.fill('1200');
assert.equal((await uiSave).status(), 200);
await p3.evaluate(() => {
sessionStorage.clear();
localStorage.clear();
});
await p3.reload();
await p3
.getByText('Dossier privado recuperado da sua conta.', {
exact: false,
})
.waitFor();
await p3
.getByRole('button', { name: '2. Operação fiscal', exact: true })
.click();
assert.equal(
await p3
.getByLabel('Base hipotética para planeamento', {
exact: false,
})
.inputValue(),
'1200',
);
await p3
.getByRole('button', { name: '3. Revisão', exact: true })
.click();
let calculations = 0;
p3.on('request', (r) => {
if (
r.method() === 'POST' &&
new URL(r.url()).pathname === '/api/v1/simulations'
)
calculations++;
});
await p3
.getByRole('button', { name: 'Validar este rascunho', exact: true })
.click();
assert.equal(calculations, 0);
await p3
.getByRole('button', { name: '2. Operação fiscal', exact: true })
.click();
await p3
.getByRole('button', {
name: 'Validar e calcular no servidor',
exact: true,
})
.click();
await p3
.getByText('Cálculo concluído no servidor.', { exact: false })
.waitFor();
assert.equal(calculations, 1);
assert.equal(posts, 0);
fs.writeFileSync(
'storage/app/private/investmoz-v2/draft-422-browser.json',
JSON.stringify(
{
qa: 'investmoz_v2_20261008_test:55441',
responses,
ownedDraft: id,
ownedCreation: result.status,
freshAuthenticatedSession: true,
noLocalStorageRecovery: true,
baseRecovered: '1200',
authenticatedUiSave: 200,
separateCalculationPosts: calculations,
deskSubmissions: posts,
},
null,
2,
),
);
await c3.close();
console.log(
'422 correction, field errors, server save and fresh account recovery passed',
);
} finally {
await b.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+268
View File
@@ -0,0 +1,268 @@
const {
chromium,
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
(async () => {
const b = await chromium.launch({
headless: true,
executablePath:
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
});
const evidence = [];
try {
for (const width of [1440, 390]) {
const context = await b.newContext({
viewport: { width, height: 850 },
});
const p = await context.newPage();
const consoleErrors = [];
p.on('pageerror', (error) => consoleErrors.push(error.message));
const base = 'http://127.0.0.1:18092';
const http = [];
let submissions = 0;
let calculations = 0;
await p.route('**/*', (r) => {
const u = r.request().url();
if (!u.startsWith(base)) return r.abort();
if (
r.request().method() === 'POST' &&
u.includes('/balcao-virtual')
)
submissions++;
if (
r.request().method() === 'POST' &&
new URL(u).pathname === '/api/v1/simulations'
)
calculations++;
return r.continue();
});
p.on('response', (r) => {
if (
r.status() === 422 &&
r.url().includes('/simulation-drafts')
)
void r.json().then((body) =>
fs.writeFileSync(
'storage/app/private/investmoz-v2/draft-422-response.json',
JSON.stringify(
{
body,
payload: r.request().postDataJSON(),
},
null,
2,
),
),
);
if (r.url().includes('/api/'))
http.push({
path: new URL(r.url()).pathname,
status: r.status(),
method: r.request().method(),
});
});
await p.addInitScript(() => {
if (sessionStorage.getItem('investmoz-simulation-draft-v1'))
return;
sessionStorage.setItem(
'investmoz-simulation-draft-v1',
JSON.stringify({
journey_type: 'personal',
detail_level: 'simple',
purpose: 'simulate_only',
preferred_language: 'pt-MZ',
fiscal_period: '2026',
fiscal_periodicity: 'annual',
fiscal_component: 'customs_ice',
fiscal_customs_code: 'QA-NAO-CLASSIFICADO',
fiscal_operation: 'import',
fiscal_scenario_mode: 'custom_planning',
rate: '5',
reduction: '0',
}),
);
});
const response = await p.goto(base + '/simulador-fiscal');
assert.equal(
response.headers()['x-investmoz-qa-target'],
'investmoz_v2_20261008_test:55441',
);
await p
.getByRole('button', { name: '3. Revisão', exact: true })
.click();
await p
.getByRole('button', {
name: 'Validar este rascunho',
exact: true,
})
.click();
const summary = p.locator('#draft-validation-summary');
await summary.waitFor();
await p.waitForFunction(
() => document.activeElement?.id === 'draft-validation-summary',
);
assert.match(await summary.innerText(), /campos pendentes/);
assert.match(await summary.innerText(), /Não calcula valores/);
assert.equal(calculations, 0);
assert.equal(submissions, 0);
await p.waitForFunction(() => {
const rect = document
.getElementById('draft-validation-summary')
?.getBoundingClientRect();
return rect && rect.top >= -1 && rect.top < innerHeight;
});
assert.equal(
await summary.evaluate((e) => {
const r = e.getBoundingClientRect();
return r.top >= -1 && r.top < innerHeight;
}),
true,
);
await summary.getByRole('button').first().click();
await p
.getByLabel('Base hipotética para planeamento', {
exact: false,
})
.fill('1000');
await p
.getByRole('button', { name: '3. Revisão', exact: true })
.click();
await p
.getByRole('button', {
name: 'Validar este rascunho',
exact: true,
})
.dblclick();
await p.waitForFunction(
() => document.activeElement?.id === 'draft-validation-summary',
);
assert.match(
await summary.innerText(),
/Campos pendentes e inconsistências: 0/,
);
assert.equal(calculations, 0);
assert.equal(submissions, 0);
await p
.getByRole('button', {
name: '2. Operação fiscal',
exact: true,
})
.click();
assert.equal(
await p
.getByLabel('Base hipotética para planeamento', {
exact: false,
})
.inputValue(),
'1000',
);
await p.waitForFunction(
() =>
JSON.parse(
sessionStorage.getItem(
'investmoz-simulation-draft-v1',
) ?? '{}',
).base === '1000',
);
await p
.getByRole('button', {
name: 'Guardar e continuar mais tarde',
exact: true,
})
.click();
await p.reload();
await p
.getByRole('button', {
name: '2. Operação fiscal',
exact: true,
})
.click();
assert.equal(
await p
.getByLabel('Base hipotética para planeamento', {
exact: false,
})
.inputValue(),
'1000',
);
await p
.getByRole('button', { name: '3. Revisão', exact: true })
.click();
await p
.getByRole('button', {
name: 'Validar este rascunho',
exact: true,
})
.click();
await p.screenshot({
path: `storage/app/private/investmoz-v2/review-summary-${width}.png`,
fullPage: true,
});
const names = await summary.locator('dt').allTextContents();
const values = await summary.locator('dd').allTextContents();
assert.ok(names.length > 0 && names.length === values.length);
assert.ok(
names.every((text) => text.trim()) &&
values.every((text) => text.trim()),
);
const calculationResponse = p.waitForResponse(
(r) =>
new URL(r.url()).pathname === '/api/v1/simulations' &&
r.request().method() === 'POST',
);
await p
.getByRole('button', {
name: 'Abrir cálculo',
exact: true,
})
.click();
await p.waitForFunction(
() => document.activeElement?.id === 'draft-tool-calculation',
);
await p
.getByRole('button', {
name: 'Validar e calcular no servidor',
exact: true,
})
.dblclick();
const calculated = await calculationResponse;
assert.ok([200, 201, 202].includes(calculated.status()));
await p
.getByText('Cálculo concluído no servidor.', { exact: false })
.waitFor();
assert.equal(calculations, 1);
assert.equal(submissions, 0);
evidence.push({
width,
passed: true,
summaryFocused: true,
summaryInViewport: true,
pendingCorrection: true,
dataRecovered: true,
calculationPosts: calculations,
deskSubmissions: submissions,
consoleErrors,
http,
});
assert.deepEqual(consoleErrors, []);
await context.close();
}
fs.writeFileSync(
'storage/app/private/investmoz-v2/review-summary-browser.json',
JSON.stringify(
{ qa: 'investmoz_v2_20261008_test:55441', evidence },
null,
2,
),
);
console.log(
'QA summary: desktop/mobile, pending correction, focus, recovery and HTTP separation passed',
);
} finally {
await b.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
@@ -0,0 +1,100 @@
const {
chromium,
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
(async () => {
const b = await chromium.launch({
headless: true,
executablePath:
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
});
try {
const p = await b.newPage();
await p.addInitScript(() =>
sessionStorage.setItem(
'investmoz-simulation-draft-v1',
JSON.stringify({
journey_type: 'personal',
detail_level: 'simple',
purpose: 'simulate_only',
preferred_language: 'pt-MZ',
fiscal_period: '2026',
fiscal_periodicity: 'annual',
fiscal_component: 'inss',
fiscal_scenario_mode: 'approved_parameters',
}),
),
);
let calculations = 0;
p.on('request', (r) => {
if (r.request) return;
if (
r.method() === 'POST' &&
new URL(r.url()).pathname === '/api/v1/simulations'
)
calculations++;
});
const r = await p.goto('http://127.0.0.1:18092/simulador-fiscal');
assert.equal(
r.headers()['x-investmoz-qa-target'],
'investmoz_v2_20261008_test:55441',
);
await p
.getByRole('button', { name: '3. Revisão', exact: true })
.click();
await p
.getByRole('button', { name: 'Validar este rascunho', exact: true })
.click();
const summary = p.locator('#draft-validation-summary');
assert.ok(
(await summary.locator('dt').allTextContents()).every(Boolean),
);
assert.ok(
(await summary.locator('dd').allTextContents()).every(Boolean),
);
await p
.getByRole('button', {
name: 'Consultar parâmetros fiscais',
exact: true,
})
.click();
await p
.getByText('Ferramentas de planeamento financeiro', { exact: true })
.waitFor();
await p
.getByText('Faltam parâmetros aprovados para', { exact: false })
.waitFor();
assert.equal(
await p.evaluate(
() =>
JSON.parse(
sessionStorage.getItem('investmoz-simulation-draft-v1'),
).fiscal_scenario_mode,
),
'approved_parameters',
);
assert.match(await p.locator('body').innerText(), /Indeterminado/);
assert.equal(calculations, 0);
fs.writeFileSync(
'storage/app/private/investmoz-v2/summary-values-approved.json',
JSON.stringify(
{
qa: true,
modeUnchanged: true,
calculationPosts: calculations,
contents: (await p.locator('body').innerText()).slice(
-5000,
),
},
null,
2,
),
);
} finally {
await b.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});