Files
AgentLab/backend/tests/test_recipe_notes_p3.py
T
ThearchyHelios a137182145 Excel 期 5:列表按期累积、穿插的小计、表格对象起草、CSV 走配方(E)
为什么:每月一份的明细导出是最常见的场景,期 4 之前列表只能每期替换;夹在中间的小计会让规则起草失败;
表格对象这个现成的边界信号没用上;CSV 不能走配方。四项都复用现有的执行器、核对、累积、溯源,不另起一套。

- 列表按期累积:ListBlock.period 二选一——{column, check: within_context} 按日期列分期,试运行多一条结构类核对
  D(每行的日期都在本期统计期内,空着也不行);{synthesize: 统计期} 每行加「统计期起」「统计期止」两列,列表在
  统计期定下来之前就抽完了,这两格先放占位、写库时换成真值。accumulate_blockers 放行绑定了统计期的列表;合计行、
  小计行另存的表各期主键分不开,照旧不能累积;同一订单两期都出现由并集的主键核对拒收。U3 按列表的分期方式核对。
  说明里「按哪一列筛选」用分期列,加的两列有列说明;空行之后文字行的说法补了并集版本(列表能累积以后要用到)。
- 穿插的小计:ListRows.subtotals {label_column, pick, scope: since_previous | group_column, group_column, keep_as}。
  小计行不进数据,每个数字格一条合计格(分段键是块 id + ·小计),核对模块按它所管那组明细的 rowid 区间用 SQL
  求和比对(新 T 编号,标题「小计行按各组明细求和」),整行记进排除的行(reason=subtotal),可以另存成
  reported_total 表(小计项、分组取值、各数字列);数字格全空报 subtotal_blank(结构类);合计行的公式引用了小计格
  时按它管的明细换算。静态校验:标签列、分组列要在列里,按组小计要给分组列,小计和合计的开头词不能互为前缀。
  规则起草把第一列同一个合计字样、下面还有明细的行提议成小计(以前记 stray_totals 失败),框选同一条规则。
- 表格对象起草:工作表有表格对象时按它出列表块:表头行、列范围、数据行、汇总行(映射成 total_row,标签取汇总行
  第一列的文字)都由表格对象给,锚点仍按表头文字、不记坐标;表名优先用表格对象的名字(含数字的不用)。不调用 AI。
- CSV 走配方:新增 csv_grid,按内容认出 CSV(不是 zip、不是 OLE、开头没有 NUL),编码和分隔符复用 tabular 读 CSV
  的那一段,适配成一张名为「CSV」的工作表(SheetScan.path=csv:,WorkbookScan.format=csv);只有写法毫无歧义的
  整数、小数转成数,千分位、前导零、日期写法一律留作文字交给配方的规则。坐标沿用「工作表!A1」(偏离细案的
  R{行}C{列},理由见 csv_grid 的模块说明)。CSV 不做稀疏判定。导入向导和简单上传的结构提示都放行 CSV。
- 每个列表一道题「每期怎么更新」(q_list_period):每期替换 / 按期累积并按某个日期列核对 / 按期累积并加统计期
  起止两列,后两种同时把导入模式改成按期累积、缺统计期的工作表一并配上(问题集合按草稿生成、回答时不变,草稿里
  的列表还不符合累积资格,q_mode 不会出现,所以并成一道题)。确认项新增 subtotals:<块>、list_period:<块>。
- 前端配方面板:列表块多「小计行」(标签列、开头词、管哪些明细、分组列、另存)和「按期累积时每一行属于哪一期」;
  回执「排除的行」认 subtotal。

验证:新增 tests/test_csv_grid.py、test_recipe_p5.py(执行器、核对、校验、资格、并集物化、起草)、
test_recipe_p5_e2e.py(经接口:三份月度 CSV 累积、部分重叠拒收、同一期替换要确认;带穿插小计的 Excel 起草、
核对、提交)。去掉小计识别、统计期回填、D 核对分别回跑,新测试 10 条失败。旧测试里「夹在中间的小计一律失败」
「列表只能每期替换」的断言按新行为改写。
2026-10-16 11:09:39 +08:00

