diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index 5ba97cd8..8f16b6bf 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -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'], diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 5e6da8c4..41dbdcf2 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -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 | diff --git a/resources/js/components/dossier-prefill.tsx b/resources/js/components/dossier-prefill.tsx index 417a5336..38e28388 100644 --- a/resources/js/components/dossier-prefill.tsx +++ b/resources/js/components/dossier-prefill.tsx @@ -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, 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" >

- Mesmo investimento · contexto v{view.context.revision} + Dados do seu investimento

- UUID {contextId} ·{' '} - recuperar dossier + {view.case.title} - . Mestres, declarações deste formulário e conteúdo já submetido - mantêm-se separados. + {' · '}Voltar ao dossier

{stale && (

{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 && (

- Fonte disponível:{' '} {r.title} - {' '} - · {r.kind} · v{r.version ?? 'não exposta'}. {r.key} +

), )} @@ -214,11 +214,10 @@ export function DossierPrefill(props: Props) { - {text(props.values[k]) || - 'não informado'} + {displayField(props.values, k)} - {text(source[k]) || 'não informado'} + {displayField(source, k)} ))} @@ -232,17 +231,20 @@ export function DossierPrefill(props: Props) { onClick={() => { const patch: Record = {}; 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) { )} - {!readOnly && reviewed &&

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.

} + {!readOnly && reviewed &&

Os dados disponíveis foram recuperados. Preencha o que falta; as suas alterações conservam-se.

}

Associar uma referência não confirma incentivos, licenças, terra disponível, reserva ou autorização. diff --git a/resources/js/components/simulation-form.tsx b/resources/js/components/simulation-form.tsx index 100dafe5..e8956133 100644 --- a/resources/js/components/simulation-form.tsx +++ b/resources/js/components/simulation-form.tsx @@ -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({}); + const [editingReused, setEditingReused] = useState([]); 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 ( +

+

{field.label}

+

{reused}

+

Recuperado do dossier.

+ +
+ ); const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) =>

{ivaIssueText(issue)}

); if (field.options) { const parentKey = diff --git a/resources/js/lib/context-inheritance.ts b/resources/js/lib/context-inheritance.ts index 6a93ed5c..30eb5c12 100644 --- a/resources/js/lib/context-inheritance.ts +++ b/resources/js/lib/context-inheritance.ts @@ -19,6 +19,16 @@ export function inheritCompatibleFields(scope:string, values:Record(!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>)?.[0]?.[field] ?? ''])) : item; const owned=previous[key]===signature(track(current)); @@ -42,6 +52,20 @@ export function inheritCompatibleFields(scope:string, values:Record): 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>(draft:T): T { const source:Record={}; const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null; diff --git a/tests/Feature/InvestmentCorridorContextTest.php b/tests/Feature/InvestmentCorridorContextTest.php index 93087948..c528cfc6 100644 --- a/tests/Feature/InvestmentCorridorContextTest.php +++ b/tests/Feature/InvestmentCorridorContextTest.php @@ -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'); diff --git a/tests/frontend/context-inheritance.test.mjs b/tests/frontend/context-inheritance.test.mjs index 41259c8f..d04a3829 100644 --- a/tests/frontend/context-inheritance.test.mjs +++ b/tests/frontend/context-inheritance.test.mjs @@ -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};