像小计的行只认夹在明细中间的;可以在配方里声明「没有小计行」,不再每期确认

复核发现(C-subtotal-suspect-false-positive):没配小计时,_suspect_word 对只有「标签文字列 + 数字列」的列表
不加限制,「其余文字列为空」自然成立,首列以 合计/总计/小计/汇总/累计 开头的普通数据行都被报 subtotal_suspect,
最后一行也报(说明却写「夹在明细中间」)。这是必勾的确认项:存量配方(「累计充值」「累计消费」这类键值清单)
升级后每一期导入都多一项,工作流里「按配方导入一期」直接被 files/imports._needs_people 挡下;确认又记不住,
只能用 declare_subtotals 把真数据改成小计。

改法:
- 执行器(recipe_engine._watch_suspect / _settle_suspects):这一行本身像小计(首列以合计字样开头、其余文字和
  日期列空着、有数)之外,还要上一行明细在某个文字或日期列上有值、这一行那一列空着,并且下一行明细在同一列上
  也有值(或者下面紧跟着合计行——最后一组的小计也在列表里面)。候选先挂着,等下一行明细再定;列表到头了还挂着的
  不报。两列的键值清单因此不会报;上下明细那一列本来就空着的也不报。
- 配方新增 ListRows.no_subtotals(缺省 false,规范化时省略,存量配方的哈希不变):人确认过这张列表没有小计行,
  以合计字样开头的行照常是数据,执行器不再报 subtotal_suspect。它是放宽,出确认项 no_subtotals:<块>,签名只看
  这个开关,配上(或改过)的那一次确认一次,之后各期不再出。和 subtotals 二选一,同时配了报 subtotals_conflict。
- 配方面板「小计行」里没勾「有小计行」时多一个勾选框「没有小计行(以合计字样开头的行也是数据,不再提醒)」;
  勾上「有小计行」时自动去掉它。subtotal_suspect 的说明改成「上下明细都有值的文字列在这一行空着」,并说明以后
  不想再确认去哪里勾。
- check-manage 加一步:勾选「没有小计行」发 PUT,no_subtotals 为 true。

