fix(consignment): 补齐指派补偿与规格复检,封闭寄卖资金/货物不一致窗口
- 指派失败改为完整补偿:取消指派(解冻打手押金 + 工单退回大厅 + 解绑现货)→ 兜底释放占用 → 摘除工单寄售标记。此前只把现货放回在库池,一旦"指派已提交、写 material 失败",工单会停在 in_progress + 押金冻结却不带寄售记录,现货还回到可匹配池,同一件货会被第二张单占用。 - 电子凭证核销失败写 consignment_fulfill_voucher_consume_failed 事件,前端按 voucherConsume.ok 弹警告并提示人工核销,不再无条件报成功(核销走快手外部接口,无法与数据库事务合并回滚)。 - 匹配规格改为 match/mismatch/unknown 三态,钻级兜底由 || 1 改 || 0:订单规格信息不全一律不进 "精确匹配",降级 partial 并在原因里列出缺失字段,消除"任意现货都 100 分精确匹配"的错发根因。 - 履约与指派在占库前做服务端规格复检:冲突 409 consignment_spec_mismatch;非 exact 必须显式 传 allowPartial(前端在部分匹配候选上先危险确认),高配充低配保留但不可误触。 - 人工解绑守卫由"状态黑名单"收窄为只拦交付中三态(与撤销白名单对齐),修复 "工单未开始 + 现货仍绑定"没有任何人工出口的问题。 - 新增 detachConsignmentMaterialFromWorkOrderWithClient,解绑回库/人工退回复库/作废同步摘掉 工单寄售标记,消除"已回库却仍显示寄卖履约"的陈旧痕迹。 - 回归测试新增 8 例(后端 410 例 / 407 通过),附迁移 079 上库前检查脚本。
This commit is contained in:
@@ -616,7 +616,15 @@ export async function releaseConsignmentInventoryClaim(
|
||||
return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input))
|
||||
}
|
||||
|
||||
/** 仅当库存未被"进行中的工单"占用时才允许人工改状态(作废 / 退回复库) */
|
||||
/**
|
||||
* 仅当库存未被"交付中的工单"占用时才允许人工改状态(作废 / 退回复库)。
|
||||
*
|
||||
* "交付中"= 工单已指派打手且正在推进(in_progress / pending_acceptance / problem),
|
||||
* 与「撤销寄售履约」的状态白名单保持一致:这种单子只能走撤销流程,人工改库存一律拦截。
|
||||
* 其余状态(unassigned / open / pending_material / accepted / cancelled)不算占用,
|
||||
* 运营必须能人工修数据 —— 尤其是"指派事务提交后写资料失败/进程崩溃"留下的
|
||||
* 「工单未开始 + 现货仍绑定」组合,人工退回复库是唯一出口。
|
||||
*/
|
||||
const NO_LIVE_BOUND_WORK_ORDER_SQL = `
|
||||
(
|
||||
ci.bound_work_order_id IS NULL
|
||||
@@ -624,11 +632,61 @@ const NO_LIVE_BOUND_WORK_ORDER_SQL = `
|
||||
SELECT 1
|
||||
FROM work_orders wo
|
||||
WHERE wo.id = ci.bound_work_order_id
|
||||
AND wo.status NOT IN ('accepted', 'cancelled')
|
||||
AND wo.status IN ('in_progress', 'pending_acceptance', 'problem')
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
/**
|
||||
* 摘掉工单 material 上的寄售履约标记,并把它推进 consignmentHistory 保留痕迹。
|
||||
*
|
||||
* 任何"解绑库存"的路径(撤销、人工回库/作废、撤单、超时回滚、指派补偿)都必须调用它,
|
||||
* 否则工单会一直带着「寄售履约中 + 某个现货编号」的陈旧标记:列表继续展示已回库的现货,
|
||||
* 撤销按钮再次出现却必然 409。
|
||||
*
|
||||
* 传了 inventoryId 时只清理指向该现货的标记,避免误伤后续重新绑定到同一工单的新现货。
|
||||
*/
|
||||
export async function detachConsignmentMaterialFromWorkOrderWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workOrderId: number | string
|
||||
inventoryId?: number | string | null
|
||||
reason: string
|
||||
now: string
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const res = await client.query<{ id: number }>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
material_json = jsonb_set(
|
||||
material_json - 'consignment',
|
||||
'{consignmentHistory}',
|
||||
COALESCE(material_json -> 'consignmentHistory', '[]'::jsonb)
|
||||
|| jsonb_build_array(
|
||||
(material_json -> 'consignment')
|
||||
|| jsonb_build_object('detachedAt', $1::text, 'detachReason', $2::text)
|
||||
),
|
||||
true
|
||||
),
|
||||
updated_at = $1
|
||||
WHERE id = $3
|
||||
AND material_json ? 'consignment'
|
||||
AND ($4::text IS NULL OR material_json -> 'consignment' ->> 'inventoryId' = $4::text)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
input.now,
|
||||
input.reason,
|
||||
Number(input.workOrderId),
|
||||
input.inventoryId === undefined || input.inventoryId === null
|
||||
? null
|
||||
: String(input.inventoryId),
|
||||
],
|
||||
)
|
||||
return Boolean(res.rows[0])
|
||||
}
|
||||
|
||||
export async function archiveConsignmentInventoryWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
@@ -647,7 +705,19 @@ export async function archiveConsignmentInventoryWithClient(
|
||||
`,
|
||||
[now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
const archived = res.rows[0] || null
|
||||
if (!archived) return null
|
||||
|
||||
// 作废同样要摘掉工单上的寄售标记(作废后现货不会再交付)
|
||||
if (archived.bound_work_order_id) {
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId: archived.bound_work_order_id,
|
||||
inventoryId: archived.id,
|
||||
reason: `人工作废寄卖库存: ${archived.inventory_no || archived.id}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
return archived
|
||||
}
|
||||
|
||||
export async function returnConsignmentInventoryToStockWithClient(
|
||||
@@ -656,6 +726,14 @@ export async function returnConsignmentInventoryToStockWithClient(
|
||||
reason: string,
|
||||
now: string,
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
// 先锁行读出旧绑定:下面的 UPDATE 会清空 bound_work_order_id,
|
||||
// 之后就不知道要摘哪张工单的寄售标记了(会造成"已回库却仍显示寄售履约")。
|
||||
const lockedRes = await client.query<{ bound_work_order_id: number | null }>(
|
||||
'SELECT bound_work_order_id FROM consignment_inventories WHERE id = $1 FOR UPDATE',
|
||||
[Number(id)],
|
||||
)
|
||||
const previousWorkOrderId = lockedRes.rows[0]?.bound_work_order_id ?? null
|
||||
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories ci
|
||||
@@ -680,7 +758,18 @@ export async function returnConsignmentInventoryToStockWithClient(
|
||||
`,
|
||||
[reason, now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
const returned = res.rows[0] || null
|
||||
if (!returned) return null
|
||||
|
||||
if (previousWorkOrderId) {
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId: previousWorkOrderId,
|
||||
inventoryId: returned.id,
|
||||
reason: `人工退回复库: ${reason || ''}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
return returned
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -718,7 +807,18 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
|
||||
`,
|
||||
[input.now, workOrderId, input.reason || ''],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
const released = res.rows[0] || null
|
||||
if (!released) return null
|
||||
|
||||
// 现货已解绑回库,工单上的寄售标记必须同步摘掉:否则工单列表会继续显示
|
||||
// 「寄售履约: 某现货」,撤销按钮也会再次出现(点下去必然 409)。
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId,
|
||||
inventoryId: released.id,
|
||||
reason: `工单解绑回库: ${input.reason || ''}`,
|
||||
now: input.now,
|
||||
})
|
||||
return released
|
||||
}
|
||||
|
||||
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
||||
|
||||
@@ -207,6 +207,7 @@ router.post(
|
||||
workOrderId: String(req.body?.workOrderId || ''),
|
||||
inventoryId: String(req.body?.inventoryId || ''),
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
allowPartial: req.body?.allowPartial === true,
|
||||
}),
|
||||
{
|
||||
successMessage: '已使用内部寄售库存直接履约!',
|
||||
@@ -226,6 +227,7 @@ router.post(
|
||||
inventoryId: String(req.body?.inventoryId || ''),
|
||||
workerId: req.body?.workerId ? Number(req.body.workerId) : null,
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
allowPartial: req.body?.allowPartial === true,
|
||||
}),
|
||||
{
|
||||
successMessage: '已指派打手,押金已冻结,工单流转进行中!',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WORK_ORDER_STATUS, type WorkOrderStatus } from '../../domain/work-order
|
||||
import {
|
||||
type ConsignmentInventoryRow,
|
||||
claimConsignmentInventoryForWorker,
|
||||
detachConsignmentMaterialFromWorkOrderWithClient,
|
||||
getConsignmentInventoryById,
|
||||
listActiveConsignmentInventoriesForMatching,
|
||||
releaseConsignmentInventoryClaim,
|
||||
@@ -28,6 +29,7 @@ import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-service.js'
|
||||
import { unassignAdminWorkOrder } from './admin-work-order-cancellation-service.js'
|
||||
import { mapWorkOrderAdmin } from './mappers.js'
|
||||
import {
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||
@@ -35,11 +37,49 @@ import {
|
||||
} from './publish-voucher-service.js'
|
||||
import {
|
||||
type ConsignmentMatchResult,
|
||||
extractPeacekeeperSpecs,
|
||||
findMatchingPeacekeeperInventories,
|
||||
matchPeacekeeperOrderWithInventory,
|
||||
} from './consignment-matching-service.js'
|
||||
import { mapConsignmentInventory } from './consignment-admin-service.js'
|
||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||
|
||||
/**
|
||||
* 履约/指派前的服务端规格复检。
|
||||
*
|
||||
* 匹配结果此前只在前端弹窗里展示,接口本身不校验规格 —— 只要拿到 inventoryId 就能把
|
||||
* 系统/区服/系列/钻级都不对的现货发出去。这里再确认一次:
|
||||
* - 规格冲突(null)→ 直接拒绝;
|
||||
* - 非精确匹配(订单规格信息不全,或高配充低配)→ 必须显式传 allowPartial 才放行。
|
||||
*/
|
||||
function assertConsignmentSpecMatchedOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
inventory: ConsignmentInventoryRow,
|
||||
allowPartial: boolean,
|
||||
): ConsignmentMatchResult {
|
||||
const fields = resolveCollectSubmittedFieldValues(workOrder)
|
||||
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||
system: fields.system,
|
||||
serverZone: fields.serverZone,
|
||||
series: fields.series,
|
||||
})
|
||||
|
||||
const match = matchPeacekeeperOrderWithInventory(orderSpecs, inventory)
|
||||
if (!match) {
|
||||
throw createHttpError('该寄卖库存与订单规格不符(系统/区服/系列/钻级不匹配),已拒绝本次履约', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_spec_mismatch',
|
||||
})
|
||||
}
|
||||
if (match.matchType !== 'exact' && !allowPartial) {
|
||||
throw createHttpError(
|
||||
`该寄卖库存不是精确匹配(${match.reason});确认要用于本订单请勾选"允许非精确匹配"后重试`,
|
||||
{ statusCode: 409, errorCode: 'consignment_spec_partial_requires_confirm' },
|
||||
)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
|
||||
const code = getConsignmentDepositErrorCode(error)
|
||||
if (code === 'consignment_deposit_insufficient') {
|
||||
@@ -110,6 +150,8 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
workOrderId: number | string
|
||||
inventoryId: number | string
|
||||
actorName: string
|
||||
/** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */
|
||||
allowPartial?: boolean
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||
@@ -147,6 +189,9 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 服务端规格复检:不能只依赖前端弹窗展示的匹配结果
|
||||
assertConsignmentSpecMatchedOrder(workOrder, inventory, input.allowPartial === true)
|
||||
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
const source = safeParseJson(material.source)
|
||||
const shopName = String(source.shopName || source.shopTitle || '').trim()
|
||||
@@ -245,7 +290,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
return { updatedOrder: wo, updatedInventory: inv }
|
||||
})
|
||||
|
||||
// 顺带核销快手电子凭证
|
||||
// 顺带核销快手电子凭证。
|
||||
// 核销要走快手外部接口、无法和上面的数据库事务合并回滚,因此这里只能做两件事:
|
||||
// 1) 失败时不吞错:写事件留痕,并把 voucherConsume.ok=false 交给前端提示运营人工核销;
|
||||
// 2) 绝不因为"凭证没核销"就谎报成功(此前前端无条件弹成功提示,属于假成功)。
|
||||
let voucherConsume: WorkOrderVoucherConsumeResult
|
||||
try {
|
||||
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder)
|
||||
@@ -260,6 +308,22 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
|
||||
}
|
||||
}
|
||||
if (!voucherConsume.ok) {
|
||||
try {
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(updatedOrder.id),
|
||||
actorType: 'system',
|
||||
actorId: 'kuaishou_send_code',
|
||||
eventType: 'consignment_fulfill_voucher_consume_failed',
|
||||
fromStatus: String(updatedOrder.status),
|
||||
toStatus: String(updatedOrder.status),
|
||||
payloadJson: JSON.stringify(voucherConsume),
|
||||
now: nowIso(),
|
||||
})
|
||||
} catch {
|
||||
// 事件写入失败不影响履约结果,仍把 voucherConsume 返回给前端
|
||||
}
|
||||
}
|
||||
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
|
||||
|
||||
@@ -278,6 +342,8 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
inventoryId: number | string
|
||||
workerId?: number | string | null
|
||||
actorName: string
|
||||
/** 订单规格信息不全或高配充低配时,必须由运营显式确认才放行 */
|
||||
allowPartial?: boolean
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||
@@ -303,6 +369,9 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 服务端规格复检:不能只依赖前端弹窗展示的匹配结果
|
||||
assertConsignmentSpecMatchedOrder(workOrder, inventory, input.allowPartial === true)
|
||||
|
||||
const targetWorkerId = input.workerId || inventory.worker_id
|
||||
if (!targetWorkerId) {
|
||||
throw createHttpError('该寄卖物品未关联打手,请在指派弹窗中选择打手账号', {
|
||||
@@ -402,20 +471,74 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
voucherConsume: assignResult.voucherConsume,
|
||||
}
|
||||
} catch (error) {
|
||||
// 指派/写资料失败:立刻把现货放回在库池,避免库存被长期占用而无法再匹配。
|
||||
// 回滚失败不能掩盖原始错误,仅在库存备注中保留痕迹。
|
||||
try {
|
||||
await releaseConsignmentInventoryClaim(inventory.id, {
|
||||
now: nowIso(),
|
||||
reason: '指派打手失败自动回滚',
|
||||
})
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
// 指派/写资料失败:必须把这次操作的全部效果撤干净,不能只还库存。
|
||||
// 否则一旦「指派事务已提交、写 material 失败」,工单会停在 in_progress + 押金已冻结,
|
||||
// 而系统并不认为它挂着寄售现货 —— 现货还回到了可匹配池,同一件货会被第二张单占用。
|
||||
await compensateFailedConsignmentAssignment({
|
||||
workOrderId: workOrder.id,
|
||||
inventoryId: inventory.id,
|
||||
actorName: input.actorName,
|
||||
reason: error instanceof Error ? error.message : '指派打手失败',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原一次失败的「选用寄售库存 + 指派打手」操作。
|
||||
*
|
||||
* 每一步都是独立 try:补偿本身失败不能掩盖原始错误,但要在库存备注/工单 material 里留痕,
|
||||
* 方便运营复盘(现货回不去时会带上备注说明)。
|
||||
*/
|
||||
async function compensateFailedConsignmentAssignment(input: {
|
||||
workOrderId: number | string
|
||||
inventoryId: number | string
|
||||
actorName: string
|
||||
reason: string
|
||||
}): Promise<void> {
|
||||
const detachedReason = `指派打手失败自动回滚: ${input.reason}`
|
||||
|
||||
// 1) 指派事务若已提交(工单已进入进行中且已指派打手),走管理端「取消指派」:
|
||||
// 解冻打手押金、工单退回大厅,并顺带解绑这次占用的现货。
|
||||
try {
|
||||
const latest = await getWorkOrderById(input.workOrderId)
|
||||
if (
|
||||
latest &&
|
||||
latest.assigned_worker_id &&
|
||||
String(latest.status) === WORK_ORDER_STATUS.IN_PROGRESS
|
||||
) {
|
||||
await unassignAdminWorkOrder(input.workOrderId, input.actorName)
|
||||
}
|
||||
} catch {
|
||||
// 忽略:下一步的库存兜底仍会执行
|
||||
}
|
||||
|
||||
// 2) 兜底把现货放回在库池(指派未提交时的主路径;已解绑时是空操作)
|
||||
try {
|
||||
await releaseConsignmentInventoryClaim(input.inventoryId, {
|
||||
now: nowIso(),
|
||||
reason: detachedReason,
|
||||
})
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
|
||||
// 3) 摘掉工单上的寄售标记:避免留下"寄售履约中"的陈旧痕迹,
|
||||
// 也避免重新指派时把上一件现货的信息带给新的打手。
|
||||
try {
|
||||
await withTransaction((client: PoolClient) =>
|
||||
detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
inventoryId: input.inventoryId,
|
||||
reason: detachedReason,
|
||||
now: nowIso(),
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许「撤销寄售履约」的工单状态白名单。
|
||||
*
|
||||
|
||||
@@ -240,3 +240,49 @@ test('findMatchingPeacekeeperInventories matches 苹果微信暗潮1钻 to 苹
|
||||
assert.equal(matches[0]!.matchType, 'exact')
|
||||
assert.equal(matches[1]!.matchType, 'exact')
|
||||
})
|
||||
|
||||
test('订单规格信息不全时绝不判为"精确匹配",钻级不再兜底为 1', () => {
|
||||
// 商品名只写"苏尔南":系统/区服/系列/钻级全部缺失
|
||||
const vague = extractPeacekeeperSpecs('苏尔南')
|
||||
assert.equal(vague.diamondLevel, 0, '解析不到钻级必须记为 0,不能兜底成 1')
|
||||
|
||||
const inv = buildInventory({ diamond_level: 1, brilliance_count: 0 })
|
||||
const matched = matchPeacekeeperOrderWithInventory(vague, inv)
|
||||
assert.ok(matched, '信息不全的订单仍应给出候选,但只能是部分匹配')
|
||||
assert.equal(matched.matchType, 'partial')
|
||||
assert.ok(matched.reason.includes('订单规格信息不全'))
|
||||
assert.ok(matched.reason.includes('系统'))
|
||||
assert.ok(matched.reason.includes('区服'))
|
||||
assert.ok(matched.reason.includes('系列'))
|
||||
assert.ok(matched.reason.includes('钻级'))
|
||||
})
|
||||
|
||||
test('订单缺钻级但其余规格完整时,也不得判为精确匹配', () => {
|
||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮')
|
||||
assert.equal(order.diamondLevel, 0)
|
||||
|
||||
const matched = matchPeacekeeperOrderWithInventory(order, buildInventory({ diamond_level: 1 }))
|
||||
assert.ok(matched)
|
||||
assert.equal(matched.matchType, 'partial')
|
||||
assert.ok(matched.reason.includes('钻级'))
|
||||
})
|
||||
|
||||
test('库存侧缺系统/区服时不能算精确匹配(无法确认一致)', () => {
|
||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
|
||||
const inv = buildInventory({ system: '', server_zone: '', series: '暗潮', diamond_level: 1 })
|
||||
|
||||
const matched = matchPeacekeeperOrderWithInventory(order, inv)
|
||||
assert.ok(matched)
|
||||
assert.equal(matched.matchType, 'partial')
|
||||
assert.ok(matched.reason.includes('系统'))
|
||||
assert.ok(matched.reason.includes('区服'))
|
||||
})
|
||||
|
||||
test('通用件与明确规格冲突时仍然排除', () => {
|
||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
|
||||
const wrongSystem = buildInventory({ system: '苹果' })
|
||||
assert.equal(matchPeacekeeperOrderWithInventory(order, wrongSystem), null)
|
||||
|
||||
const wrongZone = buildInventory({ server_zone: 'QQ区' })
|
||||
assert.equal(matchPeacekeeperOrderWithInventory(order, wrongZone), null)
|
||||
})
|
||||
|
||||
@@ -116,15 +116,29 @@ export function extractPeacekeeperSpecs(
|
||||
system: fromText.system || fromMeta.system,
|
||||
serverZone: fromText.serverZone || fromMeta.serverZone,
|
||||
series: fromText.series || fromMeta.series,
|
||||
diamondLevel: fromText.diamondLevel || fromMeta.diamondLevel || extraMeta?.diamondLevel || 1,
|
||||
// 解析不到钻级时记为 0(未知),绝不兜底成 1:
|
||||
// 兜底成 1 会让"没写钻级"的订单把任意 1 钻库存判成"精确匹配",属于错发根因。
|
||||
diamondLevel: fromText.diamondLevel || fromMeta.diamondLevel || extraMeta?.diamondLevel || 0,
|
||||
brillianceCount: fromText.brillianceCount,
|
||||
rawText,
|
||||
}
|
||||
}
|
||||
|
||||
function sameSpec(orderValue: string, inventoryValue: string): boolean {
|
||||
if (!orderValue || !inventoryValue || inventoryValue === '通用') return true
|
||||
return orderValue === inventoryValue
|
||||
/**
|
||||
* 单项规格比对结论:
|
||||
* - `match` 双方都有值且一致(或库存侧是"通用"件)
|
||||
* - `mismatch` 双方都有值且冲突 → 直接排除该库存
|
||||
* - `unknown` 任一侧没值,无法确认 → **不能算精确匹配**,只能作为"部分匹配"候选
|
||||
*/
|
||||
type SpecVerdict = 'match' | 'mismatch' | 'unknown'
|
||||
|
||||
function compareSpec(orderValue: string, inventoryValue: string): SpecVerdict {
|
||||
const order = String(orderValue || '').trim()
|
||||
const inventory = String(inventoryValue || '').trim()
|
||||
if (!inventory) return 'unknown'
|
||||
if (inventory === '通用') return 'match'
|
||||
if (!order) return 'unknown'
|
||||
return order === inventory ? 'match' : 'mismatch'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,24 +157,35 @@ export function matchPeacekeeperOrderWithInventory(
|
||||
const invZone = normalizePeacekeeperServerZone(inventory.server_zone)
|
||||
const invSeries = normalizePeacekeeperSeries(inventory.series)
|
||||
|
||||
if (!sameSpec(order.system, invSystem)) return null
|
||||
if (!sameSpec(order.serverZone, invZone)) return null
|
||||
|
||||
// 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)
|
||||
if (order.series && invSeries && order.series !== invSeries) {
|
||||
return null
|
||||
}
|
||||
const verdictSystem = compareSpec(order.system, invSystem)
|
||||
if (verdictSystem === 'mismatch') return null
|
||||
const verdictZone = compareSpec(order.serverZone, invZone)
|
||||
if (verdictZone === 'mismatch') return null
|
||||
const verdictSeries = compareSpec(order.series, invSeries)
|
||||
if (verdictSeries === 'mismatch') return null
|
||||
|
||||
const invDiamond = Number(inventory.diamond_level || 1)
|
||||
const invBrilliance = Number(inventory.brilliance_count || 0)
|
||||
const orderDiamond = Number(order.diamondLevel || 0)
|
||||
const orderBrilliance = Number(order.brillianceCount || 0)
|
||||
|
||||
// 低配不能充高配:若库存钻级低于订单要求,或库存璀璨低于订单要求,直接不匹配
|
||||
if (invDiamond < order.diamondLevel || invBrilliance < order.brillianceCount) {
|
||||
return null
|
||||
}
|
||||
// 低配不能充高配:订单明确要求了钻级/璀璨时,库存低于要求直接不匹配。
|
||||
if (orderDiamond > 0 && invDiamond < orderDiamond) return null
|
||||
if (invBrilliance < orderBrilliance) return null
|
||||
|
||||
// 1. 全部匹配(Exact Match)
|
||||
if (invDiamond === order.diamondLevel && invBrilliance === order.brillianceCount) {
|
||||
// 订单侧没写清楚的字段:这些项无法确认为"一致",因此不能给精确匹配。
|
||||
const unknownFields: string[] = []
|
||||
if (verdictSystem === 'unknown') unknownFields.push('系统')
|
||||
if (verdictZone === 'unknown') unknownFields.push('区服')
|
||||
if (verdictSeries === 'unknown') unknownFields.push('系列')
|
||||
if (orderDiamond <= 0) unknownFields.push('钻级')
|
||||
|
||||
// 1. 全部匹配(Exact Match):所有关键规格都明确且一致
|
||||
if (
|
||||
unknownFields.length === 0 &&
|
||||
invDiamond === orderDiamond &&
|
||||
invBrilliance === orderBrilliance
|
||||
) {
|
||||
return {
|
||||
inventory,
|
||||
matchType: 'exact',
|
||||
@@ -172,23 +197,28 @@ export function matchPeacekeeperOrderWithInventory(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 部分匹配(Partial / Superset Match):高配充低配
|
||||
// 2. 部分匹配(Partial / Superset Match):高配充低配,或订单规格信息不全
|
||||
const reasons: string[] = []
|
||||
if (invDiamond > order.diamondLevel) {
|
||||
reasons.push(`钻级更高(${invDiamond}钻充${order.diamondLevel}钻)`)
|
||||
if (unknownFields.length > 0) {
|
||||
reasons.push(`订单规格信息不全(未写: ${unknownFields.join('、')}),需人工确认`)
|
||||
}
|
||||
if (invBrilliance > order.brillianceCount) {
|
||||
reasons.push(`多出【${invBrilliance - order.brillianceCount}璀璨】`)
|
||||
if (orderDiamond > 0 && invDiamond > orderDiamond) {
|
||||
reasons.push(`钻级更高(${invDiamond}钻充${orderDiamond}钻)`)
|
||||
}
|
||||
if (invBrilliance > orderBrilliance) {
|
||||
reasons.push(`多出【${invBrilliance - orderBrilliance}璀璨】`)
|
||||
}
|
||||
|
||||
const strictlyHigher = orderDiamond > 0 && invDiamond > orderDiamond
|
||||
|
||||
return {
|
||||
inventory,
|
||||
matchType: 'partial',
|
||||
score: invDiamond > order.diamondLevel ? 70 : 80,
|
||||
score: strictlyHigher ? 70 : 80,
|
||||
matchedSeries: invSeries || order.series,
|
||||
matchedDiamond: invDiamond,
|
||||
matchedBrilliance: invBrilliance,
|
||||
reason: `属于高配充低配:${reasons.join(',')}`,
|
||||
reason: reasons.length > 0 ? `需人工确认:${reasons.join(',')}` : '属于高配充低配',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开
|
||||
assert.ok(!sql.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('人工作废与退回复库在现货仍被进行中工单占用时不改状态', async () => {
|
||||
test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
@@ -184,14 +184,91 @@ test('人工作废与退回复库在现货仍被进行中工单占用时不改
|
||||
'2026-09-15T00:00:00.000Z',
|
||||
)
|
||||
|
||||
for (const sql of statements) {
|
||||
const inventoryUpdates = statements.filter((sql) =>
|
||||
sql.includes('UPDATE consignment_inventories'),
|
||||
)
|
||||
assert.equal(inventoryUpdates.length, 2)
|
||||
|
||||
for (const sql of inventoryUpdates) {
|
||||
// 只拦"交付中"的工单:与撤销履约白名单一致
|
||||
assert.ok(
|
||||
sql.includes('FROM work_orders wo') && sql.includes("NOT IN ('accepted', 'cancelled')"),
|
||||
`缺少进行中工单占用保护:${sql}`,
|
||||
sql.includes('FROM work_orders wo') &&
|
||||
sql.includes("IN ('in_progress', 'pending_acceptance', 'problem')"),
|
||||
`缺少交付中工单占用保护:${sql}`,
|
||||
)
|
||||
// 已验收/已取消/未指派不得算占用:否则"指派提交后写资料失败/进程崩溃"留下的
|
||||
// 「工单未开始 + 现货仍绑定」组合就没有人工修复出口。
|
||||
assert.ok(!sql.includes("NOT IN ('accepted', 'cancelled')"), `不该再按状态黑名单拦截:${sql}`)
|
||||
}
|
||||
// 退回复库不得再强制打开匹配开关(避免覆盖运营手动暂停)
|
||||
assert.ok(!statements[1]!.includes('allow_matching'))
|
||||
assert.ok(!inventoryUpdates[1]!.includes('allow_matching'))
|
||||
})
|
||||
|
||||
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 bound_work_order_id')) {
|
||||
return { rows: [{ bound_work_order_id: 100154 }] }
|
||||
}
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 9,
|
||||
inventory_no: 'INV009',
|
||||
status: 'in_stock',
|
||||
bound_work_order_id: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { rows: [{ id: 100154 }] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await returnConsignmentInventoryToStockWithClient(
|
||||
client,
|
||||
9,
|
||||
'买家退货',
|
||||
'2026-09-15T00:00:00.000Z',
|
||||
)
|
||||
|
||||
// 1) 先加锁读旧绑定(UPDATE 之后 bound_work_order_id 就被清空了)
|
||||
assert.ok(statements[0]!.sql.includes('SELECT bound_work_order_id'))
|
||||
assert.ok(statements[0]!.sql.includes('FOR UPDATE'))
|
||||
// 2) 再回库
|
||||
assert.ok(statements[1]!.sql.includes('UPDATE consignment_inventories'))
|
||||
// 3) 最后摘掉该工单的寄售标记,并推入 consignmentHistory 留痕
|
||||
const detach = statements[2]!
|
||||
assert.ok(detach.sql.includes('UPDATE work_orders'))
|
||||
assert.ok(detach.sql.includes("material_json - 'consignment'"))
|
||||
assert.ok(detach.sql.includes('consignmentHistory'))
|
||||
assert.deepEqual(detach.values.slice(2), [100154, '9'])
|
||||
})
|
||||
|
||||
test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
const statements: { sql: string; values: unknown[] }[] = []
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return { rows: [{ id: 9, inventory_no: 'INV009', status: 'in_stock' }] }
|
||||
}
|
||||
return { rows: [{ id: 100154 }] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 100154, {
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
assert.equal(statements.length, 2)
|
||||
assert.ok(statements[1]!.sql.includes('UPDATE work_orders'))
|
||||
assert.ok(statements[1]!.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(statements[1]!.values.slice(2), [100154, '9'])
|
||||
})
|
||||
|
||||
test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => {
|
||||
@@ -1905,3 +1982,48 @@ test('打手查询条件支持提现黑名单筛选', () => {
|
||||
assert.equal(allWhere.whereClause, '')
|
||||
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',
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
src.includes('await compensateFailedConsignmentAssignment('),
|
||||
'指派失败不能再只调 releaseConsignmentInventoryClaim',
|
||||
)
|
||||
|
||||
const body = src.slice(src.indexOf('function compensateFailedConsignmentAssignment('))
|
||||
assert.ok(body.includes('unassignAdminWorkOrder('), '补偿必须撤销已提交的指派并解冻押金')
|
||||
assert.ok(body.includes('releaseConsignmentInventoryClaim('), '补偿必须把现货放回在库池')
|
||||
assert.ok(
|
||||
body.includes('detachConsignmentMaterialFromWorkOrderWithClient('),
|
||||
'补偿必须摘掉工单上的寄售标记,避免留下"寄售履约中"的陈旧痕迹',
|
||||
)
|
||||
})
|
||||
|
||||
test('履约与指派两条路径都要过服务端规格复检(不只依赖前端弹窗)', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const src = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const callCount = src.split('assertConsignmentSpecMatchedOrder(workOrder, inventory').length - 1
|
||||
assert.equal(callCount, 2, '内部履约与指派打手都必须复检规格')
|
||||
assert.ok(src.includes('consignment_spec_mismatch'))
|
||||
assert.ok(src.includes('consignment_spec_partial_requires_confirm'))
|
||||
})
|
||||
|
||||
test('内部履约的电子凭证核销失败要写事件留痕(不能静默假成功)', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const src = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
|
||||
assert.ok(src.includes('if (!voucherConsume.ok)'))
|
||||
})
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
checkWorkOrderConsignmentMatches,
|
||||
fulfillWorkOrderWithConsignment,
|
||||
revokeWorkOrderConsignmentFulfillment,
|
||||
type WorkOrderVoucherConsumeResult,
|
||||
} from '@/services/admin/consignment'
|
||||
import type { WorkOrderInventoryMatchCheck } from '@/types/consignment'
|
||||
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||
@@ -728,15 +729,53 @@ export default function WorkOrdersPanel() {
|
||||
confirmPublishWorkOrder(row)
|
||||
}
|
||||
|
||||
async function handleFulfillWithConsignment(inventoryId: number) {
|
||||
/**
|
||||
* 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗,
|
||||
* 之前这里无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。
|
||||
*/
|
||||
function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) {
|
||||
if (!voucherConsume || voucherConsume.ok) {
|
||||
if (voucherConsume?.alreadyConsumedCount) {
|
||||
message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`)
|
||||
}
|
||||
return
|
||||
}
|
||||
message.warning({
|
||||
content: `状态已变更,但电子凭证核销失败:${
|
||||
voucherConsume.errorMessage || '未知错误'
|
||||
}。请在快手后台人工核销后再发货`,
|
||||
duration: 8,
|
||||
})
|
||||
}
|
||||
|
||||
/** 非精确匹配(订单规格信息不全 / 高配充低配)必须人工确认,后端也会二次拦截 */
|
||||
function confirmPartialMatch(matchType: string | undefined, onOk: () => void) {
|
||||
if (matchType !== 'exact') {
|
||||
modal.confirm({
|
||||
title: '该现货不是精确匹配,确认继续?',
|
||||
content:
|
||||
'订单规格信息不全或这件现货高于订单要求(高配充低配)。继续履约后无法自动回退,请确认商品与买家订单一致。',
|
||||
okText: '确认履约',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '再想想',
|
||||
onOk,
|
||||
})
|
||||
return
|
||||
}
|
||||
onOk()
|
||||
}
|
||||
|
||||
async function handleFulfillWithConsignment(inventoryId: number, allowPartial = false) {
|
||||
if (!consignmentInterceptModal.order) return
|
||||
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
|
||||
try {
|
||||
await fulfillWorkOrderWithConsignment({
|
||||
const res = await fulfillWorkOrderWithConsignment({
|
||||
workOrderId: consignmentInterceptModal.order.workOrderId,
|
||||
inventoryId,
|
||||
allowPartial,
|
||||
})
|
||||
message.success('已占用内部库存,跳过打手资料,进入赠送冷静期')
|
||||
notifyVoucherConsume(res?.data?.voucherConsume)
|
||||
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
|
||||
await refreshAll()
|
||||
} catch (err: any) {
|
||||
@@ -745,16 +784,22 @@ export default function WorkOrdersPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignWithConsignmentWorker(inventoryId: number, workerId?: number | null) {
|
||||
async function handleAssignWithConsignmentWorker(
|
||||
inventoryId: number,
|
||||
workerId?: number | null,
|
||||
allowPartial = false,
|
||||
) {
|
||||
if (!consignmentInterceptModal.order) return
|
||||
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
|
||||
try {
|
||||
await assignWorkOrderWithConsignmentWorker({
|
||||
const res = await assignWorkOrderWithConsignmentWorker({
|
||||
workOrderId: consignmentInterceptModal.order.workOrderId,
|
||||
inventoryId,
|
||||
workerId,
|
||||
allowPartial,
|
||||
})
|
||||
message.success('已通过接单平台指派打手!系统已自动冻结履约押金,等待打手交货核销')
|
||||
notifyVoucherConsume(res?.data?.voucherConsume)
|
||||
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
|
||||
await refreshAll()
|
||||
} catch (err: any) {
|
||||
@@ -2973,7 +3018,11 @@ export default function WorkOrdersPanel() {
|
||||
type="primary"
|
||||
style={{ backgroundColor: '#722ed1', borderColor: '#722ed1' }}
|
||||
loading={consignmentInterceptModal.submitting}
|
||||
onClick={() => handleAssignWithConsignmentWorker(inv.id, inv.workerId)}
|
||||
onClick={() =>
|
||||
confirmPartialMatch(m.matchType, () => {
|
||||
void handleAssignWithConsignmentWorker(inv.id, inv.workerId, !isExact)
|
||||
})
|
||||
}
|
||||
>
|
||||
走接单平台指派打手(冻结押金交付)
|
||||
</Button>
|
||||
@@ -2981,7 +3030,11 @@ export default function WorkOrdersPanel() {
|
||||
<Button
|
||||
type={inv.workerId ? 'default' : 'primary'}
|
||||
loading={consignmentInterceptModal.submitting}
|
||||
onClick={() => handleFulfillWithConsignment(inv.id)}
|
||||
onClick={() =>
|
||||
confirmPartialMatch(m.matchType, () => {
|
||||
void handleFulfillWithConsignment(inv.id, !isExact)
|
||||
})
|
||||
}
|
||||
>
|
||||
内部现货履约
|
||||
</Button>
|
||||
|
||||
@@ -104,25 +104,42 @@ export function checkWorkOrderConsignmentMatches(workOrderId: number | string) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */
|
||||
export type WorkOrderVoucherConsumeResult = {
|
||||
ok: boolean
|
||||
voucherCount: number
|
||||
consumedCount: number
|
||||
alreadyConsumedCount: number
|
||||
failedCount: number
|
||||
voucherCodes: string[]
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function fulfillWorkOrderWithConsignment(payload: {
|
||||
workOrderId: number | string
|
||||
inventoryId: number | string
|
||||
/** 订单规格信息不全或高配充低配时,必须显式确认才放行(后端会二次校验) */
|
||||
allowPartial?: boolean
|
||||
}) {
|
||||
return apiPost<{ workOrder: unknown; inventory: ConsignmentInventory }>(
|
||||
'/api/v1/admin/worker-platform/consignment/fulfill',
|
||||
payload,
|
||||
)
|
||||
return apiPost<{
|
||||
workOrder: unknown
|
||||
inventory: ConsignmentInventory
|
||||
voucherConsume?: WorkOrderVoucherConsumeResult
|
||||
}>('/api/v1/admin/worker-platform/consignment/fulfill', payload)
|
||||
}
|
||||
|
||||
export function assignWorkOrderWithConsignmentWorker(payload: {
|
||||
workOrderId: number | string
|
||||
inventoryId: number | string
|
||||
workerId?: number | null
|
||||
/** 订单规格信息不全或高配充低配时,必须显式确认才放行(后端会二次校验) */
|
||||
allowPartial?: boolean
|
||||
}) {
|
||||
return apiPost<{ workOrder: unknown; inventory: ConsignmentInventory }>(
|
||||
'/api/v1/admin/worker-platform/consignment/assign-worker',
|
||||
payload,
|
||||
)
|
||||
return apiPost<{
|
||||
workOrder: unknown
|
||||
inventory: ConsignmentInventory
|
||||
voucherConsume?: WorkOrderVoucherConsumeResult
|
||||
}>('/api/v1/admin/worker-platform/consignment/assign-worker', payload)
|
||||
}
|
||||
|
||||
export function revokeWorkOrderConsignmentFulfillment(payload: {
|
||||
|
||||
+40
-11
@@ -18,7 +18,8 @@
|
||||
| `fd1eb581` | 内部现货占库进入赠送冷静期;发布押金冻结/驳回退还/验收释放闭环 | `consignment-fulfillment-service`、`work-order-gift-repo`、结算联动 |
|
||||
| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — |
|
||||
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
|
||||
| *(工作区未提交)* | 本轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
||||
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
||||
| *(工作区未提交)* | 本轮 · 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
|
||||
|
||||
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
|
||||
当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。
|
||||
@@ -70,6 +71,19 @@
|
||||
| 台账幂等键(P2-3) | 同一提报单的发布冻结加部分唯一索引 | `079` |
|
||||
| 回归测试 | 新增 5 例:占库绑定断言、撤销状态白名单、解绑范围、作废/回库守卫、终态链路接线计数 | `worker-platform-service.test.ts` |
|
||||
|
||||
### ✅ 本轮已落地(批次 C+D,接续上表)
|
||||
|
||||
| 项 | 内容 | 落点 |
|
||||
| :--- | :--- | :--- |
|
||||
| 指派部分成功补偿(P1-2) | `compensateFailedConsignmentAssignment`:取消指派(解冻押金 + 工单回大厅 + 解绑现货)→ 兜底释放占用 → 摘除工单寄售标记 | `consignment-fulfillment-service.ts` |
|
||||
| 凭证核销如实反馈(P1-5) | 失败写 `consignment_fulfill_voucher_consume_failed` 事件;前端按 `voucherConsume.ok` 弹警告并提示人工核销 | `consignment-fulfillment-service.ts`、`WorkOrdersPanel.tsx`、`services/admin/consignment.ts` |
|
||||
| 空规格不放行(P1-6) | `compareSpec` 三态判定 + 钻级不再兜底 1;未知项一律不进 exact,`reason` 列出缺失字段 | `consignment-matching-service.ts` |
|
||||
| 履约/指派服务端复检 | `assertConsignmentSpecMatchedOrder`:冲突 409,非 exact 需显式 `allowPartial`;前端危险确认后放行 | `consignment-fulfillment-service.ts`、履约/指派路由、`WorkOrdersPanel.tsx` |
|
||||
| 人工解绑守卫收窄 | `NO_LIVE_BOUND_WORK_ORDER_SQL` 由"状态黑名单"改为只拦交付中三态,与撤销白名单对齐 | `consignment-repo.ts` |
|
||||
| 陈旧寄售标记清理 | 新增 `detachConsignmentMaterialFromWorkOrderWithClient`,接入解绑回库、人工退回复库、作废三处 | `consignment-repo.ts` |
|
||||
| 迁移 079 顺序修复 | 先删旧 `source_type` CHECK 再回填,全新空库 `001→079` 已验证跑通 | `079_drop_consignment_collect_links.sql` |
|
||||
| 回归测试 | 新增 8 例:匹配三态/空规格/库存侧缺字段/通用件冲突 + 补偿接线 + 复检接线 + 凭证事件 + 摘标记 SQL(含旧绑定先锁行) | 两个测试文件 |
|
||||
|
||||
**一处刻意偏离原始批次 B 表述**:原始方案写"所有回库路径恢复 `allow_matching`",实际实现为**保留运营的开关设置**(`e828e677` 已按此方向改过撤销路径)。理由:该开关是运营"暂停匹配"的闸门,自动回库替运营打开会静默解除风控;两条路径(撤销回库、退回复库)现在语义一致,UI 文案也已同步更正(不再宣称"重新进入待匹配池")。若业务坚持"回库即上架",改一处常量即可。
|
||||
|
||||
### P0 · 直接导致功能不可用或重复出货
|
||||
@@ -233,16 +247,31 @@
|
||||
4. ✅ 回归测试 5 例(占库绑定、状态白名单、解绑范围、作废/回库守卫、终态链路接线)。
|
||||
5. 验收偏差:原始表述"回库路径一律恢复 `allow_matching`"改为"保留运营开关设置",理由见第三节"刻意偏离"。
|
||||
|
||||
### 批次 C · 指派事务化、资金/凭证一致性(P1)—— 下一批
|
||||
1. claim + assign + material/reward + event 合并事务,失败改补偿式撤销(P1-2)。
|
||||
2. 凭证核销并入状态变更事务或显式失败(P1-5)。
|
||||
3. 押金没收路径(P2-2 的 `deposit_deduction` 死分支):补"卖家违约扣没发布押金"或移除该死分支。
|
||||
4. 编辑接口的 `depositAmountFen` 与台账一致性校验(审核改额的剩余风险)。
|
||||
### 批次 C · 指派事务化、资金/凭证一致性(P1)—— ✅ 已完成(本轮)
|
||||
1. ✅ **指派"部分成功"窗口(P1-2)**:指派失败不再只把现货放回在库池,改为 `compensateFailedConsignmentAssignment` 三步补偿 ——
|
||||
① 指派事务若已提交(工单 `in_progress` + 已指派打手)走管理端「取消指派」:解冻打手押金、工单退回大厅,并顺带解绑现货;
|
||||
② 兜底释放库存占用(指派未提交时的主路径);
|
||||
③ 摘掉工单上的 `material.consignment` 并推入 `consignmentHistory`。
|
||||
每步独立 try:补偿失败不掩盖原始错误,但会留下备注/解绑痕迹。
|
||||
2. ✅ **凭证核销不再假成功(P1-5)**:核销需调用快手外部接口,无法与数据库事务合并回滚,因此改为"显式失败 + 留痕 + 如实反馈"——
|
||||
核销失败写事件 `consignment_fulfill_voucher_consume_failed`(对齐既有发布链路 `publish_voucher_consume_failed` 口径),
|
||||
返回 `voucherConsume.ok=false`;前端 `notifyVoucherConsume` 改为 `message.warning`(含失败原因与"请人工核销"),不再无条件弹成功。
|
||||
指派链路的凭证失败本来就由 `assignAdminWorkOrderToWorker` 抛 409 + 写 `assign_voucher_consume_failed`(失败即回滚,不产生部分状态)。
|
||||
3. ⏳ 押金没收(P2-2 死分支)与编辑接口改额的台账一致性校验仍未做:前者需要业务先定"卖家违约扣没"规则。
|
||||
4. **意外收获(必修)**:迁移 `079` 的语句顺序有 bug —— 旧 `source_type` CHECK 只允许 `manual/collection_link/recycle`,
|
||||
而回填 `worker_portal` 写在删旧约束之前,导致**任何全新库在 079 必然失败**(本地 dev 库就是这样启动失败的)。
|
||||
已改为"先删旧约束 → 回填 → 挂新约束",并在**全新空库**验证 `001 → 079` 一次性跑通(79 条迁移全部记录)。
|
||||
因为 079 尚未发布(所在提交仅本地未推送),修复按仓库 `check:sql` 守卫要求合入引入它的那个提交,而不是新增补救迁移。
|
||||
|
||||
### 批次 D · 匹配准确性与防错发(P1)
|
||||
1. 空规格不放行 + 钻级不再兜底 1(P1-6)。
|
||||
2. 履约事务内复检规格,非 exact 一律 409。
|
||||
3. 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)。
|
||||
### 批次 D · 匹配准确性与防错发(P1)—— ✅ 已完成(本轮)
|
||||
1. ✅ **空规格不再放行、钻级不再兜底 1(P1-6)**:`sameSpec`(任一侧为空即视为相等)改为 `compareSpec` 三态判定
|
||||
(`match` / `mismatch` / `unknown`),`extractPeacekeeperSpecs` 的钻级兜底由 `|| 1` 改为 `|| 0`(未知)。
|
||||
规则:系统/区服/系列冲突 → 直接排除;订单侧没写清楚的字段计入 `unknownFields`,**只要有未知项就绝不判"精确匹配"**,
|
||||
降级为 `partial` 并在 `reason` 里列出缺失项(如"订单规格信息不全(未写: 系统、区服、钻级),需人工确认")。
|
||||
2. ✅ **履约/指派服务端复检**:新增 `assertConsignmentSpecMatchedOrder`,两条路径(内部现货履约、指派打手)在占库前都要过:
|
||||
规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。
|
||||
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
||||
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
||||
|
||||
### 批次 E · 权限门控与运营体验(P1/P2)
|
||||
1. 前端四权限门控(P1-8 剩余部分:库存/提报面板尚无 `consignment.view/manage/review` 门控);打手端菜单收口(P1-9 剩余)。
|
||||
@@ -256,4 +285,4 @@
|
||||
1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。
|
||||
2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。
|
||||
3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。
|
||||
4. **批次顺序**:✅ 定案 —— 先 A(入口可用)→ 再 B(资金安全);两者本轮均已落地,C/D/E 待排期。
|
||||
4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)均已落地;E(权限门控与运营体验)为下一批。
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
-- 迁移 079 上库前 / 上库后检查脚本(只读,可直接在生产库执行)
|
||||
--
|
||||
-- 迁移文件:apps/backend/src/db/migrations/079_drop_consignment_collect_links.sql
|
||||
-- 已在全新空库验证:001 → 079 可一次性跑通(79 条迁移全部记录在 schema_migrations)。
|
||||
-- 生产库上库前请先跑完「上库前」四段检查。
|
||||
|
||||
-- ============================ 上库前 ============================
|
||||
|
||||
-- 1. 同一张工单被多件现货同时绑定?
|
||||
-- 会让唯一索引 uniq_consignment_inventories_bound_work_order 创建失败。
|
||||
-- 迁移自带清理(保留工单 material 里记录的那一件,其余解绑回库),
|
||||
-- 但有数据时请先确认"保留哪一件"与工单 material.consignment.inventoryId 一致。
|
||||
-- 期望:0 行。
|
||||
SELECT
|
||||
ci.bound_work_order_id,
|
||||
COUNT(*) AS bound_rows,
|
||||
ARRAY_AGG(ci.id ORDER BY ci.id) AS inventory_ids,
|
||||
MAX(NULLIF(wo.material_json -> 'consignment' ->> 'inventoryId', '')) AS material_inventory_id
|
||||
FROM consignment_inventories ci
|
||||
JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
WHERE ci.bound_work_order_id IS NOT NULL
|
||||
AND ci.status = 'consumed'
|
||||
GROUP BY ci.bound_work_order_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 2. 同一提报单重复冻结了发布押金?
|
||||
-- 会让唯一索引 uniq_worker_wallet_ledgers_consignment_publish_freeze 创建失败。
|
||||
-- 期望:0 行。若有行,需人工决定保留哪笔冻结(多冻的部分按 deposit_release 冲正)。
|
||||
SELECT
|
||||
payload_json ->> 'consignmentSubmissionId' AS submission_id,
|
||||
COUNT(*) AS freeze_rows,
|
||||
SUM(ABS(amount)) AS frozen_fen
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE ledger_type = 'deposit_freeze'
|
||||
AND payload_json ->> 'kind' = 'consignment_publish'
|
||||
GROUP BY 1
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 3. 待删除对象的影响面(收集链接渠道下线)。
|
||||
-- 用 DO 块 + 动态 SQL,保证"表/列已被删掉"时脚本也能跑(否则会直接报错中断)。
|
||||
DO $$
|
||||
DECLARE
|
||||
n bigint;
|
||||
BEGIN
|
||||
IF to_regclass('public.consignment_collect_links') IS NOT NULL THEN
|
||||
EXECUTE 'SELECT COUNT(*) FROM consignment_collect_links' INTO n;
|
||||
RAISE NOTICE '[3.1] 收集链接表仍存在,共 % 行;删除后不可恢复', n;
|
||||
ELSE
|
||||
RAISE NOTICE '[3.1] 收集链接表不存在(079 已执行或从未创建)';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'consignment_submissions' AND column_name = 'link_id'
|
||||
) THEN
|
||||
EXECUTE 'SELECT COUNT(*) FROM consignment_submissions WHERE link_id IS NOT NULL' INTO n;
|
||||
RAISE NOTICE '[3.2] 提报单 link_id 仍存在,共 % 行有引用;删除后不可恢复', n;
|
||||
ELSE
|
||||
RAISE NOTICE '[3.2] 提报单 link_id 不存在(079 已执行或从未创建)';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 3.3 来源枚举分布(迁移会把 collection_link 归并为 worker_portal)
|
||||
SELECT source_type, COUNT(*) FROM consignment_inventories GROUP BY source_type ORDER BY 1;
|
||||
|
||||
-- 4. 历史遗留的"已出库但无工单绑定"现货(079 之前 worker_deposit 占库不写绑定)
|
||||
-- 这些行不会随撤单自动解绑,需要运营在寄卖仓库手动【退回复库】。
|
||||
SELECT COUNT(*) AS legacy_consumed_without_binding
|
||||
FROM consignment_inventories
|
||||
WHERE status = 'consumed' AND bound_work_order_id IS NULL;
|
||||
|
||||
-- ============================ 上库后 ============================
|
||||
|
||||
-- 5. 结构校验:新列 / 新索引 / 枚举约束是否就位
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'consignment_inventories' AND column_name = 'assigned_worker_id'
|
||||
) AS has_assigned_worker_id,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_indexes WHERE indexname = 'uniq_consignment_inventories_bound_work_order'
|
||||
) AS has_bound_work_order_unique,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'uniq_worker_wallet_ledgers_consignment_publish_freeze'
|
||||
) AS has_ledger_freeze_unique,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.tables WHERE table_name = 'consignment_collect_links'
|
||||
) AS collect_links_table_still_exists,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'consignment_submissions' AND column_name = 'link_id'
|
||||
) AS submissions_link_id_still_exists;
|
||||
|
||||
SELECT pg_get_constraintdef(oid) AS source_type_check
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'consignment_inventories_source_type_check';
|
||||
|
||||
-- 6. 绑定一致性总览(上库后运营巡检用)
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.inventory_no,
|
||||
ci.status,
|
||||
ci.bound_work_order_id,
|
||||
wo.status AS work_order_status,
|
||||
ci.allocation_status,
|
||||
ci.assigned_worker_id,
|
||||
ci.worker_id AS seller_worker_id
|
||||
FROM consignment_inventories ci
|
||||
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
WHERE ci.status = 'consumed'
|
||||
ORDER BY ci.updated_at DESC
|
||||
LIMIT 50;
|
||||
|
||||
-- 7. 发布押金挂账巡检:台账未释放金额是否超过钱包冻结额(按打手)
|
||||
WITH ledger AS (
|
||||
SELECT
|
||||
worker_id,
|
||||
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)
|
||||
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)
|
||||
AS outstanding_fen
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE payload_json ->> 'kind' = 'consignment_publish'
|
||||
GROUP BY worker_id
|
||||
)
|
||||
SELECT
|
||||
l.worker_id,
|
||||
l.outstanding_fen,
|
||||
w.frozen_deposit_amount AS wallet_frozen_fen
|
||||
FROM ledger l
|
||||
JOIN worker_wallets w ON w.worker_id = l.worker_id
|
||||
WHERE l.outstanding_fen > 0
|
||||
AND l.outstanding_fen > w.frozen_deposit_amount;
|
||||
Reference in New Issue
Block a user