Adapt land status to saved investment land need

This commit is contained in:
2026-10-23 14:13:58 +02:00
parent f2150b1912
commit 5bda74aedc
10 changed files with 192 additions and 3 deletions
+14
View File
@@ -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.',
+32 -1
View File
@@ -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');
@@ -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 |
@@ -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.'}
</output>
)}
{current.id === 'location' && contextId && (
<aside className="rounded border p-3" aria-label="Necessidade de terra">
<p>{investmentLandPreparation(draft.context_applicability_snapshot).need === 'no'
? 'Não precisa de terra neste investimento. A situação do terreno não é pedida; os valores anteriores ficam conservados.'
: investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes'
? 'Precisa de terra neste investimento. Reveja a situação do terreno já preenchida ou indique-a quando disponível.'
: 'Falta definir se precisa de terra. Pode guardar o rascunho e continuar sem preencher a situação do terreno.'}</p>
<a className="underline" href={`/investimentos/${contextId}`}>Rever a necessidade de terra no dossier</a>
</aside>
)}
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => {
const fieldRequired =
+6
View File
@@ -19,6 +19,12 @@ 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 === '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]);
@@ -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<string, unknown>): boolean {
return !draft.context_applicability_snapshot || investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes';
}
+7
View File
@@ -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, string>)[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;
}
+2 -1
View File
@@ -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',
@@ -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');
@@ -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);
}
});