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 && ( + + )}