Files
order_site/apps/frontend/tests/admin-realtime-work-orders.test.ts

486 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
isExplicitOrderNoSearch,
normalizeSearchText,
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: '',
shopNames: '',
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,
shopNames: filters.shopNames,
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',
)
})
/**
* 显式单号检索的护栏。
*
* 生产现象:按工单号搜出来的那一行,在订单状态变化后会从结果里消失,必须整页刷新才回来。
* 成因是实时补丁的判定链里,「状态/接单人员/日期范围」这些可证伪条件排在关键词护栏之前,
* 一旦它们判定 mismatch 就允许把行删掉。而面板搜索时只清空了状态,没清接单人员/日期范围,
* 所以护栏还会被这些条件绕过。
*/
test('显式单号检索:状态流转后不得把正在看的单删掉', () => {
const cases: Array<Partial<AdminWorkOrderListFilters>> = [
{ keyword: 'WO26100217194691', statuses: 'in_progress' },
{ keyword: 'WO26100217194691', workerId: 7 },
{ keyword: 'WO26100217194691', createdFrom: '2026-10-05' },
{ keyword: 'OD26100217', statuses: 'open' },
{ keyword: '2627502003065030', statuses: 'open' },
]
for (const override of cases) {
const filters = parseAdminWorkOrderListFilters(buildQueryKey(override))!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters),
'unknown',
`${JSON.stringify(override)} 应判为 unknown(只原地更新),否则状态一变就搜不到`,
)
}
})
test('模糊关键词不享受单号护栏:状态可证伪时仍按原设计移除', () => {
const filters = parseAdminWorkOrderListFilters(
buildQueryKey({ keyword: '暗潮', statuses: 'open' }),
)!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters),
'mismatch',
)
})
/**
* 服务端 operation='remove' 曾经直接短路成 mismatch,绕过了上面的单号护栏,
* 等于「显式单号检索不删行」这条规则在最需要它的路径上失效。现在交给判定函数统一处理。
*/
test('服务端 remove 事件不得绕过显式单号护栏;模糊关键词仍可被移除', () => {
const explicit = parseAdminWorkOrderListFilters(
buildQueryKey({ keyword: 'WO26101213031939', statuses: 'open' }),
)!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), explicit, {
operation: 'remove',
}),
'unknown',
'按单号搜出来的行,即便服务端发 remove 也不能删(工单并未删除)',
)
const fuzzy = parseAdminWorkOrderListFilters(buildQueryKey({ keyword: '暗潮' }))!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot(), fuzzy, { operation: 'remove' }),
'mismatch',
'普通关键词仍要保留「服务端说没了就移除」的语义',
)
})
test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', () => {
for (const orderNo of [
// 现役前缀 WK
'WK26101213031939',
'wk26101213031939',
// 历史前缀 WO
'WO26100217194691',
'wo12345678',
// 把前缀的 O 看成 0(线上真实案例:用平台号搜得到、用工单号搜不到)
'W026101213031939',
'OD26100217',
'2627502003065030',
]) {
assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`)
}
for (const keyword of ['暗潮', '无限潮汐', 'WO1', 'WK', 'W0', '', 'abcd']) {
assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`)
}
})
test('isExplicitOrderNoSearch 容错:全角字符与零宽字符不影响识别', () => {
assert.equal(isExplicitOrderNoSearch(' WK26101213031939 '), true)
assert.equal(isExplicitOrderNoSearch('WK26101213031939\u200b'), true)
assert.equal(isExplicitOrderNoSearch('\ufeffWO26101213031939'), true)
// 归一化口径与后端 normalizeLookupKeyword 一致
assert.equal(normalizeSearchText(' WO26101213031939\u200d '), 'WO26101213031939')
})
test('关联单号(寄卖/仓库/售后/履约)也算显式单号检索', () => {
// 客服界面「只认寄卖单号」,粘进来的就是 SUB;漏判会让搜到的单被状态页签挡掉
for (const orderNo of [
'SUB26100301127318',
'INV26100310477194',
'ASde06bcb25aca',
'DTb946808bcd5b',
// 大小写与空格容忍
' sub26100301127318 ',
]) {
assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`)
}
// 前缀相近但不是单号的普通词,不能被误判(误判会清掉客服的筛选条件)
for (const keyword of ['SUBWAY', 'ASO优化', 'DT', 'INV', 'SUB123', 'ASzzz']) {
assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`)
}
})