feat(worker): support batch sharing cancellation

This commit is contained in:
yml2213
2026-08-28 19:40:49 +08:00
parent e189811617
commit d91d7180ad
13 changed files with 633 additions and 96 deletions
@@ -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,
}
}
@@ -473,6 +473,7 @@ export type WorkOrderCapabilities = {
canReopen: boolean
canPublish: boolean
canCancel: boolean
canCancelSharing: boolean
canResolveProblem: boolean
canDeduct: boolean
canConfirmFriend: boolean
@@ -28,4 +28,5 @@ export {
acceptWorkOrderAndSettle,
acceptWorkOrderShareAndSettle,
cancelWorkOrderShare,
cancelWorkOrderSharing,
} from './work-order-settlement-repo.js'
@@ -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<WorkOrderRow>(
`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<WorkOrderShareRow>(
`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,
}
})
}