From 4fd533c678a6b74119f4ecfa37153f377cf2dd99 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 1 Nov 2026 15:12:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker):=20=E6=8B=BC=E5=8D=95=E5=8D=95?= =?UTF-8?q?=E4=BA=BA=E6=92=A4=E5=8D=95=E5=90=8E=E7=A9=BA=E5=87=BA=E4=BB=BD?= =?UTF-8?q?=E9=A2=9D=E5=9B=9E=E5=A4=A7=E5=8E=85=E5=B9=B6=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E6=AF=8D=E5=8D=95=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-issue-repo.ts | 2 +- .../work-order-settlement-repo.ts | 28 +++++++++++------ .../worker-platform/work-order-share-repo.ts | 23 +++++++++++--- .../admin/worker-platform/acceptance.ts | 2 +- .../admin-work-order-acceptance-service.ts | 3 +- .../worker-platform-service.test.ts | 31 +++++++++++++++---- .../src/components/WorkOrderEventTimeline.tsx | 19 ++++++++++-- .../pages/admin/panels/WorkOrdersPanel.tsx | 6 ++-- 8 files changed, 85 insertions(+), 29 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 27919778..81e87951 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -539,7 +539,7 @@ export async function disposeWorkOrderShareProblem(input: { ) } - // 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。 + // 份额级问题单只处置这一份,不重算母单状态:被标记的份额不走「退回大厅」的份额释放口径。 await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', 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 b9900a26..84ae7f23 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 @@ -821,9 +821,9 @@ export async function acceptWorkOrderShareAndSettle(input: { /** * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 * - * **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把 - * 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的 - * 退回大厅/取消整单显式操作。 + * 份额取消后空出的份数必须回到接单大厅,所以这里用统一口径重算母单状态: + * 还有其它有效份额 → open(后台按份额推导为代练中);一份不剩 → open(待抢单)。 + * 重算时保留原 published_at / hall_queued_at,空出的份额沿用原大厅队列位置。 */ export async function cancelWorkOrderShare(input: { workOrderId: number @@ -936,22 +936,27 @@ export async function cancelWorkOrderShare(input: { WHERE id = $3`, [input.now, String(input.reason || '').trim(), input.shareId], ) - // 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。 - // (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open, - // 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。) + // 空出的份数回大厅:母单状态必须跟着剩余份额重算,不能停留在待验收。 + // 保留原队列位置,避免撤掉一份就把整单排到大厅队尾。 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, { + preserveHallQueue: true, + }) + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_cancelled_by_admin', fromStatus: workOrder.status, - toStatus: workOrder.status, + toStatus: updatedOrder?.status || workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId, quantity: Number(share.quantity || 0), releasedDepositAmount, reason: String(input.reason || '').trim(), + orderStatusAfter: updatedOrder?.status || workOrder.status, + activeShareQuantity: Number(updatedOrder?.active_share_quantity || 0), }), now: input.now, }) @@ -961,7 +966,7 @@ export async function cancelWorkOrderShare(input: { [input.shareId], ) return { - order: await getWorkOrderByIdWithClient(client, input.workOrderId), + order: updatedOrder || workOrder, share: updatedShareResult.rows[0] || null, releasedDepositAmount, failureReason: null, @@ -1127,8 +1132,11 @@ export async function settleOverdueWorkOrderShare(input: { [input.now, input.shareId], ) - // 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅 - await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) + // 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅; + // 空出的份数回大厅时保留原队列位置,避免超时一次就重排到队尾。 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, { + preserveHallQueue: true, + }) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 69f84af3..8db212d1 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -497,13 +497,19 @@ export async function reduceWorkOrderShareQuantity(input: { ], ) + // 减少的份数会回到大厅,母单状态跟着剩余份额重算;保留原队列位置。 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, { + preserveHallQueue: true, + }) + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_quantity_reduced', fromStatus: workOrder.status, - toStatus: workOrder.status, + toStatus: updatedOrder?.status || workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId: Number(share.worker_id || 0), @@ -515,6 +521,8 @@ export async function reduceWorkOrderShareQuantity(input: { // 押金口径:本次保持原冻结额,不退不补 shareDeposit: Number(share.share_deposit || 0), note: String(input.note || '').trim(), + orderStatusAfter: updatedOrder?.status || workOrder.status, + activeShareQuantity: Number(updatedOrder?.active_share_quantity || 0), }), now: input.now, }) @@ -524,7 +532,6 @@ export async function reduceWorkOrderShareQuantity(input: { [input.shareId], ) // 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零 - const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) return { order: updatedOrder || workOrder, share: updatedShare.rows[0] || null, @@ -625,11 +632,15 @@ export async function submitWorkOrderShareAcceptance(input: { * 母单 accepted / cancelled → 不动 * * 返回更新后的母单行(供调用方回给前端)。 + * + * preserveHallQueue:份额退回大厅时保留原 published_at / hall_queued_at, + * 避免因为母单状态从待验收回退到 open 而被重新排到大厅队尾。 */ export async function recomputeWorkOrderSharingStatusWithClient( client: PoolClient, workOrderId: number, now: string, + options: { preserveHallQueue?: boolean } = {}, ): Promise { const orderResult = await client.query( `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, @@ -676,13 +687,17 @@ export async function recomputeWorkOrderSharingStatusWithClient( return workOrder } + const hallQueueAssignments = options.preserveHallQueue + ? '' + : `published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, + hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,` + const updated = await client.query( ` UPDATE work_orders SET status = $1, - published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, - hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, + ${hallQueueAssignments} submitted_at = CASE WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2) ELSE submitted_at diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index fe5c1121..ecdceba5 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -177,7 +177,7 @@ router.post( req.adminSession?.displayName || req.adminSession?.username || '', ), { - successMessage: '拼单参与者已撤单,押金已退还,母单状态保持不变', + successMessage: '拼单参与者已撤单,押金已退还,空出的份数已回到接单大厅', errorMessage: '撤销拼单参与者失败', scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]', audit: (req, data) => ({ 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 a381af92..59547dbd 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 @@ -196,7 +196,8 @@ export async function cancelAdminWorkOrderShare( * 后台按份数撤销某个打手的部分份额。 * * 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额, - * 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。 + * 母单状态按剩余份额重算(可能从待验收回到代练中,保留原大厅队列位置), + * 该份额的验收资料不动(之后仍可对该份额「单独验收」)。 */ export async function reduceAdminWorkOrderShareQuantity( workOrderId: number | string, 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 8943edaf..190b875f 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 @@ -3510,13 +3510,21 @@ type ShareAggregate = { async function runRecompute( workOrder: Partial, aggregate: ShareAggregate, -): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> { + options: { preserveHallQueue?: boolean } = {}, +): Promise<{ + status: string | null + updates: unknown[][] + shareQueries: number + updateSqls: string[] +}> { const updates: unknown[][] = [] + const updateSqls: string[] = [] let shareQueries = 0 const client = { query: async (sql: string, params: unknown[] = []) => { if (sql.includes('UPDATE work_orders')) { updates.push(params) + updateSqls.push(sql) return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] } } if (sql.includes('FROM work_orders')) { @@ -3534,8 +3542,9 @@ async function runRecompute( client, 1, '2026-10-23T12:00:00.000Z', + options, ) - return { status: updated ? String(updated.status) : null, updates, shareQueries } + return { status: updated ? String(updated.status) : null, updates, shareQueries, updateSqls } } test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => { @@ -3574,7 +3583,7 @@ test('拼单母单已验收或已取消时 recompute 不回头改状态', async } }) -test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => { +test('后台单人撤单重算母单状态,空出份额回大厅并保留原队列位置', async () => { const src = fs.readFileSync( new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), 'utf8', @@ -3585,14 +3594,18 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收 ) assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') assert.ok( - !cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), - '后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)', + cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '后台单人撤单必须重算母单状态,空出的份数才能回到大厅', ) + assert.ok(cancelBlock.includes('preserveHallQueue: true'), '后台单人撤单回大厅必须保留原队列位置') assert.ok( !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(cancelBlock), '后台单人撤单不得自己写母单 status', ) - assert.ok(cancelBlock.includes('toStatus: workOrder.status'), '撤单事件不得记录母单状态迁移') + assert.ok( + cancelBlock.includes('toStatus: updatedOrder?.status || workOrder.status'), + '撤单事件必须记录母单重算后的状态', + ) const timeoutBlock = src.slice( src.indexOf('export async function settleOverdueWorkOrderShare('), @@ -3603,6 +3616,7 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收 timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('), '份额超时必须用 recompute 收敛母单状态', ) + assert.ok(timeoutBlock.includes('preserveHallQueue: true'), '份额超时回大厅必须保留原队列位置') assert.ok( !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock), '份额超时不得自己写母单 status(只能由 recompute 推导)', @@ -3642,6 +3656,11 @@ test('撤份额与单人撤单都把原因和份数写进份额行,列表才 shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('), ) assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在') + assert.ok( + reduceBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '撤份额必须重算母单状态,减少的份数才能回大厅', + ) + assert.ok(reduceBlock.includes('preserveHallQueue: true'), '撤份额回大厅必须保留原队列位置') assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note') assert.ok( reduceBlock.includes('String(share.cancel_note'), diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index a06ad096..738a58b7 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -158,8 +158,16 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { title: note ? `客服已处理问题反馈:${note}` : '客服已处理问题反馈', color: 'green', } - case 'timeout_reopen': - return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' } + case 'timeout_reopen': { + const isSharing = Boolean(event.payload?.isSharing) + const quantity = readPayloadAmount(event, 'quantity') + return { + title: isSharing + ? `拼单份额超时未完成${quantity > 0 ? `(${quantity} 份)` : ''},已退回抢单大厅` + : '订单超时未完成,已退回抢单大厅', + color: 'orange', + } + } // —— 资料节点 —— case 'material_submitted': @@ -418,9 +426,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { case 'sharing_share_cancelled_by_admin': { const workerLabel = resolveEventWorkerLabel(event) const quantity = readPayload(event, 'quantity') + const details = [ + quantity ? `撤销 ${quantity} 份` : '', + '空出的份数已回到接单大厅', + note ? `原因:${note}` : '', + ].filter(Boolean) return { title: `撤销拼单参与者${workerLabel ? `(${workerLabel})` : ''}`, - description: `${quantity ? `撤销 ${quantity} 份;` : ''}${note ? `原因:${note}` : '份数已退回接单大厅'}`, + description: details.join(';'), color: 'red', } } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 0b9e04d2..fb1a9b68 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -780,7 +780,7 @@ export default function WorkOrdersPanel() { cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, { reason: String(values.reason || '').trim(), }), - '该参与者已撤单,押金已退还,母单状态未变更', + '该参与者已撤单,押金已退还,空出的份数已回到接单大厅', ) if (!succeeded) return await queryClient.invalidateQueries({ @@ -3714,7 +3714,7 @@ export default function WorkOrdersPanel() { danger size="small" icon={} - title="只撤销当前参与者的拼单份额,其他参与者不受影响" + title="只撤销当前参与者的拼单份额,空出的份数回到接单大厅,其他参与者不受影响" onClick={() => { setCancelShare(share) cancelShareForm.setFieldsValue({ reason: '' }) @@ -3776,7 +3776,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`} - description="撤单只释放这一份份额并退还该份额冻结的押金,母单状态保持不变(待验收/进行中的单不会被踢回大厅);已提交的验收资料会保留在后台记录中。" + description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单状态按剩余份额重新计算;该份额已提交的验收资料会被清空,仅保留撤单记录。" style={{ marginBottom: 16 }} />