desk->decode($case->payload); return $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' => []]; } 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; if ($kind === 'opportunity') { abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.'); $o = Opportunity::published()->with('sector')->findOrFail((int) $id); $title = $o->title; $href = '/oportunidades/'.$o->slug; $version = $o->management_version; $fields = ['project_title' => $o->title, 'project_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()->where('category', 'industrial_park')->findOrFail($id); $title = $p->name; $version = $p->version_no; $href = '/zonas/'.$id; if ($p->primary_territory_id) { $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]; 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 (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()->where('category', 'industrial_park')->orderBy('name')->get(['id', 'name', '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', '_token']), 422, 'Campo de controlo desconhecido.'); return Validator::make($input, ['version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park', 'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000', '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); $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'], 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; } } $dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection); $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'] : [])])) : []; $plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance, 'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $dependencyChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [], 'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, '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'])) { $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 = ['revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review, 'dependency_hash' => $this->dependencyHash($plan['selection']), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id]; if ($plan['dependency_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); $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; } } } return ['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 prefill(array $ctx): array { $f = $ctx['fields']; $sim = Arr::only($f, ['project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id']); 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(['sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)]; } }