From 51c7fb92b13518e3d6e2e1b57d8c7293d3c9a8c9 Mon Sep 17 00:00:00 2001 From: nala Date: Thu, 8 Oct 2026 18:07:29 +0200 Subject: [PATCH] Corrige validacao, recuperacao e acesso ao calculo do rascunho --- docs/investmoz/v2/backlog-mestre.md | 16 + docs/investmoz/v2/primeiro-percurso-ativo.md | 58 +++ .../components/simulation-fiscal-status.tsx | 7 +- resources/js/components/simulation-form.tsx | 447 ++++++++++++++++-- resources/js/lib/simulation-api.ts | 16 + tests/frontend/simulation-api.test.mjs | 22 + tools/investmoz-v2-draft-save-browser.cjs | 259 ++++++++++ tools/investmoz-v2-review-browser.cjs | 268 +++++++++++ .../investmoz-v2-summary-approved-browser.cjs | 100 ++++ 9 files changed, 1152 insertions(+), 41 deletions(-) create mode 100644 tools/investmoz-v2-draft-save-browser.cjs create mode 100644 tools/investmoz-v2-review-browser.cjs create mode 100644 tools/investmoz-v2-summary-approved-browser.cjs diff --git a/docs/investmoz/v2/backlog-mestre.md b/docs/investmoz/v2/backlog-mestre.md index 42b899bd..58bd4760 100644 --- a/docs/investmoz/v2/backlog-mestre.md +++ b/docs/investmoz/v2/backlog-mestre.md @@ -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. diff --git a/docs/investmoz/v2/primeiro-percurso-ativo.md b/docs/investmoz/v2/primeiro-percurso-ativo.md index 8b66626f..db946144 100644 --- a/docs/investmoz/v2/primeiro-percurso-ativo.md +++ b/docs/investmoz/v2/primeiro-percurso-ativo.md @@ -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. diff --git a/resources/js/components/simulation-fiscal-status.tsx b/resources/js/components/simulation-fiscal-status.tsx index 81208edb..7a4797db 100644 --- a/resources/js/components/simulation-fiscal-status.tsx +++ b/resources/js/components/simulation-fiscal-status.tsx @@ -198,7 +198,12 @@ export function SimulationFiscalStatus({ ) : parameters.length === 0 ? (

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.

) : (
diff --git a/resources/js/components/simulation-form.tsx b/resources/js/components/simulation-form.tsx index d05ea3b7..e9e65da3 100644 --- a/resources/js/components/simulation-form.tsx +++ b/resources/js/components/simulation-form.tsx @@ -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>({}); + const [serverFieldErrors, setServerFieldErrors] = useState< + Record + >({}); 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(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(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 = {}; + 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() { {saveStatus} + {Object.keys(serverFieldErrors).length > 0 && ( +
+

+ O servidor não confirmou a gravação. Os dados + preenchidos foram preservados. Corrija os campos + indicados: +

+
    + {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 ( +
  • + +
  • + ); + }, + )} +
+
+ )} {draft.purpose && (

)} + {reviewStatus && ( +
+

+ Resumo da validação do rascunho +

+

{reviewStatus}

+

+ 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. +

+ {reviewDetails.checked.length > 0 && ( +
+

+ Dados verificados ( + {reviewDetails.checked.length}) +

+
+ {reviewDetails.checked.map((field) => ( +
+
+ {field.label} +
+
+ {field.value} +
+
+ ))} +
+
+ )} +

+ Campos pendentes e inconsistências:{' '} + {reviewDetails.pending.length}. +

+ {reviewDetails.pending.length > 0 && ( +
    + {reviewDetails.pending.map((item) => ( +
  • + +
  • + ))} +
+ )} +

+ 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. +

+ {draft.purpose === 'simulate_only' && + !reviewDetails.pending.length && ( +
+

+ {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.'} +

+
+ )} +
+ )}
+
+ + +
)}
+ {draft.purpose === 'simulate_only' && ( +
+

+ 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. +

+ {' '} + + {toolError &&

{toolError}

} +
+ )}

Os pressupostos não identificativos podem ser guardados de forma cifrada para recuperar o rascunho. Os dados pessoais e contactos diff --git a/resources/js/lib/simulation-api.ts b/resources/js/lib/simulation-api.ts index 8e76d14d..e323aeb6 100644 --- a/resources/js/lib/simulation-api.ts +++ b/resources/js/lib/simulation-api.ts @@ -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 { + if (!isApiErrorV1(value)) return {}; + const fields: Record = {}; + 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 ( diff --git a/tests/frontend/simulation-api.test.mjs b/tests/frontend/simulation-api.test.mjs index 2e2d3ec8..210bcd71 100644 --- a/tests/frontend/simulation-api.test.mjs +++ b/tests/frontend/simulation-api.test.mjs @@ -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( diff --git a/tools/investmoz-v2-draft-save-browser.cjs b/tools/investmoz-v2-draft-save-browser.cjs new file mode 100644 index 00000000..3019945b --- /dev/null +++ b/tools/investmoz-v2-draft-save-browser.cjs @@ -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; +}); diff --git a/tools/investmoz-v2-review-browser.cjs b/tools/investmoz-v2-review-browser.cjs new file mode 100644 index 00000000..c63b635d --- /dev/null +++ b/tools/investmoz-v2-review-browser.cjs @@ -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; +}); diff --git a/tools/investmoz-v2-summary-approved-browser.cjs b/tools/investmoz-v2-summary-approved-browser.cjs new file mode 100644 index 00000000..1c6fee36 --- /dev/null +++ b/tools/investmoz-v2-summary-approved-browser.cjs @@ -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; +});