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

112 lines
5.7 KiB
Python

"""CSV 走配方(Excel 期 5 第 4 项):CSV 适配成网格,后面的执行器、核对、溯源全部复用。
格子坐标沿用「工作表!A1」(见 csv_grid 的模块说明):CSV 没有工作表,名字固定是「CSV」,每月的文件名变了也认得出。
"""
from __future__ import annotations
import pytest
from app.data import csv_grid, recipe_engine, xlsx_cells
from app.data.recipe_types import Recipe
from app.data.xlsx_scan import UnsupportedTable, scan_from_json, scan_to_json
CSV = ("订单号,下单日期,门店,金额,备注\n"
"A001,2026-08-01,一号店,120,\n"
"A002,2026-08-02,二号店,80.5,\"带,逗号\"\n"
"\n"
"A003,2026-08-03,一号店,1,234,多出的一格\n").encode("utf-8")
def test_text_is_sniffed_as_csv_and_binary_is_not():
assert csv_grid.looks_like_csv(CSV)
assert csv_grid.looks_like_csv("a;b\n1;2\n".encode("gb18030"))
assert csv_grid.looks_like_csv(b"\xff\xfe" + "a\tb\n".encode("utf-16-le"))
assert not csv_grid.looks_like_csv(b"PK\x03\x04rest-of-zip")
assert not csv_grid.looks_like_csv(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 100) # 旧版 .xls
assert not csv_grid.looks_like_csv(b"\x89PNG\r\n\x1a\n\x00\x00")
assert not csv_grid.looks_like_csv(b"")
def test_scan_gives_one_visible_sheet_with_bounds_and_row_counts():
scan = csv_grid.scan(CSV)
assert scan.format == "csv" and len(scan.sheets) == 1
sh = scan.sheets[0]
assert sh.name == csv_grid.CSV_SHEET and sh.state == "visible" and sh.path == csv_grid.CSV_PATH
assert (sh.bounds.min_row, sh.bounds.min_col, sh.bounds.max_row, sh.bounds.max_col) == (1, 1, 5, 6)
# 空字段不算非空格;第 4 行是空行
assert sh.row_runs == [(1, 1, 5), (2, 2, 4), (3, 3, 5), (5, 5, 6)]
assert sh.nonempty == 20
again = scan_from_json(scan_to_json(scan))
assert again.format == "csv" and again.sheets[0].path == csv_grid.CSV_PATH
def test_cell_values_are_typed_only_when_unambiguous():
"""整数、小数写成数;千分位、前导零、正号、科学计数法、日期都留作文字,交给配方的规则(text_number、类型)判。"""
v = csv_grid.cell_value
assert v("120") == 120 and v(" 80.5 ") == 80.5 and v("-3") == -3 and v("0") == 0
for text in ("1,234", "007", "+5", "1e5", "2026-08-01", "12%", "TRUE", "=SUM(A1:A2)", "1234567890123456"):
assert v(text) == text, text
assert v("") is None and v(" ") is None
def test_grid_and_rows_read_the_same_cells():
scan = csv_grid.scan(CSV)
grid = xlsx_cells.read_grid(CSV, scan, csv_grid.CSV_SHEET)
assert grid.get(2, 4).value == 120 and grid.get(3, 4).value == 80.5 and grid.get(3, 5).value == "带,逗号"
assert grid.get(2, 5) is None and grid.get(1, 1).value == "订单号"
assert grid.get(5, 4).value == 1 and grid.get(5, 5).value == 234 # 没加引号的千分位被逗号拆开了:照实读
rows = list(xlsx_cells.iter_rows(CSV, scan, csv_grid.CSV_SHEET, min_row=1, max_row=5, min_col=1, max_col=6))
assert [r for r, _ in rows] == [1, 2, 3, 5]
assert sum(len(cells) for _, cells in rows) == len(grid.cells) == scan.sheets[0].nonempty
small = xlsx_cells.read_grid(CSV, scan, csv_grid.CSV_SHEET, max_rows=2)
assert small.truncated and set(r for r, _ in small.cells) == {1, 2}
def test_semicolon_and_tab_delimiters_and_gbk():
scan = csv_grid.scan("名称;数量\n甲;3\n".encode("gb18030"))
grid = xlsx_cells.read_grid("名称;数量\n甲;3\n".encode("gb18030"), scan, csv_grid.CSV_SHEET)
assert grid.get(2, 1).value == "甲" and grid.get(2, 2).value == 3
raw = "a\tb\n1\t2\n".encode("utf-8")
assert xlsx_cells.read_grid(raw, csv_grid.scan(raw), csv_grid.CSV_SHEET).get(2, 2).value == 2
def test_scan_any_dispatches_and_keeps_the_xlsx_refusal():
assert csv_grid.scan_any(CSV).format == "csv"
with pytest.raises(UnsupportedTable):
csv_grid.scan_any(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 100)
def test_too_many_columns_is_refused():
raw = (",".join(["x"] * (csv_grid.MAX_COLS + 1)) + "\n").encode()
with pytest.raises(UnsupportedTable) as e:
csv_grid.scan(raw)
assert "列" in str(e.value)
LIST_RECIPE = {
"recipe_format": "agentlab-recipe/2",
"sheets": [{"id": "s1", "match": {"name": "CSV"},
"blocks": [{"id": "列表1", "layout": "list", "table": "订单",
"columns": [{"header": "订单号", "name": "订单号", "type": "TEXT"},
{"header": "下单日期", "name": "下单日期", "type": "DATE"},
{"header": "门店", "name": "门店", "type": "TEXT"},
{"header": "金额", "name": "金额", "type": "REAL"}],
"rows": {"blank_rows": "stop"}}]}],
"tables": [{"name": "订单", "grain": ["订单号"]}],
}
def test_engine_runs_a_list_recipe_on_csv(tmp_path):
raw = "订单号,下单日期,门店,金额\nA001,2026-08-01,一号店,120\nA002,2026-08-02,二号店,80.5\n".encode()
scan = csv_grid.scan(raw)
db = tmp_path / "t.sqlite"
ex = recipe_engine.execute(Recipe.model_validate(LIST_RECIPE), raw, "orders_2026-08.csv", scan, str(db))
assert ex.ok, [p.message for p in ex.problems]
assert ex.tables[0].rows == 2 and ex.tables[0].sheet == "CSV"
# 溯源照常:订单号这一列从 CSV!A2 起往下 2 格
run = ex.lineage["订单"]["订单号"][0]
assert run[1:] == ["CSV", "A2", 2, "down"]
import sqlite3
rows = sqlite3.connect(db).execute('SELECT "订单号", "下单日期", "金额" FROM "订单" ORDER BY rowid').fetchall()
assert rows == [("A001", "2026-08-01", 120.0), ("A002", "2026-08-02", 80.5)]