Files
zkxw/backend/app/services/oa_formula.py
T
2026-07-15 16:20:48 +08:00

282 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OA 表单公式与数据联动(禁止任意 eval)"""
from __future__ import annotations
import ast
import operator
import re
from typing import Any, Dict, List, Optional, Tuple, Union
Number = Union[int, float]
_BIN_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Mod: operator.mod,
}
_CMP_OPS = {
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
}
# 字段引用:{field} 或 field(Ident)
_FIELD_BRACE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
class FormulaError(ValueError):
"""公式非法或求值失败"""
def _to_number(v: Any) -> Number:
if v is None or v == "":
return 0
if isinstance(v, bool):
return int(v)
if isinstance(v, (int, float)):
return v
if isinstance(v, str):
s = v.strip()
if not s:
return 0
try:
if "." in s:
return float(s)
return int(s)
except ValueError as e:
raise FormulaError(f"无法将「{v}」转为数字") from e
raise FormulaError(f"不支持的数值类型: {type(v).__name__}")
def _truthy(v: Any) -> bool:
if v is None:
return False
if isinstance(v, (int, float)):
return v != 0
if isinstance(v, str):
return v.strip() != ""
if isinstance(v, (list, dict)):
return len(v) > 0
return bool(v)
def preprocess_formula(expr: str) -> str:
"""将 {field} 转为合法标识符引用 field。"""
text = (expr or "").strip()
if not text:
raise FormulaError("公式为空")
return _FIELD_BRACE.sub(r"\1", text)
class _SafeEval(ast.NodeVisitor):
def __init__(self, form_data: Dict[str, Any]):
self.form_data = form_data or {}
def visit(self, node): # type: ignore[override]
method = "visit_" + node.__class__.__name__
visitor = getattr(self, method, None)
if visitor is None:
raise FormulaError(f"不支持的语法: {node.__class__.__name__}")
return visitor(node)
def visit_Expression(self, node: ast.Expression):
return self.visit(node.body)
def visit_Constant(self, node: ast.Constant):
if isinstance(node.value, (int, float, str, bool)) or node.value is None:
return node.value
raise FormulaError("不支持的常量类型")
# Py3.9 可能仍有 Num/Str/NameConstant
def visit_Num(self, node): # pragma: no cover
return node.n
def visit_Str(self, node): # pragma: no cover
return node.s
def visit_NameConstant(self, node): # pragma: no cover
return node.value
def visit_Name(self, node: ast.Name):
if not isinstance(node.ctx, ast.Load):
raise FormulaError("仅允许读取字段")
key = node.id
if key not in self.form_data:
# 未填视为 0 / 空:数字上下文由运算再转
return self.form_data.get(key)
return self.form_data.get(key)
def visit_UnaryOp(self, node: ast.UnaryOp):
val = self.visit(node.operand)
if isinstance(node.op, ast.UAdd):
return +_to_number(val)
if isinstance(node.op, ast.USub):
return -_to_number(val)
if isinstance(node.op, ast.Not):
return not _truthy(val)
raise FormulaError("不支持的一元运算")
def visit_BinOp(self, node: ast.BinOp):
op_type = type(node.op)
if op_type not in _BIN_OPS:
raise FormulaError("仅支持 + - * / %")
left = _to_number(self.visit(node.left))
right = _to_number(self.visit(node.right))
if op_type is ast.Div and right == 0:
raise FormulaError("除数不能为 0")
if op_type is ast.Mod and right == 0:
raise FormulaError("模运算除数不能为 0")
return _BIN_OPS[op_type](left, right)
def visit_Compare(self, node: ast.Compare):
left = self.visit(node.left)
for op, comp in zip(node.ops, node.comparators):
op_type = type(op)
if op_type not in _CMP_OPS:
raise FormulaError("不支持的比较运算")
right = self.visit(comp)
# 两侧能数字则按数字比
try:
l2, r2 = _to_number(left), _to_number(right)
ok = _CMP_OPS[op_type](l2, r2)
except FormulaError:
ok = _CMP_OPS[op_type](left, right)
if not ok:
return False
left = right
return True
def visit_BoolOp(self, node: ast.BoolOp):
if isinstance(node.op, ast.And):
val = True
for v in node.values:
val = _truthy(self.visit(v))
if not val:
return False
return True
if isinstance(node.op, ast.Or):
for v in node.values:
if _truthy(self.visit(v)):
return True
return False
raise FormulaError("不支持的逻辑运算")
def visit_IfExp(self, node: ast.IfExp):
return self.visit(node.body) if _truthy(self.visit(node.test)) else self.visit(node.orelse)
def visit_Call(self, node: ast.Call):
if not isinstance(node.func, ast.Name):
raise FormulaError("仅支持白名单函数")
fname = node.func.id.upper()
if node.keywords:
raise FormulaError("函数不支持关键字参数")
args = [self.visit(a) for a in node.args]
if fname == "IF":
if len(args) != 3:
raise FormulaError("IF 需要 3 个参数")
return args[1] if _truthy(args[0]) else args[2]
if fname == "SUM":
total: Number = 0
for a in args:
if isinstance(a, list):
for item in a:
total = total + _to_number(item)
else:
total = total + _to_number(a)
return total
if fname == "ABS":
if len(args) != 1:
raise FormulaError("ABS 需要 1 个参数")
return abs(_to_number(args[0]))
raise FormulaError(f"函数未开放: {node.func.id}")
def visit_List(self, node: ast.List):
return [self.visit(e) for e in node.elts]
def eval_formula(expr: str, form_data: Optional[Dict[str, Any]] = None) -> Any:
text = preprocess_formula(expr)
try:
tree = ast.parse(text, mode="eval")
except SyntaxError as e:
raise FormulaError(f"公式语法错误: {e.msg}") from e
return _SafeEval(form_data or {}).visit(tree)
def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
if isinstance(form_schema, list):
return [r for r in form_schema if isinstance(r, dict)]
if isinstance(form_schema, dict):
rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
if isinstance(rules, list):
return [r for r in rules if isinstance(r, dict)]
return []
def _walk_rules(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
for rule in rules:
out.append(rule)
children = rule.get("children") or []
if isinstance(children, list):
_walk_rules([c for c in children if isinstance(c, dict)], out)
def apply_formulas_and_linkages(
schema: Any, form_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
按 schema 中字段 props.formula / props.linkage 回填 form_data。
linkage 示例: { "when": "{days} > 3", "set": { "needGm": true } }
或列表 form。
"""
data: Dict[str, Any] = dict(form_data or {})
flat: List[Dict[str, Any]] = []
_walk_rules(_extract_rules(schema), flat)
# 多轮公式:处理依赖(有上限)
for _ in range(5):
changed = False
for rule in flat:
field = rule.get("field")
if not field:
continue
props = rule.get("props") or {}
formula = props.get("formula") or rule.get("formula")
if formula:
try:
val = eval_formula(str(formula), data)
except FormulaError:
raise
if data.get(field) != val:
data[field] = val
changed = True
if not changed:
break
for rule in flat:
props = rule.get("props") or {}
linkages = props.get("linkage") or rule.get("linkage")
if not linkages:
continue
items = linkages if isinstance(linkages, list) else [linkages]
for link in items:
if not isinstance(link, dict):
continue
when = link.get("when") or link.get("condition")
sets = link.get("set") or link.get("assign") or {}
if when is None:
continue
try:
ok = _truthy(eval_formula(str(when), data))
except FormulaError:
continue
if ok and isinstance(sets, dict):
for k, v in sets.items():
data[k] = v
return data