diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index a381a673..0667d805 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 { + const res = await client.query( + ` + 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 { + return withTransaction((client) => + claimConsignmentInventoryForWorkerWithClient(client, id, input), + ) +} + +export async function releaseConsignmentInventoryClaimWithClient( + client: PoolClient, + id: number | string, + input: { now: string; reason?: string }, +): Promise { + const res = await client.query( + ` + 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 { + return withTransaction((client) => releaseConsignmentInventoryClaimWithClient(client, id, input)) +} + export async function archiveConsignmentInventoryWithClient( client: PoolClient, id: number | string, diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index ebd2706a..a3238b92 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -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({ diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index a2efb8e1..f6776b90 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -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] 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 bfde1eb0..a5d67cdc 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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, +): 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 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 2ca9b90e..3a7e9051 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 @@ -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() + + 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(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, 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( diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index d73acecc..aecb4b69 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -1454,7 +1454,7 @@ export default function WorkOrdersPanel() { 扣押金 ) : null} - {(row.material as any)?.consignment ? ( + {isInternalConsignmentOrder(row) ? (