diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index 06c9c43a..35381e51 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -178,7 +178,45 @@ export function shouldPrependAdminWorkOrder(items: readonly unknown[], snapshot: } /** - * 按工单号原地增删列表行,并同步分页总数(结构与后端 `{ data: { items, pagination } }` 一致)。 + * 同步筛选统计卡(`data.statistics`)。 + * + * 统计与列表是**同一个响应**里下发的,两边必须一起改:只改 items 不改 statistics, + * 就会出现"统计说还有 1 单、列表却是暂无数据"的自相矛盾界面(线上就是这样暴露的)。 + * 注意 statusCounts 用的是**运营态**口径(后端 statistics 走 adminOperationalStatusExpression), + * 所以要按快照的 status 而非 rawStatus 记账。 + */ +function adjustWorkOrderStatistics( + statistics: unknown, + change: { previousRow?: unknown; nextRow?: unknown; totalDelta: number }, +): unknown { + if (!isRecord(statistics)) return statistics + const previousStatus = String( + (change.previousRow as { status?: unknown } | null | undefined)?.status || '', + ) + const nextStatus = String( + (change.nextRow as { status?: unknown } | null | undefined)?.status || '', + ) + + const statusCounts = isRecord(statistics.statusCounts) ? { ...statistics.statusCounts } : null + if (statusCounts && previousStatus !== nextStatus) { + if (previousStatus) { + statusCounts[previousStatus] = Math.max(0, toNumber(statusCounts[previousStatus]) - 1) + } + if (nextStatus) { + statusCounts[nextStatus] = toNumber(statusCounts[nextStatus]) + 1 + } + } + + return { + ...statistics, + totalCount: Math.max(0, toNumber(statistics.totalCount) + change.totalDelta), + ...(statusCounts ? { statusCounts } : {}), + } +} + +/** + * 按工单号原地增删列表行,并同步分页总数与筛选统计 + * (结构与后端 `{ data: { items, pagination, statistics } }` 一致)。 * 行已存在时永远只做原地替换;只有 insert 为真、且列表里确实没有这一行时才插到顶部。 */ export function patchAdminWorkOrderListItems( @@ -194,7 +232,12 @@ export function patchAdminWorkOrderListItems( if (id <= 0) return current let nextItems: unknown[] + let previousRow: unknown = null + let nextRow: unknown = null if (options.remove) { + previousRow = items.find( + (item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) === id, + ) nextItems = items.filter( (item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) !== id, ) @@ -203,12 +246,14 @@ export function patchAdminWorkOrderListItems( (item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) === id, ) if (index >= 0) { + previousRow = items[index] nextItems = items.map((item, itemIndex) => (itemIndex === index ? snapshot : item)) } else if (options.insert) { nextItems = [snapshot, ...items] } else { return current } + nextRow = snapshot } const delta = nextItems.length - items.length @@ -226,6 +271,15 @@ export function patchAdminWorkOrderListItems( }, } : {}), + ...(isRecord(current.data.statistics) + ? { + statistics: adjustWorkOrderStatistics(current.data.statistics, { + previousRow, + nextRow, + totalDelta: delta, + }), + } + : {}), }, } } diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index c716da80..7cdb645b 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -57,8 +57,26 @@ function buildSnapshot(overrides: Record = {}) { } } -function buildEnvelope(items: unknown[]) { - return { ok: true, data: { items, pagination: { total: 124, page: 1, pageSize: 20 } } } +function buildEnvelope(items: unknown[], total = items.length) { + // 统计里的状态分布用运营态口径(与后端 statistics 一致),这里直接从行里推出来 + const statusCounts: Record = {} + for (const item of items) { + const status = String((item as { status?: unknown })?.status || '') + if (status) statusCounts[status] = (statusCounts[status] || 0) + 1 + } + return { + ok: true, + data: { + items, + pagination: { total, page: 1, pageSize: 20 }, + statistics: { + totalCount: total, + statusCounts, + userPaymentAmount: 43000, + rewardAmount: 41000, + }, + }, + } } function itemsOfWorkOrders(envelope: unknown): Array<{ workOrderId: number }> { @@ -69,6 +87,73 @@ function totalOf(envelope: unknown): number { return (envelope as { data: { pagination: { total: number } } }).data.pagination.total } +function statisticsOf(envelope: unknown): { + totalCount: number + statusCounts: Record +} { + return ( + envelope as { + data: { statistics: { totalCount: number; statusCounts: Record } } + } + ).data.statistics +} + +test('按工单号搜到的行,状态变化后不能被实时补丁删掉(线上"统计还有 1 单、列表暂无数据"的成因)', () => { + const filters = parseAdminWorkOrderListFilters(buildQueryKey({ keyword: 'WO26093017345168' }))! + // 旧逻辑只拿商品名/平台单号比关键词,工单号搜出来的行会被判成不匹配 → 直接删掉 + assert.equal( + evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'in_progress' }), filters), + 'unknown', + '关键词筛选无法证真,只能原地更新,绝不能删行', + ) + + const row = buildSnapshot({ workOrderId: 79325, status: 'pending_material' }) + const envelope = buildEnvelope([row]) + const patched = patchAdminWorkOrderListItems(envelope, row, { remove: false, insert: false }) + assert.notEqual(patched, envelope, '同一行要能原地刷新') + assert.equal(itemsOfWorkOrders(patched).length, 1, '行必须还在') + assert.equal(totalOf(patched), 1) + assert.equal(statisticsOf(patched).totalCount, 1, '统计不能和列表打架') +}) + +test('实时补丁要同时维护筛选统计,避免"统计说有、列表没有"', () => { + // ① 原地替换且状态变化:总数不变,状态分布跟着走 + const openRow = buildSnapshot({ workOrderId: 100, status: 'open', rawStatus: 'open' }) + const envelope = buildEnvelope([openRow]) + assert.deepEqual(statisticsOf(envelope).statusCounts, { open: 1 }) + + const grabbed = buildSnapshot({ + workOrderId: 100, + status: 'in_progress', + rawStatus: 'in_progress', + }) + const patched = patchAdminWorkOrderListItems(envelope, grabbed, { remove: false, insert: false }) + assert.equal(statisticsOf(patched).totalCount, 1) + assert.deepEqual(statisticsOf(patched).statusCounts, { open: 0, in_progress: 1 }) + + // ② 命中状态筛选的新单插入:总数与状态分布都要 +1 + const inserted = patchAdminWorkOrderListItems( + buildEnvelope([buildSnapshot({ workOrderId: 200, status: 'open' })]), + buildSnapshot({ workOrderId: 201, status: 'in_progress' }), + { remove: false, insert: true }, + ) + assert.equal(statisticsOf(inserted).totalCount, 2) + assert.deepEqual(statisticsOf(inserted).statusCounts, { open: 1, in_progress: 1 }) + + // ③ 已不命中的行被移除:总数与它原来的状态计数都要 -1 + const removed = patchAdminWorkOrderListItems( + buildEnvelope([ + buildSnapshot({ workOrderId: 300, status: 'open', rawStatus: 'open' }), + buildSnapshot({ workOrderId: 301, status: 'in_progress', rawStatus: 'in_progress' }), + ]), + buildSnapshot({ workOrderId: 300, status: 'accepted', rawStatus: 'accepted' }), + { remove: true, insert: false }, + ) + assert.equal(itemsOfWorkOrders(removed).length, 1) + assert.equal(statisticsOf(removed).totalCount, 1) + assert.deepEqual(statisticsOf(removed).statusCounts, { open: 0, in_progress: 1 }) +}) + test('状态筛选必须按原始状态判定:拼单母单的运营态不会把行误删', () => { const filters = parseAdminWorkOrderListFilters(buildQueryKey({ statuses: 'open' }))! // 拼单母单:原始 wo.status = open(列表筛选用它),但快照的 status 被显示成 in_progress @@ -168,7 +253,7 @@ test('真正的新单(id 大于首行)可以插到顶部,并同步分页 const fresh = buildSnapshot({ workOrderId: 101 }) assert.equal(shouldPrependAdminWorkOrder(items, fresh), true) - const patched = patchAdminWorkOrderListItems(buildEnvelope(items), fresh, { + const patched = patchAdminWorkOrderListItems(buildEnvelope(items, 124), fresh, { remove: false, insert: true, }) @@ -186,7 +271,7 @@ test('已存在的行只做原地替换,不改变位置', () => { buildSnapshot({ workOrderId: 98 }), ] const updated = buildSnapshot({ workOrderId: 99, status: 'in_progress' }) - const patched = patchAdminWorkOrderListItems(buildEnvelope(items), updated, { + const patched = patchAdminWorkOrderListItems(buildEnvelope(items, 124), updated, { remove: false, insert: true, }) @@ -204,10 +289,10 @@ test('状态筛选:已不命中的工单会被移除并扣减总数', () => { assert.equal(evaluateAdminWorkOrderRealtimeMatch(accepted, filters), 'mismatch') const patched = patchAdminWorkOrderListItems( - buildEnvelope([ - buildSnapshot({ workOrderId: 100, status: 'open' }), - buildSnapshot({ workOrderId: 99 }), - ]), + buildEnvelope( + [buildSnapshot({ workOrderId: 100, status: 'open' }), buildSnapshot({ workOrderId: 99 })], + 124, + ), accepted, { remove: true, insert: false }, )