fix(admin-worker): 实时补丁同步筛选统计,并锁住"按工单号搜索结果被删行"的回归

线上现象:按工单号搜到的那一单,状态一变(实时 SSE),列表就变成「暂无数据」,而上方
筛选统计仍显示「共 1 单」;整页刷新后才恢复。

成因在前端实时快照补丁:旧逻辑里的 matchesWorkOrderKeyword 只比对「商品名 + 平台单号」,
不比对「工单号」,所以按 WO… 搜出来的行一旦有实时变更就被判成不匹配,直接从缓存里删掉;
而 statistics 是首次响应里带出来的、没被同步,于是出现"统计说有、列表没有"的自相矛盾。
(删行本身已由上一条提交改为:关键词筛选归入「不可证真」→ 只原地更新。)

本条补齐剩下的一致性:
1. 实时补丁增删/原地更新行时,同步维护 data.statistics 的 totalCount 与 statusCounts
   (statusCounts 按运营态口径记账,与后端 getWorkOrderStatistics 一致),列表与统计不再打架;
2. 补两个回归测试:按工单号搜到的行在状态变化后必须原地保留;插入/移除/原地改状态三种
   情况下统计都要跟得上。

验证:前端 80 项单测全绿(新增 2 项),lint / typecheck / build 通过。
This commit is contained in:
yml2213
2026-10-01 12:35:59 +08:00
parent 9573acc59d
commit d262b22a41
2 changed files with 148 additions and 9 deletions
@@ -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,
}),
}
: {}),
},
}
}
@@ -57,8 +57,26 @@ function buildSnapshot(overrides: Record<string, unknown> = {}) {
}
}
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<string, number> = {}
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<string, number>
} {
return (
envelope as {
data: { statistics: { totalCount: number; statusCounts: Record<string, number> } }
}
).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 },
)