From 5d11bf2012948ec69a22d86e265d27c81d1e5d42 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 16 Sep 2026 20:12:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(consignment):=20=E8=A1=A5=E9=BD=90=E6=8C=87?= =?UTF-8?q?=E6=B4=BE=E8=A1=A5=E5=81=BF=E4=B8=8E=E8=A7=84=E6=A0=BC=E5=A4=8D?= =?UTF-8?q?=E6=A3=80=EF=BC=8C=E5=B0=81=E9=97=AD=E5=AF=84=E5=8D=96=E8=B5=84?= =?UTF-8?q?=E9=87=91/=E8=B4=A7=E7=89=A9=E4=B8=8D=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 指派失败改为完整补偿:取消指派(解冻打手押金 + 工单退回大厅 + 解绑现货)→ 兜底释放占用 → 摘除工单寄售标记。此前只把现货放回在库池,一旦"指派已提交、写 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 上库前检查脚本。 --- .../worker-platform/consignment-repo.ts | 110 ++++++++++++- .../admin/worker-platform/consignment.ts | 2 + .../consignment-fulfillment-service.ts | 145 ++++++++++++++++-- .../consignment-matching-service.test.ts | 46 ++++++ .../consignment-matching-service.ts | 78 +++++++--- .../worker-platform-service.test.ts | 132 +++++++++++++++- .../pages/admin/panels/WorkOrdersPanel.tsx | 65 +++++++- .../src/services/admin/consignment.ts | 33 +++- docs/寄卖系统优化方案.md | 51 ++++-- docs/寄卖系统迁移079上库前检查.sql | 135 ++++++++++++++++ 10 files changed, 727 insertions(+), 70 deletions(-) create mode 100644 docs/寄卖系统迁移079上库前检查.sql diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index b711c2eb..63a0d7d2 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 { + 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 { + // 先锁行读出旧绑定:下面的 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( ` 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 } /** 退回复库(将已出库物品恢复为在库待匹配) */ diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 69ab7f9b..714be83f 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -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: '已指派打手,押金已冻结,工单流转进行中!', diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index f4724f5e..54bd1ce9 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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 inventory: ReturnType @@ -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 inventory: ReturnType @@ -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 { + 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 { + // 忽略:以原始错误为准 + } +} + /** * 允许「撤销寄售履约」的工单状态白名单。 * diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts index 1ebae3af..fcb01c8d 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index a7dc518e..6c0a0db3 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -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(',')}` : '属于高配充低配', } } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 99874711..6c6c6e1c 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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)')) +}) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index c703184d..841d2591 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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) + }) + } > 走接单平台指派打手(冻结押金交付) @@ -2981,7 +3030,11 @@ export default function WorkOrdersPanel() { diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index 1c5da054..c9842931 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -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: { diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index f081a140..1553549e 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -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(权限门控与运营体验)为下一批。 diff --git a/docs/寄卖系统迁移079上库前检查.sql b/docs/寄卖系统迁移079上库前检查.sql new file mode 100644 index 00000000..b165493a --- /dev/null +++ b/docs/寄卖系统迁移079上库前检查.sql @@ -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;