feat(worker): support batch sharing cancellation
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
acceptAdminWorkOrder,
|
||||
acceptAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderSharing,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
markAdminWorkOrderProblem,
|
||||
resolveAdminProblemWorkOrder,
|
||||
} from '../../../services/worker-platform/index.js'
|
||||
import { createJsonHandler, requireAdminPermission } from '../session.js'
|
||||
import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -104,6 +105,34 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/sharing/cancel',
|
||||
requireAnyAdminPermission(['worker_order.return_to_hall', 'worker_order.cancel']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
cancelAdminWorkOrderSharing(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
req.adminSession?.permissions || [],
|
||||
),
|
||||
{
|
||||
successMessage: '拼单撤单处理完成',
|
||||
errorMessage: '拼单撤单失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/sharing/cancel]',
|
||||
audit: (req, data) => ({
|
||||
action:
|
||||
String(req.body?.action || '').trim() === 'return_to_hall'
|
||||
? 'work_order_sharing_returned_to_hall'
|
||||
: 'work_order_sharing_cancelled',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
|
||||
requireAdminPermission('worker_order.cancel_share'),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
acceptWorkOrderAndSettle,
|
||||
acceptWorkOrderShareAndSettle,
|
||||
cancelWorkOrderShare,
|
||||
cancelWorkOrderSharing,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
updateWorkOrder,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
@@ -157,6 +158,81 @@ export async function cancelAdminWorkOrderShare(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelAdminWorkOrderSharing(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
actorPermissions: string[] = [],
|
||||
) {
|
||||
const action = String(payload.action || '').trim()
|
||||
if (!['return_to_hall', 'cancel_order'].includes(action)) {
|
||||
throw createHttpError('请选择拼单撤单处理方式', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_sharing_cancel_action_required',
|
||||
})
|
||||
}
|
||||
const requiredPermission =
|
||||
action === 'return_to_hall' ? 'worker_order.return_to_hall' : 'worker_order.cancel'
|
||||
if (!actorPermissions.includes(requiredPermission)) {
|
||||
throw createHttpError('当前账号没有此拼单撤单权限', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_permission_denied',
|
||||
})
|
||||
}
|
||||
const reason = String(payload.reason || '').trim()
|
||||
if (action === 'cancel_order' && !reason) {
|
||||
throw createHttpError('取消拼单订单时请填写退款或撤单原因', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_sharing_cancel_reason_required',
|
||||
})
|
||||
}
|
||||
const result = await cancelWorkOrderSharing({
|
||||
workOrderId: Number(workOrderId),
|
||||
action: action as 'return_to_hall' | 'cancel_order',
|
||||
reason,
|
||||
now: nowIso(),
|
||||
actorName,
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_cancellable') {
|
||||
throw createHttpError('当前订单不是可撤单的拼单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_cancel_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'no_cancellable_shares') {
|
||||
throw createHttpError('没有可撤销的未验收拼单份额', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_cancel_no_cancellable_shares',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'accepted_shares_present') {
|
||||
throw createHttpError('存在已验收份额,不能取消整单;请通过售后处理退款', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_cancel_accepted_shares_present',
|
||||
})
|
||||
}
|
||||
if (!result.order) {
|
||||
throw createHttpError('拼单撤单失败,订单状态可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_cancel_conflict',
|
||||
})
|
||||
}
|
||||
const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order)
|
||||
const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])]
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerIds,
|
||||
hallChanged: action === 'return_to_hall',
|
||||
})
|
||||
for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId)
|
||||
return {
|
||||
order: mapWorkOrderAdmin(result.order),
|
||||
action,
|
||||
cancelledShareCount: result.cancelledShareCount,
|
||||
releasedDepositAmount: result.releasedDepositAmount,
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
|
||||
const workOrderIds = [
|
||||
...new Set(
|
||||
|
||||
@@ -51,6 +51,7 @@ export {
|
||||
acceptAdminWorkOrderShare,
|
||||
acceptAdminWorkOrders,
|
||||
cancelAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderSharing,
|
||||
} from './admin-work-order-acceptance-service.js'
|
||||
export * from './worker-product-match-config-service.js'
|
||||
export * from './worker-announcement-config-service.js'
|
||||
|
||||
@@ -174,6 +174,10 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
canPublish:
|
||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0,
|
||||
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
|
||||
canCancelSharing:
|
||||
workOrder.sharing_enabled === true &&
|
||||
hasActiveShares &&
|
||||
['open', 'pending_acceptance', 'in_progress'].includes(workOrder.status),
|
||||
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||
canConfirmFriend:
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import {
|
||||
buildWorkOrderWhere,
|
||||
resolveWorkOrderShareCancellationStatus,
|
||||
resolveWorkOrderSharingCancellationPlan,
|
||||
resolveWorkOrderShareJoinQuantity,
|
||||
resolveOpenSharingSyncReward,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
@@ -991,6 +992,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
|
||||
assert.equal(partial.capabilities.canConfigureSharing, false)
|
||||
assert.equal(partial.capabilities.canAssign, false)
|
||||
assert.equal(partial.capabilities.canUpdateMaterial, true)
|
||||
assert.equal(partial.capabilities.canCancelSharing, true)
|
||||
|
||||
const filled = mapWorkOrderAdmin(
|
||||
buildWorkOrderRow({
|
||||
@@ -1005,6 +1007,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
|
||||
assert.equal(filled.hallAvailable, false)
|
||||
assert.equal(filled.capabilities.canAccept, true)
|
||||
assert.equal(filled.capabilities.canUnpublish, false)
|
||||
assert.equal(filled.capabilities.canCancelSharing, true)
|
||||
})
|
||||
|
||||
test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => {
|
||||
@@ -1051,6 +1054,44 @@ test('拼单份额撤单只允许未验收份额并让母工单回到大厅状
|
||||
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null)
|
||||
})
|
||||
|
||||
test('拼单母单撤单区分退回大厅和取消整单的验收边界', () => {
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderSharingCancellationPlan(
|
||||
'pending_acceptance',
|
||||
['submitted', 'accepted'],
|
||||
'return_to_hall',
|
||||
),
|
||||
{
|
||||
nextOrderStatus: 'open',
|
||||
cancellableShareCount: 1,
|
||||
acceptedShareCount: 1,
|
||||
failureReason: null,
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderSharingCancellationPlan(
|
||||
'pending_acceptance',
|
||||
['submitted', 'accepted'],
|
||||
'cancel_order',
|
||||
).failureReason,
|
||||
'accepted_shares_present',
|
||||
)
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderSharingCancellationPlan('open', ['joined', 'submitted'], 'cancel_order'),
|
||||
{
|
||||
nextOrderStatus: 'cancelled',
|
||||
cancellableShareCount: 2,
|
||||
acceptedShareCount: 0,
|
||||
failureReason: null,
|
||||
},
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderSharingCancellationPlan('accepted', ['submitted'], 'return_to_hall')
|
||||
.failureReason,
|
||||
'work_order_not_cancellable',
|
||||
)
|
||||
})
|
||||
|
||||
test('问题单退回打手按标记前状态恢复', () => {
|
||||
assert.equal(resolveProblemReturnToWorkerStatus('pending_acceptance'), 'pending_acceptance')
|
||||
assert.equal(resolveProblemReturnToWorkerStatus('open'), 'open')
|
||||
|
||||
Reference in New Issue
Block a user