$data */ public function encode(array $data): string { return Crypt::encryptString(json_encode($data, JSON_THROW_ON_ERROR)); } /** @return array */ 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 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 $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.'); $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.'); $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; } $id = (string) Str::uuid(); $base = $this->decode($case->payload); $payload = ['title' => $case->title, 'description' => $base['investment']['objective'] ?? '', 'regime' => 'unknown']; if (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 (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); $serviceFields = json_decode($service->definition, true)['fields']; 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]) && (empty($payload[$field]) || in_array($field, ['title', 'description'], true) && $context['revision'] > 0)) { $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|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 */ 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 $payload * @param array $definition * @return array */ public function validateFields(array $payload, array $definition, bool $submitting): array { $rules = ['payload' => ['required', '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 = ! isset($field['when']) || ($payload[$field['when']['field']] ?? null) === $field['when']['equals']; $required = $submitting && ($field['required'] ?? false) && $active; $rule = [$required ? 'required' : 'nullable']; $rule = [...$rule, ...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'], default => ['string', 'max:'.($field['type'] === 'textarea' ? 15000 : 1000)], }]; $rules['payload.'.$key] = $rule; } $validated = Validator::make(['payload' => $payload], $rules)->validate()['payload']; foreach ($definition['fields'] as $key => $field) { 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) { if ($submitting && trim($validated['location_source'] ?? '') === '') { throw ValidationException::withMessages(['payload.location_source' => 'Indique a fonte da localização.']); } } return $validated; } /** @param array $payload * @return list> */ public function findings(array $payload): array { $findings = []; $amounts = array_map(function ($key) use ($payload) { $value = (string) ($payload[$key] ?? ''); 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 (! 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 (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 (! ($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 $input */ public function save(User $actor, string $id, array $input, bool $submit = false): \stdClass { $data = Validator::make($input, ['version' => 'required|integer|min:1', 'payload' => 'required|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); 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); 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); if ($submit || ($data['context_reviewed'] ?? false)) { app(InvestorContextConsumers::class)->assertReviewed($actor, $case->id, $data, app(InvestorContextConsumers::class)->requestRevision($request)); } $snapshot = ['investment_context' => ['revision' => app(InvestorContext::class)->state($case)['revision']], 'payload' => $payload, 'service_version_id' => $request->service_version_id, '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 $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'])->validate(); return DB::transaction(function () use ($actor, $id, $data) { $request = $this->request($actor, $id, false, true); $case = $this->case($actor, $request->case_id); 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(); $this->event($case, $actor, 'request_'.$data['status'], ['reason' => $data['reason'], 'editable_fields' => $editable], $request); return $request; }); } /** @param array $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', '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); abort_unless((int) $case->version === (int) $data['version'], 409); $workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR); $current = $workflow[$data['stage']]; $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'])->whereNull('d.deleted_at')->first(['d.status', 'd.malware_scan_status', 'd.sha256', 'd.malware_scanned_sha256', '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, '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 $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; 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)]); 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]); } } }