portal_publico/portal_api/dashboard_contabil/formula.py

105 lines
4.6 KiB
Python

"""Interpretador restrito de fórmula de indicador personalizado (Dashboard
Contábil, ver `IndicadorContabilDefinicao.formula` em models.py) — função
pura, mesmo espírito de `regras.py`/`indicadores.py`: não toca no ORM,
recebe os valores já resolvidos.
Nunca usa `eval()`/`compile()` puro (a fórmula é digitada pelo contador,
input de usuário) — `ast.parse(..., mode="eval")` gera uma árvore e
`_avalia()` só anda por um allowlist restrito de nós (`BinOp` com
+-*/, `UnaryOp` com +-, número literal, nome de componente). Qualquer outro
nó (chamada de função, atributo, subscrição, comparação, etc.) levanta
`FormulaInvalidaError` antes de qualquer coisa ser executada."""
from __future__ import annotations
import ast
import operator
from decimal import Decimal, InvalidOperation
_OPERADORES_BINARIOS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
_OPERADORES_UNARIOS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
class FormulaInvalidaError(Exception):
pass
def _avalia_no(node: ast.AST, valores: dict[str, Decimal | None]) -> Decimal | None:
if isinstance(node, ast.Expression):
return _avalia_no(node.body, valores)
if isinstance(node, ast.BinOp):
operador = _OPERADORES_BINARIOS.get(type(node.op))
if operador is None:
raise FormulaInvalidaError("Operador não permitido na fórmula (só +, -, * e / são aceitos).")
esquerda = _avalia_no(node.left, valores)
direita = _avalia_no(node.right, valores)
if esquerda is None or direita is None:
return None
if isinstance(node.op, ast.Div) and direita == 0:
return None
return operador(esquerda, direita)
if isinstance(node, ast.UnaryOp):
operador = _OPERADORES_UNARIOS.get(type(node.op))
if operador is None:
raise FormulaInvalidaError("Operador não permitido na fórmula (só +, -, * e / são aceitos).")
valor = _avalia_no(node.operand, valores)
return None if valor is None else operador(valor)
if isinstance(node, ast.Constant):
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
raise FormulaInvalidaError("A fórmula só pode conter números, não pode ter texto.")
try:
return Decimal(str(node.value))
except InvalidOperation as exc:
raise FormulaInvalidaError("Número inválido na fórmula.") from exc
if isinstance(node, ast.Name):
if node.id not in valores:
raise FormulaInvalidaError(f'Componente "{node.id}" não existe nesta fórmula.')
return valores[node.id]
raise FormulaInvalidaError("A fórmula contém uma operação não permitida (só +, -, *, / e parênteses).")
def avalia_formula(expressao: str, valores: dict[str, Decimal | None]) -> Decimal | None:
"""Avalia `expressao` (ex. "(ativo_circulante - estoques) / passivo_circulante")
contra os valores já resolvidos dos componentes (`valores`, chave ->
Decimal ou None). Se qualquer nome referenciado tiver valor `None`
("indisponível") em qualquer ponto da árvore, ou a fórmula fizer uma
divisão por zero, o resultado inteiro é `None` — nunca uma exceção nem
um 0 mascarando indisponibilidade (mesmo espírito de `IndicadoresFinanceiros`,
ver CLAUDE.md do pacote). Levanta `FormulaInvalidaError` só quando a
fórmula em si é inválida (sintaxe, operador não permitido, componente
inexistente) — isso sempre deve ser pego na hora de salvar a definição,
não durante o cálculo de uma apuração."""
expressao = (expressao or "").strip()
if not expressao:
raise FormulaInvalidaError("Informe a fórmula do indicador.")
try:
arvore = ast.parse(expressao, mode="eval")
except SyntaxError as exc:
raise FormulaInvalidaError(f"Fórmula inválida: {exc.msg}.") from exc
return _avalia_no(arvore, valores)
def valida_formula(expressao: str, chaves_disponiveis: set[str]) -> None:
"""Validação "a seco" na hora de salvar a definição — roda a mesma
árvore contra valores fictícios (todo componente = 1) só pra garantir
que a fórmula é sintaticamente válida e só referencia nomes
conhecidos, sem se importar com o resultado numérico em si (`None`
também seria um resultado "válido" durante o cálculo real, ex. divisão
por zero, então não dá pra usar o valor de retorno aqui pra decidir
nada)."""
valores_ficticios = {chave: Decimal(1) for chave in chaves_disponiveis}
avalia_formula(expressao, valores_ficticios)