*/ public function definition(): array { return config('company-comparison'); } /** @param array $questions * @return list */ private function questionKeys(array $questions): array { $keys = []; foreach (array_keys($questions) as $key) { if (! is_string($key)) { throw new \LogicException('Comparison question identifiers must be strings.'); } $keys[] = $key; } return $keys; } /** @param array $input * @return array */ public function answers(array $input): array { return $this->normalize($input, $this->definition()['questions']); } /** @param array $input * @param array $questions * @return array */ private function normalize(array $input, array $questions): array { $keys = $this->questionKeys($questions); $rules = ['answers' => ['sometimes', 'array:'.implode(',', $keys)]]; foreach ($keys as $key) { $rules['answers.'.$key] = ['nullable', 'string', Rule::in(array_keys($questions[$key]['options']))]; } $values = Validator::make($input, $rules)->validate()['answers'] ?? []; $answers = []; foreach ($keys as $key) { $answers[$key] = is_string($values[$key] ?? null) ? $values[$key] : 'unknown'; } return $answers; } /** @param array $answers * @return list */ public function active(array $answers): array { return in_array($answers['purpose'], ['representation', 'cooperation'], true) ? ['purpose', 'regulated'] : $this->questionKeys($this->definition()['questions']); } /** @param array $answers * @return array */ public function evaluate(array $answers): array { $definition = $this->definition(); $active = $this->active($answers); $pending = array_values(array_filter($active, fn ($key) => $key !== 'preferred' && $answers[$key] === 'unknown')); $tasks = ['Definir contribuições, gestão, direitos e obrigações no acto a preparar.', 'Conferir os actos e o registo aplicáveis à alternativa escolhida.']; if (in_array('capital', $active, true)) { $tasks[] = $answers['capital'] === 'defined' ? 'Conferir os valores e o modo de realização das contribuições definidas.' : 'Definir o capital e as contribuições ainda em falta.'; if (in_array($answers['management'], ['participants', 'both'], true)) { $tasks[] = 'Definir poderes de gestão e regras de deliberação dos participantes.'; } if (in_array($answers['management'], ['appointed', 'both'], true)) { $tasks[] = 'Definir a nomeação, o mandato e a prestação de contas da administração.'; } if ($answers['responsibility'] !== 'unknown') { $tasks[] = 'Conferir no registo e no contrato o regime de responsabilidade e as excepções aplicáveis.'; } if ($answers['listing'] === 'yes') { $tasks[] = 'Conferir os requisitos específicos da emissão e do mercado pretendido.'; } } if ($answers['regulated'] !== 'no') { $tasks[] = 'Conferir as regras e habilitações específicas da actividade.'; } $route = match ($answers['purpose']) { 'representation' => 'Compare a representação da sociedade existente; não é uma nova forma societária (artigo 69).', 'cooperation' => 'Prepare o acordo de cooperação; consórcio não é apresentado como uma forma societária autónoma.', default => 'Compare alternativas para discutir a constituição; não existe uma forma universalmente preferível.', }; $forms = []; if (! in_array($answers['purpose'], ['representation', 'cooperation'], true)) { foreach ($definition['forms'] as $code => $form) { $constraints = []; $notes = []; if ($code === 'individual' && $answers['participants'] === 'several') { $constraints[] = 'A titularidade individual não corresponde a vários titulares (artigo 3).'; } if ($code === 'collective' && $answers['participants'] === 'one') { $constraints[] = 'São necessários pelo menos dois sócios (artigo 264).'; } if ($code === 'simplified' && $answers['listing'] === 'yes') { $constraints[] = 'As acções desta forma não podem ser negociadas em Bolsa (artigo 444).'; } if ($answers['participants'] === 'one' && in_array($code, ['quotas', 'anonymous', 'simplified'], true)) { $notes[] = 'Comparar a modalidade unipessoal (artigo 257).'; } if (($answers['participation'] === 'shares' && $code === 'quotas') || ($answers['participation'] === 'quotas' && in_array($code, ['anonymous', 'simplified'], true))) { $notes[] = 'A estrutura de participação difere da preferência indicada.'; } if ($answers['participation'] === 'work') { $notes[] = $code === 'collective' ? 'Conferir a contribuição em trabalho (artigos 264–266).' : 'Conferir como realizar as contribuições pretendidas.'; } $forms[] = ['code' => $code, ...$form, 'constraints' => $constraints, 'notes' => $notes, 'selected' => $answers['preferred'] === $code, 'source_href' => $definition['source']['url'].'#page='.$form['page']]; } } return ['engine_version' => $definition['version'], 'source' => $definition['source'], 'questions' => $definition['questions'], 'active' => $active, 'pending' => $pending, 'route' => $route, 'forms' => $forms, 'preparation' => $tasks, 'scope' => 'Comparação informativa da edição indicada; a actividade, o contrato e as regras especiais continuam a exigir conferência própria.']; } /** @param array $input * @return array */ public function view(User $actor, string $id, array $input): array { $case = $this->desk->case($actor, $id); $saved = $this->desk->decode($case->payload)['company_comparison'] ?? null; $v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate(); if (! empty($v['revision'])) { if (array_key_exists('answers', $input)) { throw ValidationException::withMessages(['answers' => 'Abra a preparação actual para comparar outras respostas; a revisão histórica conserva os dados guardados.']); } $row = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first(); abort_unless($row !== null, 404); $saved = $this->desk->decode($row->payload)['snapshot']; } $answers = array_key_exists('answers', $input) ? $this->answers($input) : ($saved['answers'] ?? $this->answers([])); $result = array_key_exists('answers', $input) || ! $saved ? $this->evaluate($answers) : $saved['result']; $events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT) ->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before'])) ->orderByDesc('event_order')->limit(26)->get(['id', 'payload', 'event_order']); $history = $events->take(25)->map(function ($row) { $snapshot = $this->desk->decode($row->payload)['snapshot']; return ['id' => $row->id, 'revision' => $snapshot['revision'], 'at' => $snapshot['at'], 'engine_version' => $snapshot['result']['engine_version']]; })->all(); return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version], 'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'answers' => $answers, 'result' => $result, 'saved' => $saved, 'history' => $history, 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null, 'historical' => ! empty($v['revision']), 'workingCopy' => array_key_exists('answers', $input)]; } /** @param array $input * @return array */ public function save(User $actor, string $id, array $input): array { $this->desk->case($actor, $id, true); $v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate(); $v['version'] = (int) $v['version']; return DB::transaction(function () use ($actor, $id, $v, $input) { $case = $this->desk->case($actor, $id, true, true); foreach (DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->orderByDesc('event_order')->cursor() as $event) { $data = $this->desk->decode($event->payload); if ($data['operation_key'] === $v['operation_key']) { $answers = $this->normalize($input, $data['snapshot']['result']['questions']); $hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR)); abort_unless(hash_equals($data['request_hash'], $hash), 409, 'Esta operação já guardou respostas diferentes. Reabra a comparação.'); return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']]; } } $answers = $this->answers($input); $hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR)); abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Conserve as respostas e recupere a versão actual antes de guardar.'); $payload = $this->desk->decode($case->payload); $result = $this->evaluate($answers); $previous = $payload['company_comparison'] ?? null; if ($previous !== null && $previous['answers'] === $answers && $previous['result'] === $result) { return ['saved' => true, 'replayed' => false, 'unchanged' => true, 'revision' => $previous['revision']]; } $snapshot = ['revision' => (int) ($payload['company_comparison']['revision'] ?? 0) + 1, 'answers' => $answers, 'result' => $result, 'at' => now()->toIso8601String(), 'actor_id' => $actor->id]; $payload['company_comparison'] = $snapshot; $case->version++; DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]); $this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]); return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']]; }); } }