| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- """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
|