feat: 支持取消指派打手,可重新指派他人

- 后台可取消进行中订单的指派:订单回未分配,冻结押金全额退还,记录事件与审计
- 工单列表新增「取消指派」按钮(带确认弹窗),客服/管理员/运营可用
- 取消后可重新指派给其他打手
This commit is contained in:
yml2213
2026-08-15 13:48:32 +08:00
parent d44c6039ea
commit 21211bd760
5 changed files with 182 additions and 0 deletions
@@ -1063,6 +1063,96 @@ export async function cancelWorkerWorkOrder(input: {
})
}
export async function unassignWorkOrder(input: {
workOrderId: number
now: string
}): Promise<{
order: WorkOrderRow | null
failureReason: 'work_order_not_in_progress' | 'work_order_no_worker' | null
}> {
return withTransaction(async (client) => {
const currentResult = await client.query<WorkOrderRow>(
`
SELECT *
FROM work_orders
WHERE id = $1
FOR UPDATE
`,
[input.workOrderId],
)
const workOrder = currentResult.rows[0] || null
if (!workOrder || workOrder.status !== 'in_progress') {
return { order: null, failureReason: 'work_order_not_in_progress' }
}
const workerId = Number(workOrder.assigned_worker_id || 0)
if (!workerId) {
return { order: null, failureReason: 'work_order_no_worker' }
}
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
const releaseAmount = Math.min(
await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId),
Number(wallet?.frozen_deposit_amount || 0),
)
await client.query(
`
UPDATE work_orders
SET
status = 'unassigned',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
updated_at = $1
WHERE id = $2
`,
[input.now, input.workOrderId],
)
if (releaseAmount > 0) {
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount
const nextFrozen = Math.max(
0,
Number(wallet?.frozen_deposit_amount || 0) - releaseAmount,
)
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, nextFrozen, input.now, workerId],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '后台取消指派退还押金', '{}'::jsonb, $6)
`,
[workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
)
}
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: '',
eventType: 'unassigned_by_admin',
fromStatus: 'in_progress',
toStatus: 'unassigned',
payloadJson: JSON.stringify({ workerId, releaseAmount }),
now: input.now,
})
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
failureReason: null,
}
})
}
export async function countWorkerCancellationsSince(
workerId: number | string,
sinceIso: string,
@@ -3,6 +3,7 @@ import { Router } from 'express'
import {
acceptAdminWorkOrder,
assignAdminWorkOrderToWorker,
unassignAdminWorkOrder,
creditAdminWorkerWallet,
createAdminMockWorkOrder,
deductAdminWorkOrderPendingDeposit,
@@ -361,6 +362,22 @@ router.post(
),
)
router.post(
'/worker-platform/orders/:workOrderId/unassign',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => unassignAdminWorkOrder(String(req.params.workOrderId || '')), {
successMessage: '已取消指派',
errorMessage: '取消指派失败',
scope: '[admin/worker-platform/orders/:workOrderId/unassign]',
audit: (req, data) => ({
action: 'work_order_worker_unassigned',
targetType: 'work_order',
targetId: String(req.params.workOrderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
)
router.delete(
'/worker-platform/orders/:workOrderId',
requireAdminRoles(['admin', 'operator']),
@@ -6,6 +6,7 @@ import {
acceptWorkOrderAndSettle,
addWorkerWalletCredit,
assignWorkOrderToWorker,
unassignWorkOrder,
countWorkerAcceptedOrders,
countWorkerActiveOrders,
countWorkCategoryUsages,
@@ -1146,6 +1147,43 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) {
return { order: mapWorkOrderAdmin(updated || workOrder) }
}
export async function unassignAdminWorkOrder(workOrderId: number | string) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (
workOrder.status !== WORK_ORDER_STATUS.IN_PROGRESS ||
!workOrder.assigned_worker_id
) {
throw createHttpError('只有进行中且已指派打手的订单可以取消指派', {
statusCode: 409,
errorCode: 'work_order_unassign_status_invalid',
})
}
const now = nowIso()
const { order: updated, failureReason } = await unassignWorkOrder({
workOrderId: workOrder.id,
now,
})
if (failureReason === 'work_order_not_in_progress') {
throw createHttpError('订单状态已变化,无法取消指派', {
statusCode: 409,
errorCode: 'work_order_unassign_status_invalid',
})
}
if (failureReason === 'work_order_no_worker') {
throw createHttpError('该订单未指派打手', {
statusCode: 409,
errorCode: 'work_order_unassign_no_worker',
})
}
if (!updated) {
throw createHttpError('取消指派失败,订单状态可能已变化', {
statusCode: 409,
errorCode: 'work_order_unassign_conflict',
})
}
return { order: mapWorkOrderAdmin(updated) }
}
export async function getAdminWorkerPlatformSummary() {
const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),