From d91d7180adf5fd5be81e69bcb5d67b281945a945 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 28 Aug 2026 19:40:49 +0800 Subject: [PATCH] feat(worker): support batch sharing cancellation --- .../repositories/worker-platform/shared.ts | 54 +++ .../src/repositories/worker-platform/types.ts | 1 + .../worker-platform/work-order-repo.ts | 1 + .../work-order-settlement-repo.ts | 163 +++++++++ .../admin/worker-platform/acceptance.ts | 31 +- .../admin-work-order-acceptance-service.ts | 76 ++++ .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 4 + .../worker-platform-service.test.ts | 41 +++ .../src/components/WorkOrderEventTimeline.tsx | 16 + .../pages/admin/panels/WorkOrdersPanel.tsx | 325 +++++++++++++----- .../src/services/admin/worker-platform.ts | 15 + apps/frontend/src/types/worker-platform.ts | 1 + 13 files changed, 633 insertions(+), 96 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/shared.ts b/apps/backend/src/repositories/worker-platform/shared.ts index 9af1016f..0bf114d6 100644 --- a/apps/backend/src/repositories/worker-platform/shared.ts +++ b/apps/backend/src/repositories/worker-platform/shared.ts @@ -61,3 +61,57 @@ export function resolveWorkOrderShareCancellationStatus( if (!['joined', 'submitted'].includes(shareStatus)) return null return 'open' } + +/** + * 校验母单级拼单撤单。退回大厅只影响尚未验收的份额;取消整单则不能存在已验收份额, + * 否则必须经售后处理已结算的报酬和订单退款。 + */ +export function resolveWorkOrderSharingCancellationPlan( + workOrderStatus: string, + shareStatuses: string[], + action: 'return_to_hall' | 'cancel_order', +): { + nextOrderStatus: 'open' | 'cancelled' | null + cancellableShareCount: number + acceptedShareCount: number + failureReason: + | 'work_order_not_cancellable' + | 'no_cancellable_shares' + | 'accepted_shares_present' + | null +} { + if (!['open', 'pending_acceptance', 'in_progress'].includes(workOrderStatus)) { + return { + nextOrderStatus: null, + cancellableShareCount: 0, + acceptedShareCount: 0, + failureReason: 'work_order_not_cancellable', + } + } + const cancellableShareCount = shareStatuses.filter((status) => + ['joined', 'submitted'].includes(status), + ).length + const acceptedShareCount = shareStatuses.filter((status) => status === 'accepted').length + if (cancellableShareCount === 0) { + return { + nextOrderStatus: null, + cancellableShareCount, + acceptedShareCount, + failureReason: 'no_cancellable_shares', + } + } + if (action === 'cancel_order' && acceptedShareCount > 0) { + return { + nextOrderStatus: null, + cancellableShareCount, + acceptedShareCount, + failureReason: 'accepted_shares_present', + } + } + return { + nextOrderStatus: action === 'return_to_hall' ? 'open' : 'cancelled', + cancellableShareCount, + acceptedShareCount, + failureReason: null, + } +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index b6f38d46..78485681 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -473,6 +473,7 @@ export type WorkOrderCapabilities = { canReopen: boolean canPublish: boolean canCancel: boolean + canCancelSharing: boolean canResolveProblem: boolean canDeduct: boolean canConfirmFriend: 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 e597583e..d28a74e0 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -28,4 +28,5 @@ export { acceptWorkOrderAndSettle, acceptWorkOrderShareAndSettle, cancelWorkOrderShare, + cancelWorkOrderSharing, } from './work-order-settlement-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 5363d08d..49eefb61 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -5,6 +5,7 @@ import { getWorkerWalletWithClient, WORK_ORDER_SHARE_SELECT, resolveWorkOrderShareCancellationStatus, + resolveWorkOrderSharingCancellationPlan, } from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' @@ -740,3 +741,165 @@ export async function cancelWorkOrderShare(input: { } }) } + +/** + * 母单级拼单撤单。只撤回尚未验收的 joined/submitted 份额;已验收份额保留, + * 取消整单时则要求不存在已验收份额,避免绕过售后和已结算资金处理。 + */ +export async function cancelWorkOrderSharing(input: { + workOrderId: number + action: 'return_to_hall' | 'cancel_order' + now: string + reason?: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + cancelledShareIds: number[] + cancelledWorkerIds: number[] + cancelledShareCount: number + releasedDepositAmount: number + failureReason: + | 'work_order_not_cancellable' + | 'no_cancellable_shares' + | 'accepted_shares_present' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.sharing_enabled !== true) { + return { + order: workOrder, + cancelledShareIds: [], + cancelledWorkerIds: [], + cancelledShareCount: 0, + releasedDepositAmount: 0, + failureReason: 'work_order_not_cancellable', + } + } + const sharesResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + ORDER BY id ASC + FOR UPDATE`, + [input.workOrderId], + ) + const shares = sharesResult.rows + const plan = resolveWorkOrderSharingCancellationPlan( + workOrder.status, + shares.map((share) => share.status), + input.action, + ) + if (!plan.nextOrderStatus) { + return { + order: workOrder, + cancelledShareIds: [], + cancelledWorkerIds: [], + cancelledShareCount: plan.cancellableShareCount, + releasedDepositAmount: 0, + failureReason: plan.failureReason, + } + } + + const cancellableShares = shares.filter((share) => ['joined', 'submitted'].includes(share.status)) + let releasedDepositAmount = 0 + for (const share of cancellableShares) { + const workerId = Number(share.worker_id || 0) + if (workerId <= 0) continue + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount <= 0) continue + 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, '后台批量撤销拼单份额退还押金', $6::jsonb, $7)`, + [ + workerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: Number(share.id), + action: input.action, + reason: String(input.reason || '').trim(), + }), + input.now, + ], + ) + releasedDepositAmount += releaseAmount + } + + const cancelledShareIds = cancellableShares.map((share) => Number(share.id)) + const cancelledWorkerIds = [ + ...new Set(cancellableShares.map((share) => Number(share.worker_id || 0)).filter(Boolean)), + ] + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', updated_at = $1 + WHERE id = ANY($2::bigint[])`, + [input.now, cancelledShareIds], + ) + if (input.action === 'return_to_hall') { + await client.query( + `UPDATE work_orders + SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1), + hall_queued_at = $1, updated_at = $1 + WHERE id = $2`, + [input.now, input.workOrderId], + ) + } else { + await client.query( + `UPDATE work_orders + SET status = 'cancelled', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, + submitted_at = NULL, 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: + input.action === 'return_to_hall' + ? 'sharing_unaccepted_shares_returned_to_hall_by_admin' + : 'sharing_order_cancelled_by_admin', + fromStatus: workOrder.status, + toStatus: plan.nextOrderStatus, + payloadJson: JSON.stringify({ + cancelledShareIds, + cancelledShareCount: cancelledShareIds.length, + acceptedShareCount: plan.acceptedShareCount, + releasedDepositAmount, + reason: String(input.reason || '').trim(), + }), + now: input.now, + }) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + cancelledShareIds, + cancelledWorkerIds, + cancelledShareCount: cancelledShareIds.length, + releasedDepositAmount, + failureReason: null, + } + }) +} diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 3ffb5faa..d6c81caf 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -4,11 +4,12 @@ import { acceptAdminWorkOrder, acceptAdminWorkOrderShare, cancelAdminWorkOrderShare, + cancelAdminWorkOrderSharing, deductAdminWorkOrderPendingDeposit, markAdminWorkOrderProblem, resolveAdminProblemWorkOrder, } from '../../../services/worker-platform/index.js' -import { createJsonHandler, requireAdminPermission } from '../session.js' +import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js' const router = Router() @@ -104,6 +105,34 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/sharing/cancel', + requireAnyAdminPermission(['worker_order.return_to_hall', 'worker_order.cancel']), + createJsonHandler( + (req) => + cancelAdminWorkOrderSharing( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + req.adminSession?.permissions || [], + ), + { + successMessage: '拼单撤单处理完成', + errorMessage: '拼单撤单失败', + scope: '[admin/worker-platform/orders/:workOrderId/sharing/cancel]', + audit: (req, data) => ({ + action: + String(req.body?.action || '').trim() === 'return_to_hall' + ? 'work_order_sharing_returned_to_hall' + : 'work_order_sharing_cancelled', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/shares/:shareId/cancel', requireAdminPermission('worker_order.cancel_share'), diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index 4db0dd38..b7015d42 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -3,6 +3,7 @@ import { acceptWorkOrderAndSettle, acceptWorkOrderShareAndSettle, cancelWorkOrderShare, + cancelWorkOrderSharing, countWorkOrderPendingSharingSubmissions, updateWorkOrder, } from '../../repositories/worker-platform/index.js' @@ -157,6 +158,81 @@ export async function cancelAdminWorkOrderShare( } } +export async function cancelAdminWorkOrderSharing( + workOrderId: number | string, + payload: JsonObject = {}, + actorName = '', + actorPermissions: string[] = [], +) { + const action = String(payload.action || '').trim() + if (!['return_to_hall', 'cancel_order'].includes(action)) { + throw createHttpError('请选择拼单撤单处理方式', { + statusCode: 400, + errorCode: 'work_order_sharing_cancel_action_required', + }) + } + const requiredPermission = + action === 'return_to_hall' ? 'worker_order.return_to_hall' : 'worker_order.cancel' + if (!actorPermissions.includes(requiredPermission)) { + throw createHttpError('当前账号没有此拼单撤单权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) + } + const reason = String(payload.reason || '').trim() + if (action === 'cancel_order' && !reason) { + throw createHttpError('取消拼单订单时请填写退款或撤单原因', { + statusCode: 400, + errorCode: 'work_order_sharing_cancel_reason_required', + }) + } + const result = await cancelWorkOrderSharing({ + workOrderId: Number(workOrderId), + action: action as 'return_to_hall' | 'cancel_order', + reason, + now: nowIso(), + actorName, + }) + if (result.failureReason === 'work_order_not_cancellable') { + throw createHttpError('当前订单不是可撤单的拼单', { + statusCode: 409, + errorCode: 'work_order_sharing_cancel_status_invalid', + }) + } + if (result.failureReason === 'no_cancellable_shares') { + throw createHttpError('没有可撤销的未验收拼单份额', { + statusCode: 409, + errorCode: 'work_order_sharing_cancel_no_cancellable_shares', + }) + } + if (result.failureReason === 'accepted_shares_present') { + throw createHttpError('存在已验收份额,不能取消整单;请通过售后处理退款', { + statusCode: 409, + errorCode: 'work_order_sharing_cancel_accepted_shares_present', + }) + } + if (!result.order) { + throw createHttpError('拼单撤单失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_sharing_cancel_conflict', + }) + } + const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order) + const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])] + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds, + hallChanged: action === 'return_to_hall', + }) + for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId) + return { + order: mapWorkOrderAdmin(result.order), + action, + cancelledShareCount: result.cancelledShareCount, + releasedDepositAmount: result.releasedDepositAmount, + } +} + export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') { const workOrderIds = [ ...new Set( diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index d549f8bc..b354c19b 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -51,6 +51,7 @@ export { acceptAdminWorkOrderShare, acceptAdminWorkOrders, cancelAdminWorkOrderShare, + cancelAdminWorkOrderSharing, } from './admin-work-order-acceptance-service.js' export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 0818d4c0..fea737f8 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -174,6 +174,10 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha 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), + canCancelSharing: + workOrder.sharing_enabled === true && + hasActiveShares && + ['open', 'pending_acceptance', 'in_progress'].includes(workOrder.status), canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, canConfirmFriend: 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 9d1f51bb..a29a6875 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 @@ -13,6 +13,7 @@ import type { import { buildWorkOrderWhere, resolveWorkOrderShareCancellationStatus, + resolveWorkOrderSharingCancellationPlan, resolveWorkOrderShareJoinQuantity, resolveOpenSharingSyncReward, } from '../../repositories/worker-platform/index.js' @@ -991,6 +992,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(partial.capabilities.canConfigureSharing, false) assert.equal(partial.capabilities.canAssign, false) assert.equal(partial.capabilities.canUpdateMaterial, true) + assert.equal(partial.capabilities.canCancelSharing, true) const filled = mapWorkOrderAdmin( buildWorkOrderRow({ @@ -1005,6 +1007,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(filled.hallAvailable, false) assert.equal(filled.capabilities.canAccept, true) assert.equal(filled.capabilities.canUnpublish, false) + assert.equal(filled.capabilities.canCancelSharing, true) }) test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => { @@ -1051,6 +1054,44 @@ test('拼单份额撤单只允许未验收份额并让母工单回到大厅状 assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null) }) +test('拼单母单撤单区分退回大厅和取消整单的验收边界', () => { + assert.deepEqual( + resolveWorkOrderSharingCancellationPlan( + 'pending_acceptance', + ['submitted', 'accepted'], + 'return_to_hall', + ), + { + nextOrderStatus: 'open', + cancellableShareCount: 1, + acceptedShareCount: 1, + failureReason: null, + }, + ) + assert.equal( + resolveWorkOrderSharingCancellationPlan( + 'pending_acceptance', + ['submitted', 'accepted'], + 'cancel_order', + ).failureReason, + 'accepted_shares_present', + ) + assert.deepEqual( + resolveWorkOrderSharingCancellationPlan('open', ['joined', 'submitted'], 'cancel_order'), + { + nextOrderStatus: 'cancelled', + cancellableShareCount: 2, + acceptedShareCount: 0, + failureReason: null, + }, + ) + assert.equal( + resolveWorkOrderSharingCancellationPlan('accepted', ['submitted'], 'return_to_hall') + .failureReason, + 'work_order_not_cancellable', + ) +}) + test('问题单退回打手按标记前状态恢复', () => { assert.equal(resolveProblemReturnToWorkerStatus('pending_acceptance'), 'pending_acceptance') assert.equal(resolveProblemReturnToWorkerStatus('open'), 'open') diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 3e05524e..11a084b7 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -313,6 +313,22 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { color: 'red', } } + case 'sharing_unaccepted_shares_returned_to_hall_by_admin': { + const count = readPayload(event, 'cancelledShareCount') + return { + title: `批量撤回未验收拼单份额${count ? `(${count} 个)` : ''}`, + description: note ? `原因:${note}` : '对应数量已重新开放到接单大厅', + color: 'orange', + } + } + case 'sharing_order_cancelled_by_admin': { + const count = readPayload(event, 'cancelledShareCount') + return { + title: `取消拼单订单${count ? `(已撤销 ${count} 个参与份额)` : ''}`, + description: note ? `原因:${note}` : undefined, + color: 'red', + } + } case 'pending_deposit_deducted': return { title: '扣减了待解冻押金', color: 'default' } case 'voucher_backfilled': diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index c8639622..d3db33fd 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -49,6 +49,7 @@ import { assignAdminWorkOrderToWorker, cancelAdminWorkOrder, cancelAdminWorkOrderShare, + cancelAdminWorkOrderSharing, confirmAdminWorkOrderFriend, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, @@ -146,6 +147,11 @@ type CancelShareFormValues = { reason?: string } +type CancelSharingFormValues = { + action?: 'return_to_hall' | 'cancel_order' + reason?: string +} + export default function WorkOrdersPanel() { const { message, modal } = App.useApp() const navigate = useNavigate() @@ -221,6 +227,7 @@ export default function WorkOrdersPanel() { const [materialPasteText, setMaterialPasteText] = useState('') const [sharingOrder, setSharingOrder] = useState(null) const [cancelShare, setCancelShare] = useState(null) + const [cancelSharingOrder, setCancelSharingOrder] = useState(null) const [assignOrder, setAssignOrder] = useState(null) const [assignWorkerId, setAssignWorkerId] = useState(undefined) const [editOrder, setEditOrder] = useState(null) @@ -232,6 +239,7 @@ export default function WorkOrdersPanel() { const [materialForm] = Form.useForm() const [cancelForm] = Form.useForm() const [cancelShareForm] = Form.useForm() + const [cancelSharingForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() const [editForm] = Form.useForm<{ productName?: string @@ -335,6 +343,11 @@ export default function WorkOrdersPanel() { queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)), enabled: Boolean(sharingOrder), }) + const sharingShares = sharingQuery.data?.data.shares || [] + const hasCancellableSharingShares = sharingShares.some((share) => + ['joined', 'submitted'].includes(share.status), + ) + const hasAcceptedSharingShares = sharingShares.some((share) => share.status === 'accepted') const assignWorkersQuery = useQuery({ queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword], queryFn: () => @@ -498,6 +511,28 @@ export default function WorkOrdersPanel() { cancelShareForm.resetFields() } + async function submitCancelSharing(values: CancelSharingFormValues) { + if (!cancelSharingOrder || !values.action) return + const action = values.action + const succeeded = await runAction( + () => + cancelAdminWorkOrderSharing(cancelSharingOrder.workOrderId, { + action, + reason: String(values.reason || '').trim(), + }), + (result) => + action === 'return_to_hall' + ? `已撤回 ${result.data.cancelledShareCount} 个未验收份额并退回大厅` + : `拼单订单已取消,已撤销 ${result.data.cancelledShareCount} 个参与份额`, + ) + if (!succeeded) return + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', cancelSharingOrder.workOrderId], + }) + setCancelSharingOrder(null) + cancelSharingForm.resetFields() + } + async function refreshAll() { await Promise.all([ queryClient.invalidateQueries({ @@ -1097,7 +1132,7 @@ export default function WorkOrdersPanel() { (row.capabilities?.canConfigureSharing && canConfigureSharing) ? ( ) : null} @@ -2148,8 +2183,10 @@ export default function WorkOrdersPanel() { onCancel={() => { setSharingOrder(null) setCancelShare(null) + setCancelSharingOrder(null) sharingForm.resetFields() cancelShareForm.resetFields() + cancelSharingForm.resetFields() }} onOk={() => sharingForm.submit()} footer={ @@ -2237,114 +2274,152 @@ export default function WorkOrdersPanel() { ) : (sharingQuery.data?.data.shares || []).length === 0 ? ( 暂无打手参与拼单 ) : ( - - rowKey="shareId" - size="small" - dataSource={sharingQuery.data?.data.shares || []} - loading={sharingQuery.isFetching} - pagination={false} - columns={[ - { - title: '打手', - render: (_, share) => - share.worker?.displayName || share.worker?.username || '-', - }, - { title: '数量', dataIndex: 'quantity' }, - { - title: '份额报酬', - render: (_, share) => formatMoney(share.shareReward), - }, - { - title: '状态', - render: (_, share) => ( - - {formatSharingShareStatus(share.status)} - - ), - }, - { - title: '提交时间', - render: (_, share) => - share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-', - }, - { - title: '验收图', - width: 150, - render: (_, share) => ( - - ), - }, - { - title: '备注', - render: (_, share) => String(share.acceptance?.note || '').trim() || '-', - }, - { - title: '操作', - width: 190, - render: (_, share) => { - if (share.status === 'accepted') return 已验收 - if (share.status === 'cancelled') return 已撤单 - return ( - - {share.status === 'submitted' ? ( - + + + ) : null} + + rowKey="shareId" + size="small" + dataSource={sharingShares} + loading={sharingQuery.isFetching} + pagination={false} + columns={[ + { + title: '打手', + render: (_, share) => + share.worker?.displayName || share.worker?.username || '-', + }, + { title: '数量', dataIndex: 'quantity' }, + { + title: '份额报酬', + render: (_, share) => formatMoney(share.shareReward), + }, + { + title: '状态', + render: (_, share) => ( + + {formatSharingShareStatus(share.status)} + + ), + }, + { + title: '提交时间', + render: (_, share) => + share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-', + }, + { + title: '验收图', + width: 150, + render: (_, share) => ( + + ), + }, + { + title: '备注', + render: (_, share) => String(share.acceptance?.note || '').trim() || '-', + }, + { + title: '操作', + width: 190, + render: (_, share) => { + if (share.status === 'accepted') return 已验收 + if (share.status === 'cancelled') return 已撤单 + return ( + + {share.status === 'submitted' ? ( + - ) : ( - 待提交 - )} + share.shareId, + ), + '该拼单参与者已验收', + ).then((succeeded) => { + if (succeeded) { + void queryClient.invalidateQueries({ + queryKey: [ + 'admin-worker-platform-order-sharing', + sharingOrder.workOrderId, + ], + }) + } + }) + }} + > + 单独验收 + + ) : ( + 待提交 + )} {canCancelShare ? ( ) : null} - - ) + + ) + }, }, - }, - ]} - /> + ]} + /> + )} @@ -2381,6 +2456,66 @@ export default function WorkOrdersPanel() { ) : null} + + { + setCancelSharingOrder(null) + cancelSharingForm.resetFields() + }} + onOk={() => cancelSharingForm.submit()} + destroyOnHidden + > + {cancelSharingOrder ? ( +
+ + + + + {canReturnToHall ? ( + + 退回大厅:撤回所有未验收份额,空出数量重新开放给其他打手 + + ) : null} + {canCancelOrder ? ( + + 取消整单:终止订单;存在已验收份额时不可用,需转售后处理 + + ) : null} + + + + ({ + validator(_, value) { + if (getFieldValue('action') !== 'cancel_order' || String(value || '').trim()) { + return Promise.resolve() + } + return Promise.reject(new Error('取消整单时请填写退款或撤单原因')) + }, + }), + ]} + > + + + + ) : null} +
) } diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 30523ba2..850a4a4d 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -728,6 +728,21 @@ export function cancelAdminWorkOrderShare( }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload) } +export function cancelAdminWorkOrderSharing( + workOrderId: number, + payload: { + action: 'return_to_hall' | 'cancel_order' + reason?: string + }, +) { + return apiPost<{ + order: WorkOrder + action: 'return_to_hall' | 'cancel_order' + cancelledShareCount: number + releasedDepositAmount: number + }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel`, payload) +} + export function acceptAdminWorkOrders(workOrderIds: number[]) { return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', { workOrderIds, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index fda40796..790ca523 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -550,6 +550,7 @@ export type WorkOrder = { canReopen: boolean canPublish: boolean canCancel: boolean + canCancelSharing?: boolean canResolveProblem: boolean canDeduct: boolean canConfirmFriend?: boolean