From 227764b7da355b4d169a80b3143fa270fab2652c Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 05:23:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A4=E6=8D=AE=20=E2=91=A6:=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E7=82=B9=E4=B8=A4=E6=9D=A1=E8=B5=B0=E5=90=91=E8=B7=91?= =?UTF-8?q?=E9=80=9A=20+=20=E4=BF=AE=E8=8D=89=E7=A8=BF=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E6=8C=89=E5=AD=97=E6=AE=B5=E6=8D=95=E8=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批点(AP 定价审批)卡到 PM 丁子乔工位,依据快照齐、驳回走向按声明呈现。 · 驳回必附意见:空意见被拒 ✓ · 驳回 → **回 A4 重开新实例**(A4 #2),旧实例不复活 —— 已裁定 ①④ 的落地 ✓ 沿路两处修: ① 审批点的 `reject.mapping` 是空的 —— 重开的是**新的 A4 实例**,它照样要过 A4 的 入参契约,空映射等于把「驳回回 A4」写成一句空话(实测:重开必失败)。按 G2J→A4 同样的口径补齐。 ② 草稿基线只在**建行那一刻**捕获一次:后续步新写的字段没有基线,冲突检查把它们 一律当成「他单改过」。于是重开的实例分步写同一行,放行时必被自己的前一版挡下 (实测 A4#2:「4 处字段已被他单改动」= margin/quoted_price/deviation/disposition)。 改为**按字段首次写入时捕获**(jsonb 右操作数胜:已有基线不动,新字段补上)。 加了真实 PG 的回归测试。 gates: tsc / eslint / depcruise / 398 测试 全绿 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../inbound/rfq-20260727X@x.com.eml | 12 +++++ apps/api/src/app/seed-quote.ts | 15 +++++- .../ledger/__tests__/ledger-store.it.test.ts | 50 +++++++++++++++++++ .../ledger/internal/adapter/ledger-store.ts | 4 ++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 .data/attachments/inbound/rfq-20260727X@x.com.eml diff --git a/.data/attachments/inbound/rfq-20260727X@x.com.eml b/.data/attachments/inbound/rfq-20260727X@x.com.eml new file mode 100644 index 0000000..aa5ba44 --- /dev/null +++ b/.data/attachments/inbound/rfq-20260727X@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-20260727X@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C100 RFQ-20260727X 求报价", + "body": "客户编码 C100,询价单号 RFQ-��\nPN-EEE-555 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T19:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/apps/api/src/app/seed-quote.ts b/apps/api/src/app/seed-quote.ts index c35ef63..d1da641 100644 --- a/apps/api/src/app/seed-quote.ts +++ b/apps/api/src/app/seed-quote.ts @@ -217,7 +217,20 @@ export async function seedQuote(container: Container): Promise { kind: 'approval', label: 'AP 定价审批', approver: { position: 'PM', strategy: 'sole' }, - reject: { kind: 'reopen', nodeId: 'A4', mapping: [] }, + // 驳回走向的载荷映射不能空:重开的是**新的 A4 实例**,它照样要过 A4 的入参契约。 + // 空映射 = 重开必失败,等于把「驳回回 A4」写成了一句空话。 + reject: { + kind: 'reopen', + nodeId: 'A4', + mapping: [ + { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, + { target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } }, + { target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }, + { target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } }, + { target: 'customerGrade', source: { from: 'constant', value: '普通客户' } }, + { target: 'volumeTier', source: { from: 'constant', value: '大批量' } }, + ], + }, nudge: { deadline: 'PT30M', steps: [{ after: 'PT20M', action: 'nudge' }] }, layout: { row: 0, col: 6 }, }, diff --git a/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts b/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts index ba6a603..0b96640 100644 --- a/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts +++ b/apps/api/src/modules/ledger/__tests__/ledger-store.it.test.ts @@ -22,6 +22,8 @@ const DOMAIN = 'it_quote'; const OBJECT = 'intake'; const T1 = 't-it-1' as TaskId; const T2 = 't-it-2' as TaskId; +const T3 = 't-it-3' as TaskId; +const T4 = 't-it-4' as TaskId; const declaration = (fields: ObjectDeclaration['fields']): ObjectDeclaration => ({ domain: DOMAIN, @@ -202,4 +204,52 @@ describe('ledger · 真实 PG', () => { }); expect(official.rows[0]).toMatchObject({ grade: 'D' }); }, 60_000); + + itDb()('基线按字段首次写入捕获:同一行分步写,后写的字段不被误判为他单改动', async () => { + // 本例要用到 note 列,先声明物化(只增不破坏) + await ledger.applyDeclarations([ + declaration([ + { name: 'pn', type: 'text', primaryKey: true, masked: false }, + { name: 'grade', type: 'text', primaryKey: false, masked: false }, + { name: 'note', type: 'text', primaryKey: false, masked: false }, + ]), + ]); + + // 先有一版正式记录(模拟前一个实例已放行) + await ledger.draftWrite({ + taskId: T3, + address: { domain: DOMAIN, object: OBJECT }, + key: { pn: 'BASELINE-PN' }, + patch: { grade: 'A', note: '一版' }, + }); + await ledger.releaseWrite(T3); + + // 重开的新实例分**两步**写同一行:先 grade,后 note + await ledger.draftWrite({ + taskId: T4, + address: { domain: DOMAIN, object: OBJECT }, + key: { pn: 'BASELINE-PN' }, + patch: { grade: 'B' }, + }); + await ledger.draftWrite({ + taskId: T4, + address: { domain: DOMAIN, object: OBJECT }, + key: { pn: 'BASELINE-PN' }, + patch: { note: '二版' }, + }); + + // 若基线只在建行那一刻捕获,note 就没有基线,会被当成「他单改过」而拒绝放行 + expect(await ledger.checkConflicts(T4)).toEqual([]); + const result = await ledger.releaseWrite(T4); + expect(result.mergedRows).toBe(1); + + const official = await ledger.query({ + address: { domain: DOMAIN, object: OBJECT }, + where: { pn: 'BASELINE-PN' }, + orderBy: [], + limit: 10, + offset: 0, + }); + expect(official.rows[0]).toMatchObject({ grade: 'B', note: '二版' }); + }, 60_000); }); diff --git a/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts b/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts index 2daac28..3b6182f 100644 --- a/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts +++ b/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts @@ -282,6 +282,10 @@ export function createDraftStore(runner: Queryable): DraftStore { values ($1,$2,$3,$4,$5,$6,$7,$8) on conflict (task_id, domain, object, key_hash) do update set patch = ${DRAFT_TABLE}.patch || excluded.patch, + -- 基线按**字段**首次写入时捕获:右操作数胜,已有基线保持不变、 + -- 本次新写的字段补上它们各自的基线。若只在建行时捕获一次, + -- 后续步新写的字段就没有基线,冲突检查会把它们一律当成「他单改过」。 + baseline = excluded.baseline || ${DRAFT_TABLE}.baseline, tombstone = excluded.tombstone, updated_at = now()`, [