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 && } {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 && } - {!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&&}
  • )}
} + {c.park && !remove &&

Zona/parque: {c.park.title}

} {remove&&<> + + {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])=>)}
location(next)}/>