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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user