列表中间像小计的行:照常导入、要人确认,带「改作小计核对」的修复按钮

为什么:配方没配小计的列表(期 4 起草的、或者某一期才开始出现小计的文件),首列写着「小计」、其余文字列空着的行
以前静默当数据导入,对金额列求和就把小计重复算了进去,核对也看不出来(H2)。细案 E 的复用里列了「recipe_fixes
加一个小计修复按钮」,这里补上它的触发。

- 执行器:没配 subtotals 时,首列以合计字样开头、其余文字和日期列为空、至少一格有数的行,照常按数据导入,收尾时
  合成一条 subtotal_suspect(需确认类,不拒收:确实是数据的,勾确认项照常导入);首列以「累计」开头、别的文字列有字
  的商品名这类数据行不算。
- 修复按钮 declare_subtotals(FIX_KINDS 末尾新增):给这个列表加 rows.subtotals(标签列、开头词取自文件里的合计
  字样),可选只核对或另存成「{表}_小计」;块已配小计、开头词含数字时不提议。
- 确认项 subtotal_suspect:<块>;前端修复按钮文字「改作小计核对」。
This commit is contained in:
ThearchyHelios
2026-10-16 11:16:33 +08:00
parent a137182145
commit 881c8ef65b
9 changed files with 192 additions and 7 deletions
+8
View File
@@ -1260,6 +1260,14 @@ def _period_items(bag: _Bag, recipe: Recipe, ex: dict[str, Any], ctx: ConfirmCon
bag.add(f"rows_after_stop:{blk}", ";".join(str(p.get("message") or "") for p in probs),
"、".join(cells[:20]), source="outside")
# 期 5:没配小计的列表里像小计的数据行:照常导入了,要人确认它们确实是数据(不是的话用修复按钮改作小计核对)
for p in ex.get("problems") or []:
p = plain(p)
if isinstance(p, dict) and p.get("code") == "subtotal_suspect":
blk = str((p.get("fix_args") or {}).get("block") or "、".join(str(c) for c in p.get("cells") or []))
bag.add(f"subtotal_suspect:{blk}", str(p.get("message") or ""),
"、".join(str(c) for c in (p.get("cells") or [])[:20]), source="outside")
# 列表块只有表头、没有数据行(AU-1):问题对象里没有块键,按消息里的「列表「块」」认
for p in ex.get("problems") or []:
p = plain(p)
+49 -1
View File
@@ -67,7 +67,8 @@ from app.data import engine as _data_engine
from app.data import tabular, xlsx_cells
from app.data.names import canon
from app.data.recipe_parsers import (
AxisDate, HourRange, HourTotal, Period, classify_outside, cn_date_range, cn_year_month, filename_period,
TOTAL_WORDS, AxisDate, HourRange, HourTotal, Period, classify_outside, cn_date_range, cn_year_month,
filename_period,
hour_range, hour_range_total, is_pure_period_cell, looks_numeric_text, match_key, month_day_or_date,
period_residue, text_label, thousands_number,
)
@@ -110,6 +111,7 @@ PROBLEM_CODES: dict[str, str] = {
"header_not_found": "structure", "header_ambiguous": "structure", "column_missing": "structure",
"column_extra": "structure", "rows_after_stop": "confirm", "total_row_missing": "structure",
"total_row_blank": "structure", "list_empty": "confirm", "subtotal_blank": "structure",
"subtotal_suspect": "confirm",
# 值
"value_not_integer": "structure", "value_text_number": "structure", "value_blank": "structure",
"value_error": "structure", "value_not_number": "structure", "value_not_date": "structure",
@@ -133,6 +135,7 @@ PROBLEM_FIX: dict[str, str] = {
"axis_extra_cells": "ignore_cells", "column_extra": "ignore_cells", "row_unclaimed": "ignore_cells",
"outside_number": "ignore_cells",
"hidden_rows": "declare_hidden", "hidden_cols": "declare_hidden", "sheet_missing": "rename_sheet",
"subtotal_suspect": "declare_subtotals",
}
#: 列表找表头的窗口:从 after_title 的下一行(或已用区域首行)起往下这么多行
@@ -1675,6 +1678,10 @@ class _ListRun:
self.sub_rows: dict[int, list[int]] = {}
#: 期 5:加统计期起止两列(ListPeriod.synthesize):每行末尾两格占位,统计期定下来以后由 _Sink 换成真值
self.synth = block.period is not None and block.period.synthesize is not None
#: 期 5:没配小计时,像夹在明细中间的小计的数据行(行号)和命中的合计字样:照常按数据导入,收尾时报
#: subtotal_suspect 要人确认(带修复按钮:改作小计核对)
self.suspects: list[int] = []
self.suspect_word: str | None = None
# ---------------- 喂入 ----------------
@@ -1742,6 +1749,7 @@ class _ListRun:
if self.located:
for i, text in self.headers_read.items():
self.eng.headers[(self.block.table, self.cols[i].name)] = text
self._report_suspects()
# ---------------- 表头 ----------------
@@ -2068,8 +2076,48 @@ class _ListRun:
if label is not None and match_key(_text(label.value)).startswith(self.sub_key):
self._subtotal_row(r, inb, cells, label)
return
elif (word := self._suspect_word(inb)) is not None:
self.suspects.append(r)
self.suspect_word = self.suspect_word or word
self._record(r, inb, cells, fills)
def _suspect_word(self, inb: dict[int, GridCell]) -> str | None:
"""期 5:这一行像不像夹在明细中间的小计(配方没配 subtotals 时才看):首列是以合计字样(TOTAL_WORDS)开头的文字,
其余文字、日期列都空着,至少一格有数。像的话返回那个字样。以前这种行静默当数据导入,求和就把小计重复算了
进去(H2);现在照常导入,但收尾时报出来要人确认,并给「改作小计核对」的修复按钮。合计行已经在前面认走了。"""
cell = inb.get(self.c1)
if cell is None or cell.formula is not None or not isinstance(cell.value, str):
return None
key = match_key(cell.value)
word = next((w for w in TOTAL_WORDS if key.startswith(w)), None)
if word is None or not any(self.colmap.get(i) == self.c1 for i in range(len(self.cols))):
return None
numeric = False
for i, col in enumerate(self.cols):
c = self.colmap.get(i)
if c is None or c == self.c1 or c not in inb:
continue
if col.type in ("TEXT", "DATE"):
return None
numeric = True
return word if numeric else None
def _report_suspects(self) -> None:
"""收尾:像小计的数据行合成一条 subtotal_suspect(需确认),修复参数带块 id、标签列、字样、行号和标签格。"""
if not self.suspects or not self.located:
return
label = next(col.name for i, col in enumerate(self.cols) if self.colmap.get(i) == self.c1)
cells = [self.sh.coord(r, self.c1) for r in self.suspects]
rows = _rows_text(self.suspects)
word = self.suspect_word or ""
where = (f"{self.where}:第 {rows} 行像夹在明细中间的小计(「{label}」列以「{word}」开头、其余文字列为空),"
"按数据导入了")
msg = (f"{where}。如果它们是小计,对这一列求和会把小计重复算进去:请用修复按钮改作小计核对;"
"确实是数据时,确认后照常导入")
self.eng.problems.add("subtotal_suspect", msg, where, cells[:CELLS_MAX], sheet=self.sh.name,
fix_args={"block": self.block.id, "label_column": label, "pick": word,
"rows": list(self.suspects), "cells": cells[:CELLS_MAX]})
def _plan(self) -> list[tuple[Any, int, bool, str]]:
"""每列的(列定义、所在列号、是不是主键、位置说明):定下表头之后算一次,逐行复用。"""
plan = self.__dict__.get("_plan_cache")
+62 -1
View File
@@ -253,6 +253,8 @@ def _key(kind: str, target: dict[str, Any]) -> str:
return f"declare_hidden:{t['sheet']}:{t['axis']}"
if kind == "rename_sheet":
return f"rename_sheet:{t['sheet_id']}"
if kind == "declare_subtotals":
return f"declare_subtotals:{t['block']}"
raise KeyError(kind)
@@ -750,6 +752,62 @@ def _o_declare_total(recipe: dict, p: FixProposal, opt: FixOption, reason: str |
return ops, summary
# ==========================================================================
# 期 5:列表中间像小计的行改作小计核对
# ==========================================================================
def _subtotal_keep_name(recipe: dict, block: dict) -> str:
used = {collide_key(t.get("name") or "") for t in recipe.get("tables") or []}
return unique_name(f"{block.get('table')}_小计", used)
def _p_declare_subtotals(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
"""subtotal_suspect → 列表块加 rows.subtotals(标签列、开头词取自执行器给的 fix_args:文件里的合计字样)。
块已经配了小计、标签列不在块里、开头词含数字时不提议。"""
found = find_block(recipe, args.get("block"))
if found is None:
return None
_si, _bi, block = found
label, pick = args.get("label_column"), args.get("pick")
if block.get("layout") != "list" or (block.get("rows") or {}).get("subtotals"):
return None
if label not in [c.get("name") for c in block.get("columns") or []] or not isinstance(pick, str) \
or not pick or has_digit(pick):
return None
target = {"block": block["id"], "label_column": label, "pick": pick}
keep = _subtotal_keep_name(recipe, block)
return _proposal("declare_subtotals", _key("declare_subtotals", target), code=_get(prob, "code"),
title=f"列表「{block['id']}」中间「{pick}」开头的行是小计",
cells=list(args.get("cells") or _get(prob, "cells") or []), target=target,
options=[FixOption("check_only", "改作小计核对,不另存",
detail="每行小计按上一行小计之后的明细逐列求和核对,小计行不进数据,记进回执的"
"「排除的行」"),
FixOption("keep", f"改作小计核对,原值另存表「{keep}」",
detail="同上,原表的小计另存成一张表;另存的小计表不能按期累积")],
anchor=anchor)
def _o_declare_subtotals(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_block(recipe, p.target.get("block"))
if found is None:
raise _Stale
si, bi, block = found
if (block.get("rows") or {}).get("subtotals"):
raise _Stale
keep = _subtotal_keep_name(recipe, block) if opt.value == "keep" else None
value = {"label_column": p.target["label_column"], "pick": p.target["pick"], "scope": "since_previous",
"group_column": None, "keep_as": keep}
ops: list[dict[str, Any]] = [{"op": "add", "path": ptr("sheets", si, "blocks", bi, "rows", "subtotals"),
"value": value}]
if keep:
ops.append({"op": "add", "path": "/tables/-", "value": {
"name": keep, "grain": [], "kind": "reported_total", "units": {}, "note": ""}})
summary = [f"列表「{block['id']}」中间「{p.target['pick']}」开头的行改作小计核对,不进数据"
+ (f",原值另存表「{keep}」" if keep else ",不另存")]
return ops, summary
# ==========================================================================
# ⑥ 忽略(按文字记)
# ==========================================================================
@@ -998,12 +1056,13 @@ _Builder = Callable[[dict, Any, dict, FixAnchor], "FixProposal | None"]
_BUILDERS: dict[str, _Builder] = {
"remove_label": _p_remove_label, "add_label": _p_add_label, "rename_title": _p_rename_title,
"declare_total": _p_declare_total, "ignore_cells": _p_ignore_cells, "declare_placeholder": _p_declare_placeholder,
"declare_hidden": _p_declare_hidden, "rename_sheet": _p_rename_sheet,
"declare_hidden": _p_declare_hidden, "rename_sheet": _p_rename_sheet, "declare_subtotals": _p_declare_subtotals,
}
_OPS: dict[str, Callable[[dict, FixProposal, FixOption, str | None], tuple[list, list[str]]]] = {
"remove_label": _o_remove_label, "add_label": _o_add_label, "edit_members": _o_edit_members,
"rename_title": _o_rename_title, "declare_total": _o_declare_total, "ignore_cells": _o_ignore_cells,
"declare_placeholder": _o_declare_placeholder, "declare_hidden": _o_declare_hidden, "rename_sheet": _o_rename_sheet,
"declare_subtotals": _o_declare_subtotals,
}
@@ -1064,6 +1123,8 @@ def _edit_title(p: FixProposal, opt: FixOption) -> str:
return f"工作表「{t.get('sheet')}」:{opt.label}"
if p.kind == "rename_sheet":
return opt.label
if p.kind == "declare_subtotals":
return f"列表「{t.get('block')}」中间「{t.get('pick')}」开头的行{opt.label}"
return p.title
+3 -2
View File
@@ -473,9 +473,10 @@ class Problem:
fix_args: dict[str, Any] | None = None
#: 修复按钮的九种(P3-SPEC 3.2 ①–⑨)。Problem.fix、FixProposal.kind、确认项 fix:<目标键> 的 kind 都取自这里
#: 修复按钮的种类(P3-SPEC 3.2 ①–⑨;期 5 加 declare_subtotals:列表中间像小计的行改作小计核对)。Problem.fix、
#: FixProposal.kind、确认项 fix:<目标键> 的 kind 都取自这里
FIX_KINDS = ("remove_label", "add_label", "edit_members", "rename_title", "declare_total", "ignore_cells",
"declare_placeholder", "declare_hidden", "rename_sheet")
"declare_placeholder", "declare_hidden", "rename_sheet", "declare_subtotals")
@dataclass
+2 -1
View File
@@ -488,7 +488,8 @@ def test_closed_value_sets():
"header", "row_label", "section_title", "total_word", "axis", "after_title", "outside_text")
assert _literal(T.EditResult, "kind") == ("fix", "selection")
assert _literal(T.ChangeClass, "change") == ("same", "compatible", "retire", "semantic")
assert T.FIX_KINDS == ("remove_label", "add_label", "edit_members", "rename_title", "declare_total",
assert T.FIX_KINDS[-1] == "declare_subtotals" # 期 5 加在末尾
assert T.FIX_KINDS[:-1] == ("remove_label", "add_label", "edit_members", "rename_title", "declare_total",
"ignore_cells", "declare_placeholder", "declare_hidden", "rename_sheet")
assert T.SELECTION_AS == ("list", "crosstab", "segment", "derived", "section_title", "ignore_rows",
"ignore_columns", "ignore_outside")
@@ -330,6 +330,18 @@ def c_subtotal_blank(mp):
return _run(sales_book(edit=edit), data), [N]
def c_subtotal_suspect(mp):
"""期 5:没配小计时,首列以合计字样开头、其余文字列空着的行像夹在中间的小计:照常导入,要人确认。"""
data = list_recipe()
def edit(ws):
ws["D4"] = N
ws.insert_rows(6)
ws["A6"] = "小计"
ws["D6"] = N
return _run(sales_book(edit=edit), data), [N]
def c_value_not_integer(mp):
return _run(_flow(_set(C11=9876.54)), FLOW), [9876.54]
+12 -2
View File
@@ -119,7 +119,9 @@ def test_problem_fix_table_follows_spec_3_1():
"label_unparsed": "declare_total", "value_not_number": "declare_placeholder",
"axis_extra_cells": "ignore_cells", "column_extra": "ignore_cells", "row_unclaimed": "ignore_cells",
"outside_number": "ignore_cells", "hidden_rows": "declare_hidden", "hidden_cols": "declare_hidden",
"sheet_missing": "rename_sheet"}
"sheet_missing": "rename_sheet",
# 期 5:没配小计的列表里像小计的行
"subtotal_suspect": "declare_subtotals"}
assert set(recipe_engine.PROBLEM_FIX.values()) <= set(FIX_KINDS)
# edit_members 只来自静态校验;cell_unclaimed、rows_after_stop 没有修复按钮
assert "edit_members" not in recipe_engine.PROBLEM_FIX.values()
@@ -367,9 +369,17 @@ def _column_extra():
return sales_book(edit=edit), LIST
def _subtotal_suspect():
def edit(ws):
ws.insert_rows(6)
ws["A6"], ws["D6"], ws["E6"] = "小计", N, N + 1
return sales_book(edit=edit), LIST
@pytest.mark.parametrize("code", sorted(recipe_engine.PROBLEM_FIX))
def test_fix_args_never_carry_cell_values(code):
book, recipe = _column_extra() if code == "column_extra" else _crosstab_cases()[code]
book, recipe = (_column_extra() if code == "column_extra" else _subtotal_suspect() if code == "subtotal_suspect"
else _crosstab_cases()[code])
ex = run(book, recipe)
hits = [p for p in ex.problems if p.code == code and p.fix_args is not None]
assert hits, f"{code}:{[(p.code, p.fix, p.message) for p in ex.problems]}"
+43
View File
@@ -433,3 +433,46 @@ def test_csv_drafts_a_list_and_the_engine_runs_it(tmp_path):
# 回执的坐标照 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"]
+1
View File
@@ -1859,6 +1859,7 @@ export const FIX_KIND_LABEL: Record<string, string> = {
edit_members: '更新关系成员',
rename_title: '改分段标题',
declare_total: '改作合计核对',
declare_subtotals: '改作小计核对',
ignore_cells: '按文字忽略',
declare_placeholder: '声明占位符',
declare_hidden: '设置隐藏行处理',