fix(consignment): 修复寄卖取消解绑、SUB 单号写空、拼单门禁与后台工单列表实时乱跳

1. 问题单处置「取消」后打手信息不消失:resolveProblemWorkOrder 的取消分支从未解绑寄卖现货,现货永久停在 consumed 占库不还,工单 material 上的寄售标记残留又让列表继续显示「供货打手」。取消时补上解绑;调用必须排在状态 UPDATE 之前——那条 UPDATE 会清空 friend_added_at,清完再解绑会把已确认加好友的货误判成可回池(二次出货);
2. 解绑函数在没有现货可解绑时直接 return,跳过了摘标记,于是「库存行早已不存在」的老帖永远摘不掉寄售标记(列表一直显示供货打手)。现在无论是否解绑到现货都摘标记,痕迹保留在 material.consignmentHistory 的 detachReason 里;
3. 内部现货履约写入的 sourceSubmissionNo 恒为空:取的是占库 `UPDATE ... RETURNING *` 的行,只带回本表列,没有 join 出来的 submission_no,导致工单列表把所有寄卖单都显示成「老单无提报单号」,SUB 单号这个唯一锚点丢失。改取带 join 的预读库存(内部现货资料、指派工单资料、指派事件留痕三处)。存量老单按业务口径不回填;
4. 后台接单工单列表实时乱跳:SSE 快照补丁按数组下标读 queryKey,面板 key 加了字段后下标全错(page 被读成 targetWorkOrderId → 任何页都被当成第 1 页),且完全没判「内部寄售匹配」等筛选,于是任意一条不在当前页的老单只要发生变更就被插到列表顶部。改为结构化 queryKey + 三态判定(match 才允许插顶、mismatch 才允许移除、其余只原地更新),插入还要求 page===1 且工单 id 大于当前首行;状态比对改用 rawStatus(快照的 status 是运营态,拼单母单会被显示成 in_progress),分类筛选因快照不带 parentCategoryId 归入不可证伪;
5. 新增「寄卖只做整单」硬门禁:已有人参与的拼单工单不参与现货匹配与库存提醒,履约入口一律返回 work_order_sharing_not_consignable,前端「手动匹配寄卖」入口按同一口径收敛;开了拼单但无人参与的整单不受影响,避免过度拦截。

验证:后端 520 项单测(517 pass / 3 skip)、前端 78 项全绿;并对真实开发库跑通问题单取消三类场景(老帖无库存行 / 回库 / 已加好友作废)、退回打手与退回大厅的反向用例(不得解绑)、履约写入真实 SUB 单号、拼单门禁四入口 + 未过度拦截反向用例。
This commit is contained in:
yml2213
2026-10-01 12:17:00 +08:00
parent 610c65bb34
commit 40ee36d72a
11 changed files with 869 additions and 95 deletions
@@ -0,0 +1,289 @@
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[]) {
return { ok: true, data: { items, pagination: { total: 124, page: 1, pageSize: 20 } } }
}
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
}
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), 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), 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 }),
]),
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',
)
})
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
canManuallyMatchConsignmentOrder,
canSubmitManualMatch,
describeManualMatchType,
filterManualMatchOptions,
@@ -70,3 +71,39 @@ test('待指派/待补资料/大厅待抢的订单均能手动指定现货(与
assert.equal(isManuallyMatchableOrderStatus(status), false, `${status} 不该给手动匹配入口`)
}
})
test('寄卖只做整单:已有打手参与的拼单工单不给「手动匹配寄卖」入口', () => {
// 状态允许但已有人拼单 → 不给入口(后端也会返回 work_order_sharing_not_consignable)
assert.equal(
canManuallyMatchConsignmentOrder({ status: 'open', sharingProgress: { joinedQuantity: 2 } }),
false,
'有份额被认领的拼单工单不能走寄卖现货履约',
)
assert.equal(
canManuallyMatchConsignmentOrder({ status: 'open', sharingWorkers: [{ workerId: 7 }] }),
false,
'拼单参与者列表非空同样说明已有人参与',
)
// 开了拼单但还没人参与(整单照样能履约)→ 仍然给入口
assert.equal(
canManuallyMatchConsignmentOrder({
status: 'open',
sharingProgress: { joinedQuantity: 0 },
sharingWorkers: [],
}),
true,
)
// 普通整单
assert.equal(canManuallyMatchConsignmentOrder({ status: 'unassigned' }), true)
assert.equal(canManuallyMatchConsignmentOrder({ status: 'pending_material' }), true)
// 状态本身不允许的仍然不给
for (const status of ['in_progress', 'pending_acceptance', 'accepted', 'cancelled']) {
assert.equal(
canManuallyMatchConsignmentOrder({ status, sharingProgress: { joinedQuantity: 0 } }),
false,
`${status} 本就不该给入口`,
)
}
})