From 3d877ec7450adeb711d6d3690e1c6a6554c6e2fe Mon Sep 17 00:00:00 2001
From: nala
Date: Fri, 23 Oct 2026 14:35:50 +0200
Subject: [PATCH] Preserve selected portfolio context through investment
preparation
---
.../Controllers/InvestorContextController.php | 33 +++++--
app/Services/InvestmentOpportunityChoices.php | 33 ++++++-
app/Services/InvestorContext.php | 13 ++-
.../unification/traceability-and-manual.md | 24 ++++-
resources/js/components/dossier-prefill.tsx | 7 +-
.../investment-opportunity-choices.tsx | 3 +-
resources/js/lib/investor-context.ts | 1 +
resources/js/lib/project-dossier.ts | 12 +++
resources/js/lib/search-territory.ts | 10 ++
resources/js/pages/investment/detail.tsx | 6 +-
.../js/pages/investor-path/context-start.tsx | 10 +-
resources/js/pages/investor-path/context.tsx | 9 +-
.../InvestmentOpportunityChoicesTest.php | 7 +-
.../InvestmentPortfolioContinuityTest.php | 95 +++++++++++++++++++
.../investment-portfolio-continuity.test.mjs | 25 +++++
tests/frontend/search-territory.test.mjs | 11 ++-
16 files changed, 271 insertions(+), 28 deletions(-)
create mode 100644 tests/Feature/InvestmentPortfolioContinuityTest.php
create mode 100644 tests/frontend/investment-portfolio-continuity.test.mjs
diff --git a/app/Http/Controllers/InvestorContextController.php b/app/Http/Controllers/InvestorContextController.php
index cd648ede..12b289d1 100644
--- a/app/Http/Controllers/InvestorContextController.php
+++ b/app/Http/Controllers/InvestorContextController.php
@@ -11,6 +11,19 @@ use Inertia\Inertia;
final class InvestorContextController extends Controller
{
+ private function entryChoices(Request $r, InvestorContext $context): array
+ {
+ $data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1',
+ 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
+ $source = !empty($data['source']) ? $context->reference($data['source']) : null;
+ $ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []);
+ abort_if(!empty($data['from_dossier']) && !$ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
+ $path = app(InvestmentGeography::class)->selection($data);
+ $input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
+ if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']];
+ return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
+ }
+
public function current(Request $r, InvestorContext $context)
{
return response()->json($context->current($r->user()));
@@ -51,22 +64,27 @@ final class InvestorContextController extends Controller
$data = $context->view($r->user(), $id);
abort_unless($data['can_write'], 403);
- $ids = $r->validate(['opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1'])['opportunities'] ?? [];
- $choices = $ids ? app(\App\Services\InvestmentOpportunityChoices::class)->resolve(array_map(fn ($id) => ['source' => 'opportunity:'.$id], $ids)) : null;
+ $entries = $this->entryChoices($r, $context);
+ $choices = $entries ?: null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
$explicitChoices=$choices;
$selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null;
$entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:'');
$entrySources=array_map($entryKey,$explicitChoices??[]);
- if ($choices !== null) $choices = collect([...($data['context']['opportunity_choices'] ?? []), ...$choices])->unique(fn ($choice) => $choice['opportunity']['key'])->values()->all();
+ if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
$draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
$new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true));
- $choices = collect([...$recovered, ...$new])->unique(fn($c)=>$c['opportunity']['key'])->values()->all();
+ $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
}
- return Inertia::render('investor-path/context', ['entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
+ $differences = [];
+ foreach ($explicitChoices ?? [] as $entry) {
+ $kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
+ if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
+ }
+ return Inertia::render('investor-path/context', ['entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
}
public function preview(Request $r, string $id, InvestorContext $context)
@@ -84,9 +102,8 @@ final class InvestorContextController extends Controller
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
- $ids = $r->validate(['opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1'])['opportunities'] ?? [];
- $choices = $ids ? app(\App\Services\InvestmentOpportunityChoices::class)->resolve(array_map(fn ($id) => ['source' => 'opportunity:'.$id], $ids)) : [];
- return Inertia::render('investor-path/context-start', ['opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
+ $choices = $this->entryChoices($r, $context);
+ return Inertia::render('investor-path/context-start', ['entryQuery' => $r->only(['from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install)
diff --git a/app/Services/InvestmentOpportunityChoices.php b/app/Services/InvestmentOpportunityChoices.php
index 807b89ab..1e2f05d9 100644
--- a/app/Services/InvestmentOpportunityChoices.php
+++ b/app/Services/InvestmentOpportunityChoices.php
@@ -2,11 +2,41 @@
namespace App\Services;
+use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity;
+use Illuminate\Support\Facades\DB;
/** Multiple identified references in the existing encrypted investment context. */
final class InvestmentOpportunityChoices
{
+ public function portfolioOrigins(array $ids, int $opportunity): array
+ {
+ $origins = [];
+ foreach (array_unique($ids) as $id) {
+ $record = InvestmentPortfolioController::visible()->addSelect('version_no')->findOrFail($id);
+ $kind = null;
+ foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
+ if ($record->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $opportunity)->where($foreign, $record->$foreign)->exists()) $kind = rtrim($relation, 's');
+ }
+ abort_unless($kind, 422, 'A associação escolhida não pertence a esta oportunidade.');
+ $origins[] = ['id' => $record->id, 'title' => $record->title, 'kind' => $kind, 'href' => '/carteira/'.$record->id,
+ 'version' => $record->version_no, 'sha256' => hash('sha256', json_encode([$record->title, $record->source, $record->content], JSON_THROW_ON_ERROR))];
+ }
+ return $origins;
+ }
+
+ /** Add explicitly consulted associations without resetting saved geography or deliberate answers. */
+ public function mergeEntries(array $saved, array $entries): array
+ {
+ foreach ($entries as $entry) {
+ $index = array_search($entry['opportunity']['key'], array_column(array_column($saved, 'opportunity'), 'key'), true);
+ if ($index === false) { $saved[] = $entry; continue; }
+ $origins = collect([...($saved[$index]['portfolio_origins'] ?? []), ...($entry['portfolio_origins'] ?? [])])->unique('id')->values()->all();
+ if ($origins) $saved[$index]['portfolio_origins'] = $origins;
+ }
+ return $saved;
+ }
+
public function resolve(array $choices, array $previous = [], bool $pending = false): array
{
$result = [];
@@ -15,6 +45,7 @@ final class InvestmentOpportunityChoices
abort_unless($ref['kind'] === 'opportunity', 422, 'Seleccione uma ficha de oportunidade.');
$record = Opportunity::findOrFail($ref['id']);
$old = collect($previous)->first(fn ($row) => $row['opportunity']['key'] === $ref['key']);
+ $origins = $this->portfolioOrigins($choice['portfolio_origins'] ?? array_column($old['portfolio_origins'] ?? [], 'id'), (int) $record->id);
$corridors = app(InvestmentCorridorContext::class);
$corridor = $choice['corridor_key'] ?? $old['corridor_key'] ?? $corridors->key($record);
$provinces = $corridor ? $corridors->provinces($corridor) : [];
@@ -33,7 +64,7 @@ final class InvestmentOpportunityChoices
}
$location = $territory ? app(InvestorContext::class)->reference($territory) : null;
abort_unless($location === null || $location['kind'] === 'territory', 422);
- $result[] = ['opportunity' => $ref, 'territory' => $location,
+ $result[] = ['opportunity' => $ref, 'territory' => $location, ...($origins ? ['portfolio_origins' => $origins] : []),
'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null,
'allowed_provinces' => array_map(fn($p)=>(array)$p, $provinces),
'territory_path' => $location ? array_map(fn ($node) => \Illuminate\Support\Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [],
diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php
index cdeb8c09..bf201328 100644
--- a/app/Services/InvestorContext.php
+++ b/app/Services/InvestorContext.php
@@ -52,6 +52,7 @@ final class InvestorContext
$ref = $this->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422);
$record = Opportunity::findOrFail($ref['id']);
+ app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id);
$key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record);
if (!empty($choice['territory'])) {
$location = $this->reference($choice['territory']);
@@ -118,6 +119,7 @@ final class InvestorContext
public function dependencyHash(array $selection): string
{
+ if (isset($selection['opportunity_choices'])) $selection['opportunity_choices'] = array_map(fn($choice)=>Arr::except($choice,['portfolio_origins']),$selection['opportunity_choices']);
return hash('sha256', json_encode(Arr::sortRecursive($selection), JSON_THROW_ON_ERROR));
}
@@ -339,7 +341,8 @@ final class InvestorContext
}
return Validator::make($input, [...$inputRules,
- 'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities,corridor_key',
+ 'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities,corridor_key,portfolio_origins',
+ 'opportunity_choices.*.portfolio_origins' => 'sometimes|array|max:100', 'opportunity_choices.*.portfolio_origins.*' => 'uuid',
'opportunity_choices.*.corridor_key' => ['sometimes', 'nullable', Rule::in(array_keys(config('investment_corridors')))],
'opportunity_choices.*.activities' => 'sometimes|array', 'opportunity_choices.*.activities.*' => 'string|distinct|in:production,processing,trade,transport,water,forestry,animal',
'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100',
@@ -473,11 +476,13 @@ final class InvestorContext
}
$changedFields = array_column($replaced, 'field');
$choicesChanged = $choices !== ($old['opportunity_choices'] ?? []);
- if ($choicesChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
+ $choiceInputsChanged = array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$choices) !== array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$old['opportunity_choices']??[]);
+ if ($choiceInputsChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
+ elseif ($choicesChanged) $changed[] = 'portfolio_origins';
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
- $dependencyChanged = $choicesChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
+ $dependencyChanged = $choiceInputsChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
- $calculationInputsChanged = $choicesChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]));
+ $calculationInputsChanged = $choiceInputsChanged || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]));
$payload = $this->desk->decode($case->payload);
$results = $payload['investor_path']['results'] ?? [];
$requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all();
diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md
index 13f48ea4..057da369 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -1,5 +1,27 @@
# Rastreabilidade, utilização e formação
+## Continuidade carteira → oportunidade → dossier → simulador — incremento QA, 23/10/2026
+
+Origem: pedido expresso da Tempo de conservar oportunidade, localização e associações escolhidas no mesmo percurso, sem repetir perguntas ou sobrescrever edições deliberadas. Complementa a entrega QA `6948ed62`; não instala esse conjunto nem este incremento na homologação.
+
+Os dois botões Preparar investimento da oportunidade passam a transportar a origem `from_dossier` já validada e os filtros territoriais escolhidos. A entrada mostra a oportunidade, localização proposta e associação da carteira antes de escolher/criar o dossier. As mesmas referências atravessam autenticação, rascunho por actor, recuperação, prévia, confirmação e a referência guardada do simulador. Permanecem no payload cifrado existente: sem migrations, papéis ou percurso paralelo. Posto/localidade não são inferidos quando faltam.
+
+Cada oportunidade conserva as suas próprias associações escolhidas (`portfolio_origins`), com identificador, título, versão, ligação e hash documental. O servidor só aceita fichas publicadas da carteira com ligação cadastrada à oportunidade. Não cria relações por semelhança de nome, corredor ou proximidade e não publica fichas privadas. É possível retirar uma associação pela interface sem retirar a oportunidade. Uma mesma associação pode acompanhar oportunidades diferentes, mantendo cada identidade e localização.
+
+Guia: abra uma oportunidade associada a partir da carteira e escolha Preparar investimento. Na entrada, confirme visualmente a oportunidade, localização recebida e associação; continue um dossier existente ou crie um novo. Reveja e guarde no editor existente. A confirmação retira da URL a intenção de entrada já consumida, para que actualizar recupere o contexto guardado, incluindo associações retiradas, em vez de reaplicar essa entrada. Abra Plano e simulador no mesmo dossier; os dados e referências continuam disponíveis, com os controlos existentes para editar divergências e exportar.
+
+Ao retomar uma oportunidade já existente num dossier, acrescentam-se as associações recebidas sem substituir localização, corredor ou actividades guardadas. Uma localização recebida diferente é apresentada como divergência, mantendo a anterior para revisão explícita. Confirmar associações gera a revisão/auditoria do contexto existente; alterar apenas a referência documental não reabre etapas nem invalida cálculos se as entradas efectivas não mudarem. A alteração de localização, actividade ou outros pressupostos conserva os controlos de impacto existentes.
+
+No simulador, o componente comum mostra as oportunidades, localizações e associações do dossier. Os campos recuperados continuam a usar os resumos editáveis da entrega anterior; respostas deliberadas do simulador não são substituídas. Relatórios e CSV do plano conservam cada oportunidade, o respectivo território e as associações escolhidas, com origem identificável, sem inferir incentivos ou autorização de execução.
+
+Verificação do delta e regressão directamente afectada: `InvestmentPortfolioContinuityTest` e `InvestmentOpportunityChoicesTest`, seis testes/151 asserções em base QA isolada com transacções revertidas; incluem retoma após autenticação, selecção múltipla, associação partilhada entre fichas, preservação de título/localização/actividades, rejeição de origem não relacionada/privada, confirmação e recuperação no rascunho do simulador, e alteração só de referência sem invalidar cálculos. O teste de transporte dos 34 usa agora o mesmo agrupamento `selection_labels` do navegador, sem alargar os campos aceites pela API. Frontend: cinco testes territoriais/entrada e um teste de continuidade/CSV/impressão. Types e build aprovados. As provas dos incrementos anteriores mantêm-se; não se voltou a importar, publicar ou conferir os 20 artigos, 34 oportunidades, 408 limites ou 29 fichas na homologação.
+
+Navegador QA: fixture identificada como SYNTHETIC QA, com oportunidade, âncora/associação e dossier próprios, sem atribuir a dados reais as associações do teste. Conferidos os parâmetros dos dois botões públicos, a recepção na entrada, escolha do dossier existente, rascunho guardado, prévia com Sofala/associação, confirmação e recuperação no simulador com a mesma referência. Os registos reais QA não foram reclassificados nem associados artificialmente para esta prova visual. Captura local `outputs/preservation-inventory-20261023/portfolio-dossier-simulator-continuity-QA.png`. Nenhuma escrita remota ou publicação real.
+
+**Causa e prevenção da perda anterior:** mantém-se o diagnóstico documental de `e65cb269` e o manifesto nominal da reposição auditada. A mudança do importador para rascunhos deixou de reconciliar a divulgação anteriormente aprovada noutro ambiente; não se conhece a data exacta da perda num endereço. Nas próximas entregas, conservar a visibilidade/aprovação dos exemplares existentes e conferir os IDs/títulos/estados contra o inventário aceite, antes/depois do transporte; um transporte privado não revoga a divulgação anterior. Não restaurar a base inteira nem reimportar/publicar automaticamente conteúdos novos para corrigir visibilidade. Esta é prevenção técnica conforme a instrução de preservação da Tempo, não um novo aprovador. A presença visual não substitui o manifesto, a proveniência e o recibo de reposição existentes.
+
+Saldo: visibilidade das 29 fichas confirmada no destino na passagem anterior; continuidade implementada neste recorte QA para associações efectivamente cadastradas. Fichas sem oportunidades associadas continuam a indicar essa lacuna; não foram preenchidas com relações presumidas. Homologação inalterada nesta passagem; aplicação transversal permanece parcial.
+
## Entrega QA reconciliada — preparação adaptativa, 23/10/2026
Conjunto funcional único, na ordem Git já existente: `7ca94967` → `f2150b19` → `5bda74ae`. A reconciliação documental `7d798579` conserva a evidência da carteira. Não fazer três instalações independentes, reaplicar patches ou substituir ficheiros por exemplares de cada candidata. Preservar estes commits e integrar o conjunto pelo processo Git de revisão existente.
@@ -339,7 +361,7 @@ Incremento de apresentação: o dossier conserva as 19 etapas e o histórico, ma
| 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 |
-| Continuidade carteira → oportunidade → dossier → simulador | Componentes de carteira, `investment/detail`, contexto existente | Origem documental e associações existentes, sem inferência | `from_dossier` na ficha de oportunidade; arranque do investimento existente | Mesmo percurso integrado; reposição não equivale a continuidade completa | Visibilidade: 29 IDs/títulos conferidos contra manifesto em 23/10. Lacuna concreta: `from_dossier` não acompanha a ligação Preparar investimento; continuidade privada por concluir |
+| Continuidade carteira → oportunidade → dossier → simulador | Componentes de carteira, `investment/detail`, contexto existente | Origem documental por oportunidade no payload cifrado; associações existentes, sem inferência | `from_dossier` e território no arranque; rascunho, prévia, confirmação e simulador existentes | Mesmo percurso integrado; origem relacionada/publicada; respostas deliberadas conservadas | Visibilidade: 29 IDs/títulos conferidos anteriormente. Delta QA: seis testes/151 asserções API, cinco testes de entrada territorial, um de relatório; instalação na homologação pendente |
| Ficha | `investment/detail` | Oportunidade, contexto, relações, fonte | `/oportunidades/{slug}`; `/api/v1/investment/opportunities/{slug}` | Visitante; rascunhos não acessíveis | `PublicPortalNavigationTest`, `InvestmentUnificationTest` |
| Mapa | Componente `Map` em catálogo | `geo_locations`, `geo_geometries`, `opportunity_locations` | `/api/v1/investment/map` | Visitante; dois níveis de classificação pública | Teste PostGIS e estados vazios |
| Comparar / exportar / sugerir | `investment/catalogue` | IDs publicados, dados de catálogo | `/compare`, `/export`, `/suggest` sob prefixo da API | Visitante; 4 comparações, 1.000 linhas/pontos; CSV protegido | Teste de publicação e fórmula CSV |
diff --git a/resources/js/components/dossier-prefill.tsx b/resources/js/components/dossier-prefill.tsx
index 38e28388..05594e3c 100644
--- a/resources/js/components/dossier-prefill.tsx
+++ b/resources/js/components/dossier-prefill.tsx
@@ -7,6 +7,7 @@ import {
type ContextView,
} from '@/lib/investor-context';
import { inheritCompatibleFields } from '@/lib/context-inheritance';
+import OpportunityChoices from './investment-opportunity-choices';
type Props = {
contextId?: string | null;
consumer: 'simulator' | 'land' | 'desk' | 'submission';
@@ -143,8 +144,10 @@ export function DossierPrefill(props: Props) {
)}
{message && {message}
}
- {Object.values(view.context.selection).map(
- (r) =>
+ {consumer === 'simulator' && !!view.context.opportunity_choices?.length && }
+ {Object.entries(view.context.selection)
+ .filter(([slot]) => consumer !== 'simulator' || !view.context.opportunity_choices?.length || !['opportunity', 'territory'].includes(slot))
+ .map(([, r]) =>
r && (
diff --git a/resources/js/components/investment-opportunity-choices.tsx b/resources/js/components/investment-opportunity-choices.tsx
index 7085d1b5..c13df87e 100644
--- a/resources/js/components/investment-opportunity-choices.tsx
+++ b/resources/js/components/investment-opportunity-choices.tsx
@@ -34,6 +34,7 @@ function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;
{!remove&&c.coverage&&
Abrangência: {c.coverage}
}
{notice&&{notice}
}
Localização: {c.territory?.title??'Por escolher'}
+ {!!c.portfolio_origins?.length && Associações escolhidas {c.portfolio_origins.map(origin=> {origin.title}{remove&&update({portfolio_origins:c.portfolio_origins?.filter(r=>r.id!==origin.id)})}>Retirar associação } )} }
{remove&&<>
Actividades que pretende realizar nesta oportunidade {Object.entries({production:'Produção agrícola',processing:'Transformação agroindustrial',trade:'Comercialização e exportação',transport:'Transporte e logística',water:'Captação/utilização de água',forestry:'Actividade florestal',animal:'Produção/serviços pecuários'}).map(([key,label])=> update({activities:e.target.checked?[...(c.activities??[]),key]:(c.activities??[]).filter(x=>x!==key)})}/>{label} )}
location(next)}/>
@@ -48,7 +49,7 @@ export default function OpportunityChoices({choices, onChange, disabled = false,
const update = (index: number, change: Partial) => onChange?.(choices.map((c,i) => i === index ? {...c,...change} : c));
return
Oportunidades escolhidas e localização de cada ficha
- Escolha o corredor e confirme onde pretende realizar o investimento.
+ {onChange?'Escolha o corredor e confirme onde pretende realizar o investimento.':'Escolhas guardadas no dossier; cada oportunidade conserva a sua localização e associações.'}
{choices.map((c,i)=>update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/>)}
;
}
diff --git a/resources/js/lib/investor-context.ts b/resources/js/lib/investor-context.ts
index 0a1dccdf..7add7f10 100644
--- a/resources/js/lib/investor-context.ts
+++ b/resources/js/lib/investor-context.ts
@@ -40,6 +40,7 @@ export type ContextReference = {
};
export type RequirementsAssessment = {version: string; fiscal_notice: string; requirements: {opportunity_key: string; opportunity_title: string; code: string; title: string; state: string; step: string; authority: string; documents: string[]; basis: string; source_url: string; decision: string}[]};
export type OpportunityChoice = {
+ portfolio_origins?: {id:string;title:string;kind:string;href:string;version:number;sha256:string}[];
corridor_key?: string | null;
corridor_name?: string | null;
allowed_provinces?: import('@/components/investment-territory').TerritoryOption[];
diff --git a/resources/js/lib/project-dossier.ts b/resources/js/lib/project-dossier.ts
index fe2e7ea2..79d08979 100644
--- a/resources/js/lib/project-dossier.ts
+++ b/resources/js/lib/project-dossier.ts
@@ -688,6 +688,18 @@ export function buildProjectDossierRows(
}
}
if (draft.context_applicability_snapshot) {
+ try {
+ const context = JSON.parse(String(draft.context_applicability_snapshot));
+ if (context.version === 1 && Array.isArray(context.opportunity_choices)) for (const choice of context.opportunity_choices) {
+ if (typeof choice?.opportunity?.title !== 'string') continue;
+ rows.push(['Localização', 'Oportunidade escolhida', choice.opportunity.title, 'Referência escolhida no mesmo dossier']);
+ rows.push(['Localização', 'Localização da oportunidade', typeof choice.territory?.title === 'string' ? choice.territory.title : 'Por definir', choice.opportunity.title]);
+ if (Array.isArray(choice.portfolio_origins)) for (const origin of choice.portfolio_origins) {
+ if (typeof origin?.title !== 'string') continue;
+ rows.push(['Localização', 'Associação escolhida na carteira', origin.title, choice.opportunity.title+'; referência documental consultada, sem inferir implantação ou benefícios']);
+ }
+ }
+ } catch { /* An unreadable context remains indeterminate; do not reconstruct associations. */ }
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']);
diff --git a/resources/js/lib/search-territory.ts b/resources/js/lib/search-territory.ts
index 6bcbbf65..cbe42034 100644
--- a/resources/js/lib/search-territory.ts
+++ b/resources/js/lib/search-territory.ts
@@ -1,6 +1,16 @@
const levels = ['province_id', 'district_id', 'admin_post_id', 'locality_id'];
const searchRoutes = ['/carteira', '/oportunidades', '/zonas', '/educacao'];
+/** Carry the explicitly consulted portfolio origin and search geography into the existing preparation entry. */
+export function investmentPreparationHref(opportunity: number, pageUrl: string, origin?: string | null): string {
+ const source = new URL(pageUrl, 'https://portal.invalid');
+ const inherited = source.searchParams.has('catalogue_query') ? new URLSearchParams(source.searchParams.get('catalogue_query')!) : source.searchParams;
+ const query = new URLSearchParams({source: `opportunity:${opportunity}`});
+ if (origin) query.set('from_dossier', origin);
+ for (const field of levels) if (inherited.get(field)) query.set(field, inherited.get(field)!);
+ return '/investimentos/iniciar?' + query.toString();
+}
+
/** URL-scoped search context, never a saved investment or another browser tab. */
export function searchTerritoryHref(href: string, pageUrl: string): string {
const base = 'https://portal.invalid';
diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx
index 571af389..7510ac46 100644
--- a/resources/js/pages/investment/detail.tsx
+++ b/resources/js/pages/investment/detail.tsx
@@ -1,5 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
-import { searchTerritoryHref } from '@/lib/search-territory';
+import { searchTerritoryHref, investmentPreparationHref } from '@/lib/search-territory';
import type { EducationalArticle } from '@/components/educational-article-card';
import { ClassificationSummary } from '@/components/opportunity-classification';
import { Link, router, usePage } from '@inertiajs/react';
@@ -35,7 +35,7 @@ export default function Detail({
{r.maturity_label === 'Em preparação' && Em preparação. Consulte a oportunidade e prepare o seu investimento. A execução depende das condições e autorizações aplicáveis ao projecto.
}
Iniciar ou continuar investimento com esta oportunidade
@@ -90,7 +90,7 @@ export default function Detail({
)}
-
+
Iniciar ou continuar o roteiro deste investimento
{auth.user ? (
diff --git a/resources/js/pages/investor-path/context-start.tsx b/resources/js/pages/investor-path/context-start.tsx
index 80fbf6fd..50153ac7 100644
--- a/resources/js/pages/investor-path/context-start.tsx
+++ b/resources/js/pages/investor-path/context-start.tsx
@@ -9,30 +9,32 @@ export default function ContextStart({
source,
opportunityChoices = [],
step = null,
+ entryQuery = {},
}: {
contexts: { id: string; title: string; version: number }[];
source: ContextReference | null;
opportunityChoices?: OpportunityChoice[];
step?: string | null;
+ entryQuery?: Record
;
}) {
const [chosen, setChosen] = useState('');
- const [title, setTitle] = useState(source?.title ?? '');
+ const [title, setTitle] = useState(source?.title ?? opportunityChoices[0]?.opportunity.title ?? '');
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [key] = useState(createOperationKey);
const sourceSuffix = opportunityChoices.length ? '?' + opportunityChoices.map(c => `opportunities[]=${encodeURIComponent(c.opportunity.id)}`).join('&') : source ? '?source=' + encodeURIComponent(source.key) : '';
- const suffix = sourceSuffix ? sourceSuffix+'&selection_key='+encodeURIComponent(key) : '';
+ const suffix = sourceSuffix ? sourceSuffix+'&selection_key='+encodeURIComponent(key)+'&'+new URLSearchParams(entryQuery).toString() : '';
return (
- {!!opportunityChoices.length && Oportunidades escolhidas {opportunityChoices.map(c => {c.opportunity.title} · {c.territory?.title ?? 'Localização por escolher'} )} Confirme a localização de cada ficha na comparação do dossier.
}
+ {!!opportunityChoices.length && Oportunidades escolhidas Confirme a localização de cada ficha na comparação do dossier. Ao continuar um dossier, as escolhas já guardadas conservam-se.
}
Ideia, oportunidade, sector e localização convergem no mesmo
dossier. A referência é para preparação e não confirma direitos
ou benefícios.
- {source && (
+ {source && !opportunityChoices.length && (
A sua escolha
diff --git a/resources/js/pages/investor-path/context.tsx b/resources/js/pages/investor-path/context.tsx
index 3504bc58..01432436 100644
--- a/resources/js/pages/investor-path/context.tsx
+++ b/resources/js/pages/investor-path/context.tsx
@@ -38,6 +38,7 @@ export default function ContextEditor({
requestedChoices = null,
draft = null,
entrySources = [],
+ entryLocationDifferences = [],
}: {
initial: ContextView;
options: Options;
@@ -45,6 +46,7 @@ export default function ContextEditor({
territorySelection?: TerritoryOption[];
requestedChoices?: OpportunityChoice[] | null;
entrySources?: string[];
+ entryLocationDifferences?: {title:string;saved:string;requested:string}[];
draft?: {draft_sequence?:number;needs_review?:boolean;selection?:Partial>;declarations?:Record;replace_fields?:string[];document_links?:Record}|null;
}) {
const navigation = useNavigationContext();
@@ -96,7 +98,7 @@ export default function ContextEditor({
return()=>{active=false};
},[firstCorridor,firstTerritory,corridorRetry]);
const draftBody=JSON.stringify({entry_sources:entrySources,version:view.case.version,selection:changes,declarations,replace_fields:replace,document_links:documentLinks,
- ...(manageChoices?{opportunity_choices:choices.map(c=>({source:c.opportunity.key,corridor_key:c.corridor_key,territory:c.territory?.key??null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))}:{})});
+ ...(manageChoices?{opportunity_choices:choices.map(c=>({source:c.opportunity.key,portfolio_origins:(c.portfolio_origins??[]).map(r=>r.id),corridor_key:c.corridor_key,territory:c.territory?.key??null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))}:{})});
const latestDraft=useRef(draftBody);latestDraft.current=draftBody;
const skipNextDraft=useRef(false);
const confirmedDraftBody=useRef(null);
@@ -165,7 +167,7 @@ export default function ContextEditor({
setError('');
setMessage('');
const body = {
- ...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,corridor_key:c.corridor_key,territory:c.territory?.key ?? null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))} : {}),
+ ...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,portfolio_origins:(c.portfolio_origins??[]).map(r=>r.id),corridor_key:c.corridor_key,territory:c.territory?.key ?? null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))} : {}),
version: view.case.version,
selection: changes,
declarations,
@@ -209,6 +211,8 @@ export default function ContextEditor({
`/investimentos/${view.case.id}/contexto`,
);
skipNextDraft.current=true;
+ // The entry has been consumed: refreshing must recover saved choices, not reapply a removed association.
+ window.history.replaceState(window.history.state, '', `/investimentos/${view.case.id}/contexto/editar${window.location.hash}`);
setView(next);
setChoices(next.context.opportunity_choices ?? []);
setPreparation(next.private_investment);
@@ -245,6 +249,7 @@ export default function ContextEditor({
{view.case.title}
Confirme as oportunidades e a localização, complete os dados disponíveis e reveja antes de guardar.
+ {!!entryLocationDifferences.length && {entryLocationDifferences.map(row=>{row.title}: a localização recebida ({row.requested}) não substituiu a escolha guardada ({row.saved}). Pode rever a localização abaixo antes de confirmar.
)} }
{error && (
assertCount(34, json_decode($snapshot, true)['opportunity_choices']);
$this->assertLessThan(1000000, strlen($snapshot));
+ // Mirror the existing browser partition: labels are persisted under selection_labels.
+ $economic = [...$view['prefill']['simulator'], 'purpose' => 'simulate_only', 'currency' => 'MZN'];
+ foreach (array_keys($economic) as $field) if (str_ends_with($field, '_label')) {
+ $economic['selection_labels'][substr($field, 0, -6)] = $economic[$field]; unset($economic[$field]);
+ }
$draft = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [],
- 'economic_snapshot' => [...$view['prefill']['simulator'], 'purpose' => 'simulate_only', 'currency' => 'MZN']], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json();
+ 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json();
$this->assertNotEmpty($draft);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.opportunity_choices.33.opportunity.key', 'opportunity:'.$records[33]->id);
}
diff --git a/tests/Feature/InvestmentPortfolioContinuityTest.php b/tests/Feature/InvestmentPortfolioContinuityTest.php
new file mode 100644
index 00000000..358f871a
--- /dev/null
+++ b/tests/Feature/InvestmentPortfolioContinuityTest.php
@@ -0,0 +1,95 @@
+withoutVite();
+ $db=DB::selectOne('select current_database() as name,inet_server_port() as port');
+ $this->assertSame('simulator_fiscal_delta_20261021_test',$db->name); $this->assertSame(55441,(int)$db->port);
+ config(['investor-path.enabled'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
+ $this->actor=User::factory()->create(); $this->actingAs($this->actor);
+ }
+ private function records(): array
+ {
+ $entries=collect(app(CorridorAgribusinessOpportunities::class)->entries())->where('corridor',config('investment_corridors.beira.name'))->take(2);
+ $opportunities=Opportunity::whereIn('management_creation_key',$entries->pluck('key'))->orderBy('id')->get();
+ $project=InvestmentPortfolioController::visible()->whereNotNull('project_id')->firstOrFail();
+ $cluster=InvestmentPortfolioController::visible()->whereNotNull('cluster_id')->firstOrFail();
+ foreach($opportunities as $opportunity) {
+ // Synthetic links exist only inside this rolled-back QA transaction.
+ DB::table('opportunity_projects')->insertOrIgnore(['opportunity_id'=>$opportunity->id,'project_id'=>$project->project_id]);
+ DB::table('opportunity_clusters')->insertOrIgnore(['opportunity_id'=>$opportunity->id,'cluster_id'=>$cluster->cluster_id]);
+ }
+ return [$opportunities,$project,$cluster];
+ }
+ public function test_origin_and_location_survive_entry_draft_confirmation_and_simulator_without_resetting_deliberate_values(): void
+ {
+ [$records,$origin,$cluster]=$this->records(); $opportunity=$records[0];
+ $province=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','07')->id;
+ $query='?source=opportunity:'.$opportunity->id.'&from_dossier='.$origin->id.'&province_id='.$province;
+ auth()->logout();
+ $this->get('/investimentos/iniciar'.$query)->assertRedirect(route('login'));
+ parse_str(parse_url(session('url.intended'),PHP_URL_QUERY),$intended);
+ $this->assertSame($origin->id,$intended['from_dossier']);$this->assertSame($province,$intended['province_id']);
+ $this->actingAs($this->actor);
+ $this->get('/investimentos/iniciar'.$query)->assertOk()->assertInertia(fn(Assert $p)=>$p->where('opportunityChoices.0.portfolio_origins.0.id',$origin->id)->where('opportunityChoices.0.territory.id',$province)->where('entryQuery.from_dossier',$origin->id));
+ $id=$this->postJson('/investimentos',['title'=>'QA título deliberado','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $body=['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[
+ ['source'=>'opportunity:'.$opportunity->id,'territory'=>'territory:'.$province,'portfolio_origins'=>[$origin->id],'activities'=>['trade']],
+ ['source'=>'opportunity:'.$records[1]->id,'territory'=>'territory:'.$province,'portfolio_origins'=>[$origin->id,$cluster->id]],
+ ]];
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertOk();
+ $this->flushSession();$this->actingAs($this->actor);
+ $this->get('/investimentos/'.$id.'/contexto/editar')->assertOk()->assertInertia(fn(Assert $p)=>$p->where('requestedChoices.0.portfolio_origins.0.id',$origin->id)->where('requestedChoices.0.territory.id',$province));
+ $preview=$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertOk()->json();
+ $this->assertGreaterThan(0,$preview['opportunity_choices'][0]['portfolio_origins'][0]['version']);
+ $this->assertSame('QA título deliberado',$preview['fields']['project_title']);
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar',[...$body,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ $view=$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
+ $this->assertSame($view['context']['opportunity_choices'],json_decode($view['prefill']['simulator']['context_applicability_snapshot'],true)['opportunity_choices']);
+ $this->assertSame($province,$view['prefill']['simulator']['province_id']);
+ $this->assertSame('QA título deliberado',$view['prefill']['simulator']['project_title']);
+ $created=$this->postJson('/api/v1/simulation-drafts',['context'=>$id,'personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','project_title'=>'QA edição deliberada no simulador','context_applicability_snapshot'=>$view['prefill']['simulator']['context_applicability_snapshot']]],['Idempotency-Key'=>(string)Str::uuid()])->assertCreated();
+ $this->flushSession();$this->actingAs($this->actor);
+ $simulation=$this->getJson('/api/v1/simulation-drafts/'.$created->json('data.id').'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.project_title','QA edição deliberada no simulador')->json('data.economic_snapshot');
+ $this->assertSame($view['context']['opportunity_choices'],json_decode($simulation['context_applicability_snapshot'],true)['opportunity_choices']);
+ $manica=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','06')->id;
+ $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$opportunity->id.'&from_dossier='.$cluster->id.'&province_id='.$manica)
+ ->assertOk()->assertInertia(fn(Assert $p)=>$p->where('requestedChoices.0.territory.id',$province)->where('requestedChoices.0.activities.0','trade')->has('requestedChoices',2)->has('requestedChoices.0.portfolio_origins',2)->has('entryLocationDifferences',1));
+ $body['version']=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
+ $body['opportunity_choices'][0]['portfolio_origins'][]=$cluster->id;
+ $associations=$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertOk()->json();
+ $this->assertFalse($associations['calculation_inputs_changed']);$this->assertSame([],$associations['review_steps']);
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar',[...$body,'preview_hash'=>$associations['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.dependency_hash',$view['context']['dependency_hash'])->assertJsonCount(2,'context.opportunity_choices.0.portfolio_origins');
+ }
+ public function test_unrelated_or_private_origins_are_rejected_in_entry_draft_and_preview(): void
+ {
+ [$records,$origin]=$this->records();
+ DB::table('opportunity_projects')->where('opportunity_id',$records[0]->id)->where('project_id',$origin->project_id)->delete();
+ $this->get('/investimentos/iniciar?source=opportunity:'.$records[0]->id.'&from_dossier='.$origin->id)->assertUnprocessable();
+ $id=$this->postJson('/investimentos',['title'=>'QA origem inválida','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->json('id');
+ $body=['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$records[0]->id,'portfolio_origins'=>[$origin->id]]]];
+ $this->postJson('/investimentos/'.$id.'/contexto/rascunho',[...$body,'draft_sequence'=>1])->assertUnprocessable();
+ $this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertUnprocessable();
+ $origin->publication_status='draft';$origin->save();
+ $this->get('/investimentos/iniciar?source=opportunity:'.$records[1]->id.'&from_dossier='.$origin->id)->assertNotFound();
+ }
+}
diff --git a/tests/frontend/investment-portfolio-continuity.test.mjs b/tests/frontend/investment-portfolio-continuity.test.mjs
new file mode 100644
index 00000000..2f71a7cc
--- /dev/null
+++ b/tests/frontend/investment-portfolio-continuity.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+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';
+
+test('selected associations stay attached to each opportunity in reports and do not replace deliberate simulator answers', async()=>{
+ const choices=[
+ {opportunity:{key:'opportunity:1',title:'Oportunidade QA A'},territory:{title:'Sofala / Dondo'},portfolio_origins:[{title:'Âncora QA',id:'p',sha256:'internal-hash'}]},
+ {opportunity:{key:'opportunity:2',title:'Oportunidade QA B'},territory:{title:'Manica'},portfolio_origins:[{title:'Cluster QA',id:'c'}]},
+ ];
+ const snapshot=JSON.stringify({version:1,context_revision:3,opportunity_choices:choices,land_preparation:{need:'unknown'}});
+ const values={project_title:'Título deliberado',province_id:'local-edit',context_applicability_snapshot:'old'};
+ const result=inheritCompatibleFields('dossier:A:simulator',values,{project_title:'Origem',province_id:'source',context_applicability_snapshot:snapshot},null);
+ assert.equal(result.patch.project_title,undefined);assert.equal(result.patch.province_id,undefined);
+ assert.equal(result.patch.context_applicability_snapshot,snapshot);
+ const draft={...values,...result.patch};const before=JSON.stringify(draft);
+ const rows=buildProjectDossierRows(draft);
+ assert.ok(rows.some(row=>row[1]==='Associação escolhida na carteira'&&row[2]==='Âncora QA'&&row[3].startsWith('Oportunidade QA A;')));
+ assert.ok(rows.some(row=>row[1]==='Associação escolhida na carteira'&&row[2]==='Cluster QA'&&row[3].startsWith('Oportunidade QA B;')));
+ assert.match(exportProjectDossierCsv(draft),/Sofala \/ Dondo/);
+ const html=await createProjectDossierPrintHtml(createProjectDossierReport(draft,{selectedSections:new Set(['location'])}),[],false);
+ assert.match(html,/Âncora QA/);assert.match(html,/Cluster QA/);assert.match(html,/Manica/);
+ assert.doesNotMatch(html,/internal-hash/);assert.equal(JSON.stringify(draft),before);
+});
diff --git a/tests/frontend/search-territory.test.mjs b/tests/frontend/search-territory.test.mjs
index 1cac6baf..7a5f95e0 100644
--- a/tests/frontend/search-territory.test.mjs
+++ b/tests/frontend/search-territory.test.mjs
@@ -1,6 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { searchTerritoryHref } from '../../resources/js/lib/search-territory.ts';
+import { searchTerritoryHref, investmentPreparationHref } from '../../resources/js/lib/search-territory.ts';
+
+test('preparation carries only the validated portfolio origin and territorial choices, including nested catalogue return context',()=>{
+ const href=investmentPreparationHref(53,'/oportunidades/florestas?province_id=p&district_id=d&q=private-note&context=other','origin');
+ const query=new URL(href,'https://portal.invalid').searchParams;
+ assert.equal(query.get('source'),'opportunity:53');assert.equal(query.get('from_dossier'),'origin');
+ assert.equal(query.get('province_id'),'p');assert.equal(query.get('district_id'),'d');
+ assert.equal(query.has('q'),false);assert.equal(query.has('context'),false);
+ assert.equal(new URL(investmentPreparationHref(53,'/oportunidades/florestas?catalogue_query='+encodeURIComponent('province_id=p&district_id=d')),'https://portal.invalid').searchParams.get('district_id'),'d');
+});
test('portfolio to catalogue to related screen retains only territorial search filters', () => {
const start = '/carteira?province_id=inh&district_id=vil&q=airport&kind=project';