From 71d535c4ce038e85bd436c916ab6bb6ccf3e3db4 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 10:05:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E8=A6=81=E4=BA=BA=E9=80=89?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=E6=97=B6=E6=8C=89=E9=80=90=E6=A0=BC=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=E7=9A=84=E7=BB=93=E8=AE=BA=E9=A2=84=E9=80=89=EF=BC=9A?= =?UTF-8?q?=E8=BF=99=E4=B8=80=E5=88=97=E5=8D=95=E7=8B=AC=E6=AF=94=E5=92=8C?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=8F=96=E6=95=B0=E7=9A=84=E6=9F=90=E4=B8=80?= =?UTF-8?q?=E5=88=97=E7=AD=89=E4=BB=B7=EF=BC=88ledger/match.=5Fcells=20?= =?UTF-8?q?=E8=AF=81=E6=98=8E=E4=BA=86=EF=BC=89=EF=BC=8C=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8D=A1=E4=B8=8A=E5=8F=AA=E8=AF=BB=E9=82=A3=E4=B8=80=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E6=8C=87=E6=A0=87=E5=B0=B1=E6=98=AF=E5=AE=83=E6=9C=80?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E7=9A=84=E5=AF=B9=E7=85=A7=EF=BC=8C409=20?= =?UTF-8?q?=E7=9A=84=20options=20=E9=87=8C=E7=BB=99=20suggested=EF=BC=9B?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E6=A1=86=E7=85=A7=E5=AE=83=E9=A2=84=E9=80=89?= =?UTF-8?q?=E3=80=81=E6=A0=87=E3=80=8C=E9=A2=84=E9=80=89=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E4=BA=BA=E5=8F=AF=E4=BB=A5=E6=94=B9=E3=80=82=E5=8F=AA=E6=98=AF?= =?UTF-8?q?=E3=80=8C=E7=9B=B8=E5=85=B3=E3=80=8D=E7=9A=84=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E6=A0=BC=E4=B8=8D=E9=A2=84=E9=80=89=EF=BC=88=E6=AF=94=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E6=98=AF=E7=AC=AC=E4=B8=80=E4=B8=AA=E8=AF=95=E7=9A=84?= =?UTF-8?q?=E5=88=97=EF=BC=8C=E4=B8=8D=E6=98=AF=E6=9C=80=E5=83=8F=E7=9A=84?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E4=B8=80=E4=B8=AA=E5=8F=A3=E5=BE=84=E5=88=97?= =?UTF-8?q?=E5=AF=B9=E4=B8=8A=E5=87=A0=E4=B8=AA=E6=8C=87=E6=A0=87=E7=9A=84?= =?UTF-8?q?=E4=B9=9F=E4=B8=8D=E9=A2=84=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/ledger/reconcile.py | 46 +++++++++++++++++++++++--- backend/tests/test_ledger_reconcile.py | 15 +++++++++ frontend/src/lib/termsLedger.ts | 3 ++ frontend/src/run/LedgerReconcile.tsx | 14 ++++++-- frontend/src/typesLedger.ts | 5 +++ scripts/check-ledger.mjs | 33 ++++++++++++++++++ 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/backend/app/ledger/reconcile.py b/backend/app/ledger/reconcile.py index 6ac79ec5..4a0fe3a2 100644 --- a/backend/app/ledger/reconcile.py +++ b/backend/app/ledger/reconcile.py @@ -350,9 +350,11 @@ def _plain_cell(expression: str, fetch: str) -> str | None: def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str], caliber_columns: list[str] | None, metrics: list[dict[str, Any]], fetch: str, card: str, fallback: bool, chosen: Any, - referenced: list[tuple[int, str]], projected: Any = None) -> Plan: + referenced: list[tuple[int, str]], projected: Any = None, cells: Any = None, + caliber: str | None = None) -> Plan: """配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。projected 是比对候选的 projection - (按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。纯计算。""" + (按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。cells 是这条查询逐格比的结论 + (ledger/match._cells),caliber 是这份口径的键:要人选对照时按它预选(_suggested)。纯计算。""" def check_column(column: str) -> None: if column not in ours_columns: raise ReconcileRefused(422, PAIR_COLUMN.format(column=column)) @@ -365,7 +367,8 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str numeric = [c for i, c in enumerate(ours_columns) if c.lower() not in hidden and _numeric(ours_rows, i)] options = {"options": {"columns": numeric, "metrics": [{"id": str(m.get("id")), "name": str(m.get("name") or m.get("id")), - "unit": str(m.get("unit") or "")} for m in metrics if m.get("id")]}} + "unit": str(m.get("unit") or "")} for m in metrics if m.get("id")], + "suggested": _suggested(cells, caliber, fetch, numeric, metrics)}} if multi: return _plan_rows(ours_columns, ours_rows, hidden, caliber_columns, referenced, check_column) if chosen is not None: @@ -409,6 +412,40 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str return Plan(pairs=pairs) +def _suggested(cells: Any, caliber: str | None, fetch: str, columns: list[str], + metrics: list[dict[str, Any]]) -> list[dict[str, str]]: + """要人选对照时预选哪几对:[{ours, metric}]。按逐格比的结论(ledger/match._cells)——这次结果的这一列单独比,和这份口径 + 取数节点的某一列等价(逐格证明了,projection.caliber 是那一列),口径卡上只读这一列这一格的指标就是它最可能的对照。只认 + 证明了的:只是「相关」的那一格比到的是第一个试的列,不是最像的,不替人猜;一个口径列对上几个指标的也不预选。界面照它 + 预选、人可以改。""" + from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression + + if not isinstance(cells, dict) or not caliber: + return [] + reads: dict[str, list[str]] = {} + for m in metrics: + try: + leaves = leaf_refs(parse_expression(str(m.get("expression") or ""))[0]) + except ExpressionError: + continue + cols = {_plain_cell(str(leaf), fetch) for leaf in leaves if cell_parts(str(leaf)) is not None} + if len(leaves) == 1 and len(cols) == 1 and None not in cols and m.get("id"): + reads.setdefault(str(next(iter(cols))).lower(), []).append(str(m["id"])) + out: list[dict[str, str]] = [] + for col in columns: + cell = cells.get(col) + cands = cell.get("candidates") if isinstance(cell, dict) else None + proven = next((c for c in cands or [] if isinstance(c, dict) and c.get("status") == "proven" + and c.get("caliber") == caliber and c.get("node_id") == fetch), None) + theirs = ((proven or {}).get("projection") or {}).get("caliber") if proven else None + if not isinstance(theirs, list) or len(theirs) != 1 or not isinstance(theirs[0], str): + continue + hits = reads.get(theirs[0].lower()) or [] + if len(hits) == 1: + out.append({"ours": col, "metric": hits[0]}) + return out + + def _projected_pairs(projected: Any, ours_columns: list[str]) -> list[tuple[str, str]] | None: """比对候选的 projection → [(这次的列, 口径的列)],按位置一一对应;不是投影过的、两边列数不一样、这次的列不在结果里的 为 None(照旧按整条的位置配)。""" @@ -583,7 +620,8 @@ async def reconcile(run_id: str, req: Request, *, principal: Any) -> Run: caliber_columns=_caliber_columns(template, inputs, list(pinned.entry_fields), kind, schema), metrics=pinned.metrics, fetch=str(fetch["id"]), card=pinned.card_title, fallback=bool((cand.get("diff") or {}).get("fallback")), chosen=req.pairs, - referenced=_referenced(sealed, req.query), projected=cand.get("projection")) + referenced=_referenced(sealed, req.query), projected=cand.get("projection"), + cells=query.get("cells"), caliber=key) graph = build(pinned, sql=snap["sql"], tool=f"{QUERY_PREFIX}{source}", plan=plan, inputs=inputs, fetch=str(fetch["id"])) diff --git a/backend/tests/test_ledger_reconcile.py b/backend/tests/test_ledger_reconcile.py index 68beded9..6d4c1a83 100644 --- a/backend/tests/test_ledger_reconcile.py +++ b/backend/tests/test_ledger_reconcile.py @@ -280,3 +280,18 @@ async def test_multi_row_reconcile_groups_carry_the_row_labels(client, monkeypat assert [g["label"] for g in listed["groups"]] == ["区域=east · gmv", "区域=west · gmv"] assert listed["groups"][1] == {"label": "区域=west · gmv", "ours": "ours_1_0", "caliber": "cal_1_0", "diff": "diff_1_0", "rel": "rel_1_0"} + + +async def test_a_related_query_prefills_the_pairing_from_the_cells_that_were_proven(client, monkeypatch): + """要人选对照时,按逐格比过的结论预选:这次的 gmv 那一列单独比是和口径取数的 gmv 等价的(逐格证明了),口径卡上读这一列的 + 指标就是它最可能的对照,options.suggested 给出来,界面预选上、人可以改;单数那一列没有证明,不替人猜。""" + wf = await S.publish(graph=S.caliber_graph(expression="cell(nodes.fetch, 0, 'gmv') * 1")) + sql = ("SELECT SUM(amount) AS gmv, COUNT(*) AS 单数 FROM orders WHERE status = 'paid' " + "AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'") + row = await S.explore(monkeypatch, sql, text="本周销售额 [[v:Q1.r0.gmv]],共 [[v:Q1.r0.单数]] 单。") + [action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"] + body = {"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True} + res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json=body) + assert res.status_code == 409 and res.json()["code"] == ledger_reconcile.NEEDS_PAIRS, res.text + options = res.json()["options"] + assert options["suggested"] == [{"ours": "gmv", "metric": "gmv"}], options diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts index 5be516de..a5a93452 100644 --- a/frontend/src/lib/termsLedger.ts +++ b/frontend/src/lib/termsLedger.ts @@ -87,6 +87,9 @@ export const LEDGER_UI = { maskedPlaceholder: '已遮罩,请重新填写', pairs: '选择要对照的指标', pairsHint: '认不出这次的哪一列对应口径的哪个指标,请为要对照的列选一个口径卡上的指标', + /** 有预选的:按逐格比对证明了的那几列预选上,人可以改 */ + pairsSuggested: '已按逐格比对的结论预选了能证明对应的列,请核对,可以改选', + pairSuggestedTag: '预选', pairNone: '不对照', pairEmpty: '至少选一组对照', launch: '发起对账', diff --git a/frontend/src/run/LedgerReconcile.tsx b/frontend/src/run/LedgerReconcile.tsx index aa8f0ab1..1a9b7b37 100644 --- a/frontend/src/run/LedgerReconcile.tsx +++ b/frontend/src/run/LedgerReconcile.tsx @@ -84,7 +84,12 @@ export function ReconcileDialog({ runId, action, related, onClose }: { } catch (e) { if (e instanceof ApiError && e.code === NEEDS_PAIRS) { const raw = (e.body as { options?: LedgerPairOptions } | undefined)?.options - if (raw && Array.isArray(raw.columns) && Array.isArray(raw.metrics)) setOptions(raw) + if (raw && Array.isArray(raw.columns) && Array.isArray(raw.metrics)) { + setOptions(raw) + // 逐格证明了的那几列预选上(人可以改);已经选过的不盖掉 + const picks = (raw.suggested ?? []).filter((s) => raw.columns.includes(s.ours) && raw.metrics.some((m) => m.id === s.metric)) + if (picks.length) setPairs((p) => ({ ...Object.fromEntries(picks.map((s) => [s.ours, s.metric])), ...p })) + } setProblem(e.message) } else if (e instanceof ApiError && e.code === CHECK_INPUTS) { setEditing(true) @@ -152,7 +157,9 @@ export function ReconcileDialog({ runId, action, related, onClose }: { {options && (
{LEDGER_UI.reconcile.pairs}
-

{LEDGER_UI.reconcile.pairsHint}

+

+ {options.suggested?.length ? LEDGER_UI.reconcile.pairsSuggested : LEDGER_UI.reconcile.pairsHint} +

{options.columns.map((col) => ( ))}
diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index bde041d7..745da48f 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -305,6 +305,11 @@ export interface LedgerReconcileOut { export interface LedgerPairOptions { columns: string[] metrics: { id: string; name: string; unit?: string }[] + /** + * 预选的对照(服务端 ledger/reconcile._suggested):这一列单独比和口径取数的某一列等价(逐格证明了),口径卡上只读那一格的 + * 指标。界面照它预选、人可以改;老服务端不给 + */ + suggested?: { ours: string; metric: string }[] } // ---------------------------------------------------------------- 认证队列(可沉淀的问题) diff --git a/scripts/check-ledger.mjs b/scripts/check-ledger.mjs index f4c1c54e..7d4eb95a 100644 --- a/scripts/check-ledger.mjs +++ b/scripts/check-ledger.mjs @@ -306,6 +306,39 @@ await section('口径对账:核对参数 → 发起 → 选对照的指标 → // ---------------------------------------------------------------- 「按口径」那个数的出处跨卡追到取数查询 +await section('口径对账:逐格证明了的列预选上对照,人可以改', async () => { + // 服务端按逐格比对(ledger/match._cells)证明了的那一列给出预选(options.suggested),界面照它预选、标「预选」 + const posts = [] + const extra = (method, p, body) => { + if (method === 'POST' && p === `/ledger/answers/${RUN}/reconcile`) { + posts.push(body) + if (!body?.pairs) { + return { status: 409, json: { detail: '认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标', code: 'ledger_reconcile_needs_pairs', + options: { columns: ['orders', 'refunds'], metrics: [{ id: 'orders', name: '订单数', unit: '单' }], + suggested: [{ ours: 'orders', metric: 'orders' }] } } } + } + return { status: 201, json: { run_id: 'run-rec-9', reconciled: reconciledOf('running') } } + } + return null + } + const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(() => ledgerOf(RUN, {}), extra) }) + await page.waitForSelector('#evidence-wide [data-seg="s8"]', { timeout: 10000 }) + await openSeg(page, 's8') + await page.locator('[data-evidence-panel] [data-ledger-reconcile-open]').click() + await page.locator('[data-ledger-reconcile-dialog]').waitFor({ timeout: 4000 }) + await page.locator('[data-ledger-reconcile-launch]').click() + await page.locator('[data-ledger-pairs]').waitFor({ timeout: 4000 }) + check('证明了的那一列预选上对照的指标', await page.locator('[data-ledger-pair="orders"]').inputValue() === 'orders') + check('……标「预选」,说明可以改选', await page.locator('[data-ledger-pair-suggested="orders"]').count() === 1 + && (await page.locator('[data-ledger-pairs]').innerText()).includes('可以改选')) + check('没证明的那一列不替人猜', await page.locator('[data-ledger-pair="refunds"]').inputValue() === '') + await page.locator('[data-ledger-reconcile-launch]').click() + await until(() => posts.length === 2) + check('直接发起:带上预选的对照', JSON.stringify(posts[1]?.pairs) === JSON.stringify([{ ours: 'orders', metric: 'orders' }]), + JSON.stringify(posts[1])) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + await section('对账结果里「按口径」那个数:出处跨卡追到口径的取数查询', async () => { const extra = (method, p) => { const m = p.match(/^\/runs\/run-rec-1\/evidence\/metrics\/reconcile\/([^/]+)$/)