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
@@ -824,16 +824,23 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
[input.now, workOrderId, input.reason || ''],
)
const released = res.rows[0] || null
if (!released) return null
// 现货已解绑回库,工单上的寄售标记必须同步摘掉:否则工单列表会继续显示
// 「寄售履约: 某现货」,撤销按钮也会再次出现(点下去必然 409)。
// 工单上的寄售标记必须同步摘掉:否则工单列表会继续显示「供货打手 / 内部现货」,
// 撤销按钮也会再次出现(点下去必然 409)。
// 关键:**没有解绑到现货时也要摘**。released 为 null 有两类真实场景:
// 1) 老帖工单的 material.consignment 指向的库存行早已不存在或已被人工回库;
// 2) 现货此前已被其它路径处理过(重复处置 / 补偿链路)。
// 这类单子若直接 return,标记会永久留在工单上——"问题单取消后打手列仍显示供货打手"
// 就是这么来的。此时按工单整体摘(不做 inventoryId 过滤):已经不存在被本工单占用的
// 现货,也就不存在误伤新绑定的可能。
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
workOrderId,
inventoryId: released.id,
reason: archiveOnly
? `已确认加好友,现货作废下架: ${input.reason || ''}`
: `工单解绑回库: ${input.reason || ''}`,
inventoryId: released ? released.id : null,
reason: released
? archiveOnly
? `已确认加好友,现货作废下架: ${input.reason || ''}`
: `工单解绑回库: ${input.reason || ''}`
: `无在库现货可解绑,仅摘除寄售标记: ${input.reason || ''}`,
now: input.now,
})
return released
@@ -143,6 +143,20 @@ export async function resolveProblemWorkOrder(input: {
input.action === 'reopen' ||
(input.action === 'return_to_worker' && targetStatus === 'in_progress')
// 取消处置 = 工单终止:被本工单占用的寄卖现货必须解绑(回库;已确认加好友则作废下架),
// 并同步摘掉 material 上的寄售标记。否则会出现两个后果:
// 1) 列表「打手」列继续显示「供货打手 / 内部现货」,看着像打手还挂在这张已取消的单上;
// 2) 现货永久停在 consumed 占库不还,既不参与匹配也没有押金释放入口。
// ⚠️ 必须放在下面的 UPDATE 之前:那条 UPDATE 在取消时会把 friend_added_at 清空,
// 而解绑是否需要改成「作废」正是看这个字段——清空后再解绑会把已加好友的货退回在库池,
// 等于二次出货。
if (targetStatus === 'cancelled') {
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
now: input.now,
reason: `问题单处置取消(${effectiveAction})`,
})
}
await client.query(
`
UPDATE work_orders
@@ -170,6 +170,18 @@ export type ConsignmentMatchOptionView = Omit<ConsignmentMatchOption, 'inventory
inventory: ReturnType<typeof mapConsignmentInventory>
}
/**
* 工单是否已有打手参与的拼单(`active_share_quantity` 由 WORK_ORDER_SELECT 计算下发)。
*
* 寄卖现货只做**整单**履约:一旦让拼单工单走寄卖履约,之后若运营走「取消拼单」,
* 那条链路只处理份额押金、不解绑寄卖现货(`work-order-settlement-repo` 的取消分支),
* 现货会永久停在 consumed 占库不还、工单上还挂着寄售标记。
* 与其在拼单侧补一段基本跑不到的解绑逻辑,不如把组合直接挡在入口——业务上寄卖本来就是整单。
*/
function hasActiveWorkOrderShares(workOrder: { active_share_quantity?: unknown }): boolean {
return Number(workOrder.active_share_quantity || 0) > 0
}
/**
* 订单侧「手动指定现货」:列出该订单的**全部**在库现货(含不符合规格的)。
*
@@ -186,6 +198,11 @@ export async function listWorkOrderInventoryMatchOptions(workOrderId: number | s
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
// 寄卖只做整单:已经开拼单且有打手参与的工单不参与现货匹配,
// 否则会给出"可选现货"的错觉,客服点下去要么 409、要么把拼单工序和寄卖搅在一起。
if (hasActiveWorkOrderShares(workOrder)) {
return { hasMatch: false, exactCount: 0, partialCount: 0, options: [] }
}
const fields = resolveCollectSubmittedFieldValues(workOrder)
const inventories = await listActiveConsignmentInventoriesForMatching()
const ranking = { exact: 0, partial: 1, conflict: 2 }
@@ -261,7 +278,9 @@ export async function listInventoryMatchableOrders(
})
const ranking = { exact: 0, partial: 1, conflict: 2 }
// 拼单工单不进候选:寄卖只做整单(同 hasActiveWorkOrderShares 的口径)。
const orders = items
.filter((order) => !hasActiveWorkOrderShares(order))
.map((order) => {
const fields = resolveCollectSubmittedFieldValues(order)
const orderSpecs = extractPeacekeeperSpecs(order.product_name, {
@@ -312,6 +331,10 @@ export async function checkWorkOrderInventoryMatches(workOrderId: number | strin
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
// 同上:拼单工单不做寄卖现货匹配提醒。
if (hasActiveWorkOrderShares(workOrder)) {
return { hasMatch: false, exactCount: 0, partialCount: 0, matches: [] }
}
const fields = resolveCollectSubmittedFieldValues(workOrder)
@@ -376,6 +399,14 @@ export async function fulfillWorkOrderWithConsignment(input: {
})
}
// 寄卖只做整单:已有打手参与的拼单工单不接受寄卖现货履约(入口硬拦,不靠前端隐藏按钮)。
if (hasActiveWorkOrderShares(workOrder)) {
throw createHttpError('该工单已开启拼单且有打手参与,寄卖现货只能履约整单订单', {
statusCode: 409,
errorCode: 'work_order_sharing_not_consignable',
})
}
const inventory = await getConsignmentInventoryById(input.inventoryId)
if (!inventory) {
throw createHttpError('寄卖物品不存在', {
@@ -479,7 +510,11 @@ export async function fulfillWorkOrderWithConsignment(input: {
screenshots: safeParseJson(inv.screenshots_json || inv.submission_screenshots_json || []),
costPriceFen: inv.cost_price_fen,
sourceSubmissionId: inv.source_submission_id ? Number(inv.source_submission_id) : null,
sourceSubmissionNo: inv.source_submission_no || '',
// ⚠️ sourceSubmissionNo 必须取「带 join 预读」的 inventory:上面的条件 UPDATE 是
// `RETURNING *`,只带回 consignment_inventories 自己的列,没有 join 出来的
// submission_no。取 inv.source_submission_no 会恒为 undefined → 工单列表永远显示
// 「老单无提报单号」,把 SUB 单号这个唯一锚点丢掉。
sourceSubmissionNo: inventory.source_submission_no || '',
fulfillmentType: CONSIGNMENT_FULFILLMENT_TYPE.INTERNAL_STOCK,
fulfilledAt: now,
fulfilledBy: input.actorName,
@@ -722,7 +757,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
sourceSubmissionId: claimed.source_submission_id
? Number(claimed.source_submission_id)
: null,
sourceSubmissionNo: claimed.source_submission_no || '',
sourceSubmissionNo: inventory.source_submission_no || '',
fulfillmentType: 'worker_deposit',
assignedAt: now,
assignedBy: input.actorName,
@@ -767,7 +802,8 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
sourceSubmissionId: claimed.source_submission_id
? Number(claimed.source_submission_id)
: null,
sourceSubmissionNo: claimed.source_submission_no || '',
// 同上:claimed 没有 join 列,事件留痕也要用预读的 inventory 才拿得到 SUB 单号
sourceSubmissionNo: inventory.source_submission_no || '',
}),
now,
})
@@ -210,6 +210,65 @@ test('已确认加好友后解绑:现货作废下架,绝不回池被下一
assert.equal(released?.status, 'archived')
})
test('没解绑到现货时也必须摘掉工单上的寄售标记(老帖 / 现货已被处理过的单子)', async () => {
const statements: { sql: string; values: unknown[] }[] = []
const client = {
query: async (sql: string, values?: unknown[]) => {
statements.push({ sql, values: values || [] })
if (sql.includes('SELECT acceptance_mode')) {
return { rows: [{ acceptance_mode: 'standard', friend_added_at: null }] }
}
// 库存行没有被本工单占用:解绑 0 行(老帖指向的库存行已不存在,或已被人工回库)
if (sql.includes('UPDATE consignment_inventories')) return { rows: [] }
return { rows: [] }
},
} as unknown as PoolClient
const released = await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 100200, {
now: '2026-09-15T00:00:00.000Z',
reason: '问题单处置取消(cancel_release)',
})
assert.equal(released, null, '没有占到现货时解绑结果为空')
const detach = statements.find((item) => item.sql.includes("material_json - 'consignment'"))
assert.ok(
detach,
'没有解绑到现货也必须摘掉寄售标记,否则问题单取消后「打手」列会一直显示供货打手',
)
// 传 null 代表不做 inventoryId 过滤、按工单整体摘:此时已不存在被本工单占用的现货
assert.equal(detach!.values[3], null, '无现货可解绑时应按工单整体摘标记')
})
test('问题单处置取消:先解绑寄卖现货,再改工单状态', async () => {
const readFile = await import('node:fs/promises')
const src = await readFile.readFile(
new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url),
'utf8',
)
const start = src.indexOf('export async function resolveProblemWorkOrder(')
assert.ok(start > 0, '未找到问题单处置函数')
const body = src.slice(start, src.indexOf('\n}\n', start))
const releaseIndex = body.indexOf(
'releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId',
)
assert.ok(releaseIndex > 0, '取消问题单必须解绑被本工单占用的寄卖现货,否则现货永久占库不还')
assert.ok(
body.includes("if (targetStatus === 'cancelled')"),
'只对「取消」处置解绑:退回打手重做 / 退回大厅时货还在履约,不能解绑',
)
// 顺序很重要:那条 UPDATE 会在取消时把 friend_added_at 清空,而"是否改判作废"正是看这个字段。
// 清空之后再解绑,会把已加好友的现货退回在库池 → 二次出货。
const updateIndex = body.indexOf('UPDATE work_orders')
assert.ok(updateIndex > 0, '未找到工单状态更新语句')
assert.ok(
releaseIndex < updateIndex,
'解绑必须排在工单状态更新之前,否则已确认加好友的单子会被误判成可回池',
)
})
test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => {
const statements: string[] = []
const client = {
@@ -2072,6 +2131,80 @@ test('打手查询条件支持提现黑名单筛选', () => {
assert.equal(allWhere.params.length, 0)
})
test('寄卖只做整单:拼单工单不得进入寄卖现货履约链路', async () => {
const readFile = await import('node:fs/promises')
const src = await readFile.readFile(
new URL('./consignment-fulfillment-service.ts', import.meta.url),
'utf8',
)
// 口径来源:WORK_ORDER_SELECT 计算出来的 active_share_quantity(已有打手参与的份额)
assert.ok(
src.includes('function hasActiveWorkOrderShares('),
'缺少统一的拼单判定口径,散落的 active_share_quantity 判断容易漏改',
)
assert.ok(
src.includes("errorCode: 'work_order_sharing_not_consignable'"),
'履约入口必须硬拦拼单工单(不能只靠前端隐藏按钮)',
)
// 订单侧「手动指定现货」候选:拼单工单直接返回空
const optionsBody = src.slice(
src.indexOf('export async function listWorkOrderInventoryMatchOptions'),
src.indexOf('export async function listInventoryMatchableOrders'),
)
assert.ok(
optionsBody.includes('hasActiveWorkOrderShares(workOrder)'),
'订单侧候选现货必须对拼单工单返回空',
)
// 发布时的内部库存提醒
const matchesBody = src.slice(
src.indexOf('export async function checkWorkOrderInventoryMatches'),
src.indexOf('export async function fulfillWorkOrderWithConsignment'),
)
assert.ok(
matchesBody.includes('hasActiveWorkOrderShares(workOrder)'),
'发布时的内部库存提醒也必须跳过拼单工单',
)
// 现货侧「可匹配订单」候选:过滤掉拼单工单
const ordersBody = src.slice(src.indexOf('export async function listInventoryMatchableOrders'))
assert.ok(
ordersBody.includes('.filter((order) => !hasActiveWorkOrderShares(order))'),
'现货侧「可匹配订单」候选必须过滤掉拼单工单',
)
})
test('寄卖履约必须写入真实 SUB 单号:只能取带 join 的预读库存', async () => {
const readFile = await import('node:fs/promises')
const src = await readFile.readFile(
new URL('./consignment-fulfillment-service.ts', import.meta.url),
'utf8',
)
// 内部现货履行的占库是 `UPDATE consignment_inventories ... RETURNING *`,
// 只带回本表列,submission_no 来自与 consignment_submissions 的 join。
// 取 RETURNING 行上的 source_submission_no 会恒为 undefined → 工单列表永远显示
// 「老单无提报单号」,SUB 单号这个唯一锚点就丢了(生产上 4 张单全中招)。
assert.ok(
!src.includes('sourceSubmissionNo: inv.source_submission_no'),
'内部现货路径不能取 RETURNING 行上的 submission_no',
)
assert.ok(
!src.includes('sourceSubmissionNo: claimed.source_submission_no'),
'指派路径不能取 RETURNING 行上的 submission_no',
)
const usingJoinedInventory =
src.split("sourceSubmissionNo: inventory.source_submission_no || ''").length - 1
assert.equal(
usingJoinedInventory,
3,
'内部现货资料、指派工单资料、指派事件留痕三处都必须用带 join 的预读库存',
)
})
test('指派"部分成功"窗口:失败补偿必须撤干净(取消指派 + 还库存 + 摘寄售标记)', async () => {
const readFile = await import('node:fs/promises')
const src = await readFile.readFile(
+38 -54
View File
@@ -52,6 +52,12 @@ import {
setAdminPermissions,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
evaluateAdminWorkOrderRealtimeMatch,
parseAdminWorkOrderListFilters,
patchAdminWorkOrderListItems,
shouldPrependAdminWorkOrder,
} from '@/utils/admin-realtime-work-orders'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
import dayjs from '@/lib/dayjs'
@@ -436,40 +442,43 @@ function patchAdminWorkOrderCaches(
queryClient: ReturnType<typeof useQueryClient>,
event: RealtimeEvent,
) {
const item = event.data as {
workOrderId?: unknown
status?: unknown
productName?: unknown
platformOrderId?: unknown
worker?: { workerId?: unknown } | null
}
const item = event.data
for (const [queryKey, current] of queryClient.getQueriesData({
queryKey: ['admin-worker-platform-orders'],
})) {
const statuses = String(queryKey[1] || '')
.split(',')
.filter(Boolean)
const keyword = String(queryKey[2] || '')
const workerId = Number(queryKey[3] || 0)
const workerLevelIds = String(queryKey[4] || '')
.split(',')
.map((value) => Number(value))
.filter(Boolean)
const targetWorkOrderId = Number(queryKey[5] || 0)
const page = Number(queryKey[6] || 1)
// 等级筛选的匹配逻辑只在服务端(接单打手或拼单参与者等级),快照不含等级,
// 无法在前端复现:已有行视为匹配(拉取时已通过服务端过滤),但不再插入新行。
const levelFilterActive = workerLevelIds.length > 0
const matches =
(!statuses.length || statuses.includes(String(item.status || ''))) &&
matchesWorkOrderKeyword(item, keyword) &&
(!workerId || workerId === Number(item.worker?.workerId || 0)) &&
(!targetWorkOrderId || targetWorkOrderId === Number(item.workOrderId || 0))
const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !levelFilterActive
const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert'
const filters = parseAdminWorkOrderListFilters(queryKey)
if (!filters) {
// 读不懂这个 key(旧结构 / 别的调用方):不猜、不插、不移除,交给服务端下一次拉取。
continue
}
// 三态判定:match 才允许插入顶部;mismatch 才允许移除;unknown 只原地更新已存在的行。
const verdict =
event.operation === 'remove' ? 'mismatch' : evaluateAdminWorkOrderRealtimeMatch(item, filters)
if (verdict === 'unknown') {
queryClient.setQueryData(
queryKey,
patchAdminWorkOrderListItems(current, item, { remove: false, insert: false }),
)
continue
}
const items =
isRecord(current) && isRecord(current.data) && Array.isArray(current.data.items)
? (current.data.items as unknown[])
: []
const insert =
verdict === 'match' &&
event.operation === 'upsert' &&
filters.page === 1 &&
shouldPrependAdminWorkOrder(items, item)
queryClient.setQueryData(
queryKey,
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
patchAdminWorkOrderListItems(current, item, {
remove: verdict === 'mismatch',
insert,
}),
)
}
}
@@ -597,20 +606,6 @@ function patchAdminOrderListEnvelope(
}
}
function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const item = event.data
const id = Number((item as { workOrderId?: unknown }).workOrderId || event.entityId)
const items = current.data.items as unknown[]
const nextItems =
event.operation === 'remove'
? items.filter((entry) => Number((entry as { workOrderId?: unknown })?.workOrderId) !== id)
: upsertById(items, item, 'workOrderId', insert)
return { ...current, data: { ...current.data, items: nextItems } }
}
function patchFinanceRequestListEnvelope(current: unknown, event: RealtimeEvent) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
@@ -682,17 +677,6 @@ function matchesAdminOrderDateRange(
return true
}
function matchesWorkOrderKeyword(
item: { productName?: unknown; platformOrderId?: unknown },
keyword: string,
) {
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return true
return [item.productName, item.platformOrderId]
.map((value) => String(value || '').toLowerCase())
.some((value) => value.includes(normalizedKeyword))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
@@ -83,8 +83,12 @@ import {
import { notifyVoucherConsume } from '@/utils/voucher-consume'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { isManuallyMatchableOrderStatus } from '@/utils/consignment-manual-match'
import { resolveConsignmentScreenshotUrls } from '@/utils/consignment-display'
import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match'
import {
isConsignmentOrder,
isInternalConsignmentOrder,
resolveConsignmentScreenshotUrls,
} from '@/utils/consignment-display'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
@@ -172,22 +176,6 @@ type CancelSharingFormValues = {
reason?: string
}
function isInternalConsignmentOrder(row: WorkOrder) {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
if (consignment.fulfillmentType === 'worker_deposit' || consignment.workerId) return false
return Boolean(consignment.inventoryId) && !row.worker
}
function isConsignmentOrder(row: WorkOrder) {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
return Boolean(
consignment.inventoryId ||
consignment.inventoryNo ||
consignment.fulfillmentType ||
row.gift?.boosterMaterial?.fields?.inventoryNo,
)
}
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
@@ -392,20 +380,25 @@ export default function WorkOrdersPanel() {
}, [searchParams, setSearchParams])
const ordersQuery = useQuery({
// ⚠️ 结构化 key:实时快照补丁(layouts/AdminLayout.tsx)要按名字读筛选条件。
// 以前这里是位置数组,AdminLayout 按下标去读,下标一改就全错(page 被读成 targetWorkOrderId,
// 导致任何页都被当成第 1 页,历史单被插到列表顶部"乱跳")。改字段时两边一起改。
queryKey: [
'admin-worker-platform-orders',
statuses.join(','),
keyword,
workerId,
categoryId,
workerLevelIds.join(','),
targetWorkOrderId,
acceptanceMode || '',
giftPhase || '',
page,
pageSize,
createdRange ? createdRange[0] : '',
createdRange ? createdRange[1] : '',
{
statuses: statuses.join(','),
keyword,
workerId,
categoryId,
workerLevelIds: workerLevelIds.join(','),
targetWorkOrderId,
acceptanceMode: acceptanceMode || '',
giftPhase: giftPhase || '',
page,
pageSize,
createdFrom: createdRange ? createdRange[0] : '',
createdTo: createdRange ? createdRange[1] : '',
},
],
queryFn: () =>
fetchAdminWorkOrders({
@@ -1664,7 +1657,7 @@ export default function WorkOrdersPanel() {
手动指定现货必须**常驻**:只在"检测到候选"的提醒弹窗里给入口,
订单没有任何系统候选时客服反而进不去——而那正是最需要人工指定的时候。
*/}
{canFulfillConsignment && isManuallyMatchableOrderStatus(row.status) ? (
{canFulfillConsignment && canManuallyMatchConsignmentOrder(row) ? (
<Button
icon={<ThunderboltOutlined />}
onClick={() => setManualMatchOrder(row)}
@@ -0,0 +1,231 @@
import dayjs from 'dayjs'
/**
* 后台「接单工单」列表的实时快照补丁规则。
*
* 背景:列表由 SSE 的 work_order.changed 快照原地增删(避免整页刷新导致抢单误点),
* 但补丁有两个必须同时满足的前提,否则列表会"乱跳":
* 1. 必须能读对当前列表的筛选条件。原来按数组下标读 queryKey,面板的 key 一加字段就全错——
* page 被读成了 targetWorkOrderId,于是**任何页都被当成第 1 页**(见 WorkOrdersPanel 的 key 结构);
* 2. 必须能证明这条快照确实属于当前筛选、且确实排在列表第一位,才允许插到顶部。
* 「内部寄售匹配」这类筛选原来完全没参与判定,于是普通单也会被插进寄售卖单列表。
*
* 判定分三态:
* - match:可证明命中当前筛选 → 允许原地更新,且满足排序条件时才插到顶部
* - mismatch:可证明已不命中 → 允许从列表移除
* - unknown:快照里没有足够信息复现该筛选 → 既不插入也不移除,只原地更新已存在的行,
* 等下一次真实拉取由服务端说了算
*
* 本模块刻意保持零运行时依赖(除 dayjs),因为前端单测跑在 node --experimental-strip-types 下,
* 不解析 `@/` 路径别名。
*/
export type AdminWorkOrderListFilters = {
statuses: string[]
keyword: string
workerId: number
categoryId: number
workerLevelIds: number[]
targetWorkOrderId: number
acceptanceMode: string
giftPhase: string
page: number
createdFrom: string
createdTo: string
}
export type AdminWorkOrderRealtimeVerdict = 'match' | 'mismatch' | 'unknown'
/**
* 状态筛选里的虚拟值:不是真实工单状态,而是「VIP 免审待补图」这个服务端派生筛选。
* 面板把它夹在状态列表里,同时另发 vipEvidencePending 参数,前端无法用单行快照复现。
*/
export const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function toNumber(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
/**
* 从列表 queryKey 解析筛选条件。
*
* 只认结构化 key(`['admin-worker-platform-orders', { ...筛选字段 }]`)。
* 遇到旧的下标数组返回 null,调用方放弃插入/移除、交给服务端刷新——
* 按位置猜下标正是"乱跳"的根因,这里不再留任何位置猜测。
*/
export function parseAdminWorkOrderListFilters(
queryKey: readonly unknown[],
): AdminWorkOrderListFilters | null {
const raw = queryKey[1]
if (!isRecord(raw)) return null
return {
statuses: String(raw.statuses || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
keyword: String(raw.keyword || '').trim(),
workerId: toNumber(raw.workerId),
categoryId: toNumber(raw.categoryId),
workerLevelIds: String(raw.workerLevelIds || '')
.split(',')
.map((value) => toNumber(value))
.filter(Boolean),
targetWorkOrderId: toNumber(raw.targetWorkOrderId),
acceptanceMode: String(raw.acceptanceMode || '').trim(),
giftPhase: String(raw.giftPhase || '').trim(),
page: Math.max(1, toNumber(raw.page) || 1),
createdFrom: String(raw.createdFrom || '').trim(),
createdTo: String(raw.createdTo || '').trim(),
}
}
/** 创建时间筛选:面板用「天」粒度,后端按 Asia/Shanghai 的当天边界比较。 */
function matchesCreatedRange(
snapshot: Record<string, unknown>,
createdFrom: string,
createdTo: string,
): boolean {
if (!createdFrom && !createdTo) return true
const raw = String(snapshot.createdAt || '').trim()
if (!raw) return false
const day = dayjs(raw).format('YYYY-MM-DD')
if (createdFrom && day < createdFrom) return false
if (createdTo && day > createdTo) return false
return true
}
export function evaluateAdminWorkOrderRealtimeMatch(
snapshot: unknown,
filters: AdminWorkOrderListFilters,
): AdminWorkOrderRealtimeVerdict {
if (!isRecord(snapshot)) return 'unknown'
// 先跑所有「可以证伪」的条件:任何一条不满足就是明确的 mismatch(允许从列表移除)
//
// 状态必须用 rawStatus 比:快照的 `status` 是运营态(拼单母单 open 有份额时会被显示成
// in_progress / pending_acceptance),而后台列表的状态筛选走的是原始 wo.status
// (list 没开 useOperationalStatus)。拿运营态比会误判成 mismatch,把行从列表里删掉。
const vipEvidencePendingActive = filters.statuses.includes(VIP_EVIDENCE_PENDING_STATUS)
if (filters.statuses.length > 0 && !vipEvidencePendingActive) {
const rawStatus = String(snapshot.rawStatus || snapshot.status || '')
if (!filters.statuses.includes(rawStatus)) return 'mismatch'
}
if (filters.workerId > 0) {
const assignedWorkerId = toNumber((snapshot.worker as { workerId?: unknown } | null)?.workerId)
const sharingWorkerIds = Array.isArray(snapshot.sharingWorkers)
? snapshot.sharingWorkers.map((worker) =>
toNumber((worker as { workerId?: unknown })?.workerId),
)
: []
if (assignedWorkerId !== filters.workerId && !sharingWorkerIds.includes(filters.workerId)) {
return 'mismatch'
}
}
if (filters.categoryId > 0) {
// 后端分类筛选是「category_id = X 或 category_id 属于 X 的子分类」,而快照只带自身 categoryId
// (没有 parentCategoryId),所以除了"正好等于"以外都无法证伪——可能是子分类的行。
if (toNumber(snapshot.categoryId) !== filters.categoryId) return 'unknown'
}
if (
filters.targetWorkOrderId > 0 &&
toNumber(snapshot.workOrderId) !== filters.targetWorkOrderId
) {
return 'mismatch'
}
if (!matchesCreatedRange(snapshot, filters.createdFrom, filters.createdTo)) return 'mismatch'
// 以下筛选快照里没有足够信息复现,无法证真 → 不插入新行,也不移除已有行:
// - 验收模式(内部寄售匹配 / 好友赠送 / 标准验收):判定依赖 material 里多个字段与 SQL 口径,
// 前端复刻一份就有和后端漂移的风险,宁可只原地更新;
// - 关键词:后端跨 work_order_no / platform_order_id / product_name 与多个 material JSON 路径,
// 还有平台单号、工单号的特殊匹配分支;
// - 赠送阶段:gift_ready 是"冷却到点"的实时阶段,含 NOW() 判定;
// - 打手等级:只在服务端按接单人与拼单参与者计算;
// - VIP 待补图:混在状态列表里的虚拟值,判定依赖 acceptance_json 多个字段与 NOW()。
if (filters.acceptanceMode) return 'unknown'
if (vipEvidencePendingActive) return 'unknown'
if (filters.keyword) return 'unknown'
if (filters.giftPhase) return 'unknown'
if (filters.workerLevelIds.length > 0) return 'unknown'
return 'match'
}
/**
* 是否应该把这条快照插到列表顶部。
*
* 后台列表服务端排序是 `wo.id DESC`(pinnedFirst 只用于抢单大厅),
* 所以只有「排在当前第一行之前」的新单才允许插到顶部;否则它在服务端应该排在后面甚至别的页,
* 插到顶部就是肉眼可见的乱跳。
*/
export function shouldPrependAdminWorkOrder(items: readonly unknown[], snapshot: unknown): boolean {
if (!Array.isArray(items) || items.length === 0) return true
if (!isRecord(snapshot)) return false
const snapshotId = toNumber(snapshot.workOrderId)
if (snapshotId <= 0) return false
const firstName = items[0]
if (!isRecord(firstName)) return true
return snapshotId > toNumber(firstName.workOrderId)
}
/**
* 按工单号原地增删列表行,并同步分页总数(结构与后端 `{ data: { items, pagination } }` 一致)。
* 行已存在时永远只做原地替换;只有 insert 为真、且列表里确实没有这一行时才插到顶部。
*/
export function patchAdminWorkOrderListItems(
current: unknown,
snapshot: unknown,
options: { remove: boolean; insert: boolean },
): unknown {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const items = current.data.items as unknown[]
const id = toNumber((snapshot as { workOrderId?: unknown } | null)?.workOrderId)
if (id <= 0) return current
let nextItems: unknown[]
if (options.remove) {
nextItems = items.filter(
(item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) !== id,
)
} else {
const index = items.findIndex(
(item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) === id,
)
if (index >= 0) {
nextItems = items.map((item, itemIndex) => (itemIndex === index ? snapshot : item))
} else if (options.insert) {
nextItems = [snapshot, ...items]
} else {
return current
}
}
const delta = nextItems.length - items.length
const pagination = isRecord(current.data.pagination) ? current.data.pagination : null
return {
...current,
data: {
...current.data,
items: nextItems,
...(pagination && delta !== 0
? {
pagination: {
...pagination,
total: Math.max(0, toNumber(pagination.total) + delta),
},
}
: {}),
},
}
}
@@ -33,6 +33,39 @@ export type ConsignmentWorkerDisplayInfo = {
secondaryInfo: string
}
/**
* 内部现货履约单:由客服/内部仓代交付,工单上没有接单打手。
*
* 口径必须与后端「内部寄售匹配」筛选(work-order-query-repo 的 internal_consignment 分支)一致:
* `material.consignment.inventoryId` 有值、`fulfillmentType ≠ worker_deposit`、
* `consignment.workerId` 缺失或为 0(后端按字符串 '0' 也视为无打手)、且工单未指派打手。
*/
export function isInternalConsignmentOrder(row: { material?: unknown; worker?: unknown }): boolean {
const consignment = asRecord(asRecord(row.material).consignment)
if (String(consignment.fulfillmentType || '').trim() === 'worker_deposit') return false
if (!consignment.inventoryId) return false
const rawWorkerId = consignment.workerId
const hasWorkerId =
rawWorkerId !== null &&
rawWorkerId !== undefined &&
String(rawWorkerId).trim() !== '' &&
String(rawWorkerId).trim() !== '0'
if (hasWorkerId) return false
return !row.worker
}
/** 是否寄卖(寄售)相关工单:内部现货履约与指派打手的押金履约都算。 */
export function isConsignmentOrder(row: { material?: unknown; gift?: unknown }): boolean {
const consignment = asRecord(asRecord(row.material).consignment)
const boosterFields = asRecord(asRecord(asRecord(row.gift).boosterMaterial).fields)
return Boolean(
consignment.inventoryId ||
consignment.inventoryNo ||
consignment.fulfillmentType ||
boosterFields.inventoryNo,
)
}
/** 解析寄卖库存/内部现货履约单的供货人与现货资料信息 */
export function resolveConsignmentWorkerDisplay(
order: WorkOrder,
@@ -80,3 +80,20 @@ export function filterManualMatchOptions<T>(
export function isManuallyMatchableOrderStatus(status: string): boolean {
return status === 'unassigned' || status === 'pending_material' || status === 'open'
}
/**
* 能否对这张工单「手动指定寄卖现货」= 状态允许 + 不是已有打手参与的拼单。
*
* 寄卖现货只做整单履约:后端 `fulfillWorkOrderWithConsignment` 会对拼单工单返回
* `work_order_sharing_not_consignable` 硬拦,前端按同一口径收敛入口,
* 避免"按钮点了才报错"。
*/
export function canManuallyMatchConsignmentOrder(order: {
status?: string | null
sharingProgress?: { joinedQuantity?: number | null } | null
sharingWorkers?: unknown[] | null
}): boolean {
if (!isManuallyMatchableOrderStatus(String(order.status || ''))) return false
if (Number(order.sharingProgress?.joinedQuantity || 0) > 0) return false
return !(Array.isArray(order.sharingWorkers) && order.sharingWorkers.length > 0)
}
@@ -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} 本就不该给入口`,
)
}
})