Files
SGF/app/Modules/Services/FiscalRuleExpression.php
2026-11-11 09:00:17 +02:00

206 lines
8.7 KiB
PHP

<?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);
}
}