diff --git a/apps/backend/src/repositories/worker-platform/work-order-acceptance-idempotency.test.ts b/apps/backend/src/repositories/worker-platform/work-order-acceptance-idempotency.test.ts new file mode 100644 index 00000000..6621e1cc --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-acceptance-idempotency.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +/** + * 2026-10-30 生产事故:打手双击「提交验收」,第二个请求把已验收的工单写回 + * 待验收,随后重复结算撞钱包流水 event_key 唯一索引,订单永久卡在待验收, + * 后台手动验收也一直 500。这里用源码契约锁住两个修复点。 + */ +test('整单结算先检查奖励流水:已入账只补状态,不再动钱', async () => { + const source = await readFile(new URL('./work-order-settlement-repo.ts', import.meta.url), 'utf8') + + const guardIndex = source.indexOf( + 'const rewardAlreadySettled = await hasSettledRewardLedgerWithClient', + ) + const settleIndex = source.indexOf( + 'await ensureWorkerWalletWithClient(client, workerId, input.now)', + ) + assert.ok(guardIndex > 0, '整单结算缺少已入账检查') + assert.ok(settleIndex > guardIndex, '已入账检查必须发生在钱包结算之前') + assert.ok( + source.includes( + "ledger_type IN ('reward_settlement', 'reward_pending_unfreeze', 'sharing_reward')", + ), + '幂等检查必须覆盖整单与拼单两种奖励流水', + ) +}) + +test('拼单份额结算同样先检查该份额是否已入账', async () => { + const source = await readFile(new URL('./work-order-settlement-repo.ts', import.meta.url), 'utf8') + + const firstGuard = source.indexOf('const rewardAlreadySettled') + const secondGuard = source.indexOf('const rewardAlreadySettled', firstGuard + 1) + assert.ok(firstGuard > 0, '整单结算缺少已入账检查') + assert.ok(secondGuard > firstGuard, '拼单份额结算缺少已入账检查') + assert.ok( + source.slice(secondGuard, secondGuard + 220).includes('shareId: input.shareId'), + '拼单份额必须按 shareId 判断是否已入账', + ) +}) + +test('提交验收改原子状态更新,禁止把 accepted 无条件写回待验收', async () => { + const management = await readFile( + new URL('./work-order-management-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok(management.includes('export async function markWorkOrderAcceptanceSubmitted')) + assert.ok(management.includes("AND status IN ('in_progress', 'problem')")) + assert.ok(management.includes("draft_acceptance_json = '{}'::jsonb")) + + const service = await readFile( + new URL('../../services/worker-platform/worker-acceptance-service.ts', import.meta.url), + 'utf8', + ) + assert.equal(service.includes('updateWorkOrder(workOrder.id, {'), false) + assert.ok(service.includes('markWorkOrderAcceptanceSubmitted(')) + assert.ok(service.includes('work_order_submit_status_invalid')) +}) diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index 6d29eaf8..e1ab9f42 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -132,6 +132,37 @@ export async function updateWorkOrder( return getWorkOrderById(workOrderId) } +/** + * 原子地把工单推进到「待验收」。 + * + * 只允许从进行中/问题状态进入:重复提交(双击 / 前端重试)不能把已经验收的工单 + * 再写回待验收,否则会把已入账的结算冲掉(2026-10-30 生产事故)。 + * 返回 null 表示状态已变化,调用方按幂等成功或冲突处理。 + */ +export async function markWorkOrderAcceptanceSubmitted(input: { + workOrderId: number | string + acceptanceJson: WorkOrderRow['acceptance_json'] + now: string +}): Promise { + const result = await query<{ id: number }>( + ` + UPDATE work_orders + SET + status = 'pending_acceptance', + acceptance_json = $1::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = $2, + updated_at = $2 + WHERE id = $3 + AND status IN ('in_progress', 'problem') + RETURNING id + `, + [toJsonString(input.acceptanceJson), input.now, Number(input.workOrderId)], + ) + if (!result.rows[0]) return null + return getWorkOrderById(input.workOrderId) +} + /** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */ export async function publishUnassignedWorkOrder(input: { workOrderId: number | string diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 32c77ec3..8d8874bc 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -50,6 +50,33 @@ async function upgradeWorkerLevelsAfterCommit(workerIds: number[], now: string) } } +/** + * 该发放单元是否已经入过账。 + * + * 验收可能被重复提交(双击 / 前端重试):第一次已经结算成功,第二次把状态写回 + * 待验收后如果再次结算,就会撞 `uq_worker_wallet_ledger_event_key` 让整笔事务回滚 + * (2026-10-30 生产事故)。凡是已入账的发放单元,只补验收状态,绝不再动钱。 + */ +async function hasSettledRewardLedgerWithClient( + client: PoolClient, + input: { workerId: number; workOrderId: number; shareId?: number | null }, +): Promise { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM worker_wallet_ledgers + WHERE worker_id = $1 + AND related_work_order_id = $2 + AND ledger_type IN ('reward_settlement', 'reward_pending_unfreeze', 'sharing_reward') + AND COALESCE(NULLIF(payload_json ->> 'shareId', ''), '0') = $3 + ) AS exists + `, + [input.workerId, input.workOrderId, input.shareId ? String(input.shareId) : '0'], + ) + return result.rows[0]?.exists === true +} + /** * 拼单母单收尾:标记已验收,并补寄卖的发布押金释放与供货结算。 * @@ -371,32 +398,38 @@ export async function acceptWorkOrderAndSettle(input: { const workerId = Number(workOrder.assigned_worker_id || 0) if (workerId > 0) { - await ensureWorkerWalletWithClient(client, workerId, input.now) - const wallet = await getWorkerWalletWithClient(client, workerId) - const releaseAmount = Math.min( - await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId), - Number(wallet?.frozen_deposit_amount || 0), - ) - const rewardAmount = Number(workOrder.reward_amount || 0) - const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, { + // 重复验收(双击/重试)时奖励已经入账:只补验收状态,不能再结算一次。 + const rewardAlreadySettled = await hasSettledRewardLedgerWithClient(client, { workerId, - amount: rewardAmount, workOrderId: input.workOrderId, - now: input.now, }) - const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0) - const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0 - const pendingUnfreezeAmount = - (shouldDelayUnfreeze ? rewardAmount : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0) - const nextAvailable = - Number(wallet?.available_amount || 0) + - (shouldDelayUnfreeze ? 0 : rewardAmount) + - (input.unfreezeDays > 0 ? 0 : releaseAmount) - - debtOffsetAmount - const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + if (!rewardAlreadySettled) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const releaseAmount = Math.min( + await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId), + Number(wallet?.frozen_deposit_amount || 0), + ) + const rewardAmount = Number(workOrder.reward_amount || 0) + const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, { + workerId, + amount: rewardAmount, + workOrderId: input.workOrderId, + now: input.now, + }) + const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0) + const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0 + const pendingUnfreezeAmount = + (shouldDelayUnfreeze ? rewardAmount : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0) + const nextAvailable = + Number(wallet?.available_amount || 0) + + (shouldDelayUnfreeze ? 0 : rewardAmount) + + (input.unfreezeDays > 0 ? 0 : releaseAmount) - + debtOffsetAmount + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) - await client.query( - ` + await client.query( + ` UPDATE worker_wallets SET available_amount = $1, @@ -406,111 +439,112 @@ export async function acceptWorkOrderAndSettle(input: { updated_at = $5 WHERE worker_id = $6 `, - [nextAvailable, nextFrozen, pendingUnfreezeAmount, rewardAmount, input.now, workerId], - ) + [nextAvailable, nextFrozen, pendingUnfreezeAmount, rewardAmount, input.now, workerId], + ) - if (releaseAmount > 0 && input.unfreezeDays > 0) { - await enqueueDepositUnfreezeWithClient(client, { - workerId, - workOrderId: input.workOrderId, - amount: releaseAmount, - fundType: 'deposit', - unfreezeDays: input.unfreezeDays, - now: input.now, - }) - } - if (rewardAmount > 0 && shouldDelayUnfreeze) { - await enqueueDepositUnfreezeWithClient(client, { - workerId, - workOrderId: input.workOrderId, - amount: rewardAmount, - fundType: 'reward', - unfreezeDays: input.unfreezeDays, - now: input.now, - }) - } + if (releaseAmount > 0 && input.unfreezeDays > 0) { + await enqueueDepositUnfreezeWithClient(client, { + workerId, + workOrderId: input.workOrderId, + amount: releaseAmount, + fundType: 'deposit', + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + } + if (rewardAmount > 0 && shouldDelayUnfreeze) { + await enqueueDepositUnfreezeWithClient(client, { + workerId, + workOrderId: input.workOrderId, + amount: rewardAmount, + fundType: 'reward', + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + } - if (releaseAmount > 0) { - if (input.unfreezeDays > 0) { - await client.query( - ` + if (releaseAmount > 0) { + if (input.unfreezeDays > 0) { + 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_pending_unfreeze', $2, $3, $4, $5, '验收通过押金待解冻', $6::jsonb, $7) `, - [ - workerId, - releaseAmount, - nextAvailable, - nextFrozen, - input.workOrderId, - JSON.stringify({ - workOrderId: input.workOrderId, - unfreezeDays: input.unfreezeDays, - }), - input.now, - ], - ) - } else { - await client.query( - ` + [ + workerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + unfreezeDays: input.unfreezeDays, + }), + input.now, + ], + ) + } else { + 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, '验收通过释放押金', '{}'::jsonb, $6) `, - [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], - ) + [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + ) + } } - } - if (rewardAmount > 0) { - const rewardLedgerType = shouldDelayUnfreeze - ? 'reward_pending_unfreeze' - : 'reward_settlement' - await client.query( - ` + if (rewardAmount > 0) { + const rewardLedgerType = shouldDelayUnfreeze + ? 'reward_pending_unfreeze' + : 'reward_settlement' + 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, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) `, - [ - workerId, - rewardLedgerType, - rewardAmount, - nextAvailable, - nextFrozen, - input.workOrderId, - shouldDelayUnfreeze ? '验收通过报酬待解冻' : '验收通过结算报酬', - JSON.stringify( - shouldDelayUnfreeze - ? { workOrderId: input.workOrderId, unfreezeDays: input.unfreezeDays } - : {}, - ), - input.now, - ], - ) - } - for (const debtOffset of debtOffsets) { - await client.query( - ` + [ + workerId, + rewardLedgerType, + rewardAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + shouldDelayUnfreeze ? '验收通过报酬待解冻' : '验收通过结算报酬', + JSON.stringify( + shouldDelayUnfreeze + ? { workOrderId: input.workOrderId, unfreezeDays: input.unfreezeDays } + : {}, + ), + input.now, + ], + ) + } + for (const debtOffset of debtOffsets) { + 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, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7) `, - [ - workerId, - -debtOffset.amount, - nextAvailable, - nextFrozen, - input.workOrderId, - JSON.stringify({ caseId: debtOffset.caseId, workOrderId: input.workOrderId }), - input.now, - ], - ) + [ + workerId, + -debtOffset.amount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ caseId: debtOffset.caseId, workOrderId: input.workOrderId }), + input.now, + ], + ) + } } } @@ -593,127 +627,137 @@ export async function acceptWorkOrderShareAndSettle(input: { const workerId = Number(share.worker_id || 0) if (workerId > 0) { - 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), - ) - const shareReward = Number(share.share_reward || 0) - const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, { + // 该份额已结算过(重复提交把份额状态写回 submitted)时,只补状态不再打款。 + const rewardAlreadySettled = await hasSettledRewardLedgerWithClient(client, { workerId, - amount: shareReward, workOrderId: input.workOrderId, - now: input.now, + shareId: input.shareId, }) - const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0) - const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0 - const pendingUnfreezeAmount = - (shouldDelayUnfreeze ? shareReward : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0) - const nextAvailable = - Number(wallet?.available_amount || 0) + - (shouldDelayUnfreeze ? 0 : shareReward) + - (input.unfreezeDays > 0 ? 0 : releaseAmount) - - debtOffsetAmount - const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) - await client.query( - `UPDATE worker_wallets + if (!rewardAlreadySettled) { + 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), + ) + const shareReward = Number(share.share_reward || 0) + const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, { + workerId, + amount: shareReward, + workOrderId: input.workOrderId, + now: input.now, + }) + const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0) + const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0 + const pendingUnfreezeAmount = + (shouldDelayUnfreeze ? shareReward : 0) + (input.unfreezeDays > 0 ? releaseAmount : 0) + const nextAvailable = + Number(wallet?.available_amount || 0) + + (shouldDelayUnfreeze ? 0 : shareReward) + + (input.unfreezeDays > 0 ? 0 : releaseAmount) - + debtOffsetAmount + 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, pending_unfreeze_amount = pending_unfreeze_amount + $3, total_settled_amount = total_settled_amount + $4, updated_at = $5 WHERE worker_id = $6`, - [nextAvailable, nextFrozen, pendingUnfreezeAmount, shareReward, input.now, workerId], - ) + [nextAvailable, nextFrozen, pendingUnfreezeAmount, shareReward, input.now, workerId], + ) - if (releaseAmount > 0 && input.unfreezeDays > 0) { - await enqueueDepositUnfreezeWithClient(client, { - workerId, - workOrderId: input.workOrderId, - amount: releaseAmount, - fundType: 'deposit', - unfreezeDays: input.unfreezeDays, - now: input.now, - }) - } - if (shareReward > 0 && shouldDelayUnfreeze) { - await enqueueDepositUnfreezeWithClient(client, { - workerId, - workOrderId: input.workOrderId, - amount: shareReward, - fundType: 'reward', - unfreezeDays: input.unfreezeDays, - now: input.now, - }) - } - if (releaseAmount > 0) { - await client.query( - `INSERT INTO worker_wallet_ledgers ( + if (releaseAmount > 0 && input.unfreezeDays > 0) { + await enqueueDepositUnfreezeWithClient(client, { + workerId, + workOrderId: input.workOrderId, + amount: releaseAmount, + fundType: 'deposit', + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + } + if (shareReward > 0 && shouldDelayUnfreeze) { + await enqueueDepositUnfreezeWithClient(client, { + workerId, + workOrderId: input.workOrderId, + amount: shareReward, + fundType: 'reward', + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + } + if (releaseAmount > 0) { + 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, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, - [ - workerId, - input.unfreezeDays > 0 ? 'deposit_pending_unfreeze' : 'deposit_release', - releaseAmount, - nextAvailable, - nextFrozen, - input.workOrderId, - input.unfreezeDays > 0 ? '拼单份额验收押金待解冻' : '拼单份额验收释放押金', - JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }), - input.now, - ], - ) - } - if (shareReward > 0) { - const rewardLedgerType = shouldDelayUnfreeze ? 'reward_pending_unfreeze' : 'sharing_reward' - await client.query( - `INSERT INTO worker_wallet_ledgers ( + [ + workerId, + input.unfreezeDays > 0 ? 'deposit_pending_unfreeze' : 'deposit_release', + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + input.unfreezeDays > 0 ? '拼单份额验收押金待解冻' : '拼单份额验收释放押金', + JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }), + input.now, + ], + ) + } + if (shareReward > 0) { + const rewardLedgerType = shouldDelayUnfreeze + ? 'reward_pending_unfreeze' + : 'sharing_reward' + 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, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, - [ - workerId, - rewardLedgerType, - shareReward, - nextAvailable, - nextFrozen, - input.workOrderId, - shouldDelayUnfreeze - ? '拼单份额验收报酬待解冻' - : `拼单份额验收结算报酬 ${share.quantity} 份`, - JSON.stringify( + [ + workerId, + rewardLedgerType, + shareReward, + nextAvailable, + nextFrozen, + input.workOrderId, shouldDelayUnfreeze - ? { - workOrderId: input.workOrderId, - shareId: input.shareId, - unfreezeDays: input.unfreezeDays, - } - : { workOrderId: input.workOrderId, shareId: input.shareId }, - ), - input.now, - ], - ) - } - for (const debtOffset of debtOffsets) { - await client.query( - `INSERT INTO worker_wallet_ledgers ( + ? '拼单份额验收报酬待解冻' + : `拼单份额验收结算报酬 ${share.quantity} 份`, + JSON.stringify( + shouldDelayUnfreeze + ? { + workOrderId: input.workOrderId, + shareId: input.shareId, + unfreezeDays: input.unfreezeDays, + } + : { workOrderId: input.workOrderId, shareId: input.shareId }, + ), + input.now, + ], + ) + } + for (const debtOffset of debtOffsets) { + 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, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7)`, - [ - workerId, - -debtOffset.amount, - nextAvailable, - nextFrozen, - input.workOrderId, - JSON.stringify({ - caseId: debtOffset.caseId, - workOrderId: input.workOrderId, - shareId: input.shareId, - }), - input.now, - ], - ) + [ + workerId, + -debtOffset.amount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + caseId: debtOffset.caseId, + workOrderId: input.workOrderId, + shareId: input.shareId, + }), + input.now, + ], + ) + } } } @@ -792,10 +836,7 @@ export async function cancelWorkOrderShare(input: { share: WorkOrderShareRow | null releasedDepositAmount: number failureReason: - | 'work_order_not_cancellable' - | 'share_not_found' - | 'share_status_not_cancellable' - | null + 'work_order_not_cancellable' | 'share_not_found' | 'share_status_not_cancellable' | null }> { return withTransaction(async (client) => { const orderResult = await client.query( @@ -1134,10 +1175,7 @@ export async function cancelWorkOrderSharing(input: { cancelledShareCount: number releasedDepositAmount: number failureReason: - | 'work_order_not_cancellable' - | 'no_cancellable_shares' - | 'accepted_shares_present' - | null + 'work_order_not_cancellable' | 'no_cancellable_shares' | 'accepted_shares_present' | null }> { return withTransaction(async (client) => { const orderResult = await client.query( diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index 313712e6..e5de93fb 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -14,10 +14,10 @@ import { countWorkOrderPendingSharingSubmissions, createWorkOrderEvent, listWorkOrderSharesByWorkerInOrder, + markWorkOrderAcceptanceSubmitted, settleOverdueWorkOrder, settleOverdueWorkOrderShare, submitWorkOrderShareAcceptance, - updateWorkOrder, updateWorkOrderAcceptanceRecord, updateWorkOrderDraftAcceptance, updateWorkOrderShareAcceptanceRecord, @@ -82,7 +82,7 @@ export async function submitWorkerOrderAcceptance( } = {}, ) { requireActiveWorkerSession(session) - const workOrder = await getRequiredWorkOrder(workOrderId) + let workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) const worker = await getRequiredWorker(session.workerId) if (!options.asConsignmentSupplier) { @@ -184,28 +184,54 @@ export async function submitWorkerOrderAcceptance( } : {}), } - const updated = await updateWorkOrder(workOrder.id, { - status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, - acceptance_json: acceptance, - draft_acceptance_json: {}, - submitted_at: now, - updated_at: now, - }) - await createWorkOrderEvent({ + const transitioned = await markWorkOrderAcceptanceSubmitted({ workOrderId: workOrder.id, - actorType: 'worker', - actorId: String(session.workerId), - eventType: 'acceptance_submitted', - fromStatus: workOrder.status, - toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, - payloadJson: JSON.stringify({ - imageCount: imageUrls.length, - hasVideo: Boolean(video), - autoAccepted: canAutoAcceptWithoutEvidence, - evidenceDueAt: acceptance.evidenceDueAt || null, - }), + acceptanceJson: acceptance, now, }) + let updated = transitioned + if (transitioned) { + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'worker', + actorId: String(session.workerId), + eventType: 'acceptance_submitted', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + payloadJson: JSON.stringify({ + imageCount: imageUrls.length, + hasVideo: Boolean(video), + autoAccepted: canAutoAcceptWithoutEvidence, + evidenceDueAt: acceptance.evidenceDueAt || null, + }), + now, + }) + } else { + // 重复提交(双击/前端重试):第一个请求可能已经验收或正在结算。 + // 绝不能把 accepted 再写回待验收,只按当前状态幂等处理。 + const latest = await getRequiredWorkOrder(workOrder.id).catch(() => null) + const ownerMatched = + options.asConsignmentSupplier || Number(latest?.assigned_worker_id || 0) === workerId + if ( + !latest || + !ownerMatched || + ![WORK_ORDER_STATUS.PENDING_ACCEPTANCE, WORK_ORDER_STATUS.ACCEPTED].includes( + latest.status as never, + ) + ) { + throw createHttpError('当前状态不能提交验收', { + statusCode: 409, + errorCode: 'work_order_submit_status_invalid', + }) + } + if (latest.status === WORK_ORDER_STATUS.ACCEPTED) { + publishWorkOrderRealtimeChange({ workOrderId: Number(latest.id), workerIds: [workerId] }) + return { order: mapWorkOrderForWorker(latest, resolveWorkerPermissions(worker)) } + } + // 上一请求已推进到待验收但结算未完成:用已有验收资料重试结算,不重复写资料。 + workOrder = latest + updated = latest + } // 好友赠送模式必须由后台人工验收赠送凭证,内部打手与 VIP 免审核直通均不适用。 if ( !isFriendGiftAcceptanceMode(workOrder.acceptance_mode) && diff --git a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx index 503c595e..efcdf3aa 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx @@ -1,6 +1,7 @@ import { PictureOutlined } from '@ant-design/icons' import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd' import type { FormInstance } from 'antd' +import { useRef, useState } from 'react' import ImageUpload from '@/components/files/ImageUpload' import VideoUpload from '@/components/files/VideoUpload' @@ -109,6 +110,22 @@ export function WorkerOrderModals({ onSubmitGiftMaterial: (values: GiftMaterialFormValues) => void | Promise }) { const feedbackItems = feedbacks.filter((item) => item.workOrderId === feedbackOrder?.workOrderId) + // 验收提交/保存加锁:双击或慢响应时只允许一个请求在飞,避免后端重复结算。 + const acceptanceBusyRef = useRef(false) + const [acceptanceBusy, setAcceptanceBusy] = useState(false) + + async function runAcceptanceAction(action: () => void | Promise) { + if (acceptanceBusyRef.current) return + acceptanceBusyRef.current = true + setAcceptanceBusy(true) + try { + await action() + } finally { + acceptanceBusyRef.current = false + setAcceptanceBusy(false) + } + } + return ( <> {canSupplementAcceptedEvidence(editingOrder) ? ( - ) : ( <> {canSubmitAcceptance(editingOrder) ? ( - ) : null} @@ -258,7 +290,11 @@ export function WorkerOrderModals({ ) : null } > -
+ runAcceptanceAction(() => onSubmitAcceptance(values))} + > {!editingOrder || !canSupplementAcceptedEvidence(editingOrder) ? ( diff --git a/apps/frontend/tests/worker-acceptance-submit-guard.test.ts b/apps/frontend/tests/worker-acceptance-submit-guard.test.ts new file mode 100644 index 00000000..2378a082 --- /dev/null +++ b/apps/frontend/tests/worker-acceptance-submit-guard.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +/** + * 2026-10-30 生产事故:提交验收按钮没有在途锁,双击会发两个请求;第二个请求 + * 把已验收的工单写回待验收并触发重复结算。按钮必须在请求结束前保持 loading/禁用。 + */ +test('打手端验收提交按钮有在途锁,双击只会发一个请求', async () => { + const source = await readFile( + new URL('../src/pages/worker/WorkerOrderModals.tsx', import.meta.url), + 'utf8', + ) + + assert.ok(source.includes('acceptanceBusyRef'), '缺少同步在途锁 ref') + assert.ok(source.includes('runAcceptanceAction'), '缺少统一的验收动作包装') + assert.ok( + (source.match(/loading={acceptanceBusy}/g) || []).length >= 2, + '提交验收/补充图片按钮都应显示 loading', + ) + assert.ok( + source.includes('onFinish={(values) => runAcceptanceAction(() => onSubmitAcceptance(values))}'), + '表单提交也必须走在途锁', + ) +})