mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
列表中间像小计的行:照常导入、要人确认,带「改作小计核对」的修复按钮
为什么:配方没配小计的列表(期 4 起草的、或者某一期才开始出现小计的文件),首列写着「小计」、其余文字列空着的行
以前静默当数据导入,对金额列求和就把小计重复算了进去,核对也看不出来(H2)。细案 E 的复用里列了「recipe_fixes
加一个小计修复按钮」,这里补上它的触发。
- 执行器:没配 subtotals 时,首列以合计字样开头、其余文字和日期列为空、至少一格有数的行,照常按数据导入,收尾时
合成一条 subtotal_suspect(需确认类,不拒收:确实是数据的,勾确认项照常导入);首列以「累计」开头、别的文字列有字
的商品名这类数据行不算。
- 修复按钮 declare_subtotals(FIX_KINDS 末尾新增):给这个列表加 rows.subtotals(标签列、开头词取自文件里的合计
字样),可选只核对或另存成「{表}_小计」;块已配小计、开头词含数字时不提议。
- 确认项 subtotal_suspect:<块>;前端修复按钮文字「改作小计核对」。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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: '设置隐藏行处理',
|
||||
|
||||
Reference in New Issue
Block a user