From ab6387759ec556d409341b9b31073151240f34ec Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 19 Aug 2026 14:35:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=BC=E5=8D=95=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8F=82=E4=B8=8E=E8=80=85?= =?UTF-8?q?=E6=92=A4=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-repo.ts | 173 +++++++++++++++++- .../src/routes/admin/worker-platform.ts | 26 +++ .../realtime/realtime-event-service.ts | 4 +- .../services/worker-platform/admin-service.ts | 47 +++++ .../worker-platform-service.test.ts | 14 +- .../src/components/WorkOrderEventTimeline.tsx | 9 + apps/frontend/src/layouts/AdminLayout.tsx | 18 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 149 +++++++++++---- .../src/services/admin/worker-platform.ts | 12 ++ 9 files changed, 409 insertions(+), 43 deletions(-) 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 998c0c06..b4bf9a45 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -199,6 +199,16 @@ export function resolveWorkOrderShareJoinQuantity(input: { return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity } +/** 校验单个拼单份额能否撤单,并返回母工单应回退到的状态。 */ +export function resolveWorkOrderShareCancellationStatus( + workOrderStatus: string, + shareStatus: string, +): 'open' | null { + if (!['open', 'pending_acceptance'].includes(workOrderStatus)) return null + if (!['joined', 'submitted'].includes(shareStatus)) return null + return 'open' +} + export async function joinWorkOrderShare(input: { workOrderId: number workerId: number @@ -2038,7 +2048,9 @@ export async function acceptWorkOrderAndSettle(input: { ) if (shares.length > 0) { - for (const share of shares) { + // 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。 + const unsettledShares = shares.filter((share) => share.status !== 'accepted') + for (const share of unsettledShares) { const shareWorkerId = Number(share.worker_id || 0) if (!shareWorkerId) continue await ensureWorkerWalletWithClient(client, shareWorkerId, input.now) @@ -2263,7 +2275,10 @@ export async function acceptWorkOrderAndSettle(input: { eventType: 'accepted', fromStatus: 'pending_acceptance', toStatus: 'accepted', - payloadJson: JSON.stringify({ shareCount: shares.length }), + payloadJson: JSON.stringify({ + shareCount: shares.length, + settledShareCount: shares.filter((share) => share.status !== 'accepted').length, + }), now: input.now, }) @@ -2437,6 +2452,149 @@ export async function acceptWorkOrderShareAndSettle(input: { }) } +/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */ +export async function cancelWorkOrderShare(input: { + workOrderId: number + shareId: number + now: string + reason?: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + releasedDepositAmount: number + failureReason: + | 'work_order_not_cancellable' + | 'share_not_found' + | 'share_status_not_cancellable' + | 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 || !['open', 'pending_acceptance'].includes(workOrder.status)) { + return { + order: workOrder, + share: null, + releasedDepositAmount: 0, + failureReason: 'work_order_not_cancellable', + } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { + order: workOrder, + share: null, + releasedDepositAmount: 0, + failureReason: 'share_not_found', + } + } + const nextOrderStatus = resolveWorkOrderShareCancellationStatus(workOrder.status, share.status) + if (!nextOrderStatus) { + return { + order: workOrder, + share, + releasedDepositAmount: 0, + failureReason: 'share_status_not_cancellable', + } + } + + const workerId = Number(share.worker_id || 0) + let releasedDepositAmount = 0 + if (workerId > 0) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + releasedDepositAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releasedDepositAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, + ) + 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, + releasedDepositAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: input.shareId, + reason: String(input.reason || '').trim(), + }), + input.now, + ], + ) + } + } + + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', updated_at = $1 + WHERE id = $2`, + [input.now, input.shareId], + ) + await client.query( + `UPDATE work_orders + SET status = $1, + submitted_at = NULL, + published_at = COALESCE(published_at, $2), + updated_at = $2 + WHERE id = $3`, + [nextOrderStatus, input.now, input.workOrderId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_cancelled_by_admin', + fromStatus: workOrder.status, + toStatus: nextOrderStatus, + payloadJson: JSON.stringify({ + shareId: input.shareId, + workerId, + quantity: Number(share.quantity || 0), + releasedDepositAmount, + reason: String(input.reason || '').trim(), + }), + now: input.now, + }) + + const updatedShareResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + share: updatedShareResult.rows[0] || null, + releasedDepositAmount, + failureReason: null, + } + }) +} + export async function resolveProblemWorkOrder(input: { workOrderId: number action: ProblemWorkOrderResolutionAction @@ -3230,7 +3388,16 @@ function buildWorkOrderWhere({ } if (workerId) { params.push(workerId) - filters.push(`wo.assigned_worker_id = $${params.length}`) + filters.push(`( + wo.assigned_worker_id = $${params.length} + OR EXISTS ( + SELECT 1 + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $${params.length} + AND wos.status != 'cancelled' + ) + )`) } if (categoryId) { params.push(categoryId) diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 09a41b93..f11f58f1 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -6,6 +6,7 @@ import { acceptAdminWorkOrders, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, + cancelAdminWorkOrderShare, unassignAdminWorkOrder, creditAdminWorkerWallet, createAdminMockWorkOrder, @@ -854,6 +855,31 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/cancel', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + cancelAdminWorkOrderShare( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.username || '', + ), + { + successMessage: '拼单参与者已撤单,份数已退回大厅', + errorMessage: '撤销拼单参与者失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]', + audit: (req, data) => ({ + action: 'work_order_share_cancelled', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/deduct-pending-deposit', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 47b0c41a..4cd17dac 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -310,11 +310,13 @@ async function publishWorkOrderSnapshots(input: { const viewedAt = views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null const share = shares.find((item) => Number(item.worker_id) === workerId) + const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId + const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled') publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_my_orders'], - operation: 'upsert', + operation: workerOrderVisible ? 'upsert' : 'remove', data: mapWorkOrderForWorker( workOrder, resolveWorkerPermissions(worker), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index dfdbd318..0dafa700 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 { addWorkerWalletCredit, assignWorkOrderToWorker, cancelAssignedWorkOrder, + cancelWorkOrderShare, returnAssignedWorkOrderToHall, unassignWorkOrder, countWorkerActiveOrders, @@ -2040,6 +2041,52 @@ export async function acceptAdminWorkOrderShare( } } +export async function cancelAdminWorkOrderShare( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const result = await cancelWorkOrderShare({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + now: nowIso(), + reason: String(payload.reason || '').trim(), + actorName, + }) + if (result.failureReason === 'work_order_not_cancellable') { + throw createHttpError('当前拼单状态不能撤销参与者', { + statusCode: 409, + errorCode: 'work_order_sharing_cancel_status_invalid', + }) + } + if (result.failureReason === 'share_not_found') { + throw createHttpError('未找到该拼单参与记录', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (result.failureReason === 'share_status_not_cancellable' || !result.share || !result.order) { + throw createHttpError('只有已参与或已提交验收的拼单份额可以撤单', { + statusCode: 409, + errorCode: 'work_order_sharing_share_cancel_status_invalid', + }) + } + + const workerId = Number(result.share.worker_id || 0) + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order), + hallChanged: true, + }) + if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) + return { + order: mapWorkOrderAdmin(result.order), + share: mapWorkOrderShare(result.share), + releasedDepositAmount: result.releasedDepositAmount, + } +} + export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') { const workOrderIds = [ ...new Set( 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 9b4f073f..63144a4c 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 @@ -8,7 +8,10 @@ import type { WorkProductRuleRow, WorkerUserRow, } from '../../repositories/worker-platform/index.js' -import { resolveWorkOrderShareJoinQuantity } from '../../repositories/worker-platform/index.js' +import { + resolveWorkOrderShareCancellationStatus, + resolveWorkOrderShareJoinQuantity, +} from '../../repositories/worker-platform/index.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { assertWithdrawChannelAllowedForAmount, @@ -729,6 +732,15 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { assert.equal(filled.capabilities.canUnpublish, false) }) +test('拼单份额撤单只允许未验收份额并让母工单回到大厅状态', () => { + assert.equal(resolveWorkOrderShareCancellationStatus('open', 'joined'), 'open') + assert.equal(resolveWorkOrderShareCancellationStatus('open', 'submitted'), 'open') + assert.equal(resolveWorkOrderShareCancellationStatus('pending_acceptance', 'submitted'), 'open') + assert.equal(resolveWorkOrderShareCancellationStatus('pending_acceptance', 'accepted'), null) + assert.equal(resolveWorkOrderShareCancellationStatus('accepted', 'submitted'), null) + assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null) +}) + test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => { const workOrder = buildWorkOrderRow() const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '') diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 59988e3b..75f0115f 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -182,6 +182,15 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { return { title: '更改了订单信息', color: 'default' } case 'sharing_config_updated': return { title: '更新了拼单配置', color: 'default' } + case 'sharing_share_cancelled_by_admin': { + const workerId = readPayload(event, 'workerId') + const quantity = readPayload(event, 'quantity') + return { + title: `撤销拼单参与者${workerId ? `(打手 #${workerId})` : ''}`, + description: `${quantity ? `撤销 ${quantity} 份;` : ''}${note ? `原因:${note}` : '份数已退回接单大厅'}`, + color: 'red', + } + } case 'pending_deposit_deducted': return { title: '扣减了待解冻押金', color: 'default' } case 'voucher_backfilled': diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 3dbebfa4..a342b0b5 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -349,13 +349,17 @@ function applyAdminRealtimeEvent( }) } - if ( - event.type === 'work_order.changed' && - event.scopes.includes('admin_work_orders') && - isRecord(event.data) - ) { - const data = event.data - patchAdminWorkOrderCaches(queryClient, { ...event, data }) + if (event.type === 'work_order.changed' && event.scopes.includes('admin_work_orders')) { + // 拼单明细是独立查询,工单列表快照更新时也要同步刷新当前打开的明细。 + void queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', event.entityId], + }) + if (isRecord(event.data)) { + const data = event.data + patchAdminWorkOrderCaches(queryClient, { ...event, data }) + } else { + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }) + } } if ( diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 875724eb..5565fb1c 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -49,6 +49,7 @@ import { acceptAdminWorkOrders, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, + cancelAdminWorkOrderShare, createAdminMockWorkOrder, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, @@ -120,6 +121,10 @@ type CancelOrderFormValues = { reason?: string } +type CancelShareFormValues = { + reason?: string +} + export default function WorkOrdersPanel() { const { message, modal } = App.useApp() const navigate = useNavigate() @@ -142,6 +147,7 @@ export default function WorkOrdersPanel() { const [materialScreenshots, setMaterialScreenshots] = useState([]) const [materialPasteText, setMaterialPasteText] = useState('') const [sharingOrder, setSharingOrder] = useState(null) + const [cancelShare, setCancelShare] = useState(null) const [assignOrder, setAssignOrder] = useState(null) const [assignWorkerId, setAssignWorkerId] = useState(undefined) const [editOrder, setEditOrder] = useState(null) @@ -151,6 +157,7 @@ export default function WorkOrdersPanel() { const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() const [cancelForm] = Form.useForm() + const [cancelShareForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() const [editForm] = Form.useForm<{ productName?: string @@ -349,6 +356,24 @@ export default function WorkOrdersPanel() { sharingForm.resetFields() } + async function submitCancelShare(values: CancelShareFormValues) { + if (!sharingOrder || !cancelShare) return + const workOrderId = sharingOrder.workOrderId + const succeeded = await runAction( + () => + cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, { + reason: String(values.reason || '').trim(), + }), + '该参与者已撤单,押金和拼单份数已恢复', + ) + if (!succeeded) return + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', workOrderId], + }) + setCancelShare(null) + cancelShareForm.resetFields() + } + async function refreshAll() { await Promise.all([ queryClient.invalidateQueries({ @@ -816,7 +841,9 @@ export default function WorkOrdersPanel() { ) : null} {row.sharing?.enabled || row.capabilities?.canConfigureSharing ? ( ) : null} {row.capabilities?.canAssign ? ( @@ -1589,7 +1616,9 @@ export default function WorkOrdersPanel() { open={Boolean(sharingOrder)} onCancel={() => { setSharingOrder(null) + setCancelShare(null) sharingForm.resetFields() + cancelShareForm.resetFields() }} onOk={() => sharingForm.submit()} footer={sharingOrder?.capabilities?.canConfigureSharing ? undefined : null} @@ -1679,6 +1708,7 @@ export default function WorkOrdersPanel() { rowKey="shareId" size="small" dataSource={sharingQuery.data?.data.shares || []} + loading={sharingQuery.isFetching} pagination={false} columns={[ { @@ -1709,8 +1739,14 @@ export default function WorkOrdersPanel() { width: 150, render: (_, share) => ( @@ -1722,35 +1758,55 @@ export default function WorkOrdersPanel() { }, { title: '操作', - width: 110, - render: (_, share) => - share.status === 'submitted' ? ( - - ) : share.status === 'accepted' ? ( - 已验收 - ) : ( - 待提交 - ), + }} + > + 单独验收 + + ) : ( + 待提交 + )} + + + ) + }, }, ]} /> @@ -1759,6 +1815,37 @@ export default function WorkOrdersPanel() { ) : null} + + { + setCancelShare(null) + cancelShareForm.resetFields() + }} + onOk={() => cancelShareForm.submit()} + destroyOnHidden + > + {cancelShare ? ( +
+ + + + + + ) : null} +
) } diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index ca738ba4..276ed517 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -470,6 +470,18 @@ export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number) ) } +export function cancelAdminWorkOrderShare( + workOrderId: number, + shareId: number, + payload: { reason?: string }, +) { + return apiPost<{ + order: WorkOrder + share: WorkOrderShare + releasedDepositAmount: number + }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload) +} + export function acceptAdminWorkOrders(workOrderIds: number[]) { return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', { workOrderIds,