Files
Investidor-V2/app/Services/InvestorContext.php
T
nala cf003c8623
tests / ci (push) Canceled after 0s
versao nrgocios 4
2026-10-18 15:57:06 +02:00

398 lines
24 KiB
PHP

<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Context belongs to the existing encrypted dossier. Sources remain read-only. */
final class InvestorContext
{
public const MIGRATION = '2026_10_16_150000_create_investor_navigation_preferences';
public const SOURCE = 'investor-context';
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'];
public function __construct(private readonly VirtualDesk $desk) {}
public function installed(): bool
{
return Schema::hasTable('investor_navigation_preferences');
}
public function state(\stdClass $case): array
{
$payload = $this->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)];
}
}