fix(worker-platform): 补齐拼单份额超时自动处置机制与退回大厅流程

- 根因排查:此前超时扫描仅针对整单 in_progress,而拼单母单为 open 且 deadline_at 为空,导致打手超时的拼单份额无法被定时扫描命中与释放;
- 定时扫描:在 settleOverdueWorkOrders 中引入 listOverdueWorkOrderShares,自动扫描超时未提交资料的拼单份额(joined);
- 超时处置:新增 settleOverdueWorkOrderShare 事务处理,按母单 timeout_policy 释放或扣除押金,份额标记 cancelled,母单保持或恢复 open 状态重回抢单大厅,记录 timeout 事件并推送实时变更;
- 提交防呆:在打手提交拼单验收时检测个人份额是否已超时,超时则立即执行处置并拦截提交;
- 倒计时展示:在 mapWorkOrderForWorker 中根据 myShare.created_at + timeout_minutes 为拼单打手计算并展示个人履约截止时间;
- 质量验证:补齐拼单截止时间计算与处置链路测试,全量 676 个测试均通过,通过 npm run check 校验。
This commit is contained in:
yml2213
2026-10-11 14:53:56 +08:00
parent 06d888fb6f
commit b82f9c4063
20 changed files with 418 additions and 44 deletions
+1 -7
View File
@@ -448,13 +448,7 @@ export async function updateOrderClaimStatus(
WHERE id = $4
RETURNING *
`,
[
input.claimStatus,
String(input.reason || '').trim(),
updatedAt,
Number(orderId),
],
[input.claimStatus, String(input.reason || '').trim(), updatedAt, Number(orderId)],
)
return result.rows[0] || null
}
@@ -326,7 +326,6 @@ export async function listWorkOrdersByOrderId(orderId: number | string): Promise
return result.rows
}
export async function listWorkOrders({
page = 1,
pageSize = 20,
@@ -731,6 +730,72 @@ export async function listOverdueWorkOrders({
return result.rows
}
export interface OverdueWorkOrderShareItem {
shareId: number
workOrderId: number
workerId: number
quantity: number
shareDeposit: number
timeoutMinutes: number
timeoutPolicy: string
shareCreatedAt: string
}
/** 查询已超过接单时限、尚未提交材料的拼单份额(status=joined)。 */
export async function listOverdueWorkOrderShares({
limit = 50,
workerId = 0,
}: { limit?: number; workerId?: number } = {}): Promise<OverdueWorkOrderShareItem[]> {
const params: unknown[] = []
let workerClause = ''
if (workerId > 0) {
params.push(workerId)
workerClause = `AND wos.worker_id = $${params.length}`
}
params.push(limit)
const result = await query<{
share_id: string
work_order_id: string
worker_id: string
quantity: number
share_deposit: number
timeout_minutes: number
timeout_policy: string
share_created_at: string
}>(
`SELECT
wos.id AS share_id,
wos.work_order_id,
wos.worker_id,
wos.quantity,
wos.share_deposit,
wo.timeout_minutes,
wo.timeout_policy,
wos.created_at AS share_created_at
FROM work_order_shares wos
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
WHERE wos.status = 'joined'
AND wo.sharing_enabled = TRUE
AND wo.timeout_minutes > 0
AND wos.created_at + (wo.timeout_minutes || ' minutes')::interval < NOW()
AND wo.status = 'open'
${workerClause}
ORDER BY wos.created_at ASC
LIMIT $${params.length}`,
params,
)
return result.rows.map((row) => ({
shareId: Number(row.share_id),
workOrderId: Number(row.work_order_id),
workerId: Number(row.worker_id),
quantity: Number(row.quantity),
shareDeposit: Number(row.share_deposit),
timeoutMinutes: Number(row.timeout_minutes),
timeoutPolicy: String(row.timeout_policy || 'reopen'),
shareCreatedAt: String(row.share_created_at),
}))
}
export async function countWorkerActiveOrders(workerId: number | string): Promise<number> {
const result = await query<{ total: number }>(
`
@@ -29,4 +29,5 @@ export {
acceptWorkOrderShareAndSettle,
cancelWorkOrderShare,
cancelWorkOrderSharing,
settleOverdueWorkOrderShare,
} from './work-order-settlement-repo.js'
@@ -856,6 +856,196 @@ export async function cancelWorkOrderShare(input: {
})
}
/**
* 拼单份额超时自动处置:
* 超过母单 timeout_minutes 时限且未提交材料的 joined 份额,
* 按照母单 timeout_policy 处置(默认 reopen 释放押金退回大厅,或 cancel_deduct 扣除押金)。
*/
export async function settleOverdueWorkOrderShare(input: {
workOrderId: number
shareId: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
now: string
}): Promise<{
order: WorkOrderRow | null
share: WorkOrderShareRow | null
releasedDepositAmount: number
failureReason: 'work_order_not_cancellable' | 'share_not_found' | 'share_not_overdue' | 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 || workOrder.status !== 'open') {
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 || share.status !== 'joined') {
return {
order: workOrder,
share,
releasedDepositAmount: 0,
failureReason: 'share_not_found',
}
}
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
if (timeoutMinutes <= 0) {
return {
order: workOrder,
share,
releasedDepositAmount: 0,
failureReason: 'share_not_overdue',
}
}
const deadlineMs = new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000
if (deadlineMs > new Date(input.now).getTime()) {
return {
order: workOrder,
share,
releasedDepositAmount: 0,
failureReason: 'share_not_overdue',
}
}
const workerId = Number(share.worker_id || 0)
let releasedDepositAmount = 0
let deductedDepositAmount = 0
if (workerId > 0) {
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
const frozenDeposit = Math.min(
Number(share.share_deposit || 0),
Number(wallet?.frozen_deposit_amount || 0),
)
if (frozenDeposit > 0) {
if (input.policy === 'cancel_deduct') {
deductedDepositAmount = frozenDeposit
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit)
await client.query(
`UPDATE worker_wallets
SET frozen_deposit_amount = $1, updated_at = $2
WHERE worker_id = $3`,
[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_deduction', $2, $3, $4, $5, '拼单超时处置扣除押金', $6::jsonb, $7)`,
[
workerId,
-frozenDeposit,
Number(wallet?.available_amount || 0),
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
shareId: input.shareId,
action: 'timeout_share_deduct',
}),
input.now,
],
)
} else {
// reopen or cancel_release
releasedDepositAmount = frozenDeposit
const nextAvailable = Number(wallet?.available_amount || 0) + frozenDeposit
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit)
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,
frozenDeposit,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
shareId: input.shareId,
action: 'timeout_share_reopen',
}),
input.now,
],
)
}
}
}
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled',
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
accepted_at = NULL,
updated_at = $1
WHERE id = $2`,
[input.now, input.shareId],
)
await client.query(
`UPDATE work_orders
SET status = 'open',
published_at = COALESCE(published_at, $1),
hall_queued_at = $1,
updated_at = $1
WHERE id = $2`,
[input.now, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'system',
actorId: '',
eventType: `timeout_${input.policy}`,
fromStatus: 'open',
toStatus: 'open',
payloadJson: JSON.stringify({
policy: input.policy,
workerId,
shareId: input.shareId,
quantity: Number(share.quantity || 0),
depositAmount: releasedDepositAmount || deductedDepositAmount,
isSharing: true,
}),
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,
}
})
}
/**
* 母单级拼单撤单。只撤回尚未验收的 joined/submitted 份额;已验收份额保留,
* 取消整单时则要求不存在已验收份额,避免绕过售后和已结算资金处理。