fix(consignment): 打通寄卖权限鉴权、指派占库竞态与撤销守卫
- 后台权限白名单补齐 consignment.view/manage/review/fulfill, 避免在「角色权限」页保存任意角色时静默清除寄卖权限 - 寄卖后台 19 个接口由 requireAdminRoles 改为 requireAdminPermission, 使 view/manage/review/fulfill 四个权限真正生效 - 指派打手履约改为先原子占库(WHERE status = 'in_stock'), 失败自动回滚库存,消除并发下同一件现货被两张工单同时认领 - 撤销寄售履约拒绝押金履约模式(worker_deposit), 避免打手押金不释放造成资金挂账;前端同步隐藏该入口 - 撤销回库不再强制覆盖 allow_matching,保留运营手动关闭的匹配开关 - 新增 4 个回归测试:权限白名单与迁移一致性、占库/回滚条件、撤销守卫
This commit is contained in:
@@ -3,9 +3,10 @@ import { withTransaction } from '../../db/client.js'
|
||||
import { WORK_ORDER_STATUS, type WorkOrderStatus } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
type ConsignmentInventoryRow,
|
||||
claimConsignmentInventoryForWorker,
|
||||
getConsignmentInventoryById,
|
||||
listActiveConsignmentInventoriesForMatching,
|
||||
updateConsignmentInventory,
|
||||
releaseConsignmentInventoryClaim,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import {
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||
@@ -310,82 +311,134 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 1. 调用接单平台统一指派流程(进行打手可用性校验、余额与押金冻结、电子凭证核销、工单状态流转到 in_progress)
|
||||
const assignResult = await assignAdminWorkOrderToWorker(
|
||||
workOrder.id,
|
||||
{ workerId: targetWorkerId },
|
||||
input.actorName,
|
||||
)
|
||||
|
||||
const now = nowIso()
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
|
||||
// 2. 将寄售手枪信息写入工单 material
|
||||
const updatedMaterial = {
|
||||
...material,
|
||||
consignment: {
|
||||
inventoryId: Number(inventory.id),
|
||||
inventoryNo: inventory.inventory_no,
|
||||
itemName: inventory.item_name,
|
||||
system: inventory.system,
|
||||
serverZone: inventory.server_zone,
|
||||
series: inventory.series,
|
||||
diamondLevel: inventory.diamond_level,
|
||||
brillianceCount: inventory.brilliance_count,
|
||||
costPriceFen: inventory.cost_price_fen,
|
||||
workerId: Number(targetWorkerId),
|
||||
workerName: inventory.worker_name || '',
|
||||
sourceSubmissionId: inventory.source_submission_id
|
||||
? Number(inventory.source_submission_id)
|
||||
: null,
|
||||
fulfillmentType: 'worker_deposit',
|
||||
assignedAt: now,
|
||||
assignedBy: input.actorName,
|
||||
},
|
||||
}
|
||||
|
||||
const rewardAmountYuan =
|
||||
Number(inventory.cost_price_fen || 0) > 0
|
||||
? Number((Number(inventory.cost_price_fen) / 100).toFixed(2))
|
||||
: Number(workOrder.reward_amount || 0)
|
||||
|
||||
await updateWorkOrder(workOrder.id, {
|
||||
material_json: updatedMaterial,
|
||||
reward_amount: rewardAmountYuan,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
// 3. 将寄卖库存置为 consumed 已出库/已安排打手
|
||||
const updatedInventory = await updateConsignmentInventory(inventory.id, {
|
||||
status: 'consumed',
|
||||
allocationStatus: '已指派打手(押金履约中)',
|
||||
// 1. 先原子占用库存:并发指派时只有一方能把 status 从 in_stock 改成 consumed,
|
||||
// 避免同一件现货被两张工单同时认领。
|
||||
const claimed = await claimConsignmentInventoryForWorker(inventory.id, {
|
||||
workerId: Number(targetWorkerId),
|
||||
now,
|
||||
})
|
||||
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName,
|
||||
eventType: 'consignment_worker_assigned',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
|
||||
payloadJson: JSON.stringify({
|
||||
inventoryId: inventory.id,
|
||||
inventoryNo: inventory.inventory_no,
|
||||
workerId: targetWorkerId,
|
||||
costPriceFen: inventory.cost_price_fen,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
const refreshedOrder = await getWorkOrderById(workOrder.id)
|
||||
|
||||
return {
|
||||
order: mapWorkOrderAdmin(refreshedOrder || (assignResult.order as any)),
|
||||
inventory: mapConsignmentInventory(updatedInventory!),
|
||||
voucherConsume: assignResult.voucherConsume,
|
||||
if (!claimed) {
|
||||
throw createHttpError('寄卖库存已被其他操作占用,请刷新重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_inventory_race_condition',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 调用接单平台统一指派流程(进行打手可用性校验、余额与押金冻结、电子凭证核销、工单状态流转到 in_progress)
|
||||
const assignResult = await assignAdminWorkOrderToWorker(
|
||||
workOrder.id,
|
||||
{ workerId: targetWorkerId },
|
||||
input.actorName,
|
||||
)
|
||||
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
|
||||
// 3. 将寄售手枪信息写入工单 material
|
||||
const updatedMaterial = {
|
||||
...material,
|
||||
consignment: {
|
||||
inventoryId: Number(claimed.id),
|
||||
inventoryNo: claimed.inventory_no,
|
||||
itemName: claimed.item_name,
|
||||
system: claimed.system,
|
||||
serverZone: claimed.server_zone,
|
||||
series: claimed.series,
|
||||
diamondLevel: claimed.diamond_level,
|
||||
brillianceCount: claimed.brilliance_count,
|
||||
costPriceFen: claimed.cost_price_fen,
|
||||
workerId: Number(targetWorkerId),
|
||||
workerName: claimed.worker_name || '',
|
||||
sourceSubmissionId: claimed.source_submission_id
|
||||
? Number(claimed.source_submission_id)
|
||||
: null,
|
||||
fulfillmentType: 'worker_deposit',
|
||||
assignedAt: now,
|
||||
assignedBy: input.actorName,
|
||||
},
|
||||
}
|
||||
|
||||
const rewardAmountYuan =
|
||||
Number(claimed.cost_price_fen || 0) > 0
|
||||
? Number((Number(claimed.cost_price_fen) / 100).toFixed(2))
|
||||
: Number(workOrder.reward_amount || 0)
|
||||
|
||||
await updateWorkOrder(workOrder.id, {
|
||||
material_json: updatedMaterial,
|
||||
reward_amount: rewardAmountYuan,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName,
|
||||
eventType: 'consignment_worker_assigned',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
|
||||
payloadJson: JSON.stringify({
|
||||
inventoryId: claimed.id,
|
||||
inventoryNo: claimed.inventory_no,
|
||||
workerId: targetWorkerId,
|
||||
costPriceFen: claimed.cost_price_fen,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
const refreshedOrder = await getWorkOrderById(workOrder.id)
|
||||
|
||||
return {
|
||||
order: mapWorkOrderAdmin(refreshedOrder || (assignResult.order as any)),
|
||||
inventory: mapConsignmentInventory(claimed),
|
||||
voucherConsume: assignResult.voucherConsume,
|
||||
}
|
||||
} catch (error) {
|
||||
// 指派/写资料失败:立刻把现货放回在库池,避免库存被长期占用而无法再匹配。
|
||||
// 回滚失败不能掩盖原始错误,仅在库存备注中保留痕迹。
|
||||
try {
|
||||
await releaseConsignmentInventoryClaim(inventory.id, {
|
||||
now: nowIso(),
|
||||
reason: '指派打手失败自动回滚',
|
||||
})
|
||||
} catch {
|
||||
// 忽略:以原始错误为准
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工单上的寄售信息是否可走「撤销寄售履约」流程,并返回关联库存 ID。
|
||||
*
|
||||
* 只有“内部现货占库”履约才能走本流程;押金履约模式(worker_deposit)由打手接单并
|
||||
* 冻结了履约押金,必须走接单平台的「撤单并退回大厅 / 撤单并取消订单」,
|
||||
* 否则打手押金不会释放,会造成资金挂账。
|
||||
*/
|
||||
export function resolveRevocableConsignmentInventoryId(
|
||||
consignment: Record<string, unknown>,
|
||||
): number {
|
||||
const inventoryId = consignment.inventoryId ? Number(consignment.inventoryId) : 0
|
||||
if (!inventoryId) {
|
||||
throw createHttpError('该工单没有内部寄售履约记录,无法撤销', {
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_fulfillment_missing',
|
||||
})
|
||||
}
|
||||
if (
|
||||
String(consignment.fulfillmentType || '') === CONSIGNMENT_FULFILLMENT_TYPE.WORKER_DEPOSIT ||
|
||||
Number(consignment.workerId || 0) > 0
|
||||
) {
|
||||
throw createHttpError(
|
||||
'该工单为押金履约模式,请使用「撤单并退回大厅」或「撤单并取消订单」处理',
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: 'consignment_fulfillment_worker_mode',
|
||||
},
|
||||
)
|
||||
}
|
||||
return inventoryId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +460,7 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
|
||||
const material = safeParseJson(workOrder.material_json)
|
||||
const consignmentInfo = safeParseJson(material.consignment)
|
||||
const inventoryId = consignmentInfo.inventoryId ? Number(consignmentInfo.inventoryId) : null
|
||||
const inventoryId = resolveRevocableConsignmentInventoryId(consignmentInfo)
|
||||
|
||||
const now = nowIso()
|
||||
|
||||
@@ -451,7 +504,6 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
allow_matching = TRUE,
|
||||
remark = remark || ' [撤销回库: ' || $1 || ']',
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import type {
|
||||
@@ -57,6 +60,102 @@ import {
|
||||
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||
releaseConsignmentPublishDepositWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||
import {
|
||||
claimConsignmentInventoryForWorkerWithClient,
|
||||
releaseConsignmentInventoryClaimWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
|
||||
import { resolveRevocableConsignmentInventoryId } from './consignment-fulfillment-service.js'
|
||||
|
||||
test('后台权限白名单覆盖全部迁移登记的权限,避免保存角色权限时被静默清除', () => {
|
||||
const migrationsDir = fileURLToPath(new URL('../../db/migrations', import.meta.url))
|
||||
const registered = new Set<string>()
|
||||
|
||||
for (const file of fs.readdirSync(migrationsDir)) {
|
||||
if (!file.endsWith('.sql')) continue
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8')
|
||||
// 只解析 INSERT INTO admin_permissions 的 VALUES 元组,避免误抓其他点号字符串。
|
||||
for (const block of sql.split(/INSERT INTO admin_permissions/i).slice(1)) {
|
||||
const body = block.split(/ON CONFLICT/i)[0] || ''
|
||||
for (const match of body.matchAll(/\(\s*'([a-z_]+(?:\.[a-z_]+)+)'/g)) {
|
||||
registered.add(match[1]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(registered.size > 0, '应能从迁移中解析出权限键')
|
||||
const whitelist = new Set<string>(ADMIN_PERMISSION_KEYS)
|
||||
const missing = [...registered].filter((key) => !whitelist.has(key)).sort()
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`以下权限已在迁移中登记但不在 ADMIN_PERMISSION_KEYS 中,保存角色权限时会被静默删除:${missing.join(', ')}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('寄卖指派先原子占库:只有 in_stock 能被占用,已被占用返回 null', async () => {
|
||||
const statements: string[] = []
|
||||
let matched = false
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
return { rows: matched ? [{ id: 9, inventory_no: 'INV2609150001' }] : [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const missed = await claimConsignmentInventoryForWorkerWithClient(client, 9, {
|
||||
workerId: 3,
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
})
|
||||
assert.equal(missed, null)
|
||||
|
||||
matched = true
|
||||
const claimed = await claimConsignmentInventoryForWorkerWithClient(client, 9, {
|
||||
workerId: 3,
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
})
|
||||
assert.equal(claimed?.inventory_no, 'INV2609150001')
|
||||
|
||||
assert.ok(statements[0]!.includes("WHERE id = $4 AND status = 'in_stock'"))
|
||||
assert.ok(statements[0]!.includes('RETURNING *'))
|
||||
})
|
||||
|
||||
test('寄卖指派失败回滚只在物品仍为 consumed 时放回在库', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const released = await releaseConsignmentInventoryClaimWithClient(client, 9, {
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '指派打手失败自动回滚',
|
||||
})
|
||||
assert.equal(released, null)
|
||||
assert.ok(statements[0]!.includes("WHERE id = $3 AND status = 'consumed'"))
|
||||
})
|
||||
|
||||
test('撤销寄售履约仅允许内部现货占库,押金履约必须走撤单流程', () => {
|
||||
assert.equal(
|
||||
resolveRevocableConsignmentInventoryId({ inventoryId: 12, fulfillmentType: 'internal_stock' }),
|
||||
12,
|
||||
)
|
||||
|
||||
const expectRejected = (payload: Record<string, unknown>, errorCode: string) => {
|
||||
assert.throws(
|
||||
() => resolveRevocableConsignmentInventoryId(payload),
|
||||
(error: unknown) => (error as { errorCode?: string }).errorCode === errorCode,
|
||||
)
|
||||
}
|
||||
expectRejected({}, 'consignment_fulfillment_missing')
|
||||
expectRejected(
|
||||
{ inventoryId: 12, fulfillmentType: 'worker_deposit' },
|
||||
'consignment_fulfillment_worker_mode',
|
||||
)
|
||||
expectRejected({ inventoryId: 12, workerId: 7 }, 'consignment_fulfillment_worker_mode')
|
||||
})
|
||||
|
||||
test('寄卖发布押金配置保留显式零值并回退非法值', () => {
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user