366 lines
19 KiB
PHP
366 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Support\IsolatedQaDatabase;
|
|
use Brick\Math\BigRational;
|
|
use DateTimeImmutable;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
|
|
/** Selects a published, date-effective parameter from an explicitly scoped candidate set. */
|
|
final class SimulationTaxParameterResolver
|
|
{
|
|
/** @param array<array-key,mixed> $parameters */
|
|
public function validateParameters(array $parameters): void
|
|
{
|
|
if ($parameters === [] || count($parameters) > 100) {
|
|
throw new InvalidArgumentException('Indique entre 1 e 100 parâmetros versionados.');
|
|
}
|
|
foreach ($parameters as $code => $parameter) {
|
|
if (! is_string($code) || ! preg_match('/^[A-Z][A-Z0-9._-]{1,63}$/', $code) || ! is_array($parameter)
|
|
|| array_diff(array_keys($parameter), ['value', 'unit', 'conditions']) !== []
|
|
|| array_diff(['value', 'unit', 'conditions'], array_keys($parameter)) !== []) {
|
|
throw new InvalidArgumentException('Cada parâmetro deve conter apenas valor, unidade e condições.');
|
|
}
|
|
if ((! is_string($parameter['value']) && ! is_int($parameter['value'])) || (string) $parameter['value'] === ''
|
|
|| ! is_string($parameter['unit']) || ! preg_match('/^[a-z][a-z0-9._-]{0,31}$/', $parameter['unit'])
|
|
|| ! is_array($parameter['conditions'])) {
|
|
throw new InvalidArgumentException('Reveja o valor, a unidade e as condições do parâmetro.');
|
|
}
|
|
$isNumericValue = preg_match('/^-?\d+(?:\.\d+)?$/', (string) $parameter['value']) === 1;
|
|
if (in_array($parameter['unit'], ['percent', 'mzn', 'amount', 'days', 'count', 'mzn_per_unit', 'per_unit'], true) && ! $isNumericValue) {
|
|
throw new InvalidArgumentException('Esta unidade requer um valor numérico decimal.');
|
|
}
|
|
if ($isNumericValue) {
|
|
$value = BigRational::of((string) $parameter['value']);
|
|
if ($value->isNegative() || ($parameter['unit'] === 'percent' && $value->compareTo(100) > 0)) {
|
|
throw new InvalidArgumentException('Os valores não podem ser negativos; as percentagens devem estar entre 0 e 100.');
|
|
}
|
|
}
|
|
foreach ($parameter['conditions'] as $condition => $expected) {
|
|
if (! is_string($condition) || ! is_string($expected)) {
|
|
throw new InvalidArgumentException('Indique as condições numa lista simples de nomes e valores em texto.');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve only rows made visible by the published-only RLS policy. The method
|
|
* never executes a tax formula and requires each parameter to declare context.
|
|
*
|
|
* @param array<string,string> $context
|
|
* @return list<array<string,mixed>>|array{parameters:list<array<string,mixed>>,diagnostics:list<array<string,mixed>>}
|
|
*/
|
|
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context, bool $diagnostics = false): array
|
|
{
|
|
$start = $this->date($periodStart);
|
|
$end = $this->date($periodEnd);
|
|
if ($end < $start || $start->diff($end)->days > 365) {
|
|
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
|
|
}
|
|
|
|
$rows = DB::table('simulation_tax_rule_versions as rule')
|
|
->join('investment_legal_sources as source', 'source.id', '=', 'rule.legal_source_id')
|
|
->where('rule.component', $component)
|
|
->where('rule.status', 'published')
|
|
->when(! $diagnostics, fn ($query) => $query->whereDate('rule.valid_from', '<=', $periodEnd)
|
|
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart)))
|
|
->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters')
|
|
->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008')
|
|
->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256")))
|
|
->whereColumn('source.version_no', 'rule.source_version_no')
|
|
->when(! $diagnostics, fn ($query) => $query->whereDate('source.valid_from', '<=', $periodStart)
|
|
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd)))
|
|
->select([
|
|
'rule.id', 'rule.rule_code', 'rule.rule_version', 'rule.component', 'rule.scope_key',
|
|
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
|
|
'rule.source_version_no', 'rule.source_sha256',
|
|
'source.title as source_title', 'source.diploma as source_diploma', 'source.article as source_article',
|
|
'source.url as source_url', 'source.validation_status as source_validation_status', 'source.fiscal_acceptance',
|
|
'source.version_no as source_current_version', 'source.valid_from as source_valid_from',
|
|
'source.valid_until as source_valid_until',
|
|
])
|
|
->orderBy('rule.rule_code')->orderBy('rule.rule_version')->limit(500)->get();
|
|
|
|
$versions = array_values($rows->map(function (object $row): array {
|
|
$parameters = is_array($row->parameters)
|
|
? $row->parameters
|
|
: json_decode((string) $row->parameters, true, flags: JSON_THROW_ON_ERROR);
|
|
$definition = is_array($row->rule_definition)
|
|
? $row->rule_definition
|
|
: json_decode((string) $row->rule_definition, true, flags: JSON_THROW_ON_ERROR);
|
|
|
|
return [
|
|
'id' => $row->id,
|
|
'rule_code' => $row->rule_code,
|
|
'rule_version' => (int) $row->rule_version,
|
|
'component' => $row->component,
|
|
'scope_key' => $row->scope_key,
|
|
'status' => 'published',
|
|
'valid_from' => (string) $row->valid_from,
|
|
'valid_until' => $row->valid_until === null ? null : (string) $row->valid_until,
|
|
'rule_definition' => $definition,
|
|
'parameters' => $parameters,
|
|
'source_version_no' => (int) $row->source_version_no,
|
|
'source_sha256' => $row->source_sha256,
|
|
'source' => [
|
|
'title' => $row->source_title,
|
|
'diploma' => $row->source_diploma,
|
|
'article' => $row->source_article,
|
|
'url' => $row->source_url,
|
|
'validation_status' => $row->source_validation_status,
|
|
'fiscal_acceptance' => is_array($row->fiscal_acceptance) ? $row->fiscal_acceptance : json_decode($row->fiscal_acceptance ?? 'null', true),
|
|
'version_no' => (int) $row->source_current_version,
|
|
'valid_from' => (string) $row->source_valid_from,
|
|
'valid_until' => $row->source_valid_until === null ? null : (string) $row->source_valid_until,
|
|
],
|
|
];
|
|
})->all());
|
|
|
|
$parameters = $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
|
|
|
|
$resolvedIva = array_filter($parameters, fn (array $parameter): bool => $parameter['parameter_code'] === 'IVA_GENERAL_RATE' && $parameter['status'] === 'resolved');
|
|
|
|
return $diagnostics ? ['parameters' => $parameters, 'diagnostics' => $resolvedIva !== [] ? [] : (new SimulationIvaDiagnostics)->explain($versions, $periodStart, $periodEnd, $context)] : $parameters;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string,mixed>> $versions
|
|
* @param array<string,string> $context
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
public function resolveAvailableParameters(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
|
|
{
|
|
return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'published');
|
|
}
|
|
|
|
/** Explicit draft preview. Never used by the published-parameter API. */
|
|
public function resolveDraftForQa(array $versions, string $component, string $periodStart, string $periodEnd, array $context): array
|
|
{
|
|
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
|
|
|
return $this->resolveWithStatus($versions, $component, $periodStart, $periodEnd, $context, 'draft');
|
|
}
|
|
|
|
private function resolveWithStatus(array $versions, string $component, string $periodStart, string $periodEnd, array $context, string $requiredStatus): array
|
|
{
|
|
$start = $this->date($periodStart);
|
|
$end = $this->date($periodEnd);
|
|
if ($end < $start || $start->diff($end)->days > 365) {
|
|
throw new InvalidArgumentException('Reveja o período de aplicação indicado.');
|
|
}
|
|
$groups = [];
|
|
|
|
foreach ($versions as $version) {
|
|
if (($version['component'] ?? null) !== $component
|
|
|| ($version['status'] ?? null) !== $requiredStatus
|
|
|| ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|
|
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|
|
|| ! $this->overlaps($version, $start, $end)
|
|
|| ! $this->covers($version['source'], $start) || ! $this->covers($version['source'], $end)
|
|
|| ($version['rule_definition']['language'] ?? null) !== 'investmoz.tax.v1'
|
|
|| ! is_array($version['parameters'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
$coversWholePeriod = $this->covers($version, $start) && $this->covers($version, $end);
|
|
foreach ($version['parameters'] as $code => $parameter) {
|
|
if (! is_string($code) || ! is_array($parameter) || ! is_array($parameter['conditions'] ?? null)) {
|
|
continue;
|
|
}
|
|
$groups[$code] ??= ['matches' => [], 'partial' => false, 'saw_unscoped' => false, 'saw_context_mismatch' => false];
|
|
$conditions = $parameter['conditions'];
|
|
if (! $coversWholePeriod) {
|
|
if ($conditions !== [] && $this->conditionsMatch($conditions, $context) && ($version['scope_key'] ?? '') !== '') {
|
|
$groups[$code]['partial'] = true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
if ($conditions === []) {
|
|
$groups[$code]['saw_unscoped'] = true;
|
|
} elseif (! $this->conditionsMatch($conditions, $context)) {
|
|
$groups[$code]['saw_context_mismatch'] = true;
|
|
} elseif (($version['scope_key'] ?? '') === '') {
|
|
$groups[$code]['saw_unscoped'] = true;
|
|
} else {
|
|
$groups[$code]['matches'][] = [$version, $parameter];
|
|
}
|
|
}
|
|
}
|
|
|
|
$resolved = [];
|
|
foreach ($groups as $code => $group) {
|
|
if ($group['partial']) {
|
|
$state = count($group['matches']) > 0 ? 'indeterminate_overlap' : 'indeterminate_period_change';
|
|
$resolved[] = ['parameter_code' => $code, 'status' => $state, 'value' => null];
|
|
|
|
continue;
|
|
}
|
|
if (count($group['matches']) !== 1) {
|
|
$state = count($group['matches']) > 1
|
|
? 'indeterminate_overlap'
|
|
: ($group['saw_unscoped'] ? 'indeterminate_scope' : ($group['saw_context_mismatch'] ? 'indeterminate_context' : 'indeterminate_missing'));
|
|
$resolved[] = ['parameter_code' => $code, 'status' => $state, 'value' => null];
|
|
|
|
continue;
|
|
}
|
|
[$version, $parameter] = $group['matches'][0];
|
|
if (! isset($parameter['value'], $parameter['unit']) || ! is_string($parameter['unit'])) {
|
|
$resolved[] = ['parameter_code' => $code, 'status' => 'indeterminate_invalid', 'value' => null];
|
|
|
|
continue;
|
|
}
|
|
$resolved[] = [
|
|
'parameter_code' => $code,
|
|
'status' => 'resolved',
|
|
'value' => (string) $parameter['value'],
|
|
'unit' => $parameter['unit'],
|
|
'conditions' => $parameter['conditions'],
|
|
'scope_key' => $version['scope_key'],
|
|
'rule_code' => $version['rule_code'],
|
|
'rule_id' => $version['id'],
|
|
'rule_version' => (int) $version['rule_version'],
|
|
'period' => ['start' => $periodStart, 'end' => $periodEnd],
|
|
'validity' => ['start' => $version['valid_from'], 'end' => $version['valid_until'] ?? null],
|
|
'source' => [
|
|
'title' => $version['source']['title'],
|
|
'diploma' => $version['source']['diploma'],
|
|
'article' => $version['source']['article'],
|
|
'url' => $version['source']['url'],
|
|
'declared_sha256' => $version['source_sha256'],
|
|
],
|
|
];
|
|
}
|
|
|
|
usort($resolved, static fn (array $left, array $right): int => $left['parameter_code'] <=> $right['parameter_code']);
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
/**
|
|
* @param iterable<array<string,mixed>> $publishedVersions Caller must supply only records visible under the applicable access policy.
|
|
* @param array<string,scalar|null> $conditions
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function resolve(iterable $publishedVersions, string $component, string $scopeKey, string $asOf, string $parameterCode, array $conditions = []): array
|
|
{
|
|
$date = $this->date($asOf);
|
|
$matches = [];
|
|
foreach ($publishedVersions as $version) {
|
|
if (($version['status'] ?? null) !== 'published'
|
|
|| ($version['component'] ?? null) !== $component
|
|
|| ($version['scope_key'] ?? null) !== $scopeKey
|
|
|| ! $this->covers($version, $date)
|
|
|| ! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|
|
|| (int) ($version['source_version_no'] ?? 0) !== (int) ($version['source']['version_no'] ?? -1)
|
|
|| ! $this->covers($version['source'], $date)
|
|
|| ! preg_match('/^[a-f0-9]{64}$/D', (string) ($version['source_sha256'] ?? ''))) {
|
|
continue;
|
|
}
|
|
$parameter = $version['parameters'][$parameterCode] ?? null;
|
|
if (! is_array($parameter) || ! isset($parameter['value'], $parameter['unit']) || ! is_string($parameter['unit'])
|
|
|| ! is_array($parameter['conditions'] ?? null) || ! $this->conditionsMatch($parameter['conditions'], $conditions)) {
|
|
continue;
|
|
}
|
|
$matches[] = [$version, $parameter];
|
|
}
|
|
|
|
if (count($matches) !== 1) {
|
|
return ['status' => count($matches) > 1 ? 'indeterminate_overlap' : 'indeterminate_missing', 'value' => null, 'parameter_code' => $parameterCode];
|
|
}
|
|
[$version, $parameter] = $matches[0];
|
|
|
|
return [
|
|
'status' => 'resolved',
|
|
'value' => (string) $parameter['value'],
|
|
'unit' => $parameter['unit'],
|
|
'parameter_code' => $parameterCode,
|
|
'conditions' => $parameter['conditions'],
|
|
'rule_code' => $version['rule_code'],
|
|
'rule_version' => (int) $version['rule_version'],
|
|
'rule_id' => $version['id'],
|
|
'scope_key' => $scopeKey,
|
|
'valid_from' => $version['valid_from'],
|
|
'valid_until' => $version['valid_until'] ?? null,
|
|
'source' => [
|
|
'title' => $version['source']['title'],
|
|
'diploma' => $version['source']['diploma'],
|
|
'article' => $version['source']['article'],
|
|
'url' => $version['source']['url'],
|
|
'declared_sha256' => $version['source_sha256'],
|
|
'validation_status' => $version['source']['validation_status'],
|
|
],
|
|
];
|
|
}
|
|
|
|
/** @param array<string,mixed> $version */
|
|
private function covers(array $version, DateTimeImmutable $date): bool
|
|
{
|
|
$fromValue = $version['valid_from'] ?? null;
|
|
$untilValue = $version['valid_until'] ?? null;
|
|
if (! is_string($fromValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $fromValue)
|
|
|| ($untilValue !== null && (! is_string($untilValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $untilValue)))) {
|
|
return false;
|
|
}
|
|
try {
|
|
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromValue);
|
|
$until = $untilValue === null ? null : DateTimeImmutable::createFromFormat('!Y-m-d', $untilValue);
|
|
if ($from === false || $from->format('Y-m-d') !== $fromValue
|
|
|| ($untilValue !== null && ($until === false || $until->format('Y-m-d') !== $untilValue))) {
|
|
return false;
|
|
}
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
|
|
return $date >= $from && ($until === null || $date <= $until);
|
|
}
|
|
|
|
/** @param array<string,mixed> $version */
|
|
private function overlaps(array $version, DateTimeImmutable $start, DateTimeImmutable $end): bool
|
|
{
|
|
$fromValue = $version['valid_from'] ?? null;
|
|
$untilValue = $version['valid_until'] ?? null;
|
|
if (! is_string($fromValue) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $fromValue)) {
|
|
return false;
|
|
}
|
|
$from = $this->date($fromValue);
|
|
$until = $untilValue === null ? null : (is_string($untilValue) ? $this->date($untilValue) : null);
|
|
|
|
return $from <= $end && ($until === null || $until >= $start);
|
|
}
|
|
|
|
private function date(string $date): DateTimeImmutable
|
|
{
|
|
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
|
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
|
|
throw new InvalidArgumentException('Indique uma data de aplicação válida no formato ano-mês-dia.');
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed>|null $required
|
|
* @param array<string,scalar|null> $actual
|
|
*/
|
|
private function conditionsMatch(?array $required, array $actual): bool
|
|
{
|
|
if ($required === null) {
|
|
return false;
|
|
}
|
|
foreach ($required as $key => $value) {
|
|
if (! array_key_exists($key, $actual) || $actual[$key] !== $value) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|