测试:test_recipe_p5 加复核的两列反例(门票收入 / 累计收入 / 汇总调整 / 餐饮收入;最后一行「合计调整项」)、
最后一行和上下明细那一列空着的反例、夹在中间和紧跟合计行的正例、no_subtotals 的导入、确认项、签名和冲突校验;
去掉「要有下文」的条件回跑两条失败。
This commit is contained in:
ThearchyHelios
2026-10-16 12:17:33 +08:00
parent 53d510357a
commit 70c5a006d2
8 changed files with 145 additions and 16 deletions
+5 -2
View File
@@ -274,7 +274,7 @@ _FIELD_LABEL = {
"parts": "组成列", "claims": "认领", "a": "一方", "b": "另一方", "reason": "理由",
"ignore_rows": "按行标签忽略的行", "ignore_columns": "按表头忽略的列", "ignore_outside": "按同一行文字忽略的数字",
"label": "标签", "anchor": "同一行的文字",
"subtotals": "小计行", "scope": "小计管哪些明细", "group_column": "分组的列", "period": "每行属于哪一期",
"subtotals": "小计行", "no_subtotals": "没有小计行", "scope": "小计管哪些明细", "group_column": "分组的列", "period": "每行属于哪一期",
"column": "日期列", "check": "核对方式", "synthesize": "加统计期列",
}
@@ -1036,11 +1036,14 @@ class _Checker:
def check_subtotals(self, base: tuple[Any, ...], block: ListBlock, names: list[str]) -> None:
"""期 5:小计行的标签列、分组列要是这个列表的列;按组小计要给分组列;小计和合计的开头词不能互为前缀
(「合计」「合计小计」:一行就分不清是小计还是合计)。"""
(「合计」「合计小计」:一行就分不清是小计还是合计);配了小计又声明没有小计(no_subtotals)是矛盾的。"""
sub = block.rows.subtotals
if sub is None:
return
sp = base + ("rows", "subtotals")
if block.rows.no_subtotals:
self.add(base + ("rows", "no_subtotals"), "subtotals_conflict",
"配了小计行,又声明了这张列表没有小计行:二选一")
if sub.label_column not in names:
self.add(sp + ("label_column",), "column_unknown",
f"小计标签所在的列「{sub.label_column}」不在这个列表的列里{self._near(sub.label_column, names)}")
+5
View File
@@ -527,6 +527,7 @@ def _signatures(recipe: Recipe, sheet_name: Callable[[SheetRecipe], str]) -> dic
subt = block.rows.subtotals
out[f"subtotals:{block.id}"] = _sig(
None if subt is None else (subt.label_column, subt.pick, subt.scope, subt.group_column, subt.keep_as))
out[f"no_subtotals:{block.id}"] = _sig(block.rows.no_subtotals)
period = block.period
out[f"list_period:{block.id}"] = _sig(None if period is None else (period.column, period.synthesize))
for r in recipe.relations:
@@ -1076,6 +1077,10 @@ def _recipe_items(bag: _Bag, recipe: Recipe, ex: dict[str, Any], facts: Any, *,
nums = [c.name for c in b.columns if c.type in ("INTEGER", "REAL")]
detail = f"{scope};按明细核对的列:{'、'.join(nums)}" if nums else f"{scope};这个块没有数字列,不核对"
bag.add(f"subtotals:{b.id}", f"{prefix}改作核对、不进数据{keep}", detail)
elif b.rows.no_subtotals:
# 声明没有小计行是放宽:以合计字样开头的行从此照常进数据,不再报「像小计」。配上(或改过)的那一次确认
bag.add(f"no_subtotals:{b.id}", f"「{b.id}」没有小计行:以合计字样开头的行都按数据导入,不再提醒像小计的行",
"如果其实有穿插的小计,对这些列求和会把小计重复算进去")
period = b.period
if period is not None and period.column:
bag.add(f"list_period:{b.id}", f"「{b.id}」每一行的「{period.column}」都要在本期统计期内",
+45 -10
View File
@@ -1679,9 +1679,14 @@ class _ListRun:
#: 期 5:加统计期起止两列(ListPeriod.synthesize):每行末尾两格占位,统计期定下来以后由 _Sink 换成真值
self.synth = block.period is not None and block.period.synthesize is not None
#: 期 5:没配小计时,像夹在明细中间的小计的数据行(行号)和命中的合计字样:照常按数据导入,收尾时报
#: subtotal_suspect 要人确认(带修复按钮:改作小计核对)
#: subtotal_suspect 要人确认(带修复按钮:改作小计核对)。人声明过没有小计(no_subtotals)的不看
self.suspects: list[int] = []
self.suspect_word: str | None = None
self.watch_suspects = block.rows.subtotals is None and not block.rows.no_subtotals
#: 上一行普通明细在哪些文字、日期列(列序号,不含标签所在的首列)上有值;还没有明细时是空集
self.prev_filled: frozenset[int] = frozenset()
#: 等着下一行明细确认的候选:(行号, 合计字样, 上一行明细有值、这一行空着的列)
self.pending: list[tuple[int, str, frozenset[int]]] = []
# ---------------- 喂入 ----------------
@@ -2069,6 +2074,8 @@ class _ListRun:
label = inb.get(self.colmap.get(self.total_idx, -1))
pick = match_key(block.rows.total_row.pick) # type: ignore[union-attr]
if label is not None and match_key(_text(label.value)).startswith(pick):
if self.watch_suspects:
self._settle_suspects(None)
self._total_row(r, inb, cells, label)
return
if self.sub_idx is not None:
@@ -2076,15 +2083,43 @@ 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
elif self.watch_suspects:
self._watch_suspect(r, inb)
self._record(r, inb, cells, fills)
def _watch_suspect(self, r: int, inb: dict[int, GridCell]) -> None:
"""期 5:没配小计、也没声明没有小计时,认夹在明细中间的小计(照常按数据导入,收尾时报 subtotal_suspect)。
要同时满足才算:首列以合计字样开头、其余文字和日期列都空着、至少一格有数(_suspect_word);上一行明细在某个
文字或日期列上有值、这一行那一列空着;下一行明细在同一列上也有值,或者下面紧跟着合计行。所以候选先挂着,
等下一行明细来了再定(_settle_suspects)。
为什么这么严(复核反例):只有「标签列 + 数字列」的键值清单(「累计收入 300」「汇总调整 5」),「其余文字列
为空」自然成立,什么也说明不了;放在最后、后面没有明细的行也不是「夹在中间」。报错了就是每期都要勾的
确认项,按配方导入一期的无人值守运行会被它挡住。"""
filled = frozenset(i for i, col in enumerate(self.cols)
if col.type in ("TEXT", "DATE") and (c := self.colmap.get(i)) is not None
and c != self.c1 and c in inb)
word = self._suspect_word(inb) if self.prev_filled else None
if word is not None:
self.pending.append((r, word, self.prev_filled))
return
self._settle_suspects(filled)
self.prev_filled = filled
def _settle_suspects(self, filled: frozenset[int] | None) -> None:
"""挂着的候选有了下文:filled 是紧跟着的那行明细有值的文字、日期列(None 是紧跟着合计行)。上一行明细有值、
候选空着的列,下一行明细也有值,才算夹在明细中间。"""
for row, word, gap in self.pending:
if filled is None or gap & filled:
self.suspects.append(row)
self.suspect_word = self.suspect_word or word
self.pending = []
def _suspect_word(self, inb: dict[int, GridCell]) -> str | None:
"""期 5:这一行像不像夹在明细中间的小计(配方没配 subtotals 时才看):首列是以合计字样(TOTAL_WORDS)开头的文字,
其余文字、日期列都空着,至少一格有数。像的话返回那个字样。以前这种行静默当数据导入,求和就把小计重复算了
进去(H2);现在照常导入,但收尾时报出来要人确认,并给「改作小计核对」的修复按钮。合计行已经在前面认走了。"""
"""期 5:这一行本身像不像小计:首列是以合计字样(TOTAL_WORDS)开头的文字,其余文字、日期列都空着,至少一格
有数。像的话返回那个字样。以前这种行静默当数据导入,求和就把小计重复算了进去(H2);现在照常导入,但收尾时
报出来要人确认,并给「改作小计核对」的修复按钮。合计行已经在前面认走了。上下文的条件见 _watch_suspect。"""
cell = inb.get(self.c1)
if cell is None or cell.formula is not None or not isinstance(cell.value, str):
return None
@@ -2110,10 +2145,10 @@ class _ListRun:
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}」开头、其余文字列为空),"
"按数据导入了")
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]})
+3
View File
@@ -323,6 +323,9 @@ class ListRows(_M):
total_row: TotalRow | None = None
#: 期 5:穿插在明细中间的小计行(Subtotals)。默认 None = 期 4 的行为(像小计的行照常是数据或起草失败)
subtotals: Subtotals | None = None
#: 期 5:人确认过这张列表没有小计行:以合计字样开头的行就是数据(「累计充值」这类键值清单),执行器不再报
#: subtotal_suspect,每期也就不用再勾那一项确认。和 subtotals 二选一(静态校验 subtotals_conflict)
no_subtotals: bool = False
class ListValues(_M):
+59
View File
@@ -476,3 +476,62 @@ 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"]
def _suspects(data: list[tuple[Any, ...]], columns: list[tuple[str, str]], **block: Any) -> list[str]:
ex = _run(_book(data), _list(columns, **block))
assert ex.ok, [p.message for p in ex.problems]
return [c for p in ex.problems if p.code == "subtotal_suspect" for c in p.cells]
def test_a_two_column_key_value_list_is_never_flagged():
"""复核反例:只有「标签文字列 + 数字列」的键值清单,「其余文字列为空」自然成立,不能据此说它像小计——
「累计收入」「汇总调整」是普通的数据行。最后一行以合计字样开头也一样。"""
cols = [("项目", "TEXT"), ("金额", "INTEGER")]
assert _suspects([("项目", "金额"), ("门票收入", 100), ("累计收入", 300), ("汇总调整", 5), ("餐饮收入", 50)],
cols) == []
assert _suspects([("项目", "金额"), ("门票收入", 100), ("餐饮收入", 50), ("合计调整项", 5)], cols) == []
def test_only_rows_between_details_are_flagged():
"""像小计的行要夹在明细中间:上下相邻的明细在某个文字列上有值、这一行空着;后面还得有明细(或者是合计行)。
放在最后、后面没有明细的不报(那不是「夹在中间」);上下的明细那一列本来就空着的也不报。"""
cols = [("地区", "TEXT"), ("门店", "TEXT"), ("金额", "INTEGER")]
head = ("地区", "门店", "金额")
# 最后一行:后面没有明细,也没有合计行
assert _suspects([head, ("分区甲", "门店一", 100), ("分区甲", "门店二", 200), ("小计", None, 300)], cols) == []
# 上面的明细「门店」也空着:不是因为小计才空的
assert _suspects([head, ("分区甲", None, 100), ("累计调整", None, 5), ("分区乙", "门店三", 50)], cols) == []
# 下面的明细「门店」空着:同样不报
assert _suspects([head, ("分区甲", "门店一", 100), ("累计调整", None, 5), ("分区乙", None, 50)], cols) == []
# 夹在明细中间:报
assert _suspects([head, ("分区甲", "门店一", 100), ("小计", None, 100), ("分区乙", "门店三", 50)],
cols) == ["明细!A3"]
# 最后一组的小计后面紧跟合计行:也在列表里面,报
total = {"rows": {"total_row": {"label_column": "地区", "pick": "合计"}}}
assert _suspects([head, ("分区甲", "门店一", 100), ("小计", None, 100), ("合计", None, 100)], cols,
**total) == ["明细!A3"]
def test_declaring_no_subtotals_keeps_such_rows_as_data_without_asking(tmp_path):
"""人确认过「这张列表没有小计行」(rows.no_subtotals):以合计字样开头的行照常是数据,不再每期报 subtotal_suspect、
不再每期要勾确认;这个声明本身是放宽,配上的那一次要确认一次。和 subtotals 只能二选一。"""
from app.data import recipe_confirm
recipe = _list(STORE_COLS, rows={"total_row": {"label_column": "地区", "pick": "合计"}, "no_subtotals": True})
assert validate_recipe(recipe, facts=None, origin="rules")[1] == []
db = str(tmp_path / "n.db")
ex = _run(_book(STORES), recipe, db)
assert ex.ok and not [p for p in ex.problems if p.code == "subtotal_suspect"]
assert _rows(db, 'SELECT COUNT(*) FROM "明细"') == [(6,)] # 两行「小计」照常是数据
items = recipe_confirm.confirm_items(recipe_confirm.ConfirmContext(kind="first", recipe=recipe, extraction=ex,
checks=[]))
ids = [i.id for i in items]
assert "no_subtotals:列表1" in ids and not [i for i in ids if i.startswith("subtotal_suspect:")]
sigs = recipe_confirm.recipe_item_signatures(Recipe.model_validate(recipe))
assert sigs["no_subtotals:列表1"] != recipe_confirm.recipe_item_signatures(
Recipe.model_validate(_list(STORE_COLS)))["no_subtotals:列表1"]
both = _stores_recipe()
both["sheets"][0]["blocks"][0]["rows"]["no_subtotals"] = True
probs = validate_recipe(both, facts=None, origin="rules")[1]
assert [p.code for p in probs] == ["subtotals_conflict"] and probs[0].path.endswith("/rows/no_subtotals")
+2
View File
@@ -1644,6 +1644,8 @@ export const RECIPE_TEXT = {
subtotalScope: '一行小计管哪些明细',
subtotalGroupColumn: '按哪一列分组',
subtotalKeep: '原表小计另存为表(留空则只核对)',
/** 人确认过列表里没有小计(ListRows.no_subtotals):像小计的行照常是数据,不再每期报「像小计的行」要人确认 */
noSubtotals: '没有小计行(以合计字样开头的行也是数据,不再提醒)',
listPeriod: '按期累积时每一行属于哪一期',
listPeriodColumn: '按哪个日期列核对',
listPeriodHint: '列表要按期累积,每一行都要说得出属于哪一期;所在工作表要配置统计期(表内没有时,每期试运行时录入)。导入模式另在「导入模式」中选',
+15 -1
View File
@@ -72,7 +72,7 @@ export function fillRecipeDefaults(input: Recipe | null | undefined): Recipe | n
block.extra_columns ??= 'reject'
block.ignore_columns ??= []
block.merged_data ??= 'reject'
block.rows = { blank_rows: 'stop', total_row: null, subtotals: null, ...(isObj(block.rows) ? block.rows : {}) }
block.rows = { blank_rows: 'stop', total_row: null, subtotals: null, no_subtotals: false, ...(isObj(block.rows) ? block.rows : {}) }
if (isObj(block.rows.total_row)) block.rows.total_row.keep_as ??= null
if (isObj(block.rows.subtotals)) {
block.rows.subtotals = { scope: 'since_previous', group_column: null, keep_as: null, ...block.rows.subtotals }
@@ -891,9 +891,23 @@ function ListEditor({ s, b, block, edit }: { s: number; b: number; block: any; e
x.rows.subtotals = on
? { label_column: arr(x.columns)[0]?.name ?? '', pick: '小计', scope: 'since_previous', group_column: null, keep_as: null }
: null
// 有小计和「没有小计行」只能二选一(后端静态校验 subtotals_conflict)
if (on) x.rows.no_subtotals = false
})}>
{RECIPE_TEXT.subtotalsOn}
</Check>
{!subtotals && (
<div className="mt-1">
<Check path={`${base}/rows/no_subtotals`} checked={block.rows?.no_subtotals === true}
onChange={(on) => edit((r) => {
const x = at(r)
x.rows = isObj(x.rows) ? x.rows : {}
x.rows.no_subtotals = on
})}>
{RECIPE_TEXT.noSubtotals}
</Check>
</div>
)}
{subtotals && (
<div className="mt-1 grid grid-cols-1 gap-2 md:grid-cols-3" data-list-subtotals={String(block.id)}>
<FieldWrap path={`${base}/rows/subtotals/label_column`} label={RECIPE_TEXT.subtotalLabelColumn}>
+11 -3
View File
@@ -2301,13 +2301,21 @@ await section('数据 · 表格:按配方导入', async () => {
await until(async () => puts().length === 5, 4000)
check('切换「数据中间的空行」:发 PUT,blank_rows 变成跳过', puts()[4]?.body?.recipe?.sheets?.[1]?.blocks?.[0]?.rows?.blank_rows === 'skip')
await page.waitForTimeout(300)
// 期 5:没配小计时可以声明「没有小计行」(以合计字样开头的行照常是数据,不再每期报「像小计的行」要人确认)
replies.recipe.push(echo())
await panel.locator('[data-field="/sheets/1/blocks/0/rows/no_subtotals"]').check()
await until(async () => puts().length === 6, 4000)
const noSub = puts()[5]?.body?.recipe?.sheets?.[1]?.blocks?.[0]?.rows
check('勾选「没有小计行」:发 PUT,no_subtotals 为 true、没有配小计', noSub?.no_subtotals === true && !noSub?.subtotals,
JSON.stringify(noSub ?? {}))
await page.waitForTimeout(300)
// 有问题的配方:一条对得上字段(单位),一条对不上(挂到面板顶部)
replies.recipe.push(echo({ recipe_problems: [
{ path: '/tables/0/units/分区甲', code: 'unit_label_conflict', message: '表「日客流量」:列「分区甲」的单位与标签「分区甲(人次)」不一致' },
{ path: '/sheets/0/blocks/0/label_offset', code: 'schema', message: '第 1 个工作表的交叉表:标签列偏移只能是紧挨日期的左侧一列' },
] }))
await panel.locator('[data-field="/tables/0/units/分区甲"]').selectOption('人')
await until(async () => puts().length === 6, 4000)
await until(async () => puts().length === 7, 4000)
await page.waitForTimeout(300)
const trialBtn = wizard().locator('[data-trial-run]')
check('配方有问题时「试运行」禁用', await trialBtn.isDisabled())
@@ -2321,8 +2329,8 @@ await section('数据 · 表格:按配方导入', async () => {
await panel.locator('[data-recipe-paste-toggle]').click()
await panel.locator('[data-recipe-paste] textarea').fill(JSON.stringify(FLOW_RECIPE))
await panel.locator('[data-recipe-paste] button', { hasText: '保存配方' }).click()
await until(async () => puts().length === 7, 4000)
check('「粘贴配方」发 PUT,body 就是粘贴的配方', JSON.stringify(puts()[6]?.body?.recipe) === JSON.stringify(FLOW_RECIPE))
await until(async () => puts().length === 8, 4000)
check('「粘贴配方」发 PUT,body 就是粘贴的配方', JSON.stringify(puts()[7]?.body?.recipe) === JSON.stringify(FLOW_RECIPE))
await page.waitForTimeout(300)
check('……问题清掉以后「试运行」可用', await trialBtn.isEnabled())
// 连续修改:上一次 PUT 还没回来就接着改。上一次回来时,排在后面的修改不能被服务端那份盖掉,