584 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""期 3 的说明(recipe_notes,P3-SPEC 2.8、3.2 ⑥):build_notes 返回片段、单期的忽略说明、按期累积的并集说明。
不依赖执行器、核对模块的库和 WP-4 的物化:核对结果、Extraction、各期空值数都在测试里手写(假名、假数),
并集的表结构用 derive_tables 加手写的退役列模拟 materialize_union 的 UnionReport.tables。
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
import pytest
from app.data import recipe_parsers as P
from app.data.recipe_notes import (
COLUMN_TEXT, CONSISTENT_KEYS, NOTE_TEXT, add_single_after_drop, build_notes, build_union_notes,
)
from app.data.recipe_types import (
CheckResult, ColumnOut, ExcludedRows, Extraction, OutsideText, PeriodOut, Recipe, SheetsOut, UnionNotePart,
derive_tables, prose_problems,
)
from tests.test_recipe_notes import (
FLOW, LIST_RECIPE, NULLS, SHEET, TOTALS, accept, chk, flow_checks, flow_extraction, list_item, notes_for,
)
AUG = ("2026-08-01", "2026-08-31")
SEP = ("2026-09-01", "2026-09-30")
OCT = ("2026-10-01", "2026-10-31")
#: 期 3 新增的模板(P3-SPEC 2.8 的代码块,加上单期的 ignored_rows、ignored_cells)
NEW_NOTE_KEYS = {
"accumulate", "accumulate_gap", "single_after_drop", "table_partial_periods", "table_absent",
"sum_eq_union_mixed", "sum_eq_union_pending", "sum_eq_union_partial", "sum_eq_members_vary",
"total_union_unverified", "period_conflict_union", "period_conflict_union_pending", "period_human_union",
"period_human_all", "outside_digits_union", "hidden_excluded_union", "hidden_included_union",
"ignored_columns_union", "ignored_rows", "ignored_rows_union", "ignored_cells", "ignored_cells_union",
"dim_values_vary",
}
NEW_COLUMN_KEYS = {"col_partial_periods", "col_absent", "col_retired"}
def acc(data: dict | None = None) -> dict:
d = deepcopy(data or FLOW)
d["mode"] = "accumulate"
return d
def part(data: dict | None = None, span: tuple[str, str] = AUG, *, checks: list[CheckResult] | None = None,
acceptances: list | None = None, nulls: Any = NULLS, **ex: Any) -> UnionNotePart:
ex.setdefault("period", PeriodOut(span[0], span[1], "cells", [f"{SHEET}!B2"]))
return UnionNotePart(Recipe.model_validate(acc(data)), flow_extraction(**ex),
flow_checks() if checks is None else checks, acceptances or [], nulls, span[0], span[1])
def union(parts: list[UnionNotePart], target: dict | None = None, *,
extra_cols: dict[str, list[ColumnOut]] | None = None, extra_tables: dict[str, list[ColumnOut]] | None = None,
part_nulls: list | None = None, **kw: Any):
"""并集的表结构 = 目标配方推出的表 + 退役的列(extra_cols)+ 退役的表(extra_tables),模拟 UnionReport.tables。"""
tgt = Recipe.model_validate(acc(target)) if target is not None else parts[-1].recipe
tables, _ = derive_tables(tgt)
tables = {t: list(cs) for t, cs in tables.items()}
for t, cols in (extra_cols or {}).items():
tables[t] = tables.get(t, []) + cols
tables.update(extra_tables or {})
kw.setdefault("null_counts", None)
kw.setdefault("added", [])
kw.setdefault("retired", [])
kw.setdefault("label_sets", [])
kw.setdefault("gaps", False)
return build_union_notes(tgt, parts, union_tables=tables,
part_null_counts=part_nulls if part_nulls is not None else [p.null_counts for p in parts],
**kw)
def comment(notes, table: str = "日客流") -> str:
return notes.tables[table].comment
def without_r1_and_b() -> dict:
"""A11:去掉 measures 的分区乙,R1 不再登记(关系删掉)。"""
d = deepcopy(FLOW)
seg = d["sheets"][0]["blocks"][0]["segments"][0]
seg["labels"]["expect"] = ["全日客流(人次)", "分区甲(人次)"]
seg["measures"] = {"全日客流(人次)": "全日客流", "分区甲(人次)": "分区甲"}
d["tables"][0]["units"] = {"全日客流": "人次", "分区甲": "人次"}
d["relations"] = d["relations"][1:]
return d
def with_c() -> dict:
"""D13:日客流 加「分区丙(人次)」,R1 成员按系统发现更新。"""
d = deepcopy(FLOW)
seg = d["sheets"][0]["blocks"][0]["segments"][0]
seg["labels"]["expect"].append("分区丙(人次)")
seg["measures"]["分区丙(人次)"] = "分区丙"
d["tables"][0]["units"]["分区丙"] = "人次"
d["relations"][0]["parts"] = ["分区甲", "分区乙", "分区丙"]
return d
def no_totals() -> dict:
"""合计行改为不另存(keep_as 置空):时段客流_表内合计 退役。"""
d = deepcopy(FLOW)
d["sheets"][0]["blocks"][0]["segments"][3]["keep_as"] = None
d["tables"] = d["tables"][:2]
return d
NULLS_C = {**NULLS, "日客流": {**NULLS["日客流"], "分区丙": 0}}
NULLS_NO_B = {**NULLS, "日客流": {k: v for k, v in NULLS["日客流"].items() if k != "分区乙"}}
# ---------------------------------------------------------------- build_notes 返回片段,渲染不变
@pytest.mark.parametrize("override", [
{},
{"R1": chk("R1", "relation_sum_eq", "mismatch", "data_quality", checked=30, failed=1, acceptable=True)},
{"K1": chk("K1", "derived_sum", "unverifiable", acceptable=True, reasons={"uncached": 3})},
{"R2": chk("R2", "relation_not_comparable", "info", "info", checked=31, failed=0)},
{"C1": chk("C1", "context_agree", "mismatch", "data_quality", acceptable=True)},
])
def test_fragments_join_to_the_same_template(override):
"""片段只是多返回的结构:按顺序用「。」拼起来,就是期 2 的表说明模板,一字不差。"""
notes = notes_for(checks=flow_checks(**override))
for table, frags in notes.fragments.items():
assert "。".join(f.template for f in frags) == notes.templates.get(table, "")
assert set(notes.fragments) == set(notes.tables)
def test_reference_fragments_keys_and_subjects():
notes = notes_for()
got = {t: [(f.key, f.subject) for f in fs] for t, fs in notes.fragments.items()}
assert got["日客流"] == [("grain_1", "日客流"), ("sum_eq_passed", "R1"), ("not_comparable_unequal", "R2")]
assert got["时段客流"] == [("grain_2", "时段客流"), ("not_comparable_unequal", "R2"), ("hour_range", "时段"),
("const", "时段类别"), ("total_kept", TOTALS)]
assert got[TOTALS] == [("grain_2", TOTALS), ("total_k_passed", TOTALS), ("total_overlap", "时段客流")]
# 每个片段的 key 都是 NOTE_TEXT 的键(用户写的表说明记作 note)
data = deepcopy(FLOW)
data["tables"][0]["note"] = "{列:分区甲} 只统计闸机"
keys = {f.key for fs in notes_for(data).fragments.values() for f in fs}
assert keys <= set(NOTE_TEXT) | {"note"} and "note" in keys
# ---------------------------------------------------------------- 单期:按配方忽略的行、单元格、列(⑥)
def test_ignored_rows_note_only_for_tables_of_that_block():
ex = flow_extraction(rows_excluded=[ExcludedRows(SHEET, "ignored_rows", [[32, 32]], 3, "补录(人次)", "交叉表")])
notes = notes_for(extraction=ex)
assert notes.problems == []
for t in ("日客流", "时段客流", TOTALS):
assert "原表另有按配方忽略的行,内容见证据面板" in comment(notes, t)
assert "补录" not in comment(notes, t)
# 别的块、别的原因、没有行:都不写
for e in (ExcludedRows(SHEET, "ignored_rows", [[32, 32]], 3, "补录", "别的块"),
ExcludedRows(SHEET, "blank_skipped", [[9, 9]], 0, None, "交叉表"),
ExcludedRows(SHEET, "ignored_rows", [], 0, "补录", "交叉表")):
assert "按配方忽略的行" not in comment(notes_for(extraction=flow_extraction(rows_excluded=[e])))
def test_ignored_cells_note_for_tables_of_that_sheet_and_dict_form():
e = ExcludedRows(SHEET, "ignored_outside", [[33, 33]], 2, "补录说明", None)
notes = notes_for(extraction=flow_extraction(rows_excluded=[e]))
assert all("原表另有按配方忽略的单元格,内容见证据面板" in n.comment for n in notes.tables.values())
# 暂存区、清单里存的是 JSON 形状:dict 一样认
as_dict = {"sheet": SHEET, "reason": "ignored_outside", "rows": [[33, 33]], "cells": 2, "anchor": "补录说明",
"block": None}
assert "按配方忽略的单元格" in comment(notes_for(extraction=flow_extraction(rows_excluded=[as_dict])))
other = ExcludedRows("别的表", "ignored_outside", [[33, 33]], 2, "补录说明", None)
assert "按配方忽略的单元格" not in comment(notes_for(extraction=flow_extraction(rows_excluded=[other])))
def test_crosstab_ignore_columns_reuse_ignored_columns_note():
data = deepcopy(FLOW)
data["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "合计", "reason": "右侧的月合计列不导入"}]
ex = flow_extraction(ignored_columns={"交叉表": ["合计"]})
assert "原表另有未导入的列" in comment(notes_for(data, extraction=ex))
# 配了规则、本期没有命中:不写;没配规则(期 2 的交叉表):照期 2 不写
assert "未导入的列" not in comment(notes_for(data))
assert "未导入的列" not in comment(notes_for(extraction=ex))
# ---------------------------------------------------------------- 新模板:过数字和引用检查,不含「一致」
_SLOTS = {"col": "{列:日期}", "dim": "{列:时段}", "total": "{列:全日客流}", "parts": "{列:分区甲}、{列:分区乙}",
"unit": "日期"}
_KNOWN = {"列": {"日期", "时段", "全日客流", "分区甲", "分区乙"}, "表": set(), "单位": set(P.UNITS)}
@pytest.mark.parametrize("key", sorted(NEW_NOTE_KEYS))
def test_new_table_templates_pass_prose_check_and_never_say_consistent(key):
assert key in NOTE_TEXT and key not in CONSISTENT_KEYS
text = NOTE_TEXT[key].format(**_SLOTS)
assert prose_problems(text, known=_KNOWN) == []
assert "一致" not in text
@pytest.mark.parametrize("key", sorted(NEW_COLUMN_KEYS))
def test_new_column_templates_pass_prose_check(key):
assert prose_problems(COLUMN_TEXT[key], known=_KNOWN) == [] and "一致" not in COLUMN_TEXT[key]
def test_pending_templates_never_say_accepted():
for key in ("sum_eq_union_pending", "period_conflict_union_pending"):
assert "已由用户确认接受" not in NOTE_TEXT[key] and "尚未确认接受" in NOTE_TEXT[key]
for key in ("sum_eq_union_partial", "sum_eq_union_mixed", "sum_eq_union_pending"):
assert "已逐" not in NOTE_TEXT[key] and "导入时已" not in NOTE_TEXT[key]
# ---------------------------------------------------------------- 并集:参考配方两期
def test_two_periods_reference_union():
notes = union([part(), part(span=SEP)])
assert notes.problems == []
assert comment(notes) == (
"粒度:每个日期。全日客流 等于 分区甲 与 分区乙 之和(导入时已逐日核对):求合计只用 全日客流,不要把这几列相加。"
"与 时段客流 口径不同:各时段之和不等于 全日客流,不能互相推算或相加。"
"本表按统计期累积多期数据,各期互不重叠:比较不同期时请按 日期 筛选")
assert comment(notes, TOTALS).startswith("粒度:每个日期的每个合计项。原表写明的合计,已按明细重算核对一致")
assert notes.kinds == {TOTALS: "reported_total"}
assert notes.tables["日客流"].columns == {"日期": "格式 YYYY-MM-DD,年份按统计期补全", "全日客流": "单位:人次",
"分区甲": "单位:人次", "分区乙": "单位:人次"}
# 片段就是渲染前的模板,按顺序拼起来
for t, frags in notes.fragments.items():
assert "。".join(f.template for f in frags) == notes.templates[t]
assert all("当前版本只含最近一期" not in n.comment for n in notes.tables.values())
def test_gap_uses_gap_sentence():
notes = union([part(), part(span=OCT)], gaps=True)
assert "各期之间有空缺:比较不同期之前请先查询 日期 的覆盖范围" in comment(notes)
assert "各期互不重叠:比较不同期时请按" not in comment(notes)
# ---------------------------------------------------------------- 并集:sum_eq 的每一行
R1_MISMATCH = chk("R1", "relation_sum_eq", "mismatch", "data_quality", checked=30, failed=1, acceptable=True)
def test_sum_eq_one_period_accepted_mismatch_is_mixed():
"""D26 与 8 月累积(P3-SPEC 2.8 的例子):不再出现「已逐日核对」,用 sum_eq_union_mixed。"""
notes = union([part(), part(span=SEP, checks=flow_checks(R1=R1_MISMATCH), acceptances=accept("R1"))])
assert notes.problems == []
c = comment(notes)
assert ("全日客流 与 分区甲、分区乙 之和的关系在部分期的个别日期不成立或未能核对,详见导入清单:"
"不要据此推算,也不要把这几列相加") in c
assert "已逐日核对" not in c and "求合计只用" not in c
@pytest.mark.parametrize("r1", [
chk("R1", "relation_sum_eq", "unverifiable", "data_quality", unverifiable=2, acceptable=True),
chk("R1", "relation_sum_eq", "unverifiable", "data_quality", checked=0, unverifiable=0, acceptable=True),
])
def test_sum_eq_nulls_or_no_rows_is_mixed(r1):
c = comment(union([part(checks=flow_checks(R1=r1), acceptances=accept("R1")), part(span=SEP)]))
assert "在部分期的个别日期不成立或未能核对" in c and "已逐日核对" not in c
def test_sum_eq_pending_never_says_accepted():
"""试运行预览:本期按「全部未接受」生成。早期那一期接受过也不行:合并结果不得出现「已由用户确认接受」。"""
old = part(checks=flow_checks(R1=R1_MISMATCH), acceptances=accept("R1"))
new = part(span=SEP, checks=flow_checks(R1=R1_MISMATCH))
c = comment(union([old, new]))
assert "尚未确认接受" in c and "已由用户确认接受" not in c and "已逐日核对" not in c
# 反例:两期都接受了,才写「不成立或未能核对」(不是「尚未确认接受」)
both = comment(union([old, part(span=SEP, checks=flow_checks(R1=R1_MISMATCH), acceptances=accept("R1"))]))
assert "尚未确认接受" not in both and "不成立或未能核对" in both
def test_sum_eq_members_vary():
"""D13 修复后累积:8 月 R1 = 甲 + 乙,9 月 R1 = 甲 + 乙 + 丙,都通过:sum_eq_members_vary。"""
notes = union([part(), part(with_c(), SEP, nulls=NULLS_C)], part_nulls=[NULLS, NULLS_C])
assert notes.problems == []
c = comment(notes)
assert "全日客流 等于各期登记的分项列之和,各期登记的分项列不同(某期没有的列为空值),导入时已逐期核对" in c
assert "已逐日核对" not in c
# 列:8 月没有分区丙,结构性空值由 col_partial_periods 说明;9 月这一列本身没有空值,不写「原表为空格」
assert notes.tables["日客流"].columns["分区丙"] == "单位:人次。部分期没有这一列的数据,为空值"
def test_sum_eq_reordered_parts_are_the_same_members():
"""9 月的 R1 只把分项倒了个序:成员相同,用目标那一期(9 月)的「已逐日核对」句子,不写「各期登记的分项列不同」
(这句会进模型可见的说明,评审意见)。"""
swapped = deepcopy(FLOW)
swapped["relations"][0]["parts"] = list(reversed(swapped["relations"][0]["parts"]))
notes = union([part(), part(swapped, SEP)])
assert notes.problems == []
c = comment(notes)
assert "全日客流 等于 分区乙 与 分区甲 之和(导入时已逐日核对)" in c
assert "各期登记的分项列不同" not in c
# 反例:成员真的不同(D13 加了分区丙)仍是 sum_eq_members_vary
assert "各期登记的分项列不同" in comment(union([part(), part(with_c(), SEP, nulls=NULLS_C)],
part_nulls=[NULLS, NULLS_C]))
def test_sum_eq_partial_when_target_period_drops_relation():
"""A11:9 月去掉分区乙、R1 不再登记。8 月有 R1(通过),9 月没有:sum_eq_union_partial,成员取 8 月的。"""
notes = union([part(), part(without_r1_and_b(), SEP, nulls=NULLS_NO_B)],
extra_cols={"日客流": [ColumnOut("分区乙", "INTEGER", header=None, unit=None, role="measure")]},
part_nulls=[NULLS, {**NULLS_NO_B, "日客流": {**NULLS_NO_B["日客流"], "分区乙": 30}}])
assert notes.problems == []
c = comment(notes)
assert ("全日客流 与 分区甲、分区乙 之和的关系只在部分期登记并核对,其余各期未核对,详见导入清单:"
"不要据此推算,也不要把这几列相加") in c
assert "已逐日核对" not in c
# 退役列:单位取最后一个含它的那一期(8 月)的,并集给的 unit 为空也一样;说明 col_retired
col = notes.tables["日客流"].columns["分区乙"]
assert col == "单位:人次。这一列自某一期起不再导入,此后各期为空值"
assert "原表为空格" not in col
def test_sum_eq_partial_when_only_new_period_registers_it():
"""反过来:8 月没登记 R1,9 月登记了。照样是「只在部分期登记并核对」,不沿用 9 月的「已逐日核对」。"""
old = deepcopy(FLOW)
old["relations"] = old["relations"][1:]
c = comment(union([part(old, checks=flow_checks(R1=None)), part(span=SEP)]))
assert "只在部分期登记并核对" in c and "已逐日核对" not in c
# ---------------------------------------------------------------- 并集:not_comparable、K、其余各族
def test_not_comparable_equal_in_one_period_drops_not_equal():
eq = chk("R2", "relation_not_comparable", "info", "info", checked=30, failed=0)
notes = union([part(), part(span=SEP, checks=flow_checks(R2=eq))])
for t in ("日客流", "时段客流"):
assert "口径不同,不能互相推算或相加" in comment(notes, t) and "不等于" not in comment(notes, t)
both = union([part(), part(span=SEP)])
assert "各时段之和不等于 全日客流" in comment(both)
@pytest.mark.parametrize("reason", ["uncached", "blank", "null_detail", "tiling", "hidden"])
def test_k_not_passed_in_any_period_never_says_consistent(reason):
k = chk("K1", "derived_sum", "unverifiable", acceptable=True, reasons={reason: 3})
for order in ((part(), part(span=SEP, checks=flow_checks(K1=k), acceptances=accept("K1"))),
(part(checks=flow_checks(K1=k), acceptances=accept("K1")), part(span=SEP))):
c = comment(union(list(order)), TOTALS)
assert "原表写明的合计,按原表保存的值存储。部分期的合计值未能核对,详见导入清单" in c
assert "一致" not in c
def test_k_passed_g_unverifiable_in_one_period():
g = chk("G1", "formula_refs", "unverifiable", acceptable=True, reasons={"unrecognized": 1})
c = comment(union([part(), part(span=SEP, checks=flow_checks(G1=g), acceptances=accept("G1"))]), TOTALS)
assert "原表写明的合计,合计值已按明细核对一致,公式引用的格子未能核对" in c
def test_k_blocking_in_new_period_is_not_inherited():
"""本期 K 不一致(试运行本来就拒收):本期的合计片段不写,合并时不能沿用 8 月的「核对一致」。"""
bad = chk("K1", "derived_sum", "mismatch", failed=1)
c = comment(union([part(), part(span=SEP, checks=flow_checks(K1=bad))]), TOTALS)
assert "一致" not in c and "部分期的合计值未能核对" in c
@pytest.mark.parametrize("ex,expect", [
({"hidden": {SHEET: {"rows": [12], "cols": [], "policy_rows": "exclude"}}}, "部分期的原表有被隐藏的行,本表不含这些行"),
({"outside_text": [OutsideText(SHEET, "B32", "注:9月15日闸机故障", "text_digits")]},
"部分期的原表附有说明文字(可能涉及口径),内容见证据面板"),
({"rows_excluded": [ExcludedRows(SHEET, "ignored_rows", [[32, 32]], 3, "补录", "交叉表")]},
"部分期的原表另有按配方忽略的行,内容见证据面板"),
({"rows_excluded": [ExcludedRows(SHEET, "ignored_outside", [[33, 33]], 2, "补录说明", None)]},
"部分期的原表另有按配方忽略的单元格,内容见证据面板"),
])
def test_any_period_families_use_union_wording(ex, expect):
data = deepcopy(FLOW)
if "hidden" in ex:
data["sheets"][0]["hidden"] = {"rows": "exclude"}
notes = union([part(data), part(data, SEP, **ex)], target=data)
assert notes.problems == []
assert expect in comment(notes)
assert "本期" not in comment(notes)
assert expect not in comment(union([part(data), part(data, SEP)], target=data))
def test_hidden_included_and_ignored_columns_union():
data = deepcopy(FLOW)
data["sheets"][0]["hidden"] = {"rows": "include"}
data["sheets"][0]["blocks"][0]["ignore_columns"] = [{"header": "合计", "reason": "月合计列不导入"}]
p = part(data, SEP, hidden={SHEET: {"rows": [12], "cols": [], "policy_rows": "include"}},
ignored_columns={"交叉表": ["合计"]})
c = comment(union([part(data), p], target=data))
assert "部分期的原表有被隐藏的行,本表包含这些行" in c and "部分期的原表另有未导入的列" in c
def test_period_conflict_pending_and_accepted():
c1 = chk("C1", "context_agree", "mismatch", "data_quality", acceptable=True)
pending = comment(union([part(checks=flow_checks(C1=c1), acceptances=accept("C1")),
part(span=SEP, checks=flow_checks(C1=c1))]))
assert "部分期的统计期写法在原表或文件名中有冲突,尚未确认接受" in pending
assert "已由用户确认接受" not in pending
accepted = comment(union([part(checks=flow_checks(C1=c1), acceptances=accept("C1")), part(span=SEP)]))
assert "部分期的统计期写法在原表或文件名中有冲突,已由用户确认接受,详见导入清单" in accepted
assert "冲突" not in comment(union([part(), part(span=SEP)]))
def test_period_human_some_and_all():
human = {"period": PeriodOut(SEP[0], SEP[1], "human", signed_by="甲")}
some = comment(union([part(), part(span=SEP, **human)]))
assert "部分期的统计期为人工录入" in some and "均为" not in some
both = comment(union([part(period=PeriodOut(AUG[0], AUG[1], "human")), part(span=SEP, **human)]))
assert "各期的统计期均为人工录入" in both
def test_dim_values_vary():
ls = [{"table": "时段客流", "column": "时段", "segment": "日间",
"periods": [{"start": SEP[0], "end": SEP[1], "missing": ["7-8"], "extra": []}]}]
notes = union([part(), part(span=SEP)], label_sets=ls)
assert "各期的 时段 取值不完全相同:跨期比较前请先确认两期都有的取值" in comment(notes, "时段客流")
assert "取值不完全相同" not in comment(notes)
# ---------------------------------------------------------------- 并集:表的种类、退役的表、部分期的表
def test_retired_total_table_keeps_kind_and_last_period_description():
"""目标配方把合计行改为不另存:时段客流_表内合计 退役。并集里仍标 reported_total,说明取 8 月的(只有 8 月有行);
明细表同时写着「合计在 时段客流_表内合计」(8 月)和「合计行未导入本表」(9 月)。"""
totals_cols = derive_tables(Recipe.model_validate(FLOW))[0][TOTALS]
notes = union([part(), part(no_totals(), SEP, checks=flow_checks(K1=None, G1=None))],
extra_tables={TOTALS: list(totals_cols)})
assert notes.problems == []
assert notes.kinds == {TOTALS: "reported_total"}
t = comment(notes, TOTALS)
assert "原表写明的合计,已按明细重算核对一致" in t and "部分期没有这张表的数据" in t
base = comment(notes, "时段客流")
assert "原表的合计在 时段客流_表内合计,不要与本表相加" in base and "原表的合计行未导入本表" in base
def test_new_table_only_in_new_period_is_partial():
totals_free = no_totals()
notes = union([part(totals_free, checks=flow_checks(K1=None, G1=None)), part(span=SEP)])
assert notes.problems == []
t = comment(notes, TOTALS)
assert "部分期没有这张表的数据" in t and "原表写明的合计,已按明细重算核对一致" in t
assert "部分期没有这张表的数据" not in comment(notes)
# ---------------------------------------------------------------- 并集:空值只按含这一列的期
def test_nulls_only_from_periods_that_have_the_column():
"""8 月没有分区丙(并集里是结构性空值),9 月分区丙没有空值:不写「空值表示原表为…」;9 月真有空值才写。"""
sep_nulls = {**NULLS_C, "日客流": {**NULLS_C["日客流"], "分区丙": 2}}
data = with_c()
data["sheets"][0]["blocks"][0]["values"]["blank"] = "null"
clean = union([part(), part(data, SEP, nulls=NULLS_C)], target=data, part_nulls=[NULLS, NULLS_C],
null_counts={"日客流": {"分区丙": 31}})
assert clean.tables["日客流"].columns["分区丙"] == "单位:人次。部分期没有这一列的数据,为空值"
dirty = union([part(), part(data, SEP, nulls=sep_nulls)], target=data, part_nulls=[NULLS, sep_nulls])
col = dirty.tables["日客流"].columns["分区丙"]
assert "空值表示原表为无数据占位符或空格" in col and col.endswith("部分期没有这一列的数据,为空值")
def test_placeholder_meanings_differ_between_periods():
sep_data = deepcopy(FLOW)
sep_data["sheets"][0]["blocks"][0]["values"]["placeholders"] = [{"text": "—", "meaning": "不适用"}]
notes = union([part(), part(sep_data, SEP, placeholders={"—": 5})], target=sep_data)
assert notes.tables["时段客流"].columns["客流"] == (
"单位:人次。空值表示原表为无数据或不适用占位符,不代表零;求和时请同时统计非空个数")
same = union([part(), part(span=SEP)])
assert "无数据占位符" in same.tables["时段客流"].columns["客流"]
def test_union_null_counts_zero_skips_null_note():
notes = union([part(), part(span=SEP)], null_counts={"时段客流": {"客流": 0}})
assert notes.tables["时段客流"].columns["客流"] == "单位:人次"
# ---------------------------------------------------------------- 单期物化(移除、作废之后)与 restart
def test_single_materialized_part_uses_col_absent_and_no_partial_wording():
"""A17:只剩 8 月、而目标配方是 9 月加了分区丙的 r3:分区丙 col_absent,不说「部分期」;加 single_after_drop。"""
notes = union([part()], target=with_c())
assert notes.problems == []
assert notes.tables["日客流"].columns["分区丙"] == "单位:人次。当前版本的数据中没有这一列,为空值"
for n in notes.tables.values():
assert "部分期" not in n.comment and "当前版本只含最近一期的数据,此前各期不在当前版本中" in n.comment
assert "按统计期累积多期数据" not in n.comment
# 单期时那一期自己的说法就是准确的
assert "导入时已逐日核对" in comment(notes)
def test_single_materialized_part_table_absent():
notes = union([part(no_totals(), checks=flow_checks(K1=None, G1=None))], target=FLOW)
assert notes.problems == []
assert comment(notes, TOTALS) == ("粒度:每个日期的每个合计项。当前版本的数据中没有这张表的行。"
"当前版本只含最近一期的数据,此前各期不在当前版本中")
assert set(notes.tables[TOTALS].columns.values()) >= {"单位:人次。当前版本的数据中没有这一列,为空值"}
def test_add_single_after_drop_appends_without_touching_input():
notes = notes_for()
before = {t: n.comment for t, n in notes.tables.items()}
out = add_single_after_drop(notes)
assert {t: n.comment for t, n in notes.tables.items()} == before
for t, n in out.tables.items():
assert n.comment == before[t] + "。当前版本只含最近一期的数据,此前各期不在当前版本中"
assert out.fragments[t][-1].key == "single_after_drop"
assert "。".join(f.template for f in out.fragments[t]) == out.templates[t]
assert add_single_after_drop(out).tables["日客流"].comment == out.tables["日客流"].comment
# ---------------------------------------------------------------- 「目标那一期」与结构类片段
def test_structural_fragments_come_from_target_period_even_when_backfilled():
"""补传早期:本期(目标配方)排在最前。用户写的表说明取目标那一期的,不取最晚那一期的。"""
old, new = deepcopy(FLOW), deepcopy(FLOW)
old["tables"][0]["note"] = "{列:分区甲} 只统计东门"
new["tables"][0]["note"] = "{列:分区甲} 只统计闸机"
notes = union([part(new, ("2026-07-01", "2026-07-31")), part(old, AUG)], target=new)
assert comment(notes).endswith("分区甲 只统计闸机") and "东门" not in comment(notes)
# ---------------------------------------------------------------- 输入不对:记 problems,不能保存
def test_problems_for_inconsistent_inputs():
assert union([part(), part(span=SEP)], dropped=True).problems
assert build_union_notes(Recipe.model_validate(acc()), [], union_tables={}, null_counts=None,
part_null_counts=None, added=[], retired=[], label_sets=[], gaps=False).problems
# 并集的表结构漏了某一期的表
tgt = Recipe.model_validate(acc())
tables = {t: cs for t, cs in derive_tables(tgt)[0].items() if t != TOTALS}
notes = build_union_notes(tgt, [part(), part(span=SEP)], union_tables=tables, null_counts=None,
part_null_counts=None, added=[], retired=[], label_sets=[], gaps=False)
assert any(TOTALS in p for p in notes.problems)
# 各期空值数的期数对不上
assert union([part(), part(span=SEP)], part_nulls=[NULLS]).problems
def test_list_tables_cannot_be_merged_and_are_problems():
"""列表合计行另存的表不能按期累积(accumulate_blockers):真把两期这样的列表并起来,列表合计没有并集说法,记
problems,绝不沿用单期说法。"""
data = deepcopy(LIST_RECIPE)
def lpart(span):
ex = Extraction(ok=True, derived=[list_item(c) for c in ("金额", "现金", "刷卡")],
sheets=SheetsOut(matched={"s1": "销售"}), lineage={"销售": {"金额": [[1, "销售", "D2", 6, "down"]]}})
checks = [chk("T1", "column_sum"), chk("R1", "relation_sum_eq", category="data_quality")]
return UnionNotePart(Recipe.model_validate(data), ex, checks, accept("T1"), None, span[0], span[1])
tgt = Recipe.model_validate(data)
notes = build_union_notes(tgt, [lpart(AUG), lpart(SEP)], union_tables=derive_tables(tgt)[0], null_counts=None,
part_null_counts=None, added=[], retired=[], label_sets=[], gaps=False)
assert any("列表合计行、小计行的核对结论不能按期合并" in p for p in notes.problems)
assert not any("list_total" in p for p in notes.problems) # 不露键名
assert "一致" not in notes.tables["销售_表内合计"].comment
def test_every_union_note_passes_prose_check():
notes = union([part(), part(with_c(), SEP, nulls=NULLS_C, checks=flow_checks(R1=R1_MISMATCH))],
part_nulls=[NULLS, NULLS_C], gaps=True,
label_sets=[{"table": "时段客流", "column": "时段", "periods": []}])
assert notes.problems == []
known = {"列": {c.name for cs in derive_tables(Recipe.model_validate(with_c()))[0].values() for c in cs},
"表": {"日客流", "时段客流", TOTALS}, "单位": set(P.UNITS)}
for key, tpl in notes.templates.items():
assert prose_problems(tpl, known=known) == [], key
# ---------------------------------------------------------------- 冻结白名单
async def test_store_schema_freezes_snapshot_manifest():
from types import SimpleNamespace
from app.core.artifact_store import load
from app.tools.datasource import _FROZEN_SCHEMA_KEYS, _store_schema
from tests.test_recipe_notes import _cache, _source
assert "snapshot_manifest" in _FROZEN_SCHEMA_KEYS
ctx = SimpleNamespace(run_id="", node_id="")
cache = {**_cache(), "import_mode": "recipe", "import_manifests": ["a" * 64, "b" * 64],
"snapshot_manifest": "s" * 64}
frozen = load(await _store_schema(_source(cache), ctx))
assert frozen["snapshot_manifest"] == "s" * 64 and frozen["import_manifests"] == ["a" * 64, "b" * 64]
# 没有快照清单的源(单期、手工源):冻结内容里没有这个键
one = {**_cache(), "import_mode": "recipe", "import_manifests": ["a" * 64]}
single = load(await _store_schema(_source(one), ctx))
assert "snapshot_manifest" not in single