Compare commits

...
41 changed files with 992 additions and 73 deletions
+8
View File
@@ -1,5 +1,13 @@
# Instruções permanentes do projeto InvesteMoz
## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026
Carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas fazem parte da mesma experiência e responsabilidade de correcção. Separar o diagnóstico não autoriza adiamento, desligamento do percurso ou perda de contexto entre corredor/localização, oportunidades, associações documentadas a âncoras/clusters, dossier e simulador. A reposição prioritária só se considera demonstrada com causa delimitada e disponibilidade recuperada, reutilizando o inventário e os recibos existentes; melhorias noutro ponto não a comprovam. Consultar os registos de reposição antes de voltar a declarar causa desconhecida ou registos desaparecidos.
Apresentar apenas o necessário ao investimento em preparação, com entrada por ideia, oportunidade, sector ou localização, reutilizando informação válida e autorizada da oportunidade, perfil, organização, dossier e fontes anteriores. As etapas são adaptáveis, não seis formulários obrigatórios. Não sobrescrever edições deliberadas; explicar divergências e impactos antes da confirmação. Conservar escolhas ao avançar, regressar, actualizar e autenticar-se. Seleccionar opções únicas e pedir apenas dados em falta ou desactualizados.
Ocultar campos e acções sem aplicação, conservando explicação breve e histórico; desconhecido não significa não aplicável. Na selecção múltipla, partilhar dados comuns sem perder os requisitos específicos de cada oportunidade. Consulta pública livre; autenticação no momento de manifestar interesse ou iniciar operação privada. Relatórios e exportações devem reflectir o contexto e as etapas efectivamente aplicáveis. O utilizador deve perceber onde está, o que falta e a próxima acção. Aperfeiçoar os componentes existentes por incrementos e registar cobertura na matriz existente; esta instrução não declara concluída a sua implementação transversal.
## Regras permanentes do InvesteMoz — decisão da Tempo, 12/10/2026
1. O portal InvesteMoz é propriedade da Tempo, Lda.
@@ -89,7 +89,7 @@ class InvestmentDiscoveryController extends Controller
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = false;
$linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true;
@@ -105,6 +105,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
$query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
@@ -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)
@@ -13,6 +13,17 @@ use Ramsey\Uuid\Uuid;
final class CorridorAgribusinessOpportunities
{
public const SOURCE_HASH = '1473b06c48aa234e771660e3f2a51b9d2e4e1943bd52d878e0ae854ee6657055';
/** Documentary cross-reference only; keep the two source designations and master records intact. */
public function portfolioOpportunityIds(\App\Models\InvestmentDossier $dossier): array
{
if (!$dossier->corridor_id || data_get($dossier->content, 'code') !== 'COR-02') return [];
// COR-02 is Beira e Centro in investment-portfolio.json. The seven entries are explicitly
// grouped under Corredor da Beira in the accepted source, not selected by province/name search.
$keys = collect($this->entries())->where('corridor', 'Corredor da Beira')->pluck('key');
return Opportunity::published()->whereIn('management_creation_key', $keys)
->where('workflow_context->corridor_agribusiness->source_sha256', self::SOURCE_HASH)->pluck('id')->all();
}
public const LIMITATIONS = 'Promotor, implantação exacta, área, montante, financiamento, viabilidade e licenças não indicados nesta fonte. O corredor é um enquadramento documental; não identifica uma parcela nem confirma disponibilidade.';
public function entries(): array
+4
View File
@@ -292,6 +292,10 @@ class InvestmentCatalogue
}
}
});
if (!empty($selected['corridor_id'])) {
$dossier = InvestmentPortfolioController::visible()->where('corridor_id', $selected['corridor_id'])->first();
if ($dossier) $ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
}
return $ids;
}
+33 -1
View File
@@ -2,11 +2,42 @@
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;
if (in_array($opportunity, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record), true)) $kind = 'corridor';
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 +46,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 +65,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)) : [],
+34 -4
View File
@@ -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();
@@ -563,6 +568,7 @@ final class InvestorContext
$case = $this->desk->case($actor, $id);
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$ctx = $this->state($case);
$ctx['land_preparation'] = $this->landPreparation($case);
$path = app(InvestorPath::class)->view($actor, $id);
$ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'],
'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))];
@@ -591,10 +597,32 @@ final class InvestorContext
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all();
}
public function landPreparation(\stdClass $case): array
{
$path = $this->desk->decode($case->payload)['investor_path'] ?? [];
$need = $path['facts']['land_needed'] ?? 'unknown';
return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $path['fact_reasons']['land_needed'] ?? ''];
}
public function prefill(array $ctx): array
{
$f = $ctx['fields'];
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
// Entering this tool from an investment dossier selects the existing investment-analysis route.
// The reconciliation preserves any purpose deliberately chosen in an existing simulator draft.
$sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular'];
if (($ctx['land_preparation']['need'] ?? null) === 'yes') {
$tenure = ['duat' => ['duat', 'DUAT'], 'lease' => ['leased', 'Arrendado']][$f['site_tenure'] ?? ''] ?? null;
if ($tenure) [$sim['land_status'], $sim['land_status_label']] = $tenure;
}
// Human labels travel with the exact existing identifiers, not inferred locations.
foreach (app(InvestmentGeography::class)->publicReferences()->whereIn('id', array_values(Arr::only($f, array_keys(InvestmentGeography::FIELDS))))->get(['id', 'name']) as $place) {
foreach (array_keys(InvestmentGeography::FIELDS) as $key) {
if (($f[$key] ?? null) === $place->id) $sim[$key.'_label'] = $place->name;
}
}
if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->find($f['sector_id']))) $sim['sector_id_label'] = $sector->name;
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
@@ -603,6 +631,8 @@ final class InvestorContext
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
'path' => $ctx['path_applicability'] ?? null,
'land_preparation' => $ctx['land_preparation'] ?? null,
'field_provenance' => array_map(fn($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []),
'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
+1 -1
View File
@@ -170,7 +170,7 @@ final class InvestorPath
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '') : null,
'desk' => '/balcao-virtual/dossiers/'.$id, 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
'land' => (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id) : null]];
'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id) : null]];
}
/** @param array<string,mixed> $input */
+32 -1
View File
@@ -60,9 +60,11 @@ final class SimulationFormDrafts
throw new HttpException(409, 'Já existe um rascunho activo. Carregue-o antes de guardar novas alterações.');
}
if ($case) $data['economic_snapshot'] = $this->landContext($data['economic_snapshot'], $case);
$snapshotHash = hash('sha256', json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
$draft = SimulationFormDraft::query()->create([
'id' => (string) Str::uuid(), 'owner_user_id' => $actorId, 'anonymous_token_hash' => $tokenHash,
'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $payloadHash,
'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $snapshotHash,
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
'expires_at' => $actorId === null ? now()->addDays(30) : null,
@@ -89,6 +91,7 @@ final class SimulationFormDrafts
$this->checkContext($request, $draft);
$this->requireMatch($request, $draft);
$previousEconomic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$data['economic_snapshot'] = $this->preserveInactiveLandStatus($draft, $data['economic_snapshot'], $previousEconomic, $actorId);
if (($data['economic_snapshot']['reuse_simulation'] ?? null) === 'no') {
unset($data['economic_snapshot']['simulation_sources']);
} elseif (is_array($previousEconomic['simulation_sources'] ?? null)) {
@@ -456,6 +459,34 @@ final class SimulationFormDrafts
return $query;
}
private function preserveInactiveLandStatus(SimulationFormDraft $draft, array $economic, array $previous, ?int $actorId): array
{
if (! $actorId || ! config('investor-path.enabled') || ! app(VirtualDesk::class)->installed()) return $economic;
// Use the real owner-bound dossier even if a request omits its context parameter.
$case = DB::table('desk_cases')->where('simulation_id', $draft->id)->where('user_id', $actorId)->first();
if (! $case) return $economic;
return $this->landContext($economic, $case, $previous);
}
private function landContext(array $economic, \stdClass $case, ?array $previous = null): array
{
$decision = app(InvestorContext::class)->landPreparation($case);
if ($decision['need'] !== 'yes' && $previous !== null) {
if (array_key_exists('land_status', $previous)) $economic['land_status'] = $previous['land_status'];
else unset($economic['land_status']);
if (isset($previous['selection_labels']['land_status'])) $economic['selection_labels']['land_status'] = $previous['selection_labels']['land_status'];
else unset($economic['selection_labels']['land_status']);
}
$snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true);
if (! is_array($snapshot) || ($snapshot['version'] ?? null) !== 1) {
$snapshot = ['version' => 1, 'context_revision' => app(InvestorContext::class)->state($case)['revision']];
}
$snapshot['land_preparation'] = $decision;
$economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
return $economic;
}
private function checkContext(Request $request, SimulationFormDraft $draft): void
{
$context = $request->input('context');
@@ -0,0 +1,32 @@
{
"target": "https://teste-investidor.tempo.co.mz",
"authorization": "Tempo: rever e disponibilizar a entrega consolidada pelo processo Git existente; 23/10/2026",
"base": "e1f010f941e9993645e131b34ec1f4c3fe82c71b",
"continuity": "3d877ec7",
"qa_recovery_record": "115c7731",
"served_commit": "7c1f532134e4492dec010d0c511f669a1e2c23a7",
"finished_at": "2026-10-23T13:16:11.213670+00:00",
"status": "installed",
"release": "/var/www/investmoz-homologacao/releases/7c1f532134e4492dec010d0c511f669a1e2c23a7-524bd081",
"previous_release": "/var/www/investmoz-homologacao/releases/e1f010f941e9993645e131b34ec1f4c3fe82c71b-e905ae1f",
"manifest_sha256": "e72d2fc5343a7c3bb0f9c4b2f3f3c7660b3c6e2db5ba4995875c8130d021fd68",
"database_migrations": 0,
"content_imports": 0,
"permission_changes": 0,
"git": "Fast-forward integration; no remote-exclusive main changes; no force push or patch reapplication",
"initial_failure": "115c7731 preserved the active release: one report test detected omission of an associated inactive historical document. Corrected in 7c1f5321, retaining history without requiring the inapplicable document.",
"verification": {
"affected_local_tests": 17,
"types": "passed",
"automatic_frontend_tests": {"total": 204, "passed": 204, "failed": 0},
"served_revision": "Exact commit confirmed through public build/revision.json",
"public_home": "HTTP 200 without environmental HTTP authentication",
"portfolio": {"public_records": 29, "missing_approved_ids": 0, "changed_approved_titles": 0},
"assets": "Detail, context entry/editor and simulator assets HTTP 200",
"source_hashes": "Five affected server/report files match local Git release bytes",
"persistent_runtime": "Existing environment and storage remain symlinks; no replacement of data, uploads or secrets",
"private_workflow": "Accepted synthetic QA proofs reused; no real dossier writes or new destination business-flow test"
},
"rollback": "Existing pipeline rollback to preserved previous release; retain database, encrypted payloads, uploads, permissions and later history; no database restore",
"remaining": "Transversal adaptation of the rest of the portal is partial; absent portfolio associations are not inferred"
}
@@ -0,0 +1,31 @@
{
"target": "https://teste-investidor.tempo.co.mz",
"authorization": "Tempo, 23/10/2026: concluir visual QA, integrar pelo Git e conferir apenas os tres deltas instalados",
"status": "installed",
"commit": "6b544548d3c83fc8a72ab65d8f58f84685a2877f",
"finished_at": "2026-10-23T13:45:19.950347+00:00",
"manifest_sha256": "65a694b5d81f833446a308f48f4966b32a8bdc5421f1ee2196b79ac0b798b636",
"release": "/var/www/investmoz-homologacao/releases/6b544548d3c83fc8a72ab65d8f58f84685a2877f-b38d94da",
"previous_release": "/var/www/investmoz-homologacao/releases/7c1f532134e4492dec010d0c511f669a1e2c23a7-524bd081",
"git": "Fast-forward from 7c1f5321; no remote-exclusive main changes; no force push",
"database_migrations": 0,
"content_imports": 0,
"permission_changes": 0,
"automatic_frontend_tests": {"tests": 206, "passed": 206, "failed": 0},
"qa_visual_completed": "Beira page displays seven documented opportunities; Florestas opens and both preparation links retain from_dossier",
"destination_delta": {
"language": "Existing private TESTE DE HOMOLOGACAO dossier: Portuguese (Mozambique) selected, explicit save confirmed, page reload recovered the same language without false conflict",
"test_dossier": "958df138-7980-4685-8389-fa90be02ed6b",
"simulation_draft": "6c07a968-7ff9-42fa-b252-072bb1acf221",
"report": "Printed report revision 4 distinguishes title from user dossier data, summary inherited from Florestas opportunity version 2, sector from sector catalogue and Sofala from territorial catalogue",
"beira": "Seven detail pages opened from observed links; exact titles and from_dossier preserved in preparation links",
"beira_titles": ["Açúcar — Corredor da Beira", "Carne bovina — Corredor da Beira", "Florestas — Corredor da Beira", "Hortícolas — Corredor da Beira", "Milho, trigo e arroz — Corredor da Beira", "Sésamo — Corredor da Beira", "Soja — Corredor da Beira"],
"stale_versions": "Accepted QA rejection tests reused; installed server guard bytes match the tested version. No deliberate stale write against destination user data",
"version_guard_sha256": "1626280b34481298de964ff82027ae7ea9b1aa80d1b86554987edbe6ec5a087f",
"draft_client_sha256": "dfe1a44e8a9af48735d75d0742583ce2280abf7615049d569f7963763bdd5e4c"
},
"local_proofs": ["outputs/preservation-inventory-20261023/beira-seven-visual-QA.png", "outputs/preservation-inventory-20261023/installed-language-recovery-6b544548.png", "outputs/preservation-inventory-20261023/installed-provenance-report-6b544548.png", "outputs/preservation-inventory-20261023/installed-beira-seven-6b544548.png"],
"preservation": "No republication of the 34 opportunities or reimport of portfolio; only existing labelled private test dossier used for writes; previous release preserved",
"rollback": "Existing release rollback; retain database, uploads, permissions, history and later edits",
"remaining": "Transversal application remains partial; this receipt closes only the three installed correction deltas"
}
@@ -1,5 +1,19 @@
{
"date": "2026-10-23",
"current_public_readonly_confirmation": {
"date": "2026-10-23",
"url": "https://teste-investidor.tempo.co.mz/carteira",
"visible_dossiers": 29,
"projects": 15,
"clusters": 8,
"corridors": 6,
"manifest": "approved-portfolio-visibility-20261023.json",
"missing_manifest_ids": [],
"different_titles": [],
"session_scope": "Authenticated browser reading the public route; prior anonymous recovery proof preserved",
"remote_data_writes": 0,
"remaining_continuity_gap": "from_dossier is validated on opportunity consultation but not carried by the Prepare investment link into the private context"
},
"operation": "Inventory before further acceptance tests; no active data changed",
"baseline_commit": "37a8eb015ac5022fd34bcd89cfe47c9fc61903b0",
"existing_43_components": [
@@ -1,5 +1,136 @@
# Rastreabilidade, utilização e formação
## Instalação e conferência das três correcções — 23/10/2026
**Revisão servida: `6b544548`.** Conferência visual QA da Beira concluída antes da integração: sete oportunidades visíveis, Florestas abre com origem da carteira conservada nos dois botões de preparação. Integrada por avanço directo no Git, sem alterações concorrentes em main. Executor concluído às 13:45:19 UTC, 206/206 testes frontend; zero migrations/importações e nenhuma alteração de permissões. Recibo: `continuity-corrections-installation-receipt-20261023.json`. As referências abaixo a QA pendente/homologação em `7c1f5321` descrevem o estado histórico anterior, sucedido por esta instalação.
Delta no destino, apenas no dossier privado já identificado como teste `958df138-7980-4685-8389-fa90be02ed6b`: seleccionar Português (Moçambique), Guardar e continuar mais tarde e recarregar recuperou o idioma, com confirmação e sem falso conflito. Relatório imprimível revisão 4 identifica título herdado dos dados introduzidos no dossier, resumo herdado da oportunidade Florestas v2, sector herdado do catálogo sectorial e Sofala do catálogo territorial; sem repetir o teste de todas as exportações/paginação. A guarda do servidor coincide por SHA-256 com a testada em QA; reutilizada a recusa de versões antigas, sem provocar escrita desactualizada em dados do destino.
Na ficha pública da Beira, as sete ligações foram abertas individualmente: Açúcar, Carne bovina, Florestas, Hortícolas, Milho/trigo/arroz, Sésamo e Soja. Títulos coincidentes e origem `from_dossier` conservada em todos os botões de preparação. Não foram publicadas novamente as 34 oportunidades nem alteradas relações por província. Provas e revisão anterior preservadas no recibo. Aplicação transversal continua parcial; este fecho respeita apenas os três deltas pedidos.
## Correcções da verificação de homologação — candidata QA, 23/10/2026
Referência: `c3f7c3ad`; pedido expresso da Tempo nas três prioridades. As 34 oportunidades já públicas e as provas aceites mantêm-se. Esta candidata não altera a homologação, que permanece em `7c1f5321`; sem migrations, novos papéis, publicação, importação ou alteração de dados activos.
1. **Gravação/idioma — causa de transporte reproduzida.** O Nginx do destino comprime JSON e enfraquece o ETag da representação; confirmado em leitura pública do questionário (o controlador produz uma versão forte e a resposta chega `W/"questionnaire-…"`, com gzip). O cliente do rascunho devolvia literalmente o cabeçalho ao servidor, que compara a versão forte exacta; os logs do dossier de teste mostram PUT 200 seguido de 412 e repetição após recuperação. Em QA isolado, um router de prova aplica gzip e enfraquece os ETags dos rascunhos para reproduzir esse transporte. A correcção normaliza apenas o formato conhecido `W/"sfd-UUID-vN"` antes de o usar; não aceita outra versão, não muda o controlo do servidor nem força retry/overwrite. No navegador QA, mudar o idioma para English, guardar e recarregar recuperou English com confirmação de gravação, título deliberado e escolhas preservados, sem falso conflito. A leitura pública comprova o transporte no destino; a correcção do rascunho ainda aguarda instalação/conferência desse delta na homologação. Sem consulta de credenciais ou bypass de sessão/RLS.
2. **Proveniência do relatório.** A origem existente por campo estava no dossier, mas não viajava no snapshot usado pelo relatório; as linhas tinham atribuições genéricas fixas ao utilizador. O snapshot passa a incluir apenas tipo/título/versão/revisão da proveniência, sem IDs de actores. O modelo comum de relatório/CSV usa também as assinaturas de herança já existentes: mostra origem na oportunidade/catálogo/dados do utilizador no dossier; uma alteração posterior no simulador é identificada com a origem anterior. Não muda valores nem reescreve relatórios emitidos; metadados ausentes de versões antigas não são inventados. Conferido no relatório QA: resumo herdado da oportunidade, título alterado posteriormente e referência da oportunidade/localização/associação conservadas. Captura `outputs/preservation-inventory-20261023/corrected-provenance-report-QA.png`.
3. **Beira → oportunidades.** A consulta da carteira só procurava pivots; o cadastro das 34 conserva a pertença documental no `workflow_context`, deliberadamente sem inferir relações com mestres. As fontes aceites distinguem COR-02 «Corredor da Beira e Centro» (`investment-portfolio.json`) e o grupo «Corredor da Beira» (`corridor-investment-opportunities.json`): sete entradas, não selecção por província. Esta correcção implementa uma referência documental delimitada entre essa ficha e esse grupo, sem renomear/fundir corredores, acrescentar pivots ou inferir implantação. A consulta exige a chave original e o hash da fonte aceite; outras oportunidades/corredores não entram por semelhança. A mesma resolução serve a ficha, a pesquisa por corredor, a validação da origem no detalhe e a associação escolhida no dossier. As sete fichas são Milho, trigo e arroz; Soja; Hortícolas; Sésamo; Açúcar; Florestas; Carne bovina — todas no Corredor da Beira. Não se estende esta correspondência aos outros corredores, clusters ou âncoras por inferência.
Validação delimitada: 26 testes frontend afectados passaram; novo teste de proveniência/ETag repetido após o ajuste de designações, dois aprovados. Três testes transaccionais de continuidade/Beira, 91 asserções, na base QA `simulator_fiscal_delta_20261021_test:55441`, incluindo sete resultados, pesquisa, detalhe/origem e recusa de outra oportunidade. Um teste adicional, cinco asserções, confirma que o servidor continua a recusar ETag fraco e versão antiga e aceita apenas a versão forte actual. Types/build aprovados. A conferência visual final da navegação da carteira QA foi interrompida pela automação do navegador; não se declara uma prova visual nova dessa ficha, coberta aqui pela resposta Inertia/API e validação de continuidade. Mantêm-se as provas anteriores. Matriz existente: evidências acrescidas em AUD-SIM-INT03/06/07, sem novo backlog. Aplicação transversal permanece parcial.
## Verificação do produto na homologação — 23/10/2026
Origem: pedido da Tempo de avaliar carteira → escolha → dossier → simulador → relatório, registando apenas diferenças e reutilizando as provas aceites. Revisão servida confirmada: `7c1f5321`. Sem alteração de código, permissões, originais ou publicações nesta passagem. Referências da matriz existente: AUD-SIM-INT03 (recuperação), AUD-SIM-INT06 (relatórios) e AUD-SIM-INT07 (gravação/erros).
Criado pela interface um dossier privado explicitamente denominado **TESTE DE HOMOLOGAÇÃO — continuidade 23-10-2026**, `958df138-7980-4685-8389-fa90be02ed6b`, rascunho do simulador `6c07a968-7ff9-42fa-b252-072bb1acf221`. Cenário sintético, não investimento real nem manifestação/submissão. Os dossiers anteriores permaneceram sem edição. A escolha Florestas — Corredor da Beira foi recebida; o corredor já vinha seleccionado e apresentou apenas Manica/Sofala. Escolhida Sofala e actividade florestal para o teste, a prévia propôs resumo/sector/localização e preservou o título deliberado do teste. Após confirmação, oportunidade, Agronegócio, Corredor da Beira e Sofala recuperados no dossier e no simulador sem preenchimento repetido. Necessidade de terra Não, identificada como cenário sintético: acção Terra/DUAT e etapa de regularização retiradas do dossier; Situação do terreno ocultada no simulador com explicação. Não se repete a prova de novo login já aceite em QA.
Diferenças observadas, sem declarar causa ou correcção não demonstradas:
- **Gravação/recuperação (AUD-SIM-INT03/07):** no novo rascunho e num único separador desta verificação, após escolher Simples/Português e navegar nas etapas surgiu «alterado noutro separador». Recuperar a versão mais recente conservou Simples e os dados herdados, mas não o idioma. Escolher novamente o idioma e Guardar e continuar mais tarde voltou a indicar gravação não confirmada/conflito. Não há outro separador deste dossier de teste; não atribuir a falha ao utilizador nem afirmar causa técnica confirmada. Não contornado o controlo de versão.
- **Origem no relatório (AUD-SIM-INT06):** o relatório imprimível contém o resumo e Agronegócio herdados, mas classifica-os como declarados/seleccionados pelo utilizador. A proveniência apresentada deve distinguir herança da oportunidade de edição deliberada. A selecção do território foi efectivamente feita no teste.
- **Carteira → escolha:** a ficha Corredor da Beira e Centro abre, mas informa não ter oportunidades publicadas associadas nem relações disponíveis; por essa ficha não foi possível demonstrar a passagem contextual à oportunidade. Florestas também apresenta ausência de relações publicadas. A falta de associações já estava identificada; esta observação localiza o limite no destino, não prova perda recente nem autoriza inferir/recriar ligações. O dossier antigo Florestas `90110be6-00c1-4e37-a82f-19e734f4e097` abriu sem oportunidade/sector/localização confirmados; o título não prova esses dados guardados. Foi preservado.
**Relatório:** a opção Imprimir / guardar como PDF gerou uma nova apresentação, lida no navegador. Inclui as onze secções seleccionadas, oportunidade/Sofala, necessidade de terra Não e preparação do terreno não aplicável, lacunas como Não informado e ausência de plano/cálculo sem zeros presumidos. Cabeçalho/tabelas legíveis na captura; não se declara nova conferência de paginação PDF ou de todas as páginas impressas. O relatório foi gerado com o aviso de gravação activo: não atesta que todas as últimas edições locais estavam persistidas. A tentativa de captura de download Word pela automação não terminou; isso não basta para declarar defeito do exportador nem download/abertura Word demonstrados. As provas QA desses formatos mantêm-se, sem substituir a lacuna observada de gravação.
**Correcção de saldo das 34 oportunidades:** a pendência de disponibilização não corresponde ao destino actual. O catálogo mostra as entradas; a exportação pública Agronegócio devolveu 34 referências, todas coincidentes por URL com `agribusiness-homologation-20261023.json`, zero ausentes. Não houve reimportação, nova publicação ou conferência individual de todas as páginas. Disponibilidade pública e associações à carteira são estados distintos.
Provas locais: `outputs/preservation-inventory-20261023/product-report-homologation-20261023.png` e `product-simulator-save-conflict-homologation-20261023.png`. Saldo: herança, localização e não aplicabilidade demonstradas neste cenário instalado; gravação/recuperação com falha nova; proveniência do relatório a corrigir; passagem por associações reais da carteira não demonstrada neste recorte. Aplicação transversal parcial. Nenhum fecho global do produto.
## Retoma e recuperação da continuidade — conferência QA, 23/10/2026
**Estado posterior: instalada na homologação.** A revisão `7c1f5321`, que inclui `6948ed62`, `3d877ec7`, `115c7731` e a correcção histórica do relatório, está efectivamente servida desde 23/10/2026 às 13:16 UTC. Recibo: `adaptive-portfolio-installation-receipt-20261023.json`. Conferidos revisão pública, assets do delta, hashes dos cinco ficheiros afectados e carteira anónima: 29 fichas, zero IDs ausentes e zero títulos divergentes. Executor: 204/204 testes frontend; zero migrations/importações e nenhuma alteração de permissões. Reutilizadas as provas privadas QA; não se gravou um dossier real no destino. As menções abaixo a homologação inalterada conservam o estado histórico da preparação QA, agora sucedido por este recibo. Aplicação transversal permanece parcial.
Disponibilização autorizada pela Tempo: `115c7731` integrado em `main` por avanço directo, sem alterações concorrentes no remoto, migrations ou alterações de dependências/permissões. A primeira execução automática preservou a release anterior `e1f010f9`: 203/204 testes frontend passaram; o teste do documento histórico não aplicável detectou que a simplificação do relatório ocultava também esse registo. Correcção delimitada: manter a linha histórica quando existe documento associado, sem voltar a exigir o requisito; os restantes não aplicáveis conservam a explicação breve. Verificados 17 testes afectados de relatórios, continuidade e terra, mais types. Não foram repetidas as provas funcionais QA nem alterados dados no destino. A integração Git não é recibo de instalação; confirmar a revisão servida antes de declarar disponível.
Ponto de retoma da Tempo: 29 fichas visíveis; completar a continuidade, sem repetir a reposição ou as provas aceites. A implementação `3d877ec7` sucede a `6948ed62` e permanece exclusivamente em QA. As secções históricas abaixo que identificam a perda de `from_dossier` descrevem o diagnóstico anterior: essa lacuna está corrigida neste recorte QA, ainda não na homologação.
Conferência pela interface, apenas com conta e registos SYNTHETIC QA: a oportunidade, Sofala e a associação escolhida chegaram ao dossier e ao simulador. Foi guardado no simulador o título deliberadamente diferente «SYNTHETIC QA — edição deliberada de continuidade». Regressar ao dossier e reabrir Plano e simulador conservou esse título; recarregar também o conservou. Depois de Terminar sessão e efectuar novo login normal, abriu-se o mesmo investimento pelo painel e o seu Plano e simulador: oportunidade, localização, associação e título deliberado foram recuperados, sem novo preenchimento. O título do dossier e o da oportunidade não foram substituídos pela edição do simulador. O login conduziu ao painel; a recuperação foi concluída pela ligação do investimento nesse painel, não se afirma redireccionamento automático ao simulador.
Prova local: `outputs/preservation-inventory-20261023/portfolio-continuity-recovery-login-QA.png`. O dossier sintético usado é `b5248bb8-5ada-445f-a34a-e4c628b6d457`; o rascunho recuperado é `b56a6bb8-c5df-4f22-84d0-07534d9dd8ba`. Nenhum dado real ou permissão foi alterado para esta conferência.
Relatórios: reutilizadas as provas aceites de `tests/frontend/investment-portfolio-continuity.test.mjs` para o modelo comum de relatório/CSV/impressão, e as provas de aplicabilidade e situação do terreno da entrega reconciliada. Não se declara uma nova exportação visual nesta passagem nem se repete a suite. A causa e prevenção da perda anterior permanecem na secção seguinte e no manifesto/recibo existentes. Homologação inalterada; aplicação transversal ainda parcial; associações ausentes não foram inventadas.
## 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.
| Candidata | Comportamento incluído | Provas aceites reutilizadas |
| --- | --- | --- |
| `7ca94967` | Ocultar preparação sem aplicação e respectivas acções; conservar estados e evidências; distinguir requisitos não aplicáveis nos relatórios | Teste API de preservação/reabertura: 18 asserções; relatório: 11 testes; types/build e cenário QA |
| `f2150b19` | Dossier → simulador: resumos editáveis dos dados recuperados, nomes territoriais ligados aos IDs correctos, edições deliberadas preservadas, percurso de análise existente | Reutilização: 12 testes; nomes exactos: 14 asserções API; types/build e gravação/recuperação visual QA |
| `5bda74ae` | Situação do terreno acompanha Não/Sim/Por definir na interface, gravação/validação, recuperação e relatórios; resposta anterior conservada | Delta de terreno: 4 testes frontend e 32 asserções API; types/build e três estados no navegador QA |
**Reconciliação Git:** base `origin/main` consultada novamente no remoto: `e1f010f941e9993645e131b34ec1f4c3fe82c71b`. As três candidatas são antecessoras do conjunto reconciliado; não há commits exclusivos de `main` a resolver nesta conferência. Os quatro worktrees existentes estavam limpos e os dois worktrees de outras entregas permaneceram intocados. Conferido o delta acumulado sem erros de whitespace; sem migrations, novas rotas ou alterações de dependências. Não foi reescrito histórico nem repetida a suite QA aceite. Esta conferência de integração não é nova prova funcional de todo o portal.
**Disponibilização e reversão:** esta entrega permanece em QA, sem integração em `main` ou deploy nesta passagem. Instalação futura usa o build e o deploy Git existentes; não requer importação, migração de dados, publicação de conteúdos ou novos papéis. Reversão de código deve conservar o payload cifrado, decisões, respostas anteriores, referências, relatórios emitidos e histórico; não restaurar uma base antiga nem eliminar campos/registos para reverter a interface. Conferir apenas o delta autorizado no destino quando a entrega for integrada.
**Formulação do saldo:** Homologação inalterada por esta entrega; aplicação transversal ainda parcial. A carteira e os projectos âncora permanecem prioritários no mesmo plano de estabilização. A visibilidade das 29 fichas foi confirmada contra o manifesto aprovado; a continuidade carteira → oportunidade → dossier → simulador permanece por concluir. Não declarar «sem reposição confirmada» para a visibilidade, pois isso contradiz a evidência registada e a conferência de leitura de `7d798579`. Não considerar encerrada a estabilização integrada por causa dessa visibilidade ou dos incrementos do simulador.
Pendência concreta preservada na matriz: transportar a origem `from_dossier` ao escolher Preparar investimento, sem presumir associações inexistentes. Nenhuma correcção dessa lacuna foi incluída silenciosamente neste conjunto.
## Carteira no percurso integrado — reconciliação do saldo, 23/10/2026
Decisão expressa da Tempo: carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas a separação não significa adiamento nem exclusão do percurso. A experiência deve conservar o contexto em corredor/localização → oportunidades → associações documentadas a âncoras e clusters → dossier → simulador. A recuperação da visibilidade é prioritária até confirmação da causa e da reposição; os incrementos adaptativos não constituem prova dessa reposição.
**Correcção do saldo comunicado nos últimos incrementos:** a causa e a reposição já estavam registadas na secção Preservação do concluído e no recibo existente. O importador passou a criar rascunhos sem reconciliar a aprovação documental anterior; o delta de `e65cb269` em `ImportInvestmentPortfolio.php` confirma a retirada de `--approval` e do caminho de publicação previamente aprovado. Não se atribui a essa alteração a data exacta de desaparecimento num endereço. A reposição anterior foi nominal, auditada e preservou relações/conteúdo: 15 projectos, 8 clusters, 6 corredores.
**Conferência actual de leitura na homologação:** a rota pública `/carteira` apresenta 29 fichas. Os 29 identificadores e títulos coincidem integralmente com `approved-portfolio-visibility-20261023.json`: zero ausentes e zero títulos divergentes. Contagem apresentada: 15 projectos, 8 clusters, 6 corredores. A ficha Cadeia de Valor do Arroz por Projecto de Processamento abre em `/carteira/57a4ee7a-4c07-4e26-b8f5-e07726e41512`. A sessão do navegador estava autenticada, mas foi consultada a rota pública, cujo controlador aplica a mesma consulta `visible()`; a prova anónima da reposição anterior mantém-se no recibo. Não foram executadas novas publicações, importações, escritas remotas ou reposições de dados nesta conferência.
**Continuidade ainda por concluir, distinta de desaparecimento:** a ficha de arroz consultada informa não ter oportunidades publicadas associadas. Não inferir ligações por semelhança de corredor ou cadeia de valor. No código actual, a carteira transmite `from_dossier` para uma oportunidade e a descoberta valida a origem; a ligação Preparar investimento em `investment/detail.tsx` transmite apenas `source=opportunity:{id}`. Falta preservar explicitamente essa origem documental no arranque/contexto privado. A presença das 29 fichas não demonstra essa continuidade até ao simulador, nem encerra a aplicação transversal. Manter este ponto na matriz existente e reaproveitar o percurso, sem criar cadastros paralelos ou novas associações presumidas.
## Situação do terreno acompanha a necessidade de terra — delta QA, 23/10/2026
Origem: decisão expressa da Tempo após `f2150b19`. No simulador ligado ao mesmo dossier, **Não** oculta Situação do terreno sem a exigir; **Sim** apresenta o campo com o valor já guardado e permite alterá-lo; **Por definir** identifica a pendência e permite guardar sem resposta. O simulador autónomo conserva o comportamento anterior. Não se infere a necessidade de terra da localização, do corredor ou da escolha de um documento.
A decisão vem de `investor_path.facts.land_needed`, já existente. O contexto de preparação transporta-a na referência guardada do simulador; versões anteriores recuperam a aplicabilidade S10. Informação ausente ou inválida permanece por definir. Arrendamento/DUAT já declarados no dossier podem preencher a situação correspondente quando a necessidade é Sim, sem sobrescrever uma edição deliberada no simulador. A área das instalações e os requisitos documentais/jurídicos não são alterados por inferência.
Gravação e validação: Situação do terreno continua opcional, sem criar obrigatoriedade para Sim nem para Por definir. Quando fornecida, conserva a validação das opções existentes. Na actualização do rascunho, o servidor consulta o dossier real vinculado ao seu titular, mesmo sem o parâmetro `context`, e conserva o valor e o nome anteriores enquanto o campo estiver oculto. A decisão guardada na referência é sincronizada com esse dossier; uma referência antiga não muda a decisão real. Recuperar Sim volta a mostrar o mesmo valor, editável. Valores anteriores permanecem no rascunho cifrado; fontes, decisões, resultados históricos e relatórios já emitidos não são apagados ou reescritos. Sem migration e sem alteração na homologação.
Relatórios e CSV do plano usam a mesma decisão: Sim apresenta a situação declarada ou a informação em falta; Não apresenta a explicação de não aplicabilidade à preparação actual, sem apresentar a resposta histórica como actual; Por definir apresenta a pendência. O modelo comum alimenta Word, impressão/PDF e restantes exportações do relatório. Os dados originais do rascunho permanecem conservados.
Verificação apenas do delta: quatro testes `investment-land-preparation.test.mjs` (três estados, recuperação, versão anterior, referência inválida, validação, CSV e impressão), um teste API em base QA isolada com 32 asserções (gravação incompleta, decisão real, omissão do campo, recuperação e alteração após Sim); types/build aprovados. No navegador, exclusivamente no dossier sintético QA: Não ocultou o campo; Sim permitiu guardar DUAT; Por definir mostrou a pendência e permitiu guardar; regressar a Sim recuperou DUAT seleccionado e editável. A cobertura transversal continua parcial. O saldo actual da carteira e da continuidade integrada encontra-se na reconciliação acima; este incremento não demonstra a reposição.
## Continuidade oportunidade → localização → dossier → simulador — incremento QA, 23/10/2026
Origem: decisão expressa da Tempo de aperfeiçoar o percurso adaptativo por incrementos. Mantém-se `7ca94967` como primeiro recorte QA; nenhuma destas duas alterações foi instalada na homologação nesta passagem. Diagnóstico de visibilidade e continuidade integrada da carteira: consultar a reconciliação acima; não os considerar adiados ou fora do percurso.
| Ponto encontrado | Delta neste incremento | Limite preservado |
| --- | --- | --- |
| Corredor/localização já escolhidos reapareciam no simulador como perguntas abertas | Reutilizar os mesmos identificadores e nomes do cadastro; mostrar resumos editáveis para valores herdados sem alteração local | Não inventar posto/localidade; manter as localizações específicas de todas as fichas. O simulador conserva o contexto geral da primeira escolha, sem agregar cálculos de oportunidades diferentes |
| Título, descrição e sector recuperados voltavam a pedir preenchimento | Resumo do valor recuperado e opção Alterar; falta de dado, erro de validação ou edição deliberada mantém a pergunta aberta | A gravação existente, as versões e a validação no servidor continuam a aplicar-se; não aceitar dados apenas porque o resumo foi mostrado |
| Entrar no simulador desde o dossier pedia novamente o tipo de percurso/finalidade | Entrada nova selecciona o percurso existente Avaliar o meu investimento / Apenas simular; opção Alterar disponível | Escolha técnica de navegação, não declaração jurídica. Não substituir uma finalidade previamente escolhida pelo utilizador; não escolher detalhe, perfil, regime fiscal ou respostas desconhecidas |
| Reutilização mostrava UUID, chaves e versões internas ao investidor | Componente comum apresenta nome do investimento, referências legíveis e regresso ao dossier | Identificadores, origem, versões e histórico permanecem nos registos; divergências não são resolvidas silenciosamente |
No simulador, os dados conhecidos aparecem como resumos com **Alterar**. Complete os restantes campos e use a gravação e o regresso ao dossier já existentes. Quando houver divergência, abra a comparação e seleccione expressamente os valores a substituir. Os nomes territoriais acompanham os identificadores correspondentes, incluindo após gravação/recuperação; uma escolha local divergente conserva o seu nome. Fontes sem nome recuperável não são mostradas como UUID no resumo.
Validação do delta: `context-inheritance.test.mjs` (12 testes, incluindo gravação/recuperação, edições/limpezas deliberadas, outro dossier, divergência geográfica e finalidade anterior); `InvestmentCorridorContextTest::test_simulator_reuses_names_for_the_exact_chosen_territory_and_sector_without_creating_a_location` (14 asserções, base QA isolada). A conferência visual utiliza exclusivamente o dossier sintético QA. A entrada pública/autenticação e a configuração de corredores reutilizam as provas aceites. Não se declara concluída a reutilização transversal em todos os consumidores; subsistem campos próprios do simulador e divergências a resolver pelo investidor, não dados a presumir.
Conferência visual concluída: entrada pelo mesmo dossier, percurso de análise seleccionado, título/resumo/sector como resumos editáveis, Sofala/Dondo recuperados, posto/localidade em falta sem preenchimento presumido. Uma edição deliberada do título no simulador sobreviveu à actualização da página; os restantes resumos continuaram herdados. Types e build passaram. A lacuna Situação do terreno identificada neste incremento foi tratada no delta QA descrito acima; o ocultamento de S10 em `7ca94967` não a tinha resolvido. Não alterar a aplicabilidade jurídica ou a área de instalações por inferência dessa resposta.
## Território adaptado ao corredor — decisão funcional da Tempo, 23/10/2026
Revisão funcional `0025309b` integrada pelo Git e disponível na homologação. Conferência do destino apenas de leitura: o serviço territorial para Maputo devolve exactamente as duas unidades existentes, sem consultar ou gravar um dossier real. A prova de recuperação e confirmação de Sofala/Dondo foi feita no dossier sintético QA, com indicação **Dados confirmados**. A prévia apresenta nomes e alterações úteis ao investidor, omitindo campos de identificadores internos.
@@ -270,11 +401,19 @@ Relatório desta observação: [verificação editorial](../../../outputs/invest
## Matriz executável
Decisão transversal da Tempo, 23/10/2026: percurso adaptativo com entradas por ideia/oportunidade/sector/localização; perguntar uma vez, preservar edições deliberadas e contexto entre ecrãs/autenticação; seleccionar opções únicas; ocultar exigências não aplicáveis sem confundir ausência de informação com dispensa; conservar especificidades na selecção múltipla. Mostrar posição, pendências e próxima acção. Relatórios/exportações seguem a mesma aplicabilidade. Origem: pedido expresso da Tempo nesta data; implementação incremental nos componentes existentes, sem novos aprovadores.
Incremento de apresentação: o dossier conserva as 19 etapas e o histórico, mas apresenta formulários apenas das etapas com preparação permitida. As restantes têm resumo do fundamento e podem regressar ao rever a decisão. A ferramenta Terra/DUAT deixa de ser proposta quando `land_needed=no`; `unknown` mantém a preparação disponível. Relatórios do plano reúnem requisitos documentais `not_applicable` numa explicação, mantendo requisitos em falta ou indeterminados. Esta alteração não remove obrigações jurídicas nem modifica dados guardados. A aplicação transversal a todos os formulários, entradas e exportações permanece por conferir; não se encerra esse requisito por documentação.
| Menu / ecrã | Componente React | Campos / entidade / tabela | Rota ou API | Perfil / regra | Evidência |
| ------------------------------ | ------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- |
| Preparação adaptativa / relatório | `investor-path/show`, `investment-requirements`, `project-dossier-report` | Aplicabilidade e contexto existentes; histórico preservado | `/investimentos/{id}`, `/aplicabilidade`; exportações existentes do plano | Tempo, 23/10/2026: ocultar preparação sem aplicação; desconhecido não é dispensa | Incremento QA: `InvestmentCorridorContextTest::test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence` (18 asserções); relatório 11 testes; types/build; cenário sintético no navegador. Cobertura transversal ainda parcial. |
| Dossier → simulador sem repetir dados | `dossier-prefill`, `simulation-form`, `context-inheritance` | Campos existentes, `context_reuse`, `selection_labels`; sem migration | `/investimentos/{id}/contexto`, `/simulador-fiscal?context={id}` | Resumos apenas para valores herdados intactos; alteração deliberada preservada; perfil/detalhe/regime não presumidos | Incremento QA de continuidade, 23/10/2026: 12 testes de reutilização e 14 asserções de nomes exactos; cobertura geral permanece parcial |
| Situação do terreno adaptada | `simulation-form`, `investment-land-preparation`, `project-dossier`; `SimulationFormDrafts` | `land_needed` existente; situação e rótulo conservados no rascunho cifrado | APIs existentes de contexto/rascunho e exportações do plano | Tempo: Não oculta, Sim recupera e permite editar, Por definir identifica pendência sem bloquear rascunho | Delta QA 23/10/2026: 4 testes frontend e 32 asserções API; sem migration; homologação não alterada |
| Início | `PortalInvestidor` | Sectores, oportunidades, recursos publicados | `/`, APIs existentes de homepage | Visitante; contagens do cadastro | `PublicPortalNavigationTest`, navegador |
| Catálogo / Educação | `investment/catalogue` | Filtros, educação, pivots, facetas | `/oportunidades`, `/educacao`; `/api/v1/investment/search` | Visitante; filtros em intersecção, montantes com moeda | `InvestmentUnificationTest` |
| Cluster / Corredor / Âncora | `portfolio/index`, `portfolio/show` | Dossiers, relações, cadeias e oportunidades | `/clusters`, `/corredores`, `/projectos-ancora`, `/carteira/{id}` | Visitante; dossier publicado; cadeia pública | `InvestmentPortfolioTest`, testes territoriais |
| 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 |
@@ -2637,4 +2776,4 @@ Preservação por comparação dos registos preexistentes: **32 documentos, 31 r
**Guia da equipa no destino:** abrir a [gestão única](https://teste-investidor.tempo.co.mz/gestao-conteudos) → **Gerir conteúdos, versões e histórico** → escolher **Rascunhos**, pesquisar o título e abrir **Editar artigo** ou **Ler / pré-visualizar artigo**. Corrigir o texto e o tratamento de imagem → **Pré-visualizar alterações** → **Guardar artigo como rascunho** → **Ler revisão guardada**. Usar **Voltar ao acervo para metadados e publicação** para corrigir título, descrição, origem, enquadramento, acesso e direitos. **Publicar** continua separado, conforme a autorização e os campos existentes, quando a equipa decidir publicar. Guardar uma edição de um artigo publicado retira-o até nova publicação expressa. Não há comandos ou intervenção do Codex neste percurso corrente.
Recibo: [cadastro, acessos e preservação no destino](legacy-editorial-installation-receipt-20261022.json). A [lista única dos 50](../v2/auditoria-portal/legado-correspondencia-integral.md) e o CSV existente contêm os endereços **privados** efectivos dos 28; não são links de artigos já publicados. Capturas locais em `outputs/legacy-editorial-install-20261022/`, incluindo `editor-recovered-homologation.png`. Mantêm-se dois originais incompletos e a revisão editorial dos textos pela equipa. A Tempo não declarou ter conferido commit, pacote ou guia. **A carteira vazia e os projectos âncora continuam como falha separada, sem causa confirmada; esta entrega não a resolve.**
Recibo: [cadastro, acessos e preservação no destino](legacy-editorial-installation-receipt-20261022.json). A [lista única dos 50](../v2/auditoria-portal/legado-correspondencia-integral.md) e o CSV existente contêm os endereços **privados** efectivos dos 28; não são links de artigos já publicados. Capturas locais em `outputs/legacy-editorial-install-20261022/`, incluindo `editor-recovered-homologation.png`. Mantêm-se dois originais incompletos e a revisão editorial dos textos pela equipa. A Tempo não declarou ter conferido commit, pacote ou guia. **A carteira vazia e os projectos âncora continuam como falha separada, sem causa confirmada; esta entrega não a resolve.** (Saldo histórico desta entrega de 22/10; superado pela reposição auditada e reconciliação de 23/10 descritas acima. Não representa o estado actual.)
File diff suppressed because one or more lines are too long
+23 -18
View File
@@ -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';
@@ -27,6 +28,10 @@ const empty = (v: unknown) =>
(Array.isArray(v) && v.length === 0);
const text = (v: unknown) =>
typeof v === 'string' ? v : JSON.stringify(v ?? '');
const displayField = (values: Record<string, unknown>, key: string) =>
key.endsWith('_id')
? text(values[`${key}_label`]) || (empty(values[key]) ? 'não informado' : 'Referência escolhida; designação por recuperar')
: text(values[key]) || 'não informado';
export function DossierPrefill(props: Props) {
const {
contextId,
@@ -104,7 +109,7 @@ export function DossierPrefill(props: Props) {
),
);
const differences = Object.keys(source).filter(
(k) => k !== 'context_applicability_snapshot' && text(props.values[k]) !== text(source[k]),
(k) => k !== 'context_applicability_snapshot' && !k.endsWith('_label') && text(props.values[k]) !== text(source[k]),
);
const linkedRevision =
consumer === 'simulator'
@@ -117,39 +122,37 @@ export function DossierPrefill(props: Props) {
aria-label="Dados do dossier neste formulário"
>
<h2 className="font-bold">
Mesmo investimento · contexto v{view.context.revision}
Dados do seu investimento
</h2>
<p>
UUID {contextId} ·{' '}
<Link
className="underline"
href={`/investimentos/${contextId}`}
>
recuperar dossier
{view.case.title}
</Link>
. Mestres, declarações deste formulário e conteúdo já submetido
mantêm-se separados.
{' · '}<Link className="underline" href={`/investimentos/${contextId}`}>Voltar ao dossier</Link>
</p>
<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
? `O contexto do dossier difere da revisão vinculada a este registo (v${linkedRevision}).`
? 'Os dados do dossier mudaram desde a última análise.'
: 'Confira os valores deste formulário para o contexto atual; não há uma revisão anterior vinculada a estas entradas.'}{' '}
Os valores e resultados anteriores permanecem guardados;
confira os pressupostos antes de os usar no cenário atual.
</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}>
Fonte disponível:{' '}
<Link className="underline" href={r.href}>
{r.title}
</Link>{' '}
· {r.kind} · v{r.version ?? 'não exposta'}. {r.key}
</Link>
</p>
),
)}
@@ -214,11 +217,10 @@ export function DossierPrefill(props: Props) {
</label>
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{text(props.values[k]) ||
'não informado'}
{displayField(props.values, k)}
</td>
<td className="p-2 [overflow-wrap:anywhere]">
{text(source[k]) || 'não informado'}
{displayField(source, k)}
</td>
</tr>
))}
@@ -232,17 +234,20 @@ export function DossierPrefill(props: Props) {
onClick={() => {
const patch: Record<string, unknown> = {};
for (const k of selected)
{
patch[k] = source[k] ?? '';
if (`${k}_label` in source) patch[`${k}_label`] = source[`${k}_label`];
}
const scope = `dossier:${contextId}:${consumer}`;
let raw = String(props.values.context_reuse ?? '');
try {
const metadata = JSON.parse(raw);
if(metadata.scope === scope) {
for(const key of selected) delete metadata.inherited?.[key];
for(const key of Object.keys(patch)) delete metadata.inherited?.[key];
raw=JSON.stringify(metadata);
}
} catch { /* Legacy metadata does not grant ownership of other fields. */ }
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(selected.map(key=>[key,undefined]))},patch,raw,props.allowedFields);
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(Object.keys(patch).map(key=>[key,undefined]))},patch,raw,props.allowedFields);
props.onApply({...patch,...(props.persistInheritance === false ? {} : {context_reuse:adopted.metadata})}, true);
setSelected([]);
setReviewed(false);
@@ -256,7 +261,7 @@ export function DossierPrefill(props: Props) {
</button>
</details>
)}
{!readOnly && reviewed && <p>Contexto v{view.context.revision} recuperado neste formulário, sem nova confirmação dos dados compatíveis. As edições locais conservam-se; as declarações e submissões formais mantêm os seus controlos próprios.</p>}
{!readOnly && reviewed && <p>Os dados disponíveis foram recuperados. Preencha o que falta; as suas alterações conservam-se.</p>}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
@@ -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>;
}
@@ -2,11 +2,14 @@ import { Link } from '@inertiajs/react';
import type { RequirementsAssessment } from '@/lib/investor-context';
import { card } from '@/pages/investment/shared';
export default function InvestmentRequirements({assessment, contextId}: {assessment?: RequirementsAssessment | null; contextId: string}) {
export default function InvestmentRequirements({assessment, contextId, excludedSteps = []}: {assessment?: RequirementsAssessment | null; contextId: string; excludedSteps?: string[]}) {
if (!assessment?.requirements.length) return null;
const labels: Record<string,string> = {selected:'Localização escolhida; direito não inferido',missing:'Informação em falta',conditional:'Conforme as condições do projecto',declared:'Actividades declaradas; enquadrar modalidade',undetermined:'Procedimento específico por determinar'};
const groups = new Map<string, RequirementsAssessment['requirements']>();
for (const row of assessment.requirements) groups.set(row.opportunity_key,[...(groups.get(row.opportunity_key)??[]),row]);
for (const row of assessment.requirements) {
if (excludedSteps.includes(row.step)) continue;
groups.set(row.opportunity_key,[...(groups.get(row.opportunity_key)??[]),row]);
}
return <section className="space-y-4" aria-label="Regras e documentos por oportunidade"><h2 className="text-xl font-bold">Requisitos por oportunidade e contexto</h2>
<p>{assessment.fiscal_notice}</p>
{[...groups.entries()].map(([key, rows])=><details key={key} className={card}><summary className="cursor-pointer font-semibold">{rows[0].opportunity_title} · {rows.length} aspectos a conferir</summary>{rows.map(r=><article key={r.opportunity_key+':'+r.code} className="my-3 space-y-2 border-t pt-3">
+32 -9
View File
@@ -1,6 +1,8 @@
import { simulationDraftEtag } from '@/lib/simulation-draft-etag';
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import { inheritSimulatorFields } from '@/lib/context-inheritance';
import { inheritSimulatorFields, reusedInvestmentValue } from '@/lib/context-inheritance';
import { investmentLandPreparation } from '@/lib/investment-land-preparation';
import {
PublicDocumentContent,
type PublicDocument,
@@ -807,6 +809,7 @@ export function SimulationForm() {
const canSavePersonalData = Boolean(auth?.user);
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
const [draft, setDraft] = useState<SimulationDraft>({});
const [editingReused, setEditingReused] = useState<string[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
const [contextReview, setContextReview] = useState({
@@ -1246,7 +1249,7 @@ export function SimulationForm() {
if (payload.data) {
if (cancelled) return;
draftIdRef.current = payload.data.id;
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(payload.data.version_no);
setDraftExpiresAt(payload.data.expires_at ?? null);
if (canSavePersonalData)
@@ -1368,7 +1371,7 @@ export function SimulationForm() {
if (currentDraft) {
draftIdRef.current = currentDraft.id;
draftEtagRef.current =
currentResponse.headers.get('ETag');
simulationDraftEtag(currentResponse.headers.get('ETag'));
sessionStorage.setItem(draftIdKey, currentDraft.id);
}
}
@@ -1430,7 +1433,7 @@ export function SimulationForm() {
);
draftIdRef.current = payload.data.id;
setServerFieldErrors({});
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(payload.data.version_no);
lastSavedFingerprintRef.current = fingerprint;
sessionStorage.setItem(draftIdKey, payload.data.id);
@@ -1591,7 +1594,7 @@ export function SimulationForm() {
)
throw new Error('Não foi possível abrir este dossier privado.');
draftIdRef.current = payload.data.id;
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(payload.data.version_no);
setDraftExpiresAt(null);
setSelectedAccountDraftId(payload.data.id);
@@ -1654,7 +1657,7 @@ export function SimulationForm() {
);
const record = (payload as { data: ApiSimulationDraftV1 }).data;
draftIdRef.current = record.id;
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
setDraftVersionNo(record.version_no);
setDraftExpiresAt(null);
setSelectedAccountDraftId(record.id);
@@ -1754,7 +1757,7 @@ export function SimulationForm() {
(payload as { expires_at: string }).expires_at,
);
draftEtagRef.current =
response.headers.get('ETag') ?? draftEtagRef.current;
simulationDraftEtag(response.headers.get('ETag')) ?? draftEtagRef.current;
}
setContinueLaterSaved(true);
setSaveStatus(
@@ -2373,7 +2376,7 @@ export function SimulationForm() {
);
const draftId = draftIdValue;
draftIdRef.current = draftId;
draftEtagRef.current = currentResponse.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(currentResponse.headers.get('ETag'));
if (!draftEtagRef.current)
throw new Error(
'O servidor não devolveu a versão actual do rascunho. Recarregue e tente novamente.',
@@ -2407,7 +2410,7 @@ export function SimulationForm() {
throw new Error(
'O servidor devolveu uma confirmação de anexação inválida.',
);
draftEtagRef.current = response.headers.get('ETag');
draftEtagRef.current = simulationDraftEtag(response.headers.get('ETag'));
const sources = payload.data?.simulation_sources;
if (Array.isArray(sources)) setSimulationSources(sources);
setDraft((currentDraft) => ({
@@ -3282,6 +3285,16 @@ export function SimulationForm() {
: 'Avalie um projecto definido. A análise fiscal acompanha os fluxos financeiros, mas não prova rentabilidade nem concede incentivos.'}
</output>
)}
{current.id === 'location' && contextId && (
<aside className="rounded border p-3" aria-label="Necessidade de terra">
<p>{investmentLandPreparation(draft.context_applicability_snapshot).need === 'no'
? 'Não precisa de terra neste investimento. A situação do terreno não é pedida; os valores anteriores ficam conservados.'
: investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes'
? 'Precisa de terra neste investimento. Reveja a situação do terreno já preenchida ou indique-a quando disponível.'
: 'Falta definir se precisa de terra. Pode guardar o rascunho e continuar sem preencher a situação do terreno.'}</p>
<a className="underline" href={`/investimentos/${contextId}`}>Rever a necessidade de terra no dossier</a>
</aside>
)}
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => {
const fieldRequired =
@@ -3294,6 +3307,16 @@ export function SimulationForm() {
: Boolean(field.required);
const fieldError =
serverFieldErrors[field.key] ?? errors[field.key];
const reused = contextId && ready && !fieldError && !editingReused.includes(`${contextId}:${field.key}`)
? reusedInvestmentValue(`dossier:${contextId}:simulator`, field.key, draft) : null;
if (reused !== null) return (
<div key={field.key} id={`field-${field.key}`} className="rounded border bg-emerald-50 p-3">
<p className="font-semibold">{field.label}</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere]">{reused}</p>
<p className="text-sm">Recuperado do dossier.</p>
<button type="button" className="underline" onClick={() => setEditingReused((keys) => [...keys, `${contextId}:${field.key}`])}>Alterar {field.label.toLocaleLowerCase('pt-MZ')}</button>
</div>
);
const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) => <p key={index} role="note" className="mt-1 text-sm text-amber-950">{ivaIssueText(issue)}</p>);
if (field.options) {
const parentKey =
+50
View File
@@ -19,6 +19,22 @@ export function inheritCompatibleFields(scope:string, values:Record<string,unkno
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
for(const [key,value] of Object.entries(source)) {
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
if (key === 'context_applicability_snapshot') {
// Read-only assessment of the current dossier, not a deliberate form answer.
if (signature(values[key]) !== signature(value)) patch[key] = value;
next[key] = signature(value); inherited.push(key);
continue;
}
if (key.endsWith('_label')) {
const base = key.slice(0, -6);
const baseOwned = previous[base] === signature(values[base]);
const baseVacant = empty(values[base]) && !(base in previous);
if (!(base in source) || (geo.includes(base) && geoConflict) || (!baseOwned && !baseVacant && signature(values[base]) !== signature(source[base]))) continue;
// A label describes its identifier; it must never lag behind an adopted identifier.
if (signature(values[key]) !== signature(value)) patch[key] = value;
next[key] = signature(value); inherited.push(key);
continue;
}
const current=values[key];
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
const owned=previous[key]===signature(track(current));
@@ -42,6 +58,40 @@ export function inheritCompatibleFields(scope:string, values:Record<string,unkno
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
}
/** Presentation only: a saved, unedited inherited value can be shown without asking again. */
export function reusedInvestmentValue(scope: string, key: string, values: Record<string, unknown>): string | null {
if (!['journey_type', 'purpose', 'project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id'].includes(key)) return null;
try {
const metadata = JSON.parse(String(values.context_reuse ?? ''));
if (metadata.version !== 1 || metadata.scope !== scope || empty(values[key]) || metadata.inherited?.[key] !== signature(values[key])) return null;
if (key.endsWith('_id') || ['journey_type', 'purpose'].includes(key)) {
const label = values[`${key}_label`];
return typeof label === 'string' && label.trim() && metadata.inherited?.[`${key}_label`] === signature(label) ? label : null;
}
return typeof values[key] === 'string' ? values[key] : null;
} catch { return null; }
}
/** Report the lineage without changing a stored answer or disclosing the tracking hash. */
export function investmentFieldOrigin(values: Record<string, unknown>, key: string, fallback: string): string {
try {
const reuse = JSON.parse(String(values.context_reuse ?? ''));
if (reuse.version !== 1 || !/^dossier:.+:simulator$/.test(reuse.scope) || !reuse.inherited?.[key]) return fallback;
const snapshot = JSON.parse(String(values.context_applicability_snapshot ?? ''));
const origin = snapshot.field_provenance?.[key];
const source = origin?.kind === 'opportunity' ? `oportunidade «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'process_declaration' || origin?.kind === 'dossier' ? 'dados introduzidos pelo utilizador no dossier'
: origin?.kind === 'sector' ? `catálogo sectorial «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'territory' ? `catálogo territorial «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'linked_simulation' ? 'simulador ligado ao mesmo dossier'
: 'dossier de investimento';
const version = origin?.version ? `, versão ${origin.version}` : '';
return reuse.inherited[key] === signature(values[key])
? `Herdado de ${source}${version}`
: `Alterado posteriormente pelo utilizador; origem anterior: ${source}${version}`;
} catch { return fallback; }
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
@@ -0,0 +1,19 @@
export type LandPreparation = { need: 'yes' | 'no' | 'unknown'; reason: string };
/** Preparation decision from the same saved dossier, never evidence of a land right. */
export function investmentLandPreparation(raw: unknown): LandPreparation {
try {
const snapshot = JSON.parse(String(raw ?? ''));
if (snapshot.version !== 1) return { need: 'unknown', reason: '' };
const decision = snapshot.land_preparation;
if (decision && ['yes', 'no', 'unknown'].includes(decision.need))
return { need: decision.need, reason: typeof decision.reason === 'string' ? decision.reason : '' };
// Existing snapshots already carry S10 applicability; no reimport is required.
const step = Array.isArray(snapshot.path?.steps) ? snapshot.path.steps.find((row: { code?: string } | null) => row?.code === 'S10') : null;
return { need: step?.applicability === 'not_needed_for_preparation' ? 'no' : step?.applicability === 'applicable' ? 'yes' : 'unknown', reason: typeof step?.reason === 'string' ? step.reason : '' };
} catch { return { need: 'unknown', reason: '' }; }
}
export function landStatusVisible(draft: Record<string, unknown>): boolean {
return !draft.context_applicability_snapshot || investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes';
}
+1
View File
@@ -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[];
+13 -3
View File
@@ -229,8 +229,8 @@ function makeSections(
{
field: 'Projecto',
value: absent(text(draft, 'project_title')),
origin: 'Informação introduzida no dossier',
classification: 'Declarada pelo utilizador',
origin: rows.find(row => row[1] === 'Título')?.[3] ?? 'Informação introduzida no dossier',
classification: 'Origem indicada no dossier',
},
{
field: 'Data de geração',
@@ -316,7 +316,16 @@ function makeSections(
preparation.calculation_notice,
preparation.notice,
);
for (const requirement of preparation.requirements ?? [])
const omitted = (preparation.requirements ?? []).filter(
(requirement: { state: string }) => requirement.state === 'not_applicable',
);
if (omitted.length)
section?.paragraphs.push(
`Não exigido neste contexto: ${omitted.map((requirement: { label: string; reason: string }) => `${requirement.label} (${requirement.reason})`).join('; ')}.`,
);
for (const requirement of preparation.requirements ?? []) {
// Keep an explicitly associated historical document visible without requiring it again.
if (requirement.state === 'not_applicable' && !requirement.document_id && !requirement.document) continue;
section?.rows.push({
field: requirement.label,
value:
@@ -331,6 +340,7 @@ function makeSections(
classification:
'Preparação informativa — simulação, sem submissão à Administração',
});
}
}
if (zone?.href)
section?.rows.push({
+25 -1
View File
@@ -1,4 +1,6 @@
import { displayValue } from './display-value.ts';
import { investmentFieldOrigin } from './context-inheritance.ts';
import { investmentLandPreparation } from './investment-land-preparation.ts';
import { monthlyIvaInput, monthlyIvaKey, readMonthlyIvaRecord, readMonthlyIvaSettings } from './monthly-iva.ts';
import { modeloAPurchaseReferences } from './iva-modelo-a.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
@@ -686,7 +688,29 @@ export function buildProjectDossierRows(
]);
}
}
return rows;
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']);
else rows.push(['Localização', 'Preparação do terreno', land.need === 'no' ? 'Situação do terreno não aplicável à preparação actual; valores anteriores conservados.' : 'Definir se precisa de terra; pode continuar a preparar o investimento.', 'Sem inferência de dispensa legal']);
}
const lineageFields: Record<string, string> = {'Título':'project_title','Resumo':'project_summary','Sector':'sector_id','Província':'province_id','Distrito':'district_id','Posto administrativo':'admin_post_id','Localidade':'locality_id'};
return rows.map(row => {
const key = lineageFields[row[1]];
return key && row[2] ? [row[0], row[1], row[2], investmentFieldOrigin(draft, key, row[3])] : row;
});
}
export function exportProjectDossierCsv(
+10
View File
@@ -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';
@@ -0,0 +1,5 @@
/** Compression can mark the representation weak; the draft version token itself is unchanged. */
export function simulationDraftEtag(header: string | null): string | null {
if (header && /^W\/"sfd-[0-9a-f-]{36}-v[1-9][0-9]*"$/i.test(header)) return header.slice(2);
return header;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { inheritSimulatorFields } from './context-inheritance.ts';
import { landStatusVisible } from './investment-land-preparation.ts';
import { csvCell } from './csv.js';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
@@ -738,7 +739,7 @@ export const simulationFieldGroups: Record<
label: 'Situação do terreno',
options: 'land_status',
showWhen: (d) =>
d.purpose !== 'simulate_only' || isInvestorProjectJourney(d),
landStatusVisible(d) && (d.purpose !== 'simulate_only' || isInvestorProjectJourney(d)),
},
{
key: 'area_required',
+3 -3
View File
@@ -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}>
+7 -2
View File
@@ -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"
+16 -6
View File
@@ -531,7 +531,7 @@ export default function InvestmentPath(path: Path) {
/>
)}
{!!path.investment_context?.opportunity_choices?.length && <OpportunityChoices choices={path.investment_context.opportunity_choices}/>}
<InvestmentRequirements assessment={path.investment_context?.requirements_assessment} contextId={path.case.id}/>
<InvestmentRequirements assessment={path.investment_context?.requirements_assessment} contextId={path.case.id} excludedSteps={path.steps.filter((step) => !step.preparation_allowed).map((step) => step.code)}/>
{!!path.content_sources?.length && (
<section
className="space-y-2 rounded-xl border p-4"
@@ -569,15 +569,14 @@ export default function InvestmentPath(path: Path) {
<h2 className="font-semibold">Próxima acção</h2>
{path.next ? (
<a className="underline" href={path.next.href}>
{path.next.action}: {path.next.code} · {path.next.title}
{path.next.action}: {path.next.title}
</a>
) : (
<p>Reveja as pendências e o acompanhamento.</p>
)}
<p className="text-sm">
Pode preparar etapas independentes enquanto aguarda
informação. Percurso v{path.definition_version}; os outros
roteiros mantêm a numeração original.
informação. As etapas abaixo adaptam-se às suas escolhas.
</p>
</section>
<nav
@@ -686,14 +685,25 @@ export default function InvestmentPath(path: Path) {
</button>
</form>
)}
{Object.entries(path.phases).map(([number, label]) => (
{path.steps.some((step) => !step.preparation_allowed) && (
<section className="rounded-xl border p-4" aria-label="Etapas sem aplicação">
<h2 className="font-semibold">Não precisa de preparar neste contexto</h2>
<ul>
{path.steps.filter((step) => !step.preparation_allowed).map((step) => (
<li key={step.code}>{step.title}: {step.reason || 'Conforme as escolhas guardadas.'}</li>
))}
</ul>
<p className="text-sm">Pode rever estas escolhas acima. Os dados e o histórico destas etapas ficam conservados.</p>
</section>
)}
{Object.entries(path.phases).filter(([number]) => path.steps.some((step) => step.phase === Number(number) && step.preparation_allowed)).map(([number, label]) => (
<section className="space-y-3" key={number}>
<h2 className="text-lg font-semibold">
Fase {number} · {label}
</h2>
<div className="grid items-start gap-3 md:grid-cols-2">
{path.steps
.filter((step) => step.phase === Number(number))
.filter((step) => step.phase === Number(number) && step.preparation_allowed)
.map((step) => (
<Stage
key={step.code}
@@ -30,6 +30,80 @@ class InvestmentCorridorContextTest extends TestCase
$entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
return Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
}
public function test_land_status_uses_the_real_decision_preserves_inactive_values_and_allows_incomplete_drafts(): void
{
$id=$this->postJson('/investimentos',['title'=>'QA situação do terreno','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$setNeed=function(string $need) use($id): array {
$version=$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version');
$this->postJson('/investimentos/'.$id.'/aplicabilidade',['version'=>$version,'land_needed'=>$need,'external_funding'=>'unknown','viability'=>'unknown','reason'=>'Cenário QA '.$need])->assertOk();
return $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
};
$view=$setNeed('yes');
$body=['context'=>$id,'personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','context_applicability_snapshot'=>$view['prefill']['simulator']['context_applicability_snapshot'],'land_status'=>'duat','selection_labels'=>['land_status'=>'DUAT']]];
$created=$this->postJson('/api/v1/simulation-drafts',$body,['Idempotency-Key'=>(string)Str::uuid()])->assertCreated();
$draft=$created->json('data.id');$etag=$created->headers->get('ETag');
foreach(['no','unknown'] as $need){
$view=$setNeed($need);
$this->assertSame($need,json_decode($view['prefill']['simulator']['context_applicability_snapshot'],true)['land_preparation']['need']);
$update=$this->putJson('/api/v1/simulation-drafts/'.$draft,['personal_snapshot'=>[],'economic_snapshot'=>['purpose'=>'simulate_only','context_applicability_snapshot'=>$body['economic_snapshot']['context_applicability_snapshot']]],['If-Match'=>$etag])->assertOk();
$recovered=$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk();
// Omitting context cannot bypass the real dossier decision. Hidden values are retained.
$this->assertSame('duat',$recovered->json('data.economic_snapshot.land_status'));
$this->assertSame('DUAT',$recovered->json('data.economic_snapshot.selection_labels.land_status'));
$this->assertSame($need,json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'),true)['land_preparation']['need']);
$etag=$update->headers->get('ETag');
}
$setNeed('yes');
$this->flushSession();$this->actingAs($this->investor);
$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status','duat');
$updated=$this->putJson('/api/v1/simulation-drafts/'.$draft,[...$body,'economic_snapshot'=>['purpose'=>'simulate_only','land_status'=>'leased']],['If-Match'=>$etag])->assertOk();
$recovered=$this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status','leased');
$this->assertSame('yes',json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'),true)['land_preparation']['need']);
$this->putJson('/api/v1/simulation-drafts/'.$draft,[...$body,'economic_snapshot'=>['purpose'=>'simulate_only','land_status'=>'invalid']],['If-Match'=>$updated->headers->get('ETag')])->assertUnprocessable();
$empty=app(\App\Services\InvestorContext::class)->prefill(['fields'=>['site_tenure'=>'lease'],'revision'=>0,'provenance'=>[],'land_preparation'=>['need'=>'yes','reason'=>'QA']]);
$this->assertSame('leased',$empty['simulator']['land_status']);
}
public function test_simulator_reuses_names_for_the_exact_chosen_territory_and_sector_without_creating_a_location(): void
{
$id=$this->postJson('/investimentos',['title'=>'QA continuidade simulador','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$province=collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code','07')->id;
$district=app(\App\Services\InvestmentGeography::class)->publicReferences()->where('parent_id',$province)->where('location_type','district')->firstOrFail();
$record=$this->record('beira');
$plan=$this->postJson('/investimentos/'.$id.'/contexto/previa',['version'=>$this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>[['source'=>'opportunity:'.$record->id,'corridor_key'=>'beira','territory'=>'territory:'.$district->id]]])->assertOk()->json();
$sim=app(\App\Services\InvestorContext::class)->prefill($plan)['simulator'];
$this->assertSame('investor',$sim['journey_type']);
$this->assertSame('simulate_only',$sim['purpose']);
$this->assertSame($province,$sim['province_id']);
$this->assertSame('Sofala',$sim['province_id_label']);
$this->assertSame($district->id,$sim['district_id']);
$this->assertSame($district->name,$sim['district_id_label']);
$this->assertSame((string)$record->sector_id,$sim['sector_id']);
$this->assertSame(\App\Models\Sector::findOrFail($record->sector_id)->name,$sim['sector_id_label']);
$this->assertArrayNotHasKey('locality_id_label',$sim);
$this->assertArrayNotHasKey('admin_post_id_label',$sim);
}
public function test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence(): void
{
$id = $this->postJson('/investimentos', ['title' => 'QA adaptação sem perda', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
$this->assertNotNull($view['tools']['land']);
$this->assertSame('undetermined', collect($view['steps'])->firstWhere('code', 'S10')['applicability']);
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Informação territorial já guardada'])->assertOk();
$view = $this->getJson('/investimentos/'.$id)->json();
$facts = ['land_needed' => 'no', 'external_funding' => 'no', 'viability' => 'unknown', 'reason' => 'Investimento sem necessidade de terra nem financiamento externo'];
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts])->assertOk();
$view = $this->getJson('/investimentos/'.$id)->assertJsonPath('tools.land', null)->json();
$step = collect($view['steps'])->firstWhere('code', 'S10');
$this->assertFalse($step['preparation_allowed']);
$this->assertSame('preparing', $step['saved_state']);
$this->assertSame('Informação territorial já guardada', $step['detail']['reason']);
$this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Não deve executar etapa sem aplicação'])->assertUnprocessable();
$this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts, 'land_needed' => 'yes'])->assertOk();
$view = $this->getJson('/investimentos/'.$id)->assertOk()->json();
$this->assertNotNull($view['tools']['land']);
$this->assertSame('preparing', collect($view['steps'])->firstWhere('code', 'S10')['state']);
$this->assertCount(19, $view['steps']);
}
public function test_tempo_configuration_uses_existing_ids_and_maputo_includes_both_units(): void
{
foreach(config('investment_corridors') as $key=>$entry){
@@ -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,130 @@
<?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;
public function test_draft_version_guard_still_rejects_a_real_stale_version(): void
{
$draft = new \App\Models\SimulationFormDraft;
$draft->forceFill(['id'=>(string)Str::uuid(),'version_no'=>2]);
$service=app(\App\Services\SimulationFormDrafts::class);
$guard=new \ReflectionMethod($service,'requireMatch');
$tag=$service->etag($draft);
foreach (['W/'.$tag, str_replace('-v2','-v1',$tag)] as $invalid) {
$request=\Illuminate\Http\Request::create('/','PUT'); $request->headers->set('If-Match',$invalid);
try { $guard->invoke($service,$request,$draft); $this->fail('A mismatching version must be rejected'); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) { $this->assertSame(412,$error->getStatusCode()); }
}
$request=\Illuminate\Http\Request::create('/','PUT'); $request->headers->set('If-Match',$tag);
$this->assertNull($guard->invoke($service,$request,$draft));
}
public function test_beira_documentary_membership_opens_the_same_published_opportunities_without_inferring_by_province(): void
{
$portfolio = InvestmentPortfolioController::visible()->where('content->code', 'COR-02')->firstOrFail();
$service = app(CorridorAgribusinessOpportunities::class);
$ids = $service->portfolioOpportunityIds($portfolio);
$this->assertCount(7, $ids);
$this->get('/carteira/'.$portfolio->id)->assertOk()->assertInertia(fn(Assert $page) => $page->has('opportunities', 7));
$catalogueIds=app(\App\Services\InvestmentCatalogue::class)->query(['corridor_id'=>$portfolio->corridor_id])->pluck('opportunities.id')->all();
$this->assertSame([], array_values(array_diff($ids,$catalogueIds)));
$opportunity=Opportunity::findOrFail($ids[0]);
$this->get('/oportunidades/'.$opportunity->slug.'?from_dossier='.$portfolio->id)->assertOk();
$origins=app(InvestmentOpportunityChoices::class)->portfolioOrigins([$portfolio->id],$opportunity->id);
$this->assertSame($portfolio->id,$origins[0]['id']);
$this->assertSame('corridor',$origins[0]['kind']);
$other=Opportunity::published()->whereNotIn('id',$ids)->firstOrFail();
$this->get('/oportunidades/'.$other->slug.'?from_dossier='.$portfolio->id)->assertNotFound();
$this->assertSame([], $service->portfolioOpportunityIds(InvestmentPortfolioController::visible()->whereNotNull('project_id')->firstOrFail()));
}
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();
}
}
+47 -1
View File
@@ -7,11 +7,57 @@ test('legacy draft without a compatible source keeps its shape and identity',()=
assert.equal(Object.hasOwn(inheritSimulatorFields(legacy),'compatible_fields_snapshot'),false);
});
import test from 'node:test';
import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
import { inheritCompatibleFields, inheritSimulatorFields, reusedInvestmentValue } from '../../resources/js/lib/context-inheritance.ts';
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
test('known investment questions become editable summaries after save/recovery, while missing and deliberate edits remain questions', () => {
const scope='dossier:A:simulator';
const source={project_title:'Florestas — Beira',project_summary:'Preparar investimento florestal',province_id:'p1',province_id_label:'Sofala',sector_id:'3',sector_id_label:'Agronegócio'};
const reused=inheritCompatibleFields(scope,{},source,null);
const draft={...reused.patch,context_reuse:reused.metadata,purpose:'simulate_only',journey_type:'investor'};
const saved=partitionSimulationDraft(draft);
const recovered=restoreSimulationDraft(saved.personal_snapshot,saved.economic_snapshot);
assert.equal(reusedInvestmentValue(scope,'project_title',recovered),source.project_title);
assert.equal(reusedInvestmentValue(scope,'province_id',recovered),'Sofala');
assert.equal(reusedInvestmentValue(scope,'sector_id',recovered),'Agronegócio');
assert.equal(reusedInvestmentValue(scope,'district_id',recovered),null);
assert.equal(reusedInvestmentValue('dossier:B:simulator','project_title',recovered),null);
assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,project_title:'Edição deliberada'}),null);
assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,project_title:''}),null);
assert.equal(reusedInvestmentValue(scope,'province_id',{...recovered,province_id_label:''}),null);
assert.equal(reusedInvestmentValue(scope,'currency',{...recovered,currency:'MZN'}),null);
assert.equal(reusedInvestmentValue(scope,'project_title',{...recovered,context_reuse:'invalid'}),null);
});
test('local geography and sector keep their labels when the dossier proposes different identifiers', () => {
const scope='dossier:A:simulator';
const source={province_id:'p1',province_id_label:'Sofala',district_id:'d1',district_id_label:'Dondo',sector_id:'3',sector_id_label:'Agronegócio'};
const local={province_id:'p2',province_id_label:'Gaza',district_id:'d2',district_id_label:'Outro distrito',sector_id:'4',sector_id_label:'Turismo'};
const reconciliation=inheritCompatibleFields(scope,local,source,null);
assert.deepEqual(reconciliation.patch,{});
assert.ok(reconciliation.conflicts.includes('province_id'));
const first=inheritCompatibleFields(scope,{},source,null);
const changed=inheritCompatibleFields(scope,{...first.patch,context_reuse:first.metadata},{...source,province_id:'p3',province_id_label:'Manica',district_id:'',district_id_label:''},first.metadata);
assert.equal(changed.patch.province_id,'p3');assert.equal(changed.patch.province_id_label,'Manica');
assert.equal(changed.patch.district_id,'');assert.equal(changed.patch.district_id_label,'');
const stale=inheritCompatibleFields(scope,{province_id_label:'Nome antigo'},source,null);
assert.equal(stale.patch.province_id,'p1');assert.equal(stale.patch.province_id_label,'Sofala');
});
test('investment entry selects the existing simulation route without replacing an earlier deliberate purpose', () => {
const scope='dossier:A:simulator';
const source={journey_type:'investor',journey_type_label:'Avaliar o meu investimento',purpose:'simulate_only',purpose_label:'Apenas simular'};
const first=inheritCompatibleFields(scope,{},source,null);
const values={...first.patch,context_reuse:first.metadata};
assert.equal(reusedInvestmentValue(scope,'journey_type',values),'Avaliar o meu investimento');
assert.equal(reusedInvestmentValue(scope,'purpose',values),'Apenas simular');
const old=inheritCompatibleFields(scope,{journey_type:'small_business',purpose:'seek_partner'},source,null);
assert.deepEqual(old.patch,{});
assert.equal(reusedInvestmentValue(scope,'purpose',{purpose:'seek_partner',context_reuse:old.metadata}),null);
});
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
const saved={...first.patch,context_reuse:first.metadata};
@@ -0,0 +1,22 @@
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('reports distinguish opportunity inheritance, dossier declarations and later edits without rewriting the snapshot', async()=>{
const source={project_title:'Título do promotor',project_summary:'Resumo da oportunidade',sector_id:'1',sector_id_label:'Agronegócio',context_applicability_snapshot:JSON.stringify({version:1,field_provenance:{project_summary:{kind:'opportunity',title:'Florestas — Corredor da Beira',version:2},sector_id:{kind:'opportunity',title:'Florestas — Corredor da Beira',version:2},project_title:{kind:'process_declaration',context_revision:1}}})};
const inherited=inheritCompatibleFields('dossier:A:simulator',{},source,null);
const draft={...inherited.patch,context_reuse:inherited.metadata};
let rows=buildProjectDossierRows(draft);
assert.match(rows.find(r=>r[1]==='Resumo')[3],/Herdado de oportunidade «Florestas/);
assert.match(rows.find(r=>r[1]==='Título')[3],/dados introduzidos pelo utilizador no dossier/);
const edited={...draft,project_summary:'Resumo alterado no simulador'}; const before=JSON.stringify(edited);
rows=buildProjectDossierRows(edited);
assert.match(rows.find(r=>r[1]==='Resumo')[3],/Alterado posteriormente pelo utilizador; origem anterior: oportunidade/);
assert.match(exportProjectDossierCsv(edited),/origem anterior: oportunidade/);
const report=createProjectDossierReport(edited,{selectedSections:new Set(['summary','project'])});
assert.match(await createProjectDossierPrintHtml(report,[],false),/Resumo alterado no simulador/);
assert.equal(JSON.stringify(edited),before);
assert.equal(buildProjectDossierRows({project_summary:'Legado'}).find(r=>r[1]==='Resumo')[3],'Declarado pelo utilizador');
});
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { investmentLandPreparation, landStatusVisible } from '../../resources/js/lib/investment-land-preparation.ts';
import { simulationFieldGroups, validateSimulationDraft, partitionSimulationDraft, restoreSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
import { buildProjectDossierRows, exportProjectDossierCsv } from '../../resources/js/lib/project-dossier.ts';
import { createProjectDossierReport, createProjectDossierPrintHtml } from '../../resources/js/lib/project-dossier-report.ts';
const snapshot = (need) => JSON.stringify({version:1,context_revision:2,land_preparation:{need,reason:'Decisão guardada no dossier'}});
const draft = (need) => ({purpose:'simulate_only',journey_type:'investor',land_status:'duat',land_status_label:'DUAT',context_applicability_snapshot:snapshot(need)});
test('no hides land status and yes restores the same saved value; unknown is a pending decision without a required answer', () => {
const field=simulationFieldGroups.location.find(field=>field.key==='land_status');
for(const need of ['no','yes','unknown']) {
const input=draft(need);const before=JSON.stringify(input);
assert.equal(field.showWhen(input),need==='yes');
assert.equal(Boolean(validateSimulationDraft({...input,land_status:''},'location').land_status),false);
const saved=partitionSimulationDraft(input);const recovered=restoreSimulationDraft(saved.personal_snapshot,saved.economic_snapshot);
assert.equal(recovered.land_status,'duat');assert.equal(recovered.land_status_label,'DUAT');
assert.equal(investmentLandPreparation(recovered.context_applicability_snapshot).need,need);
assert.equal(JSON.stringify(input),before);
}
});
test('old snapshots use S10 and absent or malformed decisions never imply yes or no', () => {
for(const [applicability,need] of [['applicable','yes'],['not_needed_for_preparation','no'],['undetermined','unknown']])
assert.equal(investmentLandPreparation(JSON.stringify({version:1,path:{steps:[{code:'S10',applicability}]}})).need,need);
for(const raw of ['', 'invalid', '{}', '{"version":1,"land_preparation":{"need":"invalid"}}'])
assert.equal(investmentLandPreparation(raw).need,'unknown');
assert.equal(landStatusVisible({}),true); // Standalone simulator preserves its previous behaviour.
});
test('current dossier assessment refreshes after server recovery without overwriting the saved land answer', () => {
const scope='dossier:A:simulator';
const inherited=inheritCompatibleFields(scope,{}, {context_applicability_snapshot:snapshot('yes'),land_status:'duat'},null);
const recovered={...inherited.patch,context_reuse:inherited.metadata,context_applicability_snapshot:snapshot('no')};
const restored=inheritCompatibleFields(scope,recovered,{context_applicability_snapshot:snapshot('yes')},recovered.context_reuse);
assert.equal(restored.patch.context_applicability_snapshot,snapshot('yes'));
assert.equal(Object.hasOwn(restored.patch,'land_status'),false);
});
test('report, CSV and print reflect yes, no and unknown without treating historical DUAT as current when hidden', async () => {
for(const need of ['yes','no','unknown']) {
const input=draft(need);const before=JSON.stringify(input);
const rows=buildProjectDossierRows(input);
assert.equal(rows.some(row=>row[1]==='Situação do terreno'),need==='yes');
const report=createProjectDossierReport(input,{selectedSections:new Set(['location'])});
const print=await createProjectDossierPrintHtml(report,[],false);
const csv=exportProjectDossierCsv(input);
if(need==='yes'){assert.match(print,/DUAT/);assert.match(csv,/DUAT/);}
else {assert.doesNotMatch(print,/DUAT/);assert.doesNotMatch(csv,/DUAT/);assert.match(print,need==='no'?/valores anteriores conservados/:/Definir se precisa de terra/);}
assert.equal(JSON.stringify(input),before);
}
});
@@ -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);
});
@@ -12,6 +12,29 @@ import {
validateDossierAttachments,
} from '../../resources/js/lib/project-dossier-report.ts';
test('report separates inapplicable documents from missing and unknown requirements without changing the saved snapshot', async () => {
const requirements = [
{ label: 'Título DUAT', state: 'not_applicable', reason: 'Arrendamento declarado' },
{ label: 'Identificação', state: 'missing', reason: 'Pedido em preparação' },
{ label: 'Capital externo', state: 'indeterminate', reason: 'Origem por determinar' },
];
const snapshot = JSON.stringify({ version: 1, context_revision: 2, preparation: {
enabled: true, mode: 'registration', version: 'QA', next: 'Completar identificação',
calculation_notice: 'Cálculo separado', notice: 'Preparação', requirements,
} });
const draft = { context_applicability_snapshot: snapshot };
const report = createProjectDossierReport(draft, { selectedSections: new Set(['sources']) });
const section = report.sections.find((section) => section.id === 'sources');
assert.ok(section);
assert.equal(section.rows.some((row) => row.field === 'Título DUAT'), false);
assert.match(section.paragraphs.join(' '), /Não exigido neste contexto: Título DUAT \(Arrendamento declarado\)/);
assert.equal(section.rows.find((row) => row.field === 'Identificação').value, 'Documento por associar');
assert.equal(section.rows.find((row) => row.field === 'Capital externo').value, 'Informação em falta para decidir');
const print = await createProjectDossierPrintHtml(report, [], false);
assert.match(print, /Arrendamento declarado/);
assert.equal(draft.context_applicability_snapshot, snapshot);
});
test('executive summary exposes the same financial result and period without replacing missing data by zero', () => {
const draft = { currency:'MZN', financial_tools_snapshot:serializeFinancialToolsSnapshot({budgetPeriod:'Mensal',loan:{principal:'1200',rate:'0',months:'12',rateType:'nominal',periodMonths:'1',gracePeriods:'0',upfrontFee:'0',recurringFee:'0'},breakEven:{fixed:'100.01',price:'30',variable:'20'},funding:{total:'1000',secured:'800'},incomes:[{label:'Receita QA',amount:'1000'}],expenses:[{label:'Despesa QA',amount:'1100'}]}) };
const report = createProjectDossierReport(draft,{selectedSections:new Set(['summary','finance'])});
+10 -1
View File
@@ -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';
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { simulationDraftEtag } from '../../resources/js/lib/simulation-draft-etag.ts';
test('compressed draft reads use the exact version token while genuinely stale versions stay different', () => {
const tag='"sfd-6c07a968-7ff9-42fa-b252-072bb1acf221-v2"';
assert.equal(simulationDraftEtag(`W/${tag}`),tag);
assert.equal(simulationDraftEtag(tag),tag);
assert.notEqual(simulationDraftEtag(`W/${tag}`),tag.replace('-v2','-v3'));
assert.equal(simulationDraftEtag('W/"another-resource"'),'W/"another-resource"');
assert.equal(simulationDraftEtag(null),null);
});