diff --git a/backend/app/engine/recompute.py b/backend/app/engine/recompute.py index 39f8e32d..0f3a0f76 100644 --- a/backend/app/engine/recompute.py +++ b/backend/app/engine/recompute.py @@ -25,7 +25,10 @@ round(和 metrics.run_metrics 一样只 round 浮点),渲染走 evidence.r 别的原样交回。排除行时有两种例外,都只能说不知道(unsupported),不能报「无变化」: - 指标的某个输入对不上任何快照(完整值找不回来的容器、加工过的结果、按位置从结果里取的一格、由查询结果算出来的数), 它可能就是从被排除了行的那份结果来的,重算不了。写它的节点在那份快照交回之前就跑完了的,影响不到它,照常不算 -- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准 +- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准。 + 这种容器也就不给可勾的行段(勾了只能说不知道);决定取哪一格的单格照给,勾掉它确定是排除后无法计算 +给了新值的以新值为准:只嵌在给了新值的引用里的输入(cell(…) 那一格给了新值,它行号里的 vars.res.rows)取到哪一格 +已无关紧要,它那一行排除了、它说不准受不受影响都不碍事(_Plan.moot)。 """ from __future__ import annotations @@ -166,6 +169,18 @@ class _Plan: tops: list[str] = field(default_factory=list) inputs: dict[str, _Input] = field(default_factory=dict) problem: str | None = None + #: 嵌在别的引用里的输入 → 包着它的最外层引用(_refs) + within: dict[str, set[str]] = field(default_factory=dict) + + def settled(self, path: str, changes: Changes) -> bool: + """包着这个输入的最外层引用都给了新值:它决定取哪一格已无关紧要,那几个引用以新值为准。""" + outer = self.within.get(path) + return bool(outer) and all(top in changes.inputs for top in outer) + + def moot(self, path: str, changes: Changes) -> bool: + """这个输入这次用不上:只嵌在别的引用里,包着它的又都给了新值。它那一行排除了、它说不准受不受影响,都碍不着 + 重算。它自己也是最外层引用的不算:那一处照常拿它的值。""" + return path not in self.tops and self.settled(path, changes) @property def blocked(self) -> tuple[str, str] | None: @@ -234,50 +249,54 @@ def _plan(metric: dict[str, Any], store: _Store) -> _Plan: tree, _, _ = parse_expression(expr) except ExpressionError as e: return _Plan(metric, problem=f"表达式有误({e})") - tops, nested = _refs(tree) - remaining = _remaining(metric.get("substituted"), [*tops, *nested]) + tops, within = _refs(tree) + remaining = _remaining(metric.get("substituted"), [*tops, *within]) inputs: dict[str, _Input] = {} for item in items: if isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"] not in inputs: - inputs[item["path"]] = _restore(item, remaining, nested, store) - plan = _Plan(metric, tree, tops, inputs) + inputs[item["path"]] = _restore(item, remaining, set(within), store) + plan = _Plan(metric, tree, tops, inputs, within=within) if lost := [p for p in tops if p not in inputs]: plan.problem = f"口径卡缺少输入 {'、'.join(lost[:3])} 的记录,不能推演" return plan -def _refs(tree: ast.AST) -> tuple[list[str], set[str]]: - """表达式引用的值:(最外层的引用, 嵌在别的引用里面的)。和 leaf_refs 按同一套规则认引用,只多分一下里外: - vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定取哪一格。""" +def _refs(tree: ast.AST) -> tuple[list[str], dict[str, set[str]]]: + """表达式引用的值:(最外层的引用, 嵌在别的引用里面的 → 包着它的最外层引用)。和 leaf_refs 按同一套规则认引用, + 只多分一下里外:vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定 + 取哪一格。嵌了几层的也记最外层那个:重算时只有最外层的引用拿值(_evaluate),给了新值的就是它们。""" top: list[str] = [] - inner: set[str] = set() + within: dict[str, set[str]] = {} - def add(node: ast.AST, nested: bool) -> None: + def add(node: ast.AST, outer: str | None) -> str: + """记下这个引用,交回它里面的引用归谁:已经嵌在里面的归原来那个最外层引用,最外层的归它自己。""" path = ast.unparse(node) - if nested: - inner.add(path) - elif path not in top: + if outer is not None: + within.setdefault(path, set()).add(outer) + return outer + if path not in top: top.append(path) + return path - def visit(node: ast.AST, nested: bool) -> None: + def visit(node: ast.AST, outer: str | None) -> None: if _cell_call(node): - add(node, nested) + owner = add(node, outer) for arg in node.args[1:]: - visit(arg, True) + visit(arg, owner) return if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node): - add(node, nested) + owner = add(node, outer) cur: ast.AST = node while isinstance(cur, (ast.Attribute, ast.Subscript)): if isinstance(cur, ast.Subscript): - visit(cur.slice, True) + visit(cur.slice, owner) cur = cur.value return for child in ast.iter_child_nodes(node): - visit(child, nested) + visit(child, outer) - visit(tree, False) - return top, inner + visit(tree, None) + return top, within def _remaining(substituted: Any, paths: list[str]) -> set[str]: @@ -556,13 +575,13 @@ def _doubt(plan: _Plan, changes: Changes, sources: Sources) -> str | None: 数组字段的完整值找不回来时也知道它用到哪几行,排除了那几行就算碰到了它(指标因它还原不了而推演不了)。 别的输入——完整值找不回来的容器、对不上任何快照的容器(加工过的结果)、不是直接取自快照的数(按位置从整份结果里 取的一格、transform 由查询结果算出来的数)——都可能来自被排除了行的那份结果,又重算不了,只能说不知道,不能拿 - 原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准;写它的节点在那份快照交回之前就跑完了 - 的,影响不到它(_after)。 + 原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准,只嵌在给了新值的引用里的也就无关紧要 + (_Plan.moot);写它的节点在那份快照交回之前就跑完了的,影响不到它(_after)。 """ if not changes.exclude: return None for path, inp in plan.inputs.items(): - if path in changes.inputs or inp.snapshot is not None: + if path in changes.inputs or inp.snapshot is not None or plan.moot(path, changes): continue if any(_after(snapshot, inp, sources) for snapshot in changes.exclude): why = inp.problem or (DOUBT_UNMATCHED if inp.shape == "container" else DOUBT_DERIVED) @@ -582,14 +601,16 @@ def _after(snapshot: str, inp: _Input, sources: Sources) -> bool: def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], changes: Changes, doubt: str | None) -> tuple[str, Any, str | None]: """(status, 新值, 原因)。整个推演不了的(_Plan.blocked)到不了这里。排除后缺了的、说不准受不受影响的先说, - 都没有才求值。""" - hit = [(path, inp) for path, inp in plan.inputs.items() if path in notes and path not in changes.inputs] + 都没有才求值。只嵌在给了新值的引用里的输入(_Plan.moot)不论排除了什么都不碍事:那几个引用以新值为准。""" + hit = [(path, inp) for path, inp in plan.inputs.items() + if path in notes and path not in changes.inputs and not plan.moot(path, changes)] for path, inp in hit: if inp.row is not None: return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除" for path, inp in hit: - if inp.selector: - # 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数 + if inp.selector and not plan.settled(path, changes): + # 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数。 + # 包着它的引用都给了新值时,它只剩自己作为最外层引用的那一处(排除了哪几行是确定的),照常算 return "unsupported", None, DOUBT.format(path=path, why=DOUBT_SELECTOR) if doubt: return "unsupported", None, doubt @@ -741,11 +762,14 @@ def _editable(inp: _Input, *, blocked: bool) -> bool: def _row_span(inp: _Input) -> tuple[int, int] | None: """这个输入实际用到的快照行(含两端):单格一行、数组字段一段、整份结果全部。排除别的行影响不到它,界面据此只让 这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来,都记在 problem 里)不给:排除它们的行什么也推演 - 不出来。""" + 不出来。决定取哪一格的数组字段、整份结果也不给:少了几行,外面那个引用取到哪一格说不准(_compute),勾了只能 + 说不知道,「不支持推演的输入」里又说它不能用。决定取哪一格的单格照给:勾掉它,确定是排除后无法计算。""" if inp.snapshot is None or inp.masked or inp.problem is not None: return None if inp.row is not None: return inp.row, inp.row + if inp.selector: + return None if inp.rows is not None: return inp.rows rows = inp.value.get("rows") if isinstance(inp.value, dict) else inp.value diff --git a/backend/tests/test_recompute.py b/backend/tests/test_recompute.py index 664a44b8..1f5c4bcf 100644 --- a/backend/tests/test_recompute.py +++ b/backend/tests/test_recompute.py @@ -6,7 +6,7 @@ - 容器输入的还原:agent 数组字段(按 locator.rows 去掉被排除的行)、nodes.* 从节点产出、vars.* 只认 value_artifact - 遮罩列取来的输入既不给值、也不能拿来推演 - 整个指标推演不了的(遮罩列、还原不了的输入、读不出来的快照、没记输入),不改动也照实说,输入一个都不让动 -- 排除了行却说不准受不受影响的指标,不报「无变化」 +- 排除了行却说不准受不受影响的指标,不报「无变化」,也不给勾了只能说不准的行段;给了新值的以新值为准 - 不合法的请求抛 WhatIfError(接口回 422),说清哪里不对 """ from __future__ import annotations @@ -484,15 +484,118 @@ def test_only_inputs_written_after_the_snapshot_came_back_are_doubted(finished, assert (mixed["status"], mixed["changed"]) == (("unsupported", None) if doubted else ("unchanged", False)) +#: cell() 的行号由整份结果算出:取最后一行 +LAST_CELL = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')" +LAST = lone("last", LAST_CELL, "40.0", 40.0, [ + {**cell("pull", 3, "amount", 40.0), "path": LAST_CELL}, + {**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1) + + def test_a_container_that_picks_the_cell_is_doubted_when_its_rows_are_excluded(): - """cell() 的行号由整份结果算出(取最后一行):排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。""" - path = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')" - card = lone("last", path, "40.0", 40.0, [ - {**cell("pull", 3, "amount", 40.0), "path": path}, - {**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1) - assert recompute(card["metrics"][0], Changes(), sources())["new"] == 40.0 - [last] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"] - assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"] + """排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。整份结果因此不给行段:勾了也推演不出来, + 「不支持推演的输入」里又说它不能用。那一格自己那一行照给:勾掉它,确定是排除后无法计算。""" + assert recompute(LAST["metrics"][0], Changes(), sources())["new"] == 40.0 + baseline = whatif(LAST, Changes(), sources()) + assert [(i["path"], i.get("row_span")) for i in baseline["metrics"][0]["inputs"]] == [ + (LAST_CELL, [3, 3]), ("vars.res.rows", None)] + assert [u["input"] for u in baseline["unsupported"]] == ["vars.res.rows"] + assert "取哪" in baseline["unsupported"][0]["reason"] + for row in (0, 1, 2): + [last] = whatif(LAST, Changes(exclude={S1: frozenset({row})}), sources())["metrics"] + assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"] + [last] = whatif(LAST, Changes(exclude={S1: frozenset({3})}), sources())["metrics"] + assert last["status"] == "excluded" and "第 4 行" in last["reason"] + + +@pytest.mark.parametrize("rows", [{1}, {3}, {0, 1, 2, 3}]) +def test_an_explicit_value_on_the_picked_cell_settles_the_container_that_picks_it(rows): + """那一格给了新值:取到的是哪一格已无关紧要,以新值为准。排除整份结果里的行(哪怕连它自己那一行)照常算。""" + [last] = whatif(LAST, Changes(inputs={LAST_CELL: 100}, exclude={S1: frozenset(rows)}), sources())["metrics"] + assert (last["status"], last["new"], last["changed"]) == ("ok", 100, True), last + + +def test_a_container_that_is_also_counted_keeps_only_the_count_once_the_cell_has_a_new_value(): + """整份结果既拿来数行数、又决定取哪一格:那一格给了新值,它只剩数行数的那一处,排除了哪几行是确定的,照常算。""" + card = lone("both", f"len(vars.res.rows) + {LAST_CELL}", "len(vars.res.rows) + 40.0", 44.0, [ + {**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}, + {**cell("pull", 3, "amount", 40.0), "path": LAST_CELL}], decimals=1) + assert recompute(card["metrics"][0], Changes(), sources())["new"] == 44.0 + [both] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"] + assert both["status"] == "unsupported" and "vars.res.rows" in both["reason"] + [both] = whatif(card, Changes(inputs={LAST_CELL: 100}, exclude={S1: frozenset({1})}), sources())["metrics"] + assert (both["status"], both["new"]) == ("ok", 3 + 100), both + + +#: 按第 2 行的区域取费率:费率写在查询之前(fetch),区域那一格决定取哪一项 +FEE = "vars.kpi.rates[cell(nodes.pull, 1, 'region')]" +REGION = "cell(nodes.pull, 1, 'region')" + + +@pytest.mark.parametrize("expression, substituted, value, after", [ + # 区域那一格只用来取费率:费率给了新值,它就无关紧要 + (f"{FEE} * 100", "0.05 * 100", 5.0, ("ok", 8.0)), + # 区域那一格另外还拿来数字数:那一处照常拿它的值,它那一行排除了照样算不出来 + (f"len({REGION}) + {FEE} * 100", "len('华南') + 0.05 * 100", 7.0, ("excluded", None)), +]) +def test_a_cell_that_picks_the_item_is_moot_once_the_item_has_a_new_value(expression, substituted, value, after): + """区域那一格所在的行排除了:取哪一项不知道,排除后无法计算。费率给了新值:取哪一项已无关紧要,照常算—— + 那一格另外还直接参与计算的除外。""" + card = lone("fee", expression, substituted, value, [scalar(FEE, 0.05), cell("pull", 1, "region", "华南")], + decimals=1) + timed = sources(returned={S1: 10}, finished={"fetch": 5}) + assert recompute(card["metrics"][0], Changes(), timed)["new"] == value + [fee] = whatif(card, Changes(exclude={S1: frozenset({1})}), timed)["metrics"] + assert fee["status"] == "excluded" and "第 2 行" in fee["reason"] + [fee] = whatif(card, Changes(inputs={FEE: 0.08}, exclude={S1: frozenset({1})}), timed)["metrics"] + assert (fee["status"], fee["new"]) == after, fee + + +@pytest.mark.parametrize("path, middle", [ + ("vars.kpi.rates[vars.kpi.region]", []), + # 嵌了两层:区域名先换成区域代码再取费率,包着它的最外层引用照样是费率 + ("vars.kpi.rates[vars.kpi.codes[vars.kpi.region]]", [scalar("vars.kpi.codes[vars.kpi.region]", "S")]), +]) +def test_an_index_that_would_be_doubted_is_moot_once_the_item_has_a_new_value(path, middle): + """取哪一项由区域名决定,区域名是查询交回之后才算出来的(对不上快照):排除行后取到的是哪一项说不准。费率给了 + 新值:区域名受不受影响已无关紧要,按剩下的行照常算。""" + card = lone("fee2", f"round({path} * sum(vars.kpi.amounts), 1)", "round(0.05 * sum(vars.kpi.amounts), 1)", 17.0, + [scalar(path, 0.05), *middle, scalar("vars.kpi.region", "华南", node="label"), AMOUNTS], decimals=1) + timed = sources(returned={S1: 10}, finished={"fetch": 5, "label": 12}) + assert recompute(card["metrics"][0], Changes(), timed)["new"] == 17.0 + [fee] = whatif(card, Changes(exclude={S1: frozenset({1})}), timed)["metrics"] + assert fee["status"] == "unsupported" and "排除行对 vars.kpi.region 的影响无法确定" in fee["reason"] + [fee] = whatif(card, Changes(inputs={path: 0.1}, exclude={S1: frozenset({1})}), timed)["metrics"] + assert (fee["status"], fee["new"]) == ("ok", round(0.1 * (340.0 - 80.0), 1)) == ("ok", 26.0), fee + + +def test_an_index_shared_by_two_items_is_moot_only_when_both_have_new_values(): + """费率和固定费都按区域名取:只给费率新值,固定费取到哪一项照样说不准;两样都给了才无关紧要。""" + rate, fixed = "vars.kpi.rates[vars.kpi.region]", "vars.kpi.fixed[vars.kpi.region]" + card = lone("fee4", f"round({rate} * sum(vars.kpi.amounts) + {fixed}, 1)", + "round(0.05 * sum(vars.kpi.amounts) + 3, 1)", 20.0, + [scalar(rate, 0.05), scalar("vars.kpi.region", "华南", node="label"), AMOUNTS, scalar(fixed, 3)], + decimals=1) + timed = sources(returned={S1: 10}, finished={"fetch": 5, "label": 12}) + assert recompute(card["metrics"][0], Changes(), timed)["new"] == 20.0 + [fee] = whatif(card, Changes(inputs={rate: 0.1}, exclude={S1: frozenset({1})}), timed)["metrics"] + assert fee["status"] == "unsupported" and "vars.kpi.region" in fee["reason"] + [fee] = whatif(card, Changes(inputs={rate: 0.1, fixed: 5}, exclude={S1: frozenset({1})}), timed)["metrics"] + assert (fee["status"], fee["new"]) == ("ok", 31.0), fee + + +def test_a_change_that_leaves_the_value_as_it_was_moves_nothing_downstream(): + """改动碰到了指标,算出来却没变:4 行里排除 1 行,min(行数, 3) 还是 3;填回原值也一样。照实说无变化,下游也不会 + 受影响(maybe_changed 为假)。排除到只剩 2 行才变。""" + card = lone("cap", "min(len(vars.res.rows), 3)", "min(len(vars.res.rows), 3)", 3, + [{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}]) + result = whatif(card, Changes(exclude={S1: frozenset({3})}), sources()) + [cap] = result["metrics"] + assert (cap["status"], cap["new"], cap["changed"], cap["inputs"][0]["excluded_rows"]) == ("ok", 3, False, [3]) + assert result["maybe_changed"] is False + same = whatif(CARD, Changes(inputs={"vars.kpi.gmv": 45678.5}), sources(), metric="wow") + assert (same["metrics"][0]["status"], same["metrics"][0]["changed"], same["maybe_changed"]) == ("ok", False, False) + moved = whatif(card, Changes(exclude={S1: frozenset({0, 3})}), sources()) + assert moved["metrics"][0]["new"] == 2 and moved["maybe_changed"] is True def test_a_metric_without_lineage_is_unsupported_whatever_changes(): @@ -503,6 +606,8 @@ def test_a_metric_without_lineage_is_unsupported_whatever_changes(): assert old["status"] == "unsupported" and old["changed"] is None and "没有记录输入" in old["reason"] +#: 按遮罩列那一格的长度取第几项 +PHONE_PICK = "vars.kpi.list[len(cell(nodes.crm, 0, 'phone')) - 10]" #: 整个指标推演不了的几种:(卡, Sources 的改动, 改点什么, 状态, 原因里该有的字)。每种都另有一个本来能改的输入 BLOCKED = { # 老运行:容器型 vars 输入没存完整值 @@ -513,6 +618,11 @@ BLOCKED = { "masked": (lone("mix", "len(cell(nodes.crm, 0, 'phone')) + vars.kpi.k", "len('13800000000') + 2", 13, [cell("crm", 0, "phone", "13800000000", artifact=S2), scalar("vars.kpi.k", 2)]), {}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]), + # 遮罩列的那一格只嵌在下标里(决定取哪一项):改了别的输入,取到哪一项照样露出它的长度 + "masked_index": (lone("pick_m", f"{PHONE_PICK} + vars.kpi.k", "20 + 2", 22, + [scalar(PHONE_PICK, 20), cell("crm", 0, "phone", "13800000000", artifact=S2), + scalar("vars.kpi.k", 2)]), + {}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]), # 那一格所在的查询快照读不出来:遮没遮核对不了 "unreadable": (lone("mix2", "cell(nodes.pull, 0, 'amount') + vars.kpi.k", "120.5 + 2", 122.5, [cell("pull", 0, "amount", 120.5), scalar("vars.kpi.k", 2)], decimals=1), @@ -638,6 +748,8 @@ def test_a_withheld_cell_inside_another_reference_stays_out_of_the_substituted_e [m] = whatif(card, Changes(), sources(load=lambda a: None if a == S1 else store.get(a)))["metrics"] assert m["status"] == "unchanged" and m["substituted"] is None assert [i.get("withheld") for i in m["inputs"]] == [None, True] + # 那一格所在的快照读不出来:它那一行也不给勾(排除读不出来的快照里的行,接口回 422) + assert "row_span" not in m["inputs"][1] # -------------------------------------------------------------------------- diff --git a/backend/tests/test_whatif_api.py b/backend/tests/test_whatif_api.py index 87dd3620..680dd046 100644 --- a/backend/tests/test_whatif_api.py +++ b/backend/tests/test_whatif_api.py @@ -811,6 +811,29 @@ async def test_inputs_carry_the_rows_they_use(client, monkeypatch): assert cell["row_span"] == [5, 5] and rows["row_span"] == [0, 9] +async def test_a_container_that_picks_the_cell_offers_no_rows(client, monkeypatch): + """cell() 的行号由整份结果算出:整份结果不给行段(勾了别的行,取到哪一格说不准,什么也推演不出来),那一格 + 自己那一行照常可勾、勾了确定是排除后无法计算。那一格给了新值,取到哪一格已无关紧要,排除别的行照常算。""" + pick = "cell(nodes.fetch, len(vars.res.rows) - 5, 'amount')" + row = await run_graph(monkeypatch, query_graph(metrics=[ + {"id": "pick", "name": "倒数第五单金额", "unit": "元", "decimals": 1, "format": "thousands", "expression": pick}]), + text="倒数第五单金额 [[m:pick]]。") + snapshot = await snapshot_of(row.id) + body = await whatif(client, row.id, {"card": "card"}) + [m] = body["metrics"] + assert m["status"] == "unchanged" and m["old"] == 600.5 + assert [(i["path"], i.get("row_span"), i["editable"]) for i in m["inputs"]] == [ + (pick, [5, 5], True), ("vars.res.rows", None, False)] + assert [(u["input"], "取哪" in u["reason"]) for u in body["unsupported"]] == [("vars.res.rows", True)] + [m] = (await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [5]}}}))["metrics"] + assert m["status"] == "excluded" and "第 6 行" in m["reason"] + body = await whatif(client, row.id, {"card": "card", "report": "write", + "overrides": {"inputs": {pick: 1000}, "exclude_rows": {snapshot: [0]}}}) + [m] = body["metrics"] + assert (m["status"], m["new"], m["rendered_new"]) == ("ok", 1000, "1,000.0元"), m + assert [(s["rendered_old"], s["rendered_new"]) for s in body["segments"]] == [("600.5元", "1,000.0元")] + + async def test_rows_of_a_sealed_snapshot_can_be_paged_with_masks_applied(client, monkeypatch, shop): """推演区里给整份结果勾行:按页取封存范围内快照的行,遮罩列写「已遮罩」。只读。""" row = await run_graph(monkeypatch, query_graph()) @@ -863,6 +886,24 @@ async def test_downstream_cards_through_an_intermediate_node_are_flagged(client, assert (flagged["node_id"], flagged["metrics"], flagged["direct"]) == ("yearly", ["year"], False) +async def test_a_change_that_moves_no_metric_flags_no_downstream_card(client, monkeypatch): + """改动碰到了指标、算出来却没变(10 单里排除 2 单,min(单数, 3) 还是 3):下游不会受影响,不标。排除到只剩 2 单 + 才变,这时才标。界面不会提交和原值一样的输入,排除行却常有这种情况(又如 max(…) 勾掉的不是最大值那一行)。""" + row = await run_graph(monkeypatch, query_graph( + metrics=[{"id": "cap", "name": "封顶单数", "unit": "单", "format": "thousands", + "expression": "min(len(vars.res.rows), 3)"}], + between=[node("yearly", "metrics", caliber="年口径", metrics=[ + {"id": "year", "name": "年化", "expression": "nodes.card.metrics[0].value * 52"}])]), + text="封顶单数 [[m:cap]]。") + snapshot = await snapshot_of(row.id) + body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [2, 7]}}}) + [cap] = body["metrics"] + assert (cap["status"], cap["new"], cap["changed"], cap["inputs"][0]["excluded_rows"]) == ("ok", 3, False, [2, 7]) + assert body["affected_cards"] == [] + body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: list(range(8))}}}) + assert body["metrics"][0]["new"] == 2 and [c["node_id"] for c in body["affected_cards"]] == ["yearly"] + + async def test_downstream_cards_are_flagged_when_the_change_cannot_be_followed(client, monkeypatch): """改动碰到了指标、它却推演不了(还原不了、遮罩):新值不知道,下游同样可能受影响。""" from app.engine.nodes import metrics as metrics_node diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 22cdf55e..39866717 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3062,7 +3062,8 @@ export interface WhatIfInput { excluded_rows?: number[] /** * 这个输入实际用到的快照行(含两端,从 0 数):单格一行、数组字段一段、整份查询结果全部。排除别的行影响不到它, - * 界面只让这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来)没有,整个指标推演不了时一律没有 + * 界面只让这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来)没有,决定取哪一格的数组字段、整份结果 + * 也没有(少了几行,取到哪一格说不准),整个指标推演不了时一律没有 */ row_span?: [number, number] masked?: boolean