Files
order_site/apps/frontend/tests/admin-realtime-work-orders.test.ts
T
yml2213 d262b22a41 fix(admin-worker): 实时补丁同步筛选统计,并锁住"按工单号搜索结果被删行"的回归
线上现象:按工单号搜到的那一单,状态一变(实时 SSE),列表就变成「暂无数据」,而上方
筛选统计仍显示「共 1 单」;整页刷新后才恢复。

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

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

验证:前端 80 项单测全绿(新增 2 项),lint / typecheck / build 通过。
2026-10-01 12:35:59 +08:00

375 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import test from 'node:test'
import {
evaluateAdminWorkOrderRealtimeMatch,
parseAdminWorkOrderListFilters,
patchAdminWorkOrderListItems,
shouldPrependAdminWorkOrder,
type AdminWorkOrderListFilters,
} from '../src/utils/admin-realtime-work-orders.ts'
/** 复刻 WorkOrdersPanel 的结构化 queryKey。 */
function buildQueryKey(overrides: Partial<AdminWorkOrderListFilters> = {}) {
const filters: AdminWorkOrderListFilters = {
statuses: '',
keyword: '',
workerId: 0,
categoryId: 0,
workerLevelIds: '',
targetWorkOrderId: 0,
acceptanceMode: '',
giftPhase: '',
page: 1,
createdFrom: '',
createdTo: '',
...overrides,
}
return [
'admin-worker-platform-orders',
{
statuses: filters.statuses,
keyword: filters.keyword,
workerId: filters.workerId,
categoryId: filters.categoryId,
workerLevelIds: filters.workerLevelIds,
targetWorkOrderId: filters.targetWorkOrderId,
acceptanceMode: filters.acceptanceMode,
giftPhase: filters.giftPhase,
page: filters.page,
pageSize: 20,
createdFrom: filters.createdFrom,
createdTo: filters.createdTo,
},
] as const
}
function buildSnapshot(overrides: Record<string, unknown> = {}) {
return {
workOrderId: 100,
status: 'in_progress',
rawStatus: 'in_progress',
productName: '安卓暗潮3钻 苏尔南',
platformOrderId: '2627291924608680',
createdAt: '2026-09-29T10:51:05.000Z',
material: { consignment: { inventoryId: 23, inventoryNo: 'INV26091917439822' } },
...overrides,
}
}
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 }> {
return (envelope as { data: { items: Array<{ workOrderId: number }> } }).data.items
}
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
const sharingMasterOrder = buildSnapshot({ status: 'in_progress', rawStatus: 'open' })
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(sharingMasterOrder, filters),
'match',
'拿运营态比会误判成 mismatch 并把行从列表里删掉',
)
// 反过来:原始状态确实不符仍然要能证伪
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(
buildSnapshot({ status: 'open', rawStatus: 'accepted' }),
filters,
),
'mismatch',
)
})
test('状态列表里的虚拟值「VIP 待补图」不可证真:不插不移', () => {
const filters = parseAdminWorkOrderListFilters(
buildQueryKey({ statuses: 'vip_evidence_pending' }),
)!
assert.equal(evaluateAdminWorkOrderRealtimeMatch(buildSnapshot(), filters), 'unknown')
// 与真实状态混选时,服务端是「真实状态命中 OR VIP 待补图命中」:
// 任何一行都可能通过 VIP 分支被救回来,所以整条状态筛选都不可证伪,一律 unknown(宁可不删)。
const mixed = parseAdminWorkOrderListFilters(
buildQueryKey({ statuses: 'open,vip_evidence_pending' }),
)!
for (const rawStatus of ['open', 'accepted', 'cancelled']) {
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ rawStatus }), mixed),
'unknown',
`${rawStatus} 仍可能通过 VIP 分支命中,不能判为 mismatch`,
)
}
})
test('实时补丁:只认结构化 queryKey,旧的下标数组一律不猜(返回 null)', () => {
// 老结构:['admin-worker-platform-orders', statuses, keyword, workerId, categoryId, ...]
const legacyKey = ['admin-worker-platform-orders', '', '', 0, 0, '', 0, '', '', 1, 20, '', '']
assert.equal(parseAdminWorkOrderListFilters(legacyKey), null)
const parsed = parseAdminWorkOrderListFilters(
buildQueryKey({ page: 3, statuses: 'open,problem' }),
)
assert.ok(parsed)
assert.equal(parsed!.page, 3, 'page 必须按名字读出来(原来被读成 targetWorkOrderId → 恒为 1)')
assert.deepEqual(parsed!.statuses, ['open', 'problem'])
})
test('「内部寄售匹配」筛选下:普通单不会因为实时事件被插进列表', () => {
const key = buildQueryKey({ acceptanceMode: 'internal_consignment', page: 1 })
const filters = parseAdminWorkOrderListFilters(key)!
assert.ok(filters)
// 验收模式筛选前端无法证真 → unknown:既不能插入,也不能把已有行移除
const ordinary = buildSnapshot({ workOrderId: 999, material: { collect: {} } })
assert.equal(evaluateAdminWorkOrderRealtimeMatch(ordinary, filters), 'unknown')
const envelope = buildEnvelope([buildSnapshot({ workOrderId: 100 })])
const patched = patchAdminWorkOrderListItems(envelope, ordinary, { remove: false, insert: false })
assert.equal(patched, envelope, '无法证真时不应改动列表')
assert.equal(itemsOfWorkOrders(patched).length, 1)
})
test('第 2 页及以后绝不插入(原来任何页都被当成第 1 页,是乱跳的直接原因)', () => {
const filters = parseAdminWorkOrderListFilters(buildQueryKey({ page: 2 }))!
assert.equal(filters.page, 2)
assert.equal(evaluateAdminWorkOrderRealtimeMatch(buildSnapshot(), filters), 'match')
// 即使判定为 match,page !== 1 也不允许插到顶部
const page2Items = [buildSnapshot({ workOrderId: 80 }), buildSnapshot({ workOrderId: 79 })]
const fresh = buildSnapshot({ workOrderId: 5000 })
const envelope = buildEnvelope(page2Items)
const patched = patchAdminWorkOrderListItems(envelope, fresh, {
remove: false,
insert: filters.page === 1 && shouldPrependAdminWorkOrder(page2Items, fresh),
})
assert.equal(patched, envelope, '第 2 页不应被插入新行')
})
test('历史老单(id 小于首行)不会被插到列表顶部', () => {
const items = [buildSnapshot({ workOrderId: 100 }), buildSnapshot({ workOrderId: 99 })]
const legacy = buildSnapshot({ workOrderId: 42 })
assert.equal(shouldPrependAdminWorkOrder(items, legacy), false)
const envelope = buildEnvelope(items)
const patched = patchAdminWorkOrderListItems(envelope, legacy, { remove: false, insert: false })
assert.equal(patched, envelope, '排序不该变化:老单不进当前页')
})
test('真正的新单(id 大于首行)可以插到顶部,并同步分页总数', () => {
const items = [buildSnapshot({ workOrderId: 100 })]
const fresh = buildSnapshot({ workOrderId: 101 })
assert.equal(shouldPrependAdminWorkOrder(items, fresh), true)
const patched = patchAdminWorkOrderListItems(buildEnvelope(items, 124), fresh, {
remove: false,
insert: true,
})
assert.deepEqual(
itemsOfWorkOrders(patched).map((item) => item.workOrderId),
[101, 100],
)
assert.equal(totalOf(patched), 125)
})
test('已存在的行只做原地替换,不改变位置', () => {
const items = [
buildSnapshot({ workOrderId: 100 }),
buildSnapshot({ workOrderId: 99, status: 'open' }),
buildSnapshot({ workOrderId: 98 }),
]
const updated = buildSnapshot({ workOrderId: 99, status: 'in_progress' })
const patched = patchAdminWorkOrderListItems(buildEnvelope(items, 124), updated, {
remove: false,
insert: true,
})
assert.deepEqual(
itemsOfWorkOrders(patched).map((item) => item.workOrderId),
[100, 99, 98],
'原地替换不能把它提到第一位',
)
assert.equal(totalOf(patched), 124, '行数没变,总数不能变')
})
test('状态筛选:已不命中的工单会被移除并扣减总数', () => {
const filters = parseAdminWorkOrderListFilters(buildQueryKey({ statuses: 'open' }))!
const accepted = buildSnapshot({ workOrderId: 100, status: 'accepted' })
assert.equal(evaluateAdminWorkOrderRealtimeMatch(accepted, filters), 'mismatch')
const patched = patchAdminWorkOrderListItems(
buildEnvelope(
[buildSnapshot({ workOrderId: 100, status: 'open' }), buildSnapshot({ workOrderId: 99 })],
124,
),
accepted,
{ remove: true, insert: false },
)
assert.deepEqual(
itemsOfWorkOrders(patched).map((item) => item.workOrderId),
[99],
)
assert.equal(totalOf(patched), 123)
})
test('打手 / 分类 / 工单号 / 创建日期筛选的证伪能力', () => {
const filters = parseAdminWorkOrderListFilters(
buildQueryKey({
workerId: 7,
categoryId: 12,
targetWorkOrderId: 0,
createdFrom: '2026-09-01',
createdTo: '2026-09-30',
}),
)!
// 指派给别的打手 → mismatch
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ worker: { workerId: 8 } }), filters),
'mismatch',
)
// 拼单参与者是目标打手 → 命中(后端 workerId 筛选也认拼单参与者)
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(
buildSnapshot({ sharingWorkers: [{ workerId: 7 }], categoryId: 12 }),
filters,
),
'match',
)
// 分类不等:可能命中所选分类的**子分类**,而快照不带 parentCategoryId → 不能证伪,只能 unknown
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(
buildSnapshot({ worker: { workerId: 7 }, categoryId: 99 }),
filters,
),
'unknown',
)
// 创建时间在范围外 → mismatch
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(
buildSnapshot({
worker: { workerId: 7 },
categoryId: 12,
createdAt: '2026-08-01T00:00:00.000Z',
}),
filters,
),
'mismatch',
)
})
test('关键词 / 赠送阶段 / 打手等级筛选:无法证真,只原地更新不插不移', () => {
for (const override of [
{ keyword: '暗潮' },
{ giftPhase: 'gift_ready' },
{ workerLevelIds: '1,2' },
] as const) {
const filters = parseAdminWorkOrderListFilters(buildQueryKey(override))!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot(), filters),
'unknown',
`${JSON.stringify(override)} 应判为 unknown`,
)
}
// 但「状态已经不匹配」这种可以证伪的条件优先级更高,仍要能移除
const filters = parseAdminWorkOrderListFilters(
buildQueryKey({ keyword: '暗潮', statuses: 'open' }),
)!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters),
'mismatch',
)
})