From 70c5a006d2b3f893ee896be40e59918b2a8481d0 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 12:17:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=83=8F=E5=B0=8F=E8=AE=A1=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E5=A4=B9=E5=9C=A8=E6=98=8E=E7=BB=86=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E7=9A=84=EF=BC=9B=E5=8F=AF=E4=BB=A5=E5=9C=A8=E9=85=8D?= =?UTF-8?q?=E6=96=B9=E9=87=8C=E5=A3=B0=E6=98=8E=E3=80=8C=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=B0=8F=E8=AE=A1=E8=A1=8C=E3=80=8D=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=AF=8F=E6=9C=9F=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核发现(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 的导入、确认项、签名和冲突校验; 去掉「要有下文」的条件回跑两条失败。 --- backend/app/data/recipe.py | 7 ++- backend/app/data/recipe_confirm.py | 5 ++ backend/app/data/recipe_engine.py | 55 +++++++++++++++++---- backend/app/data/recipe_types.py | 3 ++ backend/tests/test_recipe_p5.py | 59 +++++++++++++++++++++++ frontend/src/lib/terms.ts | 2 + frontend/src/pages/import/RecipePanel.tsx | 16 +++++- scripts/check-manage.mjs | 14 ++++-- 8 files changed, 145 insertions(+), 16 deletions(-) diff --git a/backend/app/data/recipe.py b/backend/app/data/recipe.py index a4835456..5bc4cad5 100644 --- a/backend/app/data/recipe.py +++ b/backend/app/data/recipe.py @@ -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)}") diff --git a/backend/app/data/recipe_confirm.py b/backend/app/data/recipe_confirm.py index bb36b8e0..015abf54 100644 --- a/backend/app/data/recipe_confirm.py +++ b/backend/app/data/recipe_confirm.py @@ -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}」都要在本期统计期内", diff --git a/backend/app/data/recipe_engine.py b/backend/app/data/recipe_engine.py index a5cea69b..7f0ac181 100644 --- a/backend/app/data/recipe_engine.py +++ b/backend/app/data/recipe_engine.py @@ -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]}) diff --git a/backend/app/data/recipe_types.py b/backend/app/data/recipe_types.py index 2f28d50e..215cd7f3 100644 --- a/backend/app/data/recipe_types.py +++ b/backend/app/data/recipe_types.py @@ -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): diff --git a/backend/tests/test_recipe_p5.py b/backend/tests/test_recipe_p5.py index 54d445d0..9361fd42 100644 --- a/backend/tests/test_recipe_p5.py +++ b/backend/tests/test_recipe_p5.py @@ -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") diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 84392cde..a75c281f 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -1644,6 +1644,8 @@ export const RECIPE_TEXT = { subtotalScope: '一行小计管哪些明细', subtotalGroupColumn: '按哪一列分组', subtotalKeep: '原表小计另存为表(留空则只核对)', + /** 人确认过列表里没有小计(ListRows.no_subtotals):像小计的行照常是数据,不再每期报「像小计的行」要人确认 */ + noSubtotals: '没有小计行(以合计字样开头的行也是数据,不再提醒)', listPeriod: '按期累积时每一行属于哪一期', listPeriodColumn: '按哪个日期列核对', listPeriodHint: '列表要按期累积,每一行都要说得出属于哪一期;所在工作表要配置统计期(表内没有时,每期试运行时录入)。导入模式另在「导入模式」中选', diff --git a/frontend/src/pages/import/RecipePanel.tsx b/frontend/src/pages/import/RecipePanel.tsx index af833366..003f029b 100644 --- a/frontend/src/pages/import/RecipePanel.tsx +++ b/frontend/src/pages/import/RecipePanel.tsx @@ -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} + {!subtotals && ( +
+ edit((r) => { + const x = at(r) + x.rows = isObj(x.rows) ? x.rows : {} + x.rows.no_subtotals = on + })}> + {RECIPE_TEXT.noSubtotals} + +
+ )} {subtotals && (
diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index 9ed2b25c..472a3c7c 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -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 还没回来就接着改。上一次回来时,排在后面的修改不能被服务端那份盖掉,