fix(worker): 拼单单人撤单后空出份额回大厅并重算母单状态
This commit is contained in:
@@ -539,7 +539,7 @@ export async function disposeWorkOrderShareProblem(input: {
|
||||
)
|
||||
}
|
||||
|
||||
// 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。
|
||||
// 份额级问题单只处置这一份,不重算母单状态:被标记的份额不走「退回大厅」的份额释放口径。
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WorkOrderRow | null> {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`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<WorkOrderRow>(
|
||||
`
|
||||
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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -196,7 +196,8 @@ export async function cancelAdminWorkOrderShare(
|
||||
* 后台按份数撤销某个打手的部分份额。
|
||||
*
|
||||
* 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额,
|
||||
* 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。
|
||||
* 母单状态按剩余份额重算(可能从待验收回到代练中,保留原大厅队列位置),
|
||||
* 该份额的验收资料不动(之后仍可对该份额「单独验收」)。
|
||||
*/
|
||||
export async function reduceAdminWorkOrderShareQuantity(
|
||||
workOrderId: number | string,
|
||||
|
||||
@@ -3510,13 +3510,21 @@ type ShareAggregate = {
|
||||
async function runRecompute(
|
||||
workOrder: Partial<WorkOrderRow>,
|
||||
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'),
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={<UndoOutlined />}
|
||||
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 }}
|
||||
/>
|
||||
<Form.Item label="撤单原因" name="reason">
|
||||
|
||||
Reference in New Issue
Block a user