mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:31:59 +00:00
a137182145
为什么:每月一份的明细导出是最常见的场景,期 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 条失败。旧测试里「夹在中间的小计一律失败」
「列表只能每期替换」的断言按新行为改写。
112 lines
5.7 KiB
Python
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)]
|