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 c4967d26..b39852a0 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 @@ -709,7 +709,13 @@ export async function acceptWorkOrderShareAndSettle(input: { }) } -/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */ +/** + * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 + * + * **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把 + * 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的 + * 退回大厅/取消整单显式操作。 + */ export async function cancelWorkOrderShare(input: { workOrderId: number shareId: number @@ -819,19 +825,16 @@ export async function cancelWorkOrderShare(input: { WHERE id = $2`, [input.now, input.shareId], ) - // 母单状态由剩余份额集合推导:撤掉这一份后,其余已提交份额仍可正常待验收 - const recomputedOrder = await recomputeWorkOrderSharingStatusWithClient( - client, - input.workOrderId, - input.now, - ) + // 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。 + // (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open, + // 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_cancelled_by_admin', fromStatus: workOrder.status, - toStatus: recomputedOrder?.status || workOrder.status, + toStatus: workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId, diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 3a91e57a..d84a8b0f 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -148,7 +148,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/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index acb6a5aa..d1430dfa 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 @@ -3302,33 +3302,39 @@ test('拼单母单已验收或已取消时 recompute 不回头改状态', async } }) -test('份额超时/撤单只改份额本身,母单状态统一交给 recompute', () => { +test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => { const src = fs.readFileSync( new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), 'utf8', ) - const blocks = [ - [ - 'export async function cancelWorkOrderShare(', - 'export async function settleOverdueWorkOrderShare(', - ], - [ - 'export async function settleOverdueWorkOrderShare(', - 'export async function cancelWorkOrderSharing(', - ], - ] - for (const [start, end] of blocks) { - const block = src.slice(src.indexOf(start!), src.indexOf(end!)) - assert.ok(block.length > 0, `${start} 片段必须存在`) - assert.ok( - block.includes('recomputeWorkOrderSharingStatusWithClient('), - `${start} 必须用 recompute 收敛母单状态`, - ) - assert.ok( - !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(block), - `${start} 不得自己写母单 status(只能由 recompute 推导)`, - ) - } + const cancelBlock = src.slice( + src.indexOf('export async function cancelWorkOrderShare('), + src.indexOf('export async function settleOverdueWorkOrderShare('), + ) + assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') + assert.ok( + !cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)', + ) + 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'), '撤单事件不得记录母单状态迁移') + + const timeoutBlock = src.slice( + src.indexOf('export async function settleOverdueWorkOrderShare('), + src.indexOf('export async function cancelWorkOrderSharing('), + ) + assert.ok(timeoutBlock.length > 0, 'settleOverdueWorkOrderShare 片段必须存在') + assert.ok( + timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额超时必须用 recompute 收敛母单状态', + ) + assert.ok( + !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock), + '份额超时不得自己写母单 status(只能由 recompute 推导)', + ) }) test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => { diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 5c30d637..d76e5a68 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -700,7 +700,7 @@ export default function WorkOrdersPanel() { cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, { reason: String(values.reason || '').trim(), }), - '该参与者已撤单,押金和拼单份数已恢复', + '该参与者已撤单,押金已退还,母单状态未变更', ) if (!succeeded) return await queryClient.invalidateQueries({ @@ -3305,7 +3305,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`} - description="撤单后会退还该份额冻结的押金,空出的份数重新回到接单大厅;已提交的验收资料会保留在后台记录中。" + description="撤单只释放这一份份额并退还该份额冻结的押金,母单状态保持不变(待验收/进行中的单不会被踢回大厅);已提交的验收资料会保留在后台记录中。" style={{ marginBottom: 16 }} />