From d262b22a4111860608f9b41e8b422c5b3cf12f21 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 12:35:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin-worker):=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E8=A1=A5=E4=B8=81=E5=90=8C=E6=AD=A5=E7=AD=9B=E9=80=89=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=EF=BC=8C=E5=B9=B6=E9=94=81=E4=BD=8F"=E6=8C=89?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=8F=B7=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=A2=AB=E5=88=A0=E8=A1=8C"=E7=9A=84=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上现象:按工单号搜到的那一单,状态一变(实时 SSE),列表就变成「暂无数据」,而上方 筛选统计仍显示「共 1 单」;整页刷新后才恢复。 成因在前端实时快照补丁:旧逻辑里的 matchesWorkOrderKeyword 只比对「商品名 + 平台单号」, 不比对「工单号」,所以按 WO… 搜出来的行一旦有实时变更就被判成不匹配,直接从缓存里删掉; 而 statistics 是首次响应里带出来的、没被同步,于是出现"统计说有、列表没有"的自相矛盾。 (删行本身已由上一条提交改为:关键词筛选归入「不可证真」→ 只原地更新。) 本条补齐剩下的一致性: 1. 实时补丁增删/原地更新行时,同步维护 data.statistics 的 totalCount 与 statusCounts (statusCounts 按运营态口径记账,与后端 getWorkOrderStatistics 一致),列表与统计不再打架; 2. 补两个回归测试:按工单号搜到的行在状态变化后必须原地保留;插入/移除/原地改状态三种 情况下统计都要跟得上。 验证:前端 80 项单测全绿(新增 2 项),lint / typecheck / build 通过。 --- .../src/utils/admin-realtime-work-orders.ts | 56 +++++++++- .../tests/admin-realtime-work-orders.test.ts | 101 ++++++++++++++++-- 2 files changed, 148 insertions(+), 9 deletions(-) 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 }, )