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

复核发现(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
+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 还没回来就接着改。上一次回来时,排在后面的修改不能被服务端那份盖掉,