desk->installed() && Schema::hasColumn('investor_journeys', 'path_definition_id') && Schema::hasColumn('journey_steps', 'path_detail'); } /** @param array $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 */ 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']; } $preparationTaskKeys = $opportunity !== null && ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->taskKeys($actor, $id, $opportunity) : null; return ['preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, '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($preparationTaskKeys !== null, fn ($q) => $q->where(fn ($p) => $p->whereNull('guidance_key')->orWhere('guidance_key', 'not like', 'swot:%')->orWhereIn('guidance_key', $preparationTaskKeys ?? []))) ->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id)) ->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']), 'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'effective_from' => $s->effective_from ?? null]), 'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null, 'preparation' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? '/investimentos/'.$id.'/preparacao'.($opportunity ? '?opportunity='.urlencode($opportunity) : '') : null, 'company' => '/investimentos/'.$id.'/forma-juridica', '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 $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 $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 $input * @return array */ 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> */ 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 $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)))], 'preparation_action' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', '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); if (!empty($data['preparation_action'])) { app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['opportunity'] ?? null, true); } $origin = !empty($data['preparation_action']) ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $data['opportunity'] ?? null, $data['preparation_action']) : null; abort_if(!empty($data['preparation_action']) && (!$origin || $origin['kind'] !== 'risk'), 422, 'A acção SWOT não pertence a este dossier/oportunidade ou não é uma acção de risco.'); $key = $origin ? 'swot:'.$origin['id'] : '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, $origin['opportunity'] ?? null), $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', 'preparation_action' => $origin['id'] ?? null, 'opportunity' => $origin['opportunity'] ?? null, 'swot_review_version' => $origin['review_version'] ?? null]); 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 $view * @return array|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 $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()]); } }