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

692 lines
48 KiB
PHP

<?php
namespace App\Services;
use App\Models\InvestmentSubmission;
use App\Models\Investor;
use App\Models\InvestorJourney;
use App\Models\SimulationFormDraft;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class VirtualDesk
{
public function installed(): bool
{
return Schema::hasTable('desk_cases') && Schema::hasColumn('desk_events', 'event_order') && Schema::hasColumn('tasks', 'desk_case_id');
}
/** @param array<string,mixed> $data */
public function encode(array $data): string
{
return Crypt::encryptString(json_encode($data, JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function decode(?string $data): array
{
return $data ? json_decode(Crypt::decryptString($data), true, 512, JSON_THROW_ON_ERROR) : [];
}
public function represents(User $actor, string $organization, bool $write = true): bool
{
return DB::table('memberships')->where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at')
->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists();
}
public function owns(User $actor, \stdClass $case, bool $write = true): bool
{
return $case->organization_id ? $this->represents($actor, $case->organization_id, $write) : (int) $case->user_id === $actor->id;
}
public function case(User $actor, string $id, bool $write = false, bool $lock = false): \stdClass
{
$case = DB::table('desk_cases')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
abort_unless($case !== null, 404);
abort_unless($this->owns($actor, $case, $write) || (! $write && ($actor->can('manage-portal') || DB::table('desk_requests')->where('case_id', $id)->where('analyst_id', $actor->id)->exists())), 403);
return $case;
}
public function assertOpen(\stdClass $case): void
{
abort_if($case->closed_at, 409, 'Dossier encerrado. Os registos guardados continuam disponíveis para consulta.');
}
/** @return array<string,int|null> */
public function caseActivity(\stdClass $case): array
{
return ['open_requests' => DB::table('desk_requests')->where('case_id', $case->id)->whereNotIn('status', ['closed', 'cancelled'])->count(),
'open_tasks' => Schema::hasColumn('tasks', 'desk_case_id') ? DB::table('tasks')->where('desk_case_id', $case->id)->whereNull('deleted_at')->whereNotIn('status', ['done', 'cancelled'])->count() : null,
'pending_payments' => Schema::hasTable('desk_payments') ? DB::table('desk_payments')->whereIn('request_id', DB::table('desk_requests')->where('case_id', $case->id)->select('id'))->whereIn('status', ['pending', 'internal_issue'])->count() : null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function changeCaseState(User $actor, string $id, array $input): array
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'state' => 'required|in:open,closed', 'reason' => 'required|string|min:3|max:5000', 'client_key' => 'required|uuid'])->validate();
$data['version'] = (int) $data['version'];
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, true, true);
abort_unless((int) $case->user_id === $actor->id, 403, 'Só o dono registado do dossier pode encerrá-lo ou reabri-lo.');
$replayed = false;
foreach (DB::table('desk_events')->where('case_id', $id)->where('action', 'case_state_changed')->get(['actor_id', 'payload']) as $event) {
$saved = $this->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) {
abort_unless((int) $event->actor_id === $actor->id && ($saved['operation'] ?? null) === $data, 409, 'Esta operação já foi registada com outros dados.');
$replayed = true;
break;
}
}
if (! $replayed) {
abort_unless((int) $case->version === $data['version'], 409, 'O dossier foi actualizado. Confira a versão actual antes de continuar.');
$closed = $data['state'] === 'closed';
abort_if(($case->closed_at !== null) === $closed, 409, 'O dossier já se encontra nesse estado. Actualize a página.');
$before = ['version' => (int) $case->version, 'closed_at' => $case->closed_at];
$activity = $this->caseActivity($case);
DB::table('desk_cases')->where('id', $id)->update(['closed_at' => $closed ? now() : null, 'version' => $case->version + 1, 'updated_at' => now()]);
$case = DB::table('desk_cases')->where('id', $id)->firstOrFail();
$this->event($case, $actor, 'case_state_changed', ['client_key' => $data['client_key'], 'operation' => $data, 'reason' => $data['reason'], 'before' => $before, 'after' => ['version' => (int) $case->version, 'closed_at' => $case->closed_at], 'activity' => $activity,
'scope' => 'owner_availability_decision_no_child_status_changes_or_official_approval']);
}
return ['id' => $id, 'version' => (int) $case->version, 'closed_at' => $case->closed_at, 'replayed' => $replayed];
});
}
public function request(User $actor, string $id, bool $ownerOnly = false, bool $lock = false): \stdClass
{
$request = DB::table('desk_requests')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first();
abort_unless($request !== null, 404);
$case = $this->case($actor, $request->case_id, $ownerOnly);
abort_unless($this->owns($actor, $case, $ownerOnly) || (! $ownerOnly && ($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id)), 403);
return $request;
}
/** @param array<string,mixed> $input */
public function createCase(User $actor, array $input): \stdClass
{
$data = Validator::make($input, ['title' => 'required|string|max:200', 'client_key' => 'required|uuid', 'reuse_consent' => 'accepted',
'organization_id' => 'nullable|uuid', 'company_id' => 'nullable|uuid', 'submission_id' => 'nullable|uuid', 'simulation_id' => 'nullable|uuid', 'journey_id' => 'nullable|uuid', 'interest_request_id' => 'nullable|integer|min:1'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::table('users')->where('id', $actor->id)->lockForUpdate()->first();
$existing = DB::table('desk_cases')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first();
$inputHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
if ($existing) {
abort_unless(($this->decode($existing->payload)['_creation_hash'] ?? '') === $inputHash, 409, 'Chave de criação usada com outro conteúdo.');
abort_unless($this->owns($actor, $existing), 403);
return $existing;
}
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403);
$interest = null;
if ($data['interest_request_id'] ?? null) {
$interest = DB::table('interest_requests')->where('id', $data['interest_request_id'])->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->firstOrFail();
$linkedInterest = DB::table('desk_cases')->where('interest_request_id', $data['interest_request_id'])->where('user_id', $actor->id)->first();
if ($linkedInterest) {
abort_unless($this->owns($actor, $linkedInterest), 403);
foreach (['organization_id', 'submission_id', 'simulation_id', 'company_id', 'journey_id'] as $anchor) {
abort_if(($data[$anchor] ?? null) && ($linkedInterest->{$anchor} ?? null) !== $data[$anchor], 409, 'Pedido existente já associado a um dossier; continue nesse contexto.');
}
return $linkedInterest;
}
}
if (($data['journey_id'] ?? null) && Schema::hasColumn('desk_cases', 'journey_id')) {
InvestorJourney::whereKey($data['journey_id'])->where('user_id', $actor->id)->firstOrFail();
$linkedJourney = DB::table('desk_cases')->where('journey_id', $data['journey_id'])->where('user_id', $actor->id)->first();
if ($linkedJourney) {
abort_unless($this->owns($actor, $linkedJourney), 403);
foreach (['organization_id', 'submission_id', 'simulation_id', 'company_id'] as $anchor) {
abort_if(($data[$anchor] ?? null) && $linkedJourney->{$anchor} !== $data[$anchor], 409, 'Este percurso já tem um dossier; associe novos elementos no contexto existente.');
}
return $linkedJourney;
}
}
$payload = ['profile' => app(InvestmentWorkspace::class)->account($actor->id)['payload'], 'name' => $actor->name, 'email' => $actor->email, '_creation_hash' => $inputHash, 'reuse_consent' => ['actor_id' => $actor->id, 'date' => now()->toIso8601String()]];
if ($data['company_id'] ?? null) {
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $actor->id)->first();
abort_unless($company !== null, 404);
$payload['company'] = $this->decode($company->payload);
$payload['company_version'] = $company->version;
}
if ($data['submission_id'] ?? null) {
$submission = InvestmentSubmission::where('id', $data['submission_id'])->where('user_id', $actor->id)->firstOrFail();
$payload['investment'] = $submission->payload;
$payload['investment_version'] = $submission->version;
}
if ($data['simulation_id'] ?? null) {
app(PartnershipSources::class)->resolve('simulation', $data['simulation_id'], $actor);
}
// One case per explicitly reused investment; retries never create parallel dossiers.
if ($data['submission_id'] ?? null) {
$linked = DB::table('desk_cases')->where('submission_id', $data['submission_id'])->where('user_id', $actor->id)->first();
if ($linked) {
abort_unless($this->owns($actor, $linked), 403);
foreach (['organization_id', 'simulation_id', 'company_id', 'journey_id'] as $anchor) {
abort_if(($data[$anchor] ?? null) && ($linked->{$anchor} ?? null) !== $data[$anchor], 409, 'Contexto já associado a outro dossier; não foi criado um dossier paralelo.');
}
return $linked;
}
}
if ($data['simulation_id'] ?? null) {
$linked = DB::table('desk_cases')->where('simulation_id', $data['simulation_id'])->where('user_id', $actor->id)->first();
if ($linked) {
abort_unless($this->owns($actor, $linked), 403);
foreach (['organization_id', 'submission_id', 'company_id', 'journey_id'] as $anchor) {
abort_if(($data[$anchor] ?? null) && ($linked->{$anchor} ?? null) !== $data[$anchor], 409, 'Contexto já associado a outro dossier; não foi criado um dossier paralelo.');
}
return $linked;
}
}
$identity = [];
if (Schema::hasColumn('desk_cases', 'investor_id')) {
$journey = ($data['journey_id'] ?? null) ? InvestorJourney::whereKey($data['journey_id'])->where('user_id', $actor->id)->firstOrFail() : null;
$account = DB::table('investor_accounts')->where('user_id', $actor->id)->first();
if (! $account) {
$investor = ($interest->investor_id ?? null) ? Investor::whereKey($interest->investor_id)->firstOrFail() : Investor::create(['legal_name' => $actor->name, 'investor_type' => 'individual', 'status' => 'prospect', 'source_system' => 'portal-account', 'data_classification' => 'confidential']);
DB::table('investor_accounts')->insert(['user_id' => $actor->id, 'investor_id' => $investor->id, 'created_at' => now(), 'updated_at' => now()]);
$account = DB::table('investor_accounts')->where('user_id', $actor->id)->first();
}
$dossierId = $journey->dossier_id ?? ($submission->dossier_id ?? null);
$projectId = $dossierId ? DB::table('investment_dossiers')->where('id', $dossierId)->whereNull('deleted_at')->value('project_id') : null;
$identity = ['investor_id' => $account->investor_id, 'journey_id' => $journey?->id, 'project_id' => $projectId, 'interest_request_id' => $data['interest_request_id'] ?? null];
}
$id = (string) Str::uuid();
DB::table('desk_cases')->insert(['id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null, 'company_id' => $data['company_id'] ?? null,
'submission_id' => $data['submission_id'] ?? null, 'simulation_id' => $data['simulation_id'] ?? null, 'title' => $data['title'], 'client_key' => $data['client_key'],
'payload' => $this->encode($payload), 'workflow' => json_encode(array_map(fn ($s) => [...$s, 'status' => 'not_started', 'evidence' => null], config('virtual-desk.steps'))), 'created_at' => now(), 'updated_at' => now(), ...$identity]);
$case = DB::table('desk_cases')->where('id', $id)->firstOrFail();
$this->event($case, $actor, 'case_created', ['reuse_consent' => true]);
return $case;
});
}
public function createRequest(User $actor, string $caseId, string $serviceId, string $clientKey, ?string $opportunity = null): \stdClass
{
abort_unless(Str::isUuid($serviceId) && Str::isUuid($clientKey), 422);
return DB::transaction(function () use ($actor, $caseId, $serviceId, $clientKey, $opportunity) {
$case = $this->case($actor, $caseId, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
$existing = DB::table('desk_requests')->where('case_id', $caseId)->where('client_key', $clientKey)->first();
if ($existing) {
abort_unless($existing->service_version_id === $serviceId, 409);
return $existing;
}
// Serialize publication and creation against the same edition selection.
DB::select('select pg_advisory_xact_lock(261008, 2)');
$service = DB::table('desk_service_versions')->where('id', $serviceId)->where('status', 'published')->first();
abort_unless($service !== null, 422, 'Versão do serviço indisponível para novos pedidos.');
$current = app(DeskServiceAvailability::class)->current()->where('code', $service->code)->first();
if (! $current || $current->id !== $serviceId) {
throw ValidationException::withMessages(['service_version_id' => 'A versão escolhida não está actualmente disponível para novos pedidos. Actualize a lista e confira o formulário aplicável; os pedidos anteriores mantêm a sua versão.']);
}
$id = (string) Str::uuid();
$base = $this->decode($case->payload);
$serviceFields = json_decode($service->definition, true)['fields'];
$payload = Arr::only(['title' => $case->title, 'description' => $base['investment']['objective'] ?? ''], array_keys($serviceFields));
foreach (array_keys($payload) as $key) {
if ($serviceFields[$key]['type'] === 'consent') {
unset($payload[$key]);
}
}
if (isset($serviceFields['regime']['options']['unknown'])) {
$payload['regime'] = 'unknown';
}
if (isset($serviceFields['description']) && $serviceFields['description']['type'] !== 'consent' && config('investor-path.enabled') && $case->simulation_id && (int) $case->user_id === $actor->id) {
$draftRequest = Request::create('/');
$draftRequest->setUserResolver(fn () => $actor);
$draft = app(SimulationFormDrafts::class)->find($case->simulation_id, $draftRequest);
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$payload['description'] = $economic['project_summary'] ?? $payload['description'];
$this->event($case, $actor, 'request_prefill_prepared', ['draft_id' => $draft->id, 'draft_version' => $draft->version_no, 'snapshot_sha256' => $draft->snapshot_sha256, 'fields' => ['title', 'description']]);
}
$context = app(InvestorContext::class)->state($case);
if (isset($serviceFields['description']) && count($context['opportunity_choices'] ?? []) > 1 && ! $opportunity) {
$payload['description'] = '';
}
$mapped = app(InvestorContext::class)->prefill($context, $opportunity)['desk'];
if ($opportunity) {
$payload['context_opportunity'] = $opportunity;
}
$payload = app(InvestmentConsumerScope::class)->bind($actor, $caseId, $payload);
foreach (['territory_id' => 'territory_id', 'location_source' => 'location_source', 'title' => 'title', 'description' => 'description', 'sector' => 'sector', 'province_id' => 'province_id', 'district_id' => 'district_id', 'admin_post_id' => 'admin_post_id', 'locality_id' => 'locality_id'] as $field => $source) {
if (isset($serviceFields[$field],$mapped[$source]) && $serviceFields[$field]['type'] !== 'consent' && (empty($payload[$field]) || in_array($field, ['title', 'description'], true) && $context['revision'] > 0)
&& Validator::make(['value' => $mapped[$source]], ['value' => $this->fieldRules($serviceFields[$field], false)])->passes()) {
$payload[$field] = $mapped[$source];
}
}
DB::table('desk_requests')->insert(['id' => $id, 'case_id' => $caseId, 'service_version_id' => $serviceId, 'client_key' => $clientKey, 'payload' => $this->encode($payload), 'created_at' => now(), 'updated_at' => now()]);
$request = DB::table('desk_requests')->where('id', $id)->firstOrFail();
$this->event($case, $actor, 'draft_created', ['investment_context_revision' => app(InvestorContext::class)->state($case)['revision']], $request);
return $request;
});
}
/** @return array<string,mixed>|null */
public function requestToolPrefill(User $actor, \stdClass $request, bool $lock = false): ?array
{
$case = $this->case($actor, $request->case_id, false, $lock);
$payload = $this->decode($request->payload);
if (! config('investor-path.enabled') || (int) $case->user_id !== $actor->id || ! $case->simulation_id || $case->closed_at
|| ! in_array($request->status, ['draft', 'returned'], true) || trim((string) ($payload['description'] ?? '')) !== ''
|| ($request->status === 'returned' && ! in_array('description', json_decode($request->editable_fields ?? '[]', true), true))
|| ! isset($this->definition($request)['fields']['description'])) {
return null;
}
$draftRequest = Request::create('/');
$draftRequest->setUserResolver(fn () => $actor);
$draft = app(SimulationFormDrafts::class)->find($case->simulation_id, $draftRequest);
if ($lock) {
$draft = SimulationFormDraft::query()->whereKey($draft->id)->lockForUpdate()->firstOrFail();
}
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$contextChoices = app(InvestorContext::class)->state($case)['opportunity_choices'] ?? [];
if (count($contextChoices) > 1) {
$snapshot = json_decode($economic['context_applicability_snapshot'] ?? '{}', true);
$draftChoices = $snapshot['opportunity_choices'] ?? [];
if (count($draftChoices) !== 1 || ($draftChoices[0]['opportunity']['key'] ?? null) !== ($payload['context_opportunity'] ?? null)) {
return null;
}
}
$description = (string) ($economic['project_summary'] ?? '');
if (trim($description) === '') {
return null;
}
return ['description' => $description, 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
'snapshot_sha256' => $draft->snapshot_sha256];
}
/** @return array<string,mixed> */
public function definition(\stdClass $request): array
{
return json_decode(DB::table('desk_service_versions')->where('id', $request->service_version_id)->value('definition'), true, 512, JSON_THROW_ON_ERROR);
}
/** @param array<string,mixed> $payload
* @param array<string,mixed> $definition
* @return array<string,mixed> */
public function validateFields(array $payload, array $definition, bool $submitting): array
{
return $this->validatedPayload($payload, $definition, $submitting, false);
}
/** Incomplete text/number formats can be recovered, but never submitted through this path.
* @param array<string,mixed> $payload
* @param array<string,mixed> $definition
* @return array<string,mixed> */
public function checkpointFields(array $payload, array $definition): array
{
return $this->validatedPayload($payload, $definition, false, true);
}
/** @param array<string,mixed> $payload
* @param array<string,mixed> $definition
* @return array<string,mixed> */
private function validatedPayload(array $payload, array $definition, bool $submitting, bool $checkpoint): array
{
$messages = [];
$rules = ['payload' => ['present', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse', 'context_opportunity', 'context_opportunity_snapshot'])],
'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
foreach ($definition['fields'] as $key => $field) {
$active = $this->fieldApplies((string) $key, $definition['fields'], $payload);
$required = $submitting && ($field['required'] ?? false) && $active;
$rules['payload.'.$key] = $this->fieldRules($field, $required, $checkpoint);
if ($field['type'] === 'multiselect') {
$rules['payload.'.$key.'.*'] = ['required', 'string', 'distinct:strict', Rule::in(array_map('strval', array_keys($field['options'])))];
}
if ($field['type'] === 'attachment') {
$rules['payload.'.$key.'.*'] = ['required', 'string', 'uuid', 'distinct:strict'];
$messages['payload.'.$key.'.required'] = 'Seleccione pelo menos um documento em «'.$field['label'].'».';
$messages['payload.'.$key.'.max'] = 'Seleccione no máximo '.$field['max_files'].' ficheiro(s) em «'.$field['label'].'».';
foreach (['array', 'list'] as $rule) {
$messages['payload.'.$key.'.'.$rule] = 'Seleccione os documentos da lista em «'.$field['label'].'».';
}
foreach (['uuid', 'string', 'distinct'] as $rule) {
$messages['payload.'.$key.'.*.'.$rule] = 'Seleccione cada documento autorizado apenas uma vez em «'.$field['label'].'».';
}
}
}
$validated = Validator::make(['payload' => $payload], $rules, $messages)->validate()['payload'];
foreach ($definition['fields'] as $key => $field) {
if ($field['type'] === 'select' && isset($validated[$key])) {
$validated[$key] = (string) $validated[$key];
}
if (in_array($field['type'], ['multiselect', 'attachment'], true) && is_array($validated[$key] ?? null)) {
sort($validated[$key], SORT_STRING);
}
if ($field['type'] === 'territory' && ! empty($validated[$key])) {
if (! app(InvestmentGeography::class)->publicReferences()->where('id', $validated[$key])->exists()) {
throw ValidationException::withMessages(['payload.'.$key => 'Seleccione uma localização disponível no cadastro territorial.']);
}
}
}
if (($validated['territory_id'] ?? null) && $this->fieldApplies('territory_id', $definition['fields'], $validated)) {
if ($submitting && trim($validated['location_source'] ?? '') === '') {
throw ValidationException::withMessages(['payload.location_source' => 'Indique a fonte da localização.']);
}
}
return $validated;
}
/** @param array<string,mixed> $field
* @return list<mixed> */
private function fieldRules(array $field, bool $required, bool $checkpoint = false): array
{
if ($checkpoint && in_array($field['type'], ['number', 'date', 'email', 'url'], true)) {
return ['nullable', function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
$fail('Conserve apenas texto ou um número neste campo do rascunho.');
}
}, 'max:1000'];
}
return [$required ? 'required' : 'nullable', ...match ($field['type']) {
'number' => ['numeric', 'decimal:0,2', 'min:'.($field['min'] ?? 0), 'max:999999999999'], 'date' => ['date_format:Y-m-d'],
'select' => [Rule::in(array_keys($field['options']))], 'territory' => ['uuid'],
'multiselect' => ['array', 'list', 'max:100'], 'consent' => ['string', Rule::in(['accepted', 'declined'])],
'attachment' => ['array', 'list', 'max:'.$field['max_files']],
'email' => ['string', 'email', 'max:1000'], 'url' => ['string', 'url:http,https', 'max:1000'],
default => ['string', 'max:'.($field['type'] === 'textarea' ? 15000 : 1000)],
}];
}
/** @param array<string,array<string,mixed>> $fields
* @param array<string,mixed> $payload
* @param array<string,bool> $ancestors */
private function fieldApplies(string $key, array $fields, array $payload, array $ancestors = []): bool
{
$field = $fields[$key] ?? null;
if (! $field || isset($ancestors[$key])) {
return false;
}
if (! isset($field['when'])) {
return true;
}
$parent = $field['when']['field'];
$answer = $payload[$parent] ?? null;
$matches = ($field['when']['operator'] ?? 'equals') === 'contains'
? is_array($answer) && in_array($field['when']['equals'], $answer, true)
: is_scalar($answer) && (string) $answer === $field['when']['equals'];
if (! $matches) {
return false;
}
$ancestors[$key] = true;
return $this->fieldApplies($parent, $fields, $payload, $ancestors);
}
/** @param array<string,mixed> $payload
* @param array<string,mixed> $definition
* @return array<string,array<string,mixed>> */
public function applicableFields(array $payload, array $definition): array
{
return array_filter($definition['fields'], fn ($key) => $this->fieldApplies((string) $key, $definition['fields'], $payload), ARRAY_FILTER_USE_KEY);
}
/** @param array<string,mixed> $payload
* @param array<string,mixed> $definition
* @return list<array<string,mixed>> */
public function findings(array $payload, array $definition): array
{
$findings = [];
$fields = $this->applicableFields($payload, $definition);
$financeApplies = count(array_filter(['investment_amount', 'own_finance', 'other_finance'], fn ($key) => ($fields[$key]['type'] ?? null) === 'number')) === 3;
$amounts = array_map(function ($key) use ($payload) {
$raw = $payload[$key] ?? null;
if (! is_string($raw) && ! is_int($raw) && ! is_float($raw)) {
return null;
}
$value = (string) $raw;
if (! preg_match('/^\d{1,12}(?:\.\d{1,2})?$/', $value)) {
return null;
}
[$whole, $fraction] = array_pad(explode('.', $value), 2, '');
return (int) $whole * 100 + (int) str_pad($fraction, 2, '0');
}, ['investment_amount', 'own_finance', 'other_finance']);
if ($financeApplies && ! in_array(null, $amounts, true) && $amounts[0] !== $amounts[1] + $amounts[2]) {
$findings[] = ['code' => 'finance_difference', 'severity' => 'error', 'field' => 'investment_amount', 'explanation' => 'A soma do financiamento difere do valor do investimento.', 'recommendation' => 'Confirme valores e moeda.', 'method' => 'deterministic', 'confidence' => null];
}
if ($financeApplies && in_array(null, $amounts, true)) {
$findings[] = ['code' => 'finance_unknown', 'severity' => 'warning', 'field' => 'investment_amount', 'explanation' => 'A coerência do financiamento permanece indeterminada sem os três valores válidos na mesma moeda.', 'recommendation' => 'Complete as fontes de financiamento; valores ausentes não são zero.', 'method' => 'deterministic', 'confidence' => null];
}
if (isset($fields['territory_id']) && ! ($payload['territory_id'] ?? null)) {
$findings[] = ['code' => 'location_unknown', 'severity' => 'warning', 'field' => 'territory_id', 'explanation' => 'Localização ainda não documentada.', 'recommendation' => 'Complete quando houver fonte.', 'method' => 'deterministic', 'confidence' => null];
}
$findings[] = ['code' => 'official_form_pending', 'severity' => 'warning', 'field' => null, 'explanation' => 'Adaptação digital do plano; não foi identificada uma versão oficial homologada do formulário APIEX no acervo.', 'recommendation' => 'A preparação e análise interna continuam disponíveis; confirme a versão exigida pela entidade para o ato oficial.', 'method' => 'documentary', 'confidence' => null];
return $findings;
}
/** @param array<string,mixed> $input */
public function save(User $actor, string $id, array $input, bool $submit = false): \stdClass
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'payload' => 'present|array', 'consent' => $submit ? 'accepted' : 'nullable|boolean', 'reason' => 'nullable|string|max:3000', 'context_revision' => 'nullable|integer|min:0', 'context_reviewed' => 'nullable|boolean',
'tool_prefill' => 'nullable|array:draft_id,draft_version,snapshot_sha256', 'tool_prefill.draft_id' => 'required_with:tool_prefill|uuid',
'tool_prefill.draft_version' => 'required_with:tool_prefill|integer|min:1', 'tool_prefill.snapshot_sha256' => 'required_with:tool_prefill|string|size:64'])->validate();
return DB::transaction(function () use ($actor, $id, $data, $submit) {
$request = $this->request($actor, $id, true, true);
if ($submit && $request->status === 'submitted' && $request->version === (int) $data['version'] + 1 && hash_equals(hash('sha256', json_encode(Arr::sortRecursive($this->decode($request->payload)), JSON_THROW_ON_ERROR)), hash('sha256', json_encode(Arr::sortRecursive($this->validateFields($data['payload'], $this->definition($request), false)), JSON_THROW_ON_ERROR)))) {
return $request;
}
abort_unless(in_array($request->status, ['draft', 'returned'], true) && $request->version === (int) $data['version'], 409, 'Pedido alterado ou já submetido.');
$prefill = null;
if (! empty($data['tool_prefill'])) {
$prefill = $this->requestToolPrefill($actor, $request, true);
$source = $data['tool_prefill'];
if ($submit || ! $prefill || $source['draft_id'] !== $prefill['draft_id'] || (int) $source['draft_version'] !== $prefill['draft_version']
|| ! hash_equals($prefill['snapshot_sha256'], $source['snapshot_sha256'])
|| ($data['payload']['description'] ?? null) !== $prefill['description']) {
throw ValidationException::withMessages(['payload.description' => 'Recupere a descrição atual deste dossier. A reutilização só preenche um campo vazio num rascunho e não submete o pedido.']);
}
}
$payload = $this->validateFields($data['payload'], $this->definition($request), $submit);
$fieldAttachments = app(DeskAttachments::class)->snapshot($request, $payload, $this->definition($request), $submit);
if ($request->status !== 'returned' || in_array('context_opportunity', json_decode($request->editable_fields ?? '[]', true), true)) {
$payload = app(InvestmentConsumerScope::class)->bind($actor, $request->case_id, $payload, $submit);
}
if ($request->status === 'returned') {
$editable = json_decode($request->editable_fields ?? '[]', true);
$previous = $this->decode($request->payload);
foreach (array_unique([...array_keys($previous), ...array_keys($payload)]) as $key) {
if (! in_array($key, $editable, true) && ($previous[$key] ?? null) !== ($payload[$key] ?? null)) {
throw ValidationException::withMessages(['payload.'.$key => 'Campo não autorizado para esta correção.']);
}
}
abort_if($submit && trim($data['reason'] ?? '') === '', 422, 'Justifique a correção.');
}
$findings = $this->findings($payload, $this->definition($request));
if ($submit && array_filter($findings, fn ($f) => $f['severity'] === 'error')) {
throw ValidationException::withMessages(['payload.investment_amount' => $findings[0]['explanation']]);
}
$case = $this->case($actor, $request->case_id, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado. As respostas guardadas continuam disponíveis para consulta.');
if ($submit || ($data['context_reviewed'] ?? false)) {
app(InvestorContextConsumers::class)->assertReviewed($actor, $case->id, $data, app(InvestorContextConsumers::class)->requestRevision($request));
}
$declarations = [];
if ($submit) {
foreach ($this->definition($request)['fields'] as $key => $field) {
if ($field['type'] === 'consent' && $this->fieldApplies($key, $this->definition($request)['fields'], $payload) && in_array($payload[$key] ?? null, ['accepted', 'declined'], true)) {
$declarations[$key] = ['label' => $field['label'], 'statement' => $field['statement'], 'statement_sha256' => hash('sha256', $field['statement']), 'answer' => $payload[$key], 'actor_id' => $actor->id, 'declared_at' => now()->toIso8601String(), 'service_version_id' => $request->service_version_id];
}
}
}
$snapshot = ['investment_context' => ['revision' => app(InvestorContext::class)->state($case)['revision']], 'payload' => $payload, 'service_version_id' => $request->service_version_id, 'field_declarations' => $declarations, 'field_attachments' => $fieldAttachments, 'documents' => DB::table('desk_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.case_id', $case->id)->where(fn ($q) => $q->whereNull('link.request_id')->orWhere('link.request_id', $id))->get(['link.document_id', 'd.sha256', 'd.version_no', 'link.source_reference'])->all(), 'findings' => $findings, 'consent' => $submit, 'source' => config('virtual-desk.source')];
DB::table('desk_revisions')->insert(['request_id' => $id, 'version' => $request->version, 'snapshot' => $this->encode(['payload' => $this->decode($request->payload), 'status' => $request->status, 'submitted_snapshot' => $request->submitted_snapshot ? $this->decode($request->submitted_snapshot) : null])]);
$updates = ['payload' => $this->encode($payload), 'version' => $request->version + 1, 'updated_at' => now()];
if ($submit) {
$updates = [...$updates, 'status' => 'submitted', 'protocol' => $request->protocol ?? 'BV-'.date('Y').'-'.strtoupper(str_replace('-', '', $id)), 'submitted_at' => now(), 'submitted_snapshot' => $this->encode($snapshot)];
}
DB::table('desk_requests')->where('id', $id)->update($updates);
$request = DB::table('desk_requests')->where('id', $id)->firstOrFail();
$this->event($case, $actor, $submit ? 'submitted' : 'draft_saved', ['reason' => $data['reason'] ?? '', 'findings' => $findings], $request);
if (($data['context_reviewed'] ?? false) && ! $submit) {
$this->event($case, $actor, 'request_context_reviewed', ['investment_context_revision' => app(InvestorContext::class)->state($case)['revision']], $request);
}
if ($prefill) {
$this->event($case, $actor, 'request_prefill_reused', ['draft_id' => $prefill['draft_id'], 'draft_version' => $prefill['draft_version'],
'snapshot_sha256' => $prefill['snapshot_sha256'], 'fields' => ['description'], 'scope' => 'draft_preparation'], $request);
}
return $request;
});
}
/** @param array<string,mixed> $data */
public function transition(User $actor, string $id, array $data): \stdClass
{
$data = Validator::make($data, ['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(array_keys(config('virtual-desk.request_states')))], 'reason' => 'required|string|min:3|max:5000', 'editable_fields' => 'nullable|array', 'editable_fields.*' => 'string|max:100',
'return_reason_code' => ['exclude_unless:status,returned', 'nullable', Rule::in(array_keys(config('product-metrics.return_reasons')))]])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$request = $this->request($actor, $id, false, true);
$case = $this->case($actor, $request->case_id, false, true);
$this->assertOpen($case);
abort_unless($request->version === (int) $data['version'], 409);
$cancel = $data['status'] === 'cancelled';
if ($cancel) {
abort_unless($this->owns($actor, $case) && in_array($request->status, ['draft', 'returned'], true), 403);
} else {
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) {
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor, $request), 403);
}
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
}
$editable = $data['editable_fields'] ?? [];
abort_if($data['status'] === 'returned' && ! $editable, 422, 'Indique os campos autorizados para correção.');
abort_if(array_diff($editable, [...array_keys($this->definition($request)['fields']), 'country', 'declared_origin_country_code']) !== [], 422);
DB::table('desk_requests')->where('id', $id)->update(['status' => $data['status'], 'version' => $request->version + 1, 'editable_fields' => json_encode($editable), 'closed_at' => in_array($data['status'], ['closed', 'cancelled'], true) ? now() : null, 'updated_at' => now()]);
$request = DB::table('desk_requests')->where('id', $id)->firstOrFail();
$classification = [];
if ($data['status'] === 'returned') {
$code = $data['return_reason_code'] ?? 'unspecified';
$classification = ['return_reason_code' => $code, 'return_reason_label' => config('product-metrics.return_reasons.'.$code), 'return_reason_definition' => config('product-metrics.version')];
}
$this->event($case, $actor, 'request_'.$data['status'], ['reason' => $data['reason'], 'editable_fields' => $editable, ...$classification], $request);
return $request;
});
}
/** @param array<string,mixed> $data */
public function stage(User $actor, string $id, array $data): \stdClass
{
$data = Validator::make($data, ['version' => 'required|integer|min:1', 'stage' => ['required', Rule::in(array_keys(config('virtual-desk.steps')))],
'status' => ['required', Rule::in(array_keys(config('virtual-desk.step_states')))], 'reason' => 'required|string|min:3|max:3000',
'evidence' => 'nullable|string|max:5000', 'document_id' => 'nullable|uuid', 'document_link_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'exception' => 'nullable|boolean'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, false, true);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id), 403);
$this->assertOpen($case);
abort_unless((int) $case->version === (int) $data['version'], 409);
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR);
$current = $workflow[$data['stage']];
if ($data['document_link_id'] ?? null) {
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $data['document_link_id'])->first();
abort_unless($link && (! ($data['document_id'] ?? null) || $data['document_id'] === $link->document_id), 422, 'Seleccione um suporte associado a este dossier.');
$data['document_id'] = $link->document_id;
}
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
if ($decision) {
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
foreach ($current['dependencies'] as $dependency) {
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
}
if ($data['stage'] !== 'E0') {
abort_unless($data['document_id'] ?? null, 422, 'Ligue o documento de suporte ou de não aplicabilidade.');
}
}
if ($data['document_id'] ?? null) {
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.id', $data['document_id'])
->when($data['document_link_id'] ?? null, fn ($q, $linkId) => $q->where('l.id', $linkId))->whereNull('d.deleted_at')->orderBy('l.created_at')->orderBy('l.id')
->first(['d.status', 'd.malware_scan_status', 'd.sha256', 'd.malware_scanned_sha256', 'l.id as link_id', 'l.source_reference', 'l.stage', 'l.valid_until']);
abort_unless($document !== null, 422);
if ($decision) {
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean' && trim($document->sha256) === $document->malware_scanned_sha256 && (! $document->valid_until || $document->valid_until >= now()->toDateString()), 422, 'Documento ainda não consultável ou expirado.');
}
}
if (in_array($data['status'], ['referred', 'waiting'], true)) {
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Registe referência e canal do encaminhamento.');
}
// Reopening an upstream step must never leave dependent conclusions falsely valid.
if (! in_array($data['status'], ['completed', 'not_applicable'], true)) {
$affected = [$data['stage']];
do {
$changed = false;
foreach ($workflow as $code => &$step) {
if (! in_array($code, $affected, true) && array_intersect($step['dependencies'], $affected)) {
$affected[] = $code;
$changed = true;
if (in_array($step['status'], ['completed', 'not_applicable'], true)) {
$step['status'] = 'pending';
}
}
} unset($step);
} while ($changed);
}
$workflow[$data['stage']] = [...$current, 'status' => $data['status'], 'evidence' => $data['evidence'] ?? null, 'document_id' => $data['document_id'] ?? null,
'document_link_id' => $document->link_id ?? null, 'document_source' => isset($document) ? ['source_reference' => $document->source_reference, 'stage' => $document->stage, 'valid_until' => $document->valid_until] : null,
'valid_until' => $data['valid_until'] ?? null, 'reason' => $data['reason'], 'reviewed_by' => $actor->id, 'updated_at' => now()->toIso8601String()];
DB::table('desk_cases')->where('id', $id)->update(['workflow' => json_encode($workflow), 'version' => $case->version + 1, 'updated_at' => now()]);
$case = DB::table('desk_cases')->where('id', $id)->firstOrFail();
$this->event($case, $actor, 'stage_updated', $data);
return $case;
});
}
/** @param array<string,mixed> $payload */
public function event(\stdClass $case, User $actor, string $action, array $payload, ?\stdClass $request = null): void
{
$id = (string) Str::uuid();
$version = $request->version ?? $case->version;
// Use the server event time, not PostgreSQL now() (which is fixed at the enclosing transaction start).
DB::table('desk_events')->insert(['id' => $id, 'case_id' => $case->id, 'request_id' => $request->id ?? null, 'actor_id' => $actor->id, 'action' => $action, 'version' => $version, 'payload' => $this->encode($payload), 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $request ? 'desk_requests' : 'desk_cases', 'entity_id' => $request->id ?? $case->id, 'actor_user_id' => $actor->id, 'action' => $action,
'after_data' => json_encode(['event_id' => $id, 'event_version' => 1, 'version' => $version, 'case_id' => $case->id, 'source_hash' => config('virtual-desk.source.sha256')])]);
$recipients = [(int) $case->user_id];
if ($case->organization_id) {
$recipients = [...DB::table('memberships')->where('organization_id', $case->organization_id)->whereNull('deleted_at')->pluck('user_id')->all()];
}
if ($request?->analyst_id) {
$recipients[] = (int) $request->analyst_id;
}
foreach (array_unique($recipients) as $user) {
DB::table('desk_notifications')->insert(['event_id' => $id, 'user_id' => $user, 'title' => config('virtual-desk.request_states.'.$request?->status, 'Dossier atualizado'), 'href' => $request ? '/balcao-virtual/pedidos/'.$request->id : '/balcao-virtual/dossiers/'.$case->id]);
}
}
}