fix: 免审验收容错与成本中心归一回填超时修复
- worker:等级升级计数移出验收事务,瞬时错误重试一次,最终失败转人工待办且不再返回 500 - worker:待验收阶段修改验收资料时保留 reviewMode/evidenceStatus/evidenceDueAt - cost-center:迁移 107 新增人工待归一覆盖部分索引(生产实测候选聚合 19.6s → 391ms) - cost-center:归一人工商品/按工单回填商品/模板预览前端超时放宽到 300s - cost-center:批处理接口语句超时放宽到 60s(SET LOCAL,在线接口仍为 15s)
This commit is contained in:
@@ -106,6 +106,36 @@ export async function createWorkerAcceptanceAdminNotification(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 免审核自动结算没跑完(多为库被回填/批量同步压到 statement_timeout):
|
||||
* 验收资料已落库、订单留在待验收,给后台落一条高优先级待办,督促人工验收。
|
||||
*/
|
||||
export async function createWorkOrderAutoAcceptFailureAdminNotification(input: {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
productName: string
|
||||
workerName: string
|
||||
reason?: string
|
||||
}) {
|
||||
return createConfiguredWorkerPlatformNotification('acceptance_submitted', {
|
||||
notificationType: 'worker_auto_accept_failed',
|
||||
priority: 'high',
|
||||
entityType: 'work_order',
|
||||
entityId: input.workOrderId,
|
||||
dedupeKey: `work_order_auto_accept_failed:${input.workOrderId}`,
|
||||
title: 'VIP 免审自动验收失败,已转人工',
|
||||
body: [
|
||||
`订单:${input.workOrderNo || input.workOrderId}`,
|
||||
`商品:${input.productName || '-'}`,
|
||||
`打手:${input.workerName || '-'}`,
|
||||
`原因:${input.reason || '结算超时'}`,
|
||||
'处理:请在待验收列表人工验收',
|
||||
].join(';'),
|
||||
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
|
||||
now: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */
|
||||
export async function createWorkOrderFullyAcceptedAdminNotification(input: {
|
||||
workOrderId: number
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { hasAcceptanceEvidence } from '../../domain/work-order-acceptance-evidence.js'
|
||||
import {
|
||||
hasAcceptanceEvidence,
|
||||
resolveVipAutoAcceptEvidencePatch,
|
||||
} from '../../domain/work-order-acceptance-evidence.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
@@ -24,6 +27,8 @@ import {
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { retryOnTransientDbError } from '../../utils/db-retry.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
@@ -32,6 +37,7 @@ import {
|
||||
} from '../file-storage/file-storage-service.js'
|
||||
import {
|
||||
createWorkerAcceptanceAdminNotification,
|
||||
createWorkOrderAutoAcceptFailureAdminNotification,
|
||||
getWorkerAcceptanceReminder,
|
||||
remindWorkerAcceptanceAdminNotification,
|
||||
} from '../admin/admin-notification-service.js'
|
||||
@@ -205,7 +211,7 @@ export async function submitWorkerOrderAcceptance(
|
||||
!isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
|
||||
(worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence)
|
||||
) {
|
||||
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
|
||||
const settlement = await settleAcceptanceWithRetry({
|
||||
workOrderId: workOrder.id,
|
||||
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||
rewardUnfreezeEnabled: getWorkerFinanceConfig().rewardUnfreezeEnabled,
|
||||
@@ -213,18 +219,44 @@ export async function submitWorkerOrderAcceptance(
|
||||
actorType: 'worker',
|
||||
actorName: worker.display_name || worker.username || '',
|
||||
})
|
||||
if (failureReason === 'sharing_pending_submissions') {
|
||||
if (settlement.failureReason === 'sharing_pending_submissions') {
|
||||
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_pending_submissions',
|
||||
})
|
||||
}
|
||||
if (!acceptedOrder) {
|
||||
throw createHttpError('免审核验收失败,请稍后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_internal_auto_accept_failed',
|
||||
if (!settlement.acceptedOrder) {
|
||||
// 并发下可能后台已经先验收了:确认一下,避免给后台落一条假的失败待办。
|
||||
const latestOrder = settlement.error
|
||||
? null
|
||||
: await getRequiredWorkOrder(workOrder.id).catch(() => null)
|
||||
if (latestOrder?.status === WORK_ORDER_STATUS.ACCEPTED) {
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerIds: [Number(worker.id)],
|
||||
})
|
||||
return {
|
||||
order: mapWorkOrderForWorker(latestOrder, resolveWorkerPermissions(worker)),
|
||||
}
|
||||
}
|
||||
// 自动结算没成功:验收资料已经落库,订单留在待验收。
|
||||
// 这里转人工并给后台落待办,不再把 500 抛回打手(重试只会拿到 409)。
|
||||
await handleAutoAcceptSettlementFailure({
|
||||
workOrder,
|
||||
workerName: worker.display_name || worker.username || '',
|
||||
error: settlement.error,
|
||||
scope: '[worker/orders/:workOrderId/submit-acceptance]',
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerIds: [Number(worker.id)],
|
||||
})
|
||||
return {
|
||||
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
|
||||
autoAcceptDeferred: true,
|
||||
}
|
||||
}
|
||||
const acceptedOrder = settlement.acceptedOrder
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'worker',
|
||||
@@ -265,6 +297,95 @@ export async function submitWorkerOrderAcceptance(
|
||||
}
|
||||
}
|
||||
|
||||
type AcceptanceSettlementResult = {
|
||||
acceptedOrder: WorkOrderRow | null
|
||||
failureReason: 'sharing_pending_submissions' | null
|
||||
error: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 免审结算遇到瞬时数据库错误(statement_timeout / 死锁)时重试一次。
|
||||
*
|
||||
* 结算整体在一个事务里,失败即回滚,重放不会重复入账;重试能扛过库被回填、
|
||||
* 批量同步压住的短时抖动。
|
||||
*/
|
||||
async function settleAcceptanceWithRetry(
|
||||
input: Parameters<typeof acceptWorkOrderAndSettle>[0],
|
||||
): Promise<AcceptanceSettlementResult> {
|
||||
try {
|
||||
const result = await retryOnTransientDbError(() => acceptWorkOrderAndSettle(input), {
|
||||
attempts: 2,
|
||||
delayMs: 400,
|
||||
onRetry: (error, nextAttempt) => {
|
||||
logWarn('[worker/acceptance]', '免审结算遇到瞬时数据库错误,准备重试', {
|
||||
workOrderId: input.workOrderId,
|
||||
nextAttempt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
})
|
||||
return { acceptedOrder: result.order, failureReason: result.failureReason, error: null }
|
||||
} catch (error) {
|
||||
return { acceptedOrder: null, failureReason: null, error }
|
||||
}
|
||||
}
|
||||
|
||||
async function settleShareAcceptanceWithRetry(
|
||||
input: Parameters<typeof acceptWorkOrderShareAndSettle>[0],
|
||||
): Promise<{
|
||||
result: Awaited<ReturnType<typeof acceptWorkOrderShareAndSettle>> | null
|
||||
error: unknown
|
||||
}> {
|
||||
try {
|
||||
const result = await retryOnTransientDbError(() => acceptWorkOrderShareAndSettle(input), {
|
||||
attempts: 2,
|
||||
delayMs: 400,
|
||||
onRetry: (error, nextAttempt) => {
|
||||
logWarn('[worker/acceptance]', '拼单免审结算遇到瞬时数据库错误,准备重试', {
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: input.shareId,
|
||||
nextAttempt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
})
|
||||
return { result, error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error }
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动结算最终失败:记日志 + 给后台落人工验收待办,不把 500 抛给打手。 */
|
||||
async function handleAutoAcceptSettlementFailure(input: {
|
||||
workOrder: WorkOrderRow
|
||||
workerName: string
|
||||
error: unknown
|
||||
scope: string
|
||||
shareId?: number | null
|
||||
}) {
|
||||
logWarn(input.scope, '免审核自动结算失败,已转人工验收', {
|
||||
workOrderId: Number(input.workOrder.id),
|
||||
...(input.shareId ? { shareId: Number(input.shareId) } : {}),
|
||||
error: input.error instanceof Error ? input.error.message : undefined,
|
||||
})
|
||||
try {
|
||||
const reason =
|
||||
input.error instanceof Error && input.error.message ? input.error.message : undefined
|
||||
await createWorkOrderAutoAcceptFailureAdminNotification({
|
||||
workOrderId: Number(input.workOrder.id),
|
||||
workOrderNo: input.workOrder.work_order_no,
|
||||
productName: input.workOrder.product_name,
|
||||
workerName: input.workerName,
|
||||
...(reason ? { reason } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
logWarn(input.scope, '免审失败待办创建失败', {
|
||||
workOrderId: Number(input.workOrder.id),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 打手在提交验收后催促后台处理;频率和次数均由服务端控制。 */
|
||||
export async function remindWorkerOrderAcceptance(
|
||||
workOrderId: number | string,
|
||||
@@ -537,7 +658,7 @@ async function submitWorkerSharingAcceptance(
|
||||
})
|
||||
}
|
||||
if (canAutoAcceptWithoutEvidence) {
|
||||
const accepted = await acceptWorkOrderShareAndSettle({
|
||||
const settlement = await settleShareAcceptanceWithRetry({
|
||||
workOrderId: Number(workOrder.id),
|
||||
shareId: Number(result.share.id),
|
||||
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||
@@ -546,11 +667,24 @@ async function submitWorkerSharingAcceptance(
|
||||
actorType: 'worker',
|
||||
actorName: worker.display_name || worker.username || '',
|
||||
})
|
||||
if (!accepted.share || !accepted.order) {
|
||||
throw createHttpError('VIP 免审核验收失败,请稍后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_vip_auto_accept_failed',
|
||||
const accepted = settlement.result
|
||||
if (!accepted?.share || accepted.share.status !== 'accepted') {
|
||||
// 份额验收资料已提交,结算失败时留在待人工验收,给后台落待办。
|
||||
await handleAutoAcceptSettlementFailure({
|
||||
workOrder,
|
||||
workerName: worker.display_name || worker.username || '',
|
||||
error: settlement.error,
|
||||
scope: '[worker/orders/:workOrderId/submit-acceptance]',
|
||||
shareId: Number(result.share.id),
|
||||
})
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(session.workerId)],
|
||||
})
|
||||
return {
|
||||
share: mapWorkOrderShare(result.share),
|
||||
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
|
||||
autoAcceptDeferred: true,
|
||||
}
|
||||
}
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(session.workerId)],
|
||||
@@ -862,6 +996,10 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
}
|
||||
const evidencePatch = resolveVipAutoAcceptEvidencePatch(
|
||||
previous,
|
||||
hasAcceptanceEvidence({ imageUrls, video }),
|
||||
)
|
||||
const acceptance = {
|
||||
note: String(payload.note ?? previous.note ?? '').trim(),
|
||||
files,
|
||||
@@ -869,6 +1007,7 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
...(video ? { video } : {}),
|
||||
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
|
||||
updatedAt: now,
|
||||
...evidencePatch,
|
||||
}
|
||||
const updated = await updateWorkOrderAcceptanceRecord({
|
||||
workOrderId: workOrder.id,
|
||||
@@ -961,6 +1100,10 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
}
|
||||
const evidencePatch = resolveVipAutoAcceptEvidencePatch(
|
||||
previous,
|
||||
hasAcceptanceEvidence({ imageUrls, video }),
|
||||
)
|
||||
const acceptance = {
|
||||
note: String(payload.note ?? previous.note ?? '').trim(),
|
||||
files,
|
||||
@@ -968,6 +1111,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
...(video ? { video } : {}),
|
||||
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
|
||||
updatedAt: now,
|
||||
...evidencePatch,
|
||||
}
|
||||
const share = await updateWorkOrderShareAcceptanceRecord({
|
||||
workOrderId: Number(workOrder.id),
|
||||
|
||||
Reference in New Issue
Block a user