492 lines
14 KiB
Python
492 lines
14 KiB
Python
#!/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"(?<![A-Za-z0-9.])(\$?)([A-Za-z]{1,3})(\d+)(?![:\w])",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _col_letter_to_index(col: str) -> 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
|