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()`, [