*/ public function definition(): array { return ['version' => self::VERSION, 'forms' => config('company-comparison.forms'), 'roles' => ['subscriber' => 'Titular, sócio ou accionista', 'administrator' => 'Administrador ou gerente', 'representative' => 'Representante', 'beneficial_owner' => 'Beneficiário efectivo declarado'], 'source' => config('company-comparison.source'), 'template_status' => 'source_pending', 'template_notice' => 'Os sete exemplares referidos no complemento ainda não foram fornecidos. Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.']; } /** Reuse only the already-authorized, frozen dossier values, never a fresh master lookup. * @return array> */ private function sources(\stdClass $case): array { $payload = $this->desk->decode($case->payload); $sources = []; if (! empty($payload['name'])) { $sources['case_proponent'] = ['name' => $payload['name'], 'kind' => 'person', 'reference' => $case->id, 'version' => $payload['reuse_consent']['date'] ?? 'creation', 'origin' => 'Proponente conservado na criação do dossier']; } if ($case->company_id && ! empty($payload['company']['name'])) { $sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id, 'version' => $payload['company_version'] ?? null, 'origin' => 'Declaração de empresa conservada no dossier']; } return $sources; } /** @param array $input * @param array|null $definition * @return array */ public function facts(array $input, ?array $definition = null): array { $definition ??= $this->definition(); $text = ['nullable', 'string', 'max:2000']; $number = ['nullable', 'string', 'regex:/^\d{1,18}(?:[.,]\d{1,6})?$/D']; $rules = ['facts' => ['present', 'array:form,proposed_name,seat,activities,regulated,currency,capital,nominal_value,management,binding,notes,parties'], 'facts.form' => ['nullable', Rule::in(array_keys($definition['forms']))], 'facts.proposed_name' => ['nullable', 'string', 'max:250'], 'facts.seat' => $text, 'facts.activities' => $text, 'facts.regulated' => ['nullable', Rule::in(['unknown', 'yes', 'no'])], 'facts.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/D'], 'facts.capital' => $number, 'facts.nominal_value' => $number, 'facts.management' => $text, 'facts.binding' => $text, 'facts.notes' => $text, 'facts.parties' => ['sometimes', 'array', 'list', 'max:100'], 'facts.parties.*' => ['array:id,name,kind,source,roles,represented_id,cash,in_kind,paid,share_count,work,commitment'], 'facts.parties.*.id' => ['required', 'uuid', 'distinct'], 'facts.parties.*.name' => ['nullable', 'string', 'max:250'], 'facts.parties.*.kind' => ['required', Rule::in(['person', 'entity'])], 'facts.parties.*.source' => ['nullable', Rule::in(['case_proponent', 'case_company'])], 'facts.parties.*.roles' => ['present', 'array', 'list', 'max:4'], 'facts.parties.*.roles.*' => ['string', Rule::in(array_keys($definition['roles']))], 'facts.parties.*.represented_id' => ['nullable', 'uuid'], 'facts.parties.*.cash' => $number, 'facts.parties.*.in_kind' => $number, 'facts.parties.*.paid' => $number, 'facts.parties.*.share_count' => ['nullable', 'string', 'regex:/^\d{1,18}$/D'], 'facts.parties.*.work' => $text, 'facts.parties.*.commitment' => $text]; $v = Validator::make($input, $rules)->validate()['facts']; $facts = []; foreach (['form', 'proposed_name', 'seat', 'activities', 'currency', 'management', 'binding', 'notes'] as $key) { $facts[$key] = $v[$key] ?? null; } $facts['regulated'] = $v['regulated'] ?? 'unknown'; foreach (['capital', 'nominal_value'] as $key) { $facts[$key] = $this->decimal($v[$key] ?? null); } $facts['parties'] = []; foreach ($v['parties'] ?? [] as $party) { $p = []; foreach (['id', 'name', 'kind', 'source', 'represented_id', 'work', 'commitment'] as $key) { $p[$key] = $party[$key] ?? null; } $p['roles'] = array_values(array_unique($party['roles'])); sort($p['roles']); foreach (['cash', 'in_kind', 'paid', 'share_count'] as $key) { $p[$key] = $this->decimal($party[$key] ?? null); } $facts['parties'][] = $p; } return $facts; } private function decimal(?string $value): ?string { return $value === null ? null : (string) BigDecimal::of(str_replace(',', '.', $value))->strippedOfTrailingZeros(); } /** @param array $facts * @param array> $sources * @param array|null $previous * @return array */ private function resolve(array $facts, array $sources, ?array $previous): array { foreach ($facts['parties'] as &$party) { if ($party['source'] !== null) { $old = null; foreach ($previous['facts']['parties'] ?? [] as $candidate) { if ($candidate['id'] === $party['id'] && $candidate['source'] === $party['source']) { $old = $candidate; break; } } $source = $old['source_snapshot'] ?? $sources[$party['source']] ?? null; abort_unless($source !== null, 422, 'A origem seleccionada não existe neste dossier.'); $party['name'] = $source['name']; $party['kind'] = $source['kind']; $party['source_snapshot'] = $source; } } return $facts; } /** @param array $facts * @return array */ public function review(array $facts): array { $issues = []; foreach (['form' => 'Definir a forma a preparar.', 'proposed_name' => 'Indicar a denominação proposta.', 'seat' => 'Indicar a sede proposta.', 'activities' => 'Descrever as actividades.', 'management' => 'Descrever a administração pretendida.', 'binding' => 'Indicar a forma de obrigar pretendida.'] as $key => $message) { if (! $facts[$key]) { $issues[] = $message; } } $subscribers = array_values(array_filter($facts['parties'], fn ($p) => in_array('subscriber', $p['roles'], true))); if ($subscribers === []) { $issues[] = 'Identificar os titulares, sócios ou accionistas.'; } if ($facts['form'] === 'collective' && count($subscribers) < 2) { $issues[] = 'A forma colectiva exige pelo menos dois sócios (artigo 264).'; } if ($facts['form'] === 'individual' && count($subscribers) > 1) { $issues[] = 'A titularidade individual não corresponde a vários titulares.'; } $corporate = $facts['form'] !== null && $facts['form'] !== 'individual'; $shares = in_array($facts['form'], ['anonymous', 'simplified'], true); $total = BigDecimal::zero(); $paid = BigDecimal::zero(); $units = BigDecimal::zero(); $complete = $subscribers !== []; $paymentsComplete = $complete; $unitsComplete = $complete; $ids = array_column($facts['parties'], 'id'); $usedSources = []; foreach ($facts['parties'] as $party) { if ($party['source'] && in_array($party['source'], $usedSources, true)) { $issues[] = 'A mesma origem foi seleccionada em mais de uma linha; conferir duplicação sem fundir pessoas por nome.'; } $usedSources[] = $party['source']; if (! $party['name'] || $party['roles'] === []) { $issues[] = 'Completar o nome e os papéis de cada interveniente.'; } if (in_array('representative', $party['roles'], true) && (! $party['represented_id'] || $party['represented_id'] === $party['id'] || ! in_array($party['represented_id'], $ids, true))) { $issues[] = 'Identificar quem cada representante representa, numa linha distinta.'; } if (! in_array('subscriber', $party['roles'], true) || ! $corporate) { continue; } $complete = $complete && $party['cash'] !== null && $party['in_kind'] !== null; $subscription = BigDecimal::of($party['cash'] ?? '0')->plus($party['in_kind'] ?? '0'); $total = $total->plus($subscription); $paymentsComplete = $paymentsComplete && $party['paid'] !== null; $paid = $paid->plus($party['paid'] ?? '0'); if ($party['paid'] !== null && $party['cash'] !== null && $party['in_kind'] !== null && BigDecimal::of($party['paid'])->compareTo($subscription) > 0) { $issues[] = 'Uma realização declarada excede a subscrição desse participante.'; } $unitsComplete = $unitsComplete && $party['share_count'] !== null; $units = $units->plus($party['share_count'] ?? '0'); if ($shares && $party['share_count'] !== null && $facts['nominal_value'] !== null && $party['cash'] !== null && $party['in_kind'] !== null && BigDecimal::of($party['share_count'])->multipliedBy($facts['nominal_value'])->compareTo($subscription) !== 0) { $issues[] = 'O valor nominal das acções de um participante difere da sua subscrição.'; } if ($party['work'] && $facts['form'] !== 'collective') { $issues[] = 'Conferir a admissibilidade da contribuição em trabalho para a forma escolhida.'; } } $totals = null; if ($corporate) { if (! $facts['currency'] || $facts['capital'] === null || ! $complete) { $issues[] = 'Completar moeda, capital e entradas em dinheiro/espécie; valores desconhecidos não são zero.'; } if ($complete && $facts['capital'] !== null && $total->compareTo($facts['capital']) !== 0) { $issues[] = 'A soma das subscrições difere do capital declarado.'; } if (! $paymentsComplete) { $issues[] = 'Completar a realização declarada de cada subscrição; não equivale a prova de pagamento.'; } if ($shares && ($facts['nominal_value'] === null || ! $unitsComplete)) { $issues[] = 'Completar o número de acções e o valor nominal para reconciliar as participações.'; } $totals = ['currency' => $facts['currency'], 'subscribed' => $complete && $facts['currency'] ? (string) $total : null, 'paid' => $paymentsComplete && $facts['currency'] ? (string) $paid : null, 'unpaid' => $complete && $paymentsComplete && $facts['currency'] ? (string) $total->minus($paid) : null, 'shares_nominal' => $shares && $unitsComplete && $facts['nominal_value'] !== null && $facts['currency'] ? (string) $units->multipliedBy($facts['nominal_value']) : null]; } if ($facts['regulated'] !== 'no') { $issues[] = 'Conferir os requisitos sectoriais ou profissionais da actividade; o objecto não concede licença.'; } return ['issues' => array_values(array_unique($issues)), 'totals' => $totals, 'status' => 'preparation', 'note' => $facts['form'] === 'collective' ? 'As contribuições em trabalho são conservadas separadamente e não integram o capital (artigo 266).' : 'Valores declarados para preparação; não comprovam entradas, poderes ou registo.']; } /** @param array $input * @return array */ public function view(User $actor, string $id, array $input): array { $case = $this->desk->case($actor, $id); $payload = $this->desk->decode($case->payload); $v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate(); $saved = $payload['company_preparation'] ?? null; if (! empty($v['revision'])) { $event = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first(); abort_unless($event !== null, 404); $saved = $this->desk->decode($event->payload)['snapshot']; } $facts = $saved['facts'] ?? $this->facts(['facts' => []]); $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']); return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version], 'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']), 'definition' => $saved['definition'] ?? $this->definition(), 'facts' => $facts, 'review' => $saved['review'] ?? $this->review($facts), 'saved' => $saved, 'sources' => $this->sources($case), 'history' => $events->take(25)->map(function ($e) { $s = $this->desk->decode($e->payload)['snapshot']; return ['id' => $e->id, 'revision' => $s['revision'], 'at' => $s['at']]; })->all(), 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null]; } /** @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(); 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']) { $facts = $this->facts($input, $data['snapshot']['definition']); $hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR)); abort_unless(hash_equals($data['request_hash'], $hash), 409, 'A operação já guardou outros dados.'); return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']]; } } $facts = $this->facts($input); $hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR)); abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam no formulário; recupere a versão antes de guardar.'); $payload = $this->desk->decode($case->payload); $previous = $payload['company_preparation'] ?? null; $resolved = $this->resolve($facts, $this->sources($case), $previous); $definition = $this->definition(); $review = $this->review($resolved); if ($previous && $previous['facts'] === $resolved && $previous['definition'] === $definition && $previous['review'] === $review) { return ['saved' => true, 'unchanged' => true, 'revision' => $previous['revision']]; } $snapshot = ['revision' => (int) ($previous['revision'] ?? 0) + 1, 'at' => now()->toIso8601String(), 'actor_id' => $actor->id, 'facts' => $resolved, 'definition' => $definition, 'review' => $review]; $payload['company_preparation'] = $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']]; }); } }