226 lines
11 KiB
PHP
226 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\InvestmentLegalSource;
|
|
use App\Models\SimulationTaxRuleVersion;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
final class SimulationTaxRuleGovernance
|
|
{
|
|
/** @param array<string,mixed> $data */
|
|
public function create(User $actor, array $data): SimulationTaxRuleVersion
|
|
{
|
|
SimulationTaxGovernanceAccess::requireRole($actor, 'editors');
|
|
|
|
return DB::transaction(function () use ($actor, $data): SimulationTaxRuleVersion {
|
|
$this->setContext($actor);
|
|
$source = $this->sourceSnapshot($data);
|
|
$rule = SimulationTaxRuleVersion::query()->create([
|
|
...$data,
|
|
'id' => (string) Str::uuid(),
|
|
'status' => 'draft',
|
|
'author_id' => $actor->id,
|
|
'source_version_no' => $source->version_no,
|
|
'version_no' => 1,
|
|
]);
|
|
$this->event($rule, $actor, 'created');
|
|
|
|
return $rule->refresh();
|
|
}, 3);
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function update(User $actor, string $id, int $version, array $data): SimulationTaxRuleVersion
|
|
{
|
|
SimulationTaxGovernanceAccess::requireRole($actor, 'editors');
|
|
|
|
return DB::transaction(function () use ($actor, $id, $version, $data): SimulationTaxRuleVersion {
|
|
$this->setContext($actor);
|
|
$rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id);
|
|
$this->matchVersion($rule, $version);
|
|
abort_unless((int) $rule->author_id === $actor->id && $rule->status === 'draft', 403,
|
|
'Só o autor pode alterar um rascunho ainda não submetido. Crie uma nova versão da regra após revisão.');
|
|
$source = $this->sourceSnapshot($data);
|
|
$rule->fill([...$data, 'source_version_no' => $source->version_no, 'version_no' => $version + 1])->save();
|
|
$this->event($rule, $actor, 'updated');
|
|
|
|
return $rule->refresh();
|
|
}, 3);
|
|
}
|
|
|
|
public function transition(User $actor, string $id, int $version, string $action, string $evidence): SimulationTaxRuleVersion
|
|
{
|
|
$role = match ($action) {
|
|
'submit' => 'editors',
|
|
'review', 'reject' => 'reviewers',
|
|
'approve' => 'approvers',
|
|
'publish', 'suspend', 'archive' => 'publishers',
|
|
default => throw new HttpException(422, 'Transição de governação inválida.'),
|
|
};
|
|
SimulationTaxGovernanceAccess::requireRole($actor, $role);
|
|
|
|
return DB::transaction(function () use ($actor, $id, $version, $action, $evidence): SimulationTaxRuleVersion {
|
|
$this->setContext($actor);
|
|
$rule = SimulationTaxRuleVersion::query()->lockForUpdate()->findOrFail($id);
|
|
$this->matchVersion($rule, $version);
|
|
$next = ['version_no' => $version + 1];
|
|
|
|
switch ($action) {
|
|
case 'submit':
|
|
abort_unless((int) $rule->author_id === $actor->id && $rule->status === 'draft', 403,
|
|
'Só o autor pode submeter o seu rascunho.');
|
|
$this->sourceStillValid($rule);
|
|
$next += ['status' => 'submitted', 'submitted_at' => now()];
|
|
break;
|
|
case 'review':
|
|
case 'reject':
|
|
abort_unless((int) $rule->author_id !== $actor->id, 403, 'O autor não pode rever a própria regra.');
|
|
abort_unless($rule->status === 'submitted', 422, 'A regra tem de estar submetida para revisão.');
|
|
$this->sourceStillValid($rule);
|
|
$next += ['status' => $action === 'review' ? 'reviewed' : 'rejected', 'reviewer_id' => $actor->id,
|
|
'review_evidence' => $evidence, 'reviewed_at' => now()];
|
|
break;
|
|
case 'approve':
|
|
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id, 403,
|
|
'Autor e revisor não podem aprovar a mesma regra.');
|
|
abort_unless($rule->status === 'reviewed', 422, 'A regra tem de estar revista antes da aprovação.');
|
|
$this->sourceStillValid($rule);
|
|
$next += ['status' => 'approved', 'approver_id' => $actor->id,
|
|
'approval_evidence' => $evidence, 'approved_at' => now()];
|
|
break;
|
|
case 'publish':
|
|
abort_unless($rule->status === 'approved', 422, 'A regra tem de estar aprovada antes da publicação.');
|
|
abort_unless((int) $rule->author_id !== $actor->id && (int) $rule->reviewer_id !== $actor->id
|
|
&& (int) $rule->approver_id !== $actor->id, 403,
|
|
'Autor, revisor e aprovador não podem publicar a mesma regra.');
|
|
$this->sourceStillValid($rule);
|
|
$this->assertNoPublishedOverlap($rule);
|
|
$next += ['status' => 'published', 'publisher_id' => $actor->id,
|
|
'publication_evidence' => $evidence, 'published_at' => now()];
|
|
break;
|
|
case 'suspend':
|
|
abort_unless($rule->status === 'published', 422, 'Só uma regra publicada pode ser suspensa.');
|
|
$next += ['status' => 'suspended', 'closure_evidence' => $evidence, 'suspended_at' => now()];
|
|
break;
|
|
case 'archive':
|
|
abort_unless(in_array($rule->status, ['published', 'suspended'], true), 422,
|
|
'Só uma regra publicada ou suspensa pode ser arquivada.');
|
|
$next += ['status' => 'archived', 'closure_evidence' => $evidence, 'archived_at' => now()];
|
|
break;
|
|
}
|
|
|
|
$rule->fill($next)->save();
|
|
$this->event($rule, $actor, $action);
|
|
|
|
return $rule->refresh();
|
|
}, 3);
|
|
}
|
|
|
|
/** @param array<string,mixed> $filters */
|
|
public function listing(User $actor, array $filters): mixed
|
|
{
|
|
SimulationTaxGovernanceAccess::requireAnyRole($actor);
|
|
|
|
return DB::transaction(function () use ($actor, $filters) {
|
|
$this->setContext($actor);
|
|
$query = SimulationTaxRuleVersion::query()->orderByDesc('created_at');
|
|
foreach (['rule_code', 'component', 'scope_key', 'status'] as $key) {
|
|
if (isset($filters[$key])) {
|
|
$query->where($key, $filters[$key]);
|
|
}
|
|
}
|
|
|
|
return $query->paginate($filters['per_page'] ?? 20)->withQueryString();
|
|
});
|
|
}
|
|
|
|
public function show(User $actor, string $id): SimulationTaxRuleVersion
|
|
{
|
|
SimulationTaxGovernanceAccess::requireAnyRole($actor);
|
|
|
|
return DB::transaction(function () use ($actor, $id): SimulationTaxRuleVersion {
|
|
$this->setContext($actor);
|
|
|
|
return SimulationTaxRuleVersion::query()->findOrFail($id);
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
private function sourceSnapshot(array $data): InvestmentLegalSource
|
|
{
|
|
$source = InvestmentLegalSource::query()->whereKey($data['legal_source_id'])->firstOrFail();
|
|
abort_unless(preg_match('/^[a-f0-9]{64}$/D', (string) $data['source_sha256']) === 1, 422,
|
|
'Indique o SHA-256 do exemplar oficial efectivamente revisto.');
|
|
abort_unless($source->validation_status === 'validated', 422, 'A fonte jurídica ainda não foi validada.');
|
|
abort_unless($source->valid_from <= $data['valid_from']
|
|
&& ($source->valid_until === null || ($data['valid_until'] !== null && $source->valid_until >= $data['valid_until']))
|
|
&& ($data['valid_until'] !== null || $source->valid_until === null), 422,
|
|
'A vigência da fonte não cobre todo o período declarado para a regra.');
|
|
|
|
return $source;
|
|
}
|
|
|
|
private function sourceStillValid(SimulationTaxRuleVersion $rule): void
|
|
{
|
|
$source = InvestmentLegalSource::query()->lockForUpdate()->find($rule->legal_source_id);
|
|
abort_unless($source !== null && $source->validation_status === 'validated'
|
|
&& (int) $source->version_no === (int) $rule->source_version_no
|
|
&& $source->valid_from <= $rule->valid_from->format('Y-m-d')
|
|
&& ($source->valid_until === null || ($rule->valid_until !== null && $source->valid_until >= $rule->valid_until->format('Y-m-d')))
|
|
&& ($rule->valid_until !== null || $source->valid_until === null), 422,
|
|
'A fonte aprovada foi revogada, alterada ou não cobre toda a vigência da regra.');
|
|
}
|
|
|
|
private function assertNoPublishedOverlap(SimulationTaxRuleVersion $rule): void
|
|
{
|
|
DB::select('select pg_advisory_xact_lock(hashtext(?), hashtext(?))', [$rule->rule_code, $rule->scope_key]);
|
|
$overlap = SimulationTaxRuleVersion::query()->where('id', '<>', $rule->id)->where('status', 'published')
|
|
->where('component', $rule->component)->where('rule_code', $rule->rule_code)->where('scope_key', $rule->scope_key)
|
|
->whereDate('valid_from', '<=', $rule->valid_until?->format('Y-m-d') ?? '9999-12-31')
|
|
->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $rule->valid_from->format('Y-m-d')))
|
|
->exists();
|
|
abort_unless(! $overlap, 409, 'Já existe regra publicada com vigência sobreposta para o mesmo código, componente e scope_key.');
|
|
}
|
|
|
|
private function matchVersion(SimulationTaxRuleVersion $rule, int $version): void
|
|
{
|
|
abort_unless((int) $rule->version_no === $version, 409, 'Versão alterada. Recarregue antes de continuar.');
|
|
}
|
|
|
|
private function setContext(User $actor): void
|
|
{
|
|
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $actor->id]);
|
|
}
|
|
|
|
private function event(SimulationTaxRuleVersion $rule, User $actor, string $action): void
|
|
{
|
|
$snapshot = $this->canonical($rule->attributesToArray());
|
|
$json = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
|
$canonicalJson = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
|
|
DB::table('simulation_tax_rule_events')->insert([
|
|
'id' => (string) Str::uuid(), 'rule_version_id' => $rule->id, 'version_no' => $rule->version_no,
|
|
'actor_user_id' => $actor->id, 'action' => $action, 'snapshot' => $json,
|
|
'sha256' => hash('sha256', $canonicalJson), 'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
private function canonical(mixed $value): mixed
|
|
{
|
|
if (! is_array($value)) {
|
|
return $value;
|
|
}
|
|
if (! array_is_list($value)) {
|
|
ksort($value);
|
|
}
|
|
foreach ($value as $key => $item) {
|
|
$value[$key] = $this->canonical($item);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|