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:
@@ -457,6 +457,12 @@ export function mapWorkOrderForWorker(
|
||||
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
|
||||
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
|
||||
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
|
||||
deadlineAt:
|
||||
myShare && Number(workOrder.timeout_minutes || 0) > 0
|
||||
? new Date(
|
||||
new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000,
|
||||
).toISOString()
|
||||
: visibleOrder.deadlineAt,
|
||||
workerNote,
|
||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createWorkOrderEvent,
|
||||
getWorkOrderShare,
|
||||
settleOverdueWorkOrder,
|
||||
settleOverdueWorkOrderShare,
|
||||
submitWorkOrderShareAcceptance,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderAcceptanceRecord,
|
||||
@@ -410,6 +411,29 @@ async function submitWorkerSharingAcceptance(
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
|
||||
if (
|
||||
sharingShare.status === 'joined' &&
|
||||
timeoutMinutes > 0 &&
|
||||
new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now()
|
||||
) {
|
||||
await settleOverdueWorkOrderShare({
|
||||
workOrderId: workOrder.id,
|
||||
shareId: sharingShare.id,
|
||||
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
|
||||
now,
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerIds: [Number(session.workerId)],
|
||||
hallChanged: true,
|
||||
})
|
||||
publishWorkerWalletRealtimeChange(Number(session.workerId))
|
||||
throw createHttpError('拼单任务已超时,系统已按超时策略处置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_timeout_expired',
|
||||
})
|
||||
}
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
|
||||
const { files, imageUrls, note } = resolveAcceptanceImages(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
listOverdueWorkOrders,
|
||||
listOverdueWorkOrderShares,
|
||||
settleOverdueWorkOrder,
|
||||
settleOverdueWorkOrderShare,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
@@ -11,10 +13,10 @@ import {
|
||||
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
|
||||
|
||||
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
|
||||
const overdue = await listOverdueWorkOrders({
|
||||
limit: Math.max(1, Number(options.limit || 50)),
|
||||
workerId: Number(options.workerId || 0),
|
||||
})
|
||||
const limit = Math.max(1, Number(options.limit || 50))
|
||||
const workerId = Number(options.workerId || 0)
|
||||
|
||||
const overdue = await listOverdueWorkOrders({ limit, workerId })
|
||||
let processedCount = 0
|
||||
let skippedCount = 0
|
||||
for (const workOrder of overdue) {
|
||||
@@ -39,8 +41,33 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
|
||||
skippedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// 处置超时未提交材料的拼单份额(status=joined)
|
||||
const overdueShares = await listOverdueWorkOrderShares({ limit, workerId })
|
||||
for (const shareItem of overdueShares) {
|
||||
const result = await settleOverdueWorkOrderShare({
|
||||
workOrderId: shareItem.workOrderId,
|
||||
shareId: shareItem.shareId,
|
||||
policy: normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (result.share) {
|
||||
processedCount += 1
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(shareItem.workOrderId),
|
||||
workerIds: Number(shareItem.workerId) ? [Number(shareItem.workerId)] : [],
|
||||
hallChanged: true,
|
||||
})
|
||||
if (Number(shareItem.workerId)) {
|
||||
publishWorkerWalletRealtimeChange(Number(shareItem.workerId))
|
||||
}
|
||||
} else {
|
||||
skippedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkedCount: overdue.length,
|
||||
checkedCount: overdue.length + overdueShares.length,
|
||||
processedCount,
|
||||
skippedCount,
|
||||
}
|
||||
|
||||
@@ -3059,3 +3059,68 @@ test('已冻结打手调级或变更资料不应因未传 freezeReason 报错',
|
||||
'必须区分是否为新冻结,防止已冻结账号调级时抛出 freeze_reason 必填错误',
|
||||
)
|
||||
})
|
||||
|
||||
test('mapWorkOrderForWorker 拼单工单正确计算个人份额截止时间', () => {
|
||||
const workOrder = buildWorkOrderRow({
|
||||
product_name: '指挥官秘钥15个',
|
||||
status: 'open',
|
||||
sharing_enabled: true,
|
||||
sharing_total_quantity: 15,
|
||||
sharing_unit_reward: 700,
|
||||
timeout_minutes: 120,
|
||||
deadline_at: null,
|
||||
})
|
||||
const share = buildWorkOrderShareRow({
|
||||
created_at: '2026-08-18T09:09:08.000Z',
|
||||
})
|
||||
const mapped = mapWorkOrderForWorker(
|
||||
workOrder,
|
||||
resolveWorkerPermissions(buildWorkerUserRow()),
|
||||
share,
|
||||
)
|
||||
|
||||
assert.equal(mapped.deadlineAt, '2026-08-18T11:09:08.000Z')
|
||||
})
|
||||
|
||||
test('拼单超时处置链路完整性验证', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const timeoutSrc = await readFile(
|
||||
new URL('./worker-order-timeout-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
timeoutSrc.includes('listOverdueWorkOrderShares') &&
|
||||
timeoutSrc.includes('settleOverdueWorkOrderShare'),
|
||||
'settleOverdueWorkOrders 必须同时扫描并处置超时拼单份额',
|
||||
)
|
||||
|
||||
const acceptanceSrc = await readFile(
|
||||
new URL('./worker-acceptance-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
acceptanceSrc.includes('settleOverdueWorkOrderShare') &&
|
||||
acceptanceSrc.includes('work_order_timeout_expired'),
|
||||
'submitWorkerSharingAcceptance 必须在提交验收时拦截超时份额并处置',
|
||||
)
|
||||
|
||||
const queryRepoSrc = await readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
queryRepoSrc.includes('listOverdueWorkOrderShares') &&
|
||||
queryRepoSrc.includes("wos.status = 'joined'"),
|
||||
'listOverdueWorkOrderShares 必须查询超时且未提交材料的 joined 份额',
|
||||
)
|
||||
|
||||
const settlementRepoSrc = await readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
settlementRepoSrc.includes('settleOverdueWorkOrderShare') &&
|
||||
settlementRepoSrc.includes("SET status = 'cancelled'"),
|
||||
'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user