Preserve selected portfolio context through investment preparation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)) : [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) {
|
||||
</p>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
{Object.values(view.context.selection).map(
|
||||
(r) =>
|
||||
{consumer === 'simulator' && !!view.context.opportunity_choices?.length && <OpportunityChoices choices={view.context.opportunity_choices}/>}
|
||||
{Object.entries(view.context.selection)
|
||||
.filter(([slot]) => consumer !== 'simulator' || !view.context.opportunity_choices?.length || !['opportunity', 'territory'].includes(slot))
|
||||
.map(([, r]) =>
|
||||
r && (
|
||||
<p key={r.key}>
|
||||
<Link className="underline" href={r.href}>
|
||||
|
||||
@@ -34,6 +34,7 @@ function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;
|
||||
{!remove&&c.coverage&&<p>Abrangência: {c.coverage}</p>}
|
||||
{notice&&<p role="status">{notice}</p>}
|
||||
<p>Localização: {c.territory?.title??'Por escolher'}</p>
|
||||
{!!c.portfolio_origins?.length && <section aria-label="Associações escolhidas"><h4 className="font-semibold">Associações escolhidas</h4><ul>{c.portfolio_origins.map(origin=><li key={origin.id}><Link className="underline" href={origin.href}>{origin.title}</Link>{remove&&<button type="button" className="ml-3 underline" disabled={disabled} onClick={()=>update({portfolio_origins:c.portfolio_origins?.filter(r=>r.id!==origin.id)})}>Retirar associação</button>}</li>)}</ul></section>}
|
||||
{remove&&<>
|
||||
<fieldset disabled={disabled}><legend>Actividades que pretende realizar nesta oportunidade</legend>{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])=><label key={key} className="mr-4 inline-block"><input type="checkbox" checked={c.activities?.includes(key)??false} onChange={e=>update({activities:e.target.checked?[...(c.activities??[]),key]:(c.activities??[]).filter(x=>x!==key)})}/>{label}</label>)}</fieldset>
|
||||
<TerritoryFields initial={c.territory_path} corridor={corridor?.key} disabled={disabled} onChange={next=>location(next)}/>
|
||||
@@ -48,7 +49,7 @@ export default function OpportunityChoices({choices, onChange, disabled = false,
|
||||
const update = (index: number, change: Partial<OpportunityChoice>) => onChange?.(choices.map((c,i) => i === index ? {...c,...change} : c));
|
||||
return <section className="space-y-4" aria-label="Oportunidades e respectivas localizações">
|
||||
<h2 className="text-xl font-bold">Oportunidades escolhidas e localização de cada ficha</h2>
|
||||
<p>Escolha o corredor e confirme onde pretende realizar o investimento.</p>
|
||||
<p>{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.'}</p>
|
||||
{choices.map((c,i)=><ChoiceCard key={c.opportunity.key} c={c} i={i} corridors={corridors} disabled={disabled} update={change=>update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/>)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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, string>)[String(draft.land_status)] ?? 'Informação em falta'), 'Informação declarada; não comprova título ou disponibilidade']);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' && <p className="rounded border border-amber-700 bg-amber-50 p-4"><strong>Em preparação.</strong> Consulte a oportunidade e prepare o seu investimento. A execução depende das condições e autorizações aplicáveis ao projecto.</p>}
|
||||
<Link
|
||||
className={button}
|
||||
href={'/investimentos/iniciar?source=opportunity:' + r.id}
|
||||
href={investmentPreparationHref(r.id, page.url, dossierOrigin?.id)}
|
||||
>
|
||||
Iniciar ou continuar investimento com esta oportunidade
|
||||
</Link>
|
||||
@@ -90,7 +90,7 @@ export default function Detail({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link className={button} href={`/investimentos/iniciar?source=opportunity:${r.id}`}>
|
||||
<Link className={button} href={investmentPreparationHref(r.id, page.url, dossierOrigin?.id)}>
|
||||
Iniciar ou continuar o roteiro deste investimento
|
||||
</Link>
|
||||
{auth.user ? (
|
||||
|
||||
@@ -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<string, string>;
|
||||
}) {
|
||||
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 (
|
||||
<Frame title="Iniciar ou continuar um investimento">
|
||||
{!!opportunityChoices.length && <section className={card}><h2 className="font-bold">Oportunidades escolhidas</h2><ul>{opportunityChoices.map(c => <li key={c.opportunity.key}><Link className="underline" href={c.opportunity.href}>{c.opportunity.title}</Link> · {c.territory?.title ?? 'Localização por escolher'}</li>)}</ul><p>Confirme a localização de cada ficha na comparação do dossier.</p></section>}
|
||||
{!!opportunityChoices.length && <section className={card}><h2 className="font-bold">Oportunidades escolhidas</h2><ul>{opportunityChoices.map(c => <li key={c.opportunity.key}><Link className="underline" href={c.opportunity.href}>{c.opportunity.title}</Link> · {c.territory?.title ?? 'Localização por escolher'}{c.portfolio_origins?.map(origin=><p key={origin.id}>Associação escolhida: <Link className="underline" href={origin.href}>{origin.title}</Link></p>)}</li>)}</ul><p>Confirme a localização de cada ficha na comparação do dossier. Ao continuar um dossier, as escolhas já guardadas conservam-se.</p></section>}
|
||||
<p>
|
||||
Ideia, oportunidade, sector e localização convergem no mesmo
|
||||
dossier. A referência é para preparação e não confirma direitos
|
||||
ou benefícios.
|
||||
</p>
|
||||
<ContextDocuments source={source ?? undefined} />
|
||||
{source && (
|
||||
{source && !opportunityChoices.length && (
|
||||
<section className={card}>
|
||||
<h2 className="font-bold">A sua escolha</h2>
|
||||
<Link className="underline" href={source.href}>
|
||||
|
||||
@@ -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<Record<Slot,string>>;declarations?:Record<string,string>;replace_fields?:string[];document_links?:Record<string,string|null>}|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<string|null>(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}
|
||||
</p>
|
||||
<p>Confirme as oportunidades e a localização, complete os dados disponíveis e reveja antes de guardar.</p>
|
||||
{!!entryLocationDifferences.length && <aside className="rounded border border-amber-500 p-3" aria-label="Localização anterior conservada">{entryLocationDifferences.map(row=><p key={row.title}>{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.</p>)}</aside>}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -130,8 +130,13 @@ final class InvestmentOpportunityChoicesTest extends TestCase
|
||||
$snapshot = $view['prefill']['simulator']['context_applicability_snapshot'];
|
||||
$this->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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\CorridorAgribusinessOpportunities;
|
||||
use App\Services\InvestmentCorridorContext;
|
||||
use App\Services\InvestmentOpportunityChoices;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class InvestmentPortfolioContinuityTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
private User $actor;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user