Archived
判据 ⑦:审批点两条走向跑通 + 修草稿基线按字段捕获
审批点(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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -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": []
|
||||||
|
}
|
||||||
@@ -217,7 +217,20 @@ export async function seedQuote(container: Container): Promise<void> {
|
|||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
label: 'AP 定价审批',
|
label: 'AP 定价审批',
|
||||||
approver: { position: 'PM', strategy: 'sole' },
|
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' }] },
|
nudge: { deadline: 'PT30M', steps: [{ after: 'PT20M', action: 'nudge' }] },
|
||||||
layout: { row: 0, col: 6 },
|
layout: { row: 0, col: 6 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const DOMAIN = 'it_quote';
|
|||||||
const OBJECT = 'intake';
|
const OBJECT = 'intake';
|
||||||
const T1 = 't-it-1' as TaskId;
|
const T1 = 't-it-1' as TaskId;
|
||||||
const T2 = 't-it-2' 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 => ({
|
const declaration = (fields: ObjectDeclaration['fields']): ObjectDeclaration => ({
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
@@ -202,4 +204,52 @@ describe('ledger · 真实 PG', () => {
|
|||||||
});
|
});
|
||||||
expect(official.rows[0]).toMatchObject({ grade: 'D' });
|
expect(official.rows[0]).toMatchObject({ grade: 'D' });
|
||||||
}, 60_000);
|
}, 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -282,6 +282,10 @@ export function createDraftStore(runner: Queryable): DraftStore {
|
|||||||
values ($1,$2,$3,$4,$5,$6,$7,$8)
|
values ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
on conflict (task_id, domain, object, key_hash) do update
|
on conflict (task_id, domain, object, key_hash) do update
|
||||||
set patch = ${DRAFT_TABLE}.patch || excluded.patch,
|
set patch = ${DRAFT_TABLE}.patch || excluded.patch,
|
||||||
|
-- 基线按**字段**首次写入时捕获:右操作数胜,已有基线保持不变、
|
||||||
|
-- 本次新写的字段补上它们各自的基线。若只在建行时捕获一次,
|
||||||
|
-- 后续步新写的字段就没有基线,冲突检查会把它们一律当成「他单改过」。
|
||||||
|
baseline = excluded.baseline || ${DRAFT_TABLE}.baseline,
|
||||||
tombstone = excluded.tombstone,
|
tombstone = excluded.tombstone,
|
||||||
updated_at = now()`,
|
updated_at = now()`,
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user