From 5bda74aedc905ff945d6cc796ec87ca46d32ebe2 Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 23 Oct 2026 14:13:58 +0200 Subject: [PATCH] Adapt land status to saved investment land need --- app/Services/InvestorContext.php | 14 +++++ app/Services/SimulationFormDrafts.php | 33 +++++++++++- .../unification/traceability-and-manual.md | 15 +++++- resources/js/components/simulation-form.tsx | 11 ++++ resources/js/lib/context-inheritance.ts | 6 +++ .../js/lib/investment-land-preparation.ts | 19 +++++++ resources/js/lib/project-dossier.ts | 7 +++ resources/js/lib/simulation-form.ts | 3 +- .../Feature/InvestmentCorridorContextTest.php | 33 ++++++++++++ .../investment-land-preparation.test.mjs | 54 +++++++++++++++++++ 10 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 resources/js/lib/investment-land-preparation.ts create mode 100644 tests/frontend/investment-land-preparation.test.mjs diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php index 8f16b6bf..cdeb8c09 100644 --- a/app/Services/InvestorContext.php +++ b/app/Services/InvestorContext.php @@ -563,6 +563,7 @@ final class InvestorContext $case = $this->desk->case($actor, $id); abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403); $ctx = $this->state($case); + $ctx['land_preparation'] = $this->landPreparation($case); $path = app(InvestorPath::class)->view($actor, $id); $ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'], 'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))]; @@ -591,6 +592,14 @@ final class InvestorContext return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all(); } + public function landPreparation(\stdClass $case): array + { + $path = $this->desk->decode($case->payload)['investor_path'] ?? []; + $need = $path['facts']['land_needed'] ?? 'unknown'; + + return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $path['fact_reasons']['land_needed'] ?? '']; + } + public function prefill(array $ctx): array { $f = $ctx['fields']; @@ -598,6 +607,10 @@ final class InvestorContext // 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']; + if (($ctx['land_preparation']['need'] ?? null) === 'yes') { + $tenure = ['duat' => ['duat', 'DUAT'], 'lease' => ['leased', 'Arrendado']][$f['site_tenure'] ?? ''] ?? null; + if ($tenure) [$sim['land_status'], $sim['land_status_label']] = $tenure; + } // 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) { @@ -613,6 +626,7 @@ final class InvestorContext 'opportunity_choices' => $ctx['opportunity_choices'] ?? [], 'requirements_assessment' => $ctx['requirements_assessment'] ?? null, 'path' => $ctx['path_applicability'] ?? null, + 'land_preparation' => $ctx['land_preparation'] ?? null, 'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null, 'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']), 'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.', diff --git a/app/Services/SimulationFormDrafts.php b/app/Services/SimulationFormDrafts.php index c7d222ea..8ec41ed7 100644 --- a/app/Services/SimulationFormDrafts.php +++ b/app/Services/SimulationFormDrafts.php @@ -60,9 +60,11 @@ final class SimulationFormDrafts throw new HttpException(409, 'Já existe um rascunho activo. Carregue-o antes de guardar novas alterações.'); } + if ($case) $data['economic_snapshot'] = $this->landContext($data['economic_snapshot'], $case); + $snapshotHash = hash('sha256', json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); $draft = SimulationFormDraft::query()->create([ 'id' => (string) Str::uuid(), 'owner_user_id' => $actorId, 'anonymous_token_hash' => $tokenHash, - 'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $payloadHash, + 'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $snapshotHash, 'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']), 'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']), 'expires_at' => $actorId === null ? now()->addDays(30) : null, @@ -89,6 +91,7 @@ final class SimulationFormDrafts $this->checkContext($request, $draft); $this->requireMatch($request, $draft); $previousEconomic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot); + $data['economic_snapshot'] = $this->preserveInactiveLandStatus($draft, $data['economic_snapshot'], $previousEconomic, $actorId); if (($data['economic_snapshot']['reuse_simulation'] ?? null) === 'no') { unset($data['economic_snapshot']['simulation_sources']); } elseif (is_array($previousEconomic['simulation_sources'] ?? null)) { @@ -456,6 +459,34 @@ final class SimulationFormDrafts return $query; } + private function preserveInactiveLandStatus(SimulationFormDraft $draft, array $economic, array $previous, ?int $actorId): array + { + if (! $actorId || ! config('investor-path.enabled') || ! app(VirtualDesk::class)->installed()) return $economic; + // Use the real owner-bound dossier even if a request omits its context parameter. + $case = DB::table('desk_cases')->where('simulation_id', $draft->id)->where('user_id', $actorId)->first(); + if (! $case) return $economic; + return $this->landContext($economic, $case, $previous); + } + + private function landContext(array $economic, \stdClass $case, ?array $previous = null): array + { + $decision = app(InvestorContext::class)->landPreparation($case); + if ($decision['need'] !== 'yes' && $previous !== null) { + if (array_key_exists('land_status', $previous)) $economic['land_status'] = $previous['land_status']; + else unset($economic['land_status']); + if (isset($previous['selection_labels']['land_status'])) $economic['selection_labels']['land_status'] = $previous['selection_labels']['land_status']; + else unset($economic['selection_labels']['land_status']); + } + $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true); + if (! is_array($snapshot) || ($snapshot['version'] ?? null) !== 1) { + $snapshot = ['version' => 1, 'context_revision' => app(InvestorContext::class)->state($case)['revision']]; + } + $snapshot['land_preparation'] = $decision; + $economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + + return $economic; + } + private function checkContext(Request $request, SimulationFormDraft $draft): void { $context = $request->input('context'); diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 41dbdcf2..a6e8f59a 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,5 +1,17 @@ # Rastreabilidade, utilização e formação +## Situação do terreno acompanha a necessidade de terra — delta QA, 23/10/2026 + +Origem: decisão expressa da Tempo após `f2150b19`. No simulador ligado ao mesmo dossier, **Não** oculta Situação do terreno sem a exigir; **Sim** apresenta o campo com o valor já guardado e permite alterá-lo; **Por definir** identifica a pendência e permite guardar sem resposta. O simulador autónomo conserva o comportamento anterior. Não se infere a necessidade de terra da localização, do corredor ou da escolha de um documento. + +A decisão vem de `investor_path.facts.land_needed`, já existente. O contexto de preparação transporta-a na referência guardada do simulador; versões anteriores recuperam a aplicabilidade S10. Informação ausente ou inválida permanece por definir. Arrendamento/DUAT já declarados no dossier podem preencher a situação correspondente quando a necessidade é Sim, sem sobrescrever uma edição deliberada no simulador. A área das instalações e os requisitos documentais/jurídicos não são alterados por inferência. + +Gravação e validação: Situação do terreno continua opcional, sem criar obrigatoriedade para Sim nem para Por definir. Quando fornecida, conserva a validação das opções existentes. Na actualização do rascunho, o servidor consulta o dossier real vinculado ao seu titular, mesmo sem o parâmetro `context`, e conserva o valor e o nome anteriores enquanto o campo estiver oculto. A decisão guardada na referência é sincronizada com esse dossier; uma referência antiga não muda a decisão real. Recuperar Sim volta a mostrar o mesmo valor, editável. Valores anteriores permanecem no rascunho cifrado; fontes, decisões, resultados históricos e relatórios já emitidos não são apagados ou reescritos. Sem migration e sem alteração na homologação. + +Relatórios e CSV do plano usam a mesma decisão: Sim apresenta a situação declarada ou a informação em falta; Não apresenta a explicação de não aplicabilidade à preparação actual, sem apresentar a resposta histórica como actual; Por definir apresenta a pendência. O modelo comum alimenta Word, impressão/PDF e restantes exportações do relatório. Os dados originais do rascunho permanecem conservados. + +Verificação apenas do delta: quatro testes `investment-land-preparation.test.mjs` (três estados, recuperação, versão anterior, referência inválida, validação, CSV e impressão), um teste API em base QA isolada com 32 asserções (gravação incompleta, decisão real, omissão do campo, recuperação e alteração após Sim); types/build aprovados. No navegador, exclusivamente no dossier sintético QA: Não ocultou o campo; Sim permitiu guardar DUAT; Por definir mostrou a pendência e permitiu guardar; regressar a Sim recuperou DUAT seleccionado e editável. A cobertura transversal continua parcial. Carteira e projectos âncora mantêm-se como incidente separado. + ## 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. @@ -15,7 +27,7 @@ No simulador, os dados conhecidos aparecem como resumos com **Alterar**. Complet 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. +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. A lacuna Situação do terreno identificada neste incremento foi tratada no delta QA descrito acima; o ocultamento de S10 em `7ca94967` não a tinha resolvido. 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 @@ -295,6 +307,7 @@ Incremento de apresentação: o dossier conserva as 19 etapas e o histórico, ma | ------------------------------ | ------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | | 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 | +| Situação do terreno adaptada | `simulation-form`, `investment-land-preparation`, `project-dossier`; `SimulationFormDrafts` | `land_needed` existente; situação e rótulo conservados no rascunho cifrado | APIs existentes de contexto/rascunho e exportações do plano | Tempo: Não oculta, Sim recupera e permite editar, Por definir identifica pendência sem bloquear rascunho | Delta QA 23/10/2026: 4 testes frontend e 32 asserções API; sem migration; homologação não alterada | | 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/simulation-form.tsx b/resources/js/components/simulation-form.tsx index e8956133..bd218e00 100644 --- a/resources/js/components/simulation-form.tsx +++ b/resources/js/components/simulation-form.tsx @@ -1,6 +1,7 @@ import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva'; import { SimulationMonthlyIva } from './simulation-monthly-iva'; import { inheritSimulatorFields, reusedInvestmentValue } from '@/lib/context-inheritance'; +import { investmentLandPreparation } from '@/lib/investment-land-preparation'; import { PublicDocumentContent, type PublicDocument, @@ -3283,6 +3284,16 @@ export function SimulationForm() { : 'Avalie um projecto definido. A análise fiscal acompanha os fluxos financeiros, mas não prova rentabilidade nem concede incentivos.'} )} + {current.id === 'location' && contextId && ( + + )}
{fields.map((field) => { const fieldRequired = diff --git a/resources/js/lib/context-inheritance.ts b/resources/js/lib/context-inheritance.ts index 30eb5c12..165b0736 100644 --- a/resources/js/lib/context-inheritance.ts +++ b/resources/js/lib/context-inheritance.ts @@ -19,6 +19,12 @@ 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 === 'context_applicability_snapshot') { + // Read-only assessment of the current dossier, not a deliberate form answer. + if (signature(values[key]) !== signature(value)) patch[key] = value; + next[key] = signature(value); inherited.push(key); + continue; + } if (key.endsWith('_label')) { const base = key.slice(0, -6); const baseOwned = previous[base] === signature(values[base]); diff --git a/resources/js/lib/investment-land-preparation.ts b/resources/js/lib/investment-land-preparation.ts new file mode 100644 index 00000000..8f447d22 --- /dev/null +++ b/resources/js/lib/investment-land-preparation.ts @@ -0,0 +1,19 @@ +export type LandPreparation = { need: 'yes' | 'no' | 'unknown'; reason: string }; + +/** Preparation decision from the same saved dossier, never evidence of a land right. */ +export function investmentLandPreparation(raw: unknown): LandPreparation { + try { + const snapshot = JSON.parse(String(raw ?? '')); + if (snapshot.version !== 1) return { need: 'unknown', reason: '' }; + const decision = snapshot.land_preparation; + if (decision && ['yes', 'no', 'unknown'].includes(decision.need)) + return { need: decision.need, reason: typeof decision.reason === 'string' ? decision.reason : '' }; + // Existing snapshots already carry S10 applicability; no reimport is required. + const step = Array.isArray(snapshot.path?.steps) ? snapshot.path.steps.find((row: { code?: string } | null) => row?.code === 'S10') : null; + return { need: step?.applicability === 'not_needed_for_preparation' ? 'no' : step?.applicability === 'applicable' ? 'yes' : 'unknown', reason: typeof step?.reason === 'string' ? step.reason : '' }; + } catch { return { need: 'unknown', reason: '' }; } +} + +export function landStatusVisible(draft: Record): boolean { + return !draft.context_applicability_snapshot || investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes'; +} diff --git a/resources/js/lib/project-dossier.ts b/resources/js/lib/project-dossier.ts index d29b45e9..fe2e7ea2 100644 --- a/resources/js/lib/project-dossier.ts +++ b/resources/js/lib/project-dossier.ts @@ -1,4 +1,5 @@ import { displayValue } from './display-value.ts'; +import { investmentLandPreparation } from './investment-land-preparation.ts'; import { monthlyIvaInput, monthlyIvaKey, readMonthlyIvaRecord, readMonthlyIvaSettings } from './monthly-iva.ts'; import { modeloAPurchaseReferences } from './iva-modelo-a.ts'; import { fiscalQaRows } from './fiscal-qa-preview.ts'; @@ -686,6 +687,12 @@ export function buildProjectDossierRows( ]); } } + if (draft.context_applicability_snapshot) { + const land = investmentLandPreparation(draft.context_applicability_snapshot); + rows.push(['Localização', 'Necessidade de terra', land.need === 'no' ? 'Não' : land.need === 'yes' ? 'Sim' : 'Por definir', land.reason || 'Decisão de preparação do dossier; não comprova direitos sobre a terra']); + if (land.need === 'yes') rows.push(['Localização', 'Situação do terreno', String(draft.land_status_label ?? ({ owned: 'Próprio', duat: 'DUAT', leased: 'Arrendado', requested: 'Pedido em curso', unknown: 'Por determinar' } as Record)[String(draft.land_status)] ?? 'Informação em falta'), 'Informação declarada; não comprova título ou disponibilidade']); + else rows.push(['Localização', 'Preparação do terreno', land.need === 'no' ? 'Situação do terreno não aplicável à preparação actual; valores anteriores conservados.' : 'Definir se precisa de terra; pode continuar a preparar o investimento.', 'Sem inferência de dispensa legal']); + } return rows; } diff --git a/resources/js/lib/simulation-form.ts b/resources/js/lib/simulation-form.ts index e9b3e6aa..7afc4e2a 100644 --- a/resources/js/lib/simulation-form.ts +++ b/resources/js/lib/simulation-form.ts @@ -1,4 +1,5 @@ import { inheritSimulatorFields } from './context-inheritance.ts'; +import { landStatusVisible } from './investment-land-preparation.ts'; import { csvCell } from './csv.js'; import { fiscalQaRows } from './fiscal-qa-preview.ts'; import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts'; @@ -738,7 +739,7 @@ export const simulationFieldGroups: Record< label: 'Situação do terreno', options: 'land_status', showWhen: (d) => - d.purpose !== 'simulate_only' || isInvestorProjectJourney(d), + landStatusVisible(d) && (d.purpose !== 'simulate_only' || isInvestorProjectJourney(d)), }, { key: 'area_required', diff --git a/tests/Feature/InvestmentCorridorContextTest.php b/tests/Feature/InvestmentCorridorContextTest.php index c528cfc6..df079128 100644 --- a/tests/Feature/InvestmentCorridorContextTest.php +++ b/tests/Feature/InvestmentCorridorContextTest.php @@ -30,6 +30,39 @@ 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_land_status_uses_the_real_decision_preserves_inactive_values_and_allows_incomplete_drafts(): void + { + $id=$this->postJson('/investimentos',['title'=>'QA situação do terreno','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id'); + $setNeed=function(string $need) use($id): array { + $version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'); + $this->postJson('/investimentos/'.$id.'/aplicabilidade',['version'=>$version,'land_needed'=>$need,'external_funding'=>'unknown','viability'=>'unknown','reason'=>'Cenário QA '.$need])->assertOk(); + return $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json(); + }; + $view=$setNeed('yes'); + $body=['context'=>$id,'personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','context_applicability_snapshot'=>$view['prefill']['simulator']['context_applicability_snapshot'],'land_status'=>'duat','selection_labels'=>['land_status'=>'DUAT']]]; + $created=$this->postJson('/api/v1/simulation-drafts',$body,['Idempotency-Key'=>(string)Str::uuid()])->assertCreated(); + $draft=$created->json('data.id');$etag=$created->headers->get('ETag'); + foreach(['no','unknown'] as $need){ + $view=$setNeed($need); + $this->assertSame($need,json_decode($view['prefill']['simulator']['context_applicability_snapshot'],true)['land_preparation']['need']); + $update=$this->putJson('/api/v1/simulation-drafts/'.$draft,['personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','context_applicability_snapshot'=>$body['economic_snapshot']['context_applicability_snapshot']]],['If-Match'=>$etag])->assertOk(); + $recovered=$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk(); + // Omitting context cannot bypass the real dossier decision. Hidden values are retained. + $this->assertSame('duat',$recovered->json('data.economic_snapshot.land_status')); + $this->assertSame('DUAT',$recovered->json('data.economic_snapshot.selection_labels.land_status')); + $this->assertSame($need,json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'),true)['land_preparation']['need']); + $etag=$update->headers->get('ETag'); + } + $setNeed('yes'); + $this->flushSession();$this->actingAs($this->investor); + $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status','duat'); + $updated=$this->putJson('/api/v1/simulation-drafts/'.$draft,[...$body,'economic_snapshot'=>['purpose'=>'simulate_only','land_status'=>'leased']],['If-Match'=>$etag])->assertOk(); + $recovered=$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status','leased'); + $this->assertSame('yes',json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'),true)['land_preparation']['need']); + $this->putJson('/api/v1/simulation-drafts/'.$draft,[...$body,'economic_snapshot'=>['purpose'=>'simulate_only','land_status'=>'invalid']],['If-Match'=>$updated->headers->get('ETag')])->assertUnprocessable(); + $empty=app(\App\Services\InvestorContext::class)->prefill(['fields'=>['site_tenure'=>'lease'],'revision'=>0,'provenance'=>[],'land_preparation'=>['need'=>'yes','reason'=>'QA']]); + $this->assertSame('leased',$empty['simulator']['land_status']); + } 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'); diff --git a/tests/frontend/investment-land-preparation.test.mjs b/tests/frontend/investment-land-preparation.test.mjs new file mode 100644 index 00000000..906583f8 --- /dev/null +++ b/tests/frontend/investment-land-preparation.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { investmentLandPreparation, landStatusVisible } from '../../resources/js/lib/investment-land-preparation.ts'; +import { simulationFieldGroups, validateSimulationDraft, partitionSimulationDraft, restoreSimulationDraft } from '../../resources/js/lib/simulation-form.ts'; +import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts'; +import { buildProjectDossierRows, exportProjectDossierCsv } from '../../resources/js/lib/project-dossier.ts'; +import { createProjectDossierReport, createProjectDossierPrintHtml } from '../../resources/js/lib/project-dossier-report.ts'; + +const snapshot = (need) => JSON.stringify({version:1,context_revision:2,land_preparation:{need,reason:'Decisão guardada no dossier'}}); +const draft = (need) => ({purpose:'simulate_only',journey_type:'investor',land_status:'duat',land_status_label:'DUAT',context_applicability_snapshot:snapshot(need)}); + +test('no hides land status and yes restores the same saved value; unknown is a pending decision without a required answer', () => { + const field=simulationFieldGroups.location.find(field=>field.key==='land_status'); + for(const need of ['no','yes','unknown']) { + const input=draft(need);const before=JSON.stringify(input); + assert.equal(field.showWhen(input),need==='yes'); + assert.equal(Boolean(validateSimulationDraft({...input,land_status:''},'location').land_status),false); + const saved=partitionSimulationDraft(input);const recovered=restoreSimulationDraft(saved.personal_snapshot,saved.economic_snapshot); + assert.equal(recovered.land_status,'duat');assert.equal(recovered.land_status_label,'DUAT'); + assert.equal(investmentLandPreparation(recovered.context_applicability_snapshot).need,need); + assert.equal(JSON.stringify(input),before); + } +}); + +test('old snapshots use S10 and absent or malformed decisions never imply yes or no', () => { + for(const [applicability,need] of [['applicable','yes'],['not_needed_for_preparation','no'],['undetermined','unknown']]) + assert.equal(investmentLandPreparation(JSON.stringify({version:1,path:{steps:[{code:'S10',applicability}]}})).need,need); + for(const raw of ['', 'invalid', '{}', '{"version":1,"land_preparation":{"need":"invalid"}}']) + assert.equal(investmentLandPreparation(raw).need,'unknown'); + assert.equal(landStatusVisible({}),true); // Standalone simulator preserves its previous behaviour. +}); + +test('current dossier assessment refreshes after server recovery without overwriting the saved land answer', () => { + const scope='dossier:A:simulator'; + const inherited=inheritCompatibleFields(scope,{}, {context_applicability_snapshot:snapshot('yes'),land_status:'duat'},null); + const recovered={...inherited.patch,context_reuse:inherited.metadata,context_applicability_snapshot:snapshot('no')}; + const restored=inheritCompatibleFields(scope,recovered,{context_applicability_snapshot:snapshot('yes')},recovered.context_reuse); + assert.equal(restored.patch.context_applicability_snapshot,snapshot('yes')); + assert.equal(Object.hasOwn(restored.patch,'land_status'),false); +}); + +test('report, CSV and print reflect yes, no and unknown without treating historical DUAT as current when hidden', async () => { + for(const need of ['yes','no','unknown']) { + const input=draft(need);const before=JSON.stringify(input); + const rows=buildProjectDossierRows(input); + assert.equal(rows.some(row=>row[1]==='Situação do terreno'),need==='yes'); + const report=createProjectDossierReport(input,{selectedSections:new Set(['location'])}); + const print=await createProjectDossierPrintHtml(report,[],false); + const csv=exportProjectDossierCsv(input); + if(need==='yes'){assert.match(print,/DUAT/);assert.match(csv,/DUAT/);} + else {assert.doesNotMatch(print,/DUAT/);assert.doesNotMatch(csv,/DUAT/);assert.match(print,need==='no'?/valores anteriores conservados/:/Definir se precisa de terra/);} + assert.equal(JSON.stringify(input),before); + } +});