Files
Investidor-V2/app/Services/InvestorContext.php
T

696 lines
51 KiB
PHP

<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\SimulationFormDraft;
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 Illuminate\Validation\Rule;
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', 'currency', 'expected_start_date', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'investor_origin', 'person_type', 'entity_stage', 'tax_residency_country', 'fiscal_residency_country', 'activity_id', 'project_type', 'maturity_stage', 'special_zone_id', 'investment_regime', ...PrivateInvestmentPreparation::FIELDS];
public function __construct(private readonly VirtualDesk $desk) {}
public function draft(User $actor, string $id): ?array
{
$case = $this->desk->case($actor, $id, true);
$draft = $this->desk->decode($case->payload)['investor_path']['context_drafts'][(string)$actor->id] ?? null;
return $draft ? [...$draft, 'needs_review' => (int)$draft['version'] !== (int)$case->version] : null;
}
public function saveDraft(User $actor, string $id, array $input): array
{
$case=$this->desk->case($actor,$id,true);
abort_if($case->closed_at,409);
$sequence = (int)($input['draft_sequence'] ?? 0);
$entrySources=Validator::make($input,['entry_sources'=>'sometimes|array|max:200','entry_sources.*'=>'string|max:100'])->validate()['entry_sources']??[];
unset($input['draft_sequence'],$input['entry_sources']);
abort_unless($sequence > 0, 422);
$data = $this->input($input);
if (isset($data['complements'])) app(InvestmentComplements::class)->resolve($data['complements'], $this->state($case)['complements'] ?? []);
foreach ($data['selection'] as $slot=>$key) {
if (!$key) continue;
$ref=$this->reference($key);
abort_unless(in_array($ref['kind'],$slot==='opportunity'?['opportunity','business']:[$slot],true),422,'Referência incompatível com a escolha.');
}
foreach ($data['opportunity_choices'] ?? [] as $choice) {
$ref = $this->reference($choice['source']);
abort_unless($ref['kind'] === 'opportunity', 422);
$record = Opportunity::findOrFail($ref['id']);
app(InvestmentOpportunityChoices::class)->portfolioOrigins($choice['portfolio_origins'] ?? [], (int) $record->id);
$key = $choice['corridor_key'] ?? app(InvestmentCorridorContext::class)->key($record);
if (!empty($choice['territory'])) {
$location = $this->reference($choice['territory']);
abort_unless($location['kind'] === 'territory', 422);
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;
if ($source && str_starts_with($source,'opportunity:')) {
$key=$first['corridor_key'] ?? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail(substr($source,12)));
if ($key) {
$territory=$first['territory'] ?? $data['selection']['territory'] ?? null;
$ids=app(InvestmentCorridorContext::class)->provinces($key);
$territory=$territory ? substr($territory,10) : (count($ids)===1?$ids[0]->id:null);
$park=$this->reference($data['selection']['park']);
abort_unless($park['kind']==='park' && in_array($park['id'],array_column(app(InvestmentCorridorContext::class)->parks($key,$territory),'id'),true),422,'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
}
}
}
return DB::transaction(function () use ($actor, $id, $data, $sequence, $entrySources) {
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at || (int)$case->version !== (int)$data['version'], 409, 'O dossier mudou. Recupere os dados antes de continuar.');
$payload = $this->desk->decode($case->payload);
$previous = $payload['investor_path']['context_drafts'][(string)$actor->id] ?? [];
abort_if(($previous['draft_sequence'] ?? 0) > $sequence, 409, 'Existe um rascunho mais recente. Recupere o dossier antes de continuar.');
$payload['investor_path']['context_drafts'][(string)$actor->id] = [...$data, 'draft_sequence' => $sequence, 'entry_sources'=>array_values(array_unique([...($previous['entry_sources']??[]),...$entrySources]))];
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload)]);
return ['saved' => true];
});
}
public function installed(): bool
{
return Schema::hasTable('investor_navigation_preferences');
}
public function state(\stdClass $case): array
{
$payload = $this->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];
}
}
}
}
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'] ?? '';
return $context;
}
public function dependencyHash(array $selection): string
{
unset($selection['portfolio'], $selection['selection']['portfolio']);
if (isset($selection['opportunity_choices'])) $selection['opportunity_choices'] = array_map(fn($choice)=>Arr::except($choice,['portfolio_origins']),$selection['opportunity_choices']);
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;
}
$proactive = $published ? app(ProactiveInvestment::class)->publicData($o) : ($draft['proactive'] ?? null);
if ($proactive) $zoneAnalysis = ['proactive' => $proactive, 'source_key' => $key, 'source_version' => $version, 'private_source' => !$published];
// 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 === 'portfolio') {
abort_unless(Str::isUuid($id), 422);
$dossier = \App\Http\Controllers\InvestmentPortfolioController::visible()->findOrFail($id);
$title = $dossier->title;
$href = '/carteira/'.$id;
// Documentary coverage is not the investor's chosen implantation.
$version = DB::table('investment_dossiers')->where('id', $id)->value('version_no');
$fields = ['project_title' => $title, 'project_summary' => $dossier->content['description'] ?? $dossier->content['focus'] ?? ''];
$state = 'documented_origin';
$zoneAnalysis = ['source' => $dossier->source, 'documented_sector' => $dossier->content['sector'] ?? null,
'documented_location' => $dossier->content['location'] ?? $dossier->content['region'] ?? null,
'geography' => app(InvestmentRelations::class)->dossier($dossier), 'available_opportunity' => false];
} 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), ['complements', 'opportunity_choices', '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,
'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,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:portfolio,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);
$complements = array_key_exists('complements', $data) ? app(InvestmentComplements::class)->resolve($data['complements'], $old['complements'] ?? []) : ($old['complements'] ?? []);
$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]);
}
}
$choices = $old['opportunity_choices'] ?? [];
if (! array_key_exists('opportunity_choices', $data) && array_key_exists('territory', $data['selection'])) {
$source = $data['selection']['opportunity'] ?? ($old['selection']['opportunity']['key'] ?? null);
if ($source && str_starts_with($source, 'opportunity:')) {
$documented = Opportunity::findOrFail(substr($source, 12))->primary_territory_id;
abort_if($documented && $data['selection']['territory'] !== 'territory:'.$documented, 422,
'Confirme a alternativa territorial por ficha e indique o motivo; a localização documentada foi preservada.');
}
}
if (array_key_exists('opportunity_choices', $data)) {
$choices = app(InvestmentOpportunityChoices::class)->resolve($data['opportunity_choices'], $choices);
$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.');
}
$selection = $old['selection'];
foreach ($complements as $complement) {
if (($complement['availability'] ?? '') === 'review_required') continue;
foreach ([$complement['origin']['key'], $complement['target']['key'] ?? ''] as $key) {
if (str_starts_with($key, 'opportunity:')) abort_unless(in_array($key, array_column(array_column($choices, 'opportunity'), 'key'), true), 422, 'Conserve a oportunidade escolhida ou retire explicitamente a sua relação complementar.');
}
}
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;
}
$corridor = $choices[0]['corridor_key'] ?? (isset($selection['opportunity']) && $selection['opportunity']['kind'] === 'opportunity'
? app(InvestmentCorridorContext::class)->key(Opportunity::findOrFail($selection['opportunity']['id'])) : null);
if ($corridor && isset($selection['territory'])) abort_unless(app(InvestmentCorridorContext::class)->compatible($corridor, $selection['territory']['id']), 422, 'A localização não pertence ao corredor escolhido.');
if ($corridor && isset($selection['park'])) {
$eligible = array_column(app(InvestmentCorridorContext::class)->parks($corridor, $selection['territory']['id'] ?? null), 'id');
if (!in_array($selection['park']['id'], $eligible, true)) {
abort_if(!empty($data['selection']['park']), 422, 'A zona ou parque não tem enquadramento compatível documentado nesta localização.');
unset($selection['park']);
}
}
// 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 (['portfolio', '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]);
}
}
if ($corridor) {
foreach (['province_id','district_id','admin_post_id','locality_id','province','district','location'] as $key) {
if (isset($selection['territory']['fields'][$key])) {
$fields[$key]=(string)$selection['territory']['fields'][$key];
$provenance[$key]=Arr::except($selection['territory'],['fields']);
} else unset($fields[$key],$provenance[$key]);
}
$fields['special_zone_id']=$selection['park']['id']??'';
$fields['investment_regime']=$selection['park']['fields']['investment_regime']??'general';
}
$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 (['portfolio', 'opportunity', 'sector', 'territory', 'park'] as $slot) {
if (($old['selection'][$slot] ?? null) !== ($selection[$slot] ?? null)) {
$changed[] = $slot;
}
}
$changedFields = array_column($replaced, 'field');
if ($complements !== ($old['complements'] ?? [])) $changed[] = 'complements';
$choicesChanged = $choices !== ($old['opportunity_choices'] ?? []);
$choiceInputsChanged = array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$choices) !== array_map(fn($c)=>Arr::except($c,['portfolio_origins']),$old['opportunity_choices']??[]);
if ($choiceInputsChanged) $changed = array_values(array_unique([...$changed, 'opportunity', 'territory']));
elseif ($choicesChanged) $changed[] = 'portfolio_origins';
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
$dependencyChanged = $choiceInputsChanged || in_array('complements', $changed, true) || $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$privateConditionsChanged = (bool) array_intersect($changedFields, PrivateInvestmentPreparation::FIELDS);
$calculationInputsChanged = $choiceInputsChanged || $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']));
}
if (in_array('complements', $changed, true)) $steps = array_values(array_unique([...$steps, 'S02', 'S08']));
$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 = ['complements' => $complements, 'opportunity_choices' => $choices, '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.'];
$plan['requirements_assessment'] = app(InvestmentContextRequirements::class)->assess(['opportunity_choices' => $choices, 'fields' => $fields]);
$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 = ['opportunity_choices' => $plan['opportunity_choices'], '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([...($plan['opportunity_choices'] !== [] ? ['opportunity_choices' => $plan['opportunity_choices']] : []), ...($plan['complements'] ? ['complements' => $plan['complements']] : []), 'selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
$context['requirements_assessment'] = $plan['requirements_assessment'];
$context['complements'] = $plan['complements'];
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;
unset($payload['investor_path']['context_drafts'][(string)$actor->id]);
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, ?string $opportunity = null): array
{
$case = $this->desk->case($actor, $id);
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$ctx = $this->state($case);
$ctx['land_preparation'] = $this->landPreparation($case, $opportunity);
$path = app(InvestorPath::class)->view($actor, $id, $opportunity);
$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(app(InvestmentConsumerScope::class)->context($ctx, $opportunity), $this->preparationDocuments($id)) : null;
$ctx['preparation_assessment'] = $preparation;
$prefill = $this->prefill($ctx, $opportunity);
$saved = $this->desk->decode($case->payload);
if (!empty($saved['reuse_consent'])) {
$contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== '');
$prefill['simulator'] = [...$contacts, ...$prefill['simulator']];
$prefill['land'] = [...$contacts, ...(!empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']];
}
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' => $prefill, '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 landPreparation(\stdClass $case, ?string $opportunity = null): array
{
$payload = $this->desk->decode($case->payload);
$ctx = $this->state($case);
$key = app(InvestmentConsumerScope::class)->key($ctx, $opportunity);
$path = $payload['investor_path'] ?? [];
$need = app(InvestmentConsumerScope::class)->facts($payload, $ctx, $key)['land_needed'];
return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $key ? ($path['opportunity_fact_reasons'][$key]['land_needed'] ?? '') : ($path['fact_reasons']['land_needed'] ?? '')];
}
public function prefill(array $ctx, ?string $opportunity = null): array
{
$original = $ctx;
$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.
// The reconciliation preserves any purpose deliberately chosen in an existing simulator draft.
$sim += ['journey_type' => 'investor', 'journey_type_label' => 'Avaliar o meu investimento', 'purpose' => 'simulate_only', 'purpose_label' => 'Apenas simular'];
if (($ctx['land_preparation']['need'] ?? null) === 'yes') {
$tenure = ['duat' => ['duat', 'DUAT'], 'lease' => ['leased', 'Arrendado']][$f['site_tenure'] ?? ''] ?? null;
if ($tenure) [$sim['land_status'], $sim['land_status_label']] = $tenure;
}
// Human labels travel with the exact existing identifiers, not inferred locations.
foreach (app(InvestmentGeography::class)->publicReferences()->whereIn('id', array_values(Arr::only($f, array_keys(InvestmentGeography::FIELDS))))->get(['id', 'name']) as $place) {
foreach (array_keys(InvestmentGeography::FIELDS) as $key) {
if (($f[$key] ?? null) === $place->id) $sim[$key.'_label'] = $place->name;
}
}
if (! empty($f['sector_id']) && ($sector = Sector::where('is_active', true)->find($f['sector_id']))) $sim['sector_id_label'] = $sector->name;
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
'consumer_opportunity' => $ctx['consumer_opportunity']['opportunity']['key'] ?? null,
'portfolio_origin' => $ctx['selection']['portfolio'] ?? null,
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
'complements' => $ctx['complements'] ?? [],
'requirements_assessment' => $ctx['requirements_assessment'] ?? null,
'path' => $ctx['path_applicability'] ?? null,
'land_preparation' => $ctx['land_preparation'] ?? null,
'field_provenance' => array_map(fn($origin) => Arr::only($origin, ['kind', 'title', 'version', 'context_revision']), $ctx['provenance'] ?? []),
'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);
$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)];
}
}