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 b343db7..933f600 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 @@ -32,6 +32,7 @@ const T6 = 't-it-6' as TaskId; const T7 = 't-it-7' as TaskId; const T8 = 't-it-8' as TaskId; const T9 = 't-it-9' as TaskId; +const T10 = 't-it-10' as TaskId; /** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */ const SIMULATED = new Set(); @@ -76,7 +77,7 @@ afterAll(async () => { .query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN]) .catch(() => undefined); await pool - .query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9]]) + .query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]]) .catch(() => undefined); await pool.end(); } @@ -463,3 +464,47 @@ describe('ledger · 数据工单确认即入库(范围放行)', () => { expect((await ledger.taskDrafts(T9))[0]).toMatchObject({ landed: false }); }, 60_000); }); + +/** + * **保存过的行不许跳位**(2026-08-31 用户:「点击保存后,列表里面的元素每次排列顺序 + * 都不一致,需要保持顺序的一致性」)。 + * + * 病根是草稿层从前按 `updated_at` 读:`put` 的 upsert 分支每次都写 `updated_at = now()`, + * 人保存了哪几行、哪几行就跳到列表末尾;同一批写下的行(配方一次 project 铺 7 行) + * `updated_at` 又完全相同,并列之间没有次级键 —— PostgreSQL 更新一行是「旧版本作废 + + * 新版本插到堆尾」,物理顺序本来就会变,于是每保存一次屏上的表就重排一次。 + * + * 这一条**只有对真库才测得出来**:假 store 的 Map 天然按插入序,永远看不到这个毛病。 + * 断言两件事:①同一批写下的多行,读出来就是写下的顺序;②改中间那一行(件上按保存) + * 之后**顺序一个字不变** —— 用真的 `draftWrite`(它走的就是那条 upsert)。 + */ +describe('ledger · 草稿层读出来的顺序稳定(件上的列表不许每次保存都重排)', () => { + const REF = { domain: DOMAIN, object: OBJECT }; + const keysOf = async (taskId: TaskId): Promise => + (await ledger.taskDrafts(taskId)).map((d) => String((d.key as Record)['pn'])); + + itDb()('按诞生序读:同批写下的顺序原样保留,改了中间那一行也不跳位', async () => { + const pns = ['PN-A', 'PN-B', 'PN-C', 'PN-D', 'PN-E']; + for (const pn of pns) { + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn }, patch: { pn, eau: 1 } }); + } + expect(await keysOf(T10)).toEqual(pns); + + /* 件上按保存 = 对已有的那一行再写一笔(走 upsert 的 update 分支) */ + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-B' }, patch: { eau: 999 } }); + expect(await keysOf(T10)).toEqual(pns); + + /* 再保存两次、换不同的行 —— 顺序仍是诞生序,不是「最近改过的在最后」 */ + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-D' }, patch: { eau: 7 } }); + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-A' }, patch: { eau: 8 } }); + expect(await keysOf(T10)).toEqual(pns); + + /* 值确实写进去了 —— 不是靠「没写成功」换来的稳定 */ + const rows = await ledger.taskDrafts(T10); + expect(rows.find((d) => (d.key as Record)['pn'] === 'PN-B')?.patch).toMatchObject({ eau: 999 }); + + /* 新写的行排在末尾(诞生序的另一半:后来的就该在后面) */ + await ledger.draftWrite({ taskId: T10, address: REF, key: { pn: 'PN-F' }, patch: { pn: 'PN-F', eau: 2 } }); + expect(await keysOf(T10)).toEqual([...pns, 'PN-F']); + }, 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 940ba24..df9a3fa 100644 --- a/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts +++ b/apps/api/src/modules/ledger/internal/adapter/ledger-store.ts @@ -319,7 +319,16 @@ create table if not exists ${DRAFT_TABLE} ( ); -- 已落地记号(2026-08-27,数据工单确认即入库):非空 = 这一行已在一道更早的门上并进正式表, -- 放行时不再写它。加列走 if not exists —— 这张表由启动时的 DDL 自建,不走迁移。 -alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz;`; +alter table ${DRAFT_TABLE} add column if not exists landed_at timestamptz; +-- **诞生序**(2026-08-31 用户:「点击保存后,列表里面的元素每次排列顺序都不一致」): +-- 读草稿层从前按 updated_at 排,而 upsert 每次都写 updated_at = now() —— 人保存了哪几行, +-- 哪几行就跳到列表末尾;同一批写下的行(配方一次 project 铺 7 行)updated_at 又完全相同, +-- 并列之间没有次级键,PostgreSQL 返回什么顺序都算数(更新一行 = 旧版本作废 + 新版本插到 +-- 堆尾,物理顺序本来就会变),于是每保存一次屏上就重排一次。 +-- bigserial:**insert 那一刻取号,upsert 的 update 分支不碰它** —— 记的是「这一行是第几个 +-- 被写下的」,而配方铺行的顺序正是人期望在件上看到的顺序。单键、严格全序、没有并列。 +-- 与 landed_at 同款走 DDL 加列(这张表不走迁移,见上一句)。 +alter table ${DRAFT_TABLE} add column if not exists seq bigserial;`; type DraftRow = { task_id: string; @@ -346,16 +355,31 @@ function toEntry(row: DraftRow): DraftEntry & ObjectRef { export function createDraftStore(runner: Queryable): DraftStore { return { + /* + * **按诞生序读,不按更新时刻读**(2026-08-31 用户:「点击保存后,列表里面的元素 + * 每次排列顺序都不一致,需要保持顺序的一致性」)。 + * + * 从前是 `order by updated_at`,而 `put` 的 upsert 分支每次都写 `updated_at = now()` + * —— **人保存了哪几行,哪几行就跳到列表末尾**;同一批写下的行(配方一次 project + * 铺 7 行)`updated_at` 又完全相同,并列之间没有次级键,PostgreSQL 返回什么顺序都 + * 算数(更新一行 = 旧版本作废 + 新版本插到堆尾,物理顺序本来就会变),于是每保存 + * 一次,屏上的表就重排一次。 + * + * `seq` 是 `bigserial`(迁移 0036):**insert 那一刻取号,upsert 的 update 分支不碰它** + * —— 记的正是「这一行是第几个被写下的」。而配方铺行的顺序就是人期望在件上看到的 + * 顺序(原 PN 的参数在前、候选的在后,组内按字典序 —— 配方那么遍历的)。 + * 单键、严格全序、没有并列,也就没有「并列之间谁先谁后」这种不确定。 + */ async list(taskId) { const res = await runner.query( - `select * from ${DRAFT_TABLE} where task_id = $1 order by updated_at`, + `select * from ${DRAFT_TABLE} where task_id = $1 order by seq`, [taskId], ); return res.rows.map(toEntry); }, async listForObject(taskId, ref) { const res = await runner.query( - `select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by updated_at`, + `select * from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 order by seq`, [taskId, ref.domain, ref.object], ); return res.rows.map(toEntry);