Files
AgentLab/backend/tests/test_recipe_suggest.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

1278 lines
60 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.
"""WP-4 确定性起草(recipe_suggest)的测试。
夹具一律在这里用 openpyxl 现造(假名:分区甲 / 分区乙,表名「客流汇总」),网格由 grid_from_ws 从 openpyxl 普通模式
直接造出来,不依赖读取层(WP-2);validate / dry_run 用桩,不依赖静态校验(WP-1)和执行器(WP-2)。
"""
from __future__ import annotations
import copy
import datetime as dt
import io
import json
import random
from pathlib import Path
from typing import Any
import pytest
from openpyxl import Workbook
from openpyxl.utils import column_index_from_string, get_column_letter
from app.data import recipe_suggest as S
from app.data.recipe_types import (
REASON_SLOT,
AxisOut,
Extraction,
Grid,
GridCell,
OutsideText,
PeriodOut,
Problem,
Recipe,
RecipeProblem,
)
from app.data.xlsx_scan import scan as xlsx_scan
FLOW = json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text(encoding="utf-8"))
# ==========================================================================
# 夹具工具(test_recipe_ai.py 也用)
# ==========================================================================
def grid_from_ws(ws: Any, cache: dict[str, Any] | None = None) -> Grid:
"""openpyxl 普通模式的工作表 → Grid。公式格的保存值由 cache(坐标 → 值)给出(openpyxl 写公式不带缓存)。"""
cache = cache or {}
cells: dict[tuple[int, int], GridCell] = {}
for row in ws.iter_rows():
for cell in row:
v = cell.value
if v is None:
continue
if isinstance(v, str) and v.startswith("="):
cells[(cell.row, cell.column)] = GridCell(cache.get(cell.coordinate), v)
elif isinstance(v, str) and not v.strip():
continue
else:
cells[(cell.row, cell.column)] = GridCell(v)
merges = [(m.min_row, m.min_col, m.max_row, m.max_col) for m in ws.merged_cells.ranges]
hidden_rows = {r for r, d in ws.row_dimensions.items() if d.hidden}
hidden_cols: set[int] = set()
for key, d in ws.column_dimensions.items():
if d.hidden:
lo = d.min or column_index_from_string(key)
hi = d.max or lo
hidden_cols.update(range(lo, hi + 1))
bounds = None
if cells:
rs = [r for r, _ in cells]
cs = [c for _, c in cells]
bounds = (min(rs), min(cs), max(rs), max(cs))
return Grid(ws.title, bounds, cells, merges, hidden_rows, hidden_cols)
def build(wb: Workbook, cache: dict[str, dict[str, Any]] | None = None):
"""保存成字节、跑期 1 的扫描、按可见工作表造网格。返回 (scan, grids)。"""
bio = io.BytesIO()
wb.save(bio)
sc = xlsx_scan(bio.getvalue())
grids = {ws.title: grid_from_ws(ws, (cache or {}).get(ws.title))
for ws in wb.worksheets if ws.sheet_state == "visible"}
return sc, grids
def flow_book(start: dt.date = dt.date(2026, 8, 1), days: int = 31, *, seed: int = 0,
big: tuple[int, int] = (3000, 9000)) -> tuple[Workbook, dict[str, dict[str, Any]], dict[str, Any]]:
"""按 P2-SPEC 9.1 的坐标造「结构仿照客流表」:返回 (工作簿, 公式缓存, 填进去的数)。"""
rnd = random.Random(seed)
wb = Workbook()
ws = wb.active
ws.title = "客流汇总"
last = 2 + days
end = start + dt.timedelta(days=days - 1)
L = get_column_letter
ws["B2"] = f"统计时间范围:{start.year}年{start.month}月{start.day}日至{end.year}年{end.month}月{end.day}日"
ws.merge_cells(f"B2:{L(last)}2")
ws["B3"] = "客流汇总表"
ws.merge_cells(f"B3:{L(last)}3")
for i in range(days):
d = start + dt.timedelta(days=i)
ws.cell(4, 3 + i, f"{d.month}月{d.day}日")
labels = ["全日客流(人次)", "分区甲(人次)", "分区乙(人次)"]
for r, lab in zip((5, 6, 7), labels):
ws.cell(r, 2, lab)
nums: dict[str, Any] = {"jia": [], "yi": []}
ws.merge_cells(f"B8:{L(last)}8")
ws["B9"] = "日间时段客流(人次)"
ws.merge_cells(f"B9:{L(last)}9")
for k, h in enumerate(range(7, 18)):
ws.cell(10 + k, 2, f"{h}-{h + 1}")
ws["B21"] = "夜间时段客流(人次)"
ws.merge_cells(f"B21:{L(last)}21")
for k, h in enumerate(range(18, 24)):
ws.cell(22 + k, 2, f"{h}-{h + 1}")
for r, lab in zip((28, 29, 30), ["18-22 时合计", "22-24 时合计", "18-24 时合计"]):
ws.cell(r, 2, lab)
cache: dict[str, Any] = {}
for i in range(days):
c = 3 + i
jia, yi = rnd.randint(*big), rnd.randint(2000, 8000)
nums["jia"].append(jia)
nums["yi"].append(yi)
ws.cell(5, c, jia + yi)
ws.cell(6, c, jia)
ws.cell(7, c, yi)
ws.cell(10, c, "·")
slots = {}
for r in list(range(11, 21)) + list(range(22, 28)):
slots[r] = rnd.randint(50, 900)
if sum(slots.values()) == jia + yi:
slots[11] += 1
for r, v in slots.items():
ws.cell(r, c, v)
col = L(c)
ws.cell(28, c, f"=SUM({col}22:{col}25)")
ws.cell(29, c, f"=SUM({col}26:{col}27)")
ws.cell(30, c, f"=SUM({col}22:{col}27)")
cache[f"{col}28"] = sum(slots[r] for r in range(22, 26))
cache[f"{col}29"] = sum(slots[r] for r in range(26, 28))
cache[f"{col}30"] = sum(slots[r] for r in range(22, 28))
return wb, {"客流汇总": cache}, nums
def ok_validate(calls: list | None = None):
"""静态校验桩:pydantic 收就算通过。calls 记下 (配方, facts, origin)。"""
def _v(data: dict[str, Any], facts: Any, origin: str):
if calls is not None:
calls.append((data, facts, origin))
return Recipe.model_validate(data), []
return _v
def canonical(d: dict[str, Any]) -> dict[str, Any]:
return Recipe.model_validate(d).model_dump(mode="json", exclude_defaults=True)
def rename_wide(recipe: dict[str, Any], old: str, new: str) -> dict[str, Any]:
"""把宽表名、该段 id 和关系里引用的表名一并换掉(P2-SPEC 第 10 节 WP-4 测试的做法)。"""
text = json.dumps(recipe, ensure_ascii=False)
return json.loads(text.replace(json.dumps(old, ensure_ascii=False), json.dumps(new, ensure_ascii=False)))
# ==========================================================================
# 参考布局
# ==========================================================================
@pytest.fixture(scope="module")
def flow():
wb, cache, nums = flow_book()
sc, grids = build(wb, cache)
return sc, grids, nums
def test_reference_layout_draft_matches_reference_recipe(flow):
sc, grids, _ = flow
calls: list = []
d = S.draft(sc, grids, "月报导出_2026-08-01_2026-08-31.xlsx", validate=ok_validate(calls))
assert d.complete, d.failures
assert d.origin == "rules" and d.failures == [] and d.failures_for_model == []
assert calls and calls[0][2] == "rules" and calls[0][1] is d.facts
wide = d.recipe["tables"][0]["name"]
assert wide == "客流汇总_按日"
# 期 3:参考布局符合按期累积的资格(交叉表、有统计期),草稿默认写 mode=accumulate(P3-SPEC 2.2),其余与
# 参考配方相同;q_mode 答「每期替换」后与参考配方逐字相同
assert d.recipe["mode"] == "accumulate"
assert canonical(rename_wide(d.recipe, wide, "日客流")) == canonical({**FLOW, "mode": "accumulate"})
q_mode = next(q for q in d.questions if q.id == "q_mode")
replaced = apply_ops(d.recipe, q_mode.effects["replace"])
assert canonical(rename_wide(replaced, wide, "日客流")) == canonical(FLOW)
def apply_ops(doc: dict[str, Any], ops: list[dict[str, Any]], reason: str = "巧合") -> dict[str, Any]:
"""RFC 6902 的 add / replace / remove 子集(测试自用;生产里是 WP-1 的 recipe.apply_patch)。"""
doc = json.loads(json.dumps(doc, ensure_ascii=False))
def fill(v: Any) -> Any:
if v == REASON_SLOT:
return reason
if isinstance(v, dict):
return {k: fill(x) for k, x in v.items()}
if isinstance(v, list):
return [fill(x) for x in v]
return v
for op in ops:
parts = [p.replace("~1", "/").replace("~0", "~") for p in op["path"].split("/")[1:]]
node: Any = doc
for p in parts[:-1]:
node = node[int(p)] if isinstance(node, list) else node[p]
last = parts[-1]
if isinstance(node, list):
if op["op"] == "remove":
node.pop(int(last))
elif last == "-":
node.append(fill(op["value"]))
elif op["op"] == "add":
node.insert(int(last), fill(op["value"]))
else:
node[int(last)] = fill(op["value"])
else:
if op["op"] == "remove":
del node[last]
else:
if op["op"] == "replace":
assert last in node, op
node[last] = fill(op["value"])
return doc
def test_reference_layout_facts(flow):
sc, grids, _ = flow
facts = S.detect_facts(grids, sc)
assert [(f.id, f.kind) for f in facts.facts] == [("F1", "sum_eq"), ("F2", "not_equal_sum")]
f1, f2 = facts.facts
assert f1.text == "第 5 行 = 第 6 行 + 第 7 行(31 列中 31 列成立)"
assert f1.detail == {"segment": "客流汇总_按日", "total": "全日客流(人次)",
"parts": ["分区甲(人次)", "分区乙(人次)"], "rows": [5, 6, 7]}
assert f2.text == "日间、夜间各行之和 与 第 5 行:31 列中 0 列相等"
assert f2.detail == {"a": ["日间", "夜间"], "b_segment": "客流汇总_按日", "b": "全日客流(人次)",
"equal": 0, "checked": 31}
assert facts.candidates["日间时段客流(人次)"][0] == "日间"
assert facts.nonnumeric == {"·": 31}
# 只有关系不给数值:事实的文字里没有任何一个填进去的数
_, _, nums = flow
blob = json.dumps([f.__dict__ for f in facts.facts], ensure_ascii=False)
assert not any(str(v) in blob for v in nums["jia"] + nums["yi"] if v > 100)
def test_reference_layout_cards_and_questions(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert [c.id for c in d.cards] == [
"axis:交叉表", "period:s1", "titles:交叉表", "fact:F1", "merge:时段客流", "placeholder:交叉表:·",
"derived:夜间合计", "fact:F2", "outside:s1", "mode"]
titles = [c.title for c in d.cards]
assert titles[0] == "第 4 行是日期表头,按交叉表导入"
assert titles[1] == "统计期取自 B2"
assert titles[2] == "分段标题 B9、B21"
assert titles[3].startswith("第 5 行 = 第 6 行 + 第 7 行")
assert titles[4] == "日间、夜间合进一张表「时段客流」"
assert titles[5] == "「·」表示无数据吗"
assert titles[6] == "第 28–30 行是合计,改作核对并另存"
assert titles[7] == "各时段之和与全日客流 31 天中 0 天相等"
assert titles[8] == "B3 是区域外文字"
assert titles[9] == "导入模式:按期累积(建议)"
assert d.cards[9].question == "q_mode"
assert "每期以统计期为键" in d.cards[9].reason and "此前各期留在历史版本中" in d.cards[9].reason
assert d.cards[1].cells == ["客流汇总!B2"]
assert d.cards[8].cells == ["客流汇总!B3"]
by_id = {c.id: c for c in d.cards}
assert by_id["placeholder:交叉表:·"].question == "q_placeholder:·"
assert by_id["fact:F2"].question == "q_relation:F2"
qs = {q.id: q for q in d.questions}
# q_mode 固定排在最后(期 2 的测试和性能用例取 questions[0])
assert [q.id for q in d.questions] == ["q_relation:F1", "q_placeholder:·", "q_relation:F2", "q_mode"]
assert all(q.default is None for q in d.questions)
for qid in ("q_relation:F1", "q_relation:F2"):
q = qs[qid]
opts = {o["value"]: o for o in q.options}
assert opts["register"]["needs_reason"] is False
assert opts["dismiss"]["needs_reason"] is True
assert REASON_SLOT in json.dumps(q.effects["dismiss"], ensure_ascii=False)
ph = qs["q_placeholder:·"]
assert [o["value"] for o in ph.options] == ["null", "reject"]
def test_question_effects_apply_and_are_state_independent(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
qs = {q.id: q for q in d.questions}
base = d.recipe
dismissed = apply_ops(base, qs["q_relation:F1"].effects["dismiss"])
rel = dismissed["relations"][0]
assert rel == {"id": "R1", "kind": "dismissed", "claims": "F1", "reason": "巧合"}
Recipe.model_validate(dismissed)
# 先选「不登记」再改选「登记」,等于直接选「登记」(整条 replace,与状态无关)
again = apply_ops(dismissed, qs["q_relation:F1"].effects["register"])
assert canonical(again) == canonical(base)
# 在改过的配方上重算问题,effects 仍然把它写回同一条
_, qs2 = S.questions_for(dismissed, d.facts, grids)
q2 = next(q for q in qs2 if q.id == "q_relation:F1")
assert canonical(apply_ops(dismissed, q2.effects["register"])) == canonical(base)
rejected = apply_ops(base, qs["q_placeholder:·"].effects["reject"])
assert rejected["sheets"][0]["blocks"][0]["values"]["placeholders"] == []
assert apply_ops(base, qs["q_placeholder:·"].effects["null"]) == base
def test_detect_facts_remaps_to_renamed_recipe(flow):
"""用户把宽表和该段 id 改名成「日客流」:给了 recipe 时,事实里的分段 id 跟着换,认领检查才对得上。"""
sc, grids, _ = flow
renamed = rename_wide(S.draft(sc, grids, "x.xlsx", validate=ok_validate()).recipe, "客流汇总_按日", "日客流")
facts = S.detect_facts(grids, sc, recipe=renamed)
assert facts.facts[0].detail["segment"] == "日客流"
assert facts.facts[1].detail["b_segment"] == "日客流"
assert facts.facts[1].detail["a"] == ["日间", "夜间"]
# 不给 recipe 时是规则起草的取法
assert S.detect_facts(grids, sc).facts[0].detail["segment"] == "客流汇总_按日"
# 标签写法只按 match_key 对:换成配方里 expect 的写法
flow2 = json.loads(json.dumps(FLOW))
seg = flow2["sheets"][0]["blocks"][0]["segments"][0]
seg["labels"]["expect"][1] = "分区甲 (人次)"
seg["measures"] = {"全日客流(人次)": "全日客流", "分区甲 (人次)": "分区甲", "分区乙(人次)": "分区乙"}
f = S.remap_facts(S.detect_facts(grids, sc), flow2).facts[0]
assert f.detail["parts"] == ["分区甲 (人次)", "分区乙(人次)"]
def test_draft_with_dry_run_structure_problem_is_incomplete(flow):
sc, grids, _ = flow
seen: list = []
def dry(recipe):
seen.append(recipe)
return Extraction(ok=False, problems=[
Problem("cell_unclaimed", "structure", "第 31 行有 2 个单元格没有去处:987654", ["客流汇总!C31"],
model_message="第 31 行有 2 个单元格没有去处"),
Problem("period_missing", "input", "未能从表格中解析出统计期"),
Problem("outside_digits", "confirm", "区域外有含数字的文字"),
])
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate(), dry_run=dry)
assert seen and isinstance(seen[0], Recipe)
assert not d.complete
assert d.failures == ["第 31 行有 2 个单元格没有去处:987654"]
assert d.failures_for_model == ["cell_unclaimed:第 31 行有 2 个单元格没有去处(客流汇总!C31)"]
assert any(c.id == "unclaimed:s1" for c in d.cards)
# input、confirm 类不算起草不完整
d2 = S.draft(sc, grids, "x.xlsx", validate=ok_validate(),
dry_run=lambda r: Extraction(ok=False, problems=[Problem("period_missing", "input", "x")]))
assert d2.complete
def test_draft_with_static_problems_is_incomplete(flow):
sc, grids, _ = flow
def bad(data, facts, origin):
return None, [RecipeProblem("/tables/0/units/全日客流", "unit_unknown", "表「日客流」:单位不在单位词表中")]
d = S.draft(sc, grids, "x.xlsx", validate=bad, dry_run=lambda r: pytest.fail("静态校验没过不该干跑"))
assert not d.complete and d.recipe is not None
assert d.failures == ["表「日客流」:单位不在单位词表中"]
assert d.failures_for_model == ["/tables/0/units/全日客流:表「日客流」:单位不在单位词表中"]
def boom(data, facts, origin):
raise RuntimeError("x")
d2 = S.draft(sc, grids, "x.xlsx", validate=boom)
assert not d2.complete and d2.failures
def test_questions_for_region_cards_come_from_extraction(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
ext = Extraction(
ok=True,
period=PeriodOut("2026-08-01", "2026-08-31", "cells", cells=["客流汇总!B40"]),
outside_text=[OutsideText("客流汇总", "B41", "备注说明", "text"),
OutsideText("客流汇总", "B42", "注:9月15日闸机故障", "text_digits")],
axes=[AxisOut("交叉表", 4, "8月1日", "8月31日", 31, "text")],
problems=[Problem("row_unclaimed", "structure", "第 44 行没有分段认领", ["客流汇总!B44"])],
placeholders={"·": 29},
)
cards, _ = S.questions_for(d.recipe, d.facts, grids, extraction=ext)
by_id = {c.id: c for c in cards}
assert by_id["period:s1"].cells == ["客流汇总!B40"]
assert by_id["period:s1"].title == "统计期取自 B40"
assert by_id["outside:s1"].cells == ["客流汇总!B41", "客流汇总!B42"]
assert "提交时逐条确认" in by_id["outside:s1"].reason
assert by_id["unclaimed:s1"].cells == ["客流汇总!B44"]
assert "29 格" in by_id["placeholder:交叉表:·"].reason
# 干跑说统计期是人工录入的:统计期卡片换成「每期人工录入」
ext2 = Extraction(ok=True, period=PeriodOut("2026-08-01", "2026-08-31", "human"))
cards2, qs2 = S.questions_for(d.recipe, d.facts, grids, extraction=ext2)
assert next(c for c in cards2 if c.id == "period:s1").question == "q_period"
assert any(q.id == "q_period" for q in qs2)
def test_questions_for_invalid_recipe_does_not_raise(flow):
sc, grids, _ = flow
cards, qs = S.questions_for({"recipe_format": "x"}, S.detect_facts(grids, sc), grids)
assert [c.id for c in cards] == ["mode"] and qs == []
# ==========================================================================
# 交叉表的变体
# ==========================================================================
def _crosstab(ws, labels_rows: list[tuple[int, str]], days: int = 8, start_col: int = 3, axis_row: int = 4,
value=lambda r, c: (r * 7 + c * 3) % 50 + 10):
for i in range(days):
ws.cell(axis_row, start_col + i, f"8月{i + 1}日")
for r, lab in labels_rows:
ws.cell(r, start_col - 1, lab)
if lab.endswith("时段客流(人次)") or lab.endswith("(标题)"):
continue
for i in range(days):
ws.cell(r, start_col + i, value(r, i))
def test_overlapping_hour_segments_are_not_merged():
wb = Workbook()
ws = wb.active
ws.title = "时段"
ws["B2"] = "统计期:2026年8月1日至2026年8月8日"
rows = [(5, "工作日时段客流(人次)"), (6, "7-8"), (7, "8-9"), (9, "周末时段客流(人次)"), (10, "7-8"), (11, "8-9")]
_crosstab(ws, rows)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete, d.failures
segs = d.recipe["sheets"][0]["blocks"][0]["segments"]
assert [s["table"] for s in segs] == ["工作日时段客流", "周末时段客流"]
assert all(s["const"] == {} for s in segs)
assert [s["id"] for s in segs] == ["工作日时段客流", "周末时段客流"]
assert [t["grain"] for t in d.recipe["tables"]] == [["日期", "时段"], ["日期", "时段"]]
assert not any(c.id.startswith("merge:") for c in d.cards)
assert d.recipe["sheets"][0]["context"][0]["prefer_prefix"] == "统计期"
def test_multiple_sheets_get_unique_segment_and_block_ids():
wb = Workbook()
for k, name in enumerate(["一号", "二号"]):
ws = wb.active if k == 0 else wb.create_sheet(name)
ws.title = name
ws["B2"] = "2026年8月1日至2026年8月8日"
rows = [(5, "合计(人次)"), (6, "甲(人次)"), (7, "乙(人次)"),
(9, "日间时段客流(人次)"), (10, "7-8"), (11, "8-9"),
(13, "夜间时段客流(人次)"), (14, "18-19"), (15, "19-20")]
_crosstab(ws, rows)
hidden = wb.create_sheet("底稿")
hidden["A1"] = "不导入"
hidden.sheet_state = "hidden"
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete, d.failures
sheets = d.recipe["sheets"]
assert [s["id"] for s in sheets] == ["s1", "s2"]
block_ids = [b["id"] for s in sheets for b in s["blocks"]]
seg_ids = [g["id"] for s in sheets for b in s["blocks"] for g in b["segments"]]
assert block_ids == ["交叉表", "交叉表_s2"]
assert len(seg_ids) == len(set(seg_ids))
assert "日间" in seg_ids and "日间_s2" in seg_ids
names = [t["name"] for t in d.recipe["tables"]]
assert len(names) == len(set(names))
assert names[:2] == ["一号_按日", "时段客流"] and "时段客流_2" in names
assert any(c.id == "hidden_sheet:底稿" for c in d.cards)
assert d.cards[-1].id == "mode"
def test_unparseable_total_row_fails_with_reason():
wb = Workbook()
ws = wb.active
ws.title = "表"
ws["B2"] = "2026年8月1日至2026年8月8日"
rows = [(5, "日间时段客流(人次)"), (6, "7-8"), (7, "8-9"), (8, "上午合计")]
_crosstab(ws, rows)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete
assert any("第 8 行像合计行,但标签无法确定区间" in f for f in d.failures)
def test_missing_period_asks_for_human_input():
wb = Workbook()
ws = wb.active
ws.title = "表"
_crosstab(ws, [(5, "甲(人次)"), (6, "乙(人次)")])
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete
assert d.recipe["sheets"][0]["context"][0]["prefer_prefix"] is None
q = next(q for q in d.questions if q.id == "q_period")
assert [o["value"] for o in q.options] == ["human"] and q.effects == {"human": []}
assert next(c for c in d.cards if c.id == "period:s1").title == "未找到统计期:每期人工录入"
def test_value_area_text_fails_but_model_version_has_no_value():
wb = Workbook()
ws = wb.active
ws.title = "表"
ws["B2"] = "2026年8月1日至2026年8月8日"
_crosstab(ws, [(5, "甲(人次)"), (6, "乙(人次)")])
ws["E6"] = "约987654"
ws["F6"] = "暂缺"
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete
assert any("约987654" in f for f in d.failures)
assert any("E6" in f for f in d.failures_for_model)
assert not any("987654" in f or "暂缺" in f for f in d.failures_for_model)
def test_failures_for_model_scrubs_cell_values_from_dry_run(flow):
"""model_message 为空时只发 code 和坐标;即使 model_message 写进了格子里的数,也换成 <值>。"""
sc, grids, _ = flow
grids = dict(grids)
g = grids["客流汇总"]
cells = dict(g.cells)
cells[(31, 3)] = GridCell(987654)
cells[(31, 4)] = GridCell("123,457")
grids["客流汇总"] = Grid(g.sheet, g.bounds, cells, g.merges, g.hidden_rows, g.hidden_cols)
probs = [Problem("outside_number", "structure", "C31 的 987654 在数据区外", ["客流汇总!C31"]),
Problem("value_not_number", "structure", "D31「123,457」", ["客流汇总!D31"],
model_message="D31 写着 123,457,不是数")]
lines = S.problem_lines_for_model(probs, grids)
assert lines[0] == "outside_number(客流汇总!C31)"
assert lines[1] == "value_not_number:D31 写着 <值>,不是数(客流汇总!D31)"
assert not any("987654" in x or "123,457" in x for x in lines)
def test_blank_and_formula_cells_in_value_area_become_questions():
wb = Workbook()
ws = wb.active
ws.title = "表"
ws["B2"] = "2026年8月1日至2026年8月8日"
_crosstab(ws, [(5, "甲(人次)"), (6, "乙(人次)")])
ws["D5"] = None
ws["E6"] = "=E5*2"
sc, grids = build(wb, {"表": {"E6": 40}})
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
values = d.recipe["sheets"][0]["blocks"][0]["values"]
assert values["blank"] == "reject" and values["formula"] == "reject"
qs = {q.id: q for q in d.questions}
assert qs["q_blank:交叉表"].default is None and qs["q_formula:交叉表"].default is None
after = apply_ops(d.recipe, qs["q_blank:交叉表"].effects["null"] + qs["q_formula:交叉表"].effects["accept_cached"])
v = after["sheets"][0]["blocks"][0]["values"]
assert v["blank"] == "null" and v["formula"] == "accept_cached"
# 紧凑形式(去掉默认值,values 整个不在)上重算的 effects 也能应用
compact = canonical(d.recipe)
assert "values" not in compact["sheets"][0]["blocks"][0]
_, qs2 = S.questions_for(compact, d.facts, grids)
q = next(x for x in qs2 if x.id == "q_blank:交叉表")
assert apply_ops(compact, q.effects["null"])["sheets"][0]["blocks"][0]["values"]["blank"] == "null"
def test_hidden_rows_in_region_ask_without_default():
wb = Workbook()
ws = wb.active
ws.title = "表"
ws["B2"] = "2026年8月1日至2026年8月8日"
_crosstab(ws, [(5, "甲(人次)"), (6, "乙(人次)"), (7, "丙(人次)")])
ws.row_dimensions[6].hidden = True
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
q = next(q for q in d.questions if q.id == "q_hidden:表")
assert q.default is None
assert [o["value"] for o in q.options] == ["reject_if_any", "include", "exclude"]
after = apply_ops(d.recipe, q.effects["exclude"])
assert after["sheets"][0]["hidden"] == {"rows": "exclude", "cols": "reject_if_any"}
def test_non_text_row_label_is_a_failure():
wb = Workbook()
ws = wb.active
ws.title = "表"
ws["B2"] = "2026年8月1日至2026年8月8日"
_crosstab(ws, [(5, "甲(人次)"), (6, "乙(人次)")])
ws["B6"] = 2025
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete
assert any("B6" in f and "不是文字" in f for f in d.failures)
assert "2025" not in json.dumps(d.recipe, ensure_ascii=False)
def test_unknown_unit_keeps_raw_name_and_gets_a_card():
wb = Workbook()
ws = wb.active
ws.title = "表"
ws["B2"] = "2026年8月1日至2026年8月8日"
_crosstab(ws, [(5, "销量(箱)"), (6, "金额(万元)")])
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
seg = d.recipe["sheets"][0]["blocks"][0]["segments"][0]
assert seg["measures"] == {"销量(箱)": "销量_箱", "金额(万元)": "金额"}
assert d.recipe["tables"][0]["units"] == {"金额": "万元"}
assert any(c.title == "「销量(箱)」的单位「箱」不在单位词表中" for c in d.cards)
# ==========================================================================
# 列表(P2-SPEC 6.1 第 11 条)
# ==========================================================================
def test_list_header_types_and_grain():
wb = Workbook()
ws = wb.active
ws.title = "销售明细"
ws.append(["订单号", "日期", "数量", "金额(元)", "备注"])
rows = [["A001", dt.datetime(2026, 8, 1), 3, 12.5, "首单"],
["A002", dt.datetime(2026, 8, 2), 1, 7.0, None],
["A003", dt.datetime(2026, 8, 3), 2, 9.25, "加急"]]
for r in rows:
ws.append(r)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete, d.failures
block = d.recipe["sheets"][0]["blocks"][0]
assert block["id"] == "列表1" and block["layout"] == "list" and block["table"] == "销售明细"
assert [(c["header"], c["name"], c["type"]) for c in block["columns"]] == [
("订单号", "订单号", "TEXT"), ("日期", "日期", "DATE"), ("数量", "数量", "INTEGER"),
("金额(元)", "金额", "REAL"), ("备注", "备注", "TEXT")]
assert d.recipe["tables"] == [{"name": "销售明细", "grain": ["订单号"], "kind": "data",
"units": {"金额": "元"}, "note": ""}]
assert d.recipe["sheets"][0]["context"] == []
assert d.cards[0].id == "list:列表1"
def test_list_two_row_header_with_merged_upper_row():
wb = Workbook()
ws = wb.active
ws.title = "汇总"
ws["A1"] = "地区"
ws.merge_cells("A1:A2")
ws["B1"] = "销售"
ws.merge_cells("B1:C1")
ws["B2"] = "数量"
ws["C2"] = "金额"
for r, row in enumerate([["华东", 10, 100], ["华北", 20, 120]], start=3):
for c, v in enumerate(row, start=1):
ws.cell(r, c, v)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
block = d.recipe["sheets"][0]["blocks"][0]
assert block["header_rows"] == 2
assert [c["header"] for c in block["columns"]] == ["地区", "销售_数量", "销售_金额"]
assert d.complete, d.failures
def test_list_blank_row_in_middle_is_skipped_with_card():
wb = Workbook()
ws = wb.active
ws.title = "明细"
for row in [["地区", "产品", "金额"], ["华东", "甲", 10], ["华东", "乙", 20], [None, None, None],
["华北", "甲", 30], ["华北", "乙", 40]]:
ws.append(row)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
block = d.recipe["sheets"][0]["blocks"][0]
assert block["rows"]["blank_rows"] == "skip"
assert len(d.recipe["sheets"][0]["blocks"]) == 1
assert any(c.id == "blank_skip:列表1" for c in d.cards)
def test_list_stacked_tables_become_two_blocks():
wb = Workbook()
ws = wb.active
ws.title = "汇总"
ws["B1"] = "一、销售"
for r, row in enumerate([["地区", "销量", "金额"], ["华东", 10, 100], ["华北", 20, 120], ["华南", 15, 80]], start=2):
for c, v in enumerate(row, start=2):
ws.cell(r, c, v)
ws["B7"] = "二、费用"
for r, row in enumerate([["部门", "费用"], ["甲部", 5], ["乙部", 7], ["丙部", 8]], start=8):
for c, v in enumerate(row, start=2):
ws.cell(r, c, v)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
blocks = d.recipe["sheets"][0]["blocks"]
assert [(b["id"], b["table"]) for b in blocks] == [("列表1", "销售"), ("列表2", "费用")]
assert [c["header"] for c in blocks[1]["columns"]] == ["部门", "费用"]
assert d.complete, d.failures
out = next(c for c in d.cards if c.id == "outside:s1")
assert out.cells == ["汇总!B1", "汇总!B7"]
def test_list_side_by_side_tables_split_on_empty_column():
wb = Workbook()
ws = wb.active
ws.title = "并排"
for r, row in enumerate([["地区", "金额"], ["华东", 1], ["华北", 2]], start=1):
ws.cell(r, 1, row[0])
ws.cell(r, 2, row[1])
for r, row in enumerate([["部门", "费用"], ["甲部", 5], ["乙部", 7]], start=1):
ws.cell(r, 4, row[0])
ws.cell(r, 5, row[1])
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
blocks = d.recipe["sheets"][0]["blocks"]
assert [[c["header"] for c in b["columns"]] for b in blocks] == [["地区", "金额"], ["部门", "费用"]]
assert [b["table"] for b in blocks] == ["并排", "并排_2"]
def test_list_total_row_is_verified_and_kept():
wb = Workbook()
ws = wb.active
ws.title = "销售"
for row in [["地区", "销量", "金额"], ["华东", 10, 100], ["华北", 20, 120], ["合计", 30, 220]]:
ws.append(row)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
block = d.recipe["sheets"][0]["blocks"][0]
assert block["rows"]["total_row"] == {"label_column": "地区", "pick": "合计", "keep_as": "销售_表内合计"}
assert [t["name"] for t in d.recipe["tables"]] == ["销售", "销售_表内合计"]
assert d.recipe["tables"][1]["grain"] == ["合计项"] and d.recipe["tables"][1]["kind"] == "reported_total"
assert any(c.id == "total_row:列表1" and "第 4 行" in c.title for c in d.cards)
def test_list_vertical_merge_in_text_column_fills_with_question():
wb = Workbook()
ws = wb.active
ws.title = "分组"
for row in [["地区", "产品", "金额"], ["华东", "甲", 10], [None, "乙", 20], ["华北", "甲", 30], [None, "乙", 40]]:
ws.append(row)
ws.merge_cells("A2:A3")
ws.merge_cells("A4:A5")
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
block = d.recipe["sheets"][0]["blocks"][0]
assert block["merged_data"] == "fill"
q = next(q for q in d.questions if q.id == "q_merged:列表1")
assert q.default is None
assert apply_ops(d.recipe, q.effects["reject"])["sheets"][0]["blocks"][0]["merged_data"] == "reject"
def test_list_header_with_year_gets_a_card():
wb = Workbook()
ws = wb.active
ws.title = "年度"
for row in [["地区", "2026年上半年_销量"], ["华东", 10], ["华北", 20]]:
ws.append(row)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert any(c.id == "header_year:列表1" for c in d.cards)
def test_list_period_cell_outside_becomes_context():
wb = Workbook()
ws = wb.active
ws.title = "销售"
ws["A1"] = "统计时间范围:2026年8月1日至2026年8月31日"
for r, row in enumerate([["地区", "金额"], ["华东", 1], ["华北", 2]], start=3):
ws.cell(r, 1, row[0])
ws.cell(r, 2, row[1])
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.recipe["sheets"][0]["context"][0]["prefer_prefix"] == "统计时间范围"
assert any(c.id == "period:s1" for c in d.cards)
def test_unrecognized_key_value_form():
wb = Workbook()
ws = wb.active
ws.title = "登记表"
ws["A1"] = "项目登记表"
for r, (k, v) in enumerate([("项目名称:", "某工程"), ("负责人:", "张三"), ("预算(万元):", 120),
("开始日期:", dt.datetime(2026, 8, 1)), ("备注:", "无")], start=3):
ws.cell(r, 1, k)
ws.cell(r, 2, v)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=lambda *a: pytest.fail("认不出时不该静态校验"))
assert d.recipe is None and not d.complete
assert any("认不出版式" in f for f in d.failures)
assert d.failures_for_model and not any("张三" in f or "120" in f for f in d.failures_for_model)
# 不带冒号的键值表单:表头像,但下面没有一列是统一的数字或日期
wb2 = Workbook()
ws2 = wb2.active
ws2.title = "登记表"
for k, v in [("项目名称", "某工程"), ("负责人", "张三"), ("预算", 120), ("部门", "工程部")]:
ws2.append([k, v])
sc2, grids2 = build(wb2)
d2 = S.draft(sc2, grids2, "x.xlsx", validate=ok_validate())
assert not d2.complete
assert any("不是列表" in f for f in d2.failures)
def test_draft_never_raises_on_odd_input():
wb = Workbook()
ws = wb.active
ws.title = "空"
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.recipe is None and not d.complete and d.failures
def test_layout_hints():
wb, cache, _ = flow_book()
sc, grids = build(wb, cache)
h = S.layout_hints(grids["客流汇总"])
assert (h.axis_row, h.label_col) == (4, 2)
wb2 = Workbook()
ws = wb2.active
ws.title = "表"
ws["A1"] = "标题"
for row in [["地区", "金额"], ["华东", 1], ["华北", 2]]:
ws.append(row)
_, grids2 = build(wb2)
h2 = S.layout_hints(grids2["表"])
assert h2.axis_row is None and h2.header_rows == {2} and h2.top_header_row == 2
def test_constants_match_period1():
from app.data import tabular
assert S.DATE_HEADER_MIN == tabular.DATE_HEADER_MIN == 7
assert S.DRAFT_MAX_ROWS == 500
def test_fact_search_stays_fast_on_large_measures_segment():
"""几十行的指标段不能让组合数爆炸(暂存时起草要在一秒量级内完成)。"""
import time
wb = Workbook()
ws = wb.active
ws.title = "大段"
ws["B2"] = "2026年8月1日至2026年8月31日"
rows = [(5 + i, f"指标{chr(0x4e00 + i)}(人次)") for i in range(45)]
_crosstab(ws, rows, days=31, value=lambda r, c: (r * 131 + c * 17) % 997 + 3)
sc, grids = build(wb)
t0 = time.perf_counter()
S.detect_facts(grids, sc)
assert time.perf_counter() - t0 < 2.0
def test_question_ids_are_unique_across_blocks():
wb = Workbook()
for k, name in enumerate(["一号", "二号"]):
ws = wb.active if k == 0 else wb.create_sheet(name)
ws.title = name
ws["B2"] = "2026年8月1日至2026年8月8日"
_crosstab(ws, [(5, "甲(人次)"), (6, "乙(人次)")])
ws["D6"] = "·"
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
ids = [q.id for q in d.questions]
assert len(ids) == len(set(ids))
assert "q_placeholder:·" in ids and "q_placeholder:·@交叉表_s2" in ids
assert {c.question for c in d.cards if c.question} <= set(ids)
# ==========================================================================
# 列表:数据行不当表头、合计字样不在第一列或在中间
# ==========================================================================
def _roster(blank_before_wang: bool = False) -> Workbook:
"""名单:第 4 行(王五)的金额空着。这一行全是文字、互不相同、下面有数,单看「像表头」。"""
wb = Workbook()
ws = wb.active
ws.title = "名单"
ws.append(["姓名", "电话", "金额", "备注"])
people = [("张三", "138-1234-5678", 10, "老客户"), ("李四", "139-8765-4321", 20, "新客户"),
("王五", "137-2222-3333", None, "转介绍"), ("赵六", "136-2222-4444", 40, "老客户"),
("钱七", "135-4444-5555", 50, "新客户")]
for i, p in enumerate(people):
if blank_before_wang and i == 2:
ws.append([None, None, None, None])
ws.append(list(p))
return wb
def test_list_row_with_blank_number_is_not_a_header():
sc, grids = build(_roster())
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete, d.failures
blocks = d.recipe["sheets"][0]["blocks"]
assert len(blocks) == 1
assert [c["name"] for c in blocks[0]["columns"]] == ["姓名", "电话", "金额", "备注"]
assert blocks[0]["columns"][2]["type"] == "INTEGER"
assert S.layout_hints(grids["名单"]).header_rows == {1}
blob = json.dumps(d.recipe, ensure_ascii=False)
assert "王五" not in blob and "137" not in blob
assert any(c.id == "list:列表1" and "第 2–6 行是数据" in c.reason for c in d.cards)
# 空行之后紧跟着这种行:和上方同类型,跳过空行继续,不当成第二张表
sc2, grids2 = build(_roster(blank_before_wang=True))
d2 = S.draft(sc2, grids2, "x.xlsx", validate=ok_validate())
assert d2.complete, d2.failures
assert len(d2.recipe["sheets"][0]["blocks"]) == 1
assert d2.recipe["sheets"][0]["blocks"][0]["rows"]["blank_rows"] == "skip"
assert S.layout_hints(grids2["名单"]).header_rows == {1}
def test_list_stacked_with_title_but_no_blank_row_splits():
"""「小标题 + 表头」也算两张表的分界(没有空行时)。"""
wb = Workbook()
ws = wb.active
ws.title = "汇总"
for row in [["地区", "销量", "金额"], ["分区甲", 10, 100], ["分区乙", 20, 120], ["二、费用", None, None],
["部门", "费用", None], ["部门甲", 5, None], ["部门乙", 7, None]]:
ws.append(row)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
blocks = d.recipe["sheets"][0]["blocks"]
assert [[c["header"] for c in b["columns"]] for b in blocks] == [["地区", "销量", "金额"], ["部门", "费用"]]
assert blocks[1]["table"] == "费用"
assert d.complete, d.failures
def test_list_header_glued_to_data_is_a_failure_not_a_split():
"""另一张表的表头紧挨着上方的数据(没有空行或标题):不切开、不把它当表头,起草失败写明原因。"""
wb = Workbook()
ws = wb.active
ws.title = "汇总"
for row in [["地区", "销量"], ["分区甲", 10], ["分区乙", 20], ["部门", "费用"], ["部门甲", 5], ["部门乙", 7]]:
ws.append(row)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete
assert len(d.recipe["sheets"][0]["blocks"]) == 1
assert any("第 4 行像另一张表的表头,B 列应是数字或日期" in f and "没有空行或标题" in f for f in d.failures)
assert S.layout_hints(grids["汇总"]).header_rows == {1}
def test_list_total_word_outside_first_column_fails():
wb = Workbook()
ws = wb.active
ws.title = "销售"
ws.append(["日期", "地区", "金额"])
for i, (zone, v) in enumerate([("分区甲", 100), ("分区乙", 120), ("分区丙", 130), ("分区丁", 140),
("分区戊", 160)]):
ws.append([dt.datetime(2026, 8, 1 + i), zone, v])
ws.append([None, "合计", 650])
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete
assert "工作表「销售」:第 7 行像合计行(B 列「合计」),但合计字样不在第一列,无法按合计行核对" in d.failures
assert any("第 7 行像合计行(B 列以「合计」开头)" in f for f in d.failures_for_model)
assert not any("650" in f for f in d.failures + d.failures_for_model)
assert d.recipe["sheets"][0]["blocks"][0]["rows"]["total_row"] is None
def test_list_subtotal_rows_in_middle_fail():
# 小计写在第二列、夹在中间
wb = Workbook()
ws = wb.active
ws.title = "门店"
for row in [["地区", "门店", "金额"], ["分区甲", "门店一", 100], ["分区甲", "门店二", 200], ["分区甲", "小计", 300],
["分区乙", "门店三", 150], ["分区乙", "门店四", 150], ["分区乙", "小计", 300]]:
ws.append(row)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete
assert [f for f in d.failures if "像合计行" in f] == [
"工作表「门店」:第 4 行像合计行(B 列「小计」),但合计字样不在第一列,无法按合计行核对",
"工作表「门店」:第 7 行像合计行(B 列「小计」),但合计字样不在第一列,无法按合计行核对"]
# 期 5:小计写在第一列、下面还有明细:提议成穿插的小计(不进数据,按上一行小计之后的明细核对),不再记失败
wb2 = Workbook()
ws2 = wb2.active
ws2.title = "门店"
for row in [["门店", "金额"], ["门店一", 100], ["门店二", 200], ["小计", 300], ["门店三", 150], ["门店四", 150],
["合计", 600]]:
ws2.append(row)
sc2, grids2 = build(wb2)
d2 = S.draft(sc2, grids2, "x.xlsx", validate=ok_validate())
assert d2.complete and d2.failures == []
block = d2.recipe["sheets"][0]["blocks"][0]
assert block["rows"]["total_row"]["pick"] == "合计"
assert block["rows"]["subtotals"] == {"label_column": "门店", "pick": "小计", "scope": "since_previous",
"group_column": None, "keep_as": None}
# 字样不一样(「小计」「合计」都夹在中间):分不清,照旧记失败
wb3 = Workbook()
ws3 = wb3.active
ws3.title = "门店"
for row in [["门店", "金额"], ["门店一", 100], ["小计", 100], ["门店二", 200], ["合计", 200], ["门店三", 150],
["总计", 450]]:
ws3.append(row)
sc3, grids3 = build(wb3)
d3 = S.draft(sc3, grids3, "x.xlsx", validate=ok_validate())
assert not d3.complete and any("不在列表末尾" in f for f in d3.failures)
# 末尾第一列的合计行照旧改作核对,不算失败(test_list_total_row_is_verified_and_kept)
# ==========================================================================
# 系统发现按配方改写:只在事实所在的工作表里找
# ==========================================================================
def _two_flow_sheets(names=("客流汇总", "客流汇总二")) -> Workbook:
wb = Workbook()
for k, name in enumerate(names):
ws = wb.active if k == 0 else wb.create_sheet(name)
ws.title = name
ws["B2"] = "2026年8月1日至2026年8月8日"
rows = [(5, "全日(人次)"), (6, "甲(人次)"), (7, "乙(人次)"), (9, "日间时段客流(人次)"), (10, "7-8"),
(11, "8-9")]
def val(r, c, k=k):
jia, yi = 10 + c + k, 20 + 2 * c
return {5: jia + yi, 6: jia, 7: yi}.get(r, (r * 7 + c * 3) % 50 + 10)
_crosstab(ws, rows, value=val)
return wb
def test_remap_facts_stays_on_the_fact_sheet():
sc, grids = build(_two_flow_sheets())
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete, d.failures
by_id = {f.id: f for f in d.facts.facts}
assert by_id["F3"].sheet == "客流汇总二" and by_id["F3"].detail["segment"] == "客流汇总二_按日"
# 规则草稿原样按配方改写:一个字没改,事实也不能被改指到第一张表的分段
remapped = {f.id: f.detail for f in S.detect_facts(grids, sc, recipe=d.recipe).facts}
assert remapped == {f.id: f.detail for f in d.facts.facts}
assert remapped["F4"]["b_segment"] == "客流汇总二_按日"
# 第二张表的宽表和明细分段改了名:按名字认得上的同表分段,明细分段按标题认
renamed = rename_wide(d.recipe, "客流汇总二_按日", "日客流二")
renamed = json.loads(json.dumps(renamed, ensure_ascii=False).replace('"id": "日间时段客流_2"', '"id": "日间二"'))
seg_ids = [g["id"] for b in renamed["sheets"][1]["blocks"] for g in b["segments"]]
assert seg_ids == ["日客流二", "日间二"]
remapped = {f.id: f.detail for f in S.detect_facts(grids, sc, recipe=renamed).facts}
assert remapped["F1"]["segment"] == "客流汇总_按日" and remapped["F2"]["a"] == ["日间时段客流"]
assert remapped["F3"]["segment"] == "日客流二"
assert remapped["F4"]["b_segment"] == "日客流二" and remapped["F4"]["a"] == ["日间二"]
def test_remap_facts_two_long_tables_on_one_sheet():
"""一张工作表上两张长表(工作日、周末的时段相交,不合表):改过 id 的明细分段按标题认,不把两张表拼在一起。"""
wb = Workbook()
ws = wb.active
ws.title = "时段"
ws["B2"] = "统计期:2026年8月1日至2026年8月8日"
rows = [(5, "全日(人次)"), (7, "工作日时段客流(人次)"), (8, "7-8"), (9, "8-9"),
(11, "周末时段客流(人次)"), (12, "7-8"), (13, "8-9")]
_crosstab(ws, rows)
sc, grids = build(wb)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
ne = [f for f in d.facts.facts if f.kind == "not_equal_sum"]
assert [f.detail["a"] for f in ne] == [["工作日时段客流"], ["周末时段客流"]]
renamed = json.loads(json.dumps(d.recipe, ensure_ascii=False)
.replace('"id": "工作日时段客流"', '"id": "工作日"')
.replace('"id": "周末时段客流"', '"id": "周末"'))
got = [f.detail["a"] for f in S.detect_facts(grids, sc, recipe=renamed).facts if f.kind == "not_equal_sum"]
assert got == [["工作日"], ["周末"]]
# 不给标题(直接调 remap_facts)时认不全:两张长表,不取「整张工作表的全部分段」,原样留着
plain = S.remap_facts(d.facts, renamed)
assert [f.detail["a"] for f in plain.facts if f.kind == "not_equal_sum"] == [["工作日时段客流"], ["周末时段客流"]]
def test_remap_facts_follows_renamed_sheet_only_when_unambiguous():
sc, grids = build(_two_flow_sheets(names=("客流汇总",)))
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
moved = json.loads(json.dumps(d.recipe, ensure_ascii=False))
moved["sheets"][0]["match"]["name"] = "上月的表名"
moved = rename_wide(moved, "客流汇总_按日", "日客流")
# 文件里只有一张可见工作表、配方里一张可回退:按回退认
assert S.detect_facts(grids, sc, recipe=moved).facts[0].detail["segment"] == "日客流"
# 不告诉有几张可见工作表时不回退
assert S.remap_facts(d.facts, moved).facts[0].detail["segment"] == "客流汇总_按日"
# ==========================================================================
# 卡片、命名、失败原因
# ==========================================================================
def test_hidden_sheet_card_is_not_duplicated_with_dry_run():
from app.data.recipe_types import SheetsOut
wb, cache, _ = flow_book()
spare = wb.create_sheet("备用")
spare["A1"] = "不导入"
spare.sheet_state = "hidden"
sc, grids = build(wb, cache)
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate(),
dry_run=lambda r: Extraction(ok=True, sheets=SheetsOut(skipped_hidden=[{"sheet": "备用"}])))
ids = [c.id for c in d.cards]
assert len(ids) == len(set(ids))
assert ids[-2:] == ["hidden_sheet:备用", "mode"]
# 没有干跑时由 draft 按扫描结果补上
d2 = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert [c.id for c in d2.cards][-2:] == ["hidden_sheet:备用", "mode"]
def test_deduped_long_table_keeps_a_meaningful_value_column():
sc, grids = build(_two_flow_sheets())
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
segs = [g for s in d.recipe["sheets"] for b in s["blocks"] for g in b["segments"] if g["role"] == "dimension"]
assert [(g["table"], g["value"]) for g in segs] == [("日间时段客流", "客流"), ("日间时段客流_2", "客流")]
ne = [r for r in d.recipe["relations"] if r["kind"] == "not_comparable"]
assert [r["a"] for r in ne] == [{"table": "日间时段客流", "value": "客流"},
{"table": "日间时段客流_2", "value": "客流"}]
def test_internal_errors_do_not_expose_exception_names(flow, monkeypatch, caplog):
sc, grids, _ = flow
def boom(*a, **k):
raise KeyError("张三")
monkeypatch.setattr(S, "_analyze", boom)
with caplog.at_level("WARNING", logger="app.data.recipe_suggest"):
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert not d.complete and d.recipe is None
assert d.failures == ["规则起草未能完成:分析表格版式时出错。请在配方面板中粘贴一份完整的配方"]
assert "KeyError" in caplog.text and "张三" not in caplog.text
monkeypatch.undo()
def bad_validate(data, facts, origin):
raise RuntimeError("x")
d2 = S.draft(sc, grids, "x.xlsx", validate=bad_validate)
assert d2.failures == ["配方检查未能完成:检查时出错。请在配方面板中修改配方后重试"]
def bad_dry(recipe):
raise ValueError("x")
d3 = S.draft(sc, grids, "x.xlsx", validate=ok_validate(), dry_run=bad_dry)
assert d3.failures == ["按草稿检查表格未能完成:检查时出错。请在配方面板中修改配方后重试"]
for f in [*d.failures, *d2.failures, *d3.failures,
*d.failures_for_model, *d2.failures_for_model, *d3.failures_for_model]:
assert not any(name in f for name in ("Error", "Exception"))
def test_long_list_with_many_blank_numbers_stays_one_block_and_fast():
"""几百行、隔行空着金额的名单:每一行都「像表头」,既不能切成几百块,也不能逐行从头推类型(行数的平方)。"""
import time
wb = Workbook()
ws = wb.active
ws.title = "名单"
ws.append(["姓名", "部门", "金额", "备注"])
for i in range(499):
ws.append([f"人员{i:03d}", f"部门{'甲乙丙'[i % 3]}", None if i % 2 else i + 1, f"说明{i}"])
sc, grids = build(wb)
t0 = time.perf_counter()
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
S.layout_hints(grids["名单"])
assert time.perf_counter() - t0 < 1.0
assert d.complete, d.failures
assert len(d.recipe["sheets"][0]["blocks"]) == 1
# ==========================================================================
# 期 3(WP-2):导入模式的默认值、q_mode、卡片(P3-SPEC 2.2)
# ==========================================================================
def _sales_list() -> Workbook:
wb = Workbook()
ws = wb.active
ws.title = "月报"
ws.append(["地区", "销量"])
for row in (["分区甲", 10], ["分区乙", 20]):
ws.append(row)
return wb
def test_list_draft_stays_replace_without_q_mode():
"""列表形态的表不符合按期累积的资格:草稿不写 mode(每期替换),不问 q_mode,卡片写第一条原因。"""
sc, grids = build(_sales_list())
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert d.complete, d.failures
assert d.recipe["mode"] == "replace"
assert all(q.id != "q_mode" for q in d.questions)
card = next(c for c in d.cards if c.id == "mode")
assert card.title == "导入模式:每期替换" and card.question is None
assert "配方没有配置统计期" in card.reason and "此前各期留在历史版本中" in card.reason
def test_q_mode_effects_and_position(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
q = d.questions[-1]
assert q.id == "q_mode" and q.text == "这份报表每期怎么更新" and q.default is None
assert [(o["value"], o["label"]) for o in q.options] == [("accumulate", "按期累积(建议)"), ("replace", "每期替换")]
assert q.effects == {"accumulate": [{"op": "add", "path": "/mode", "value": "accumulate"}],
"replace": [{"op": "add", "path": "/mode", "value": "replace"}]}
# 在 canonical 形式(去掉了 mode)上也能应用:add 到不存在的键等于新增
canon_form = canonical(d.recipe)
assert apply_ops(canon_form, q.effects["replace"])["mode"] == "replace"
# 改成每期替换以后,问题照出(资格看配方结构,不看当前取值),卡片照旧建议按期累积
replaced = apply_ops(d.recipe, q.effects["replace"])
cards, qs = S.questions_for(replaced, d.facts, grids)
assert qs[-1].id == "q_mode" and next(c for c in cards if c.id == "mode").title == "导入模式:按期累积(建议)"
def test_ineligible_crosstab_has_no_q_mode(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
relaxed = copy.deepcopy(d.recipe)
relaxed["sheets"][0]["blocks"][0]["axis"]["checks"] = ["contiguous"]
cards, qs = S.questions_for(relaxed, d.facts, grids)
assert all(q.id != "q_mode" for q in qs)
mode = next(c for c in cards if c.id == "mode")
assert mode.title == "导入模式:每期替换" and "不能按期累积" in mode.reason
# ==========================================================================
# 期 3(WP-2):按规则重新起草,并把名字对齐到现行配方(P3-SPEC 6.3)
# ==========================================================================
def test_align_to_contract_gives_the_reference_recipe(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
assert canonical(d.recipe) != canonical(FLOW)
aligned, report = S.align_to_contract(d.recipe, FLOW)
assert report == []
assert canonical(aligned) == canonical(FLOW)
assert d.recipe["tables"][0]["name"] == "客流汇总_按日", "入参不变"
def test_align_to_contract_copies_renamed_columns_ids_and_relations(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
cur = json.loads(json.dumps(FLOW, ensure_ascii=False).replace('"时段客流"', '"分时客流"')
.replace('"客流"', '"人数"').replace('"分区甲"', '"甲区"'))
segs = cur["sheets"][0]["blocks"][0]["segments"]
segs[1]["id"] = "白天"
segs[2]["const"]["时段类别"] = {"pick": "夜"} # 现行的 pick 仍是新标题的子串:沿用
cur["relations"][0]["id"], cur["relations"][1]["id"] = "R7", "R3"
Recipe.model_validate(cur)
aligned, report = S.align_to_contract(d.recipe, cur)
assert report == []
assert canonical(aligned) == canonical(cur)
def test_align_to_contract_reports_what_did_not_match(flow):
sc, grids, _ = flow
d = S.draft(sc, grids, "x.xlsx", validate=ok_validate())
cur = copy.deepcopy(FLOW)
seg = cur["sheets"][0]["blocks"][0]["segments"][0]
seg["labels"]["expect"] = ["甲", "乙", "丙"]
seg["measures"] = {"甲": "甲", "乙": "乙", "丙": "丙"}
cur["tables"][0]["units"] = {}
cur["relations"] = [cur["relations"][1]]
aligned, report = S.align_to_contract(d.recipe, cur)
assert any("表「客流汇总_按日」没有在现行配方里找到对应的表" in r for r in report)
assert any(r.startswith("关系「R1」") for r in report)
assert aligned["tables"][0]["name"] == "客流汇总_按日"
# 宽表没配上,两条关系的成员都对不上现行的关系:保留起草器的编号,各记一句
assert [r["id"] for r in aligned["relations"]] == ["R1", "R2"]
assert any(r.startswith("关系「R2」") for r in report)
Recipe.model_validate(aligned)
def _two_tables(names: tuple[str, str], labels: tuple[list[str], list[str]]) -> dict:
"""两个工作表各一张宽表的配方(只用来比较表的来源签名)。"""
sheets, tables = [], []
for i, (name, labs) in enumerate(zip(names, labels)):
sheets.append({"id": f"s{i + 1}", "match": {"name": f"表{i + 1}"}, "context": [{}], "blocks": [{
"id": f"块{i + 1}", "layout": "crosstab", "segments": [{
"id": name, "role": "measures", "table": name, "locate": {"by": "labels"},
"labels": {"expect": labs}, "measures": {x: f"列{j}" for j, x in enumerate(labs)}}]}]})
tables.append({"name": name, "grain": ["日期"]})
return {"recipe_format": "agentlab-recipe/2", "sheets": sheets, "tables": tables}
def test_align_to_contract_tie_is_not_paired():
"""同一张起草的表有两个并列最大的候选:不配对,记进 report(评审一-m13)。"""
cand = _two_tables(("起草甲", "起草乙"), (["a", "b"], ["x", "y"]))
cand["sheets"] = cand["sheets"][:1]
cand["tables"] = cand["tables"][:1]
cur = _two_tables(("现行一", "现行二"), (["a", "b"], ["a", "b"]))
aligned, report = S.align_to_contract(cand, cur)
assert aligned["tables"][0]["name"] == "起草甲"
assert any("同样相似" in r for r in report)
def test_align_to_contract_two_candidates_for_one_current_table_are_not_paired():
cand = _two_tables(("起草甲", "起草乙"), (["a", "b"], ["a", "b"]))
cur = _two_tables(("现行一", "现行二"), (["a", "b"], ["x", "y"]))
aligned, report = S.align_to_contract(cand, cur)
assert [t["name"] for t in aligned["tables"]] == ["起草甲", "起草乙"]
assert any("都同样像现行的表「现行一」" in r for r in report)
def test_align_to_contract_is_one_to_one():
cand = _two_tables(("起草甲", "起草乙"), (["a", "b", "c"], ["a", "b"]))
cur = _two_tables(("现行一", "现行二"), (["a", "b", "c"], ["a", "b", "d"]))
aligned, _ = S.align_to_contract(cand, cur)
names = [t["name"] for t in aligned["tables"]]
assert names[0] == "现行一" and names[1] != "现行一" and len(set(names)) == 2