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&&}
  • )}
} {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])=>)}
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

    {opportunityChoices.map(c =>
  • {c.opportunity.title} · {c.territory?.title ?? 'Localização por escolher'}{c.portfolio_origins?.map(origin=>

    Associação escolhida: {origin.title}

    )}
  • )}

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 && } {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';