desk->decode($case->payload); $context = $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [], 'fields' => ['project_title' => $case->title], 'provenance' => ['project_title' => ['kind' => 'dossier', 'id' => $case->id, 'version' => $case->version]], 'review_steps' => []]; // Explicit dossier anchor only: never the account's most recent simulation or another owner's draft. if (($case->simulation_id ?? null) && request()->user()?->id === (int) $case->user_id) { $draft = SimulationFormDraft::whereKey($case->simulation_id)->where('owner_user_id', $case->user_id)->whereNull('deleted_at')->first(); if ($draft) { $values = [...SimulationFormDraft::decryptSnapshot($draft->personal_snapshot), ...SimulationFormDraft::decryptSnapshot($draft->economic_snapshot)]; foreach (Arr::only($values, self::FIELDS) as $key => $value) { if (is_scalar($value) && (string) $value !== '' && (! array_key_exists($key, $context['fields']) || ($context['provenance'][$key]['kind'] ?? null) === 'linked_simulation')) { $context['fields'][$key] = (string) $value; $context['provenance'][$key] = ['kind' => 'linked_simulation', 'id' => $draft->id, 'version' => $draft->version_no, 'sha256' => $draft->snapshot_sha256]; } } } } $zone = $context['selection']['park'] ?? null; $context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general'; $context['fields']['special_zone_id'] = $zone['id'] ?? ''; return $context; } public function dependencyHash(array $selection): string { return hash('sha256', json_encode(Arr::sortRecursive($selection), JSON_THROW_ON_ERROR)); } public function reference(string $key): array { [$kind,$id] = array_pad(explode(':', $key, 2), 2, ''); $fields = []; $href = ''; $title = ''; $version = null; $documents = []; $visibility = 'public'; $state = null; $zoneAnalysis = null; if ($kind === 'opportunity') { abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.'); $o = Opportunity::with('sector')->findOrFail((int) $id); $published = Opportunity::published()->whereKey($o->id)->exists(); if (! $published) { abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.'); $visibility = 'private'; $state = app(PortalProfileAccess::class)->state($o); } if (request()->user()) { $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o); } $draft = ! $published ? app(OpportunityManagement::class)->values($o) : []; $title = $draft['canonical']['official_title'] ?? $o->title; $href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view'; $version = $o->management_version; $fields = ['project_title' => $title, 'project_summary' => $draft['canonical']['executive_summary'] ?? $o->summary]; if ($o->sector) { $fields['sector_id'] = (string) $o->sector_id; $fields['sector'] = $o->sector->name; } // Text labels do not establish a canonical territorial correspondence. if ($o->province) { $fields['province'] = $o->province; } if ($o->district) { $fields['district'] = $o->district; } if ($o->primary_territory_id) { $fields = [...$fields, ...$this->territoryFields($o->primary_territory_id)]; } } elseif ($kind === 'business') { abort_unless(config('existing-business.enabled') && Str::isUuid($id), 404); $o = ExistingBusiness::published()->with('sector')->findOrFail($id); $title = $o->public_title; $href = '/negocios/'.$o->public_slug; $version = $o->version_no; $fields = ['project_title' => $o->public_title, 'project_summary' => $o->objective]; if ($o->sector) { $fields['sector_id'] = (string) $o->sector_id; $fields['sector'] = $o->sector->name; } $location = DB::table('existing_business_locations')->where('existing_business_id', $id)->where('is_public', true)->where('is_primary', true)->first(); if ($location) { $leaf = $location->province_id; foreach (['district', 'admin_post', 'locality'] as $level) { if (in_array($location->precision_level, array_slice(['province', 'district', 'admin_post', 'locality', 'exact'], array_search($level, ['province', 'district', 'admin_post', 'locality', 'exact'])), true) && $location->{$level.'_id'}) { $leaf = $location->{$level.'_id'}; } } if ($leaf) { $fields = [...$fields, ...$this->territoryFields($leaf)]; } } } elseif ($kind === 'sector') { abort_unless(ctype_digit($id), 422); $s = Sector::where('is_active', true)->findOrFail((int) $id); $title = $s->name; $href = SectorOrganization::href($s->slug); $version = $s->updated_at?->toIso8601String(); $fields = ['sector_id' => (string) $s->id, 'sector' => $s->name]; } elseif ($kind === 'territory') { abort_unless(Str::isUuid($id), 422); $t = app(InvestmentGeography::class)->publicReferences()->whereIn('location_type', array_values(InvestmentGeography::FIELDS))->where('id', $id)->first(); abort_unless($t, 404, 'Território público indisponível.'); $title = $t->name; $version = $t->version_no; $href = '/oportunidades?'.$t->location_type.'_id='.$id; $fields = $this->territoryFields($id); } elseif ($kind === 'park') { abort_unless(Str::isUuid($id), 422); $p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id); $title = $p->name; $fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category]; $version = $p->version_no; $zoneAnalysis = ['conditions' => app(InvestmentZoneOperationalDetails::class)->publicData($p)['conditions'] ?? [], 'source' => $p->legal_source_id ? DB::table('investment_legal_sources')->where('id', $p->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'valid_from', 'valid_until']) : null, 'recognition' => 'undetermined', 'executable_benefit' => false]; $href = '/zonas/'.$id; if ($p->primary_territory_id) { $fields = [...$fields, ...$this->territoryFields($p->primary_territory_id)]; } } else { abort(422, 'Tipo de referência inválido.'); } $fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== ''); $record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields]; if ($zoneAnalysis !== null) { $record['analysis'] = $zoneAnalysis; } if ($documents || $visibility === 'private') { $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state]; } return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))]; } private function territoryFields(string $id): array { $path = app(InvestmentGeography::class)->path($id); $fields = []; $names = []; foreach ($path as $n) { if (! in_array($n->location_type, array_values(InvestmentGeography::FIELDS), true)) { continue; } $fields[$n->location_type.'_id'] = $n->id; $names[] = $n->name; if (in_array($n->location_type, ['province', 'district'], true)) { $fields[$n->location_type] = $n->name; } } if ($names) { $fields['location'] = implode(' / ', $names); } return $fields; } public function options(string $query = ''): array { $query = mb_substr(trim($query), 0, 120); $items = []; foreach (Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(['id', 'title']) as $o) { $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity']; } if (request()->user()) { $private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(); foreach ($private as $o) { if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) { $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity']; } } } if (config('existing-business.enabled')) { foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) { $items[] = ['key' => 'business:'.$o->id, 'title' => $o->public_title, 'kind' => 'business']; } } return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])]; } public function current(User $actor): array { if (! $this->installed()) { return ['installed' => false, 'focus' => null]; } $p = DB::table('investor_navigation_preferences')->where('user_id', $actor->id)->first(); if (! $p) { return ['installed' => true, 'focus' => null]; } try { $case = $this->desk->case($actor, $p->case_id); app(InvestorPath::class)->view($actor, $case->id); } catch (ModelNotFoundException $e) { return ['installed' => true, 'focus' => null, 'message' => 'O contexto anterior já não está disponível para esta conta.']; } catch (HttpException $e) { if (in_array($e->getStatusCode(), [403, 404, 409], true)) { return ['installed' => true, 'focus' => null, 'message' => 'O contexto anterior já não está disponível para esta conta.']; }throw $e; } if ($case->closed_at) { return ['installed' => true, 'focus' => null]; } return ['installed' => true, 'focus' => ['case_id' => $case->id, 'step_code' => $p->step_code, 'version' => (int) $p->version]]; } public function focus(User $actor, array $input): array { abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.'); $data = Validator::make($input, ['case_id' => 'required|uuid', 'step_code' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/'], 'version' => 'required|integer|min:0'])->validate(); return DB::transaction(function () use ($actor, $data) { DB::table('users')->where('id', $actor->id)->lockForUpdate()->firstOrFail(); $case = $this->desk->case($actor, $data['case_id']); abort_if($case->closed_at, 409); app(InvestorPath::class)->view($actor, $case->id); $old = DB::table('investor_navigation_preferences')->where('user_id', $actor->id)->first(); $step = $data['step_code'] ?? null; if ($old && $old->case_id === $case->id && $old->step_code === $step) { return $this->current($actor); } abort_unless((int) ($old->version ?? 0) === $data['version'], 409, 'O contexto em foco mudou noutro dispositivo; recupere-o antes de escolher novamente.'); DB::table('investor_navigation_preferences')->updateOrInsert(['user_id' => $actor->id], ['case_id' => $case->id, 'step_code' => $step, 'version' => (int) ($old->version ?? 0) + 1, 'updated_at' => now()]); return $this->current($actor); }); } private function input(array $input): array { abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', 'document_links', '_token']), 422, 'Campo de controlo desconhecido.'); abort_if(! app(PrivateInvestmentPreparation::class)->available() && (! empty($input['document_links']) || array_intersect(array_keys(is_array($input['declarations'] ?? null) ? $input['declarations'] : []), PrivateInvestmentPreparation::FIELDS)), 409, 'Esta preparação ainda não está disponível neste ambiente. As respostas foram preservadas.'); $inputRules = [ 'document_links' => 'sometimes|array:'.implode(',', PrivateInvestmentPreparation::DOCUMENTS), 'document_links.*' => 'nullable|uuid', ]; foreach (app(PrivateInvestmentPreparation::class)->questions() as $question) { $inputRules['declarations.'.$question['key']] = isset($question['options']) ? ['nullable', Rule::in(array_keys($question['options']))] : ['nullable', 'regex:/^\d+(?:\.\d+)?$/D', 'numeric', 'min:0', 'max:999999999999999999']; } return Validator::make($input, [...$inputRules, 'version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park', 'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage,'.implode(',', PrivateInvestmentPreparation::FIELDS), 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000', 'declarations.currency' => 'nullable|regex:/^[A-Z]{3}$/', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100', 'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate(); } public function preview(User $actor, string $id, array $input): array { $data = $this->input($input); $case = $this->desk->case($actor, $id, true); abort_if($case->closed_at, 409); abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão atual.'); app(InvestorPath::class)->view($actor, $id); $old = $this->state($case); $links = $old['preparation_documents'] ?? []; foreach ($data['document_links'] ?? [] as $key => $document) { if ($document) { abort_unless(DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.id', $document)->whereNull('d.deleted_at')->exists(), 422, 'Seleccione um documento já associado a este dossier; os dados anteriores foram preservados.'); $links[$key] = $document; } else { unset($links[$key]); } } $selection = $old['selection']; foreach ($data['selection'] as $slot => $key) { if ($key === null || $key === '') { unset($selection[$slot]); continue; } $ref = $this->reference($key); $expected = $slot === 'opportunity' ? ['opportunity', 'business'] : [$slot]; abort_unless(in_array($ref['kind'], $expected, true), 422, 'Referência incompatível com a dimensão.'); $selection[$slot] = $ref; } // A new opportunity may propose its explicitly stored sector; it never matches names. if (isset($data['selection']['opportunity']) && ! array_key_exists('sector', $data['selection']) && isset($selection['opportunity']['fields']['sector_id'])) { if (Sector::where('is_active', true)->whereKey($selection['opportunity']['fields']['sector_id'])->exists()) { $selection['sector'] = $this->reference('sector:'.$selection['opportunity']['fields']['sector_id']); } } $proposed = []; $origins = []; foreach (['opportunity', 'sector', 'park', 'territory'] as $slot) { if (in_array($slot, ['park', 'territory'], true) && array_intersect(array_keys($selection[$slot]['fields'] ?? []), array_keys(InvestmentGeography::FIELDS))) { foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'] as $key) { unset($proposed[$key],$origins[$key]); } } foreach ($selection[$slot]['fields'] ?? [] as $key => $value) { $proposed[$key] = (string) $value; $origins[$key] = Arr::except($selection[$slot], ['fields']); } } $fields = $old['fields']; $provenance = $old['provenance']; $changed = []; $kept = []; $replaced = []; foreach ($data['declarations'] ?? [] as $key => $value) { $fields[$key] = (string) ($value ?? ''); $provenance[$key] = ['kind' => 'process_declaration', 'actor_id' => $actor->id, 'context_revision' => $old['revision'] + 1]; } foreach ($proposed as $key => $value) { if (($fields[$key] ?? '') === '' || (($provenance[$key]['kind'] ?? '') !== 'process_declaration' && ($provenance[$key]['kind'] ?? '') !== 'dossier') || in_array($key, $data['replace_fields'] ?? [], true)) { if (($fields[$key] ?? null) !== $value) { $replaced[] = ['field' => $key, 'before' => $fields[$key] ?? null, 'after' => $value]; }$fields[$key] = $value; $provenance[$key] = $origins[$key]; } else { $kept[] = ['field' => $key, 'value' => $fields[$key], 'proposed' => $value]; } } // Remove inherited subordinate geography only when its source was actually replaced. foreach ($fields as $key => $value) { if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector', 'special_zone_id', 'investment_regime'], true) && isset($provenance[$key]['key'])) { unset($fields[$key],$provenance[$key]); } } $replaced = []; foreach (array_unique([...array_keys($old['fields']), ...array_keys($fields)]) as $key) { if (($old['fields'][$key] ?? null) !== ($fields[$key] ?? null)) { $replaced[] = ['field' => $key, 'before' => $old['fields'][$key] ?? null, 'after' => $fields[$key] ?? null]; } } foreach (['opportunity', 'sector', 'territory', 'park'] as $slot) { if (($old['selection'][$slot] ?? null) !== ($selection[$slot] ?? null)) { $changed[] = $slot; } } $changedFields = array_column($replaced, 'field'); $conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary'])); $dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged; $privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS); $calculationInputsChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])])); $payload = $this->desk->decode($case->payload); $results = $payload['investor_path']['results'] ?? []; $requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all(); $steps = $dependencyChanged ? array_values(array_unique([...(array_intersect($changed, ['opportunity', 'sector']) ? ['S02', 'S03', 'S04', 'S08', 'S12', 'S13', 'S14', 'S15', 'S17'] : []), ...(array_intersect($changed, ['territory', 'park']) ? ['S08', 'S09', 'S10', 'S11', 'S17'] : [])])) : []; if (array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary', ...PrivateInvestmentPreparation::FIELDS]))) { $steps = array_values(array_unique([...$steps, 'S02', 'S03', 'S08', 'S12', 'S13', 'S17'])); } if ($privateConditionsChanged) { $steps = array_values(array_unique([...$steps, 'S02', 'S05', 'S06', 'S07'])); } $privatePreparation = null; if (app(PrivateInvestmentPreparation::class)->available()) { $docs = $this->preparationDocuments($id); $beforePreparation = app(PrivateInvestmentPreparation::class)->assess($old, $docs); $afterPreparation = app(PrivateInvestmentPreparation::class)->assess(['fields' => $fields, 'selection' => $selection, 'preparation_documents' => $links], $docs); $privatePreparation = ['before' => $beforePreparation, 'after' => $afterPreparation]; } $plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance, 'preparation_documents' => $links, 'private_investment' => $privatePreparation, 'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $calculationInputsChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [], 'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'calculation_inputs_changed' => $calculationInputsChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(), 'notice' => 'Não confirma incentivos, licenças, disponibilidade de terra, reserva ou autorização. Submissões e decisões anteriores mantêm o conteúdo enviado.']; $hash = hash_hmac('sha256', json_encode(Arr::sortRecursive([$actor->id, $data, $plan]), JSON_THROW_ON_ERROR), config('app.key')); return [...$plan, 'preview_hash' => $hash]; } public function commit(User $actor, string $id, array $input): array { abort_unless($this->installed(), 409, 'Instalação do contexto ainda pendente.'); $control = Validator::make($input, ['preview_hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate(); $this->input($input); $requestHash = hash('sha256', json_encode(Arr::sortRecursive($input), JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $id, $input, $control, $requestHash) { DB::select('SELECT pg_advisory_xact_lock(161026,hashtext(?))', [$control['operation_key']]); $receipt = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $control['operation_key'])->first(); if ($receipt) { $saved = json_decode($receipt->after_data, true); abort_unless((int) $receipt->actor_user_id === $actor->id && ($saved['case_id'] ?? '') === $id && hash_equals($saved['request_hash'], $requestHash), 409, 'Chave de operação usada com outro conteúdo ou ator.'); $this->desk->case($actor, $id, true); return [...$saved['result'], 'replayed' => true]; } $case = $this->desk->case($actor, $id, true, true); $before = $this->state($case); $plan = $this->preview($actor, $id, $input); abort_unless(hash_equals($plan['preview_hash'], $control['preview_hash']), 409, 'O dossier ou uma fonte mudou desde a prévia; compare novamente.'); if (! $plan['changed_dimensions'] && ! $plan['updated'] && empty($input['declarations']) && empty($input['replace_fields']) && empty($input['document_links'])) { $result = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => $before['revision'], 'changed' => false, 'replayed' => false]; DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'desk_cases', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'correlation_id' => $control['operation_key'], 'action' => 'context_unchanged', 'data_classification' => 'restricted', 'after_data' => json_encode(['case_id' => $id, 'request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]); return $result; } $payload = $this->desk->decode($case->payload); $revision = $before['revision'] + 1; $review = $before['review_steps'] ?? []; foreach ($plan['review_steps'] as $step) { $review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.']; } $context = ['preparation_documents' => $plan['preparation_documents'], 'preparation_snapshot' => $plan['private_investment']['after'] ?? $before['preparation_snapshot'] ?? null, 'revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review, 'dependency_hash' => $this->dependencyHash(['selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id]; if ($plan['calculation_inputs_changed']) { foreach ($payload['investor_path']['results'] ?? [] as $i => $r) { $payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()]; } } $payload['investor_path']['context'] = $context; DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'title' => $context['fields']['project_title'] ?? $case->title, 'version' => $case->version + 1, 'updated_at' => now()]); $this->desk->event($this->desk->case($actor, $id), $actor, 'investment_context_changed', ['before' => $before, 'after' => $context, 'affected_result_ids' => array_column($plan['dependent_results'], 'id'), 'preserved_request_ids' => array_column($plan['requests'], 'id')]); $result = ['case_id' => $id, 'version' => (int) $case->version + 1, 'revision' => $revision, 'replayed' => false]; DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'desk_cases', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'correlation_id' => $control['operation_key'], 'action' => 'context_changed', 'data_classification' => 'restricted', 'after_data' => json_encode(['case_id' => $id, 'request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]); return $result; }, 3); } public function view(User $actor, string $id): array { $case = $this->desk->case($actor, $id); abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403); $ctx = $this->state($case); $path = app(InvestorPath::class)->view($actor, $id); $ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'], 'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))]; $simulatorRevision = null; if ((int) $case->user_id === $actor->id && $case->simulation_id) { $draftVersion = DB::table('simulation_form_drafts')->where('id', $case->simulation_id)->where('owner_user_id', $actor->id)->whereNull('deleted_at')->value('version_no'); foreach (array_reverse($path['results']) as $result) { if (($result['draft_id'] ?? null) === $case->simulation_id && ($result['draft_version'] ?? null) === $draftVersion) { $simulatorRevision = $result['context_revision'] ?? null; break; } } } $preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($ctx, $this->preparationDocuments($id)) : null; $ctx['preparation_assessment'] = $preparation; 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.']; } public function preparationDocuments(string $id): array { return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all(); } public function prefill(array $ctx): array { $f = $ctx['fields']; $sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]); $zone = $ctx['selection']['park'] ?? null; $sim['context_applicability_snapshot'] = json_encode([ 'version' => 1, 'context_revision' => $ctx['revision'], 'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null, 'provenance' => $ctx['provenance'], 'path' => $ctx['path_applicability'] ?? null, 'preparation' => isset($ctx['preparation_assessment']) ? Arr::except($ctx['preparation_assessment'], ['questions']) : null, 'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']), 'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.', ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); 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)]; } }