commit melhorias sgf
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Services;
|
||||
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\RoundingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** A deliberately small, data-only expression language for reviewed tax-rule drafts. */
|
||||
final class FiscalRuleExpression
|
||||
{
|
||||
private const LANGUAGE = 'investmoz.tax.v1';
|
||||
|
||||
private const ROUNDING = [
|
||||
'UNNECESSARY' => RoundingMode::Unnecessary,
|
||||
'UP' => RoundingMode::Up,
|
||||
'DOWN' => RoundingMode::Down,
|
||||
'CEILING' => RoundingMode::Ceiling,
|
||||
'FLOOR' => RoundingMode::Floor,
|
||||
'HALF_UP' => RoundingMode::HalfUp,
|
||||
'HALF_DOWN' => RoundingMode::HalfDown,
|
||||
'HALF_CEILING' => RoundingMode::HalfCeiling,
|
||||
'HALF_FLOOR' => RoundingMode::HalfFloor,
|
||||
'HALF_EVEN' => RoundingMode::HalfEven,
|
||||
];
|
||||
|
||||
/** @param array<string,mixed> $definition */
|
||||
public function validate(array $definition): void
|
||||
{
|
||||
$this->topologicalOrder($definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a validated expression. Missing values are reported as indeterminate;
|
||||
* callers must supply versioned parameters and provenance explicitly.
|
||||
*
|
||||
* @param array<string,mixed> $definition
|
||||
* @param array<string,string|int> $inputs
|
||||
* @param array<string,string|int> $parameters
|
||||
* @return array{status:string,result:?string,missing:list<string>,steps:list<array<string,mixed>>}
|
||||
*/
|
||||
public function evaluate(array $definition, array $inputs, array $parameters): array
|
||||
{
|
||||
$order = $this->topologicalOrder($definition);
|
||||
$nodes = $definition['nodes'];
|
||||
$values = [];
|
||||
$missing = [];
|
||||
$steps = [];
|
||||
|
||||
foreach ($order as $id) {
|
||||
$node = $nodes[$id];
|
||||
$op = $node['op'];
|
||||
if ($op === 'input' || $op === 'parameter') {
|
||||
$key = $node['key'];
|
||||
$source = $op === 'input' ? $inputs : $parameters;
|
||||
if (! array_key_exists($key, $source)) {
|
||||
$missing[] = $op.':'.$key;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'key' => $key, 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->number($source[$key]);
|
||||
} elseif ($op === 'literal') {
|
||||
$value = $this->number($node['value']);
|
||||
} else {
|
||||
$args = array_values(array_map(static fn (string $ref): ?BigRational => $values[$ref] ?? null, $node['args'] ?? []));
|
||||
if (in_array(null, $args, true)) {
|
||||
$values[$id] = null;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'args' => $node['args'] ?? [], 'value' => null];
|
||||
|
||||
continue;
|
||||
}
|
||||
$value = $this->operate($op, $args, $node);
|
||||
}
|
||||
$values[$id] = $value;
|
||||
$steps[] = ['id' => $id, 'op' => $op, 'value' => (string) $value];
|
||||
}
|
||||
|
||||
$result = $values[$definition['result']] ?? null;
|
||||
|
||||
return [
|
||||
'status' => $missing === [] && $result !== null ? 'determinate' : 'indeterminate',
|
||||
'result' => $result === null ? null : (string) $result,
|
||||
'missing' => array_values(array_unique($missing)),
|
||||
'steps' => $steps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $definition
|
||||
* @return list<string>
|
||||
*/
|
||||
private function topologicalOrder(array $definition): array
|
||||
{
|
||||
if (($definition['language'] ?? null) !== self::LANGUAGE || ! is_array($definition['nodes'] ?? null) || ! is_string($definition['result'] ?? null)) {
|
||||
throw new InvalidArgumentException('O formato da expressão não é suportado.');
|
||||
}
|
||||
$nodes = $definition['nodes'];
|
||||
if ($nodes === [] || count($nodes) > 100) {
|
||||
throw new InvalidArgumentException('A expressão deve conter entre 1 e 100 elementos.');
|
||||
}
|
||||
|
||||
foreach ($nodes as $id => $node) {
|
||||
if (! is_string($id) || ! preg_match('/^[a-z][a-z0-9_]{0,47}$/', $id) || ! is_array($node) || ! is_string($node['op'] ?? null)) {
|
||||
throw new InvalidArgumentException('Reveja o elemento indicado na expressão.');
|
||||
}
|
||||
$op = $node['op'];
|
||||
$allowedFields = match ($op) {
|
||||
'input', 'parameter' => ['op', 'key'],
|
||||
'literal' => ['op', 'value'],
|
||||
'add', 'subtract', 'multiply', 'divide', 'min', 'max' => ['op', 'args'],
|
||||
'round' => ['op', 'args', 'scale', 'mode'],
|
||||
default => throw new InvalidArgumentException('Esta operação não é suportada na expressão.'),
|
||||
};
|
||||
if (array_diff(array_keys($node), $allowedFields) !== [] || array_diff($allowedFields, array_keys($node)) !== []) {
|
||||
throw new InvalidArgumentException('Há campos em falta ou não suportados num elemento da expressão.');
|
||||
}
|
||||
if (in_array($op, ['input', 'parameter'], true) && (! is_string($node['key']) || ! preg_match('/^[a-z][a-z0-9_.-]{0,79}$/', $node['key']))) {
|
||||
throw new InvalidArgumentException('Reveja o identificador da entrada ou do parâmetro.');
|
||||
}
|
||||
if ($op === 'literal') {
|
||||
$this->number($node['value']);
|
||||
}
|
||||
if (isset($node['args'])) {
|
||||
if (! is_array($node['args'])) {
|
||||
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
|
||||
}
|
||||
$count = count($node['args']);
|
||||
$validCount = $op === 'round' ? $count === 1 : ($op === 'subtract' || $op === 'divide' ? $count === 2 : $count >= 2 && $count <= 20);
|
||||
if (! $validCount || count(array_filter($node['args'], 'is_string')) !== $count) {
|
||||
throw new InvalidArgumentException('Reveja os argumentos da expressão.');
|
||||
}
|
||||
}
|
||||
if ($op === 'round' && (! is_int($node['scale']) || $node['scale'] < 0 || $node['scale'] > 12 || ! isset(self::ROUNDING[$node['mode']]))) {
|
||||
throw new InvalidArgumentException('Reveja a configuração de arredondamento.');
|
||||
}
|
||||
}
|
||||
|
||||
if (! array_key_exists($definition['result'], $nodes)) {
|
||||
throw new InvalidArgumentException('O resultado da expressão refere um elemento inexistente.');
|
||||
}
|
||||
$state = [];
|
||||
$order = [];
|
||||
$visit = function (string $id) use (&$visit, &$state, &$order, $nodes): void {
|
||||
if (($state[$id] ?? 0) === 1) {
|
||||
throw new InvalidArgumentException('A expressão contém uma dependência circular.');
|
||||
}
|
||||
if (($state[$id] ?? 0) === 2) {
|
||||
return;
|
||||
}
|
||||
$state[$id] = 1;
|
||||
foreach ($nodes[$id]['args'] ?? [] as $dependency) {
|
||||
if (! is_string($dependency) || ! array_key_exists($dependency, $nodes)) {
|
||||
throw new InvalidArgumentException('A expressão refere um elemento inexistente.');
|
||||
}
|
||||
$visit($dependency);
|
||||
}
|
||||
$state[$id] = 2;
|
||||
$order[] = $id;
|
||||
};
|
||||
foreach (array_keys($nodes) as $id) {
|
||||
$visit($id);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<BigRational> $args
|
||||
* @param array<string,mixed> $node
|
||||
*/
|
||||
private function operate(string $op, array $args, array $node): BigRational
|
||||
{
|
||||
if ($op === 'round') {
|
||||
return BigRational::of((string) $args[0]->toScale($node['scale'], self::ROUNDING[$node['mode']]));
|
||||
}
|
||||
if ($op === 'add') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->plus($value), $args[0]);
|
||||
}
|
||||
if ($op === 'subtract') {
|
||||
return $args[0]->minus($args[1]);
|
||||
}
|
||||
if ($op === 'multiply') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => $carry->multipliedBy($value), $args[0]);
|
||||
}
|
||||
if ($op === 'divide') {
|
||||
return $args[0]->dividedBy($args[1]);
|
||||
}
|
||||
if ($op === 'min' || $op === 'max') {
|
||||
return array_reduce(array_slice($args, 1), static fn (BigRational $carry, BigRational $value): BigRational => ($op === 'min' ? $carry->compareTo($value) <= 0 : $carry->compareTo($value) >= 0) ? $carry : $value, $args[0]);
|
||||
}
|
||||
throw new InvalidArgumentException('Esta operação não é suportada na expressão.');
|
||||
}
|
||||
|
||||
private function number(mixed $value): BigRational
|
||||
{
|
||||
if ((! is_string($value) && ! is_int($value)) || ! preg_match('/^-?\d{1,24}(?:\.\d{1,8})?$/D', (string) $value)) {
|
||||
throw new InvalidArgumentException('Introduza valores numéricos decimais ou inteiros.');
|
||||
}
|
||||
|
||||
return BigRational::of((string) $value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user