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

399 lines
28 KiB
PHP

<?php
namespace App\Services;
use App\Models\CrmTask;
use App\Models\LandAssistanceCase;
use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class InvestorPath
{
public function __construct(private readonly VirtualDesk $desk) {}
public function available(): bool
{
return (bool) config('investor-path.enabled') && $this->desk->installed()
&& Schema::hasColumn('investor_journeys', 'path_definition_id') && Schema::hasColumn('journey_steps', 'path_detail');
}
/** @param array<string,mixed> $input */
public function create(User $actor, array $input): string
{
abort_unless($this->available(), 404);
return DB::transaction(function () use ($actor, $input): string {
$case = $this->desk->createCase($actor, $input);
$this->initialize($actor, $case->id);
return $case->id;
});
}
public function initialize(User $actor, string $id): void
{
abort_unless($this->available(), 404);
DB::transaction(function () use ($actor, $id): void {
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409);
if ($case->journey_id) {
$existing = DB::table('investor_journeys')->where('id', $case->journey_id)->firstOrFail();
// Never convert or renumber a saved portfolio/legacy journey implicitly.
abort_unless($existing->path_definition_id !== null, 409, 'O percurso existente foi preservado; a conversão exige uma correspondência explícita.');
return;
}
$version = DB::table('questionnaire_versions')->where('id', config('investor-path.definition_id'))
->where('code', 'investor-path-19')->where('status', 'published')->firstOrFail();
$definition = json_decode($version->schema_snapshot, true, 512, JSON_THROW_ON_ERROR);
InvestorPathDefinition::validate($definition);
$journey = (string) Str::uuid();
DB::table('investor_journeys')->insert(['id' => $journey, 'investor_id' => $case->investor_id, 'user_id' => $actor->id,
'status' => 'started', 'path_definition_id' => $version->id, 'source_system' => 'investor-path', 'data_classification' => 'confidential']);
foreach ($definition['steps'] as $step) {
DB::table('journey_steps')->insert(['id' => (string) Str::uuid(), 'journey_id' => $journey, 'step_code' => $step['code'],
'sequence_no' => $step['sequence'], 'status' => 'pending', 'source_system' => 'investor-path', 'data_classification' => 'confidential',
'path_detail' => $this->desk->encode(['state' => 'not_started'])]);
}
DB::table('desk_cases')->where('id', $id)->update(['journey_id' => $journey, 'version' => $case->version + 1, 'updated_at' => now()]);
$this->desk->event($this->desk->case($actor, $id), $actor, 'investor_path_started', ['journey_id' => $journey, 'definition_id' => $version->id, 'source_sha256' => $definition['source_sha256']]);
});
}
/** @return array<string,mixed> */
public function view(User $actor, string $id, ?string $opportunity = null): array
{
abort_unless($this->available(), 404);
$case = $this->desk->case($actor, $id);
$journey = DB::table('investor_journeys')->where('id', $case->journey_id)->whereNull('deleted_at')->firstOrFail();
abort_unless($journey->path_definition_id !== null, 409, 'Este dossier conserva o seu roteiro original.');
$version = DB::table('questionnaire_versions')->where('id', $journey->path_definition_id)->firstOrFail();
$definition = json_decode($version->schema_snapshot, true, 512, JSON_THROW_ON_ERROR);
$payload = $this->desk->decode($case->payload);
$path = $payload['investor_path'] ?? [];
$context = app(InvestorContext::class)->state($case);
$scope = app(InvestmentConsumerScope::class);
$opportunity = $scope->key($context, $opportunity);
$allChoices = $context['opportunity_choices'] ?? [];
$facts = $scope->facts($payload, $context, $opportunity);
$factReasons = $opportunity ? ($path['opportunity_fact_reasons'][$opportunity] ?? []) : ($path['fact_reasons'] ?? []);
$context = $scope->context($context, $opportunity);
$records = DB::table('journey_steps')->where('journey_id', $journey->id)->whereNull('deleted_at')->get()->keyBy('step_code');
$steps = [];
foreach ($definition['steps'] as $step) {
$row = $records->get($step['code']);
abort_unless($row !== null, 409, 'Etapa em falta; reparar a instalação sem reconstruir o histórico.');
$detail = $this->desk->decode($row->path_detail);
if ($opportunity && (count($allChoices) > 1 || isset($detail['opportunities'][$opportunity]))) {
$detail = $detail['opportunities'][$opportunity] ?? ['state' => 'not_started'];
}
$value = $step['branch'] ? ($facts[$step['branch']] ?? 'unknown') : 'yes';
$applicable = $value === 'unknown' ? 'undetermined' : ($value === 'no' ? 'not_needed_for_preparation' : 'applicable');
$state = $value === 'no' ? 'dispensed' : $detail['state'];
$steps[] = [...$step, 'id' => $row->id, 'version' => $row->version_no, 'state' => $state, 'saved_state' => $detail['state'],
'applicability' => $applicable, 'reason' => $value === 'no' ? ($factReasons[$step['branch']] ?? '') : ($detail['reason'] ?? ''),
'detail' => $detail, 'preparation_allowed' => $value !== 'no',
'action_gaps' => []];
}
foreach ($steps as &$contextStep) {
$contextStep['context_review_required'] = isset($context['review_steps'][$contextStep['code']]) && ($contextStep['detail']['context_dependency_hash'] ?? null) !== ($context['dependency_hash'] ?? null);
if ($contextStep['context_review_required'] && in_array($contextStep['state'], ['completed', 'approved', 'ready_to_submit'], true)) {
$contextStep['state'] = 'correction_required';
$contextStep['reason'] = 'O contexto mudou. O ato/avanço anterior foi preservado; reveja o fundamento aplicável ao novo contexto.';
}
}
unset($contextStep);
$steps = app(FintechInvestment::class)->adjustSteps($payload, $steps);
$byCode = collect($steps)->keyBy('code');
foreach ($steps as &$step) {
foreach ($step['dependencies'] as $action => $dependencies) {
$step['action_gaps'][$action] = array_values(array_filter($dependencies, function ($code) use ($byCode) {
$dependency = $byCode->get($code);
// Branch dispensation is not evidence of any legal permission.
return ! in_array($dependency['state'], ['completed', 'approved'], true);
}));
}
}
unset($step);
foreach ($steps as &$step) {
if (in_array($step['state'], ['completed', 'submitted'], true)
&& count(array_filter($step['action_gaps'])) > 0) {
$step['state'] = 'correction_required';
$step['reason'] = 'Uma dependência desta acção foi reaberta; reveja o avanço. O registo original foi preservado.';
}
}
unset($step);
$owner = $this->desk->owns($actor, $case);
$manager = $actor->can('manage-portal');
$requests = DB::table('desk_requests')->where('case_id', $id)
->when(! $owner && ! $manager, fn ($q) => $q->where('analyst_id', $actor->id))
->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'version', 'submitted_snapshot'])->map(function ($row) use ($context) {
$revision = app(InvestorContextConsumers::class)->requestRevision($row);
return ['id' => $row->id, 'status' => $row->status, 'protocol' => $row->protocol, 'version' => $row->version, 'context_revision' => $revision,
'context_state' => $context['revision'] !== $revision ? ($row->submitted_snapshot ? 'historical_submission' : 'review_required') : 'current'];
});
$documents = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $id)->whereNull('d.deleted_at')
->when(! $owner && ! $manager, fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', $requests->pluck('id'))))
->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256']);
// Only the private draft owner can receive the financial snapshot/result. Dossier access alone is insufficient.
$results = (int) $case->user_id === $actor->id ? ($path['results'] ?? []) : [];
$next = collect($steps)->first(fn ($step) => $step['preparation_allowed'] && ! in_array($step['state'], ['completed', 'approved', 'waiting_external', 'submitted', 'reviewing'], true));
$landCase = (int) $case->user_id === $actor->id
? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get()
->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id
&& (! $allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null;
$preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null;
if ($preparation && $preparation['enabled']) {
$next = ['code' => 'S06', 'title' => $preparation['next'], 'action' => 'Preparar', 'href' => '/investimentos/'.$id.'/contexto/editar#preparation-questions'];
}
return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect(StructuredData::collectionItems($path['sector_contexts'] ?? []))->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect(StructuredData::collectionItems($path['content_sources'] ?? []))->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null,
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'content_sources' => ($owner || $manager) ? ($path['content_sources'] ?? []) : [],
'tasks' => DB::table('tasks')->where('desk_case_id', $id)
->whereNull('deleted_at')
->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id))
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'definition'])
->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title']]),
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '')) : null]];
}
/** @param array<string,mixed> $input */
public function facts(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100',
'land_needed' => ['required', Rule::in(['yes', 'no', 'unknown'])], 'external_funding' => ['required', Rule::in(['yes', 'no', 'unknown'])],
'viability' => ['required', Rule::in(['unknown', 'proceed', 'reformulate', 'archive'])],
'reason' => 'required|string|min:3|max:3000'])->validate();
DB::transaction(function () use ($actor, $id, $data): void {
$case = $this->writable($actor, $id, $data['version']);
$payload = $this->desk->decode($case->payload);
$choice = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true);
foreach (['land_needed', 'external_funding', 'viability'] as $key) {
if ($choice) {
$payload['investor_path']['opportunity_facts'][$choice][$key] = $data[$key];
$payload['investor_path']['opportunity_fact_reasons'][$choice][$key] = $data['reason'];
} else {
$payload['investor_path']['facts'][$key] = $data[$key];
$payload['investor_path']['fact_reasons'][$key] = $data['reason'];
}
}
$this->savePayload($case, $payload);
$this->desk->event($this->desk->case($actor, $id), $actor, 'path_applicability_declared', $data);
});
}
/** @param array<string,mixed> $input */
public function stage(User $actor, string $id, string $code, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))],
'reason' => 'required|string|min:3|max:3000', 'action' => ['required', Rule::in(['prepare', 'submit', 'execute', 'operate', 'review', 'record_external'])],
'document_id' => 'nullable|uuid', 'authority' => 'nullable|string|max:200', 'reference' => 'nullable|string|max:300',
'context_revision' => 'nullable|integer|min:0', 'context_reviewed' => 'nullable|boolean', 'decision_date' => 'nullable|date_format:Y-m-d|before_or_equal:today'])->validate();
DB::transaction(function () use ($actor, $id, $code, $data): void {
$case = $this->desk->case($actor, $id, false, true);
abort_if($case->closed_at, 409);
abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão actual.');
$key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true);
$view = $this->view($actor, $id, $key);
$step = $this->findStep($view, $code);
abort_unless($step !== null && $step['preparation_allowed'], 422, 'Etapa dispensada de preparação; altere os pressupostos com fundamento.');
$state = $data['state'];
abort_if($state === 'completed' && ($step['fintech_review_required'] ?? false), 422, 'Os pressupostos Fintech desta revisão mudaram; preserve a preparação e atualize a conferência antes da conclusão.');
$internal = in_array($state, ['reviewing', 'correction_required'], true);
$external = in_array($state, ['approved', 'rejected', 'expired'], true);
$scope = 'investor_preparation';
if ($internal || $external) {
abort_unless($view['canReview'], 403, 'A conferência interna exige um responsável distinto do promotor.');
abort_unless($data['action'] === ($external ? 'record_external' : 'review'), 422);
$scope = $external ? 'external_document_recorded' : 'internal_validation';
} else {
abort_unless($view['canWrite'], 403);
abort_unless(! in_array($data['action'], ['review', 'record_external'], true), 422);
if ($state === 'submitted') {
abort_unless($data['action'] === 'submit' && ! empty($data['reference']), 422, 'Registe a referência da submissão; guardar não equivale a submeter.');
}
}
if ($external || $data['action'] !== 'prepare') {
abort_unless(empty($step['action_gaps'][$data['action']] ?? []), 422,
'Dependências reais da acção: '.implode(', ', $step['action_gaps'][$data['action']] ?? []));
}
if ($external) {
abort_unless(! empty($data['document_id']) && ! empty($data['authority']) && ! empty($data['reference']) && ! empty($data['decision_date']), 422, 'Registe documento, entidade, referência e data do acto externo.');
$doc = 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']);
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento de decisão ainda não consultável.');
}
// Completion of a legal/finance/operation action requires the corresponding evidence; preparation can proceed independently.
if ($state === 'completed' && in_array($code, ['S07', 'S08', 'S10', 'S11', 'S14', 'S15', 'S17', 'S18'], true)) {
abort_unless(($step['saved_state'] ?? '') === 'approved' && in_array($data['action'], ['execute', 'operate'], true), 422, 'Conclusão operacional exige acto externo documentado; use preparação enquanto aguarda.');
}
abort_if($state === 'completed' && ($step['context_review_required'] ?? false) && (! (bool) ($data['context_reviewed'] ?? false) || ($data['context_revision'] ?? -1) !== ($view['investment_context']['revision'] ?? 0)), 422, 'O contexto desta etapa exige nova conferência fundamentada; o registo anterior permanece no histórico.');
$detail = [...$data, 'context_dependency_hash' => $view['investment_context']['dependency_hash'] ?? null, 'fintech_fingerprint' => $view['fintech']['review_fingerprints'][$code] ?? null, 'scope' => $scope, 'actor_id' => $actor->id, 'recorded_at' => now()->toIso8601String()];
$stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail'));
if ($key) {
$stored['opportunities'][$key] = $detail;
} else {
$stored = [...$stored, ...$detail];
}
DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored),
'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'),
'version_no' => $step['version'] + 1, 'updated_at' => now()]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$this->desk->event($this->desk->case($actor, $id), $actor, 'path_stage_recorded', ['code' => $code, ...$detail]);
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function returnTool(Request $request, string $id, array $input): array
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'draft_id' => 'required|uuid', 'draft_version' => 'required|integer|min:1', 'context_revision' => 'nullable|integer|min:0', 'context_reviewed' => 'nullable|boolean'])->validate();
return DB::transaction(function () use ($request, $id, $data): array {
$actor = $request->user();
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409);
abort_unless($case->simulation_id === $data['draft_id'] && (int) $case->user_id === $actor->id, 403, 'Ferramenta pertencente a outro contexto.');
$draft = app(SimulationFormDrafts::class)->find($data['draft_id'], $request);
abort_unless($draft->version_no === $data['draft_version'], 409, 'O rascunho mudou; aguarde a gravação e volte a recuperar o resultado.');
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$snapshot = $economic['financial_tools_snapshot'] ?? null;
abort_unless(is_string($snapshot) && strlen($snapshot) <= 30000, 422, 'Guarde primeiro as entradas da ferramenta financeira.');
$parsed = json_decode($snapshot, true);
abort_unless(is_array($parsed) && ($parsed['version'] ?? null) === 1 && is_array($parsed['data'] ?? null), 422, 'Snapshot financeiro incompatível.');
$payload = $this->desk->decode($case->payload);
$context = app(InvestorContext::class)->state($case);
$opportunity = app(InvestmentConsumerScope::class)->key($context, $economic['context_opportunity'] ?? null, true);
if ($context['revision'] > 0) {
abort_unless(($data['context_revision'] ?? -1) === $context['revision'], 409, 'O contexto mudou; recupere e confira os pressupostos antes de devolver o resultado.');
abort_unless($data['context_reviewed'] ?? false, 422, 'Confirme a revisão dos pressupostos financeiros para este contexto.');
}
$resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision'].($opportunity ? ':'.$opportunity : ''));
foreach ($payload['investor_path']['results'] ?? [] as $result) {
if ($result['source_key'] === $resultKey) {
return $result;
}
}
abort_unless((int) $case->version === $data['version'], 409, 'Recupere a versão actual do dossier.');
$result = ['id' => (string) Str::uuid(), 'source_key' => $resultKey, 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
'snapshot_sha256' => $draft->snapshot_sha256, 'financial_snapshot' => $snapshot,
'context_opportunity' => $opportunity,
'currency' => $economic['currency'] ?? null, 'engine' => 'financial-calculators:v1',
'kind' => 'local_calculation_inputs', 'context_state' => 'current', 'context_revision' => $context['revision'], 'context_dependency_hash' => $context['dependency_hash'] ?? app(InvestorContext::class)->dependencyHash([]), 'recorded_at' => now()->toIso8601String()];
$payload['investor_path']['results'][] = $result;
abort_unless(count($payload['investor_path']['results']) <= 100, 422, 'Limite de evidências financeiras deste dossier atingido.');
$this->savePayload($case, $payload);
$this->desk->event($this->desk->case($actor, $id), $actor, 'tool_evidence_returned', array_diff_key($result, ['financial_snapshot' => true]));
return $result;
});
}
/** @return list<array<string,mixed>> */
public function contexts(User $actor): array
{
if (! $this->available()) {
return [];
}
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$contexts = DB::table('desk_cases as c')->join('investor_journeys as j', 'j.id', '=', 'c.journey_id')->whereNotNull('j.path_definition_id')
->whereNull('j.deleted_at')->whereNull('c.closed_at')->where(fn ($q) => $q->where(fn ($p) => $p->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations))
->orderByDesc('c.updated_at')->get(['c.id', 'c.title', 'c.version', 'c.updated_at'])
->map(fn ($row) => ['id' => $row->id, 'title' => $row->title, 'version' => $row->version, 'updated_at' => $row->updated_at])->all();
return array_values($contexts);
}
/** @param array<string,mixed> $input */
public function task(User $actor, string $id, array $input): string
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'code' => ['required', Rule::in(array_map(fn ($n) => sprintf('S%02d', $n), range(1, 19)))],
'title' => 'required|string|min:3|max:200', 'due_at' => 'required|date|after:now', 'consent' => 'accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data): string {
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409);
$key = 'path:'.$data['code'];
$existing = DB::table('tasks')->where('desk_case_id', $id)->where('guidance_key', $key)->first();
if ($existing) {
abort_unless($existing->title === $data['title'] && (int) $existing->accepted_by === $actor->id
&& Carbon::parse($existing->due_at)->equalTo(Carbon::parse($data['due_at'])), 409, 'A tarefa desta etapa já existe com outros dados.');
return $existing->id;
}
$this->writable($actor, $id, $data['version']);
$step = $this->findStep($this->view($actor, $id), $data['code']);
abort_unless($step !== null && $step['preparation_allowed'], 422);
$task = CrmTask::create(['desk_case_id' => $id, 'guidance_key' => $key, 'title' => $data['title'], 'due_at' => $data['due_at'],
'status' => 'open', 'assignee_user_id' => $actor->id, 'accepted_by' => $actor->id, 'accepted_at' => now(),
'external_sync_status' => 'pending_authorization', 'data_classification' => 'confidential', 'source_system' => 'investor-path']);
$this->desk->event($case, $actor, 'path_task_accepted', ['task_id' => $task->id, 'code' => $data['code'], 'scope' => 'internal_crm']);
return $task->id;
});
}
private function writable(User $actor, string $id, int $version): \stdClass
{
abort_unless($this->available(), 404);
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409);
abort_unless((int) $case->version === $version, 409, 'Recupere a versão actual do dossier.');
$this->view($actor, $id);
return $case;
}
/** @param array<string,mixed> $view
* @return array<string,mixed>|null
*/
private function findStep(array $view, string $code): ?array
{
if (! is_array($view['steps'] ?? null)) {
return null;
}
foreach ($view['steps'] as $step) {
if (is_array($step) && ($step['code'] ?? null) === $code) {
return $step;
}
}
return null;
}
/** @param array<string,mixed> $payload */
private function savePayload(\stdClass $case, array $payload): void
{
DB::table('desk_cases')->where('id', $case->id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
}
}