Reuse investment context as editable simulator summaries

This commit is contained in:
2026-10-23 14:03:37 +02:00
parent 7ca94967f3
commit f2150b1912
7 changed files with 148 additions and 18 deletions
+10
View File
@@ -595,6 +595,16 @@ final class InvestorContext
{
$f = $ctx['fields'];
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
// Entering this tool from an investment dossier selects the existing investment-analysis route.
// The reconciliation preserves any purpose deliberately chosen in an existing simulator draft.
$sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular'];
// Human labels travel with the exact existing identifiers, not inferred locations.
foreach (app(InvestmentGeography::class)->publicReferences()->whereIn('id', array_values(Arr::only($f, array_keys(InvestmentGeography::FIELDS))))->get(['id', 'name']) as $place) {
foreach (array_keys(InvestmentGeography::FIELDS) as $key) {
if (($f[$key] ?? null) === $place->id) $sim[$key.'_label'] = $place->name;
}
}
if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->find($f['sector_id']))) $sim['sector_id_label'] = $sector->name;
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
@@ -1,5 +1,22 @@
# Rastreabilidade, utilização e formação
## Continuidade oportunidade → localização → dossier → simulador — incremento QA, 23/10/2026
Origem: decisão expressa da Tempo de aperfeiçoar o percurso adaptativo por incrementos. Mantém-se `7ca94967` como primeiro recorte QA; nenhuma destas duas alterações foi instalada na homologação nesta passagem. Carteira e projectos âncora continuam como incidente separado.
| Ponto encontrado | Delta neste incremento | Limite preservado |
| --- | --- | --- |
| Corredor/localização já escolhidos reapareciam no simulador como perguntas abertas | Reutilizar os mesmos identificadores e nomes do cadastro; mostrar resumos editáveis para valores herdados sem alteração local | Não inventar posto/localidade; manter as localizações específicas de todas as fichas. O simulador conserva o contexto geral da primeira escolha, sem agregar cálculos de oportunidades diferentes |
| Título, descrição e sector recuperados voltavam a pedir preenchimento | Resumo do valor recuperado e opção Alterar; falta de dado, erro de validação ou edição deliberada mantém a pergunta aberta | A gravação existente, as versões e a validação no servidor continuam a aplicar-se; não aceitar dados apenas porque o resumo foi mostrado |
| Entrar no simulador desde o dossier pedia novamente o tipo de percurso/finalidade | Entrada nova selecciona o percurso existente Avaliar o meu investimento / Apenas simular; opção Alterar disponível | Escolha técnica de navegação, não declaração jurídica. Não substituir uma finalidade previamente escolhida pelo utilizador; não escolher detalhe, perfil, regime fiscal ou respostas desconhecidas |
| Reutilização mostrava UUID, chaves e versões internas ao investidor | Componente comum apresenta nome do investimento, referências legíveis e regresso ao dossier | Identificadores, origem, versões e histórico permanecem nos registos; divergências não são resolvidas silenciosamente |
No simulador, os dados conhecidos aparecem como resumos com **Alterar**. Complete os restantes campos e use a gravação e o regresso ao dossier já existentes. Quando houver divergência, abra a comparação e seleccione expressamente os valores a substituir. Os nomes territoriais acompanham os identificadores correspondentes, incluindo após gravação/recuperação; uma escolha local divergente conserva o seu nome. Fontes sem nome recuperável não são mostradas como UUID no resumo.
Validação do delta: `context-inheritance.test.mjs` (12 testes, incluindo gravação/recuperação, edições/limpezas deliberadas, outro dossier, divergência geográfica e finalidade anterior); `InvestmentCorridorContextTest::test_simulator_reuses_names_for_the_exact_chosen_territory_and_sector_without_creating_a_location` (14 asserções, base QA isolada). A conferência visual utiliza exclusivamente o dossier sintético QA. A entrada pública/autenticação e a configuração de corredores reutilizam as provas aceites. Não se declara concluída a reutilização transversal em todos os consumidores; subsistem campos próprios do simulador e divergências a resolver pelo investidor, não dados a presumir.
Conferência visual concluída: entrada pelo mesmo dossier, percurso de análise seleccionado, título/resumo/sector como resumos editáveis, Sofala/Dondo recuperados, posto/localidade em falta sem preenchimento presumido. Uma edição deliberada do título no simulador sobreviveu à actualização da página; os restantes resumos continuaram herdados. Types e build passaram. Lacuna adicional identificada para o próximo recorte: a pergunta **Situação do terreno** do simulador ainda não usa a decisão de necessidade de terra do dossier; não se considera resolvida pelo ocultamento de S10 em `7ca94967`. Não alterar a aplicabilidade jurídica ou a área de instalações por inferência dessa resposta.
## Território adaptado ao corredor — decisão funcional da Tempo, 23/10/2026
Revisão funcional `0025309b` integrada pelo Git e disponível na homologação. Conferência do destino apenas de leitura: o serviço territorial para Maputo devolve exactamente as duas unidades existentes, sem consultar ou gravar um dossier real. A prova de recuperação e confirmação de Sofala/Dondo foi feita no dossier sintético QA, com indicação **Dados confirmados**. A prévia apresenta nomes e alterações úteis ao investidor, omitindo campos de identificadores internos.
@@ -277,6 +294,7 @@ Incremento de apresentação: o dossier conserva as 19 etapas e o histórico, ma
| Menu / ecrã | Componente React | Campos / entidade / tabela | Rota ou API | Perfil / regra | Evidência |
| ------------------------------ | ------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |
| Preparação adaptativa / relatório | `investor-path/show`, `investment-requirements`, `project-dossier-report` | Aplicabilidade e contexto existentes; histórico preservado | `/investimentos/{id}`, `/aplicabilidade`; exportações existentes do plano | Tempo, 23/10/2026: ocultar preparação sem aplicação; desconhecido não é dispensa | Incremento QA: `InvestmentCorridorContextTest::test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence` (18 asserções); relatório 11 testes; types/build; cenário sintético no navegador. Cobertura transversal ainda parcial. |
| Dossier → simulador sem repetir dados | `dossier-prefill`, `simulation-form`, `context-inheritance` | Campos existentes, `context_reuse`, `selection_labels`; sem migration | `/investimentos/{id}/contexto`, `/simulador-fiscal?context={id}` | Resumos apenas para valores herdados intactos; alteração deliberada preservada; perfil/detalhe/regime não presumidos | Incremento QA de continuidade, 23/10/2026: 12 testes de reutilização e 14 asserções de nomes exactos; cobertura geral permanece parcial |
| Início | `PortalInvestidor` | Sectores, oportunidades, recursos publicados | `/`, APIs existentes de homepage | Visitante; contagens do cadastro | `PublicPortalNavigationTest`, navegador |
| Catálogo / Educação | `investment/catalogue` | Filtros, educação, pivots, facetas | `/oportunidades`, `/educacao`; `/api/v1/investment/search` | Visitante; filtros em intersecção, montantes com moeda | `InvestmentUnificationTest` |
| Cluster / Corredor / Âncora | `portfolio/index`, `portfolio/show` | Dossiers, relações, cadeias e oportunidades | `/clusters`, `/corredores`, `/projectos-ancora`, `/carteira/{id}` | Visitante; dossier publicado; cadeia pública | `InvestmentPortfolioTest`, testes territoriais |
+18 -16
View File
@@ -27,6 +27,10 @@ const empty = (v: unknown) =>
(Array.isArray(v) && v.length === 0);
const text = (v: unknown) =>
typeof v === 'string' ? v : JSON.stringify(v ?? '');
const displayField = (values: Record<string, unknown>, key: string) =>
key.endsWith('_id')
? text(values[`${key}_label`]) || (empty(values[key]) ? 'não informado' : 'Referência escolhida; designação por recuperar')
: text(values[key]) || 'não informado';
export function DossierPrefill(props: Props) {
const {
contextId,
@@ -104,7 +108,7 @@ export function DossierPrefill(props: Props) {
),
);
const differences = Object.keys(source).filter(
(k) => k !== 'context_applicability_snapshot' && text(props.values[k]) !== text(source[k]),
(k) => k !== 'context_applicability_snapshot' && !k.endsWith('_label') && text(props.values[k]) !== text(source[k]),
);
const linkedRevision =
consumer === 'simulator'
@@ -117,24 +121,22 @@ export function DossierPrefill(props: Props) {
aria-label="Dados do dossier neste formulário"
>
<h2 className="font-bold">
Mesmo investimento · contexto v{view.context.revision}
Dados do seu investimento
</h2>
<p>
UUID {contextId} ·{' '}
<Link
className="underline"
href={`/investimentos/${contextId}`}
>
recuperar dossier
{view.case.title}
</Link>
. Mestres, declarações deste formulário e conteúdo já submetido
mantêm-se separados.
{' · '}<Link className="underline" href={`/investimentos/${contextId}`}>Voltar ao dossier</Link>
</p>
<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
? `O contexto do dossier difere da revisão vinculada a este registo (v${linkedRevision}).`
? 'Os dados do dossier mudaram desde a última análise.'
: 'Confira os valores deste formulário para o contexto atual; não há uma revisão anterior vinculada a estas entradas.'}{' '}
Os valores e resultados anteriores permanecem guardados;
confira os pressupostos antes de os usar no cenário atual.
@@ -145,11 +147,9 @@ export function DossierPrefill(props: Props) {
(r) =>
r && (
<p key={r.key}>
Fonte disponível:{' '}
<Link className="underline" href={r.href}>
{r.title}
</Link>{' '}
· {r.kind} · v{r.version ?? 'não exposta'}. {r.key}
</Link>
</p>
),
)}
@@ -214,11 +214,10 @@ export function DossierPrefill(props: Props) {
</label>
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{text(props.values[k]) ||
'não informado'}
{displayField(props.values, k)}
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{text(source[k]) || 'não informado'}
{displayField(source, k)}
</td>
</tr>
))}
@@ -232,17 +231,20 @@ export function DossierPrefill(props: Props) {
onClick={() => {
const patch: Record<string, unknown> = {};
for (const k of selected)
{
patch[k] = source[k] ?? '';
if (`${k}_label` in source) patch[`${k}_label`] = source[`${k}_label`];
}
const scope = `dossier:${contextId}:${consumer}`;
let raw = String(props.values.context_reuse ?? '');
try {
const metadata = JSON.parse(raw);
if(metadata.scope === scope) {
for(const key of selected) delete metadata.inherited?.[key];
for(const key of Object.keys(patch)) delete metadata.inherited?.[key];
raw=JSON.stringify(metadata);
}
} catch { /* Legacy metadata does not grant ownership of other fields. */ }
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(selected.map(key=>[key,undefined]))},patch,raw,props.allowedFields);
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(Object.keys(patch).map(key=>[key,undefined]))},patch,raw,props.allowedFields);
props.onApply({...patch,...(props.persistInheritance === false ? {} : {context_reuse:adopted.metadata})}, true);
setSelected([]);
setReviewed(false);
@@ -256,7 +258,7 @@ export function DossierPrefill(props: Props) {
</button>
</details>
)}
{!readOnly && reviewed && <p>Contexto v{view.context.revision} recuperado neste formulário, sem nova confirmação dos dados compatíveis. As edições locais conservam-se; as declarações e submissões formais mantêm os seus controlos próprios.</p>}
{!readOnly && reviewed && <p>Os dados disponíveis foram recuperados. Preencha o que falta; as suas alterações conservam-se.</p>}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
+12 -1
View File
@@ -1,6 +1,6 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import { inheritSimulatorFields } from '@/lib/context-inheritance';
import { inheritSimulatorFields, reusedInvestmentValue } from '@/lib/context-inheritance';
import {
PublicDocumentContent,
type PublicDocument,
@@ -807,6 +807,7 @@ export function SimulationForm() {
const canSavePersonalData = Boolean(auth?.user);
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
const [draft, setDraft] = useState<SimulationDraft>({});
const [editingReused, setEditingReused] = useState<string[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
const [contextReview, setContextReview] = useState({
@@ -3294,6 +3295,16 @@ export function SimulationForm() {
: Boolean(field.required);
const fieldError =
serverFieldErrors[field.key] ?? errors[field.key];
const reused = contextId && ready && !fieldError && !editingReused.includes(`${contextId}:${field.key}`)
? reusedInvestmentValue(`dossier:${contextId}:simulator`, field.key, draft) : null;
if (reused !== null) return (
<div key={field.key} id={`field-${field.key}`} className="rounded border bg-emerald-50 p-3">
<p className="font-semibold">{field.label}</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere]">{reused}</p>
<p className="text-sm">Recuperado do dossier.</p>
<button type="button" className="underline" onClick={() => setEditingReused((keys) => [...keys, `${contextId}:${field.key}`])}>Alterar {field.label.toLocaleLowerCase('pt-MZ')}</button>
</div>
);
const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) => <p key={index} role="note" className="mt-1 text-sm text-amber-950">{ivaIssueText(issue)}</p>);
if (field.options) {
const parentKey =
+24
View File
@@ -19,6 +19,16 @@ export function inheritCompatibleFields(scope:string, values:Record<string,unkno
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
for(const [key,value] of Object.entries(source)) {
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
if (key.endsWith('_label')) {
const base = key.slice(0, -6);
const baseOwned = previous[base] === signature(values[base]);
const baseVacant = empty(values[base]) && !(base in previous);
if (!(base in source) || (geo.includes(base) && geoConflict) || (!baseOwned && !baseVacant && signature(values[base]) !== signature(source[base]))) continue;
// A label describes its identifier; it must never lag behind an adopted identifier.
if (signature(values[key]) !== signature(value)) patch[key] = value;
next[key] = signature(value); inherited.push(key);
continue;
}
const current=values[key];
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
const owned=previous[key]===signature(track(current));
@@ -42,6 +52,20 @@ export function inheritCompatibleFields(scope:string, values:Record<string,unkno
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
}
/** Presentation only: a saved, unedited inherited value can be shown without asking again. */
export function reusedInvestmentValue(scope: string, key: string, values: Record<string, unknown>): string | null {
if (!['journey_type', 'purpose', 'project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id'].includes(key)) return null;
try {
const metadata = JSON.parse(String(values.context_reuse ?? ''));
if (metadata.version !== 1 || metadata.scope !== scope || empty(values[key]) || metadata.inherited?.[key] !== signature(values[key])) return null;
if (key.endsWith('_id') || ['journey_type', 'purpose'].includes(key)) {
const label = values[`${key}_label`];
return typeof label === 'string' && label.trim() && metadata.inherited?.[`${key}_label`] === signature(label) ? label : null;
}
return typeof values[key] === 'string' ? values[key] : null;
} catch { return null; }
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
@@ -30,6 +30,25 @@ class InvestmentCorridorContextTest extends TestCase
$entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
return Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
}
public function test_simulator_reuses_names_for_the_exact_chosen_territory_and_sector_without_creating_a_location(): void
{
$id=$this->postJson('/investimentos',['title'=>'QA continuidade simulador','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$province=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','07')->id;
$district=app(\App\Services\InvestmentGeography::class)->publicReferences()->where('parent_id',$province)->where('location_type','district')->firstOrFail();
$record=$this->record('beira');
$plan=$this->postJson('/investimentos/'.$id.'/contexto/previa',['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$record->id,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]]])->assertOk()->json();
$sim=app(\App\Services\InvestorContext::class)->prefill($plan)['simulator'];
$this->assertSame('investor',$sim['journey_type']);
$this->assertSame('simulate_only',$sim['purpose']);
$this->assertSame($province,$sim['province_id']);
$this->assertSame('Sofala',$sim['province_id_label']);
$this->assertSame($district->id,$sim['district_id']);
$this->assertSame($district->name,$sim['district_id_label']);
$this->assertSame((string)$record->sector_id,$sim['sector_id']);
$this->assertSame(\App\Models\Sector::findOrFail($record->sector_id)->name,$sim['sector_id_label']);
$this->assertArrayNotHasKey('locality_id_label',$sim);
$this->assertArrayNotHasKey('admin_post_id_label',$sim);
}
public function test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence(): void
{
$id = $this->postJson('/investimentos', ['title' => 'QA adaptação sem perda', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
+47 -1
View File
@@ -7,11 +7,57 @@ test('legacy draft without a compatible source keeps its shape and identity',()=
assert.equal(Object.hasOwn(inheritSimulatorFields(legacy),'compatible_fields_snapshot'),false);
});
import test from 'node:test';
import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
import { inheritCompatibleFields, inheritSimulatorFields, reusedInvestmentValue } from '../../resources/js/lib/context-inheritance.ts';
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
test('known investment questions become editable summaries after save/recovery, while missing and deliberate edits remain questions', () => {
const scope='dossier:A:simulator';
const source={project_title:'Florestas — Beira',project_summary:'Preparar investimento florestal',province_id:'p1',province_id_label:'Sofala',sector_id:'3',sector_id_label:'Agronegócio'};
const reused=inheritCompatibleFields(scope,{},source,null);
const draft={...reused.patch,context_reuse:reused.metadata,purpose:'simulate_only',journey_type:'investor'};
const saved=partitionSimulationDraft(draft);
const recovered=restoreSimulationDraft(saved.personal_snapshot,saved.economic_snapshot);
assert.equal(reusedInvestmentValue(scope,'project_title',recovered),source.project_title);
assert.equal(reusedInvestmentValue(scope,'province_id',recovered),'Sofala');
assert.equal(reusedInvestmentValue(scope,'sector_id',recovered),'Agronegócio');
assert.equal(reusedInvestmentValue(scope,'district_id',recovered),null);
assert.equal(reusedInvestmentValue('dossier:B:simulator','project_title',recovered),null);
assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,project_title:'Edição deliberada'}),null);
assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,project_title:''}),null);
assert.equal(reusedInvestmentValue(scope,'province_id',{...recovered,province_id_label:''}),null);
assert.equal(reusedInvestmentValue(scope,'currency',{...recovered,currency:'MZN'}),null);
assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,context_reuse:'invalid'}),null);
});
test('local geography and sector keep their labels when the dossier proposes different identifiers', () => {
const scope='dossier:A:simulator';
const source={province_id:'p1',province_id_label:'Sofala',district_id:'d1',district_id_label:'Dondo',sector_id:'3',sector_id_label:'Agronegócio'};
const local={province_id:'p2',province_id_label:'Gaza',district_id:'d2',district_id_label:'Outro distrito',sector_id:'4',sector_id_label:'Turismo'};
const reconciliation=inheritCompatibleFields(scope,local,source,null);
assert.deepEqual(reconciliation.patch,{});
assert.ok(reconciliation.conflicts.includes('province_id'));
const first=inheritCompatibleFields(scope,{},source,null);
const changed=inheritCompatibleFields(scope,{...first.patch,context_reuse:first.metadata},{...source,province_id:'p3',province_id_label:'Manica',district_id:'',district_id_label:''},first.metadata);
assert.equal(changed.patch.province_id,'p3');assert.equal(changed.patch.province_id_label,'Manica');
assert.equal(changed.patch.district_id,'');assert.equal(changed.patch.district_id_label,'');
const stale=inheritCompatibleFields(scope,{province_id_label:'Nome antigo'},source,null);
assert.equal(stale.patch.province_id,'p1');assert.equal(stale.patch.province_id_label,'Sofala');
});
test('investment entry selects the existing simulation route without replacing an earlier deliberate purpose', () => {
const scope='dossier:A:simulator';
const source={journey_type:'investor',journey_type_label:'Avaliar o meu investimento',purpose:'simulate_only',purpose_label:'Apenas simular'};
const first=inheritCompatibleFields(scope,{},source,null);
const values={...first.patch,context_reuse:first.metadata};
assert.equal(reusedInvestmentValue(scope,'journey_type',values),'Avaliar o meu investimento');
assert.equal(reusedInvestmentValue(scope,'purpose',values),'Apenas simular');
const old=inheritCompatibleFields(scope,{journey_type:'small_business',purpose:'seek_partner'},source,null);
assert.deepEqual(old.patch,{});
assert.equal(reusedInvestmentValue(scope,'purpose',{purpose:'seek_partner',context_reuse:old.metadata}),null);
});
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
const saved={...first.patch,context_reuse:first.metadata};