From b672088fe358e974e4bd7491dd7557c0a507886b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 11:54:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E9=87=8C=E8=A1=8C=E5=BA=8F?= =?UTF-8?q?=E4=B8=8D=E5=AE=9A=E7=9A=84=E6=8C=87=E6=A0=87=E4=B8=8D=E5=BD=93?= =?UTF-8?q?=E3=80=8C=E6=B2=A1=E5=8F=98=E3=80=8D=EF=BC=88=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E8=80=85=E6=8B=8D=E6=9D=BF=EF=BC=89=EF=BC=9A=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E9=83=BD=E8=A1=8C=E5=BA=8F=E4=B8=8D=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=EF=BC=8C=E7=BB=93=E8=AE=BA=E5=86=99=E3=80=8C=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E4=B8=8D=E4=BA=86=EF=BC=88=E8=A1=8C=E5=BA=8F=E4=B8=8D?= =?UTF-8?q?=E5=AE=9A=EF=BC=89=E3=80=8D=EF=BC=88=E5=8F=AC=E5=9B=9E=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E7=9A=84=E6=96=B0=E7=8A=B6=E6=80=81=20row=5Forder?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=BC=80=E5=8F=AC=E5=9B=9E=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E8=AF=B7=E4=BA=BA=E6=A0=B8=E5=AF=B9=EF=BC=8C=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E5=92=8C=E9=80=9A=E7=9F=A5=E7=9A=84=E6=A0=87=E9=A2=98=E7=85=A7?= =?UTF-8?q?=E5=AE=9E=E8=AF=B4=E6=B2=A1=E8=83=BD=E6=A0=B8=E5=AF=B9=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E5=8E=9F=E7=AD=94=E6=A1=88=E4=B8=8A=E8=AE=B0?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E6=A0=B8=E5=AF=B9=EF=BC=8C=E4=B8=8D=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E3=80=8D=EF=BC=9B=E9=83=A8=E5=88=86=E8=A1=8C=E5=BA=8F?= =?UTF-8?q?=E4=B8=8D=E5=AE=9A=E7=9A=84=EF=BC=8C=E5=B7=B2=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=E7=85=A7=E5=B8=B8=E5=88=A4=EF=BC=8C=E8=A1=8C=E5=BA=8F?= =?UTF-8?q?=E4=B8=8D=E5=AE=9A=E7=9A=84=E9=82=A3=E5=87=A0=E9=A1=B9=E5=9C=A8?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=E9=87=8C=E5=88=97=E5=87=BA=EF=BC=88row=5Ford?= =?UTF-8?q?er=5Fmetrics=EF=BC=89=EF=BC=8C=E6=95=B0=E6=B2=A1=E5=8F=98?= =?UTF-8?q?=E6=97=B6=E5=8E=9F=E7=AD=94=E6=A1=88=E7=9A=84=E8=AE=B0=E8=B4=A6?= =?UTF-8?q?=E7=85=A7=E6=A0=B7=E5=88=97=E5=87=BA=E3=80=81=E9=97=AE=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9A=84=E8=BD=AE=E6=AC=A1=E5=86=99=E3=80=8C=E5=85=B6?= =?UTF-8?q?=E4=B8=AD=E3=80=8Cx=E3=80=8D=E8=A1=8C=E5=BA=8F=E4=B8=8D?= =?UTF-8?q?=E5=AE=9A=EF=BC=8C=E6=B2=A1=E8=83=BD=E6=A0=B8=E5=AF=B9=E3=80=8D?= =?UTF-8?q?=E3=80=82=E8=A1=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/ledger/recall.py | 51 +++++++++++++++++++---------- backend/app/notify/service.py | 4 ++- backend/tests/test_ledger_recall.py | 50 ++++++++++++++++++++++++++++ frontend/src/canvas/RecallPanel.tsx | 2 ++ frontend/src/lib/termsLedger.ts | 2 ++ frontend/src/lib/termsRecall.ts | 4 ++- frontend/src/run/LedgerStep.tsx | 7 +++- frontend/src/typesLedger.ts | 2 ++ frontend/src/typesRecall.ts | 3 +- 9 files changed, 104 insertions(+), 21 deletions(-) diff --git a/backend/app/ledger/recall.py b/backend/app/ledger/recall.py index 491a05ad..26a83ea2 100644 --- a/backend/app/ledger/recall.py +++ b/backend/app/ledger/recall.py @@ -82,7 +82,7 @@ REASONS = ("caliber", "catalog", "structure") #: 发起的批次:发布后自动的、版本历史里手动接着算的 BATCHES = ("auto", "manual") #: 一组的结论 -OUTCOMES = ("changed", "unchanged", "failed", "running", "pending", "not_recomputable") +OUTCOMES = ("changed", "unchanged", "row_order", "failed", "running", "pending", "not_recomputable") #: 启动时接着做最近几天发布的 RESUME_DAYS = 7 #: 目录、表结构变了时最多看多少次探索运行(新的在前) @@ -109,6 +109,8 @@ SUPERSEDED = "已有更新的发布版 v{latest},召回在 v{latest} 那一版 NOT_CURRENT = "v{version} 已不是当前发布版,召回不再接着算" #: 收尾 CHANGED_TITLE = "口径「{name}」升到 v{version},答案的数变了" +#: 全部指标行序不定、核对不了时的待办标题(settle 的 row_order) +ROW_ORDER_TITLE = "口径「{name}」升到 v{version},答案里的数行序不定、没能核对,请人工核对" CATALOG_TITLE = "答案依据的数据目录有变:按新目录检查出了新的问题" STRUCTURE_TITLE = "口径「{name}」v{version} 依据的表结构变了,试算没有算出来" #: 依据有变的四种(ledger.basis_changed 的 kind) @@ -531,11 +533,12 @@ def _reasons(plan: Plan) -> list[dict[str, Any]]: async def outcome_of(session: Any, run: Run) -> tuple[str, dict[str, Any]]: - """一次召回运行的结论:(changed / unchanged / failed / running, 收尾时记下的那一条 ledger.recalled 的内容)。""" + """一次召回运行的结论:(changed / unchanged / row_order(全部指标行序不定、核对不了)/ failed / running, 收尾时记下的那一条 + ledger.recalled 的内容)。""" data = await _settled(session, run.id) if data is not None: - if data.get("outcome") == "failed": - return "failed", data + if data.get("outcome") in ("failed", "row_order"): + return str(data["outcome"]), data return ("changed" if data.get("changed") else "unchanged"), data if run.status in ("failed", "cancelled"): return "failed", {"error": run.error} @@ -714,24 +717,33 @@ async def settle(run_id: str) -> str | None: await _mark(run_id, {**base, "outcome": "failed", "changed": None, "error": CARD_UNREADABLE}) return "failed" results = compare(meta, values) - changed = any(r["changed"] for r in results) + # 行序不定的(recall_card 记的 row_order)比不出变没变:不算「没变」。全部都行序不定的结论写核对不了、开待办请人核对; + # 部分行序不定的,已判定的照常判,行序不定的那几项在结论、待办、原答案的记账里照实列出 + unstable = [r["name"] for r in results if r.get("row_order") == "unstable"] + decided = [r for r in results if r.get("row_order") != "unstable"] + changed = any(r["changed"] for r in decided) + outcome = "row_order" if not decided else "changed" if changed else "unchanged" answers = list(dict.fromkeys(str(u.get("run_id")) for u in origin.get("uses") or [] if u.get("run_id"))) tasks: list[str] = [] - if changed: - tasks = await open_tasks(run, reason="caliber") + if outcome in ("changed", "row_order"): + tasks = await open_tasks(run, reason="caliber", row_order=outcome == "row_order") else: for answer in answers: - await _mark_answer(answer, run) - await _mark(run_id, {**base, "outcome": "changed" if changed else "unchanged", "changed": changed, + await _mark_answer(answer, run, row_order=unstable) + await _mark(run_id, {**base, "outcome": outcome, "changed": changed if decided else None, "answers": len(answers), "tasks": tasks, - "metrics": [{"k": r["k"], "name": r["name"], "changed": r["changed"]} for r in results]}) - return "changed" if changed else "unchanged" + **({"row_order_metrics": unstable} if unstable else {}), + "metrics": [{"k": r["k"], "name": r["name"], "changed": r["changed"], + **({"row_order": "unstable"} if r.get("row_order") else {})} + for r in results]}) + return outcome finally: _settling.discard(run_id) -async def _mark_answer(answer_id: str, recall_run: Run) -> None: - """数没变:在原答案的运行上记一条 ledger.recalled(已按 vN 核对,不影响)。这次召回记过的不再记。""" +async def _mark_answer(answer_id: str, recall_run: Run, *, row_order: Sequence[str] = ()) -> None: + """数没变:在原答案的运行上记一条 ledger.recalled(已按 vN 核对,不影响)。这次召回记过的不再记。row_order 是行序不定、 + 没能核对的那几项(照实列出,不算进「不影响」)。""" origin = recall_run.origin if isinstance(recall_run.origin, dict) else {} async with SessionLocal() as session: rows = (await session.execute(select(RunEvent.data).where( @@ -740,7 +752,8 @@ async def _mark_answer(answer_id: str, recall_run: Run) -> None: return await _mark(answer_id, {"role": "answer", "changed": False, "recall_run": recall_run.id, "workflow_id": origin.get("workflow_id"), "from_version": origin.get("from_version"), - "to_version": origin.get("to_version")}) + "to_version": origin.get("to_version"), + **({"row_order_metrics": list(row_order)} if row_order else {})}) # ========================================================================== @@ -775,7 +788,7 @@ def correlation(run_id: str, owner: tuple[str, str], reason: str = "caliber", ex async def open_tasks(run: Run, *, reason: str, owners: Iterable[tuple[str, str]] | None = None, - title: str | None = None, extra: str = "") -> list[str]: + title: str | None = None, extra: str = "", row_order: bool = False) -> list[str]: """给答案的主人开 recall 待办(挂在 run 上)。owners 不给就按召回运行 origin 里各答案的主人。按 correlation_key 认同一条, 开过的不再开。提交之后推通知。返回这次新开的待办 id。""" from app.process import tasks as process_tasks @@ -787,7 +800,8 @@ async def open_tasks(run: Run, *, reason: str, owners: Iterable[tuple[str, str]] targets = await _owner_targets(session, owners) name = await ledger_uses.workflow_name(session, str(origin.get("workflow_id") or "")) if title is None: - title = CHANGED_TITLE.format(name=name or "口径", version=origin.get("to_version")) + title = (ROW_ORDER_TITLE if row_order else CHANGED_TITLE).format(name=name or "口径", + version=origin.get("to_version")) opened: list[str] = [] for owner in targets: key = correlation(run.id, owner, reason, extra) @@ -1007,7 +1021,10 @@ async def answer_marks(session: Any, run_id: str, principal: Any) -> dict[str, A data = data if isinstance(data, dict) else {} if etype == EventType.LEDGER_RECALLED and data.get("role") == "answer": checked.append({k: data.get(k) for k in ("workflow_id", "from_version", "to_version", "recall_run")} - | {"changed": bool(data.get("changed")), "at": at(ts)}) + | {"changed": bool(data.get("changed")), "at": at(ts)} + # 行序不定、没能核对的那几项(只列名字):不算进「不影响」 + | ({"row_order_metrics": [str(x) for x in data["row_order_metrics"]]} + if isinstance(data.get("row_order_metrics"), list) and data["row_order_metrics"] else {})) workflows.add(str(data.get("workflow_id") or "")) elif etype == EventType.LEDGER_BASIS_CHANGED: items = data.get("items") diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index 4c401397..470a0acf 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -405,7 +405,9 @@ async def notify_recall(task_id: str, users: list[str]) -> int: def message(answers: str, tasks: int, link: tuple[str, str] | None) -> dict[str, Any]: return {"event": event, "status": "pending", "ok": False, - "title": recall.CHANGED_TITLE.format(name=name[:80] or "口径", version=origin.get("to_version")), + # 待办开出来时按结论起的标题(数变了 / 行序不定、没能核对):通知和待办说同一句 + "title": (recall.ROW_ORDER_TITLE if "行序不定" in (task.title or "") else recall.CHANGED_TITLE) + .format(name=name[:80] or "口径", version=origin.get("to_version")), "lines": [*head, ("答案", answers), *([("待办", f"{tasks} 条")] if tasks > 1 else []), *tail], "links": [link] if base and link else [], "subject": {"task_id": task_id, "run_id": task.run_id, "workflow_id": origin.get("workflow_id")}} diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py index 043587df..5610451a 100644 --- a/backend/tests/test_ledger_recall.py +++ b/backend/tests/test_ledger_recall.py @@ -991,3 +991,53 @@ async def test_probe_notes_and_change_refreshes_do_not_overwrite_each_other(engi row = (await session.execute(select(SourceChange).where(SourceChange.id == out["id"]))).scalar_one() assert sorted(p["workflow_id"] for p in row.affects["probes"]) == [f"wf-{i}" for i in range(6)], row.affects assert row.affects["rules_touched"] is True, "探查刷新的那一份也在" + + +# -------------------------------------------------------------------------- +# 行序不定的指标(召回比不出变没变):不当「没变」 +# -------------------------------------------------------------------------- + + +async def test_all_metrics_with_unstable_row_order_cannot_be_checked_and_open_a_task(monkeypatch): + """全部指标都行序不定:结论写「核对不了(行序不定)」,开召回待办请人核对,不在原答案上记「已核对,不影响」。""" + wf = await S.publish() + a1 = await answer(monkeypatch) + graph = S.caliber_graph() + graph["nodes"][3]["data"]["config"]["instructions"] = "写周销售(新的写法)" + await publish_v2(wf, graph=graph) + monkeypatch.setattr(recall, "_rows_stable", lambda pinned, metric: False) + [done] = await run_recall(wf) + assert done.status == "succeeded", done.error + [settled] = [d for d in await events(done.id, EventType.LEDGER_RECALLED) if d.get("role") == "recall"] + assert settled["outcome"] == "row_order" and settled["changed"] is None + assert settled["row_order_metrics"] == ["销售额"] and settled["metrics"][0]["row_order"] == "unstable" + [task] = await recall_tasks(done.id) + assert task.title == "口径「周销售」升到 v2,答案里的数行序不定、没能核对,请人工核对" + assert await events(a1.id, EventType.LEDGER_RECALLED) == [], "核对不了的不在原答案上记「不影响」" + async with SessionLocal() as session: + view = await recall.batches(session, wf, 2) + state, _ = await recall.outcome_of(session, done) + assert state == "row_order" and view["groups"][0]["state"] == "row_order" and view["counts"]["row_order"] == 1 + + +async def test_some_metrics_with_unstable_row_order_are_listed_and_the_rest_decide(monkeypatch): + """部分行序不定:已判定的照常判(数没变就在原答案上记不影响),行序不定的那几项在结论和原答案的记账里照实列出。""" + two = S.caliber_graph() + card = two["nodes"][2]["data"]["config"] + card["metrics"] = [*card["metrics"], {**card["metrics"][0], "id": "gmv2", "name": "销售额(另算)"}] + wf = await S.publish(graph=two) + a1 = await answer(monkeypatch) + v2 = S.caliber_graph() + v2["nodes"][2]["data"]["config"]["metrics"] = card["metrics"] + v2["nodes"][3]["data"]["config"]["instructions"] = "写周销售(新的写法)" + await publish_v2(wf, graph=v2) + monkeypatch.setattr(recall, "_rows_stable", lambda pinned, metric: metric.get("id") != "gmv2") + [done] = await run_recall(wf) + assert done.status == "succeeded", done.error + [settled] = [d for d in await events(done.id, EventType.LEDGER_RECALLED) if d.get("role") == "recall"] + assert settled["outcome"] == "unchanged" and settled["changed"] is False + assert settled["row_order_metrics"] == ["销售额(另算)"] + assert [m.get("changed") for m in settled["metrics"]] == [False, None] + [booked] = await events(a1.id, EventType.LEDGER_RECALLED) + assert booked["changed"] is False and booked["row_order_metrics"] == ["销售额(另算)"] + assert await recall_tasks(done.id) == [] diff --git a/frontend/src/canvas/RecallPanel.tsx b/frontend/src/canvas/RecallPanel.tsx index a09532fd..3e29fe6d 100644 --- a/frontend/src/canvas/RecallPanel.tsx +++ b/frontend/src/canvas/RecallPanel.tsx @@ -79,6 +79,8 @@ export function RecallPreviewRow({ workflowId, version }: { workflowId: string; const STATE_COLOR: Record = { changed: 'var(--st-waiting)', failed: 'var(--err)', not_recomputable: 'var(--text-faint)', unchanged: 'var(--ok)', + // 核对不了(行序不定):要人去核对,和「数变了」同一种提醒色 + row_order: 'var(--st-waiting)', } /** 列表先列几组,其余收起 */ const SHOWN = 6 diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts index a5a93452..29165b0b 100644 --- a/frontend/src/lib/termsLedger.ts +++ b/frontend/src/lib/termsLedger.ts @@ -133,6 +133,8 @@ export const LEDGER_UI = { recall: { /** 口径升版后在同一份数据上重算过、数没变 */ checked: (caliber: string) => `已按口径${caliber} 核对,不影响`, + /** 部分指标行序不定:那几项没能核对,照实列出 */ + checkedRowOrder: (names: string[]) => `其中${names.map((x) => `「${x}」`).join('、')}行序不定,没能核对`, /** 依据有变:目录里影响数字的项被驳回或修改、出现新码值、处理变化时改了目录(目录类),表或列被删、类型变了(表结构) */ basis: (what: 'catalog' | 'structure' | 'other', items: number) => `${what === 'structure' ? '依据的表结构有变' : what === 'catalog' ? '依据的数据目录有变' : '答案的依据有变'}` diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts index a1931a94..e15ef124 100644 --- a/frontend/src/lib/termsRecall.ts +++ b/frontend/src/lib/termsRecall.ts @@ -58,6 +58,7 @@ export const RECALL_TEXT = { const parts = [ c.changed ? `数变了 ${n(c.changed)} 组` : '', c.unchanged ? `没变 ${n(c.unchanged)} 组` : '', + c.row_order ? `核对不了(行序不定)${n(c.row_order)} 组` : '', c.running ? `正在重算 ${n(c.running)} 组` : '', c.failed ? `重算失败 ${n(c.failed)} 组` : '', c.not_recomputable ? `无法重算 ${n(c.not_recomputable)} 组` : '', @@ -65,7 +66,8 @@ export const RECALL_TEXT = { return parts.join(',') }, state: { - changed: '数变了', unchanged: '没变', failed: '重算失败', running: '正在重算', pending: '未重算', + changed: '数变了', unchanged: '没变', row_order: '核对不了(行序不定)', failed: '重算失败', running: '正在重算', + pending: '未重算', not_recomputable: '无法重算', } as Record, reason: (state: string, reason: string) => (state === 'not_recomputable' ? `无法重算:${reason}` : reason), diff --git a/frontend/src/run/LedgerStep.tsx b/frontend/src/run/LedgerStep.tsx index cf511dbf..4d247e7d 100644 --- a/frontend/src/run/LedgerStep.tsx +++ b/frontend/src/run/LedgerStep.tsx @@ -127,7 +127,12 @@ export function RecallMarks({ recall }: { recall?: LedgerRecallMarks }) { {[...checked.values()].map((c) => (
  • - {R.checked(caliberName(c.workflow_name, c.to_version))} + + {R.checked(caliberName(c.workflow_name, c.to_version))} + {!!c.row_order_metrics?.length && ( + {R.checkedRowOrder(c.row_order_metrics)} + )} +
  • ))} {[...basis.entries()].map(([group, b]) => ( diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index 745da48f..ca0ab914 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -220,6 +220,8 @@ export interface LedgerRecallMarks { recall_run?: string | null changed?: boolean at?: string | null + /** 行序不定、没能核对的那几项(服务端 ledger/recall.settle):不算进「不影响」 */ + row_order_metrics?: string[] }[] /** 依据有变:kind 是 catalog(目录里影响数字的项被驳回或修改)/ structure(表结构)/ values(新码值)/ catalog_edit */ basis?: { kind?: string | null; items?: number; new_errors?: number; at?: string | null }[] diff --git a/frontend/src/typesRecall.ts b/frontend/src/typesRecall.ts index 90aca8c8..542bf4fe 100644 --- a/frontend/src/typesRecall.ts +++ b/frontend/src/typesRecall.ts @@ -42,7 +42,8 @@ export interface RecallPreview { reasons: { reason: string; groups: number }[] } -export type RecallGroupState = 'changed' | 'unchanged' | 'failed' | 'running' | 'pending' | 'not_recomputable' +/** row_order:全部指标行序不定、核对不了(服务端 ledger/recall.settle),已开待办请人核对 */ +export type RecallGroupState = 'changed' | 'unchanged' | 'row_order' | 'failed' | 'running' | 'pending' | 'not_recomputable' /** 召回批次里的一组 */ export interface RecallGroup {