mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:41:59 +00:00
2cdbf9a733
按期累积(新 recipe_accumulate;table_versions 扩展) - 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图 - 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对 - 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」 - 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用 - 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查 修复按钮(新 recipe_fixes;执行器给问题带结构化参数) - 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名 - 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单 - 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对 框选转锚点(新 recipe_select) - 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因 - 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理 确认与对照(recipe_confirm、recipe_diff、新 recipe_compare) - 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方 - 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答 - 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行 版本页(新 source_versions、api/source_versions;前端 VersionsDialog) - 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期 - 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认 期 1、期 2 遗留 - 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500 - 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id - 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
583 lines
33 KiB
Python
583 lines
33 KiB
Python
"""期 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():
|
||
"""列表不能按期累积(P3-SPEC 2.2):真把两期列表并起来,列表合计没有并集说法,记 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
|