From bcad199e0f3edd1ece02ce3d4bd197eefafd07b9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 17:47:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=203=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E5=90=8C=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E8=A3=81=E5=86=B3=E9=87=8C=E5=87=A0=E4=B8=AA=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E8=90=BD=E5=88=B0=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E7=9A=84?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E9=A1=B9=E4=B8=8A=E6=97=B6=EF=BC=8C=E5=8D=A1?= =?UTF-8?q?=E4=B8=8A=E6=8C=89=E5=85=B6=E4=BD=99=E6=8C=87=E6=A0=87=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E7=9A=84=E9=80=89=E6=8B=A9=E8=AF=B4=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=E4=B8=8D=E5=90=8C=E7=9A=84=E6=94=B9=E8=AF=B4?= =?UTF-8?q?=E9=83=BD=E4=B8=8D=E8=AE=B0=EF=BC=8C=E6=9C=89=E6=95=88=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=9D=A1=E4=BB=B6=E8=AF=B4=E5=90=88=E6=88=90=E4=B8=80?= =?UTF-8?q?=E5=8F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:服务端写目录前把同一次裁决里落到同一张表同一项上的说法归拢(dual_check_facts._merged):业务日期、去重规则只要说法不同就一项都不写,有效记录条件用 AND 合成一句写。审批卡上的预告是每个指标单独算的,卡上不看别的指标选了什么:一个指标采用原流程(paid_at)、另一个采用盲算(created_at),两块里各自加粗说「记为……」,服务端一项都不写;两个指标都采用去重的一边、去重的列不同,也是这样。结尾只说按发起审批时的数据目录预告,看不出选择的组合也会让写入作废。审核人可能为了不让同一张表出现两个业务日期,把本该分开的选择改成一致。 审批卡(run/DualCheckApproval.tsx) - 按其余指标当前的选择,给每个指标的两种选择各找出落到同一数据源、同一张表、同一项上的指标(peersOf)。只看预告会写的一边:选「两边都不对」、还没选的,服务端不会并进来。说法照服务端的 _same_fact 比:业务日期只看哪一列,其余压掉多余的空白比。 - 业务日期、去重规则说法不同时,那一句改说「……与「退款率」的选择说法不同,都不记」,不再算作会写。有效记录条件在「记为……」后面补一句「与「退款率」采用的条件用 AND 合成一句」。 - 选择一变,各块的说法跟着变。没选中的那几句也按别的指标当前的选择说,审核人改选之前就看得出这一选会不会让整项作废。 - 结尾改成「写入的项记为人工确认的事实;以上按发起审批时的数据目录和各指标当前的选择预告……」。原来是「两边都不对时不记。记为人工确认的事实」,读起来像在说不记的那一项。 - 没有预告时的结尾同样改。时间列、去重两句补上「同一次裁决里几个指标给同一张表记的业务日期(去重规则)不同时都不记」。 夹具与检查 - 夹具加 peers 和 merges。peers 换掉「退款率」对不上的那个输入,和「销售额」落到 orders 的业务日期、去重规则、有效记录条件上。merges 是四种组合:业务日期分开选、选同一列、去重的列不同、有效记录条件不同。 - peers 的差异说明和预告是服务端的真实输出。merges 的 written 是 record_choices 在空目录上真写一遍的结果。 - test_dual_check_fixture.py 新增两项。一项不依赖服务端,查每种组合真的落到同一项上。另一项在服务端合入后建临时数据源真写一遍,逐项比对。 - 在 vs/f1a 的 f7d1a2e 和 233afd1 上都跑过:8 项全过,regenerate 逐字节不变。 - 预览用例 dual-check-approval 认 peer 参数。 - check-verify 新增一节: - 分开选时,两边加粗的那一句都说都不记,写明哪张表的哪一项、和哪个指标说法不同。 - 没选中的那一句按别的指标当前的选择说;一个指标改选两边都不对后,另一个回到「记为」。 - 选同一列照常记;去重的列不同都不记;有效记录条件说合成一句。 - 390px 下深浅色都不横向溢出。 - check-verify 没有预告的一节补两项:「人工确认」说的是写入的项;说法不同时都不记。 - 变红验证:去掉实现(peersOf 返回空),check-verify 变红 6 项;把有效记录条件也当成说法不同,变红 1 项;把还没选、两边都不对也当成采用了一边,变红 2 项。 - 改动后 297 项全部通过(原来 274 项)。check-all 里 check-verify 的耗时估计按实测改成 71 秒。 --- backend/tests/test_dual_check_fixture.py | 135 +++++++++++++++--- frontend/src/dev/fixtures/dual_check.json | 164 +++++++++++++++++++++- frontend/src/dev/fixtures/dual_check.tsx | 9 +- frontend/src/lib/terms.ts | 31 +++- frontend/src/run/DualCheckApproval.tsx | 114 ++++++++++++--- scripts/check-all.mjs | 2 +- scripts/check-verify.mjs | 125 ++++++++++++++++- 7 files changed, 519 insertions(+), 61 deletions(-) diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index bd8cb0ea..47f8df79 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -12,8 +12,15 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 **写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么, dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按 「服务端到底写不写」核对(2026-10 复核:卡上说会记入、服务端却不写的有好几种)。夹具里的预告(catalog_preview 按指标、 -variants 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。双盲复算的服务端合入 -之前没有这个模块,逐项比对那一项跳过,形状和门槛照样查。 +variants 和 peers 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。双盲复算的 +服务端合入之前没有这个模块,逐项比对那几项跳过,形状和门槛照样查。 + +**同一次裁决里几个指标落到同一项上。** 预告是每个指标单独算的,服务端写目录前却要把同一次裁决里落到同一张表同一项上的 +说法归拢(业务日期、去重规则说法不同的一项都不写,有效记录条件合成一句写),卡上按其余指标当前的选择补上这一层 +(2026-10 第 3 轮复核:两个指标分开选,卡上各自说「记为……」,服务端一项都不写)。peers 是换掉「退款率」对不上的那个 +输入的几对 SQL,和「销售额」(或换上的变体)落到数据目录的同一项上;merges 是检查脚本看的几种组合,written 是服务端 +按这几种选择真写一遍(record_choices,空目录、临时数据源)的结果。它要建数据源,regenerate 不生成:测试报不一致时, +照报出的实际结果改 written,再看审批卡的说法要不要跟着改。 重新生成夹具里的差异说明和预告(只改 diff、摘要的 diff 和 fallback、预告,其余原样;没有预告的模块时预告不动): cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py @@ -22,14 +29,19 @@ from __future__ import annotations import importlib import json +import uuid from collections.abc import Iterator from pathlib import Path from typing import Any import pytest +from app.data import catalog from app.data.catalog import TABLE_FIELD_LABEL from app.data.sql_semantics import explain_sql_diff +from app.db.base import SessionLocal +from app.db.models import DataSource +from tests.fixtures.sources import drop_source FIXTURE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "dev" / "fixtures" / "dual_check.json" DIALECT = "sqlite" @@ -51,8 +63,11 @@ SCHEMA: dict[str, Any] = {"tables": { FACT_ITEMS = ("business_date", "valid_filter", "dedup") #: 差异里能对上这三项的方面:时间列、筛选条件、聚合 FACT_ASPECTS = ("time", "filter", "aggregate") -#: 变体换掉的是口径卡里的「销售额」:预告里的指标名照它 +#: 变体换掉的是口径卡里的「销售额」,peers 换掉的是「退款率」:预告里的指标名照它 VARIANT_METRIC = "销售额" +PEER_METRIC = "退款率" +#: 一份夹具里换掉 SQL 的两组:变体换「销售额」,peers 换「退款率」 +SWAPS = {"variants": VARIANT_METRIC, "peers": PEER_METRIC} def _diff(original: str, blind: str) -> dict[str, Any]: @@ -95,14 +110,30 @@ def _candidate(metric: str, original: str, blind: str) -> dict[str, Any]: def _preview_cases(fx: dict[str, Any]) -> Iterator[tuple[str, str, str, str, dict[str, Any], dict[str, Any]]]: """夹具里每一份预告:(名字, 指标名, 原流程 SQL, 盲算 SQL, 工件里的差异, 预告)。口径卡的指标按对不上的那个输入, - 变体按它的一对 SQL(检查脚本拿它换掉「销售额」)。""" + 变体和 peers 按各自的一对 SQL(检查脚本拿变体换掉「销售额」、拿 peers 换掉「退款率」)。""" for metric in fx["card"]["metrics"]: (a, b), = _differing(metric) yield (metric["metric_id"], metric["name"], a["sql"], b["sql"], metric["diff"], fx["catalog_preview"][metric["metric_id"]]) - for name, variant in fx["variants"].items(): - original, blind = variant["sql"] - yield name, VARIANT_METRIC, original, blind, variant["diff"], variant["catalog"] + for group, metric_name in SWAPS.items(): + for name, swap in fx[group].items(): + original, blind = swap["sql"] + yield f"{group}.{name}", metric_name, original, blind, swap["diff"], swap["catalog"] + + +def _merge_metrics(fx: dict[str, Any], case: dict[str, Any]) -> list[tuple[str, str, tuple[str, str], dict[str, Any]]]: + """merges 里的一种组合 → 两个指标各自的 (指标 id, 指标名, 两条 SQL, 预告):「销售额」是口径卡原样(gmv 为空)或换上 + 的变体,「退款率」是换上的 peers。""" + gmv = fx["card"]["metrics"][0] + if case["gmv"]: + variant = fx["variants"][case["gmv"]] + gmv_sql, gmv_preview = tuple(variant["sql"]), variant["catalog"] + else: + (a, b), = _differing(gmv) + gmv_sql, gmv_preview = (a["sql"], b["sql"]), fx["catalog_preview"]["gmv"] + peer = fx["peers"][case["refund_rate"]] + return [("gmv", VARIANT_METRIC, gmv_sql, gmv_preview), + ("refund_rate", PEER_METRIC, tuple(peer["sql"]), peer["catalog"])] def _shown(side: dict[str, Any]) -> dict[str, Any]: @@ -112,10 +143,10 @@ def _shown(side: dict[str, Any]) -> dict[str, Any]: return {"write": side.get("write")} -def _server_preview() -> Any: - """服务端的预告(dual_check_facts.preview);双盲复算的服务端还没合入时返回 None。""" +def _server(name: str) -> Any: + """服务端写目录模块里的 preview(预告)或 record_choices(真写);双盲复算的服务端还没合入时返回 None。""" try: - return getattr(importlib.import_module("app.engine.dual_check_facts"), "preview", None) + return getattr(importlib.import_module("app.engine.dual_check_facts"), name, None) except ImportError: return None @@ -141,10 +172,11 @@ def test_summary_follows_the_card(): def test_variants_carry_the_real_diff(): fx = _load() - assert fx["variants"] - for name, variant in fx["variants"].items(): - original, blind = variant["sql"] - assert variant["diff"] == _diff(original, blind), name + for group in SWAPS: + assert fx[group], group + for name, swap in fx[group].items(): + original, blind = swap["sql"] + assert swap["diff"] == _diff(original, blind), f"{group}.{name}" def test_text_diff_is_not_the_raw_text(): @@ -157,12 +189,12 @@ def test_text_diff_is_not_the_raw_text(): def test_catalog_previews_have_the_server_shape(): - """每个指标、每个变体都有预告,两边齐全;写的只能是服务端写目录的门槛之内的(两条 SQL 不等价、没有兜底、恰好一条 - 差异,而且是时间列、筛选条件或聚合),写的是三项之一、有表有取值;不写的有原因。不依赖服务端的模块,一直查。""" + """每个指标、每个变体和 peers 都有预告,两边齐全;写的只能是服务端写目录的门槛之内的(两条 SQL 不等价、没有兜底、 + 恰好一条差异,而且是时间列、筛选条件或聚合),写的是三项之一、有表有取值;不写的有原因。不依赖服务端的模块,一直查。""" fx = _load() assert set(fx["catalog_preview"]) == {m["metric_id"] for m in fx["card"]["metrics"]} cases = list(_preview_cases(fx)) - assert len(cases) == len(fx["card"]["metrics"]) + len(fx["variants"]) + assert len(cases) == len(fx["card"]["metrics"]) + sum(len(fx[group]) for group in SWAPS) for name, _, _, _, diff, preview in cases: assert set(preview) == {"a", "b"}, name single = not diff["equivalent"] and not diff["fallback"] and len(diff["items"]) == 1 @@ -179,7 +211,7 @@ def test_catalog_previews_have_the_server_shape(): def test_catalog_previews_follow_the_server(): """预告里审批卡用得到的部分(写不写、写到哪、写成什么)和服务端(dual_check_facts.preview)一致:服务端改了写目录 的规则,这里先变红——审批卡上照预告说的、检查脚本按预告核对的有条件说法都要跟着看一遍。""" - preview = _server_preview() + preview = _server("preview") if preview is None: pytest.skip("双盲复算的服务端(dual_check_facts.preview)还没合入") for name, metric, original, blind, _, expected in _preview_cases(_load()): @@ -187,6 +219,61 @@ def test_catalog_previews_follow_the_server(): assert {s: _shown(got[s]) for s in ("a", "b")} == {s: _shown(expected[s]) for s in ("a", "b")}, name +def test_merges_put_two_metrics_on_the_same_item(): + """merges 里每一种组合:两个指标按各自的选择,预告都写、写的是同一个数据源同一张表的同一项。检查脚本拿这几种组合看 + 「同一次裁决里几个指标落到同一项上」卡上怎么说,换了 SQL 落不到同一项上,那几项检查就什么也没测。服务端写了的, + written 里只有这一项。不依赖服务端的模块,一直查。""" + fx = _load() + assert fx["merges"] + for name, case in fx["merges"].items(): + sides = [said[case["choices"][metric_id]] for metric_id, _, _, said in _merge_metrics(fx, case)] + assert all(s["write"] is True for s in sides), name + targets = {(s["source"], s["table"], s["item"]) for s in sides} + assert len(targets) == 1, name + assert all((SOURCE, w["table"], w["item"]) in targets for w in case["written"]), name + + +@pytest.fixture +async def make_source(): + """建一个只有表结构(SCHEMA)、目录为空的临时数据源,返回它的名字和 id;用完连同目录一起删掉。写目录只读表结构, + 不连库,所以不用建库文件。""" + made: list[str] = [] + + async def make() -> tuple[str, str]: + name = f"dc_fixture_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", readonly=True, enabled=True, schema_cache=SCHEMA) + session.add(row) + await session.commit() + made.append(row.id) + return name, row.id + + yield make + for source_id in made: + await drop_source(source_id) + + +async def test_merged_choices_follow_the_server(make_source): + """merges 里每一种组合按服务端真写一遍(dual_check_facts.record_choices,空目录):写了哪几项、写成什么,和夹具的 + written 一致。审批卡按同一套规矩说——同一张表的同一项,业务日期、去重规则说法不同的一项都不写,有效记录条件用 AND + 合成一句写——服务端改了归拢的规矩,这里先变红,卡上的说法(DualCheckApproval 的 peersOf)要跟着看一遍。""" + record = _server("record_choices") + if record is None: + pytest.skip("双盲复算的服务端(dual_check_facts.record_choices)还没合入") + fx = _load() + for name, case in fx["merges"].items(): + source, source_id = await make_source() + candidates = [{**_candidate(metric, *sql), "source": source, "choice": case["choices"][metric_id]} + for metric_id, metric, sql, _ in _merge_metrics(fx, case)] + out = await record(candidates, run_id="run-dc-0001", node_title="复核周报口径", actor=None) + written = [] + async with SessionLocal() as session: + for w in out["written"]: + entry = await catalog.read_entry(session, source_id, w["table"]) + written.append({"table": w["table"], "item": w["item"], "value": entry.notes[w["item"]]["value"]}) + assert written == case["written"], (name, out["skipped"]) + + def regenerate() -> None: fx = _load() for metric in fx["card"]["metrics"]: @@ -195,16 +282,18 @@ def regenerate() -> None: summary = {s["metric_id"]: s for s in fx["approval"]["payload"]["summary"]} for metric in fx["card"]["metrics"]: summary[metric["metric_id"]].update(_summary_of(metric)) - for variant in fx["variants"].values(): - variant["diff"] = _diff(*variant["sql"]) - if (preview := _server_preview()) is not None: + for group in SWAPS: + for swap in fx[group].values(): + swap["diff"] = _diff(*swap["sql"]) + if (preview := _server("preview")) is not None: fx["catalog_preview"] = {} for metric in fx["card"]["metrics"]: (a, b), = _differing(metric) said = preview(_candidate(metric["name"], a["sql"], b["sql"]), SCHEMA, {}) fx["catalog_preview"][metric["metric_id"]] = said - for variant in fx["variants"].values(): - variant["catalog"] = preview(_candidate(VARIANT_METRIC, *variant["sql"]), SCHEMA, {}) + for group, metric_name in SWAPS.items(): + for swap in fx[group].values(): + swap["catalog"] = preview(_candidate(metric_name, *swap["sql"]), SCHEMA, {}) FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index e7372406..6d2a75ae 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -1,5 +1,5 @@ { - "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants 里的 catalog(采用哪一边会往数据目录里写什么)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说", + "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants、peers 里的 catalog(采用哪一边会往数据目录里写什么)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改;merges 里的 written 是服务端按那几种选择真写一遍(record_choices)的结果,同一个测试核对,它要建临时数据源才算得出,不重新生成,报不一致时照报出的实际结果改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说;peers 换掉「退款率」对不上的那个输入,和「销售额」落到数据目录的同一项上,merges 是检查脚本看的几种组合(同一次裁决里几个指标说法不同、要合写时卡上怎么说)", "approval": { "id": "ap-dc-0001", "run_id": "run-dc-0001", @@ -659,5 +659,167 @@ } } } + }, + "peers": { + "time": { + "sql": [ + "SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id\nWHERE o.paid_at >= '2026-09-01'\n AND o.paid_at < '2026-09-08'", + "SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id\nWHERE o.created_at >= '2026-09-01'\n AND o.created_at < '2026-09-08'" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "time", + "text": "原流程按 paid_at 筛时间,盲算按 created_at" + } + ], + "fallback": false, + "text_diff": [ + "- AND orders.paid_at >= '2026-09-01'", + "+ AND orders.created_at >= '2026-09-01'", + "- AND orders.paid_at < '2026-09-08'", + "+ AND orders.created_at < '2026-09-08'" + ] + }, + "catalog": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } + } + } + }, + "dedup": { + "sql": [ + "SELECT COUNT(DISTINCT o.id) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id", + "SELECT COUNT(o.id) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "aggregate", + "text": "原流程对 orders.id 去重计数,盲算没去重" + } + ], + "fallback": false, + "text_diff": [ + "- COUNT(DISTINCT orders.id)", + "+ COUNT(orders.id)" + ] + }, + "catalog": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "dedup", + "value": "对 id 计数时去重" + }, + "b": { + "write": false, + "reason": "指标「退款率」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录" + } + } + }, + "filter": { + "sql": [ + "SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id\nWHERE o.status <> 'cancelled'", + "SELECT COUNT(*) AS refunds\nFROM refunds r\nJOIN orders o ON o.id = r.order_id" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "filter", + "text": "原流程排除了 orders.status = 'cancelled' 的行,盲算没有" + } + ], + "fallback": false, + "text_diff": [ + "- orders.id = refunds.order_id AND orders.status <> 'cancelled'", + "+ orders.id = refunds.order_id" + ] + }, + "catalog": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "valid_filter", + "value": "status <> 'cancelled'" + }, + "b": { + "write": false, + "reason": "指标「退款率」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } + } + } + }, + "merges": { + "time_split": { + "gmv": null, + "refund_rate": "time", + "choices": { + "gmv": "a", + "refund_rate": "b" + }, + "written": [] + }, + "time_same": { + "gmv": null, + "refund_rate": "time", + "choices": { + "gmv": "a", + "refund_rate": "a" + }, + "written": [ + { + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + } + ] + }, + "dedup_columns": { + "gmv": "dedup", + "refund_rate": "dedup", + "choices": { + "gmv": "a", + "refund_rate": "a" + }, + "written": [] + }, + "filters": { + "gmv": "filter_extra", + "refund_rate": "filter", + "choices": { + "gmv": "a", + "refund_rate": "a" + }, + "written": [ + { + "table": "orders", + "item": "valid_filter", + "value": "status <> 'test' AND status <> 'cancelled'" + } + ] + } } } diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx index 72662af6..12f7d370 100644 --- a/frontend/src/dev/fixtures/dual_check.tsx +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -25,8 +25,10 @@ import data from './dual_check.json' * ?syn=dual-check-approval 另认地址上的几个参数(检查脚本用;不带时 payload 只有约定里的几项,同老服务端): * - variant=<名字>:「销售额」的摘要换成 variants 里那一对 SQL 的(前 3 条差异的原话、是否只有兜底,同节点的写法), * 工件由检查脚本按同一个变体伪造 - * - catalog=1:摘要里每个指标带上服务端预告的「采用哪一边会往数据目录里写什么」(catalog_preview;换了变体的 - * 「销售额」用变体的 catalog)。catalog=bad:「销售额」带一份认不出的预告 + * - peer=<名字>:「退款率」的摘要同样换成 peers 里那一对 SQL 的。peers 和「销售额」(或换上的变体)落到数据目录的 + * 同一项上,看同一次裁决里几个指标说法不同、要合写时卡上怎么说(夹具的 merges) + * - catalog=1:摘要里每个指标带上服务端预告的「采用哪一边会往数据目录里写什么」(catalog_preview;换了变体、peers 的 + * 指标用它们自己的 catalog)。catalog=bad:「销售额」带一份认不出的预告 * - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」 */ const approval = (patch: Partial = {}): Approval => ({ @@ -40,11 +42,12 @@ interface Variant { diff: { items: { aspect: string; text: string }[] }; catalog function approvalOf(search: string): Approval { const q = new URLSearchParams(search) const variant = (data.variants as unknown as Record)[q.get('variant') ?? ''] + const peer = (data.peers as unknown as Record)[q.get('peer') ?? ''] const catalog = q.get('catalog') const onReject = q.get('on_reject') const previews = data.catalog_preview as unknown as Record const summary = data.approval.payload.summary.map((s) => { - const swapped = s.metric_id === 'gmv' && variant ? variant : null + const swapped = s.metric_id === 'gmv' && variant ? variant : s.metric_id === 'refund_rate' && peer ? peer : null const items = swapped?.diff.items return { ...s, diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 80bc6b39..8d8f19ad 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3464,20 +3464,28 @@ export const DUAL_CHECK_TEXT = { /** * 服务端没有预告(老服务端,见 catalogPreview)、两边又只差一处时,提交前说清选择可能被记进数据目录。卡上只看得到 * 差异的方面,看不到服务端写目录的其余条件(dual_check_facts.derive:只有一边按时间筛的不记,多出的条件得在排除 - * 无效记录、又不是指标自己的范围,只差去不去重时得采用去重的一边),所以每句只说必要条件(「仅当……才」), - * 不写的几种常见情况照实说出来,免得审核人为了不让目录被写错而避开本来对的一边。卡上是「方面那句 + tail」 + * 无效记录、又不是指标自己的范围,只差去不去重时得采用去重的一边;同一次裁决里几个指标给同一张表记的业务日期、 + * 去重规则不同时一项都不写),所以每句只说必要条件(「仅当……才」),不写的几种常见情况照实说出来,免得审核人 + * 为了不让目录被写错而避开本来对的一边。卡上是「方面那句 + tail」。tail 紧跟在「……时不记」后面,开头写明说的是 + * 写入的项 */ catalog: { - time: '仅当两边按不同的列筛时间,采用的一边所用的时间列才会作为该表的业务日期记入数据目录;只有一边按时间筛时不记', + time: '仅当两边按不同的列筛时间,采用的一边所用的时间列才会作为该表的业务日期记入数据目录;只有一边按时间筛时不记,' + + '同一次裁决里几个指标给同一张表记的业务日期不同时都不记', filter: '仅当多出的条件只在一边、并且是在排除退款、作废、测试这类无效记录,采用有它的一边时,这个条件才会作为该表的' + '有效记录条件记入数据目录;只看某个地区、某个渠道这类限定指标范围的条件不记', - aggregate: '仅当两边只差是否去重、并且采用的是去重的一边,去重的做法才会作为该表的去重规则记入数据目录;采用不去重的一边时不记', - tail: '。记为人工确认的事实;选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,是否记入以时间线上的记录为准', + aggregate: '仅当两边只差是否去重、并且采用的是去重的一边,去重的做法才会作为该表的去重规则记入数据目录;采用不去重的一边时不记,' + + '同一次裁决里几个指标给同一张表记的去重规则不同时都不记', + tail: '。写入的项记为人工确认的事实;选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,是否记入以时间线上的记录为准', }, /** * 服务端预告了采用哪一边会往数据目录里写什么(payload.summary[].catalog)时照它说:三个选择各一句,写的说哪张表的 * 哪一项记成什么,不写的只说不记(原因在裁决后的时间线上)。两边都不写时卡上不提数据目录。卡上是 - * 「head + 三句(;分隔)+ tail」,审核人当前选的那一句加重 + * 「head + 三句(;分隔)+ tail」,审核人当前选的那一句加重。 + * + * 预告是每个指标单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢(dual_check_facts._merged): + * 业务日期、去重规则说法不同的一项都不写(conflict),有效记录条件用 AND 合成一句写(joined)。卡上按其余指标当前的 + * 选择补上这一层,tail 里说明预告是按当前的选择算的 */ catalogPreview: { head: '记入数据目录:', @@ -3486,7 +3494,16 @@ export const DUAL_CHECK_TEXT = { /** 两边写的是同一张表的同一项、只差取值时,后一边省掉「哪张表的哪一项」 */ writeSame: (choice: string, value: string) => `${choice}时记为「${value}」`, skip: (choice: string) => `${choice}时不记`, - tail: '。记为人工确认的事实;以上按发起审批时的数据目录预告,实际写入以时间线上的记录为准', + /** metrics 是 metricNames 写好的、和这一边说法不同的指标(按它们当前的选择) */ + conflict: (choice: string, where: string, metrics: string) => `${choice}时,${where}与${metrics}的选择说法不同,都不记`, + /** 同 writeSame:两边写的是同一项时,后一边省掉「哪张表的哪一项」 */ + conflictSame: (choice: string, metrics: string) => `${choice}时与${metrics}的选择说法不同,都不记`, + /** 有效记录条件接在 write / writeSame 后面:和这些指标当前采用的条件合成一句写 */ + joined: (metrics: string) => `,与${metrics}采用的条件用 AND 合成一句`, + /** 「退款率」、「订单数」;多于 3 个只列前 3 个 */ + metricNames: (names: string[]) => names.slice(0, 3).map((n) => `「${n}」`).join('、') + + (names.length > 3 ? `等 ${names.length} 个指标` : ''), + tail: '。写入的项记为人工确认的事实;以上按发起审批时的数据目录和各指标当前的选择预告,实际写入以时间线上的记录为准', }, choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index fb461bc7..601ca785 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -59,12 +59,23 @@ interface MetricView { full: DualCheckMetric | null } -/** 服务端预告的一边:写的话写到哪(where 是 「orders」(shop)的业务日期 这样的说法)、写成什么 */ +/** + * 服务端预告的一边:写的话写到哪(where 是 「orders」(shop)的业务日期 这样的说法)、写成什么(value 是给人看的 + * 写法,raw 是原样的取值,和别的指标的说法比是不是同一个时用) + */ type PreviewSide = - | { write: true; table: string; source: string | null; item: string; where: string; value: string } + | { write: true; table: string; source: string | null; item: string; where: string; value: string; raw: unknown } | { write: false } +type WritingSide = Extract interface CatalogPreview { a: PreviewSide; b: PreviewSide } +/** + * 同一次裁决里,别的指标按当前的选择落到同一项上(同一数据源、同一张表、同一项)的:differ 是说法和这一边不同的指标名 + * ——业务日期、去重规则说法不同时服务端一项都不写;joined 是条件和这一边不同的指标名——有效记录条件用 AND 合成一句写 + */ +interface Peers { differ: string[]; joined: string[] } +type PreviewPeers = Partial> + const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) const hasSql = (v: unknown): boolean => typeof v === 'string' && !!v.trim() @@ -97,8 +108,9 @@ function notesOf(texts: string[]): { standard: boolean; notes: string[] } { * 服务端没有预告时(老服务端),审核人的选择可能被记进数据目录的形状:照服务端写目录的门槛(dual_check_facts.derive) * ——两条 SQL 不等价、没用兜底、差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成 * 一份(每个输入至少一条),所以「恰好一条」也就是只有一个输入对不上。过了这道门槛也不一定写:只有一边按时间筛、 - * 多出的条件是指标自己的范围、采用了不去重的一边都不写,这些卡上看不出来(差异条目只有方面和原话),所以只说必要 - * 条件,并把不写的几种照实说出来(DUAL_CHECK_TEXT.approval.catalog) + * 多出的条件是指标自己的范围、采用了不去重的一边都不写,同一次裁决里几个指标给同一张表记的业务日期、去重规则不同时 + * 一项都不写,这些卡上看不出来(差异条目只有方面和原话,没有预告也认不出写到哪张表),所以只说必要条件,并把不写的 + * 几种照实说出来(DUAL_CHECK_TEXT.approval.catalog) */ function catalogOf(metric: DualCheckMetric, items: { aspect: string; text: string }[]): CatalogAspect | null { if (metric.diff?.equivalent === true || metric.diff?.fallback === true || items.length !== 1) return null @@ -119,7 +131,7 @@ function previewSideOf(raw: unknown): PreviewSide | undefined { const label = CATALOG_TABLE_FIELD_LABEL[item as keyof typeof CATALOG_TABLE_FIELD_LABEL] return { write: true, table, source, item, - where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label), value: patchValueText(item, s.value), + where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label), value: patchValueText(item, s.value), raw: s.value, } } @@ -134,6 +146,51 @@ function previewOf(raw: unknown): CatalogPreview | null { return a && b ? { a, b } : null } +/** 两边写的是不是同一个数据源、同一张表的同一项(服务端写目录前按这三样归拢) */ +const sameTarget = (a: PreviewSide, b: PreviewSide): boolean => + a.write && b.write && a.table === b.table && a.source === b.source && a.item === b.item + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +/** 同一项的两个说法是不是同一个(同服务端的 _same_fact):业务日期只看哪一列,其余是文字、压掉多余的空白比 */ +function sameFact(item: string, a: unknown, b: unknown): boolean { + if (item === 'business_date') return isRecord(a) && isRecord(b) && a.column === b.column + const flat = (s: string) => s.split(/\s+/).filter(Boolean).join(' ') + return typeof a === 'string' && typeof b === 'string' && flat(a) === flat(b) +} + +/** + * 每个指标的预告是单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢成一项 + * (dual_check_facts._merged):业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就一项都不写;有效记录 + * 条件都在排除无效记录,用 AND 合成一句写。这里按其余指标当前的选择,给每个指标的两种选择各找出落到同一项上的指标 + * (见 Peers),卡上照它说。只看预告会写的一边:选「两边都不对」、还没选的,服务端不会并进来;预告说不写的,多半是 + * 差异对不上目录项,服务端同样不并。少数是因为目录里已有的说法才不写,服务端仍会把它并进来、可能让这一项都不写, + * 可预告里没有它落到哪一项,卡上看不出,所以结尾说以时间线上的记录为准 + */ +function peersOf(views: MetricView[], choices: Record): Map { + const adopted: { id: string; name: string; side: WritingSide }[] = [] + for (const v of views) { + const c = choices[v.id] + const side = c === 'a' || c === 'b' ? v.preview?.[c] : undefined + if (side?.write) adopted.push({ id: v.id, name: v.name, side }) + } + const out = new Map() + for (const v of views) { + const peers: PreviewPeers = {} + for (const c of ['a', 'b'] as const) { + const mine = v.preview?.[c] + if (!mine?.write) continue + // 说法和这一边不同的指标:业务日期、去重规则让整组都不写,有效记录条件要合成一句 + const names = [...new Set(adopted + .filter((o) => o.id !== v.id && sameTarget(o.side, mine) && !sameFact(mine.item, o.side.raw, mine.raw)) + .map((o) => o.name))] + if (!names.length) continue + peers[c] = mine.item === 'valid_filter' ? { differ: [], joined: names } : { differ: names, joined: [] } + } + out.set(v.id, peers) + } + return out +} + function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] { const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m])) // 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面 @@ -267,6 +324,8 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro const [note, setNote] = useState('') const [busy, setBusy] = useState(false) const pending = views.filter((v) => !choices[v.id]).length + // 预告要按其余指标当前的选择补上「同一项上说法不同」「合成一句」:选择一变,各块的说法跟着变 + const peers = useMemo(() => peersOf(views, choices), [views, choices]) /** 提交裁决(approved 为真,value 是 {choices, note})或驳回(认不出指标时的出口) */ const send = async (approved: boolean) => { @@ -338,7 +397,7 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro ) : ( <> {views.map((v) => ( - setChoices((cur) => ({ ...cur, [v.id]: c }))} /> ))} @@ -364,8 +423,8 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro } /** 一个不一致的指标:名字、两个值、相差多少、差异说明、对照两条 SQL、三选一 */ -function MetricBlock({ view, name, choice, onChoose }: { - view: MetricView; name: string; choice?: DualCheckChoice; onChoose: (c: DualCheckChoice) => void +function MetricBlock({ view, name, choice, peers, onChoose }: { + view: MetricView; name: string; choice?: DualCheckChoice; peers?: PreviewPeers; onChoose: (c: DualCheckChoice) => void }) { const uid = useId() // 只有兜底说明(或只有解析失败)时默认展开:一条具体说法都没有,审核人只能对照原文判断 @@ -425,7 +484,7 @@ function MetricBlock({ view, name, choice, onChoose }: { {expanded && view.full && } {view.preview ? ( // 服务端预告了:两边都不写就不提数据目录 - (view.preview.a.write || view.preview.b.write) && + (view.preview.a.write || view.preview.b.write) && ) : view.catalog && (

{T.catalog[view.catalog]}{T.catalog.tail}

)} @@ -450,31 +509,44 @@ function MetricBlock({ view, name, choice, onChoose }: { ) } -const sameTarget = (a: PreviewSide, b: PreviewSide): boolean => - a.write && b.write && a.table === b.table && a.source === b.source && a.item === b.item +/** 预告里的一句:说什么、这一选会不会写(说法不同、都不写的不算)、是不是说法不同、是不是要和别的条件合写 */ +interface Clause { side: DualCheckChoice; text: string; write: boolean; conflict: boolean; joined: boolean } /** * 服务端预告的「记入数据目录」:三个选择各一句,审核人当前选的那一句加重——选之前三句都看得到,选了之后一眼看出这一选 - * 会往目录里写什么。两边写的是同一项(只差取值,比如业务日期按哪一列)时,后一句省掉「哪张表的哪一项」 + * 会往目录里写什么。两边写的是同一项(只差取值,比如业务日期按哪一列)时,后一句省掉「哪张表的哪一项」。 + * peers 是同一次裁决里别的指标按当前的选择落到同一项上的(peersOf):说法不同的这一句改说都不记,有效记录条件补一句 + * 合写;选择变了跟着变 */ -function CatalogPreviewNote({ preview, choice }: { preview: CatalogPreview; choice?: DualCheckChoice }) { +function CatalogPreviewNote({ preview, peers, choice }: { preview: CatalogPreview; peers?: PreviewPeers; choice?: DualCheckChoice }) { const P = T.catalogPreview - const said = (side: 'a' | 'b'): string => { + const skip = (side: DualCheckChoice): Clause => ({ side, text: P.skip(T.choices[side]), write: false, conflict: false, joined: false }) + const clause = (side: 'a' | 'b'): Clause => { const s = preview[side] - if (!s.write) return P.skip(T.choices[side]) - return side === 'b' && sameTarget(preview.a, s) ? P.writeSame(T.choices.b, s.value) : P.write(T.choices[side], s.where, s.value) + if (!s.write) return skip(side) + // 前一句已经写明了是哪张表的哪一项(说法不同的那句也写),这一句省掉 + const short = side === 'b' && sameTarget(preview.a, s) + const { differ = [], joined = [] } = peers?.[side] ?? {} + if (differ.length) { + const names = P.metricNames(differ) + const text = short ? P.conflictSame(T.choices.b, names) : P.conflict(T.choices[side], s.where, names) + return { side, text, write: false, conflict: true, joined: false } + } + const text = short ? P.writeSame(T.choices.b, s.value) : P.write(T.choices[side], s.where, s.value) + return joined.length + ? { side, text: text + P.joined(P.metricNames(joined)), write: true, conflict: false, joined: true } + : { side, text, write: true, conflict: false, joined: false } } - const clauses: [DualCheckChoice, string, boolean][] = [ - ['a', said('a'), preview.a.write], ['b', said('b'), preview.b.write], ['neither', P.skip(T.choices.neither), false], - ] + const clauses = [clause('a'), clause('b'), skip('neither')] return (

{P.head} - {clauses.map(([side, text, write], i) => ( + {clauses.map(({ side, text, write, conflict, joined }, i) => ( {i > 0 && ';'} + data-dual-catalog-side={side} data-write={write ? '' : undefined} data-current={choice === side ? '' : undefined} + data-conflict={conflict ? '' : undefined} data-joined={joined ? '' : undefined}> {text} diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index fba135a7..4d77467c 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -96,7 +96,7 @@ const ESTIMATE = { stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, // 占位,换成真检查时按实测改 - schedules: 60, compare: 45, verify: 65, inquiry: 1, bundle: 16, whatif: 33, + schedules: 60, compare: 45, verify: 71, inquiry: 1, bundle: 16, whatif: 33, } // 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index ce037f01..931bead6 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -6,9 +6,11 @@ // 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出 // (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等; // 「记入数据目录」不能说得比服务端宽:服务端在摘要里预告了采用哪一边写什么(payload.summary[].catalog)时照它说, -// 两边都不写就不提,当前选的那一句加重;老服务端没有预告时只在可能写的形状下(恰好一条具体差异,且是时间列、筛选 -// 条件或聚合)说必要条件,只有一边按时间筛、多出的是指标自己范围的条件、采用不去重的一边这几种不写的照实说—— -// 夹具里每个变体都带着服务端对这一对 SQL 的真实预告,两种说法都按它核对; +// 两边都不写就不提,当前选的那一句加重;同一次裁决里别的指标按当前的选择落到同一张表的同一项上时,照服务端写目录前 +// 的归拢说——业务日期、去重规则说法不同的改说都不记,有效记录条件说合成一句;老服务端没有预告时只在可能写的形状下 +// (恰好一条具体差异,且是时间列、筛选条件或聚合)说必要条件,只有一边按时间筛、多出的是指标自己范围的条件、采用 +// 不去重的一边这几种不写的照实说——夹具里每个变体都带着服务端对这一对 SQL 的真实预告,几种组合带着服务端真写一遍的 +// 结果,两种说法都按它核对; // 三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」 // (mode 是 input,通用那套会把一个字符串当 value 发出去),认不出指标时另给「驳回并终止」;后果按运行类别和节点的 // 「未采用原流程时」说(payload 带了实际生效的处置就照它说,老服务端从运行的图快照里读),探索运行不提出具,读不到 @@ -326,7 +328,7 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输 /** * 把「销售额」换成 variants 里的一对 SQL 及其真实的差异说明(取值照旧不同),别的指标原样;diff 给了就用给的 - * (拼几个输入的、兜底的原话) + * (拼几个输入的、兜底的原话)。peer 给了就把「退款率」对不上的那个输入同样换成 peers 里的一对 SQL */ function cardWith(name, patch = {}) { const v = fx.variants[name] @@ -337,6 +339,14 @@ function cardWith(name, patch = {}) { gmv.diff = JSON.parse(JSON.stringify(v?.diff ?? gmv.diff)) Object.assign(gmv, patch.metric ?? {}) if (patch.diff) gmv.diff = patch.diff + if (patch.peer) { + const p = fx.peers[patch.peer] + const rr = card.metrics[1] + const k = rr.original.inputs.findIndex((i, n) => i.value !== rr.blind.inputs[n].value) + rr.original.inputs[k].sql = p.sql[0] + rr.blind.inputs[k].sql = p.sql[1] + rr.diff = JSON.parse(JSON.stringify(p.diff)) + } return async () => ({ json: { id: CARD_ID, content: card } }) } @@ -377,6 +387,13 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 // 服务端会写的那一边,卡上的必要条件里得说到写的是哪一项 const items = writes.map((s) => LABEL[expect[s].item]) check(`……服务端会写的(${items.join('、') || '无'})卡上说到了`, items.every((i) => note.includes(i)), note) + // 「不记」后面紧跟一句「记为人工确认的事实」,读起来像在说不记的那一项 + check('……「人工确认」说的是写入的项', note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note) + } + if (name === 'time_one_side' || name === 'dedup') { + // 同一次裁决里几个指标给同一张表记的业务日期、去重规则不同时,服务端一项都不写(有效记录条件是合成一句写)。 + // 没有预告时卡上看不出别的指标落到哪一项上,照实说这一条 + check('……同一次裁决里几个指标给同一张表记的说法不同时都不记', note.includes('同一次裁决里') && note.includes('不同时都不记'), note) } if (name === 'time_one_side') { // 正上方那条差异说「盲算没有按时间筛选」:卡上不能再说两边只差在按哪一列筛时间 @@ -420,10 +437,14 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 check('几个输入都差在同一方面(合并后两条):不说会记入数据目录', await metric(two.page, 'gmv').locator('[data-dual-catalog]').count() === 0) }) -/** 一块里预告的三句:{a, b, neither} → {text, write, current} */ +/** + * 一块里预告的三句:{a, b, neither} → {text, write, current, conflict, joined}。conflict:同一次裁决里别的指标对同一项 + * 说法不同、都不记;joined:有效记录条件要和别的指标的合成一句 + */ const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')] .map((s) => [s.dataset.dualCatalogSide, { text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight), + conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, }]))) await section('审批卡:服务端预告了写目录时照它说,当前选的那一句加重', async () => { @@ -537,6 +558,100 @@ await section('审批卡:服务端预告了写目录时照它说,当前选 } }) +await section('审批卡:同一次裁决里几个指标落到数据目录的同一项上,按其余指标当前的选择说', async () => { + // 预告是每个指标单独算的,服务端写目录前却把同一次裁决里落到同一张表同一项上的说法归拢(dual_check_facts._merged): + // 业务日期、去重规则说法不同的一项都不写,有效记录条件用 AND 合成一句写。两个指标分开选时卡上要是各自加粗说 + // 「记为……」,审核人会以为同一张表要记两个业务日期,可能为了避开它把本该分开的选择改成一致(2026-10 第 3 轮复核)。 + // 卡上按其余指标当前的选择说,选择变了说法跟着变。夹具 merges 是这几种组合在服务端真写一遍的结果 + // (test_dual_check_fixture.py 用 record_choices 核对),peers 换掉「退款率」,和「销售额」落到同一项上 + const LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } + const open = async (name, opts = {}) => { + const m = fx.merges[name] + const query = ['catalog=1', `peer=${m.refund_rate}`, ...(m.gmv ? [`variant=${m.gmv}`] : [])].join('&') + const out = await preview('dual-check-approval', { query, artifact: cardWith(m.gmv, { peer: m.refund_rate }), ...opts }) + await out.page.waitForSelector('[data-dual-check][data-state="ready"]') + return out + } + const choose = (page, id, c) => metric(page, id).locator('label', { hasText: LABEL[c] }).click() + const chooseAll = async (page, name) => { + for (const [id, c] of Object.entries(fx.merges[name].choices)) await choose(page, id, c) + } + /** 两个指标当前选中的那一句 */ + const current = async (page) => Object.fromEntries(await Promise.all(['gmv', 'refund_rate'].map(async (id) => ( + [id, Object.values(await previewClauses(metric(page, id))).find((x) => x.current) ?? null])))) + + // 业务日期:销售额采用原流程(paid_at)、退款率采用盲算(created_at) + check('夹具:分开选时服务端一项都不写(record_choices 的真实结果)', fx.merges.time_split.written.length === 0) + const { page, errors } = await open('time_split') + let g = await previewClauses(metric(page, 'gmv')) + check('还没选:退款率没有选择,销售额照自己的预告说', g.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' + && g.a.write && !g.a.conflict, g.a?.text) + await choose(page, 'gmv', 'a') + let r = await previewClauses(metric(page, 'refund_rate')) + check('销售额选了原流程:退款率选原流程(同一列)照常记', r.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' + && r.a.write && !r.a.conflict, r.a?.text) + check('……退款率选盲算(另一列)说清和销售额说法不同、都不记,不再说「记为」', !!r.b?.conflict && !r.b.write + && r.b.text.includes('「销售额」') && r.b.text.endsWith('都不记') && !r.b.text.includes('记为'), r.b?.text) + await chooseAll(page, 'time_split') + let cur = await current(page) + check('分开选:两边加粗的那一句都说都不记,和服务端一致', ['gmv', 'refund_rate'].every((id) => cur[id]?.conflict && !cur[id].write + && cur[id].weight >= 500), JSON.stringify(cur)) + check('……销售额那一句写明是哪张表的哪一项、和哪个指标说法不同', + cur.gmv?.text === '采用原流程的结果时,「orders」(shop)的业务日期与「退款率」的选择说法不同,都不记', cur.gmv?.text) + check('……退款率那一句是同一项,不再重复写表名', cur.refund_rate?.text === '采用盲算的结果时与「销售额」的选择说法不同,都不记', + cur.refund_rate?.text) + g = await previewClauses(metric(page, 'gmv')) + check('……销售额那一块里,改选盲算(和退款率同一列)的那一句照常说记', g.b?.text === '采用盲算的结果时记为「created_at」' + && g.b.write && !g.b.conflict, g.b?.text) + const note = await text(metric(page, 'gmv').locator('[data-dual-catalog]')) + check('……结尾说清预告按各指标当前的选择算,「人工确认」说的是写入的项', note.includes('各指标当前的选择') + && note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note) + // 退款率改选两边都不对:不再落到同一项上,销售额照常记 + await choose(page, 'refund_rate', 'neither') + g = await previewClauses(metric(page, 'gmv')) + check('退款率改选两边都不对:销售额加粗的那一句回到记为「paid_at」', g.a?.current && g.a.write && !g.a.conflict + && g.a.text.endsWith('记为「paid_at」'), g.a?.text) + check('页面没有报错', errors.length === 0, errors.join(';')) + + // 选同一边、落到同一列:服务端写一次 + const same = await open('time_same') + await chooseAll(same.page, 'time_same') + cur = await current(same.page) + const sameValue = previewValue(fx.merges.time_same.written[0]?.value) + check('选同一列:两边加粗的那一句都说记为这一列,和服务端写的一致', fx.merges.time_same.written.length === 1 + && ['gmv', 'refund_rate'].every((id) => cur[id]?.write && !cur[id].conflict && cur[id].text.endsWith(`记为「${sameValue}」`)), JSON.stringify(cur)) + + // 去重规则:两个指标都采用去重的一边,去重的列不同(user_id、id) + const dd = await open('dedup_columns') + await chooseAll(dd.page, 'dedup_columns') + cur = await current(dd.page) + check('去重的列不同:服务端一项都不写,两边加粗的那一句都说都不记', fx.merges.dedup_columns.written.length === 0 + && cur.gmv?.text === '采用原流程的结果时,「orders」(shop)的去重规则与「退款率」的选择说法不同,都不记' + && cur.refund_rate?.text === '采用原流程的结果时,「orders」(shop)的去重规则与「销售额」的选择说法不同,都不记' + && !cur.gmv.write && !cur.refund_rate.write, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + + // 有效记录条件:两个指标各自多出的条件不同(status <> 'test'、status <> 'cancelled'),服务端合成一句写 + const ff = await open('filters') + await chooseAll(ff.page, 'filters') + cur = await current(ff.page) + const own = { gmv: fx.variants.filter_extra.catalog.a.value, refund_rate: fx.peers.filter.catalog.a.value } + const merged = fx.merges.filters.written[0]?.value ?? '' + check('夹具:服务端把两边的条件合成一句写', own.gmv !== own.refund_rate && merged.includes(own.gmv) && merged.includes(own.refund_rate), merged) + check('有效记录条件不同:两边加粗的那一句都说记、并和另一个指标采用的条件合成一句', + cur.gmv?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件记为「${own.gmv}」,与「退款率」采用的条件用 AND 合成一句` + && cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句` + && cur.gmv.write && cur.gmv.joined && cur.refund_rate.write && cur.refund_rate.joined, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`) + + // 窄屏:说法不同的那一句更长,也不撑宽 + for (const theme of ['dark', 'light']) { + const narrow = await open('time_split', { width: 390, height: 844, theme }) + await chooseAll(narrow.page, 'time_split') + const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`390px(${theme}):说法不同的那一句不横向溢出`, w <= 0, `${w}px`) + await shot(narrow.page, `approval-merge-narrow-${theme}`) + } +}) + await section('审批卡:只有解析失败、兜底原话另有原因时', async () => { // 只有解析失败:和摘要阶段(服务端把它算作兜底)说法一致,默认展开对照,解析的原话附在下面 const { page } = await preview('dual-check-approval', { artifact: cardWith('parse') })