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:
@@ -542,6 +542,81 @@ export async function updateConsignmentInventory(
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 以“条件更新”方式原子占用在库物品(指派打手押金履约)。
|
||||
*
|
||||
* 必须在指派打手之前调用:并发下只有一个调用能拿到 `status = 'in_stock'` 的那一行,
|
||||
* 避免同一件现货被两张工单同时认领。
|
||||
* 返回 null 表示物品已被其他操作占用。
|
||||
*/
|
||||
export async function claimConsignmentInventoryForWorkerWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
input: { workerId: number; now: string; allocationStatus?: string },
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'consumed',
|
||||
allocation_status = $1,
|
||||
worker_id = $2,
|
||||
consumed_at = $3,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status = 'in_stock'
|
||||
RETURNING *
|
||||
`,
|
||||
[input.allocationStatus || '已指派打手(押金履约中)', input.workerId, input.now, id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
export async function claimConsignmentInventoryForWorker(
|
||||
id: number | string,
|
||||
input: { workerId: number; now: string; allocationStatus?: string },
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
return withTransaction((client) =>
|
||||
claimConsignmentInventoryForWorkerWithClient(client, id, input),
|
||||
)
|
||||
}
|
||||
|
||||
export async function releaseConsignmentInventoryClaimWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
input: { now: string; reason?: string },
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
`
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
allocation_status = '待安排',
|
||||
worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
remark = CASE
|
||||
WHEN $2 = '' THEN remark
|
||||
ELSE remark || ' [指派回滚: ' || $2 || ']'
|
||||
END,
|
||||
updated_at = $1
|
||||
WHERE id = $3 AND status = 'consumed'
|
||||
RETURNING *
|
||||
`,
|
||||
[input.now, input.reason || '', id],
|
||||
)
|
||||
return res.rows[0] || null
|
||||
}
|
||||
|
||||
export async function releaseConsignmentInventoryClaim(
|
||||
id: number | string,
|
||||
input: { now: string; reason?: string },
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input))
|
||||
}
|
||||
|
||||
export async function archiveConsignmentInventoryWithClient(
|
||||
client: PoolClient,
|
||||
id: number | string,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
fulfillWorkOrderWithConsignment,
|
||||
revokeWorkOrderConsignmentFulfillment,
|
||||
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from '../session.js'
|
||||
import { createJsonHandler, requireAdminPermission } from '../session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -30,7 +30,7 @@ const router = Router()
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/inventories',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler((req) => fetchAdminConsignmentInventories(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取寄卖库存失败',
|
||||
@@ -40,7 +40,7 @@ router.get(
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/stats',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler(() => fetchAdminConsignmentStats(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取寄卖统计失败',
|
||||
@@ -50,7 +50,7 @@ router.get(
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/inventories/:id',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler((req) => getAdminConsignmentInventory(String(req.params.id || '')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取寄卖物品详情失败',
|
||||
@@ -60,7 +60,7 @@ router.get(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/inventories',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminConsignmentInventory(req.body, req.adminSession?.username || 'admin'),
|
||||
{
|
||||
@@ -73,7 +73,7 @@ router.post(
|
||||
|
||||
router.put(
|
||||
'/worker-platform/consignment/inventories/:id',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminConsignmentInventory(
|
||||
@@ -91,7 +91,7 @@ router.put(
|
||||
|
||||
router.patch(
|
||||
'/worker-platform/consignment/inventories/:id/matching',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
toggleAdminConsignmentMatching(String(req.params.id || ''), Boolean(req.body?.allowMatching)),
|
||||
@@ -105,7 +105,7 @@ router.patch(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/inventories/:id/archive',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler((req) => archiveAdminConsignmentInventory(String(req.params.id || '')), {
|
||||
successMessage: '物品已下架作废',
|
||||
errorMessage: '作废寄卖物品失败',
|
||||
@@ -115,7 +115,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/inventories/:id/return',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
returnAdminConsignmentInventory(
|
||||
@@ -135,7 +135,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/links',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler(() => fetchAdminConsignmentLinks(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取收集链接列表失败',
|
||||
@@ -145,7 +145,7 @@ router.get(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/links',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler(
|
||||
(req) => createAdminConsignmentLink(req.body, req.adminSession?.username || 'admin'),
|
||||
{
|
||||
@@ -158,7 +158,7 @@ router.post(
|
||||
|
||||
router.put(
|
||||
'/worker-platform/consignment/links/:id',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.manage'),
|
||||
createJsonHandler((req) => updateAdminConsignmentLink(String(req.params.id || ''), req.body), {
|
||||
successMessage: '收集链接已更新',
|
||||
errorMessage: '更新收集链接失败',
|
||||
@@ -170,7 +170,7 @@ router.put(
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/submissions',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler((req) => fetchAdminConsignmentSubmissions(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取提报列表失败',
|
||||
@@ -180,7 +180,7 @@ router.get(
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/submissions/:id',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler((req) => getAdminConsignmentSubmission(String(req.params.id || '')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取提报详情失败',
|
||||
@@ -190,7 +190,7 @@ router.get(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/submissions/:id/approve',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.review'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
approveAdminConsignmentSubmission(
|
||||
@@ -208,7 +208,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/submissions/:id/reject',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.review'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
rejectAdminConsignmentSubmission(
|
||||
@@ -228,7 +228,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/matches/:workOrderId',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler((req) => checkWorkOrderInventoryMatches(String(req.params.workOrderId || '')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '检查寄售库存匹配失败',
|
||||
@@ -238,7 +238,7 @@ router.get(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/fulfill',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.fulfill'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
fulfillWorkOrderWithConsignment({
|
||||
@@ -256,7 +256,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/assign-worker',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.fulfill'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
assignWorkOrderWithConsignmentWorker({
|
||||
@@ -275,7 +275,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
'/worker-platform/consignment/revoke',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
requireAdminPermission('consignment.fulfill'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
revokeWorkOrderConsignmentFulfillment({
|
||||
|
||||
@@ -21,6 +21,10 @@ export const ADMIN_PERMISSION_KEYS = [
|
||||
'worker_order.manage_problem',
|
||||
'worker_order.deduct_deposit',
|
||||
'worker_cancel_request.review',
|
||||
'consignment.view',
|
||||
'consignment.manage',
|
||||
'consignment.review',
|
||||
'consignment.fulfill',
|
||||
] as const
|
||||
|
||||
export type AdminPermissionKey = (typeof ADMIN_PERMISSION_KEYS)[number]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1454,7 +1454,7 @@ export default function WorkOrdersPanel() {
|
||||
扣押金
|
||||
</Button>
|
||||
) : null}
|
||||
{(row.material as any)?.consignment ? (
|
||||
{isInternalConsignmentOrder(row) ? (
|
||||
<Button
|
||||
icon={<RollbackOutlined />}
|
||||
danger
|
||||
|
||||
Reference in New Issue
Block a user