Files
AgentLab/backend/app/data/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

155 lines
7.8 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.
"""CSV 走配方(Excel 期 5 第 4 项):把 CSV 适配成配方读取层认的样子——一张可见工作表的扫描结果(WorkbookScan)
和网格(Grid / 逐行流),之后的执行器、核对、说明、溯源、按期累积全部复用,不另写一套 CSV 的解析(否则会和配方
语言分叉,违背「一种说法」)。
**怎么认出是 CSV。** 看内容不看扩展名(配方的几个入口只拿得到原件字节):不是 zip(xlsx)、不是旧版 .xls 的 OLE
文件头、前 64 KB 里没有 NUL 字节(图片、PDF 这类二进制文件都有),就按 CSV 读。编码识别、分隔符嗅探复用 tabular
读 CSV 的那一段(UTF-8 / GB18030 / 带 BOM 的 UTF-16,逗号、分号、制表符、竖线)。
**坐标沿用「工作表!A1」。** 细案原写「格子坐标记成 R{行}C{列}」,这里没这么做(见交付说明的偏离项):坐标在执行器、
核对、修复按钮、框选、溯源、裁判摘录、前端网格里一共有十来处解析和拼写,引入第二种写法就要处处认两种,漏一处就是
着色错位、下钻找不到格子;期 1 读 CSV 时(tabular._shape_reasons)用的也是 A1。CSV 没有工作表,名字固定为
CSV_SHEET:每月文件名不同(orders_2026-08.csv、orders_2026-09.csv),配方按工作表名认,名字必须稳定。行号是第几条
记录(引号里的换行不另起一行),列号是第几个字段,和用 Excel 打开这个 CSV 看到的 A1 坐标一致。
**格子的值。** CSV 里只有文字。只有写法毫无歧义的才转成数(-12、80.5,不超过 15 位有效数字),其余一律原样留作文字:
「1,234」(千分位,交给配方的 text_number 决定)、「007」(前导零是编码的一部分)、「+5」「1e5」「12%」、日期写法
(执行器按 DATE 列解析文字日期,起草按文字认日期)、「=SUM(…)」(CSV 里不是公式,Excel 打开时才会当公式算)。
空字段、只有空白的字段是空格(和 xlsx_scan 的口径一致)。没有公式、合并单元格、隐藏行列。
"""
from __future__ import annotations
import csv
import io
import re
from dataclasses import dataclass
from typing import Any, Iterator
from app.data.recipe_types import GridCell
from app.data.xlsx_scan import Bounds, SheetScan, UnsupportedTable, WorkbookScan
__all__ = ["CSV_PATH", "CSV_SHEET", "MAX_COLS", "MAX_ROWS", "CsvTable", "cell_value", "looks_like_csv", "parse",
"rows", "scan", "scan_any"]
#: CSV 的「工作表名」:配方按它认(SheetMatch.name),每月的文件名不同也认得出
CSV_SHEET = "CSV"
#: SheetScan.path 的取值:读取层据此知道这张「工作表」要从 CSV 里读,不去压缩包里找 XML
CSV_PATH = "csv:"
#: 和 Excel 的上限一致:超过的 CSV 用 Excel 打开也装不下,交给模型的格子坐标也写不出来
MAX_ROWS = 1_048_576
MAX_COLS = 16_384
#: 嗅探看多少字节
_SNIFF = 64 * 1024
#: 写法毫无歧义的整数、小数(不带正号、不带前导零、不用科学计数法)
_INT = re.compile(r"-?(?:0|[1-9][0-9]*)")
_DEC = re.compile(r"-?(?:0|[1-9][0-9]*)\.[0-9]+")
#: 超过这么多位有效数字的不转成数:转成浮点会丢位(19 位卡号撞值),留作文字
_DIGITS_MAX = 15
def looks_like_csv(raw: bytes) -> bool:
"""这份原件按 CSV 读:不是 zip、不是 OLE(旧版 .xls)、开头没有 NUL 字节、不是空文件。带 BOM 的 UTF-16 例外
(每个字符都带 NUL,Excel「Unicode 文本」另存出来的就是它)。"""
if not raw or raw[:4] == b"PK\x03\x04" or raw[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1":
return False
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
return True
return b"\x00" not in raw[:_SNIFF]
def cell_value(text: str) -> Any:
"""CSV 的一个字段 → 格子的值:空 / 只有空白是 None(空格),写法毫无歧义的整数、小数转成数,其余原样(文字)。"""
t = text.strip()
if not t:
return None
digits = len(t.lstrip("-").replace(".", "").lstrip("0")) or 1
if digits <= _DIGITS_MAX:
if _INT.fullmatch(t):
return int(t)
if _DEC.fullmatch(t):
return float(t)
return text
@dataclass
class CsvTable:
"""解析好的 CSV:records[i] 是第 i + 1 条记录的字段(原文)。"""
records: list[list[str]]
delimiter: str
def parse(raw: bytes) -> CsvTable:
"""原件 → 记录。编码、分隔符照 tabular 读 CSV 的做法;字段太长、引号不配对读不了时抛 UnsupportedTable。"""
from app.data import tabular
text = tabular._decode_csv(raw)
try:
delimiter = csv.Sniffer().sniff(text[:8192], delimiters=",;\t|").delimiter
except csv.Error:
delimiter = "," # 嗅不出来就按最常见的来
records: list[list[str]] = []
try:
for record in csv.reader(io.StringIO(text, newline=""), delimiter=delimiter):
if len(records) >= MAX_ROWS:
raise UnsupportedTable(f"CSV 文件超过 {MAX_ROWS:,} 行,超出可以导入的上限")
if len(record) > MAX_COLS:
raise UnsupportedTable(f"CSV 文件第 {len(records) + 1} 行有 {len(record):,} 列,超过 {MAX_COLS:,} 列的上限")
records.append(record)
except csv.Error as e:
raise UnsupportedTable(f"无法按 CSV 读取这个文件:第 {len(records) + 1} 行的写法不正确({e})。"
"请在 Excel 中重新另存为 CSV 后上传") from e
return CsvTable(records=records, delimiter=delimiter)
def _cells(table: CsvTable) -> Iterator[tuple[int, list[tuple[int, GridCell]]]]:
for r, record in enumerate(table.records, 1):
out = [(c, GridCell(v)) for c, field in enumerate(record, 1) if (v := cell_value(field)) is not None]
if out:
yield r, out
def scan(raw: bytes) -> WorkbookScan:
"""CSV 原件 → 扫描结果:一张可见工作表(名字 CSV_SHEET、路径 CSV_PATH),边界、非空格数、逐行非空格数的游程。
没有合并区、隐藏行列、公式、错误值、离群格(CSV 里每一格都是写出来的,没有残留格式)。"""
table = parse(raw)
sheet = SheetScan(name=CSV_SHEET, state="visible", path=CSV_PATH)
lo_r = lo_c = hi_r = hi_c = 0
runs: list[tuple[int, int, int]] = []
for r, cells in _cells(table):
n = len(cells)
sheet.nonempty += n
first, last = cells[0][0], cells[-1][0]
if not lo_r:
lo_r, lo_c, hi_c = r, first, last
lo_c, hi_c, hi_r = min(lo_c, first), max(hi_c, last), r
if runs and runs[-1][1] == r - 1 and runs[-1][2] == n:
runs[-1] = (runs[-1][0], r, n)
else:
runs.append((r, r, n))
if lo_r:
sheet.bounds = Bounds(lo_r, lo_c, hi_r, hi_c)
sheet.dimension = sheet.bounds.a1()
sheet.row_runs = runs
return WorkbookScan(sheets=[sheet], format="csv")
def scan_any(raw: bytes) -> WorkbookScan:
"""配方导入的扫描入口:CSV 走这里的适配,其余照旧交给 xlsx_scan(读不了照旧拒收,文案不变)。"""
from app.data import xlsx_scan
if looks_like_csv(raw):
return scan(raw)
return xlsx_scan.scan(raw)
def rows(table: CsvTable, *, min_row: int, max_row: int, min_col: int,
max_col: int) -> Iterator[tuple[int, list[tuple[int, GridCell]]]]:
"""按行流式给一个矩形里的非空格:(行号, [(列号, 格)…]),口径同 xlsx_cells.sheet_rows(整行为空的不给)。"""
for r in range(max(min_row, 1), min(max_row, len(table.records)) + 1):
record = table.records[r - 1]
out = [(c, GridCell(v)) for c in range(max(min_col, 1), min(max_col, len(record)) + 1)
if (v := cell_value(record[c - 1])) is not None]
if out:
yield r, out