diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index ed1cc0e3..84007b55 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -1295,6 +1295,97 @@ export async function unassignWorkOrder(input: { }) } +export async function cancelAssignedWorkOrder(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( + ` + 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 = 'cancelled', + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + published_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: 'cancelled_by_admin', + fromStatus: 'in_progress', + toStatus: 'cancelled', + 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, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 24ff2798..03155d80 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -4,6 +4,7 @@ import { acceptAdminWorkOrder, acceptAdminWorkOrders, assignAdminWorkOrderToWorker, + cancelAdminWorkOrder, unassignAdminWorkOrder, creditAdminWorkerWallet, createAdminMockWorkOrder, @@ -382,6 +383,22 @@ router.post( }), ) +router.post( + '/worker-platform/orders/:workOrderId/cancel', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => cancelAdminWorkOrder(String(req.params.workOrderId || '')), { + successMessage: '订单已撤单,打手额度已恢复', + errorMessage: '撤单失败', + scope: '[admin/worker-platform/orders/:workOrderId/cancel]', + audit: (req, data) => ({ + action: 'work_order_cancelled', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.delete( '/worker-platform/orders/:workOrderId', requireAdminRoles(['admin', 'operator']), @@ -416,7 +433,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/unpublish', - requireAdminRoles(['admin', 'operator']), + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler((req) => unpublishAdminWorkOrder(String(req.params.workOrderId || '')), { successMessage: '订单已撤回到未分配', errorMessage: '下架接单订单失败', diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index b6568eb8..c4f54d49 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -6,6 +6,7 @@ import { acceptWorkOrderAndSettle, addWorkerWalletCredit, assignWorkOrderToWorker, + cancelAssignedWorkOrder, unassignWorkOrder, countWorkerAcceptedOrders, countWorkerActiveOrders, @@ -1240,6 +1241,42 @@ export async function unassignAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated) } } +export async function cancelAdminWorkOrder(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_cancel_status_invalid', + }) + } + const { order: updated, failureReason } = await cancelAssignedWorkOrder({ + workOrderId: workOrder.id, + now: nowIso(), + }) + if (failureReason === 'work_order_not_in_progress') { + throw createHttpError('订单状态已变化,无法撤单', { + statusCode: 409, + errorCode: 'work_order_cancel_status_invalid', + }) + } + if (failureReason === 'work_order_no_worker') { + throw createHttpError('该订单尚未被打手接取', { + statusCode: 409, + errorCode: 'work_order_cancel_no_worker', + }) + } + if (!updated) { + throw createHttpError('撤单失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_cancel_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' }), diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index fb321a04..45b518c8 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -46,8 +46,8 @@ import { acceptAdminWorkOrder, acceptAdminWorkOrders, assignAdminWorkOrderToWorker, + cancelAdminWorkOrder, createAdminMockWorkOrder, - unassignAdminWorkOrder, creditAdminWorkerWallet, deductAdminWorkOrderPendingDeposit, deleteAdminWorkCategory, @@ -692,25 +692,25 @@ export default function WorkOrdersPanel() { const assignedWorker = row.worker if (!assignedWorker) return modal.confirm({ - title: '取消指派该打手?', + title: '确认撤单?', content: ( - 将 {assignedWorker.displayName || assignedWorker.username} 的接单撤回,订单回到未分配, - 可重新指派给其他打手;冻结押金将退还。 + 将取消 {assignedWorker.displayName || assignedWorker.username} 正在代练的订单,订单不会重新发布到大厅, + 打手接单额度和冻结押金将恢复。 ), - okText: '确认取消指派', + okText: '确认撤单', cancelText: '取消', okButtonProps: { danger: true }, onOk: () => runAction( - () => unassignAdminWorkOrder(row.workOrderId), - '已取消指派', + () => cancelAdminWorkOrder(row.workOrderId), + '已撤单并恢复打手额度', ), }) }} > - 取消指派 + 撤单 ) : null} {['pending_material', 'in_progress'].includes(row.status) ? ( diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index f979142f..0a97fc97 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -149,6 +149,12 @@ export function unassignAdminWorkOrder(workOrderId: number) { ) } +export function cancelAdminWorkOrder(workOrderId: number) { + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/cancel`, + ) +} + export function creditAdminWorkerWallet( workerId: number, payload: { amount: number; note?: string },