Files
AgentLab/backend/tests/test_recipe_p5.py
T
ThearchyHelios 80fcf27732 配方导入只把 .csv / .tsv 当 CSV 读;网页、XML 冒充的 Excel 照旧拒收并说清楚
复核发现(C-csv-sniff-any-text):配方导入的扫描入口 scan_any 只看内容,不是 zip、不是 OLE、前 64KB 没有 NUL
就当 CSV 读,不看扩展名。企业系统常见的「HTML 表格冒充 .xls」、误传的文本文件,会被拆成一张叫「CSV」的工作表去
起草、核对;给已有 xlsx 配方传新一期时还会报找不到工作表、给出 rename_sheet 修复,都误导用户。

改法:
- 和简单导入(tabular._CSV)一样按扩展名定:scan_any(原件, 文件名) 只把 .csv / .tsv(且内容是文字)按 CSV 读,
  别的照旧交给 xlsx_scan 拒收。内容以「<」开头的(网页、Excel 2003 的 XML 表格,含带 BOM 的 UTF-8 / UTF-16)另给
  一句「它其实是网页或 XML 格式……请用 Excel 打开后另存为 .xlsx 或 .csv」。扩展名是 CSV、内容却不是文字的(图片
  改了扩展名)照实说;内容其实是 xlsx 的照工作簿读。
- .tsv 一律按制表符分隔(同 tabular):每行逗号、制表符个数都一样时,csv.Sniffer 优先挑逗号,会把 TSV 拆错。
- 扫描时定下的分隔符记进工作表的 path("csv:," / "csv:\t"),读格子(xlsx_cells.sheet_rows)用同一个,重放、
  试运行不再各嗅各的;这一版之前只有「csv:」的照旧再嗅一次。
- Pipeline.scan 改成 (原件, 文件名),recipe_imports 的四处调用都传上暂存区记下的文件名。

测试:test_csv_grid 加 .txt 的 CSV 内容拒收、四种网页 / XML 冒充 .xls 的拒收说法、.csv 扩展名的图片、TSV 按制表符
并且分隔符经扫描 JSON 往返后读格子照用、老暂存区的 path;改回只看内容回跑两条失败。
2026-10-16 12:23:19 +08:00

538 lines
29 KiB
Python

