diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index f483daf3..99df4de1 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -319,6 +319,7 @@ export type WorkOrderCapabilities = { canAccept: boolean canMarkProblem: boolean canDelete: boolean + canReopen: boolean canPublish: boolean canCancel: boolean canResolveProblem: boolean 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 3913fc30..82635429 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -1981,6 +1981,84 @@ export async function cancelAssignedWorkOrder(input: { }) } +/** 将已取消工单恢复为未分配状态,不变更历史押金流水。 */ +export async function reopenCancelledWorkOrder(input: { + workOrderId: number + now: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: 'work_order_not_cancelled' | 'work_order_has_active_shares' | 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 !== 'cancelled') { + return { order: null, failureReason: 'work_order_not_cancelled' } + } + + const activeShareResult = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 + AND status != 'cancelled' + `, + [input.workOrderId], + ) + if (Number(activeShareResult.rows[0]?.total || 0) > 0) { + return { order: null, failureReason: 'work_order_has_active_shares' } + } + + await client.query( + ` + UPDATE work_orders + SET + status = 'unassigned', + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + problem_note = '', + published_at = NULL, + pinned_at = NULL, + updated_at = $1 + WHERE id = $2 + `, + [input.now, input.workOrderId], + ) + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'reopened_from_cancelled', + fromStatus: 'cancelled', + toStatus: 'unassigned', + payloadJson: JSON.stringify({ + previousAssignedWorkerId: Number(workOrder.last_assigned_worker_id || 0) || null, + }), + 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 26c2ae65..72b87826 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -34,6 +34,7 @@ import { markAdminWorkOrderProblem, pinAdminWorkOrder, publishAdminWorkOrder, + reopenAdminCancelledWorkOrder, reprocessAdminKuaishouSendCodeWorkOrders, reviewAdminWorkerFinanceRequest, resolveAdminProblemWorkOrder, @@ -651,6 +652,29 @@ router.delete( }), ) +router.post( + '/worker-platform/orders/:workOrderId/reopen', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + reopenAdminCancelledWorkOrder( + String(req.params.workOrderId || ''), + req.adminSession?.username || '', + ), + { + successMessage: '订单已恢复为未分配状态', + errorMessage: '重新启用订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/reopen]', + audit: (req, data) => ({ + action: 'work_order_reopened', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/publish', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index e43164d6..b5fe2ce6 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -49,6 +49,7 @@ import { sumPendingUnfreezeByOrderIds, resolveProblemWorkOrder, reviewWorkerFinanceRequest, + reopenCancelledWorkOrder, updateWorkOrder, updateWorkOrderBasic, updateWorkerUser, @@ -1795,6 +1796,37 @@ export async function deleteAdminWorkOrder(workOrderId: number | string) { return { deleted: true } } +export async function reopenAdminCancelledWorkOrder(workOrderId: number | string, actorName = '') { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (!mapWorkOrderAdmin(workOrder).capabilities.canReopen) { + throw createHttpError('只有没有有效拼单份额的已取消订单可以重新启用', { + statusCode: 409, + errorCode: 'work_order_reopen_status_invalid', + }) + } + + const { order: updated, failureReason } = await reopenCancelledWorkOrder({ + workOrderId: Number(workOrder.id), + now: nowIso(), + actorName, + }) + if (failureReason === 'work_order_has_active_shares') { + throw createHttpError('该订单仍有有效拼单份额,不能重新启用', { + statusCode: 409, + errorCode: 'work_order_reopen_has_active_shares', + }) + } + if (!updated) { + throw createHttpError('订单状态已变化,无法重新启用', { + statusCode: 409, + errorCode: 'work_order_reopen_conflict', + }) + } + + publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true }) + return { order: mapWorkOrderAdmin(updated) } +} + export async function publishAdminWorkOrder(workOrderId: number | string, actorName = '') { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index f2efc799..8321c15f 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -411,6 +411,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.CANCELLED, ].includes(workOrder.status as never), + canReopen: workOrder.status === WORK_ORDER_STATUS.CANCELLED && !hasActiveShares, canPublish: workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0, canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id), 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 4a3ae0c7..73dea746 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 @@ -734,6 +734,27 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(filled.capabilities.canUnpublish, false) }) +test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => { + const cancelled = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'cancelled' })) + assert.equal(cancelled.capabilities.canReopen, true) + assert.equal(cancelled.capabilities.canEdit, false) + assert.equal(cancelled.capabilities.canPublish, false) + + const restored = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'unassigned' })) + assert.equal(restored.capabilities.canReopen, false) + assert.equal(restored.capabilities.canEdit, true) + assert.equal(restored.capabilities.canPublish, true) + + const cancelledSharing = mapWorkOrderAdmin( + buildWorkOrderRow({ + status: 'cancelled', + sharing_enabled: true, + active_share_quantity: 1, + }), + ) + assert.equal(cancelledSharing.capabilities.canReopen, false) +}) + test('拼单打手汇总展示当前参与者并合并同一打手的份数', () => { const workers = mapWorkOrderSharingWorkers([ buildWorkOrderShareRow({ worker_id: 1, quantity: 2, worker_username: 'alpha' }), diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index f6245974..1ee420a7 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -189,6 +189,8 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { return { title: '问题单已处置:取消订单并扣除押金', color: 'orange' } // —— 取消节点 —— + case 'reopened_from_cancelled': + return { title: '重新启用已取消订单,恢复为未分配状态', color: 'orange' } case 'cancelled_by_admin': return { title: note ? `取消订单(${note})` : '取消订单', diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 64d79b99..b4e10288 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -60,6 +60,7 @@ import { markAdminWorkOrderProblem, pinAdminWorkOrder, publishAdminWorkOrder, + reopenAdminCancelledWorkOrder, resolveAdminProblemWorkOrder, submitAdminWorkOrderMaterial, unpublishAdminWorkOrder, @@ -863,6 +864,26 @@ export default function WorkOrdersPanel() { 删除 ) : null} + {row.capabilities?.canReopen ? ( + + ) : null} {row.sharing?.enabled || row.capabilities?.canConfigureSharing ? (