From f7a09eb108ec920c489e4f2fa4693541e832e4dc Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 16:09:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E4=BB=BD=E5=BF=AB=E7=85=A7=E6=8C=89?= =?UTF-8?q?=E6=9C=9F=E6=AC=A1=E5=88=86=E7=BB=84=E7=9A=84=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=E4=B9=9F=E6=A0=B8=E6=9C=AC=E6=9C=9F=E9=BD=90?= =?UTF-8?q?=E4=B8=8D=E9=BD=90=EF=BC=88=E5=A4=8D=E6=A0=B8=20R20=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=8F=AF=E5=8A=A0=E3=80=81=E6=AF=94=E7=8E=87=E3=80=81?= =?UTF-8?q?=E4=B9=98=E7=A7=AF=E4=B8=89=E7=A7=8D=E6=A8=A1=E5=BC=8F=E7=94=A8?= =?UTF-8?q?=E6=B3=A2=E5=8A=A8=E5=9F=BA=E7=BA=BF=E5=90=8C=E4=B8=80=E5=A5=97?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E6=A3=80=E6=9F=A5=EF=BC=88comparabl?= =?UTF-8?q?e.period=5Fcomplete=EF=BC=9A=E4=B8=8D=E5=88=B0=E6=95=B4?= =?UTF-8?q?=E5=91=A8=E3=80=81=E6=B2=A1=E8=BF=87=E5=AE=8C=E3=80=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=B2=A1=E5=88=B0=E3=80=81SQL=20=E6=88=AA=E6=8E=89?= =?UTF-8?q?=E4=B8=80=E9=83=A8=E5=88=86=EF=BC=89=EF=BC=8C=E6=9C=AC=E6=9C=9F?= =?UTF-8?q?=E4=B8=8D=E9=BD=90=E7=AE=97=E4=B8=8D=E4=BA=86=EF=BC=88partial?= =?UTF-8?q?=5Ftarget=EF=BC=8C=E3=80=8C=E6=9C=AC=E6=9C=9F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8F=AA=E5=88=B0=202026-10-20=EF=BC=8C=E8=BF=98=E5=B7=AE=205?= =?UTF-8?q?=20=E5=A4=A9=EF=BC=8C=E5=92=8C=E5=AE=8C=E6=95=B4=E7=9A=84?= =?UTF-8?q?=E5=91=A8=E6=AF=94=E6=B2=A1=E6=9C=89=E6=84=8F=E4=B9=89=E3=80=8D?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=A0=B8=E5=AF=B9=E4=B8=8D=E4=BA=86=EF=BC=88?= =?UTF-8?q?=E6=9F=A5=E4=B8=8D=E5=88=B0=E8=A1=A8=E7=9A=84=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E3=80=81=E5=91=A8=E5=8F=B7=E8=AE=A4=E4=B8=8D=E5=87=BA=E7=AE=97?= =?UTF-8?q?=E6=B3=95=E3=80=81=E6=9C=9F=E6=AC=A1=E4=B8=8D=E6=98=AF=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=EF=BC=89=E9=99=84=E4=B8=80=E5=8F=A5=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E5=9C=A8=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E3=80=81=E4=B8=8D=E8=BF=9B=E5=88=A4=E5=AE=9A=E8=AF=AD=EF=BC=88?= =?UTF-8?q?=E4=B8=A4=E4=BB=BD=E5=BF=AB=E7=85=A7=E6=97=B6=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E6=80=A7=E7=BB=93=E8=AE=BA=E7=85=A7=E6=97=A7=E8=BF=9B=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E8=AF=AD=EF=BC=89=EF=BC=9B=E7=BB=93=E8=AE=BA=E7=9A=84?= =?UTF-8?q?=20params=20=E8=AE=B0=E4=B8=8B=E8=BF=90=E8=A1=8C=E5=BD=93?= =?UTF-8?q?=E5=A4=A9=E3=80=82=E6=9C=9F=E6=AC=A1=E6=8C=89=20periods.parse?= =?UTF-8?q?=5Fcompared=20=E8=AE=A4=EF=BC=9A=E5=91=A8=E5=8F=B7=E6=8C=89=20S?= =?UTF-8?q?QL=20=E8=AE=A4=E7=AE=97=E6=B3=95=EF=BC=9B=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E9=87=8C=E5=BE=80=E5=BE=80=E5=8F=AA=E6=9C=89=E6=AF=94=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E6=9C=9F=EF=BC=8C=E5=80=BC=E9=83=BD=E6=98=AF=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E3=80=81=E6=8E=A8=E6=96=AD=E6=88=90=E6=8C=89=E5=91=A8?= =?UTF-8?q?=E3=80=81=E6=9C=9F=E6=AC=A1=E5=88=97=E5=8F=88=E6=98=AF=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=8F=96=E7=9A=84=E6=97=A5=E6=9C=9F=E5=88=97=EF=BC=88?= =?UTF-8?q?=E5=8E=9F=E6=A0=B7=E3=80=81date(=E5=88=97)=E3=80=81DATE(?= =?UTF-8?q?=E5=88=97)=E3=80=81CAST=20=E6=88=90=E6=97=A5=E6=9C=9F=EF=BC=89?= =?UTF-8?q?=E6=97=B6=E6=8C=89=E5=A4=A9=E8=AE=A4=E2=80=94=E2=80=94=E6=98=A8?= =?UTF-8?q?=E5=A4=A9=E5=AF=B9=E4=B8=8A=E5=91=A8=E5=90=8C=E4=B8=80=E5=A4=A9?= =?UTF-8?q?=E6=AD=A3=E5=A5=BD=E9=9A=94=207=20=E5=A4=A9=EF=BC=8C=E5=85=89?= =?UTF-8?q?=E7=9C=8B=E5=80=BC=E5=88=86=E4=B8=8D=E5=87=BA=E6=98=AF=E4=B8=A4?= =?UTF-8?q?=E5=A4=A9=E8=BF=98=E6=98=AF=E4=B8=A4=E5=91=A8=E3=80=82=E5=8F=AF?= =?UTF-8?q?=E6=AF=94=E6=80=A7=E6=A3=80=E6=9F=A5=E6=8C=89=E6=9C=9F=E6=AC=A1?= =?UTF-8?q?=E5=88=86=E7=BB=84=E3=80=81=E6=B2=A1=E7=BB=99=E7=B2=92=E5=BA=A6?= =?UTF-8?q?=E6=97=B6=E5=90=8C=E6=A0=B7=E6=8C=89=E5=AE=83=E8=AE=A4=E3=80=82?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E6=8E=A5=E5=8F=A3=E7=9A=84=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88tests/test=5Fanalysis=5Fevidence=5Fa?= =?UTF-8?q?pi.py=EF=BC=89=E8=B7=9F=E7=9D=80=E8=A1=A5=E4=B8=8A=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E5=8F=A5=E8=AF=B4=E6=98=8E=E7=9A=84=E7=A7=8D=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/analysis/comparable.py | 3 +- backend/app/analysis/decompose.py | 62 ++++++++++++++++++--- backend/app/analysis/periods.py | 31 ++++++++++- backend/tests/test_analysis_decompose.py | 58 ++++++++++++++++++- backend/tests/test_analysis_evidence_api.py | 2 +- backend/tests/test_analysis_periods.py | 12 ++++ 6 files changed, 155 insertions(+), 13 deletions(-) diff --git a/backend/app/analysis/comparable.py b/backend/app/analysis/comparable.py index bc157432..bc428e89 100644 --- a/backend/app/analysis/comparable.py +++ b/backend/app/analysis/comparable.py @@ -568,7 +568,8 @@ def comparable(raw: Mapping[str, Any], env: Any) -> Any: "里有的期次值", reason=f"期次列「{period}」里没有{what}「{ph.key_text([params[side]])}」") env.inputs.use(target, "data", [period, *([measure] if measure else [])], rows["base"] + rows["target"]) - parsed = pd.parse(values, grain, weeks=pd.week_scheme(target.sql, period, target.dialect)) + parsed = pd.parse(values, grain, weeks=pd.week_scheme(target.sql, period, target.dialect)) if grain \ + else pd.parse_compared(values, target.sql, period, target.dialect) if parsed is None: item_b = item_t = pd.Period(None, 0, None, None) grain = "seq" diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index 1efc4291..104001b6 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -2,9 +2,10 @@ **参数**(AnalysisRequest.params;列名不分大小写,按快照认): -- 一份快照:query(编号)、period(期次列)、base / target(期次列里的两个值);或两份快照:base_query、target_query(各自 - 只有维度列和度量列,先做可比性检查:口径结构不同的不拆,算不了 incomparable_sides;其余不可比的照样算,判定后附上 - 可比性结论)。 +- 一份快照:query(编号)、period(期次列)、base / target(期次列里的两个值),本期齐不齐同波动基线那一套(comparable. + period_complete:不到整期、没过完、数据没到 → partial_target;核对不了的附说明,只在证据面板);或两份快照:base_query、 + target_query(各自只有维度列和度量列,先做可比性检查:口径结构不同的不拆,算不了 incomparable_sides;其余不可比的照样算, + 判定后附上可比性结论)。 - additive:by(维度列,1–2 列)、measure(度量列);可选 headline {base, target}(基期、本期总数各一格,如 Q1.r1.gmv,用来 核对闭合)、parent {of: A3, key: [华东]}(下钻:这一层各段之和要等于 A3 里华东那一段)。 - ratio:by、numerator(分子列)、denominator(分母列);可选 headline {base, target}(整体比率的两格)、parent(上一层的比率 @@ -275,6 +276,30 @@ def _comparability(env: Env, params: dict[str, Any], base: Snap, target: Snap, m return outcome, outcome.structure +def _period_complete(env: Env, params: dict[str, Any], snap: Snap, measure: str | None + ) -> tuple[str | None, dict[str, str] | None]: + """一份快照按期次分组时本期齐不齐(同波动基线那一套,comparable.period_complete;期次按 periods.parse_compared 认): + (不齐时 partial_target 的原因, 核对不了时的附带说明)。两份快照的在可比性检查里核过整期,不在这里核。""" + from app.analysis import periods as pd + + if "base_query" in params: + return None, None + params["today"] = today_param(env) + period = params["period"] + i = snap.index(period) + values = [row[i] if i < len(row) else None for row in snap.rows] + parsed = pd.parse_compared(values, snap.sql, period, snap.dialect) + if parsed is None: + return None, ph.caveat("comparability", ph.NO_DATES) + items, grain = parsed + target = next(item for item, v in zip(items, values) if same_value(params["target"], v)) + status, why, note = cmp.period_complete(snap, period, target, grain, measure, env.facts, env.book, + env.today) # type: ignore[arg-type] + if status == "short": + return f"{why},和完整的{pd.PER_WORD[grain]}比没有意义", None + return None, ph.caveat("comparability", note) if status == "unknown" and note else None + + def _share_rank(segments: Sequence[Any], share: Any) -> list[Any]: """按 share 从大到小(一样大的按先出现的)。""" return sorted(segments, key=lambda s: -share(s)) @@ -323,6 +348,12 @@ def additive(raw: Mapping[str, Any], env: Env) -> Any: if structure is not None: return refuse(env, params, used, "incomparable_sides", f"{structure},不是同一个量的两期,不拆", criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats) + partial, note = _period_complete(env, params, ts, measure) + if partial is not None: + return refuse(env, params, used, "partial_target", partial, + criterion=ph.criterion_additive(env.settings, top_k_max=None, **criterion_args), caveats=caveats) + if note is not None: + caveats.append(note) segs = _segments(side_b, side_t, Fraction(0)) if len(segs) < 2: return refuse(env, params, used, "too_few_segments", @@ -435,7 +466,9 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li values["gap"] = canon(max(gaps.values(), key=abs)) verdict = {"code": code, "subjects": [_key_strings(x.key) for x in subjects], "direction": sign_word(delta) if code in ("driver", "concentrated", "dispersed") else None} - phrase = ph.with_caveats(main, caveats, skip=("offset",) if code == "flat" else ()) + # 一份快照时 comparability 只会是本期核对不了的那句(同波动基线,只在证据面板);两份快照的可比性结论照旧进判定语 + skip = (("offset",) if code == "flat" else ()) + (("comparability",) if outcome is None else ()) + phrase = ph.with_caveats(main, caveats, skip=skip) return build(env, params, used, table=table, values=values, verdict=verdict, criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats) @@ -487,6 +520,9 @@ def ratio(raw: Mapping[str, Any], env: Env) -> Any: outcome, structure = _comparability(env, params, bs, ts, den) if structure is not None: return refuse(env, params, used, "incomparable_sides", f"{structure},不是同一个量的两期,不拆", criterion=criterion) + partial, note = _period_complete(env, params, ts, den) + if partial is not None: + return refuse(env, params, used, "partial_target", partial, criterion=criterion) segs = _segments(side_b, side_t, (Fraction(0), Fraction(0))) live = [x for x in segs if x.base[1] != 0 or x.target[1] != 0] if len(live) < 2: @@ -504,7 +540,7 @@ def ratio(raw: Mapping[str, Any], env: Env) -> Any: ph.say(ph.key_text(x.key), f"{when}的", den_label, "为 0 而", num_label, "不为 0,比率拆不了"), criterion=criterion) return _ratio_result(env, params, used, segs, by, dims, label, num_label, den_label, heads, parent_row, outcome, - criterion, target_snap=ts) + criterion, target_snap=ts, note=note) def ratio_effects(segs: Sequence[tuple[Fraction, Fraction, Fraction, Fraction]] @@ -533,7 +569,8 @@ def ratio_effects(segs: Sequence[tuple[Fraction, Fraction, Fraction, Fraction]] def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[Segment], by: list[str], dims: list[str], label: str, num_label: str, den_label: str, heads: Mapping[str, tuple[Fraction, Any]], parent_row: Mapping[str, Any] | None, - outcome: cmp.Outcome | None, criterion: str, *, target_snap: Snap) -> Any: + outcome: cmp.Outcome | None, criterion: str, *, target_snap: Snap, + note: Mapping[str, str] | None = None) -> Any: main_share, flat_pct = env.threshold("main_share"), env.threshold("flat_pct") min_den = env.threshold("min_denominator") live = [x for x in segs if x.base[1] != 0 or x.target[1] != 0] @@ -595,6 +632,8 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ ph.say(num_label, "或", den_label)))) if outcome is not None and outcome.verdict != "comparable": caveats.append(ph.caveat("comparability", outcome.phrase)) + if note is not None: + caveats.append(dict(note)) total_text = ph.ratio_change(R, R2, proportion=proportion) if code == "mix_driven": star = subjects[0] @@ -641,7 +680,8 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ verdict = {"code": code, "subjects": [_key_strings(x.key) for x in subjects], "direction": sign_word(delta) if code in ("mix_driven", "rate_driven", "both") else None} return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, - phrase=ph.with_caveats(main, caveats), caveats=caveats) + phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()), + caveats=caveats) # -------------------------------------------------------------------------- @@ -715,6 +755,9 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any: outcome, structure = _comparability(env, params, bs, ts, product) if structure is not None: return refuse(env, params, used, "incomparable_sides", f"{structure},不是同一个量的两期,不拆", criterion=criterion) + partial, note = _period_complete(env, params, ts, product) + if partial is not None: + return refuse(env, params, used, "partial_target", partial, criterion=criterion) if any(v <= 0 for row in cells.values() for v, _ in row): return refuse(env, params, used, "non_positive", "乘积或因子里有不大于 0 的值,对数拆解要求全为正", criterion=criterion) for when, row in cells.items(): @@ -738,6 +781,8 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any: caveats: list[dict[str, str]] = [] if outcome is not None and outcome.verdict != "comparable": caveats.append(ph.caveat("comparability", outcome.phrase)) + if note is not None: + caveats.append(note) total_text = ph.change(delta, M) top = max(range(len(factors)), key=lambda i: shares[i] if shares[i] is not None else -math.inf) if flat: @@ -765,7 +810,8 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any: "top_share": canon(shares[top]) if shares[top] is not None else None} verdict = {"code": code, "subjects": subjects, "direction": sign_word(delta) if code != "flat" else None} return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, - phrase=ph.with_caveats(main, caveats), caveats=caveats) + phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()), + caveats=caveats) __all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "USED", "additive", "factor", "lmdi", diff --git a/backend/app/analysis/periods.py b/backend/app/analysis/periods.py index 8082d248..63e6fd28 100644 --- a/backend/app/analysis/periods.py +++ b/backend/app/analysis/periods.py @@ -271,6 +271,35 @@ def week_scheme(sql: str, column: str, dialect: str | None) -> str | None: return None +@lru_cache(maxsize=256) +def plain_day(sql: str, column: str, dialect: str | None) -> bool: + """期次列是不是直接取的一个日期列(原样、date(列)、DATE(列)、CAST(列 AS DATE)),没有按周、按月换算过。""" + from sqlglot import exp + + from app.engine import cell_format as cf + + try: + value = _period_expr(sql, column, dialect) + except Exception: # noqa: BLE001 + return False + if isinstance(value, (exp.Date, exp.TsOrDsToDate)): + extra = [v for k, v in value.args.items() if k != "this" and v not in (None, [], False)] + value = cf._unwrap(value.this) if not extra else None + return isinstance(value, exp.Column) and not isinstance(value.this, exp.Star) + + +def parse_compared(values: Sequence[Any], sql: str, column: str, + dialect: str | None) -> tuple[list[Period], str] | None: + """按期次分组的一份快照里比的两期(变化拆解、可比性检查没给粒度时;快照里往往只有这两期):同 parse,周号按 SQL 认算法, + 粒度按值推断;但值都是日期、推断成按周、期次列又是直接取的日期列(plain_day)时按天认——两期正好隔 7 天,光看值分不出 + 是两天还是两周,期次列没按周换算过就是那一天。""" + parsed = parse(values, weeks=week_scheme(sql, column, dialect)) + if parsed is not None and parsed[1] == "week" and all(_shape(v) == "date" for v in values) \ + and plain_day(sql, column, dialect): + return parse(values, "day") + return parsed + + def _period_expr(sql: str, column: str, dialect: str | None) -> Any: """结果列 column 的写法(sqlglot 节点):最外层 SELECT 里那一项;是从 CTE、子查询里原样取出的一列时往里找。""" from sqlglot import exp @@ -497,4 +526,4 @@ def weekend_days(start: date, end: date) -> int: __all__ = ["COUNT_WORD", "DEFAULT_LAG", "GRAINS", "PER_WORD", "WEEK_SCHEMES", "Period", "calendar_unit", "parse", - "short_days", "unreadable", "week_scheme", "week_span", "weekend_days"] + "parse_compared", "plain_day", "short_days", "unreadable", "week_scheme", "week_span", "weekend_days"] diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index 67ab1946..00024921 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -57,8 +57,10 @@ async def test_a_driver_is_named_with_its_share_and_the_whole_shape_is_filled(): "columns": ["region", "week", "gmv"], "rows": "all"}] assert result["hypothesis"] == "华东拖累" and result["probes"] == [] assert result["criterion"].endswith("以各段之和为总数") - assert {c["code"] for c in result["caveats"]} == {"headline_basis"} + # 期次是现成的一列「2026-W41」,认不出是按哪种周号算的:本期齐不齐核对不了,只附在证据面板(不进判定语) + assert {c["code"] for c in result["caveats"]} == {"headline_basis", "comparability"} assert result["params"]["catalog"] == {"labels": {"region": "地区"}, "measures": {"gmv": "flow"}} + assert result["params"]["today"] == "2026-10-21" async def test_the_segments_add_up_to_the_total_digit_for_digit(): @@ -163,7 +165,7 @@ async def test_drilling_down_checks_closure_against_the_parent_segment(): assert child["params"]["depth"] == 1 and child["params"]["parent"] == {"of": "A3", "key": ["华东"]} assert child["inputs"][0]["role"] == "parent" and child["inputs"][0]["ref"] == "A3" assert "max_drill_depth" in child["settings"] - assert {c["code"] for c in child["caveats"]} == set() # 有 parent 就不是「以各段之和为总数」 + assert {c["code"] for c in child["caveats"]} == {"comparability"} # 有 parent 就不是「以各段之和为总数」 short = snap("Q4", province["sql"], ["province", "week", "gmv"], [["沪", "2026-W41", 500], ["沪", "2026-W42", 410], ["苏", "2026-W41", 300], ["苏", "2026-W42", 290]]) unclosed = await analyze("decompose", "additive", snaps=[short], parents={"A3": top}, **params) @@ -504,3 +506,55 @@ async def test_a_dimension_named_like_a_result_column_is_a_call_error(): error = await call_error("decompose", "ratio", snaps=[mixed], query="Q1", by="Mix", period="week", base="W41", target="W42", numerator="refunds", denominator="orders") assert error.code == "reserved_column" and "「Mix」" in error.message + + + +# -------------------------------------------------------------------------- +# 一份快照按期次分组:本期齐不齐(同波动基线那一套) +# -------------------------------------------------------------------------- + +WEEK_START = ("SELECT region, date(order_date, '-6 days', 'weekday 1') AS week, SUM(amount) AS gmv FROM orders " + "GROUP BY 1, 2") +DAY_SQL = "SELECT region, order_date AS day, SUM(amount) AS gmv FROM orders GROUP BY 1, 2" + + +def _two(base, target, *, sql=WEEK_START, col="week"): + rows = [["华东", base, 820], ["华东", target, 700], ["华北", base, 600], ["华北", target, 560]] + return snap("Q2", sql, ["region", col, "gmv"], rows, types={"region": "text", col: "text", "gmv": "number"}) + + +async def test_one_snapshot_by_period_checks_the_target_is_whole(): + probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")}) + ask = {"query": "Q2", "by": ["region"], "period": "week", "measure": "gmv", "label": "销售额"} + # 运行当天 10-21:10-19 那一周没过完 + partial = await analyze("decompose", "additive", snaps=[_two("2026-10-12", "2026-10-19")], probe=probe, + base="2026-10-12", target="2026-10-19", **ask) + assert partial["reason_code"] == "partial_target" + assert partial["reason"] == "本期数据只到 2026-10-20,还差 5 天,和完整的周比没有意义" + assert partial["params"]["today"] == "2026-10-21" and partial["probes"][0]["column"] == "order_date" + whole = await analyze("decompose", "additive", snaps=[_two("2026-10-05", "2026-10-12")], probe=probe, + base="2026-10-05", target="2026-10-12", **ask) + assert whole["status"] == "ok" and not [c for c in whole["caveats"] if c["code"] == "comparability"] + # 查不到表的范围:只附一句核对不了,不进判定语 + unknown = await analyze("decompose", "additive", snaps=[_two("2026-10-05", "2026-10-12")], + base="2026-10-05", target="2026-10-12", **ask) + note = next(c["text"] for c in unknown["caveats"] if c["code"] == "comparability") + assert note == "没能核对本期的数据是否到齐(查不到表里这一列的日期范围)" and note not in unknown["phrase"] + ratio_rows = [["线上", "2026-10-12", 50, 1000], ["线上", "2026-10-19", 80, 1000], ["线下", "2026-10-12", 20, 1000], + ["线下", "2026-10-19", 26, 1000]] + ratio_snap = snap("Q1", "SELECT channel, date(order_date, '-6 days', 'weekday 1') AS week, SUM(refund_flag) AS " + "refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2", + ["channel", "week", "refunds", "orders"], ratio_rows) + refused = await analyze("decompose", "ratio", snaps=[ratio_snap], probe=probe, query="Q1", by="channel", + period="week", base="2026-10-12", target="2026-10-19", numerator="refunds", + denominator="orders") + assert refused["reason_code"] == "partial_target" + + +async def test_two_days_a_week_apart_from_a_plain_date_column_are_two_days(): + # 期次列直接取的日期:昨天(10-20)对上周同一天,不是两周;10-20 当天的数据已经到了 + probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")}) + result = await analyze("decompose", "additive", snaps=[_two("2026-10-13", "2026-10-20", sql=DAY_SQL, col="day")], + probe=probe, query="Q2", by=["region"], period="day", base="2026-10-13", + target="2026-10-20", measure="gmv") + assert result["status"] == "ok" and not [c for c in result["caveats"] if c["code"] == "comparability"] diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py index 93b3b867..9c2c7568 100644 --- a/backend/tests/test_analysis_evidence_api.py +++ b/backend/tests/test_analysis_evidence_api.py @@ -136,7 +136,7 @@ async def test_the_phrase_opens_judgment_method_calculation_inputs_recompute_and assert judged["cited"] == {"what": "phrase"} and judged["render_ok"] is True and judged["eid_ok"] is True assert judged["origin"] == "card" and judged["item"] == "drop" and judged["label"] == "分析" assert judged["hypothesis"] == "华东拖累" and judged["hypothesis_note"].startswith("Agent 写的假设") - assert {c["code"] for c in judged["caveats"]} == {"measure_unlabeled", "headline_basis"} + assert {c["code"] for c in judged["caveats"]} == {"measure_unlabeled", "headline_basis", "comparability"} assert method["method"] == "decompose.additive@1" and method["method_label"] == "变化拆解(可加)" assert {s["key"]: s["label"] for s in method["settings"]}["main_share"] == "「主要来源」的占比" assert {s["key"] for s in method["settings"]} >= {"main_share", "concentration", "table_top_k"} diff --git a/backend/tests/test_analysis_periods.py b/backend/tests/test_analysis_periods.py index 241f3f1a..76ff612e 100644 --- a/backend/tests/test_analysis_periods.py +++ b/backend/tests/test_analysis_periods.py @@ -128,3 +128,15 @@ def test_unreadable_values_are_named(): assert pd.unreadable(["2026-W40", "上周", "2026-W41", None]) == ("shape", ["上周", None]) assert pd.unreadable(["2026-W40", "2026-W41", "2026-10-12"]) == ("mixed", ["2026-10-12"]) assert pd.unreadable(["2026-10", "2026-11"], "week") == ("grain", ["2026-10", "2026-11"]) + + +def test_a_plain_date_column_is_a_day_even_when_two_values_are_a_week_apart(): + plain = "SELECT region, order_date AS day, SUM(amount) AS gmv FROM orders GROUP BY 1, 2" + starts = ("SELECT region, date(order_date, '-6 days', 'weekday 1') AS week, SUM(amount) AS gmv FROM orders " + "GROUP BY 1, 2") + assert pd.plain_day(plain, "day", "sqlite") and pd.plain_day(plain.replace("order_date AS", "DATE(order_date) AS"), + "day", "sqlite") + assert not pd.plain_day(starts, "week", "sqlite") + assert pd.parse_compared(["2026-10-13", "2026-10-20"], plain, "day", "sqlite")[1] == "day" + assert pd.parse_compared(["2026-10-12", "2026-10-19"], starts, "week", "sqlite")[1] == "week" + assert pd.parse_compared(["2026-09-01", "2026-10-01"], plain, "day", "sqlite")[1] == "month"