diff --git a/backend/app/analysis/common.py b/backend/app/analysis/common.py index 3eb39865..b497e83f 100644 --- a/backend/app/analysis/common.py +++ b/backend/app/analysis/common.py @@ -107,22 +107,26 @@ def need(raw: Mapping[str, Any], key: str, what: str) -> Any: def written_places(raw: Any) -> int: - """快照里一格写到第几位小数(headline 是取整过的,按它的书写精度容差):数字文本按字面,浮点按最短写法,整数为 0。""" + """快照里一格写到第几位小数(headline 是取整过的,按它的书写精度容差):数字文本按字面,浮点按最短写法,整数为 0。是整数 + 的浮点(SQLite 的 ROUND() 交回 2120.0)是取到个位的,也为 0——最短写法里的「.0」不是书写精度。""" if isinstance(raw, bool) or raw is None: return 0 + if isinstance(raw, float) and raw.is_integer(): + return 0 text = repr(raw) if isinstance(raw, float) else str(raw).strip() if "e" in text.lower(): return 0 return len(text.split(".", 1)[1]) if "." in text else 0 -def closes(total: Fraction, cell: Fraction, raw: Any) -> bool: +def closes(total: Fraction, cell: Fraction, raw: Any, *, exact: bool = False) -> bool: """各段之和(或合起来的比率)和总数那一格对得上:十进制逐位相等;总数取整过的,差在它书写精度的半个末位以内也算;浮点 - 算出来的差在最后几位的也算(相对误差 1e-9 以内,同 engine/compare._equal)。""" + 算出来的差在最后几位的也算(相对误差 1e-9 以内,同 engine/compare._equal)。exact:对的是上一层那一段算出来的精确值 + (下钻),没有取整,只容浮点的最后几位。""" if total == cell: return True gap = abs(total - cell) - if gap <= Fraction(1, 2) / Fraction(10) ** written_places(raw): + if not exact and gap <= Fraction(1, 2) / Fraction(10) ** written_places(raw): return True scale = max(abs(total), abs(cell)) return scale != 0 and gap <= scale * Fraction(1, 10 ** 9) diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index 18520067..8d09e453 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -345,7 +345,7 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li gaps[side] = heads[side][0] - total if parent_row is not None: expect = to_fraction(parent_row.get(side)) - if expect is None or not closes(total, expect, parent_row.get(side)): + if expect is None or not closes(total, expect, None, exact=True): gaps.setdefault(side, (expect or Fraction(0)) - total) closed = not gaps deltas = {x.key: x.target - x.base for x in segs} @@ -547,7 +547,7 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ "d_target": sum((x.target[1] for x in live), Fraction(0))} for col, total in sums.items(): expect = to_fraction(parent_row.get(col)) - if expect is None or not closes(total, expect, parent_row.get(col)): + if expect is None or not closes(total, expect, None, exact=True): gaps.setdefault("base" if col.endswith("base") else "target", (expect or Fraction(0)) - total) closed = not gaps mix_total = sum((e[4] for e in effects), Fraction(0)) diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index f651c35d..7cb21a10 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -127,6 +127,29 @@ async def test_a_rounded_headline_closes_within_its_written_precision(): assert (result["verdict"]["code"] != "unclosed") is closes, written +async def test_a_headline_rounded_by_the_database_to_a_whole_number_closes_within_half_a_unit(): + # SQLite 的 ROUND() 交回浮点 2120.0:取到了个位,最短写法里的「.0」不是一位小数的书写精度 + rows = [["华东", "2026-W41", "1120.2"], ["华东", "2026-W42", "700"], ["华北", "2026-W41", "1000.2"], + ["华北", "2026-W42", "600"]] + for written, closes in ((2120.0, True), (2121.0, False), (2120.5, False)): + head = snap("Q1", "SELECT ROUND(SUM(amount)) AS gmv FROM orders", ["gmv_base", "gmv"], [[written, 1300.0]]) + result = await additive(rows, snaps=[weekly(rows), head], headline=["Q1.r0.gmv_base", "Q1.r0.gmv"]) + assert (result["verdict"]["code"] != "unclosed") is closes, written + + +async def test_a_drill_closes_against_the_exact_value_of_the_parent_segment(): + top = await additive() # 华东 820 → 700 + near = snap("Q4", "SELECT province, week, SUM(amount) AS gmv FROM orders WHERE region = '华东' GROUP BY 1, 2", + ["province", "week", "gmv"], + [["沪", "2026-W41", "500.4"], ["沪", "2026-W42", "410"], ["苏", "2026-W41", 320], + ["苏", "2026-W42", "290.3"]]) + params = {"query": "Q4", "by": ["province"], "period": "week", "base": "2026-W41", "target": "2026-W42", + "measure": "gmv", "parent": {"of": "A3", "key": ["华东"]}} + off = await analyze("decompose", "additive", snaps=[near], parents={"A3": top}, **params) + # 上一层那一段是算出来的精确值,不是取整过的总数:820.4 对 820、700.3 对 700 都不闭合 + assert off["verdict"]["code"] == "unclosed" and off["values"]["gap"] == "-0.4" + + async def test_drilling_down_checks_closure_against_the_parent_segment(): top = await additive() province = snap("Q4", "SELECT province, week, SUM(amount) AS gmv FROM orders WHERE region = '华东' GROUP BY 1, 2",