diff --git a/backend/app/engine/recompute.py b/backend/app/engine/recompute.py index bf2bc524..8636d24e 100644 --- a/backend/app/engine/recompute.py +++ b/backend/app/engine/recompute.py @@ -473,6 +473,9 @@ def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], if inp.row is not None and path in notes and path not in changes.inputs: return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除" value, reason = _evaluate(plan, values) + if reason is None and isinstance(value, float) and not math.isfinite(value): + # 溢出成无穷大、算出非数:口径卡渲染成「—」,这里也说清算不出来(JSON 里也放不下这种值) + reason = "计算结果不是有限的数值(溢出或非数),无法推演" return ("ok", value, None) if reason is None else ("error", None, reason) @@ -545,7 +548,7 @@ def _result(plan: _Plan, status: str, new: Any, reason: str | None, values: dict return { "id": metric.get("id"), "name": metric.get("name") or metric.get("id"), "unit": metric.get("unit") or "", "decimals": metric.get("decimals"), "format": metric.get("format") or DEFAULT_FORMAT, - "old": old, "new": new, "delta": delta, + "old": _finite(old), "new": _finite(new), "delta": delta, "rendered_old": _render(metric, old), "rendered_new": _render(metric, new) if known else None, "rendered_delta": rendered_delta, "changed": (not _same(old, new)) if known else None, @@ -562,6 +565,11 @@ def _render(metric: dict[str, Any], value: Any, conv: str | None = None) -> str: return MISSING +def _finite(value: Any) -> Any: + """无穷大、非数换成 None:老卡上可能记着(口径卡渲染成「—」),JSON 里放不下。""" + return None if isinstance(value, float) and not math.isfinite(value) else value + + def _same(a: Any, b: Any) -> bool: if is_number(a) and is_number(b): return a == b or (isinstance(a, float) and isinstance(b, float) and math.isnan(a) and math.isnan(b)) @@ -620,7 +628,7 @@ def _input_view(inp: _Input, values: dict[str, Any], notes: dict[str, list[int]] new = _outline(values.get(inp.path), inp.table) if inp.problem is None else None changed = inp.problem is None and not _same(old, new) else: - old, new = item.get("value"), values.get(inp.path) + old, new = _finite(item.get("value")), _finite(values.get(inp.path)) changed = not _same(old, new) view.update(old=old, new=new, changed=changed) if inp.path in notes: diff --git a/backend/tests/test_recompute.py b/backend/tests/test_recompute.py index 83081459..1b9b6594 100644 --- a/backend/tests/test_recompute.py +++ b/backend/tests/test_recompute.py @@ -218,6 +218,21 @@ def test_division_by_zero_is_explained(): assert wow["status"] == "error" and wow["new"] is None and wow["reason"] == "除以零" +def test_an_overflow_is_an_error_not_a_crash(): + """算出无穷大:说清算不出来;老卡上记着无穷大的也不往外送(JSON 放不下,接口会 500)。""" + import json + + result = whatif(CARD, Changes(inputs={"vars.kpi.refunds": 1e308, "vars.kpi.orders": 1e-10}), sources(), + metric="ratio") + [ratio] = result["metrics"] + assert ratio["status"] == "error" and ratio["new"] is None and "有限" in ratio["reason"] + card = {**CARD, "metrics": [metric("big", "vars.kpi.x * 2", "inf * 2", math.inf, + [scalar("vars.kpi.x", math.inf)], rendered="—")]} + big = whatif(card, Changes(), sources())["metrics"][0] + assert big["old"] is None and big["inputs"][0]["old"] is None and big["rendered_old"] == "—" + json.dumps([result, big], allow_nan=False) + + def test_a_missing_input_can_be_supplied(): card = {**CARD, "metrics": [metric("cost", "vars.kpi.cost / vars.kpi.orders", "vars.kpi.cost / 1234", None, [scalar("vars.kpi.cost", None), scalar("vars.kpi.orders", 1234)], decimals=2,