286 lines
17 KiB
PHP
286 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Brick\Math\BigDecimal;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
/** Factual preparation only. Actual contractual exemplars have not been supplied. */
|
|
final class CompanyPreparation
|
|
{
|
|
public const EVENT = 'company_preparation_saved';
|
|
|
|
public const VERSION = 'company-preparation-1';
|
|
|
|
public function __construct(private readonly VirtualDesk $desk) {}
|
|
|
|
/** @return array<string,mixed> */
|
|
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<string,array<string,mixed>> */
|
|
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<string,mixed> $input
|
|
* @param array<string,mixed>|null $definition
|
|
* @return array<string,mixed> */
|
|
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<string,mixed> $facts
|
|
* @param array<string,array<string,mixed>> $sources
|
|
* @param array<string,mixed>|null $previous
|
|
* @return array<string,mixed> */
|
|
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<string,mixed> $facts
|
|
* @return array<string,mixed> */
|
|
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<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
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<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();
|
|
|
|
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']];
|
|
});
|
|
}
|
|
}
|