#!/usr/bin/env python # -*- coding: utf-8 -*- """ 表达式求值 MVP(对齐 JNPF expression 子集) - #{param} 参数占位 - sum/avg/max/min/count(数据集别名.字段) - sum/avg/max/min/count(A1:B2) 单元格区域 - A1、$B2 单元格引用 - 四则运算(仅数字) """ from __future__ import annotations import ast import copy import operator import re from typing import Any, Dict, List, Optional, Tuple from online_dev.report_manager.engine.column_layout import parse_cell_range from online_dev.report_manager.engine.preview_mvp import _replace_params_in_value def _get_nested_value(row: Dict[str, Any], field: str) -> Any: if not field: return None if field in row: return row[field] parts = field.split(".") cur: Any = row for p in parts: if isinstance(cur, dict) and p in cur: cur = cur[p] else: return None return cur _AGG_FUNCS = ("sum", "avg", "max", "min", "count") _AGG_PATTERN = re.compile( r"(sum|avg|max|min|count)\s*\(\s*([a-zA-Z_][\w.]*)\s*\)", re.IGNORECASE, ) _CELL_RANGE_AGG_PATTERN = re.compile( r"(sum|avg|max|min|count)\s*\(\s*([A-Za-z]+\d+)\s*:\s*([A-Za-z]+\d+)\s*\)", re.IGNORECASE, ) _CELL_REF_PATTERN = re.compile( r"(? int: col = col.upper() n = 0 for ch in col: n = n * 26 + (ord(ch) - ord("A") + 1) return n - 1 def _parse_a1(addr: str) -> Optional[Tuple[int, int]]: """A1 / $B2 -> (row, col) 0-based""" if not addr: return None m = re.match(r"^\$?([A-Za-z]+)(\d+)$", addr.strip()) if not m: return None return int(m.group(2)) - 1, _col_letter_to_index(m.group(1)) def _cell_to_numeric(value: Any) -> Optional[float]: if value is None or value == "": return None if isinstance(value, (int, float)): return float(value) try: return float(str(value).strip()) except (TypeError, ValueError): return None def _get_cell_value( snapshot: Dict[str, Any], sheet_id: str, row: int, col: int, ) -> Any: sheets = snapshot.get("sheets") or {} sheet = sheets.get(sheet_id) or {} cell_data = sheet.get("cellData") or {} row_obj = cell_data.get(str(row)) or {} cell = row_obj.get(str(col)) or {} return cell.get("v") def _collect_cells_in_range( snapshot: Dict[str, Any], sheet_id: str, start: str, end: str, ) -> List[float]: bounds = parse_cell_range(f"{start}:{end}") if not bounds: return [] r0, r1, c0, c1 = bounds values: List[float] = [] for r in range(r0, r1 + 1): for c in range(c0, c1 + 1): num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, r, c)) if num is not None: values.append(num) return values def _aggregate_cell_range( func: str, snapshot: Dict[str, Any], sheet_id: str, start: str, end: str, ) -> float: values = _collect_cells_in_range(snapshot, sheet_id, start, end) if not values: return 0 f = func.lower() if f == "sum": return sum(values) if f == "avg": return sum(values) / len(values) if f == "max": return max(values) if f == "min": return min(values) if f == "count": return float(len(values)) return 0 def _replace_cell_range_aggregates( expr: str, snapshot: Dict[str, Any], sheet_id: str, ) -> str: def repl(m: re.Match) -> str: val = _aggregate_cell_range( m.group(1), snapshot, sheet_id, m.group(2), m.group(3) ) if val == int(val): return str(int(val)) return str(round(val, 8)) return _CELL_RANGE_AGG_PATTERN.sub(repl, expr) def _replace_cell_refs( expr: str, snapshot: Dict[str, Any], sheet_id: str, ) -> str: def repl(m: re.Match) -> str: pos = _parse_a1(f"{m.group(2)}{m.group(3)}") if not pos: return m.group(0) row, col = pos num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, row, col)) if num is None: return "0" if num == int(num): return str(int(num)) return str(num) return _CELL_REF_PATTERN.sub(repl, expr) _SAFE_OPS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.USub: operator.neg, } def _parse_dataset_field(ref: str) -> Tuple[Optional[str], str]: if "." in ref: parts = ref.split(".", 1) return parts[0], parts[1] return None, ref def _aggregate(func: str, datasets: Dict[str, List[Any]], ref: str) -> float: alias, field = _parse_dataset_field(ref) if not alias or not field: return 0 rows = datasets.get(alias) or [] values: List[float] = [] for row in rows: if not isinstance(row, dict): continue v = _get_nested_value(row, field) if v is None or v == "": continue try: values.append(float(v)) except (TypeError, ValueError): if func.lower() == "count": values.append(1.0) if not values: return 0 f = func.lower() if f == "sum": return sum(values) if f == "avg": return sum(values) / len(values) if f == "max": return max(values) if f == "min": return min(values) if f == "count": return float(len(values)) return 0 def _replace_aggregates(expr: str, datasets: Dict[str, List[Any]]) -> str: def repl(m: re.Match) -> str: val = _aggregate(m.group(1), datasets, m.group(2)) if val == int(val): return str(int(val)) return str(round(val, 8)) return _AGG_PATTERN.sub(repl, expr) def _safe_eval_numeric(expr: str) -> Any: expr = (expr or "").strip() if not expr: return "" node = ast.parse(expr, mode="eval") return _eval_node(node.body) def _eval_node(node: ast.AST) -> float: if isinstance(node, ast.Constant): if isinstance(node.value, (int, float)): return float(node.value) raise ValueError("non-numeric constant") if isinstance(node, ast.BinOp): op = _SAFE_OPS.get(type(node.op)) if not op: raise ValueError("unsupported operator") return op(_eval_node(node.left), _eval_node(node.right)) if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): return -_eval_node(node.operand) raise ValueError("unsupported expression") def evaluate_formula( formula: str, params: Dict[str, Any], datasets: Dict[str, List[Any]], snapshot: Optional[Dict[str, Any]] = None, sheet_id: str = "sheet1", ) -> str: """ 求值表达式,返回可写入单元格的字符串结果。 支持前缀 '=' 或裸公式。 """ raw = (formula or "").strip() if not raw: return "" if raw.startswith("="): raw = raw[1:].strip() text = _replace_params_in_value(raw, params) if snapshot: text = _replace_cell_range_aggregates(text, snapshot, sheet_id) text = _replace_aggregates(text, datasets) if snapshot: text = _replace_cell_refs(text, snapshot, sheet_id) try: result = _safe_eval_numeric(text) if result == int(result): return str(int(result)) return str(result) except Exception: return text def _extract_formula_cell_refs(formula: str) -> List[Tuple[int, int]]: """从公式中提取 A1 风格单元格引用(0-based row, col)""" raw = (formula or "").strip() if raw.startswith("="): raw = raw[1:].strip() refs: List[Tuple[int, int]] = [] seen: set = set() for m in _CELL_REF_PATTERN.finditer(raw): pos = _parse_a1(f"{m.group(2)}{m.group(3)}") if pos and pos not in seen: seen.add(pos) refs.append(pos) return refs def _sort_expression_targets( targets: List[Tuple[str, int, int, str]], ) -> Tuple[List[Tuple[str, int, int, str]], bool]: """ 按单元格引用依赖拓扑排序表达式目标。 返回 (排序后列表, 是否存在环)。 """ if len(targets) <= 1: return targets, False expr_keys = {(s, r, c) for s, r, c, _ in targets} deps: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = { k: set() for k in expr_keys } for sheet_id, row, col, formula in targets: key = (sheet_id, row, col) for ref_row, ref_col in _extract_formula_cell_refs(formula): dep_key = (sheet_id, ref_row, ref_col) if dep_key in expr_keys and dep_key != key: deps[key].add(dep_key) in_degree = {k: len(deps[k]) for k in expr_keys} children: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = { k: set() for k in expr_keys } for key, dep_set in deps.items(): for dep in dep_set: children[dep].add(key) queue = sorted(k for k in expr_keys if in_degree[k] == 0) order: List[Tuple[str, int, int]] = [] while queue: key = queue.pop(0) order.append(key) for child in sorted(children[key]): in_degree[child] -= 1 if in_degree[child] == 0: queue.append(child) has_cycle = len(order) != len(expr_keys) if has_cycle: return targets, True key_to_target = {(s, r, c): (s, r, c, f) for s, r, c, f in targets} return [key_to_target[k] for k in order], False def _write_expression_cell( sheets: Dict[str, Any], sheet_id: str, row: int, col: int, formula: str, value: str, ) -> None: sheet = sheets.get(sheet_id) if not sheet: return cell_data = sheet.setdefault("cellData", {}) row_data = cell_data.setdefault(str(row), {}) cell_obj = row_data.setdefault(str(col), {}) cell_obj["v"] = value display_formula = formula.strip() if display_formula and not display_formula.startswith("="): display_formula = f"={display_formula}" if display_formula: cell_obj["f"] = display_formula cell_obj["t"] = 4 custom = cell_obj.get("custom") or {} custom["type"] = "expression" custom["field"] = formula custom["formula"] = display_formula or formula cell_obj["custom"] = custom def _collect_expression_targets( cells_meta: Dict[str, Any], snapshot: Dict[str, Any], ) -> List[Tuple[str, int, int, str]]: """返回 (sheet_id, row, col, formula)""" targets: List[Tuple[str, int, int, str]] = [] seen: set = set() for cell in cells_meta.get("cells") or []: if cell.get("type") != "expression": continue sheet_id = cell.get("sheet", "sheet1") row = int(cell.get("row", 0)) col = int(cell.get("col", 0)) custom = cell.get("custom") or {} formula = ( custom.get("field") or custom.get("value") or custom.get("formula") or "" ) key = (sheet_id, row, col) if key not in seen: seen.add(key) targets.append((sheet_id, row, col, str(formula))) sheets = snapshot.get("sheets") or {} for sheet_id, sheet in sheets.items(): if not isinstance(sheet, dict): continue cell_data = sheet.get("cellData") or {} for rk, row in cell_data.items(): if not isinstance(row, dict): continue try: row_i = int(rk) except ValueError: continue for ck, cell in row.items(): if not isinstance(cell, dict): continue custom = cell.get("custom") or {} if custom.get("type") != "expression": continue try: col_i = int(ck) except ValueError: continue formula = ( custom.get("field") or custom.get("value") or custom.get("formula") or cell.get("v") or "" ) key = (sheet_id, row_i, col_i) if key not in seen: seen.add(key) targets.append((sheet_id, row_i, col_i, str(formula))) return targets def detect_expression_cycles( cells_meta: Dict[str, Any], snapshot: Optional[Dict[str, Any]] = None, ) -> List[str]: """ 检测表达式单元格引用环。 返回警告码列表(供预览 API warnings 字段使用)。 """ snap = snapshot if snapshot is not None else {"sheets": {}} targets = _collect_expression_targets(cells_meta, snap) if len(targets) <= 1: return [] _, has_cycle = _sort_expression_targets(targets) if has_cycle: return ["expression_cycle"] return [] def apply_expression_cells( snapshot: Dict[str, Any], cells_meta: Dict[str, Any], datasets: Dict[str, List[Any]], params: Dict[str, Any], ) -> Dict[str, Any]: if not snapshot: return snapshot or {} result = copy.deepcopy(snapshot) sheets = result.get("sheets") or {} targets = _collect_expression_targets(cells_meta, result) if not targets: return result ordered, has_cycle = _sort_expression_targets(targets) max_passes = min(len(targets) + 1, 32) def _eval_all(batch: List[Tuple[str, int, int, str]]) -> bool: changed = False for sheet_id, row, col, formula in batch: prev = _get_cell_value(result, sheet_id, row, col) value = evaluate_formula(formula, params, datasets, result, sheet_id) if str(prev) != str(value): changed = True _write_expression_cell(sheets, sheet_id, row, col, formula, value) return changed if not has_cycle: _eval_all(ordered) else: for _ in range(max_passes): if not _eval_all(targets): break result["sheets"] = sheets return result