oa_formula.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """OA 表单公式与数据联动(禁止任意 eval)"""
  2. from __future__ import annotations
  3. import ast
  4. import operator
  5. import re
  6. from typing import Any, Dict, List, Optional, Tuple, Union
  7. Number = Union[int, float]
  8. _BIN_OPS = {
  9. ast.Add: operator.add,
  10. ast.Sub: operator.sub,
  11. ast.Mult: operator.mul,
  12. ast.Div: operator.truediv,
  13. ast.Mod: operator.mod,
  14. }
  15. _CMP_OPS = {
  16. ast.Eq: operator.eq,
  17. ast.NotEq: operator.ne,
  18. ast.Lt: operator.lt,
  19. ast.LtE: operator.le,
  20. ast.Gt: operator.gt,
  21. ast.GtE: operator.ge,
  22. }
  23. # 字段引用:{field} 或 field(Ident)
  24. _FIELD_BRACE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
  25. class FormulaError(ValueError):
  26. """公式非法或求值失败"""
  27. def _to_number(v: Any) -> Number:
  28. if v is None or v == "":
  29. return 0
  30. if isinstance(v, bool):
  31. return int(v)
  32. if isinstance(v, (int, float)):
  33. return v
  34. if isinstance(v, str):
  35. s = v.strip()
  36. if not s:
  37. return 0
  38. try:
  39. if "." in s:
  40. return float(s)
  41. return int(s)
  42. except ValueError as e:
  43. raise FormulaError(f"无法将「{v}」转为数字") from e
  44. raise FormulaError(f"不支持的数值类型: {type(v).__name__}")
  45. def _truthy(v: Any) -> bool:
  46. if v is None:
  47. return False
  48. if isinstance(v, (int, float)):
  49. return v != 0
  50. if isinstance(v, str):
  51. return v.strip() != ""
  52. if isinstance(v, (list, dict)):
  53. return len(v) > 0
  54. return bool(v)
  55. def preprocess_formula(expr: str) -> str:
  56. """将 {field} 转为合法标识符引用 field。"""
  57. text = (expr or "").strip()
  58. if not text:
  59. raise FormulaError("公式为空")
  60. return _FIELD_BRACE.sub(r"\1", text)
  61. class _SafeEval(ast.NodeVisitor):
  62. def __init__(self, form_data: Dict[str, Any]):
  63. self.form_data = form_data or {}
  64. def visit(self, node): # type: ignore[override]
  65. method = "visit_" + node.__class__.__name__
  66. visitor = getattr(self, method, None)
  67. if visitor is None:
  68. raise FormulaError(f"不支持的语法: {node.__class__.__name__}")
  69. return visitor(node)
  70. def visit_Expression(self, node: ast.Expression):
  71. return self.visit(node.body)
  72. def visit_Constant(self, node: ast.Constant):
  73. if isinstance(node.value, (int, float, str, bool)) or node.value is None:
  74. return node.value
  75. raise FormulaError("不支持的常量类型")
  76. # Py3.9 可能仍有 Num/Str/NameConstant
  77. def visit_Num(self, node): # pragma: no cover
  78. return node.n
  79. def visit_Str(self, node): # pragma: no cover
  80. return node.s
  81. def visit_NameConstant(self, node): # pragma: no cover
  82. return node.value
  83. def visit_Name(self, node: ast.Name):
  84. if not isinstance(node.ctx, ast.Load):
  85. raise FormulaError("仅允许读取字段")
  86. key = node.id
  87. if key not in self.form_data:
  88. # 未填视为 0 / 空:数字上下文由运算再转
  89. return self.form_data.get(key)
  90. return self.form_data.get(key)
  91. def visit_UnaryOp(self, node: ast.UnaryOp):
  92. val = self.visit(node.operand)
  93. if isinstance(node.op, ast.UAdd):
  94. return +_to_number(val)
  95. if isinstance(node.op, ast.USub):
  96. return -_to_number(val)
  97. if isinstance(node.op, ast.Not):
  98. return not _truthy(val)
  99. raise FormulaError("不支持的一元运算")
  100. def visit_BinOp(self, node: ast.BinOp):
  101. op_type = type(node.op)
  102. if op_type not in _BIN_OPS:
  103. raise FormulaError("仅支持 + - * / %")
  104. left = _to_number(self.visit(node.left))
  105. right = _to_number(self.visit(node.right))
  106. if op_type is ast.Div and right == 0:
  107. raise FormulaError("除数不能为 0")
  108. if op_type is ast.Mod and right == 0:
  109. raise FormulaError("模运算除数不能为 0")
  110. return _BIN_OPS[op_type](left, right)
  111. def visit_Compare(self, node: ast.Compare):
  112. left = self.visit(node.left)
  113. for op, comp in zip(node.ops, node.comparators):
  114. op_type = type(op)
  115. if op_type not in _CMP_OPS:
  116. raise FormulaError("不支持的比较运算")
  117. right = self.visit(comp)
  118. # 两侧能数字则按数字比
  119. try:
  120. l2, r2 = _to_number(left), _to_number(right)
  121. ok = _CMP_OPS[op_type](l2, r2)
  122. except FormulaError:
  123. ok = _CMP_OPS[op_type](left, right)
  124. if not ok:
  125. return False
  126. left = right
  127. return True
  128. def visit_BoolOp(self, node: ast.BoolOp):
  129. if isinstance(node.op, ast.And):
  130. val = True
  131. for v in node.values:
  132. val = _truthy(self.visit(v))
  133. if not val:
  134. return False
  135. return True
  136. if isinstance(node.op, ast.Or):
  137. for v in node.values:
  138. if _truthy(self.visit(v)):
  139. return True
  140. return False
  141. raise FormulaError("不支持的逻辑运算")
  142. def visit_IfExp(self, node: ast.IfExp):
  143. return self.visit(node.body) if _truthy(self.visit(node.test)) else self.visit(node.orelse)
  144. def visit_Call(self, node: ast.Call):
  145. if not isinstance(node.func, ast.Name):
  146. raise FormulaError("仅支持白名单函数")
  147. fname = node.func.id.upper()
  148. if node.keywords:
  149. raise FormulaError("函数不支持关键字参数")
  150. args = [self.visit(a) for a in node.args]
  151. if fname == "IF":
  152. if len(args) != 3:
  153. raise FormulaError("IF 需要 3 个参数")
  154. return args[1] if _truthy(args[0]) else args[2]
  155. if fname == "SUM":
  156. total: Number = 0
  157. for a in args:
  158. if isinstance(a, list):
  159. for item in a:
  160. total = total + _to_number(item)
  161. else:
  162. total = total + _to_number(a)
  163. return total
  164. if fname == "ABS":
  165. if len(args) != 1:
  166. raise FormulaError("ABS 需要 1 个参数")
  167. return abs(_to_number(args[0]))
  168. raise FormulaError(f"函数未开放: {node.func.id}")
  169. def visit_List(self, node: ast.List):
  170. return [self.visit(e) for e in node.elts]
  171. def eval_formula(expr: str, form_data: Optional[Dict[str, Any]] = None) -> Any:
  172. text = preprocess_formula(expr)
  173. try:
  174. tree = ast.parse(text, mode="eval")
  175. except SyntaxError as e:
  176. raise FormulaError(f"公式语法错误: {e.msg}") from e
  177. return _SafeEval(form_data or {}).visit(tree)
  178. def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
  179. if isinstance(form_schema, list):
  180. return [r for r in form_schema if isinstance(r, dict)]
  181. if isinstance(form_schema, dict):
  182. rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
  183. if isinstance(rules, list):
  184. return [r for r in rules if isinstance(r, dict)]
  185. return []
  186. def _walk_rules(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
  187. for rule in rules:
  188. out.append(rule)
  189. children = rule.get("children") or []
  190. if isinstance(children, list):
  191. _walk_rules([c for c in children if isinstance(c, dict)], out)
  192. def apply_formulas_and_linkages(
  193. schema: Any, form_data: Optional[Dict[str, Any]] = None
  194. ) -> Dict[str, Any]:
  195. """
  196. 按 schema 中字段 props.formula / props.linkage 回填 form_data。
  197. linkage 示例: { "when": "{days} > 3", "set": { "needGm": true } }
  198. 或列表 form。
  199. """
  200. data: Dict[str, Any] = dict(form_data or {})
  201. flat: List[Dict[str, Any]] = []
  202. _walk_rules(_extract_rules(schema), flat)
  203. # 多轮公式:处理依赖(有上限)
  204. for _ in range(5):
  205. changed = False
  206. for rule in flat:
  207. field = rule.get("field")
  208. if not field:
  209. continue
  210. props = rule.get("props") or {}
  211. formula = props.get("formula") or rule.get("formula")
  212. if formula:
  213. try:
  214. val = eval_formula(str(formula), data)
  215. except FormulaError:
  216. raise
  217. if data.get(field) != val:
  218. data[field] = val
  219. changed = True
  220. if not changed:
  221. break
  222. for rule in flat:
  223. props = rule.get("props") or {}
  224. linkages = props.get("linkage") or rule.get("linkage")
  225. if not linkages:
  226. continue
  227. items = linkages if isinstance(linkages, list) else [linkages]
  228. for link in items:
  229. if not isinstance(link, dict):
  230. continue
  231. when = link.get("when") or link.get("condition")
  232. sets = link.get("set") or link.get("assign") or {}
  233. if when is None:
  234. continue
  235. try:
  236. ok = _truthy(eval_formula(str(when), data))
  237. except FormulaError:
  238. continue
  239. if ok and isinstance(sets, dict):
  240. for k, v in sets.items():
  241. data[k] = v
  242. return data