修复拼单明细并支持参与者撤单

This commit is contained in:
yml2213
2026-08-19 14:35:23 +08:00
parent 4b1dcd47ea
commit ab6387759e
9 changed files with 409 additions and 43 deletions
@@ -199,6 +199,16 @@ export function resolveWorkOrderShareJoinQuantity(input: {
return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity
}
/** 校验单个拼单份额能否撤单,并返回母工单应回退到的状态。 */
export function resolveWorkOrderShareCancellationStatus(
workOrderStatus: string,
shareStatus: string,
): 'open' | null {
if (!['open', 'pending_acceptance'].includes(workOrderStatus)) return null
if (!['joined', 'submitted'].includes(shareStatus)) return null
return 'open'
}
export async function joinWorkOrderShare(input: {
workOrderId: number
workerId: number
@@ -2038,7 +2048,9 @@ export async function acceptWorkOrderAndSettle(input: {
)
if (shares.length > 0) {
for (const share of shares) {
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
const unsettledShares = shares.filter((share) => share.status !== 'accepted')
for (const share of unsettledShares) {
const shareWorkerId = Number(share.worker_id || 0)
if (!shareWorkerId) continue
await ensureWorkerWalletWithClient(client, shareWorkerId, input.now)
@@ -2263,7 +2275,10 @@ export async function acceptWorkOrderAndSettle(input: {
eventType: 'accepted',
fromStatus: 'pending_acceptance',
toStatus: 'accepted',
payloadJson: JSON.stringify({ shareCount: shares.length }),
payloadJson: JSON.stringify({
shareCount: shares.length,
settledShareCount: shares.filter((share) => share.status !== 'accepted').length,
}),
now: input.now,
})
@@ -2437,6 +2452,149 @@ export async function acceptWorkOrderShareAndSettle(input: {
})
}
/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */
export async function cancelWorkOrderShare(input: {
workOrderId: number
shareId: number
now: string
reason?: string
actorName?: string
}): Promise<{
order: WorkOrderRow | null
share: WorkOrderShareRow | null
releasedDepositAmount: number
failureReason:
| 'work_order_not_cancellable'
| 'share_not_found'
| 'share_status_not_cancellable'
| 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 || !['open', 'pending_acceptance'].includes(workOrder.status)) {
return {
order: workOrder,
share: null,
releasedDepositAmount: 0,
failureReason: 'work_order_not_cancellable',
}
}
const shareResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
[input.shareId, input.workOrderId],
)
const share = shareResult.rows[0] || null
if (!share) {
return {
order: workOrder,
share: null,
releasedDepositAmount: 0,
failureReason: 'share_not_found',
}
}
const nextOrderStatus = resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)
if (!nextOrderStatus) {
return {
order: workOrder,
share,
releasedDepositAmount: 0,
failureReason: 'share_status_not_cancellable',
}
}
const workerId = Number(share.worker_id || 0)
let releasedDepositAmount = 0
if (workerId > 0) {
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
releasedDepositAmount = Math.min(
Number(share.share_deposit || 0),
Number(wallet?.frozen_deposit_amount || 0),
)
if (releasedDepositAmount > 0) {
const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount
const nextFrozen = Math.max(
0,
Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount,
)
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,
releasedDepositAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
shareId: input.shareId,
reason: String(input.reason || '').trim(),
}),
input.now,
],
)
}
}
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled', updated_at = $1
WHERE id = $2`,
[input.now, input.shareId],
)
await client.query(
`UPDATE work_orders
SET status = $1,
submitted_at = NULL,
published_at = COALESCE(published_at, $2),
updated_at = $2
WHERE id = $3`,
[nextOrderStatus, input.now, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType: 'sharing_share_cancelled_by_admin',
fromStatus: workOrder.status,
toStatus: nextOrderStatus,
payloadJson: JSON.stringify({
shareId: input.shareId,
workerId,
quantity: Number(share.quantity || 0),
releasedDepositAmount,
reason: String(input.reason || '').trim(),
}),
now: input.now,
})
const updatedShareResult = await client.query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[input.shareId],
)
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
share: updatedShareResult.rows[0] || null,
releasedDepositAmount,
failureReason: null,
}
})
}
export async function resolveProblemWorkOrder(input: {
workOrderId: number
action: ProblemWorkOrderResolutionAction
@@ -3230,7 +3388,16 @@ function buildWorkOrderWhere({
}
if (workerId) {
params.push(workerId)
filters.push(`wo.assigned_worker_id = $${params.length}`)
filters.push(`(
wo.assigned_worker_id = $${params.length}
OR EXISTS (
SELECT 1
FROM work_order_shares wos
WHERE wos.work_order_id = wo.id
AND wos.worker_id = $${params.length}
AND wos.status != 'cancelled'
)
)`)
}
if (categoryId) {
params.push(categoryId)
@@ -6,6 +6,7 @@ import {
acceptAdminWorkOrders,
assignAdminWorkOrderToWorker,
cancelAdminWorkOrder,
cancelAdminWorkOrderShare,
unassignAdminWorkOrder,
creditAdminWorkerWallet,
createAdminMockWorkOrder,
@@ -854,6 +855,31 @@ router.post(
),
)
router.post(
'/worker-platform/orders/:workOrderId/shares/:shareId/cancel',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(
(req) =>
cancelAdminWorkOrderShare(
String(req.params.workOrderId || ''),
String(req.params.shareId || ''),
req.body || {},
req.adminSession?.username || '',
),
{
successMessage: '拼单参与者已撤单,份数已退回大厅',
errorMessage: '撤销拼单参与者失败',
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]',
audit: (req, data) => ({
action: 'work_order_share_cancelled',
targetType: 'work_order_share',
targetId: String(req.params.shareId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
requireAdminRoles(['admin', 'operator']),
@@ -310,11 +310,13 @@ async function publishWorkOrderSnapshots(input: {
const viewedAt =
views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null
const share = shares.find((item) => Number(item.worker_id) === workerId)
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId
const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled')
publishRealtimeEvent({
type: 'work_order.changed',
entityId: workOrderId,
scopes: ['worker_my_orders'],
operation: 'upsert',
operation: workerOrderVisible ? 'upsert' : 'remove',
data: mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(worker),
@@ -6,6 +6,7 @@ import {
addWorkerWalletCredit,
assignWorkOrderToWorker,
cancelAssignedWorkOrder,
cancelWorkOrderShare,
returnAssignedWorkOrderToHall,
unassignWorkOrder,
countWorkerActiveOrders,
@@ -2040,6 +2041,52 @@ export async function acceptAdminWorkOrderShare(
}
}
export async function cancelAdminWorkOrderShare(
workOrderId: number | string,
shareId: number | string,
payload: JsonObject = {},
actorName = '',
) {
const result = await cancelWorkOrderShare({
workOrderId: Number(workOrderId),
shareId: Number(shareId),
now: nowIso(),
reason: String(payload.reason || '').trim(),
actorName,
})
if (result.failureReason === 'work_order_not_cancellable') {
throw createHttpError('当前拼单状态不能撤销参与者', {
statusCode: 409,
errorCode: 'work_order_sharing_cancel_status_invalid',
})
}
if (result.failureReason === 'share_not_found') {
throw createHttpError('未找到该拼单参与记录', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (result.failureReason === 'share_status_not_cancellable' || !result.share || !result.order) {
throw createHttpError('只有已参与或已提交验收的拼单份额可以撤单', {
statusCode: 409,
errorCode: 'work_order_sharing_share_cancel_status_invalid',
})
}
const workerId = Number(result.share.worker_id || 0)
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrderId),
workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order),
hallChanged: true,
})
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
return {
order: mapWorkOrderAdmin(result.order),
share: mapWorkOrderShare(result.share),
releasedDepositAmount: result.releasedDepositAmount,
}
}
export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') {
const workOrderIds = [
...new Set(
@@ -8,7 +8,10 @@ import type {
WorkProductRuleRow,
WorkerUserRow,
} from '../../repositories/worker-platform/index.js'
import { resolveWorkOrderShareJoinQuantity } from '../../repositories/worker-platform/index.js'
import {
resolveWorkOrderShareCancellationStatus,
resolveWorkOrderShareJoinQuantity,
} from '../../repositories/worker-platform/index.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
assertWithdrawChannelAllowedForAmount,
@@ -729,6 +732,15 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(filled.capabilities.canUnpublish, false)
})
test('拼单份额撤单只允许未验收份额并让母工单回到大厅状态', () => {
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'joined'), 'open')
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'submitted'), 'open')
assert.equal(resolveWorkOrderShareCancellationStatus('pending_acceptance', 'submitted'), 'open')
assert.equal(resolveWorkOrderShareCancellationStatus('pending_acceptance', 'accepted'), null)
assert.equal(resolveWorkOrderShareCancellationStatus('accepted', 'submitted'), null)
assert.equal(resolveWorkOrderShareCancellationStatus('open', 'cancelled'), null)
})
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
const workOrder = buildWorkOrderRow()
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')