diff --git a/backend/app/data/recipe_confirm.py b/backend/app/data/recipe_confirm.py index 3271eed9..bb36b8e0 100644 --- a/backend/app/data/recipe_confirm.py +++ b/backend/app/data/recipe_confirm.py @@ -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) diff --git a/backend/app/data/recipe_engine.py b/backend/app/data/recipe_engine.py index f44803da..a5cea69b 100644 --- a/backend/app/data/recipe_engine.py +++ b/backend/app/data/recipe_engine.py @@ -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") diff --git a/backend/app/data/recipe_fixes.py b/backend/app/data/recipe_fixes.py index 54f6be61..5921ebfb 100644 --- a/backend/app/data/recipe_fixes.py +++ b/backend/app/data/recipe_fixes.py @@ -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 diff --git a/backend/app/data/recipe_types.py b/backend/app/data/recipe_types.py index c4b8d62c..2f28d50e 100644 --- a/backend/app/data/recipe_types.py +++ b/backend/app/data/recipe_types.py @@ -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 diff --git a/backend/tests/test_recipe_contract.py b/backend/tests/test_recipe_contract.py index 50b1f0b9..03827e7e 100644 --- a/backend/tests/test_recipe_contract.py +++ b/backend/tests/test_recipe_contract.py @@ -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") diff --git a/backend/tests/test_recipe_engine_messages.py b/backend/tests/test_recipe_engine_messages.py index fef58344..c2013ab1 100644 --- a/backend/tests/test_recipe_engine_messages.py +++ b/backend/tests/test_recipe_engine_messages.py @@ -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] diff --git a/backend/tests/test_recipe_engine_p3.py b/backend/tests/test_recipe_engine_p3.py index a4c6ad63..f827a6bf 100644 --- a/backend/tests/test_recipe_engine_p3.py +++ b/backend/tests/test_recipe_engine_p3.py @@ -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]}" diff --git a/backend/tests/test_recipe_p5.py b/backend/tests/test_recipe_p5.py index b635a014..54d445d0 100644 --- a/backend/tests/test_recipe_p5.py +++ b/backend/tests/test_recipe_p5.py @@ -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"] diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8d80043d..84392cde 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -1859,6 +1859,7 @@ export const FIX_KIND_LABEL: Record = { edit_members: '更新关系成员', rename_title: '改分段标题', declare_total: '改作合计核对', + declare_subtotals: '改作小计核对', ignore_cells: '按文字忽略', declare_placeholder: '声明占位符', declare_hidden: '设置隐藏行处理',