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
@@ -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(