diff --git a/app/Http/Controllers/InvestorPathController.php b/app/Http/Controllers/InvestorPathController.php
index 43505e78..687be094 100644
--- a/app/Http/Controllers/InvestorPathController.php
+++ b/app/Http/Controllers/InvestorPathController.php
@@ -88,7 +88,8 @@ class InvestorPathController extends Controller
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
{
- $data = $path->view($request->user(), $id);
+ $choice = $request->validate(['opportunity' => 'nullable|string|max:100'])['opportunity'] ?? null;
+ $data = $path->view($request->user(), $id, $choice);
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data);
}
diff --git a/app/Http/Requests/StoreSimulationFormDraftRequest.php b/app/Http/Requests/StoreSimulationFormDraftRequest.php
index cc8e7838..2ac875ab 100644
--- a/app/Http/Requests/StoreSimulationFormDraftRequest.php
+++ b/app/Http/Requests/StoreSimulationFormDraftRequest.php
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
- 'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
+ 'context_opportunity', 'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -73,6 +73,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:1000000'],
+ 'economic_snapshot.context_opportunity' => ['nullable', 'string', 'max:100'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
diff --git a/app/Services/InvestmentConsumerScope.php b/app/Services/InvestmentConsumerScope.php
index 14bd1547..82cae6cd 100644
--- a/app/Services/InvestmentConsumerScope.php
+++ b/app/Services/InvestmentConsumerScope.php
@@ -8,6 +8,27 @@ use Illuminate\Support\Arr;
/** An identified opportunity within the authorised dossier, never the account's last choice. */
final class InvestmentConsumerScope
{
+ public function key(array $context, ?string $key, bool $required = false): ?string
+ {
+ $choices = $context['opportunity_choices'] ?? [];
+ $key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null);
+ abort_if($key && !collect($choices)->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.');
+ abort_if($required && count($choices) > 1 && !$key, 422, 'Escolha a oportunidade em tratamento.');
+ return $key;
+ }
+
+ public function facts(array $payload, array $context, ?string $key): array
+ {
+ $key = $this->key($context, $key);
+ $path = $payload['investor_path'] ?? [];
+ $defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown'];
+ // A dossier-wide dispensation cannot decide a distinct opportunity's needs.
+ if ($key && count($context['opportunity_choices'] ?? []) > 1) return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])];
+ if ($key && isset($path['opportunity_facts'][$key])) return [...$defaults, ...$path['opportunity_facts'][$key]];
+ if (count($context['opportunity_choices'] ?? []) > 1) return $defaults;
+ return [...$defaults, ...($path['facts'] ?? [])];
+ }
+
public function context(array $context, ?string $key): array
{
$choices = $context['opportunity_choices'] ?? [];
@@ -44,6 +65,7 @@ final class InvestmentConsumerScope
$context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn($r) => $r['opportunity_key'] === $key));
$context['consumer_opportunity'] = $choice;
$context['complements'] = array_values(array_filter($context['complements'] ?? [], fn($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '',10),array_column($choice['portfolio_origins'] ?? [],'id'),true)));
+ $context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices'=>[$choice], 'fields'=>Arr::except($context['fields'],['project_title','project_summary']), 'complements'=>$context['complements']]);
return $context;
}
diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php
index 90e27e59..1be96221 100644
--- a/app/Services/InvestorContext.php
+++ b/app/Services/InvestorContext.php
@@ -586,8 +586,8 @@ 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['land_preparation'] = $this->landPreparation($case, $opportunity);
+ $path = app(InvestorPath::class)->view($actor, $id, $opportunity);
$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']))))];
@@ -602,12 +602,19 @@ final class InvestorContext
}
}
- $preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($ctx, $this->preparationDocuments($id)) : null;
+ $preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess(app(InvestmentConsumerScope::class)->context($ctx, $opportunity), $this->preparationDocuments($id)) : null;
$ctx['preparation_assessment'] = $preparation;
+ $prefill = $this->prefill($ctx, $opportunity);
+ $saved = $this->desk->decode($case->payload);
+ if (!empty($saved['reuse_consent'])) {
+ $contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== '');
+ $prefill['simulator'] = [...$contacts, ...$prefill['simulator']];
+ $prefill['land'] = [...$contacts, ...(!empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']];
+ }
return ['private_investment' => $preparation, 'preparation_documents' => $this->preparationDocuments($id), 'simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']),
'can_write' => $path['canWrite'], 'next' => $path['next'], 'results' => $path['results'], 'requests' => $path['requests'],
- 'prefill' => $this->prefill($ctx, $opportunity), 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
+ 'prefill' => $prefill, 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
}
public function preparationDocuments(string $id): array
@@ -615,18 +622,21 @@ 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
+ public function landPreparation(\stdClass $case, ?string $opportunity = null): array
{
- $path = $this->desk->decode($case->payload)['investor_path'] ?? [];
- $need = $path['facts']['land_needed'] ?? 'unknown';
+ $payload = $this->desk->decode($case->payload);
+ $ctx = $this->state($case);
+ $key = app(InvestmentConsumerScope::class)->key($ctx, $opportunity);
+ $path = $payload['investor_path'] ?? [];
+ $need = app(InvestmentConsumerScope::class)->facts($payload, $ctx, $key)['land_needed'];
- return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $path['fact_reasons']['land_needed'] ?? ''];
+ return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $key ? ($path['opportunity_fact_reasons'][$key]['land_needed'] ?? '') : ($path['fact_reasons']['land_needed'] ?? '')];
}
public function prefill(array $ctx, ?string $opportunity = null): array
{
$original = $ctx;
- if ($opportunity) $ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity);
+ $ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity);
$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.
@@ -646,6 +656,7 @@ final class InvestorContext
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
+ 'consumer_opportunity' => $ctx['consumer_opportunity']['opportunity']['key'] ?? null,
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
diff --git a/app/Services/InvestorPath.php b/app/Services/InvestorPath.php
index 90f86e14..e3881da5 100644
--- a/app/Services/InvestorPath.php
+++ b/app/Services/InvestorPath.php
@@ -68,7 +68,7 @@ final class InvestorPath
}
/** @return array */
- public function view(User $actor, string $id): array
+ public function view(User $actor, string $id, ?string $opportunity = null): array
{
abort_unless($this->available(), 404);
$case = $this->desk->case($actor, $id);
@@ -79,18 +79,26 @@ final class InvestorPath
$payload = $this->desk->decode($case->payload);
$path = $payload['investor_path'] ?? [];
$context = app(InvestorContext::class)->state($case);
- $facts = $path['facts'] ?? ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown'];
+ $scope = app(InvestmentConsumerScope::class);
+ $opportunity = $scope->key($context, $opportunity);
+ $allChoices = $context['opportunity_choices'] ?? [];
+ $facts = $scope->facts($payload, $context, $opportunity);
+ $factReasons = $opportunity ? ($path['opportunity_fact_reasons'][$opportunity] ?? []) : ($path['fact_reasons'] ?? []);
+ $context = $scope->context($context, $opportunity);
$records = DB::table('journey_steps')->where('journey_id', $journey->id)->whereNull('deleted_at')->get()->keyBy('step_code');
$steps = [];
foreach ($definition['steps'] as $step) {
$row = $records->get($step['code']);
abort_unless($row !== null, 409, 'Etapa em falta; reparar a instalação sem reconstruir o histórico.');
$detail = $this->desk->decode($row->path_detail);
+ if ($opportunity && (count($allChoices) > 1 || isset($detail['opportunities'][$opportunity]))) {
+ $detail = $detail['opportunities'][$opportunity] ?? ['state' => 'not_started'];
+ }
$value = $step['branch'] ? ($facts[$step['branch']] ?? 'unknown') : 'yes';
$applicable = $value === 'unknown' ? 'undetermined' : ($value === 'no' ? 'not_needed_for_preparation' : 'applicable');
$state = $value === 'no' ? 'dispensed' : $detail['state'];
$steps[] = [...$step, 'id' => $row->id, 'version' => $row->version_no, 'state' => $state, 'saved_state' => $detail['state'],
- 'applicability' => $applicable, 'reason' => $value === 'no' ? ($path['fact_reasons'][$step['branch']] ?? '') : ($detail['reason'] ?? ''),
+ 'applicability' => $applicable, 'reason' => $value === 'no' ? ($factReasons[$step['branch']] ?? '') : ($detail['reason'] ?? ''),
'detail' => $detail, 'preparation_allowed' => $value !== 'no',
'action_gaps' => []];
}
@@ -143,7 +151,8 @@ final class InvestorPath
$landCase = (int) $case->user_id === $actor->id
? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get()
- ->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id) : null;
+ ->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id
+ && (!$allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null;
$preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null;
if ($preparation && $preparation['enabled']) {
@@ -152,11 +161,12 @@ final class InvestorPath
return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
- 'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $path['fact_reasons'] ?? [],
+ 'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
+ 'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect($path['sector_contexts'] ?? [])->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect($path['content_sources'] ?? [])->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null,
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
- 'investment_context' => ($owner || $manager) ? $context : null,
+ 'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
@@ -167,25 +177,31 @@ final class InvestorPath
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'definition'])
->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title']]),
- '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',
+ 'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
+ 'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
- '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]];
+ 'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '')) : null]];
}
/** @param array $input */
public function facts(User $actor, string $id, array $input): void
{
- $data = Validator::make($input, ['version' => 'required|integer|min:1',
+ $data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100',
'land_needed' => ['required', Rule::in(['yes', 'no', 'unknown'])], 'external_funding' => ['required', Rule::in(['yes', 'no', 'unknown'])],
'viability' => ['required', Rule::in(['unknown', 'proceed', 'reformulate', 'archive'])],
'reason' => 'required|string|min:3|max:3000'])->validate();
DB::transaction(function () use ($actor, $id, $data): void {
$case = $this->writable($actor, $id, $data['version']);
$payload = $this->desk->decode($case->payload);
+ $choice = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true);
foreach (['land_needed', 'external_funding', 'viability'] as $key) {
- $payload['investor_path']['facts'][$key] = $data[$key];
- $payload['investor_path']['fact_reasons'][$key] = $data['reason'];
+ if ($choice) {
+ $payload['investor_path']['opportunity_facts'][$choice][$key] = $data[$key];
+ $payload['investor_path']['opportunity_fact_reasons'][$choice][$key] = $data['reason'];
+ } else {
+ $payload['investor_path']['facts'][$key] = $data[$key];
+ $payload['investor_path']['fact_reasons'][$key] = $data['reason'];
+ }
}
$this->savePayload($case, $payload);
$this->desk->event($this->desk->case($actor, $id), $actor, 'path_applicability_declared', $data);
@@ -195,7 +211,7 @@ final class InvestorPath
/** @param array $input */
public function stage(User $actor, string $id, string $code, array $input): void
{
- $data = Validator::make($input, ['version' => 'required|integer|min:1', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))],
+ $data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))],
'reason' => 'required|string|min:3|max:3000', 'action' => ['required', Rule::in(['prepare', 'submit', 'execute', 'operate', 'review', 'record_external'])],
'document_id' => 'nullable|uuid', 'authority' => 'nullable|string|max:200', 'reference' => 'nullable|string|max:300',
'context_revision' => 'nullable|integer|min:0', 'context_reviewed' => 'nullable|boolean', 'decision_date' => 'nullable|date_format:Y-m-d|before_or_equal:today'])->validate();
@@ -203,7 +219,8 @@ final class InvestorPath
$case = $this->desk->case($actor, $id, false, true);
abort_if($case->closed_at, 409);
abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão actual.');
- $view = $this->view($actor, $id);
+ $key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true);
+ $view = $this->view($actor, $id, $key);
$step = $this->findStep($view, $code);
abort_unless($step !== null && $step['preparation_allowed'], 422, 'Etapa dispensada de preparação; altere os pressupostos com fundamento.');
$state = $data['state'];
@@ -238,7 +255,10 @@ final class InvestorPath
}
abort_if($state === 'completed' && ($step['context_review_required'] ?? false) && (! (bool) ($data['context_reviewed'] ?? false) || ($data['context_revision'] ?? -1) !== ($view['investment_context']['revision'] ?? 0)), 422, 'O contexto desta etapa exige nova conferência fundamentada; o registo anterior permanece no histórico.');
$detail = [...$data, 'context_dependency_hash' => $view['investment_context']['dependency_hash'] ?? null, 'fintech_fingerprint' => $view['fintech']['review_fingerprints'][$code] ?? null, 'scope' => $scope, 'actor_id' => $actor->id, 'recorded_at' => now()->toIso8601String()];
- DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($detail),
+ $stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail'));
+ if ($key) $stored['opportunities'][$key] = $detail;
+ else $stored = [...$stored, ...$detail];
+ DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored),
'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'),
'version_no' => $step['version'] + 1, 'updated_at' => now()]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
@@ -267,11 +287,12 @@ final class InvestorPath
abort_unless(is_array($parsed) && ($parsed['version'] ?? null) === 1 && is_array($parsed['data'] ?? null), 422, 'Snapshot financeiro incompatível.');
$payload = $this->desk->decode($case->payload);
$context = app(InvestorContext::class)->state($case);
+ $opportunity = app(InvestmentConsumerScope::class)->key($context, $economic['context_opportunity'] ?? null, true);
if ($context['revision'] > 0) {
abort_unless(($data['context_revision'] ?? -1) === $context['revision'], 409, 'O contexto mudou; recupere e confira os pressupostos antes de devolver o resultado.');
abort_unless($data['context_reviewed'] ?? false, 422, 'Confirme a revisão dos pressupostos financeiros para este contexto.');
}
- $resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision']);
+ $resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision'].($opportunity ? ':'.$opportunity : ''));
foreach ($payload['investor_path']['results'] ?? [] as $result) {
if ($result['source_key'] === $resultKey) {
return $result;
@@ -280,6 +301,7 @@ final class InvestorPath
abort_unless((int) $case->version === $data['version'], 409, 'Recupere a versão actual do dossier.');
$result = ['id' => (string) Str::uuid(), 'source_key' => $resultKey, 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
'snapshot_sha256' => $draft->snapshot_sha256, 'financial_snapshot' => $snapshot,
+ 'context_opportunity' => $opportunity,
'currency' => $economic['currency'] ?? null, 'engine' => 'financial-calculators:v1',
'kind' => 'local_calculation_inputs', 'context_state' => 'current', 'context_revision' => $context['revision'], 'context_dependency_hash' => $context['dependency_hash'] ?? app(InvestorContext::class)->dependencyHash([]), 'recorded_at' => now()->toIso8601String()];
$payload['investor_path']['results'][] = $result;
diff --git a/app/Services/SimulationFormDrafts.php b/app/Services/SimulationFormDrafts.php
index 8ec41ed7..29fa33b9 100644
--- a/app/Services/SimulationFormDrafts.php
+++ b/app/Services/SimulationFormDrafts.php
@@ -470,7 +470,10 @@ final class SimulationFormDrafts
private function landContext(array $economic, \stdClass $case, ?array $previous = null): array
{
- $decision = app(InvestorContext::class)->landPreparation($case);
+ $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true);
+ $key = $economic['context_opportunity'] ?? $snapshot['consumer_opportunity'] ?? null;
+ $key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), is_string($key) ? $key : null);
+ $decision = app(InvestorContext::class)->landPreparation($case, $key);
if ($decision['need'] !== 'yes' && $previous !== null) {
if (array_key_exists('land_status', $previous)) $economic['land_status'] = $previous['land_status'];
else unset($economic['land_status']);
@@ -482,6 +485,8 @@ final class SimulationFormDrafts
$snapshot = ['version' => 1, 'context_revision' => app(InvestorContext::class)->state($case)['revision']];
}
$snapshot['land_preparation'] = $decision;
+ $snapshot['consumer_opportunity'] = $key;
+ if ($key) $economic['context_opportunity'] = $key;
$economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
return $economic;
diff --git a/resources/js/components/dossier-navigation-context.tsx b/resources/js/components/dossier-navigation-context.tsx
index dfa03ca1..2f146b48 100644
--- a/resources/js/components/dossier-navigation-context.tsx
+++ b/resources/js/components/dossier-navigation-context.tsx
@@ -153,7 +153,7 @@ export function NavigationContextProvider({
}
const view = focus
? await contextApi(
- `/investimentos/${focus.case_id}`,
+ `/investimentos/${focus.case_id}${direct===focus.case_id && new URL(url,'https://context.invalid').searchParams.get('opportunity') ? '?opportunity='+encodeURIComponent(new URL(url,'https://context.invalid').searchParams.get('opportunity')!) : ''}`,
)
: null;
if (!stopped) {
@@ -285,7 +285,7 @@ export function NavigationContextSummary() {
{view && (
Abrir dossier atual
@@ -293,7 +293,7 @@ export function NavigationContextSummary() {
{view?.next && (
Preparar {view.next.code}
diff --git a/resources/js/components/dossier-prefill.tsx b/resources/js/components/dossier-prefill.tsx
index 46a77ad2..56250b35 100644
--- a/resources/js/components/dossier-prefill.tsx
+++ b/resources/js/components/dossier-prefill.tsx
@@ -32,6 +32,10 @@ const empty = (v: unknown) =>
(Array.isArray(v) && v.length === 0);
const text = (v: unknown) =>
typeof v === 'string' ? v : JSON.stringify(v ?? '');
+function savedOpportunity(values: Record): string {
+ if (values.context_opportunity) return String(values.context_opportunity);
+ try { return JSON.parse(String(values.context_applicability_snapshot ?? '')).consumer_opportunity ?? ''; } catch { return ''; }
+}
const displayField = (values: Record, key: string) =>
key.endsWith('_id')
? text(values[`${key}_label`]) || (empty(values[key]) ? 'não informado' : 'Referência escolhida; designação por recuperar')
@@ -52,7 +56,11 @@ export function DossierPrefill(props: Props) {
const [selected, setSelected] = useState([]);
const [reviewed, setReviewed] = useState(false);
const [message, setMessage] = useState('');
- const [opportunity, setOpportunity] = useState(() => String(props.values.context_opportunity || props.initialOpportunity || (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('opportunity') || '' : '')));
+ const userChose = useRef(false);
+ const [opportunity, setOpportunity] = useState(() => String(props.initialOpportunity || (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('opportunity') || '' : '') || savedOpportunity(props.values)));
+ useEffect(() => {
+ if (!userChose.current && !opportunity && enabled) { const saved = savedOpportunity(props.values); if (saved) setOpportunity(saved); }
+ }, [enabled, props.values, opportunity]);
const loadedOpportunity = useRef('');
useEffect(() => {
if (!contextId) return;
@@ -80,7 +88,7 @@ export function DossierPrefill(props: Props) {
const chosen = opportunity || (choices.length === 1 ? choices[0].opportunity.key : '');
const source = view.prefill[consumer] ?? {};
const now = latest.current;
- if (consumer !== 'simulator' && chosen && now.values.context_opportunity !== chosen && (!now.allowedFields || now.allowedFields.includes('context_opportunity'))) now.onApply({context_opportunity:chosen},true);
+ if ((chosen || consumer === 'simulator') && now.values.context_opportunity !== chosen && (!now.allowedFields || now.allowedFields.includes('context_opportunity'))) now.onApply({context_opportunity:chosen},true);
const inherited = inheritCompatibleFields(`dossier:${contextId}:${consumer}`, now.values, source, now.values.context_reuse, now.allowedFields);
if (Object.keys(inherited.patch).length || (now.persistInheritance !== false && now.values.context_reuse !== inherited.metadata)) {
// The shared reconciliation has already selected safe fields; metadata travels with the same saved form.
@@ -139,19 +147,19 @@ export function DossierPrefill(props: Props) {
{view.case.title}
- {' · '} Voltar ao dossier
+ {' · '} Voltar ao dossier
- {(consumer==='simulator'||opportunity||view.context.opportunity_choices?.length===1)&& }
+ {(opportunity||view.context.opportunity_choices?.length===1)&& }
- {consumer!=='simulator'&&contextId&&r.opportunity_key===(opportunity || (view.context.opportunity_choices?.length===1 ? view.context.opportunity_choices[0].opportunity.key : '')))} : null}/>}
- {consumer !== 'simulator' && !!view.context.opportunity_choices?.length && Oportunidade em tratamento
+ {contextId&&step.state==='dispensed').map(step=>step.code)} assessment={view.context.requirements_assessment ? {...view.context.requirements_assessment,requirements:view.context.requirements_assessment.requirements.filter(r=>r.opportunity_key===chosenKey)} : null}/>}
+ {!!view.context.opportunity_choices?.length && Oportunidade em tratamento
{
- const key=e.target.value; setOpportunity(key); setSelected([]);setReviewed(false);
- props.onApply({context_opportunity:key},true);
+ const key=e.target.value; userChose.current=true; setOpportunity(key); setSelected([]);setReviewed(false);
+ if (consumer !== 'simulator') props.onApply({context_opportunity:key},true);
props.onReview?.(view.context.revision,false);
}}>Escolha a oportunidade deste pedido {view.context.opportunity_choices.map(c=>{c.opportunity.title} — {c.territory?.title??'Localização por definir'} )}
{!opportunity && view.context.opportunity_choices.length>1 && Escolha a oportunidade para recuperar a localização e os requisitos específicos. Os dados comuns e as suas edições conservam-se.
}
@@ -166,7 +174,7 @@ export function DossierPrefill(props: Props) {
)}
{message && {message}
}
- {!!view.context.opportunity_choices?.length && (consumer==='simulator'||!!chosenChoice) && c.opportunity.key===chosenKey)}/>}
+ {!!chosenChoice && c.opportunity.key===chosenKey)}/>}
{consumer!=='simulator'&&chosenChoice?.opportunity.fields.sector&&Sector desta oportunidade: {chosenChoice.opportunity.fields.sector}
}
{Object.entries(view.context.selection)
diff --git a/resources/js/components/investment-opportunity-choices.tsx b/resources/js/components/investment-opportunity-choices.tsx
index 110ccb17..3fcecd40 100644
--- a/resources/js/components/investment-opportunity-choices.tsx
+++ b/resources/js/components/investment-opportunity-choices.tsx
@@ -72,6 +72,6 @@ export default function OpportunityChoices({choices, onChange, disabled = false,
return
Oportunidades escolhidas e localização de cada ficha
{onChange?'Escolha o corredor e confirme onde pretende realizar o investimento.':'Escolhas guardadas no dossier; cada oportunidade conserva a sua localização e associações.'}
- {choices.map((c,i)=>{contextId&&!onChange&& Terra/DUAT desta oportunidade Balcão desta oportunidade }update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/>
)}
+ {choices.map((c,i)=>{contextId&&!onChange&& Simular esta oportunidade Etapas e pedidos desta oportunidade Terra/DUAT desta oportunidade Balcão desta oportunidade }update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/>
)}
;
}
diff --git a/resources/js/components/investment-requirements.tsx b/resources/js/components/investment-requirements.tsx
index 7a19ce03..a1ef28af 100644
--- a/resources/js/components/investment-requirements.tsx
+++ b/resources/js/components/investment-requirements.tsx
@@ -15,7 +15,7 @@ export default function InvestmentRequirements({assessment, contextId, excludedS
{[...groups.entries()].map(([key, rows])=>{rows[0].opportunity_title} · {rows.length} aspectos a conferir {rows.map(r=>
{r.title} — {r.opportunity_title} {labels[r.state] ?? r.state}
Entidade: {r.authority}
{r.basis}
{r.documents.map(d=>{d} )}
- Consultar fundamento e procedimento {' · '} Preparar e acompanhar em {r.step}
+ Consultar fundamento e procedimento {' · '} Preparar e acompanhar em {r.step}
{r.decision}
)} )}
;
diff --git a/resources/js/components/simulation-form.tsx b/resources/js/components/simulation-form.tsx
index 674f12ce..25706631 100644
--- a/resources/js/components/simulation-form.tsx
+++ b/resources/js/components/simulation-form.tsx
@@ -1564,7 +1564,7 @@ export function SimulationForm() {
'Não foi possível devolver as entradas financeiras guardadas ao dossier.',
);
}
- window.location.assign(`/investimentos/${contextId}`);
+ window.location.assign(`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`);
} catch (cause) {
setContinuityError(
cause instanceof Error
@@ -2522,7 +2522,7 @@ export function SimulationForm() {
)}
Ver dossier
@@ -3292,7 +3292,7 @@ export function SimulationForm() {
: 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.'}
- Rever a necessidade de terra no dossier
+ Rever a necessidade de terra no dossier
)}
diff --git a/resources/js/lib/investor-path.ts b/resources/js/lib/investor-path.ts
index b88f0ce2..231faff1 100644
--- a/resources/js/lib/investor-path.ts
+++ b/resources/js/lib/investor-path.ts
@@ -19,6 +19,7 @@ export const pathStates: Record
= {
expired: 'Acto externo expirado',
};
export type PathResult = {
+ context_opportunity?: string | null;
id: string;
financial_snapshot: string;
currency: string | null;
diff --git a/resources/js/lib/project-dossier-report.ts b/resources/js/lib/project-dossier-report.ts
index 11f18428..55c7b5e5 100644
--- a/resources/js/lib/project-dossier-report.ts
+++ b/resources/js/lib/project-dossier-report.ts
@@ -284,6 +284,11 @@ function makeSections(
);
if (context.version === 1) {
const section = sections.get('sources');
+ if (context.consumer_opportunity) section?.rows.push({field:'Oportunidade em tratamento', value:context.consumer_opportunity, origin:context.opportunity_choices?.[0]?.opportunity?.title ?? 'Escolha guardada no dossier', classification:'Âmbito desta análise'});
+ for (const step of context.path?.steps ?? []) {
+ if (step.applicability === 'not_needed_for_preparation') section?.paragraphs.push(`${step.code} — preparação não necessária neste âmbito: ${step.reason || 'Decisão guardada do promotor'}. Histórico conservado; não constitui dispensa legal.`);
+ else section?.rows.push({field:`Etapa ${step.code}: ${step.title}`,value:step.applicability === 'undetermined' ? 'Aplicabilidade por determinar' : step.state,origin:context.consumer_opportunity ?? 'Contexto comum do dossier',classification:'Preparação no âmbito seleccionado'});
+ }
const zone = context.zone;
section?.rows.push({
field: 'Contexto do investimento',
diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx
index 4391cbf8..d91beb79 100644
--- a/resources/js/pages/investment/detail.tsx
+++ b/resources/js/pages/investment/detail.tsx
@@ -45,6 +45,8 @@ export default function Detail({
{auth.user&&dossierContext&&
+ Simular esta oportunidade
+ Etapas e pedidos desta oportunidade
Terra/DUAT desta oportunidade
Balcão desta oportunidade
}
diff --git a/resources/js/pages/investor-path/show.tsx b/resources/js/pages/investor-path/show.tsx
index c211618f..e45ab013 100644
--- a/resources/js/pages/investor-path/show.tsx
+++ b/resources/js/pages/investor-path/show.tsx
@@ -34,6 +34,8 @@ type Step = {
source_paragraph: number;
};
type Path = {
+ context_opportunity: string | null;
+ opportunity_options: NonNullable;
private_investment?: Preparation | null;
fintech: FintechView | null;
investment_context: DossierContext | null;
@@ -94,6 +96,7 @@ function Result({ result }: { result: PathResult }) {
const values = recoverPathResult(result);
return (
+ {result.context_opportunity && Oportunidade desta análise: {result.context_opportunity}
}
{result.context_state === 'review_required' && (
().props;
const facts = useForm({
version: path.case.version,
+ context_opportunity: path.context_opportunity,
...path.facts,
reason: '',
});
@@ -439,6 +444,13 @@ export default function InvestmentPath(path: Path) {
Os meus investimentos
{path.case.title}
+ {!!path.opportunity_options?.length && Oportunidade em tratamento
+ router.get(`/investimentos/${path.case.id}`, event.target.value ? {opportunity:event.target.value} : {}, {preserveState:false})}>
+ Escolha a oportunidade
+ {path.opportunity_options.map(choice => {choice.opportunity.title} — {choice.territory?.title ?? 'Localização por definir'} )}
+
+ {!path.context_opportunity && path.opportunity_options.length > 1 && Escolha para consultar as etapas, os requisitos e o pedido de terra correspondentes. Os dados comuns conservam-se.
}
+ }
diff --git a/tests/Feature/InvestmentTreatmentScopeTest.php b/tests/Feature/InvestmentTreatmentScopeTest.php
new file mode 100644
index 00000000..0db36d26
--- /dev/null
+++ b/tests/Feature/InvestmentTreatmentScopeTest.php
@@ -0,0 +1,169 @@
+withoutVite();
+ $this->withoutMiddleware(\Illuminate\Routing\Middleware\ThrottleRequests::class);
+ $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,'investor-path.private_preparation'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
+ $this->actor=User::factory()->create();$this->actingAs($this->actor);
+ $this->id=$this->postJson('/investimentos',['title'=>'SYNTHETIC QA — âmbito específico','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $corridors=app(InvestmentCorridorContext::class);
+ $records=[];foreach(['beira','limpopo'] as $corridor){
+ $entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
+ $records[]=Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
+ }
+ $sofala=collect($corridors->provinces('beira'))->firstWhere('source_code','07')->id;
+ $gaza=$corridors->provinces('limpopo')[0]->id;
+ $manica=collect($corridors->provinces('beira'))->firstWhere('name','Manica')->id;
+ $template=app(InvestmentZones::class)->visible()->whereIn('category',['industrial_park','zee','zfi'])->firstOrFail();
+ foreach([$sofala,$gaza,$manica] as $territory){$p=$template->replicate();$p->code='SYNTHETIC-QA-'.Str::uuid();$p->name='SYNTHETIC QA — parque '.$territory;$p->primary_territory_id=$territory;$p->save();$this->parks[]=$p;}
+ $this->choices=[['source'=>'opportunity:'.$records[0]->id,'corridor_key'=>'beira','territory'=>'territory:'.$sofala,'park'=>'park:'.$this->parks[0]->id,'activities'=>['production']],['source'=>'opportunity:'.$records[1]->id,'corridor_key'=>'limpopo','territory'=>'territory:'.$gaza,'park'=>'park:'.$this->parks[1]->id,'activities'=>['trade']]];
+ $this->confirm();
+ }
+
+ private function confirm(): array
+ {
+ $v=$this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
+ $body=['version'=>$v['case']['version'],'selection'=>[],'opportunity_choices'=>$this->choices];
+ $plan=$this->postJson('/investimentos/'.$this->id.'/contexto/previa',$body)->assertOk()->json();
+ $this->postJson('/investimentos/'.$this->id.'/contexto/confirmar',[...$body,'preview_hash'=>$plan['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ return $this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
+ }
+
+ private function treatment(string $key): array
+ {
+ return $this->getJson('/investimentos/'.$this->id.'?opportunity='.urlencode($key))->assertOk()->json();
+ }
+
+ public function test_opportunity_decisions_steps_and_simulator_are_isolated_from_common_dossier(): void
+ {
+ $keys=array_column($this->choices,'source');
+ foreach ($keys as $index=>$key) {
+ $v=$this->treatment($key);
+ $this->postJson('/investimentos/'.$this->id.'/aplicabilidade',['version'=>$v['case']['version'],'context_opportunity'=>$key,'land_needed'=>$index?'yes':'no','external_funding'=>'unknown','viability'=>'proceed','reason'=>'QA decisão específica por oportunidade'])->assertOk();
+ }
+ $a=$this->treatment($keys[0]);$b=$this->treatment($keys[1]);
+ $this->assertSame('no',$a['facts']['land_needed']);$this->assertSame('yes',$b['facts']['land_needed']);
+ $this->assertNull($a['tools']['land']);$this->assertNotNull($b['tools']['land']);
+ $this->assertSame('not_needed_for_preparation',collect($a['steps'])->firstWhere('code','S10')['applicability']);
+ $this->postJson('/investimentos/'.$this->id.'/etapas/S10',['version'=>$b['case']['version'],'context_opportunity'=>$keys[1],'state'=>'preparing','action'=>'prepare','reason'=>'Preparação apenas da segunda oportunidade'])->assertOk();
+ $this->assertSame('preparing',collect($this->treatment($keys[1])['steps'])->firstWhere('code','S10')['saved_state']);
+ $this->assertSame('not_started',collect($this->treatment($keys[0])['steps'])->firstWhere('code','S10')['saved_state']);
+ $v=$this->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($keys[1]))->assertOk()->json();
+ $sim=$v['prefill']['simulator'];$snap=json_decode($sim['context_applicability_snapshot'],true);
+ $this->assertSame($keys[1],$snap['consumer_opportunity']);$this->assertSame('yes',$snap['land_preparation']['need']);
+ $this->assertSame($this->parks[1]->primary_territory_id,$sim['province_id']);
+ $this->assertStringContainsString('opportunity=', $b['tools']['simulator']);
+ $this->assertArrayNotHasKey('province_id',$this->getJson('/investimentos/'.$this->id.'/contexto')->json('prefill.simulator'));
+ $payload=app(VirtualDesk::class)->decode(DB::table('desk_cases')->where('id',$this->id)->value('payload'));
+ $this->assertArrayNotHasKey('facts',$payload['investor_path']);
+ }
+
+ public function test_land_shortcut_recovers_associated_request_even_when_other_request_is_newer(): void
+ {
+ $ids=[];foreach(array_reverse($this->choices) as $choice) {
+ $ids[$choice['source']]=$this->postJson('/investidor/terra-duat',['payload'=>['investor_path_case_id'=>$this->id,'context_opportunity'=>$choice['source'],'project_reference'=>'QA scope']])->assertCreated()->json('id');
+ }
+ foreach($ids as $key=>$id) $this->assertStringEndsWith($id,$this->treatment($key)['tools']['land']);
+ $unscoped=$this->getJson('/investimentos/'.$this->id)->assertOk()->json();
+ foreach($ids as $id) $this->assertStringNotContainsString($id,$unscoped['tools']['land']);
+ $this->getJson('/investimentos/'.$this->id.'?opportunity=opportunity:99999999')->assertUnprocessable();
+ }
+
+ public function test_simulator_saves_recovers_selected_scope_and_rejects_foreign_choice(): void
+ {
+ $key=$this->choices[1]['source'];
+ $sim=$this->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($key))->json('prefill.simulator');
+ $data=['context'=>$this->id,'personal_snapshot'=>[], 'economic_snapshot'=>['purpose'=>'simulate_only','project_title'=>'QA âmbito simulado','context_opportunity'=>$key,'context_applicability_snapshot'=>$sim['context_applicability_snapshot'],'financial_tools_snapshot'=>json_encode(['version'=>1,'data'=>[]])]];
+ $this->postJson('/api/v1/simulation-drafts',$data,['Idempotency-Key'=>(string)Str::uuid()])->assertCreated();
+ $this->getJson('/api/v1/simulation-drafts/current?context='.$this->id)->assertOk()->assertJsonPath('data.economic_snapshot.context_opportunity',$key);
+ $this->actingAs($this->actor->fresh());
+ $saved=$this->getJson('/api/v1/simulation-drafts/current?context='.$this->id)->assertOk()->json('data.economic_snapshot');
+ $this->assertSame($key,json_decode($saved['context_applicability_snapshot'],true)['consumer_opportunity']);
+ $current=$this->getJson('/api/v1/simulation-drafts/current?context='.$this->id)->json('data');
+ $case=$this->treatment($key);
+ $this->postJson('/investimentos/'.$this->id.'/resultados',['version'=>$case['case']['version'],'draft_id'=>$current['id'],'draft_version'=>$current['version_no'],'context_revision'=>$case['investment_context']['revision'],'context_reviewed'=>true])->assertOk()->assertJsonPath('result.context_opportunity',$key);
+ $other=$this->postJson('/investimentos',['title'=>'QA identidade alheia ao dossier','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $this->postJson('/api/v1/simulation-drafts',[...$data,'context'=>$other],['Idempotency-Key'=>(string)Str::uuid()])->assertUnprocessable();
+ }
+
+ public function test_authorised_common_contacts_are_reused_without_inventing_company_or_tax_identity(): void
+ {
+ $this->putJson('/investidor/profile',['version'=>0,'payload'=>['phone'=>'+258840001234','country'=>'Portugal','sector'=>'Sector de preferência']])->assertOk();
+ $id=$this->postJson('/investimentos',['title'=>'QA contactos autorizados','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $v=$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('prefill');
+ foreach(['land','simulator'] as $tool) {
+ $this->assertSame('+258840001234',$v[$tool]['phone']);$this->assertSame($this->actor->email,$v[$tool]['email']);
+ $this->assertArrayNotHasKey('tax_residency_country',$v[$tool]);$this->assertArrayNotHasKey('sector',$v[$tool]);
+ }
+ $this->assertSame($this->actor->name,$v['land']['full_name']);
+ }
+
+ public function test_idea_sector_and_territory_entries_keep_explicit_source_through_recovery(): void
+ {
+ $id=$this->postJson('/investimentos',['title'=>'QA ideia livre','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
+ $this->get('/investimentos/iniciar')->assertOk()->assertInertia(fn($p)=>$p->where('source',null));
+ $sector=DB::table('sectors')->where('is_active',true)->first();
+ foreach(['sector:'.$sector->id,'territory:'.$this->parks[1]->primary_territory_id] as $source) {
+ $this->get('/investimentos/'.$id.'/contexto/editar?source='.urlencode($source))->assertOk()->assertInertia(fn($p)=>$p->where('source.key',$source));
+ $slot=str_starts_with($source,'sector:')?'sector':'territory';
+ $v=$this->getJson('/investimentos/'.$id.'/contexto')->json();
+ $body=['version'=>$v['case']['version'],'selection'=>[$slot=>$source]];
+ $preview=$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertOk()->json();
+ $this->postJson('/investimentos/'.$id.'/contexto/confirmar',[...$body,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ $this->actingAs($this->actor->fresh());
+ $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.selection.'.$slot.'.key',$source);
+ }
+ $view=$this->getJson('/investimentos/'.$id.'/contexto')->json('prefill');
+ $this->assertSame($this->parks[1]->primary_territory_id,$view['simulator']['province_id']);
+ $this->assertSame($this->parks[1]->primary_territory_id,$view['land']['parcels'][0]['province_id']);
+ $this->assertSame($id,$this->getJson('/investimentos/'.$id)->json('case.id'));
+ }
+
+ public function test_sector_entry_survives_authentication_redirect_with_same_source(): void
+ {
+ $sector=DB::table('sectors')->where('is_active',true)->first();
+ $url='/investimentos/iniciar?source='.urlencode('sector:'.$sector->id);
+ \Illuminate\Support\Facades\Auth::logout();
+ $this->get($url)->assertRedirect('/login')->assertSessionHas('url.intended',url($url));
+ $this->post('/login',['email'=>$this->actor->email,'password'=>'password'])->assertRedirect(url($url));
+ $this->get($url)->assertOk()->assertInertia(fn($p)=>$p->where('source.key','sector:'.$sector->id));
+ }
+
+ public function test_removing_first_keeps_second_identity_common_edits_and_historical_requests(): void
+ {
+ $before=$this->confirm();$second=$before['context']['opportunity_choices'][1];
+ $key=$this->choices[1]['source'];
+ $land=$this->postJson('/investidor/terra-duat',['payload'=>['investor_path_case_id'=>$this->id,'context_opportunity'=>$this->choices[0]['source'],'project_reference'=>'QA histórico']])->assertCreated()->json('id');
+ $this->choices=[$this->choices[1]];
+ $v=$this->getJson('/investimentos/'.$this->id.'/contexto')->json();
+ $body=['version'=>$v['case']['version'],'selection'=>[],'opportunity_choices'=>$this->choices,'declarations'=>['project_title'=>'Título deliberado comum']];
+ $preview=$this->postJson('/investimentos/'.$this->id.'/contexto/previa',$body)->assertOk()->json();
+ $this->postJson('/investimentos/'.$this->id.'/contexto/confirmar',[...$body,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
+ $after=$this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
+ $this->assertSame($second,$after['context']['opportunity_choices'][0]);
+ $this->assertSame('Título deliberado comum',$after['context']['fields']['project_title']);
+ $this->getJson('/investidor/terra-duat/'.$land)->assertOk();
+ $this->assertStringNotContainsString($land,$this->treatment($key)['tools']['land']);
+ }
+}
diff --git a/tests/frontend/treatment-scope-export.test.mjs b/tests/frontend/treatment-scope-export.test.mjs
new file mode 100644
index 00000000..481e609d
--- /dev/null
+++ b/tests/frontend/treatment-scope-export.test.mjs
@@ -0,0 +1,32 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import JSZip from 'jszip';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { createProjectDossierReport, createProjectDossierPrintHtml, generateProjectDossierDocx, generateProjectDossierPackage } from '../../resources/js/lib/project-dossier-report.ts';
+import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
+
+test('selected second opportunity survives actual Word and ZIP generation and the native print document', async () => {
+ const snapshot=JSON.stringify({version:1,context_revision:12,consumer_opportunity:'opportunity:309',zone:{title:'Parque Manica',version:3,href:'/parques/manica'},opportunity_choices:[{opportunity:{key:'opportunity:309',title:'Segunda oportunidade Manica'}}],land_preparation:{need:'yes'},path:{steps:[{code:'S10',title:'Terra Manica',applicability:'applicable',state:'preparing'},{code:'S13',title:'Financiamento',applicability:'not_needed_for_preparation',reason:'Sem financiamento externo neste ambito'}]}});
+ const report=createProjectDossierReport({project_title:'QA segunda oportunidade',context_applicability_snapshot:snapshot},{selectedSections:new Set(['sources'])});
+ const html=await createProjectDossierPrintHtml(report,[],false);
+ assert.match(html,/Terra Manica/);assert.match(html,/Sem financiamento externo neste ambito/);assert.match(html,/opportunity:309/);assert.match(html,/Segunda oportunidade Manica/);assert.match(html,/Parque Manica/);assert.doesNotMatch(html,/Sofala/);
+ const docx=await generateProjectDossierDocx(report,[],true);
+ const bytes=await docx.arrayBuffer();const word=await JSZip.loadAsync(bytes);
+ const xml=await word.file('word/document.xml').async('string');
+ assert.match(xml,/opportunity:309/);assert.match(xml,/Parque Manica/);assert.doesNotMatch(xml,/Sofala/);
+ const bundle=await generateProjectDossierPackage(docx,[],true);const zip=await JSZip.loadAsync(await bundle.arrayBuffer());
+ const inner=Object.keys(zip.files).find(name=>name.endsWith('.docx'));assert.ok(inner);
+ assert.deepEqual(await zip.file(inner).async('uint8array'),new Uint8Array(bytes));
+ await mkdir('outputs/multi-opportunity-fix-20261023/treatment-exports',{recursive:true});
+ await writeFile('outputs/multi-opportunity-fix-20261023/treatment-exports/second.docx',new Uint8Array(bytes));
+ await writeFile('outputs/multi-opportunity-fix-20261023/treatment-exports/second.zip',new Uint8Array(await bundle.arrayBuffer()));
+ await writeFile('outputs/multi-opportunity-fix-20261023/treatment-exports/second-print.html',html);
+});
+
+test('new common contact reuse preserves a deliberate contact and follows inherited contact only',()=>{
+ const source={email:'qa@example.invalid',phone:'+258840001234'};
+ const a=inheritCompatibleFields('dossier:qa:land',{email:'edited@example.invalid'},source,'');
+ assert.equal(a.patch.phone,source.phone);assert.equal(a.patch.email,undefined);
+ const b=inheritCompatibleFields('dossier:qa:land',{email:'edited@example.invalid',phone:source.phone},{...source,phone:'+258840005678'},a.metadata);
+ assert.equal(b.patch.phone,'+258840005678');assert.equal(b.patch.email,undefined);
+});