209 lines
12 KiB
PHP
209 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Validator;
|
||
use Illuminate\Validation\Rule;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
/** Informative comparison, stored in the existing encrypted dossier and event history. */
|
||
final class CompanyComparison
|
||
{
|
||
public const EVENT = 'company_comparison_saved';
|
||
|
||
public function __construct(private readonly VirtualDesk $desk) {}
|
||
|
||
/** @return array<string,mixed> */
|
||
public function definition(): array
|
||
{
|
||
return config('company-comparison');
|
||
}
|
||
|
||
/** @param array<array-key,mixed> $questions
|
||
* @return list<string> */
|
||
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<string,mixed> $input
|
||
* @return array<string,string> */
|
||
public function answers(array $input): array
|
||
{
|
||
return $this->normalize($input, $this->definition()['questions']);
|
||
}
|
||
|
||
/** @param array<string,mixed> $input
|
||
* @param array<array-key,mixed> $questions
|
||
* @return array<string,string> */
|
||
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<string,string> $answers
|
||
* @return list<string> */
|
||
public function active(array $answers): array
|
||
{
|
||
return in_array($answers['purpose'], ['representation', 'cooperation'], true) ? ['purpose', 'regulated'] : $this->questionKeys($this->definition()['questions']);
|
||
}
|
||
|
||
/** @param array<string,string> $answers
|
||
* @return array<string,mixed> */
|
||
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<string,mixed> $input
|
||
* @return array<string,mixed> */
|
||
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<string,mixed> $input
|
||
* @return array<string,mixed> */
|
||
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']];
|
||
});
|
||
}
|
||
}
|