"""Excel 期 5(下一轮 03 细案 E):列表按期累积、穿插的小计、表格对象起草、CSV 走配方。
不连接口的部分:执行器、核对、静态校验、累积资格、并集物化、起草。夹具全是合成的通用名字(门店一、分区甲、
订单 A001……),数字是手写的小数,手算得出。接口的端到端在 test_recipe_p5_e2e.py。
"""
from __future__ import annotations
import copy
import io
import os
import sqlite3
import tempfile
from pathlib import Path
from typing import Any
import pytest
from openpyxl import Workbook
from openpyxl.worksheet.table import Table
from app.data import csv_grid, raw_store, recipe_checks, recipe_engine, recipe_suggest, table_versions, xlsx_cells
from app.data import xlsx_scan
from app.data.recipe import validate_recipe
from app.data.recipe_accumulate import materialize_union
from app.data.recipe_types import (
LIST_PERIOD_END, LIST_PERIOD_START, SUBTOTAL_KEY, Recipe, UnionPart, accumulate_blockers, derive_tables,
)
def _book(rows: list[tuple[Any, ...]], *, sheet: str = "明细", top: list[tuple[str, Any]] | None = None,
start: int = 1) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = sheet
for ref, value in top or []:
ws[ref] = value
for r, row in enumerate(rows, start=start):
for c, v in enumerate(row, start=1):
if v is not None:
ws.cell(r, c, v)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _run(raw: bytes, recipe: dict[str, Any], db: str | None = None, filename: str = "明细.xlsx", **kw: Any):
scan = csv_grid.scan_any(raw, filename)
return recipe_engine.execute(Recipe.model_validate(recipe), raw, filename, scan, db, **kw)
def _rows(db: str, sql: str) -> list[tuple[Any, ...]]:
conn = sqlite3.connect(db)
try:
return conn.execute(sql).fetchall()
finally:
conn.close()
def _checks(db: str, ex: Any, recipe: dict[str, Any]) -> dict[str, Any]:
return {c.id: c for c in recipe_checks.run_checks(db, ex, Recipe.model_validate(recipe))}
def _list(columns: list[tuple[str, str]], *, table: str = "明细", sheet: str = "明细", context: bool = False,
**block: Any) -> dict[str, Any]:
out = {
"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s1", "match": {"name": sheet},
"context": [{"id": "统计期"}] if context else [],
"blocks": [{"id": "列表1", "layout": "list", "table": table,
"columns": [{"header": h, "name": h, "type": t} for h, t in columns], **block}]}],
"tables": [{"name": table, "grain": []}],
}
return out
# ==========================================================================
# 穿插的小计
# ==========================================================================
STORES = [("地区", "门店", "金额", "数量"),
("分区甲", "门店一", 100, 1), ("分区甲", "门店二", 200, 2), ("小计", None, 300, 3),
("分区乙", "门店三", 150, 4), ("分区乙", "门店四", 150, 5), ("小计", None, 300, 9),
("合计", None, 600, 12)]
STORE_COLS = [("地区", "TEXT"), ("门店", "TEXT"), ("金额", "INTEGER"), ("数量", "INTEGER")]
def _stores_recipe(**sub: Any) -> dict[str, Any]:
return _list(STORE_COLS, rows={"total_row": {"label_column": "地区", "pick": "合计"},
"subtotals": {"label_column": "地区", "pick": "小计", **sub}})
def test_subtotal_rows_are_checked_against_their_group_and_kept_out_of_the_data(tmp_path):
db = str(tmp_path / "s.db")
recipe = _stores_recipe()
ex = _run(_book(STORES), recipe, db)
assert ex.ok, [p.message for p in ex.problems]
# 只有 4 行明细进了数据;两行小计、一行合计都不在
assert _rows(db, 'SELECT "门店", "金额" FROM "明细" ORDER BY rowid') == [
("门店一", 100), ("门店二", 200), ("门店三", 150), ("门店四", 150)]
subs = [d for d in ex.derived if d.segment == f"列表1{SUBTOTAL_KEY}"]
assert [(d.cell, d.rowid_first, d.rowid_last) for d in subs] == [
("明细!C4", 1, 2), ("明细!D4", 1, 2), ("明细!C7", 3, 4), ("明细!D7", 3, 4)]
[excluded] = [x for x in ex.rows_excluded if x.reason == "subtotal"]
assert excluded.rows == [[4, 4], [7, 7]] and excluded.anchor == "小计" and excluded.block == "列表1"
checks = _checks(db, ex, recipe)
assert checks["T1"].title == "合计行按列求和(块「列表1」)" and checks["T1"].status == "passed"
assert checks["T2"].title == "小计行按各组明细求和(块「列表1」)" and checks["T2"].status == "passed"
assert checks["T2"].checked == 4
def test_a_wrong_subtotal_is_a_mismatch(tmp_path):
rows = list(STORES)
rows[6] = ("小计", None, 300, 10) # 4 + 5 ≠ 10
db = str(tmp_path / "s.db")
recipe = _stores_recipe()
ex = _run(_book(rows), recipe, db)
checks = _checks(db, ex, recipe)
t2 = checks["T2"]
assert t2.status == "mismatch" and t2.failed == 1 and t2.cells == ["明细!D7"]
assert "按明细求和为 9" in t2.details[0]
def test_group_column_scope_only_covers_the_rows_of_the_same_group(tmp_path):
"""按组小计:紧挨在小计上面、分组列取值相同的那几行。小计行的标签写在「门店」列,分组按「地区」。
同一份数据按「上一行小计之后的全部明细」核对就对不上(350 ≠ 50)。"""
rows = [("地区", "门店", "金额"), ("分区甲", "门店一", 100), ("分区甲", "门店二", 200),
("分区乙", "门店三", 50), ("分区乙", "小计", 50)]
cols = [("地区", "TEXT"), ("门店", "TEXT"), ("金额", "INTEGER")]
recipe = _list(cols, rows={"subtotals": {"label_column": "门店", "pick": "小计", "scope": "group_column",
"group_column": "地区", "keep_as": "明细_小计"}})
recipe["tables"].append({"name": "明细_小计", "kind": "reported_total"})
db = str(tmp_path / "g.db")
ex = _run(_book(rows), recipe, db)
assert ex.ok, [p.message for p in ex.problems]
assert _checks(db, ex, recipe)["T1"].status == "passed"
assert _rows(db, 'SELECT * FROM "明细_小计"') == [("小计", "分区乙", 50)]
assert [c.name for c in derive_tables(Recipe.model_validate(recipe))[0]["明细_小计"]] == ["小计项", "地区", "金额"]
since = copy.deepcopy(recipe)
since["sheets"][0]["blocks"][0]["rows"]["subtotals"].update(scope="since_previous", group_column=None)
since["tables"][1]["name"] = "明细_小计"
db2 = str(tmp_path / "g2.db")
ex2 = _run(_book(rows), since, db2)
assert _checks(db2, ex2, since)["T1"].status == "mismatch"
def test_subtotal_validation():
def problems(**sub: Any) -> list[str]:
data = _stores_recipe(**sub)
_, probs = validate_recipe(data, facts=None, origin="rules")
return [p.code for p in probs]
assert problems() == []
assert "subtotal_group_missing" in problems(scope="group_column")
assert "column_unknown" in problems(scope="group_column", group_column="城市")
assert "subtotal_group_missing" in problems(group_column="地区")
assert "period_literal" in problems(pick="2026小计")
bad = _stores_recipe()
bad["sheets"][0]["blocks"][0]["rows"]["subtotals"]["pick"] = "合计"
assert "subtotal_pick_conflict" in [p.code for p in validate_recipe(bad, facts=None, origin="rules")[1]]
# ==========================================================================
# 列表按期累积:每一行属于哪一期
# ==========================================================================
PERIOD_CELL = ("A1", "统计时间:2026年8月1日至2026年8月31日")
ORDERS = [("订单号", "下单日期", "金额"), ("A001", "2026-08-01", 120), ("A002", "2026-08-15", 80),
("A003", "2026-08-31", 40)]
ORDER_COLS = [("订单号", "TEXT"), ("下单日期", "DATE"), ("金额", "INTEGER")]
def _orders(period: dict[str, Any], *, context: bool = True, grain: list[str] | None = None) -> dict[str, Any]:
data = _list(ORDER_COLS, table="订单", context=context, period=period)
data["tables"][0]["grain"] = grain if grain is not None else ["订单号"]
return data
def test_synthesized_period_columns_carry_this_periods_bounds(tmp_path):
recipe = _orders({"synthesize": "统计期"}, grain=["订单号", LIST_PERIOD_START])
cols = derive_tables(Recipe.model_validate(recipe))[0]["订单"]
assert [(c.name, c.role) for c in cols][-2:] == [(LIST_PERIOD_START, "period"), (LIST_PERIOD_END, "period")]
db = str(tmp_path / "p.db")
ex = _run(_book(ORDERS, top=[PERIOD_CELL], start=3), recipe, db)
assert ex.ok, [p.message for p in ex.problems]
assert _rows(db, f'SELECT "订单号", "{LIST_PERIOD_START}", "{LIST_PERIOD_END}" FROM "订单" ORDER BY rowid') == [
("A001", "2026-08-01", "2026-08-31"), ("A002", "2026-08-01", "2026-08-31"),
("A003", "2026-08-01", "2026-08-31")]
assert ex.table_hashes["订单"]
# 统计期起止列没有格子来历:溯源只有表头推出来的三列
assert set(ex.lineage["订单"]) == {"订单号", "下单日期", "金额"}
# 干跑(不建库)同样走得通
assert _run(_book(ORDERS, top=[PERIOD_CELL], start=3), recipe).ok
def test_synthesized_period_with_a_human_period(tmp_path):
import datetime as dt
from app.data.recipe_types import PeriodInput
recipe = _orders({"synthesize": "统计期"})
raw = _book(ORDERS)
assert {p.code for p in _run(raw, recipe).problems} == {"period_missing"}
db = str(tmp_path / "h.db")
ex = _run(raw, recipe, db, context_inputs={"统计期": PeriodInput(dt.date(2026, 8, 1), dt.date(2026, 8, 31))})
assert ex.ok and _rows(db, f'SELECT DISTINCT "{LIST_PERIOD_START}" FROM "订单"') == [("2026-08-01",)]
def test_rows_must_fall_inside_the_period_when_bound_to_a_date_column(tmp_path):
recipe = _orders({"column": "下单日期"})
db = str(tmp_path / "w.db")
ex = _run(_book(ORDERS, top=[PERIOD_CELL], start=3), recipe, db)
assert ex.ok
d1 = _checks(db, ex, recipe)["D1"]
assert d1.kind == "period_rows" and d1.status == "passed" and d1.checked == 3
late = list(ORDERS) + [("A004", "2026-09-02", 10)]
db2 = str(tmp_path / "w2.db")
ex2 = _run(_book(late, top=[PERIOD_CELL], start=3), recipe, db2)
d1 = _checks(db2, ex2, recipe)["D1"]
assert d1.status == "mismatch" and d1.category == "structure" and d1.failed == 1
assert d1.cells == ["明细!B7"] and "2026-08-01 至 2026-08-31" in d1.details[0]
def test_list_period_validation():
def codes(data: dict[str, Any]) -> list[str]:
return [p.code for p in validate_recipe(data, facts=None, origin="rules")[1]]
assert codes(_orders({"column": "下单日期"})) == []
assert "list_period_invalid" in codes(_orders({"column": "金额"}))
assert "column_unknown" in codes(_orders({"column": "付款日期"}))
assert "list_period_invalid" in codes(_orders({"column": "下单日期", "synthesize": "统计期"}))
assert "list_period_invalid" in codes(_orders({}))
assert "covers_without_context" in codes(_orders({"synthesize": "统计期"}, context=False))
clash = _list(ORDER_COLS + [(LIST_PERIOD_START, "TEXT")], table="订单", context=True,
period={"synthesize": "统计期"})
assert "name_collision" in codes(clash)
def test_lists_become_eligible_for_accumulation_once_bound():
unbound = Recipe.model_validate({**_list(ORDER_COLS, table="订单", context=True), "mode": "accumulate"})
[p] = accumulate_blockers(unbound)
assert "没有指定每一行属于哪一期" in p.message
for period in ({"column": "下单日期"}, {"synthesize": "统计期"}):
data = {**_orders(period), "mode": "accumulate"}
assert accumulate_blockers(Recipe.model_validate(data)) == []
assert validate_recipe(data, facts=None, origin="rules")[1] == []
kept = {**_orders({"column": "下单日期"}), "mode": "accumulate"}
kept["sheets"][0]["blocks"][0]["rows"] = {"total_row": {"label_column": "订单号", "pick": "合计",
"keep_as": "订单_表内合计"}}
kept["tables"].append({"name": "订单_表内合计", "grain": ["合计项"], "kind": "reported_total"})
blockers = accumulate_blockers(Recipe.model_validate(kept))
assert [p.path for p in blockers] == ["/tables/1"] and "合计行、小计行另存的表" in blockers[0].message
def _built(raw: bytes, recipe: dict[str, Any], tag: str) -> tuple[str, Any]:
folder = Path(tempfile.mkdtemp(prefix="p5-built-"))
db = folder / f"{tag}.db"
ex = _run(raw, recipe, str(db))
assert ex.ok, [p.message for p in ex.problems]
table_versions.settle_journal(db)
return str(db), ex
def _part(db: str, ex: Any, recipe: dict[str, Any], **kw: Any) -> UnionPart:
return UnionPart(import_id=None, start=kw.get("start", ex.period.start), end=kw.get("end", ex.period.end),
db_path=db, recipe=Recipe.model_validate(recipe), db_sha256=raw_store.sha256_file(db),
table_hashes=dict(ex.table_hashes))
@pytest.mark.parametrize("period", [{"column": "下单日期"}, {"synthesize": "统计期"}])
def test_two_months_of_a_list_materialize_into_one_union(tmp_path, period):
recipe = {**_orders(period), "mode": "accumulate"}
aug = _built(_book(ORDERS, top=[PERIOD_CELL], start=3), recipe, "aug")
sep_rows = [ORDERS[0], ("B001", "2026-09-03", 70), ("B002", "2026-09-30", 30)]
sep = _built(_book(sep_rows, top=[("A1", "统计时间:2026年9月1日至2026年9月30日")], start=3), recipe, "sep")
out = tmp_path / "u.db"
report = materialize_union(out, target=Recipe.model_validate(recipe),
parts=[_part(*aug, recipe), _part(*sep, recipe)])
assert report.ok, [c.details for c in report.checks]
assert report.rows == {"订单": 5}
assert {c.id: c.status for c in report.checks} == {"U1": "passed", "U2": "passed", "U3": "passed"}
assert report.checks[2].checked == 2
# 登记的统计期和库里的对不上(把 8 月的库说成 9 月):U3 拒收
bad = materialize_union(tmp_path / "bad.db", target=Recipe.model_validate(recipe),
parts=[_part(*aug, recipe, start="2026-09-01", end="2026-09-30")])
u3 = next(c for c in bad.checks if c.id == "U3")
assert not bad.ok and u3.status == "mismatch" and "第 1 期" in u3.details[0]
def test_the_same_order_in_two_periods_is_a_union_key_conflict(tmp_path):
"""明细跨期重复(同一订单两期都出现)按日期列分期时主键冲突,U2 拒收(细案风险条:语义要在配方里显式声明)。"""
recipe = {**_orders({"synthesize": "统计期"}), "mode": "accumulate"}
aug = _built(_book(ORDERS, top=[PERIOD_CELL], start=3), recipe, "aug2")
sep = _built(_book(ORDERS, top=[("A1", "统计时间:2026年9月1日至2026年9月30日")], start=3), recipe, "sep2")
report = materialize_union(tmp_path / "u.db", target=Recipe.model_validate(recipe),
parts=[_part(*aug, recipe), _part(*sep, recipe)])
u2 = next(c for c in report.checks if c.id == "U2")
assert not report.ok and u2.status == "mismatch"
# 主键带上统计期起就分得开:每月一份的快照式明细
keyed = {**_orders({"synthesize": "统计期"}, grain=["订单号", LIST_PERIOD_START]), "mode": "accumulate"}
aug = _built(_book(ORDERS, top=[PERIOD_CELL], start=3), keyed, "aug3")
sep = _built(_book(ORDERS, top=[("A1", "统计时间:2026年9月1日至2026年9月30日")], start=3), keyed, "sep3")
assert materialize_union(tmp_path / "k.db", target=Recipe.model_validate(keyed),
parts=[_part(*aug, keyed), _part(*sep, keyed)]).rows == {"订单": 6}
# ==========================================================================
# 起草:表格对象、穿插的小计、每一行属于哪一期的问题
# ==========================================================================
def _ok_validate(data, facts, origin):
from app.data.recipe import validate_recipe as v
return v(data, facts=facts, origin=origin)
def _draft(raw: bytes, filename: str = "明细.xlsx"):
scan = csv_grid.scan_any(raw, filename)
grids = {s.name: xlsx_cells.read_grid(raw, scan, s.name, max_rows=recipe_suggest.DRAFT_MAX_ROWS)
for s in scan.sheets if s.state == "visible" and s.nonempty}
def dry(recipe: Recipe):
return recipe_engine.execute(recipe, raw, filename, scan, None)
return recipe_suggest.draft(scan, grids, filename, validate=_ok_validate, dry_run=dry), scan, grids
def _table_book(*, totals: bool = True, name: str = "门店销售") -> bytes:
"""一个表格对象:B3:D7(表头、3 行明细、汇总行),表格上面有一行说明、右边有一个空格隔开的备注列。"""
wb = Workbook()
ws = wb.active
ws.title = "销售"
ws["B1"] = "门店销售明细"
rows = [("门店", "日期", "金额"), ("门店一", "2026-08-01", 10), ("门店二", "2026-08-02", 20),
(None, None, None), ("门店三", "2026-08-03", 30)]
if totals:
rows.append(("汇总", None, 60))
for r, row in enumerate(rows, start=3):
for c, v in enumerate(row, start=2):
if v is not None:
ws.cell(r, c, v)
t = Table(displayName=name, ref=f"B3:D{2 + len(rows)}")
if totals:
t.totalsRowCount = 1
ws.add_table(t)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_a_table_object_is_drafted_as_a_list_without_ai():
raw = _table_book()
scan = xlsx_scan.scan(raw)
assert [(t.name, t.ref, t.totals_row_count) for t in scan.tables] == [("门店销售", "B3:D8", 1)]
draft, _, _ = _draft(raw)
assert draft.complete and draft.origin == "rules", draft.failures
[block] = draft.recipe["sheets"][0]["blocks"]
assert block["layout"] == "list" and block["table"] == "门店销售"
assert [c["header"] for c in block["columns"]] == ["门店", "日期", "金额"]
assert block["rows"]["total_row"] == {"label_column": "门店", "pick": "汇总", "keep_as": "门店销售_表内合计"}
assert block["rows"]["blank_rows"] == "skip"
# 锚点按表头文字,不记坐标
assert "B3" not in str(block)
def test_a_table_object_with_digits_in_its_name_falls_back_to_the_title():
draft, _, _ = _draft(_table_book(name="表1"))
assert draft.complete and draft.recipe["sheets"][0]["blocks"][0]["table"] == "门店销售明细"
def test_drafted_subtotals_run_and_verify(tmp_path):
raw = _book(STORES)
draft, scan, _ = _draft(raw)
assert draft.complete, draft.failures
block = draft.recipe["sheets"][0]["blocks"][0]
assert block["rows"]["subtotals"]["pick"] == "小计" and block["rows"]["total_row"]["pick"] == "合计"
db = str(tmp_path / "d.db")
recipe = Recipe.model_validate(draft.recipe)
ex = recipe_engine.execute(recipe, raw, "明细.xlsx", scan, db)
assert ex.ok and ex.tables[0].rows == 4
assert all(c.status == "passed" for c in recipe_checks.run_checks(db, ex, recipe) if c.kind == "column_sum")
def test_period_question_binds_the_list_and_switches_to_accumulation():
raw = _book(ORDERS, top=[PERIOD_CELL], start=3)
draft, _, grids = _draft(raw)
assert draft.complete, draft.failures
from app.data import recipe as R
recipe = draft.recipe
cards, questions = recipe_suggest.questions_for(recipe, draft.facts, grids)
q = next(q for q in questions if q.id == "q_list_period:列表1")
assert [o["value"] for o in q.options] == ["none", "column:下单日期", "synthesize"] and q.default == "none"
assert not any(q.id == "q_mode" for q in questions)
bound = R.apply_patch(recipe, q.effects["column:下单日期"])
assert bound["sheets"][0]["blocks"][0]["period"]["column"] == "下单日期" and bound["mode"] == "accumulate"
assert validate_recipe(bound, facts=None, origin="rules")[1] == []
assert R.apply_patch(recipe, q.effects["none"])["mode"] == "replace"
# 工作表没配统计期的,选了以后一并加上
plain = _book(ORDERS)
draft2, _, grids2 = _draft(plain)
assert draft2.recipe["sheets"][0]["context"] == []
_, qs = recipe_suggest.questions_for(draft2.recipe, draft2.facts, grids2)
q2 = next(q for q in qs if q.id == "q_list_period:列表1")
bound2 = R.apply_patch(draft2.recipe, q2.effects["synthesize"])
assert bound2["sheets"][0]["context"][0]["id"] == "统计期"
assert bound2["sheets"][0]["blocks"][0]["period"]["synthesize"] == "统计期"
assert accumulate_blockers(Recipe.model_validate(bound2)) == []
# ==========================================================================
# CSV
# ==========================================================================
def test_csv_drafts_a_list_and_the_engine_runs_it(tmp_path):
raw = ("订单号,下单日期,门店,金额\n"
"A001,2026-08-01,一号店,120\n"
"A002,2026-08-02,二号店,80.5\n"
"小计,,,200.5\n"
"A003,2026-08-03,一号店,1\n"
"合计,,,201.5\n").encode("gb18030")
draft, scan, _ = _draft(raw, "orders_2026-08.csv")
assert draft.complete and draft.origin == "rules", draft.failures
sheet = draft.recipe["sheets"][0]
assert sheet["match"]["name"] == csv_grid.CSV_SHEET
block = sheet["blocks"][0]
assert [c["type"] for c in block["columns"]] == ["TEXT", "DATE", "TEXT", "REAL"]
assert block["rows"]["subtotals"]["pick"] == "小计"
db = str(tmp_path / "c.db")
recipe = Recipe.model_validate(draft.recipe)
ex = recipe_engine.execute(recipe, raw, "orders_2026-08.csv", scan, db)
assert ex.ok and ex.tables[0].rows == 3
assert all(c.status == "passed" for c in recipe_checks.run_checks(db, ex, recipe) if c.kind == "column_sum")
# 回执的坐标照 Excel 打开这个 CSV 时的 A1 写
[excluded] = [x for x in ex.rows_excluded if x.reason == "subtotal"]
assert excluded.sheet == "CSV" and excluded.rows == [[4, 4]]
# ==========================================================================
# 没配小计时,像小计的行:照常导入、要人确认,带「改作小计核对」的修复按钮
# ==========================================================================
def test_a_subtotal_like_row_without_subtotals_is_flagged_and_fixable(tmp_path):
from app.data import recipe_confirm, recipe_fixes
from app.data.recipe import apply_patch
plain = _list(STORE_COLS, rows={"total_row": {"label_column": "地区", "pick": "合计"}})
ex = _run(_book(STORES), plain)
[p] = [p for p in ex.problems if p.code == "subtotal_suspect"]
assert p.category == "confirm" and p.fix == "declare_subtotals"
assert p.cells == ["明细!A4", "明细!A7"] and p.fix_args["pick"] == "小计" and p.fix_args["label_column"] == "地区"
assert ex.ok # 需确认类:照常导入,提交时勾确认项
full = Recipe.model_validate(plain).model_dump(mode="json")
[prop] = recipe_fixes.propose_fixes(full, [recipe_fixes.dataclasses.asdict(p)], [], None)
assert prop.kind == "declare_subtotals" and [o.value for o in prop.options] == ["check_only", "keep"]
res = recipe_fixes.fix_ops(full, prop, "check_only", None)
assert res.ok, res.problems
fixed = apply_patch(full, res.ops)
assert fixed["sheets"][0]["blocks"][0]["rows"]["subtotals"]["pick"] == "小计"
db = str(tmp_path / "f.db")
ex2 = _run(_book(STORES), fixed, db)
assert ex2.ok and not [p for p in ex2.problems if p.code == "subtotal_suspect"]
assert _checks(db, ex2, fixed)["T2"].status == "passed"
keep = apply_patch(full, recipe_fixes.fix_ops(full, prop, "keep", None).ops)
assert keep["tables"][-1] == {"name": "明细_小计", "grain": [], "kind": "reported_total", "units": {}, "note": ""}
assert validate_recipe(keep, facts=None, origin="rules")[1] == []
# 确认项:没改作小计核对就提交的,要勾「确实是数据」
items = recipe_confirm.confirm_items(recipe_confirm.ConfirmContext(kind="first", recipe=plain, extraction=ex,
checks=[]))
[item] = [i for i in items if i.id == "subtotal_suspect:列表1"]
assert item.required and "改作小计核对" in item.label
def test_ordinary_rows_starting_with_a_total_word_are_not_flagged():
"""首列以「累计」开头、别的文字列有字的是数据(「累计积分卡」这种商品名),不当成像小计的行。"""
rows = [("商品", "门店", "金额"), ("累计积分卡", "门店一", 10), ("普通卡", "门店二", 20)]
ex = _run(_book(rows), _list([("商品", "TEXT"), ("门店", "TEXT"), ("金额", "INTEGER")]))
assert ex.ok and not [p for p in ex.problems if p.code == "subtotal_suspect"]
def _suspects(data: list[tuple[Any, ...]], columns: list[tuple[str, str]], **block: Any) -> list[str]:
ex = _run(_book(data), _list(columns, **block))
assert ex.ok, [p.message for p in ex.problems]
return [c for p in ex.problems if p.code == "subtotal_suspect" for c in p.cells]
def test_a_two_column_key_value_list_is_never_flagged():
"""复核反例:只有「标签文字列 + 数字列」的键值清单,「其余文字列为空」自然成立,不能据此说它像小计——
「累计收入」「汇总调整」是普通的数据行。最后一行以合计字样开头也一样。"""
cols = [("项目", "TEXT"), ("金额", "INTEGER")]
assert _suspects([("项目", "金额"), ("门票收入", 100), ("累计收入", 300), ("汇总调整", 5), ("餐饮收入", 50)],
cols) == []
assert _suspects([("项目", "金额"), ("门票收入", 100), ("餐饮收入", 50), ("合计调整项", 5)], cols) == []
def test_only_rows_between_details_are_flagged():
"""像小计的行要夹在明细中间:上下相邻的明细在某个文字列上有值、这一行空着;后面还得有明细(或者是合计行)。
放在最后、后面没有明细的不报(那不是「夹在中间」);上下的明细那一列本来就空着的也不报。"""
cols = [("地区", "TEXT"), ("门店", "TEXT"), ("金额", "INTEGER")]
head = ("地区", "门店", "金额")
# 最后一行:后面没有明细,也没有合计行
assert _suspects([head, ("分区甲", "门店一", 100), ("分区甲", "门店二", 200), ("小计", None, 300)], cols) == []
# 上面的明细「门店」也空着:不是因为小计才空的
assert _suspects([head, ("分区甲", None, 100), ("累计调整", None, 5), ("分区乙", "门店三", 50)], cols) == []
# 下面的明细「门店」空着:同样不报
assert _suspects([head, ("分区甲", "门店一", 100), ("累计调整", None, 5), ("分区乙", None, 50)], cols) == []
# 夹在明细中间:报
assert _suspects([head, ("分区甲", "门店一", 100), ("小计", None, 100), ("分区乙", "门店三", 50)],
cols) == ["明细!A3"]
# 最后一组的小计后面紧跟合计行:也在列表里面,报
total = {"rows": {"total_row": {"label_column": "地区", "pick": "合计"}}}
assert _suspects([head, ("分区甲", "门店一", 100), ("小计", None, 100), ("合计", None, 100)], cols,
**total) == ["明细!A3"]
def test_declaring_no_subtotals_keeps_such_rows_as_data_without_asking(tmp_path):
"""人确认过「这张列表没有小计行」(rows.no_subtotals):以合计字样开头的行照常是数据,不再每期报 subtotal_suspect、
不再每期要勾确认;这个声明本身是放宽,配上的那一次要确认一次。和 subtotals 只能二选一。"""
from app.data import recipe_confirm
recipe = _list(STORE_COLS, rows={"total_row": {"label_column": "地区", "pick": "合计"}, "no_subtotals": True})
assert validate_recipe(recipe, facts=None, origin="rules")[1] == []
db = str(tmp_path / "n.db")
ex = _run(_book(STORES), recipe, db)
assert ex.ok and not [p for p in ex.problems if p.code == "subtotal_suspect"]
assert _rows(db, 'SELECT COUNT(*) FROM "明细"') == [(6,)] # 两行「小计」照常是数据
items = recipe_confirm.confirm_items(recipe_confirm.ConfirmContext(kind="first", recipe=recipe, extraction=ex,
checks=[]))
ids = [i.id for i in items]
assert "no_subtotals:列表1" in ids and not [i for i in ids if i.startswith("subtotal_suspect:")]
sigs = recipe_confirm.recipe_item_signatures(Recipe.model_validate(recipe))
assert sigs["no_subtotals:列表1"] != recipe_confirm.recipe_item_signatures(
Recipe.model_validate(_list(STORE_COLS)))["no_subtotals:列表1"]
both = _stores_recipe()
both["sheets"][0]["blocks"][0]["rows"]["no_subtotals"] = True
probs = validate_recipe(both, facts=None, origin="rules")[1]
assert [p.code for p in probs] == ["subtotals_conflict"] and probs[0].path.endswith("/rows/no_subtotals")