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:
@@ -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 },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user