diff --git a/app/Http/Controllers/InvestmentSubmissionController.php b/app/Http/Controllers/InvestmentSubmissionController.php
index 791a74d3..b067f532 100644
--- a/app/Http/Controllers/InvestmentSubmissionController.php
+++ b/app/Http/Controllers/InvestmentSubmissionController.php
@@ -192,11 +192,13 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
- 'payload' => ['required', 'array:context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
+ 'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
+ 'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
+ 'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
@@ -247,6 +249,9 @@ class InvestmentSubmissionController extends Controller
}
}
+ $bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
+ if ($bound) $data['payload'] = app(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
+ else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
return $data;
}
@@ -266,7 +271,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
- $row->update(['payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
+ $row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -310,7 +315,8 @@ class InvestmentSubmissionController extends Controller
return InterestRequest::findOrFail($row->interest_request_id)->reference;
}
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
- $opportunity = $row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null;
+ $scopeKey = $data['payload']['context_opportunity'] ?? null;
+ $opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
@@ -331,12 +337,14 @@ class InvestmentSubmissionController extends Controller
'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(),
'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id,
'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id,
+ 'context_opportunity' => $p['context_opportunity'] ?? null,
+ 'context_opportunity_snapshot' => $p['context_opportunity_snapshot'] ?? null,
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
'residence_country_code' => $p['residence_country_code'] ?? null,
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
- $row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
+ $row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
diff --git a/app/Http/Controllers/InvestorContextController.php b/app/Http/Controllers/InvestorContextController.php
index a92b4f6a..90b1bd30 100644
--- a/app/Http/Controllers/InvestorContextController.php
+++ b/app/Http/Controllers/InvestorContextController.php
@@ -53,7 +53,8 @@ final class InvestorContextController extends Controller
public function show(Request $r, string $id, InvestorContext $context)
{
- return response()->json($context->view($r->user(), $id));
+ $data = $r->validate(['opportunity' => 'nullable|string|max:100']);
+ return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
}
public function options(Request $r, InvestorContext $context)
diff --git a/app/Http/Controllers/VirtualDeskController.php b/app/Http/Controllers/VirtualDeskController.php
index 5941e298..8db723a6 100644
--- a/app/Http/Controllers/VirtualDeskController.php
+++ b/app/Http/Controllers/VirtualDeskController.php
@@ -119,8 +119,8 @@ class VirtualDeskController extends Controller
public function createRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
- $data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid']);
- $row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key']);
+ $data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid', 'opportunity' => 'nullable|string|max:100']);
+ $row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key'], $data['opportunity'] ?? null);
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id);
}
diff --git a/app/Http/Requests/StoreLandAssistanceCaseRequest.php b/app/Http/Requests/StoreLandAssistanceCaseRequest.php
index 9266b885..08aeda54 100644
--- a/app/Http/Requests/StoreLandAssistanceCaseRequest.php
+++ b/app/Http/Requests/StoreLandAssistanceCaseRequest.php
@@ -19,7 +19,9 @@ class StoreLandAssistanceCaseRequest extends FormRequest
{
$payload = $this->validated('payload');
- return is_array($payload) ? $payload : [];
+ if (!is_array($payload)) return [];
+ if (!empty($payload['investor_path_case_id'])) return app(\App\Services\InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support'));
+ return \Illuminate\Support\Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
}
public function authorize(): bool
@@ -33,7 +35,9 @@ class StoreLandAssistanceCaseRequest extends FormRequest
return [
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
'context_revision' => ['nullable', 'integer', 'min:0'], 'context_reviewed' => ['nullable', 'boolean'],
- 'payload' => ['required', 'array:context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
+ 'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
+ 'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
+ 'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
'payload.request_description' => ['nullable', 'string', 'max:4000'],
diff --git a/app/Services/InvestmentConsumerScope.php b/app/Services/InvestmentConsumerScope.php
new file mode 100644
index 00000000..14bd1547
--- /dev/null
+++ b/app/Services/InvestmentConsumerScope.php
@@ -0,0 +1,63 @@
+firstWhere('opportunity.key', $key) : null;
+ abort_if($key && !$choice, 422, 'Escolha uma oportunidade deste dossier.');
+ $specific = ['project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'special_zone_id', 'investment_regime'];
+ $context['fields'] = Arr::except($context['fields'], $specific);
+ $context['provenance'] = Arr::except($context['provenance'] ?? [], $specific);
+ $context['selection'] = Arr::except($context['selection'], ['opportunity', 'territory', 'park']);
+ if (!$choice) {
+ $context['opportunity_choices'] = [];
+ $context['requirements_assessment']['requirements'] = [];
+ return $context;
+ }
+ $context['selection']['opportunity'] = $choice['opportunity'];
+ $context['fields'] = [...$context['fields'], ...Arr::only($choice['opportunity']['fields'], $specific)];
+ foreach (Arr::only($choice['opportunity']['fields'], $specific) as $field => $value) $context['provenance'][$field] = Arr::only($choice['opportunity'], ['key','kind','id','title','version','sha256']);
+ unset($context['selection']['sector']);
+ if (!empty($context['fields']['sector_id'])) $context['selection']['sector'] = app(InvestorContext::class)->reference('sector:'.$context['fields']['sector_id']);
+ if ($choice['territory']) {
+ $context['selection']['territory'] = $choice['territory'];
+ $context['fields'] = [...Arr::except($context['fields'], array_keys(InvestmentGeography::FIELDS)), ...$choice['territory']['fields']];
+ foreach ($choice['territory']['fields'] as $field => $value) $context['provenance'][$field] = Arr::only($choice['territory'], ['key','kind','id','title','version','sha256']);
+ } else {
+ $context['fields'] = Arr::except($context['fields'], ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location']);
+ }
+ $park = $choice['park'] ?? null;
+ if ($park) $context['selection']['park'] = $park;
+ $context['fields']['special_zone_id'] = $park['id'] ?? '';
+ $context['fields']['investment_regime'] = $park['fields']['investment_regime'] ?? 'general';
+ $context['opportunity_choices'] = [$choice];
+ $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)));
+ return $context;
+ }
+
+ public function bind(User $actor, string $id, array $payload, bool $required = false): array
+ {
+ $case = app(VirtualDesk::class)->case($actor, $id, true);
+ $context = app(InvestorContext::class)->state($case);
+ $scoped = $this->context($context, $payload['context_opportunity'] ?? null);
+ $choice = $scoped['consumer_opportunity'] ?? null;
+ abort_if($required && count($context['opportunity_choices'] ?? []) > 1 && !$choice, 422, 'Escolha a oportunidade em tratamento antes de submeter.');
+ if (!$choice) return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
+ // Canonical server snapshot; client text cannot establish a different origin or territory.
+ $payload['context_opportunity'] = $choice['opportunity']['key'];
+ $payload['context_opportunity_snapshot'] = json_encode(['context_id' => $id, 'revision' => $context['revision'], 'choice' => $choice, 'requirements' => $scoped['requirements_assessment'] ?? null], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
+ return $payload;
+ }
+}
diff --git a/app/Services/InvestmentOpportunityChoices.php b/app/Services/InvestmentOpportunityChoices.php
index 202b233d..be9f92e6 100644
--- a/app/Services/InvestmentOpportunityChoices.php
+++ b/app/Services/InvestmentOpportunityChoices.php
@@ -51,7 +51,7 @@ final class InvestmentOpportunityChoices
$corridor = $choice['corridor_key'] ?? $old['corridor_key'] ?? $corridors->key($record);
$provinces = $corridor ? $corridors->provinces($corridor) : [];
$documented = $record->primary_territory_id;
- $territory = $choice['territory'] ?? ($documented ? 'territory:'.$documented : null);
+ $territory = array_key_exists('territory', $choice) ? $choice['territory'] : ($documented ? 'territory:'.$documented : null);
if (!$territory && count($provinces) === 1) $territory = 'territory:'.$provinces[0]->id;
if ($corridor && $territory) abort_unless($corridors->compatible($corridor, substr($territory, 10)), 422, 'A localização não pertence ao corredor escolhido.');
$previousTerritory = $old['territory']['key'] ?? null;
@@ -65,7 +65,18 @@ final class InvestmentOpportunityChoices
}
$location = $territory ? app(InvestorContext::class)->reference($territory) : null;
abort_unless($location === null || $location['kind'] === 'territory', 422);
+ $parkKey = array_key_exists('park', $choice) ? $choice['park'] : ($old['park']['key'] ?? null);
+ $park = $parkKey ? app(InvestorContext::class)->reference($parkKey) : null;
+ if ($park) {
+ $eligible = $corridor ? array_column($corridors->parks($corridor, $location['id'] ?? null), 'id') : [];
+ if (!in_array($park['id'], $eligible, true)) {
+ abort_if(!empty($choice['park']), 422, 'A zona ou parque não é compatível com a localização desta oportunidade.');
+ $park = null;
+ }
+ abort_unless(!$park || $park['kind'] === 'park', 422);
+ }
$result[] = ['opportunity' => $ref, 'territory' => $location, ...($origins ? ['portfolio_origins' => $origins] : []),
+ 'park' => $park,
'corridor_key' => $corridor, 'corridor_name' => $corridor ? config('investment_corridors.'.$corridor.'.name') : null,
'allowed_provinces' => array_map(fn($p)=>(array)$p, $provinces),
'territory_path' => $location ? array_map(fn ($node) => \Illuminate\Support\Arr::only((array) $node, ['id', 'name', 'parent_id', 'location_type']), app(InvestmentGeography::class)->path($location['id'], true)) : [],
diff --git a/app/Services/InvestorContext.php b/app/Services/InvestorContext.php
index d6c1b909..90e27e59 100644
--- a/app/Services/InvestorContext.php
+++ b/app/Services/InvestorContext.php
@@ -61,6 +61,7 @@ final class InvestorContext
if ($key) abort_unless(app(InvestmentCorridorContext::class)->compatible($key, $location['id']), 422, 'A localização não pertence ao corredor escolhido.');
}
}
+ if (isset($data['opportunity_choices'])) app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $this->state($case)['opportunity_choices'] ?? [], true);
if (!empty($data['selection']['park'])) {
$first=$data['opportunity_choices'][0] ?? null;
$source=$first['source'] ?? $data['selection']['opportunity'] ?? null;
@@ -111,6 +112,7 @@ final class InvestorContext
}
}
}
+ if (!empty($context['opportunity_choices']) && !array_key_exists('park', $context['opportunity_choices'][0])) $context['opportunity_choices'][0]['park'] = $context['selection']['park'] ?? null;
$zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
@@ -345,10 +347,11 @@ final class InvestorContext
'complements' => 'sometimes|array|max:100', 'complements.*' => 'array:origin,code,target,territory_id',
'complements.*.origin' => 'required|string|max:100', 'complements.*.code' => 'required|string|max:40',
'complements.*.target' => 'nullable|string|max:100', 'complements.*.territory_id' => 'nullable|uuid',
- 'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities,corridor_key,portfolio_origins',
+ 'opportunity_choices' => 'sometimes|array|max:100', 'opportunity_choices.*' => 'array:source,territory,location_reason,confirm_location_change,activities,corridor_key,portfolio_origins,park',
'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.*.park' => 'sometimes|nullable|string|max:100',
'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100',
'opportunity_choices.*.location_reason' => 'nullable|string|max:2000', 'opportunity_choices.*.confirm_location_change' => 'sometimes|boolean',
'version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
@@ -389,6 +392,7 @@ final class InvestorContext
$data['selection']['opportunity'] = $choices[0]['opportunity']['key'] ?? null;
// Shared fields concern the first identified opportunity; other sites remain separate.
$data['selection']['territory'] = $choices[0]['territory']['key'] ?? null;
+ if (array_key_exists('park', $data['opportunity_choices'][0] ?? [])) $data['selection']['park'] = $choices[0]['park']['key'] ?? null;
} elseif (array_key_exists('opportunity', $data['selection']) && $choices !== []) {
abort(422, 'Actualize a selecção identificada de oportunidades; não substitua silenciosamente o conjunto por uma ficha.');
}
@@ -577,7 +581,7 @@ final class InvestorContext
}, 3);
}
- public function view(User $actor, string $id): array
+ public function view(User $actor, string $id, ?string $opportunity = null): array
{
$case = $this->desk->case($actor, $id);
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
@@ -603,7 +607,7 @@ final class InvestorContext
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), 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
+ 'prefill' => $this->prefill($ctx, $opportunity), '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
@@ -619,8 +623,10 @@ final class InvestorContext
return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $path['fact_reasons']['land_needed'] ?? ''];
}
- public function prefill(array $ctx): array
+ public function prefill(array $ctx, ?string $opportunity = null): array
{
+ $original = $ctx;
+ if ($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.
@@ -653,6 +659,8 @@ final class InvestorContext
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
+ $scoped = app(InvestmentConsumerScope::class)->context($original, $opportunity);
+ $f = $scoped['fields'];
return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null),
'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null),
'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
diff --git a/app/Services/VirtualDesk.php b/app/Services/VirtualDesk.php
index 6f12ab8b..1626d6f8 100644
--- a/app/Services/VirtualDesk.php
+++ b/app/Services/VirtualDesk.php
@@ -170,11 +170,11 @@ class VirtualDesk
});
}
- public function createRequest(User $actor, string $caseId, string $serviceId, string $clientKey): \stdClass
+ public function createRequest(User $actor, string $caseId, string $serviceId, string $clientKey, ?string $opportunity = null): \stdClass
{
abort_unless(Str::isUuid($serviceId) && Str::isUuid($clientKey), 422);
- return DB::transaction(function () use ($actor, $caseId, $serviceId, $clientKey) {
+ return DB::transaction(function () use ($actor, $caseId, $serviceId, $clientKey, $opportunity) {
$case = $this->case($actor, $caseId, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
$service = DB::table('desk_service_versions')->where('id', $serviceId)->where('status', 'published')->first();
@@ -197,7 +197,10 @@ class VirtualDesk
$this->event($case, $actor, 'request_prefill_prepared', ['draft_id' => $draft->id, 'draft_version' => $draft->version_no, 'snapshot_sha256' => $draft->snapshot_sha256, 'fields' => ['title', 'description']]);
}
$context = app(InvestorContext::class)->state($case);
- $mapped = app(InvestorContext::class)->prefill($context)['desk'];
+ if (count($context['opportunity_choices'] ?? []) > 1 && !$opportunity) $payload['description'] = '';
+ $mapped = app(InvestorContext::class)->prefill($context, $opportunity)['desk'];
+ if ($opportunity) $payload['context_opportunity'] = $opportunity;
+ $payload = app(InvestmentConsumerScope::class)->bind($actor, $caseId, $payload);
$serviceFields = json_decode($service->definition, true)['fields'];
foreach (['territory_id' => 'territory_id', 'location_source' => 'location_source', 'title' => 'title', 'description' => 'description', 'sector' => 'sector', 'province_id' => 'province_id', 'district_id' => 'district_id', 'admin_post_id' => 'admin_post_id', 'locality_id' => 'locality_id'] as $field => $source) {
if (isset($serviceFields[$field],$mapped[$source]) && (empty($payload[$field]) || in_array($field, ['title', 'description'], true) && $context['revision'] > 0)) {
@@ -230,6 +233,12 @@ class VirtualDesk
$draft = SimulationFormDraft::query()->whereKey($draft->id)->lockForUpdate()->firstOrFail();
}
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
+ $contextChoices = app(InvestorContext::class)->state($case)['opportunity_choices'] ?? [];
+ if (count($contextChoices) > 1) {
+ $snapshot = json_decode($economic['context_applicability_snapshot'] ?? '{}', true);
+ $draftChoices = $snapshot['opportunity_choices'] ?? [];
+ if (count($draftChoices) !== 1 || ($draftChoices[0]['opportunity']['key'] ?? null) !== ($payload['context_opportunity'] ?? null)) return null;
+ }
$description = (string) ($economic['project_summary'] ?? '');
if (trim($description) === '') {
return null;
@@ -250,7 +259,9 @@ class VirtualDesk
* @return array */
public function validateFields(array $payload, array $definition, bool $submitting): array
{
- $rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse'])],
+ $rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse', 'context_opportunity', 'context_opportunity_snapshot'])],
+ 'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
+ 'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
@@ -334,6 +345,9 @@ class VirtualDesk
}
}
$payload = $this->validateFields($data['payload'], $this->definition($request), $submit);
+ if ($request->status !== 'returned' || in_array('context_opportunity', json_decode($request->editable_fields ?? '[]', true), true)) {
+ $payload = app(InvestmentConsumerScope::class)->bind($actor, $request->case_id, $payload, $submit);
+ }
if ($request->status === 'returned') {
$editable = json_decode($request->editable_fields ?? '[]', true);
$previous = $this->decode($request->payload);
diff --git a/resources/js/components/dossier-navigation-context.tsx b/resources/js/components/dossier-navigation-context.tsx
index c926274f..dfa03ca1 100644
--- a/resources/js/components/dossier-navigation-context.tsx
+++ b/resources/js/components/dossier-navigation-context.tsx
@@ -234,13 +234,16 @@ export function NavigationContextSummary() {
return null;
const saved = view?.investment_context;
const ref = saved?.selection;
+ const params = new URLSearchParams(url.split('?')[1] ?? '');
+ const scopedChoice = saved?.opportunity_choices?.find(c=>c.opportunity.key===params.get('opportunity'));
+ const multiple = (saved?.opportunity_choices?.length ?? 0)>1;
const currentStep = view?.steps.find((s) => s.code === step);
- const sector =
+ const sector = scopedChoice ? scopedChoice.opportunity.fields.sector || 'Não indicado' :
ref?.sector?.title ||
saved?.fields.sector ||
view?.sector_contexts.map((s) => s.name).join(' · ') ||
'Não indicado';
- const location =
+ const location = scopedChoice ? scopedChoice.territory?.title || 'Por definir' : multiple ? 'Consulte a localização de cada oportunidade' :
saved?.fields.location ||
ref?.territory?.title ||
ref?.park?.title ||
@@ -260,7 +263,7 @@ export function NavigationContextSummary() {
view?.case.title ||
'Selecione um dossier',
Oportunidade:
- ref?.opportunity?.title || 'Sem oportunidade associada',
+ scopedChoice?.opportunity.title || (multiple ? 'Várias — oportunidade em tratamento no formulário' : ref?.opportunity?.title || 'Sem oportunidade associada'),
Sector: sector,
Localização: location,
'Etapa atual': currentStep
diff --git a/resources/js/components/dossier-prefill.tsx b/resources/js/components/dossier-prefill.tsx
index 6b796049..46a77ad2 100644
--- a/resources/js/components/dossier-prefill.tsx
+++ b/resources/js/components/dossier-prefill.tsx
@@ -1,3 +1,5 @@
+import { InvestmentConsumerIdentity } from './investment-consumer-identity';
+import InvestmentRequirements from './investment-requirements';
import { useEffect, useRef, useState } from 'react';
import { InvestmentContextAssessment } from './investment-context-assessment';
import { Link } from '@inertiajs/react';
@@ -18,6 +20,7 @@ type Props = {
allowedFields?: string[];
persistInheritance?: boolean;
boundRevision?: number;
+ initialOpportunity?: string;
onApply: (patch: Record, replace: boolean) => void;
onReview?: (revision: number, reviewed: boolean) => void;
};
@@ -49,14 +52,17 @@ 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 loadedOpportunity = useRef('');
useEffect(() => {
if (!contextId) return;
let stopped = false;
setView(null);
setError('');
- void contextApi(`/investimentos/${contextId}/contexto`)
+ void contextApi(`/investimentos/${contextId}/contexto${opportunity ? '?opportunity='+encodeURIComponent(opportunity) : ''}`)
.then((v) => {
if (!stopped) {
+ loadedOpportunity.current = opportunity;
setView(v);
setReviewed(false);
}
@@ -67,11 +73,14 @@ export function DossierPrefill(props: Props) {
return () => {
stopped = true;
};
- }, [contextId, retry]);
+ }, [contextId, retry, opportunity]);
useEffect(() => {
- if (!view || view.case.id !== contextId || !enabled || readOnly || !view.can_write) return;
+ if (!view || loadedOpportunity.current !== opportunity || view.case.id !== contextId || !enabled || readOnly || !view.can_write) return;
+ const choices = view.context.opportunity_choices ?? [];
+ 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);
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.
@@ -109,6 +118,8 @@ export function DossierPrefill(props: Props) {
([k]) => !props.allowedFields || props.allowedFields.includes(k),
),
);
+ const chosenKey = opportunity || (view.context.opportunity_choices?.length===1 ? view.context.opportunity_choices[0].opportunity.key : '');
+ const chosenChoice = view.context.opportunity_choices?.find(c=>c.opportunity.key===chosenKey);
const differences = Object.keys(source).filter(
(k) => k !== 'context_applicability_snapshot' && !k.endsWith('_label') && text(props.values[k]) !== text(source[k]),
);
@@ -134,7 +145,17 @@ export function DossierPrefill(props: Props) {
{' · '} Voltar ao dossier
-
+ {(consumer==='simulator'||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
+ {
+ const key=e.target.value; setOpportunity(key); setSelected([]);setReviewed(false);
+ 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.
}
+ }
{stale && (
{linkedRevision > 0
@@ -145,10 +166,11 @@ export function DossierPrefill(props: Props) {
)}
{message && {message}
}
- {consumer === 'simulator' && !!view.context.opportunity_choices?.length && }
+ {!!view.context.opportunity_choices?.length && (consumer==='simulator'||!!chosenChoice) && c.opportunity.key===chosenKey)}/>}
+ {consumer!=='simulator'&&chosenChoice?.opportunity.fields.sector&&Sector desta oportunidade: {chosenChoice.opportunity.fields.sector}
}
{Object.entries(view.context.selection)
- .filter(([slot]) => consumer !== 'simulator' || !view.context.opportunity_choices?.length || !['opportunity', 'territory'].includes(slot))
+ .filter(([slot]) => !view.context.opportunity_choices?.length || !['opportunity', 'territory', 'park', ...(consumer==='simulator'?[]:['sector'])].includes(slot))
.map(([, r]) =>
r && (
diff --git a/resources/js/components/investment-consumer-identity.tsx b/resources/js/components/investment-consumer-identity.tsx
new file mode 100644
index 00000000..5061920d
--- /dev/null
+++ b/resources/js/components/investment-consumer-identity.tsx
@@ -0,0 +1,16 @@
+export function InvestmentConsumerIdentity({raw}:{raw:unknown}) {
+ if (typeof raw !== 'string' || !raw) return null;
+ try {
+ const saved=JSON.parse(raw);
+ const c=saved.choice;
+ if (!c?.opportunity?.key) return null;
+ const states:Record={selected:'Localização escolhida',missing:'Informação em falta',conditional:'Conforme as condições do projecto',declared:'Actividades declaradas',undetermined:'Por determinar'};
+ return
+ Oportunidade associada ao pedido
+ {c.opportunity.title} · {c.opportunity.key}
+ Localização guardada: {c.territory?.title??'Por definir'} · Corredor: {c.corridor_name??'Por definir'}
+ Zona/parque: {c.park?.title??'Sem selecção'} · Revisão do dossier: {saved.revision}
+ {(saved.requirements?.requirements??[]).map((r:{code:string;title:string;state:string})=>{r.title} · {states[r.state]??'Por determinar'}
)}
+ ;
+ } catch {return null;}
+}
diff --git a/resources/js/components/investment-opportunity-choices.tsx b/resources/js/components/investment-opportunity-choices.tsx
index c13df87e..110ccb17 100644
--- a/resources/js/components/investment-opportunity-choices.tsx
+++ b/resources/js/components/investment-opportunity-choices.tsx
@@ -1,4 +1,5 @@
import { Link } from '@inertiajs/react';
+import { contextApi, type ContextReference } from '@/lib/investor-context';
import { TerritoryFields } from './investment-territory';
import type { OpportunityChoice } from '@/lib/investor-context';
import { card, input } from '@/pages/investment/shared';
@@ -7,8 +8,11 @@ import type { TerritoryOption } from './investment-territory';
export type CorridorOption = {key:string;name:string;provinces:TerritoryOption[]};
-function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;i:number;corridors:CorridorOption[];disabled:boolean;update:(change:Partial)=>void;remove?:()=>void}) {
+function ChoiceCard({c,i,corridors,disabled,update,remove,contextId}:{c:OpportunityChoice;i:number;corridors:CorridorOption[];disabled:boolean;update:(change:Partial)=>void;remove?:()=>void;contextId?:string}) {
const [notice,setNotice]=useState('');
+ const [parks,setParks]=useState<{id:string;name:string;version_no:number}[]>([]);
+ const [parkError,setParkError]=useState('');
+ const [parkLoading,setParkLoading]=useState(false);
const corridor=corridors.find(r=>r.key===c.corridor_key)||corridors.find(r=>r.name===c.coverage);
const path=c.territory_path??[];
function location(next:TerritoryOption[], key=corridor?.key) {
@@ -21,8 +25,18 @@ function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;
location(corridor.provinces.length===1?corridor.provinces:[]);
} else if (!path.length && corridor.provinces.length===1) {setNotice('Província seleccionada automaticamente: '+corridor.provinces[0].name+'.');location(corridor.provinces);}
},[corridor?.key,JSON.stringify(corridor?.provinces),path[0]?.id,!!remove]);
+ useEffect(()=>{
+ if (!remove || !corridor?.key) return;
+ let active=true; setParkLoading(true);setParkError('');
+ const params=new URLSearchParams(); if(corridor?.key)params.set('corridor',corridor.key);if(c.territory?.id)params.set('territory',c.territory.id);
+ void contextApi<{parks:{id:string;name:string;version_no:number}[]}>('/investimentos/opcoes-corredor?'+params).then(data=>{
+ if(!active)return;setParks(data.parks);
+ if(c.park&&!data.parks.some(p=>'park:'+p.id===c.park?.key)) {update({park:null});setNotice('A zona ou parque desta oportunidade foi retirado por incompatibilidade com a localização escolhida. As restantes oportunidades conservaram-se.');}
+ }).catch(e=>{if(active)setParkError(e.message)}).finally(()=>{if(active)setParkLoading(false)});
+ return()=>{active=false};
+ },[corridor?.key,c.territory?.id,!!remove]);
return
- {i+1}. {c.opportunity.title}
+ {i+1}. {c.opportunity.title}
{remove && Corredor para este investimento{
const next=corridors.find(r=>r.key===e.target.value);
if(!next)return;
@@ -31,11 +45,19 @@ function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;
location(kept.length?kept:next.provinces.length===1?next.provinces:[],next.key);
setNotice(!path.length?(next.provinces.length===1?'Província seleccionada automaticamente: '+next.provinces[0].name+'.':'Escolha uma das províncias deste corredor.'):compatible?'A localização compatível foi conservada.':'A província e os níveis territoriais incompatíveis foram retirados.');
}}>Seleccione um corredor {corridors.map(r=>{r.name} )} }
- {!remove&&c.coverage&&Abrangência: {c.coverage}
}
+ {!remove&&Corredor escolhido: {c.corridor_name??'Por definir'}
}
+ {!remove&&c.coverage&&c.coverage!==c.corridor_name&&Referência documental de origem: {c.coverage}
}
{notice&&{notice}
}
Localização: {c.territory?.title??'Por escolher'}
{!!c.portfolio_origins?.length && Associações escolhidas {c.portfolio_origins.map(origin=> {origin.title}{remove&&update({portfolio_origins:c.portfolio_origins?.filter(r=>r.id!==origin.id)})}>Retirar associação } )} }
+ {c.park && !remove && Zona/parque: {c.park.title}
}
{remove&&<>
+ Zona ou parque desta oportunidade{
+ const p=parks.find(p=>'park:'+p.id===e.target.value);
+ update({park:p?{key:'park:'+p.id,kind:'park',id:p.id,title:p.name,href:'/zonas/'+p.id,version:p.version_no,sha256:'',fields:{}} as ContextReference:null});
+ }}>Sem zona ou parque seleccionado {parks.map(p=>{p.name} )}
+ {parkError&&{parkError}
}
+ {!parkLoading&&!parkError&&{!c.territory?'Escolha a localização desta oportunidade.':parks.length?'A consulta não confirma disponibilidade, admissão ou incentivos.':'Sem zona ou parque com enquadramento documentado nesta localização.'}
}
Actividades que pretende realizar nesta oportunidade {Object.entries({production:'Produção agrícola',processing:'Transformação agroindustrial',trade:'Comercialização e exportação',transport:'Transporte e logística',water:'Captação/utilização de água',forestry:'Actividade florestal',animal:'Produção/serviços pecuários'}).map(([key,label])=> update({activities:e.target.checked?[...(c.activities??[]),key]:(c.activities??[]).filter(x=>x!==key)})}/>{label} )}
location(next)}/>
Motivo da alternativa territorial
@@ -45,11 +67,11 @@ function ChoiceCard({c,i,corridors,disabled,update,remove}:{c:OpportunityChoice;
;
}
-export default function OpportunityChoices({choices, onChange, disabled = false, corridors=[]}: {choices: OpportunityChoice[]; onChange?: (choices: OpportunityChoice[]) => void; disabled?: boolean;corridors?:CorridorOption[]}) {
+export default function OpportunityChoices({choices, onChange, disabled = false, corridors=[], contextId}: {choices: OpportunityChoice[]; onChange?: (choices: OpportunityChoice[]) => void; disabled?: boolean;corridors?:CorridorOption[];contextId?:string}) {
const update = (index: number, change: Partial) => onChange?.(choices.map((c,i) => i === index ? {...c,...change} : c));
return
Oportunidades escolhidas e localização de cada ficha
{onChange?'Escolha o corredor e confirme onde pretende realizar o investimento.':'Escolhas guardadas no dossier; cada oportunidade conserva a sua localização e associações.'}
- {choices.map((c,i)=>update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/>)}
+ {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}/>
)}
;
}
diff --git a/resources/js/lib/investor-context.ts b/resources/js/lib/investor-context.ts
index 1bd73fc0..57339427 100644
--- a/resources/js/lib/investor-context.ts
+++ b/resources/js/lib/investor-context.ts
@@ -40,6 +40,7 @@ export type ContextReference = {
};
export type RequirementsAssessment = {version: string; fiscal_notice: string; requirements: {opportunity_key: string; opportunity_title: string; code: string; title: string; state: string; step: string; authority: string; documents: string[]; basis: string; source_url: string; decision: string}[]};
export type OpportunityChoice = {
+ park?: ContextReference | null;
portfolio_origins?: {id:string;title:string;kind:string;href:string;version:number;sha256:string}[];
corridor_key?: string | null;
corridor_name?: string | null;
diff --git a/resources/js/lib/project-dossier.ts b/resources/js/lib/project-dossier.ts
index 6adff6d5..548d3814 100644
--- a/resources/js/lib/project-dossier.ts
+++ b/resources/js/lib/project-dossier.ts
@@ -700,6 +700,7 @@ export function buildProjectDossierRows(
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]);
+ rows.push(['Localização', 'Zona/parque da oportunidade', typeof choice.park?.title === 'string' ? choice.park.title : 'Sem selecção', choice.opportunity.title+'; '+choice.opportunity.key]);
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']);
diff --git a/resources/js/pages/desk/case.tsx b/resources/js/pages/desk/case.tsx
index 70e042b0..cdd31cad 100644
--- a/resources/js/pages/desk/case.tsx
+++ b/resources/js/pages/desk/case.tsx
@@ -146,6 +146,7 @@ export default function Case({
const requestForm = useForm({
service_version_id: services[0]?.id ?? '',
client_key: createOperationKey(),
+ opportunity: new URLSearchParams(usePage().url.split('?')[1] ?? '').get('opportunity'),
});
const stageForm = useForm({
version: dossier.version,
diff --git a/resources/js/pages/desk/request.tsx b/resources/js/pages/desk/request.tsx
index 36b1af08..be0bebb3 100644
--- a/resources/js/pages/desk/request.tsx
+++ b/resources/js/pages/desk/request.tsx
@@ -139,7 +139,7 @@ export default function RequestPage({
values={form.data.payload}
boundRevision={boundContextRevision}
readOnly={!canEdit}
- allowedFields={Object.keys(definition.fields).filter(
+ allowedFields={[...Object.keys(definition.fields), 'context_opportunity'].filter(
(k) =>
request.status !== 'returned' ||
request.editable_fields.includes(k),
diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx
index b4986492..4391cbf8 100644
--- a/resources/js/pages/investment/detail.tsx
+++ b/resources/js/pages/investment/detail.tsx
@@ -32,6 +32,7 @@ export default function Detail({
}) {
const page = usePage<{ auth: { user: unknown } }>();
const { auth } = page.props;
+ const dossierContext = new URLSearchParams(page.url.split('?')[1] ?? '').get('context');
const [sharing, setSharing] = useState('');
return (
@@ -43,6 +44,10 @@ export default function Detail({
Iniciar ou continuar investimento com esta oportunidade
+ {auth.user&&dossierContext&&
+ Terra/DUAT desta oportunidade
+ Balcão desta oportunidade
+ }
Voltar à pesquisa
diff --git a/resources/js/pages/investment/submission.tsx b/resources/js/pages/investment/submission.tsx
index 81538947..608d994f 100644
--- a/resources/js/pages/investment/submission.tsx
+++ b/resources/js/pages/investment/submission.tsx
@@ -9,6 +9,8 @@ import { Conversation } from './conversation';
import type { Message } from './conversation';
import CountrySelect, { type Country } from '@/components/country-select';
type Payload = {
+ context_opportunity?: string;
+ context_opportunity_snapshot?: string;
simple_participation?: boolean;
opportunity_title?: string;
opportunity_reference?: string;
@@ -79,6 +81,7 @@ type Props = {
id: string;
version: number;
payload: Partial;
+ opportunity_id?: number | null;
status: string;
};
references: References;
@@ -461,6 +464,7 @@ function Editor({
('/investimentos/opcoes-corredor?'+params).then(data=>{
if(!active)return;setCorridors(data.corridors);setParks(data.parks);
const park=Object.hasOwn(changes,'park')?changes.park:view.context.selection.park?.key;
- if(firstCorridor&&park&&!data.parks.some(p=>'park:'+p.id===park)){
+ if(!manageChoices&&firstCorridor&&park&&!data.parks.some(p=>'park:'+p.id===park)){
setChanges(old=>({...old,park:''}));setPlan(null);setMessage('A zona ou parque anterior foi retirado porque não tem enquadramento compatível com a localização escolhida.');
}
}).catch(e=>{if(active)setCorridorError(e.message)}).finally(()=>{if(active)setCorridorLoading(false)});
return()=>{active=false};
},[firstCorridor,firstTerritory,corridorRetry]);
const draftBody=JSON.stringify({complements:complements.map(complementInput),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,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??[]}))}:{})});
+ ...(manageChoices?{opportunity_choices:choices.map(c=>({source:c.opportunity.key,park:c.park?.key??null,portfolio_origins:(c.portfolio_origins??[]).map(r=>r.id),corridor_key:c.corridor_key,territory:c.territory?.key??null,location_reason:c.location_reason,confirm_location_change:!!c.confirm_location_change,activities:c.activities??[]}))}:{})});
const latestDraft=useRef(draftBody);latestDraft.current=draftBody;
const skipNextDraft=useRef(false);
const confirmedDraftBody=useRef(null);
@@ -192,7 +192,7 @@ export default function ContextEditor({
setMessage('');
const body = {
complements: complements.map(complementInput),
- ...(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??[]}))} : {}),
+ ...(manageChoices ? {opportunity_choices: choices.map(c => ({source:c.opportunity.key,park:c.park?.key??null,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,
@@ -434,7 +434,7 @@ export default function ContextEditor({
))}
-
+ {!manageChoices && <>
{choices.length>1?'Zona ou parque da primeira oportunidade':'ZEE, ZFI ou parque consultado'}
{firstCorridor&&!corridorLoading&&!corridorError&&{!firstTerritory?'Escolha a localização para consultar zonas e parques.':!parks.length?'Não existem zonas ou parques com enquadramento documentado compatível nesta localização.':'A consulta não confirma disponibilidade de lotes, admissão ou incentivos.'}
}
+ >}
{!manageChoices&&<>
Localização — escolher até ao nível conhecido
diff --git a/resources/js/pages/investor-path/show.tsx b/resources/js/pages/investor-path/show.tsx
index a073c493..c211618f 100644
--- a/resources/js/pages/investor-path/show.tsx
+++ b/resources/js/pages/investor-path/show.tsx
@@ -428,7 +428,7 @@ export default function InvestmentPath(path: Path) {
function support() {
router.post(
`/balcao-virtual/dossiers/${path.case.id}/pedidos`,
- { service_version_id: service, client_key: requestKey },
+ { service_version_id: service, client_key: requestKey, opportunity: new URLSearchParams(window.location.search).get('opportunity') },
{ onError: (errors) => setError(Object.values(errors).join('; ')) },
);
}
@@ -531,7 +531,7 @@ export default function InvestmentPath(path: Path) {
}
/>
)}
- {!!path.investment_context?.opportunity_choices?.length && }
+ {!!path.investment_context?.opportunity_choices?.length && }
!step.preparation_allowed).map((step) => step.code)}/>
{!!path.content_sources?.length && (
diff --git a/resources/js/pages/land-assistance/case.tsx b/resources/js/pages/land-assistance/case.tsx
index d49938ad..4605c5f2 100644
--- a/resources/js/pages/land-assistance/case.tsx
+++ b/resources/js/pages/land-assistance/case.tsx
@@ -1,3 +1,4 @@
+import { InvestmentConsumerIdentity } from '@/components/investment-consumer-identity';
import { Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
@@ -103,6 +104,7 @@ export default function LandCase({
};
return (
+
{returnDossier && Voltar ao dossier: {returnDossier.title}}
diff --git a/resources/js/pages/land-assistance/print.tsx b/resources/js/pages/land-assistance/print.tsx
index 46854882..74c673df 100644
--- a/resources/js/pages/land-assistance/print.tsx
+++ b/resources/js/pages/land-assistance/print.tsx
@@ -1,3 +1,4 @@
+import { InvestmentConsumerIdentity } from '@/components/investment-consumer-identity';
import { displayValue } from '@/lib/display-value';
import type { ReactNode } from 'react';
type Props = {
@@ -44,6 +45,7 @@ export default function PrintCase({
Imprimir / guardar como PDF
+
InvesteMoz · modelo de preparação · {modelVersion}
diff --git a/resources/views/desk/receipt.blade.php b/resources/views/desk/receipt.blade.php
index 7d68c3d7..e8e879de 100644
--- a/resources/views/desk/receipt.blade.php
+++ b/resources/views/desk/receipt.blade.php
@@ -24,7 +24,15 @@ footer { margin-top:28px; border-top:1px solid #cbd8d3; padding-top:12px; }
InvesteMoz
Recibo de submissão interna Protocolo: {{ $record->protocol }}
Submetido em {{ \Illuminate\Support\Carbon::parse($record->submitted_at, 'UTC')->setTimezone('Africa/Maputo')->format('d/m/Y H:i') }} (CAT, UTC+2) · Versão {{ $record->version }}
Resumo {{ $definition['title'] ?? 'Pedido no Balcão Virtual' }}
Este recibo comprova a submissão interna no portal. Não confirma aprovação, financiamento, licença ou recepção por uma entidade externa. Os dados abaixo são os declarados no momento da submissão.
Dados submetidos Informação declarada pelo requerente
+@php
+ $opportunityScope = json_decode($snapshot['payload']['context_opportunity_snapshot'] ?? '{}', true);
+@endphp
+@if(data_get($opportunityScope, 'choice.opportunity.key'))
+Oportunidade associada {{ data_get($opportunityScope, 'choice.opportunity.title') }} · {{ data_get($opportunityScope, 'choice.opportunity.key') }}
+Contexto territorial guardado {{ data_get($opportunityScope, 'choice.territory.title') ?: 'Por definir' }} · {{ data_get($opportunityScope, 'choice.corridor_name') ?: 'Corredor por definir' }} · {{ data_get($opportunityScope, 'choice.park.title') ?: 'Sem zona/parque seleccionado' }} · Revisão {{ $opportunityScope['revision'] }}
+@endif
@foreach(($snapshot['payload'] ?? []) as $key => $value)
+@continue(in_array($key, ['context_opportunity_snapshot', 'context_opportunity', 'context_reuse'], true))
@php
$field = $definition['fields'][$key] ?? [];
$empty = $value === null || $value === '' || $value === [];
diff --git a/tests/Feature/InvestmentConsumerScopeTest.php b/tests/Feature/InvestmentConsumerScopeTest.php
new file mode 100644
index 00000000..9f779a66
--- /dev/null
+++ b/tests/Feature/InvestmentConsumerScopeTest.php
@@ -0,0 +1,134 @@
+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();
+ }
+
+ public function test_second_only_change_updates_its_parks_and_all_consumer_proposals_without_replacing_first(): void
+ {
+ $before=$this->getJson('/investimentos/'.$this->id.'/contexto')->json('context');
+ $key=$this->choices[1]['source'];
+ $this->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($key))->assertOk()->assertJsonPath('prefill.land.parcels.0.province_id',$this->parks[1]->primary_territory_id);
+ $pending=$this->choices;$pending[1]=[...$pending[1],'corridor_key'=>'beira','territory'=>null,'park'=>null];
+ $this->postJson('/investimentos/'.$this->id.'/contexto/rascunho',['version'=>$this->getJson('/investimentos/'.$this->id.'/contexto')->json('case.version'),'selection'=>[],'opportunity_choices'=>$pending,'draft_sequence'=>1000000])->assertOk();
+ $this->get('/investimentos/'.$this->id.'/contexto/editar')->assertOk()->assertInertia(fn($p)=>$p->where('requestedChoices.1.territory',null));
+ $this->choices[1]=[...$this->choices[1],'corridor_key'=>'beira','territory'=>'territory:'.$this->parks[2]->primary_territory_id,'park'=>'park:'.$this->parks[2]->id,'confirm_location_change'=>true,'location_reason'=>'Alteração expressa apenas da segunda oportunidade para comparação QA.'];
+ $view=$this->confirm();
+ $this->assertSame($before['opportunity_choices'][0],$view['context']['opportunity_choices'][0]);
+ $this->assertSame($this->parks[2]->id,$view['context']['opportunity_choices'][1]['park']['id']);
+ $scope=$this->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($key))->assertOk();
+ $scope->assertJsonPath('prefill.land.parcels.0.province_id',$this->parks[2]->primary_territory_id)->assertJsonPath('prefill.desk.territory_id',$this->parks[2]->primary_territory_id)->assertJsonPath('prefill.submission.location','Manica');
+ $snapshot=json_decode($scope->json('prefill.simulator.context_applicability_snapshot'),true);
+ $this->assertSame($this->parks[2]->id,$snapshot['zone']['id']);
+ foreach($snapshot['requirements_assessment']['requirements'] as $r)$this->assertSame($key,$r['opportunity_key']);
+ $this->assertCount(1,$snapshot['opportunity_choices']);
+ $this->choices[1]['park']='park:'.$this->parks[1]->id;
+ $this->postJson('/investimentos/'.$this->id.'/contexto/previa',['version'=>$view['case']['version'],'selection'=>[],'opportunity_choices'=>$this->choices])->assertUnprocessable();
+ }
+
+ public function test_ambiguous_scope_proposes_only_common_data_and_rejects_foreign_identity(): void
+ {
+ $view=$this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
+ $this->assertSame('SYNTHETIC QA — âmbito específico',$view['prefill']['land']['project_reference']);
+ $this->assertEmpty($view['prefill']['land']['parcels'][0]);
+ $this->assertArrayNotHasKey('territory_id',$view['prefill']['desk']);
+ $this->assertArrayNotHasKey('location',$view['prefill']['submission']);
+ $this->getJson('/investimentos/'.$this->id.'/contexto?opportunity=opportunity:99999999')->assertUnprocessable();
+ $this->actingAs(User::factory()->create())->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($this->choices[1]['source']))->assertForbidden();
+ }
+
+ public function test_land_desk_and_manifestation_save_and_recover_canonical_second_identity_and_preserve_deliberate_fields(): void
+ {
+ $first=$this->getJson('/investimentos/'.$this->id.'/contexto')->json('context.opportunity_choices.0');
+ $this->choices[1]=[...$this->choices[1],'corridor_key'=>'beira','territory'=>'territory:'.$this->parks[2]->primary_territory_id,'park'=>'park:'.$this->parks[2]->id,'confirm_location_change'=>true,'location_reason'=>'Alteração expressa apenas da segunda oportunidade para comparação QA.'];
+ $this->confirm();
+ $key=$this->choices[1]['source'];
+ $scope=app(InvestorContext::class)->prefill($this->getJson('/investimentos/'.$this->id.'/contexto')->json('context'),$key);
+ $land=$this->postJson('/investidor/terra-duat',['payload'=>[...$scope['land'],'investor_path_case_id'=>$this->id,'context_opportunity'=>$key,'context_opportunity_snapshot'=>'forged','purpose'=>'Texto deliberado do requerente']])->assertCreated()->json('id');
+ $landPayload=$this->getJson('/investidor/terra-duat/'.$land)->assertOk()->json('case.payload');
+ $this->assertSame('Texto deliberado do requerente',$landPayload['purpose']);
+ $this->assertSame($key,json_decode($landPayload['context_opportunity_snapshot'],true)['choice']['opportunity']['key']);
+ $this->get('/investidor/terra-duat/'.$land.'/imprimir')->assertOk()->assertInertia(fn($p)=>$p->where('case.payload.context_opportunity',$key));
+ $desk=app(VirtualDesk::class);$service=DB::table('desk_service_versions')->where('status','published')->firstOrFail();
+ $request=$desk->createRequest($this->actor,$this->id,$service->id,(string)Str::uuid(),$key);
+ $payload=$desk->decode($request->payload);$payload['context_opportunity_snapshot']='forged';
+ if(isset($desk->definition($request)['fields']['description']))$payload['description']='Descrição deliberada de QA';
+ $saved=$desk->save($this->actor,$request->id,['version'=>$request->version,'payload'=>$payload]);
+ $recovered=$desk->decode($desk->request($this->actor,$saved->id)->payload);
+ $this->assertSame($key,$recovered['context_opportunity']);
+ $this->assertSame($this->parks[2]->id,json_decode($recovered['context_opportunity_snapshot'],true)['choice']['park']['id']);
+ $rendered=view('desk.receipt',['record'=>(object)[...(array)$saved,'protocol'=>'SYNTHETIC-QA-SCOPE','submitted_at'=>now()->toDateTimeString()],'snapshot'=>['payload'=>$recovered],'definition'=>$desk->definition($saved)])->render();
+ $this->assertStringContainsString('Manica',$rendered);$this->assertStringContainsString($key,$rendered);
+ $submission=$this->postJson('/investidor/manifestacoes',['opportunity_id'=>(int)substr($key,12)])->assertCreated()->json('id');
+ $v=$this->getJson('/investimentos/'.$this->id.'/contexto')->json('case.version');
+ $bound=$this->postJson('/investidor/manifestacoes/'.$submission.'/contexto',['case_id'=>$this->id,'case_version'=>$v,'version'=>1])->assertOk()->json('version');
+ $this->putJson('/investidor/manifestacoes/'.$submission,['version'=>$bound,'payload'=>[...$scope['submission'],'context_opportunity'=>$key,'context_opportunity_snapshot'=>'forged','objective'=>'Objectivo deliberado do investidor']])->assertOk();
+ $p=$this->getJson('/investidor/manifestacoes/'.$submission)->assertOk()->json('submission.payload');
+ $this->assertSame($key,$p['context_opportunity']);$this->assertSame('Objectivo deliberado do investidor',$p['objective']);
+ $this->assertSame($key,json_decode($p['context_opportunity_snapshot'],true)['choice']['opportunity']['key']);
+ $this->assertSame('Manica',json_decode($p['context_opportunity_snapshot'],true)['choice']['territory']['title']);
+ $this->assertSame((int)substr($key,12),$this->getJson('/investidor/manifestacoes/'.$submission)->json('submission.opportunity_id'));
+ $revision=$this->getJson('/investidor/manifestacoes/'.$submission.'/revisions/3')->assertOk()->json('snapshot.payload');
+ $this->assertSame($key,$revision['context_opportunity']);
+ $this->assertSame($first,$this->getJson('/investimentos/'.$this->id.'/contexto')->json('context.opportunity_choices.0'));
+ }
+
+ public function test_returned_request_keeps_its_locked_opportunity_snapshot_when_only_description_is_editable(): void
+ {
+ $desk=app(VirtualDesk::class);$service=DB::table('desk_service_versions')->where('status','published')->firstOrFail();
+ $row=$desk->createRequest($this->actor,$this->id,$service->id,(string)Str::uuid(),$this->choices[1]['source']);
+ $payload=$desk->decode($row->payload);$snapshot=$payload['context_opportunity_snapshot'];
+ DB::table('desk_requests')->where('id',$row->id)->update(['status'=>'returned','editable_fields'=>json_encode(['description']),'protocol'=>'SYNTHETIC-QA-'.Str::uuid(),'submitted_at'=>now(),'submitted_snapshot'=>$desk->encode(['payload'=>$payload,'investment_context'=>['revision'=>1]])]);
+ $this->choices[1]=[...$this->choices[1],'corridor_key'=>'beira','territory'=>'territory:'.$this->parks[2]->primary_territory_id,'park'=>'park:'.$this->parks[2]->id,'confirm_location_change'=>true,'location_reason'=>'Mudança apenas da segunda oportunidade após devolução do pedido QA.'];$this->confirm();
+ $saved=$desk->save($this->actor,$row->id,['version'=>$row->version,'payload'=>[...$payload,'description'=>'Correcção deliberada apenas da descrição']]);
+ $after=$desk->decode($saved->payload);
+ $this->assertSame($snapshot,$after['context_opportunity_snapshot']);
+ $this->assertSame($payload['context_opportunity'],$after['context_opportunity']);
+ }
+}
diff --git a/tests/frontend/consumer-opportunity-inheritance.test.mjs b/tests/frontend/consumer-opportunity-inheritance.test.mjs
new file mode 100644
index 00000000..4db8a317
--- /dev/null
+++ b/tests/frontend/consumer-opportunity-inheritance.test.mjs
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
+import { buildProjectDossierRows } from '../../resources/js/lib/project-dossier.ts';
+
+test('changing only the treated opportunity refreshes inherited geography and preserves deliberate data',()=>{
+ const scope='dossier:QA:land';
+ const first={project_reference:'Investimento conjunto',purpose:'Segunda oportunidade',parcels:[{province_id:'gaza'}]};
+ const initial=inheritCompatibleFields(scope,{},first,null);
+ const values={...initial.patch,context_reuse:initial.metadata,purpose:'Edição deliberada',parcels:[{province_id:'gaza',area:'23',place_description:'Local declarado'}]};
+ const next=inheritCompatibleFields(scope,values,{...first,purpose:'Novo resumo da segunda',parcels:[{province_id:'manica'}]},initial.metadata);
+ assert.equal(next.patch.parcels[0].province_id,'manica');
+ assert.equal(next.patch.parcels[0].district_id,'');
+ assert.equal(next.patch.parcels[0].area,'23');
+ assert.equal(next.patch.parcels[0].place_description,'Local declarado');
+ assert.equal('purpose' in next.patch,false);
+ assert.ok(next.conflicts.includes('purpose'));
+ // The land form merges this geographic proposal into the existing parcel;
+ // area and the declared place are not part of the geographic replacement.
+ const merged={...values.parcels[0],...next.patch.parcels[0]};
+ assert.equal(merged.area,'23');assert.equal(merged.place_description,'Local declarado');
+});
+
+test('a deliberate site in a second-opportunity request remains a conflict instead of being replaced',()=>{
+ const scope='dossier:QA:desk';
+ const source={territory_id:'gaza',description:'Resumo específico'};
+ const initial=inheritCompatibleFields(scope,{},source,null);
+ const next=inheritCompatibleFields(scope,{...initial.patch,territory_id:'local-deliberado'},{...source,territory_id:'manica'},initial.metadata);
+ assert.equal('territory_id' in next.patch,false);
+ assert.ok(next.conflicts.includes('territory_id'));
+});
+
+test('report keeps each park attached to its own opportunity after only the second changes',()=>{
+ const choices=[{opportunity:{key:'opportunity:1',title:'Primeira'},territory:{title:'Sofala'},park:{title:'Parque Sofala'}},{opportunity:{key:'opportunity:2',title:'Segunda'},territory:{title:'Manica'},park:{title:'Parque Manica'}}];
+ const rows=buildProjectDossierRows({context_applicability_snapshot:JSON.stringify({version:1,opportunity_choices:choices})});
+ const parks=rows.filter(r=>r[1]==='Zona/parque da oportunidade');
+ assert.deepEqual(parks.map(r=>[r[2],r[3]]),[['Parque Sofala','Primeira; opportunity:1'],['Parque Manica','Segunda; opportunity:2']]);
+});