fix(worker): 验收重复提交幂等,修复卡待验收与手动验收 500

This commit is contained in:
yml2213
2026-10-30 21:33:56 +08:00
parent a6ccb2e2d8
commit 96ae744dbb
6 changed files with 453 additions and 239 deletions
@@ -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'))
})
@@ -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<WorkOrderRow | null> {
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
@@ -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<boolean> {
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<WorkOrderRow>(
@@ -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<WorkOrderRow>(
@@ -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) &&
@@ -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<void>
}) {
const feedbackItems = feedbacks.filter((item) => item.workOrderId === feedbackOrder?.workOrderId)
// 验收提交/保存加锁:双击或慢响应时只允许一个请求在飞,避免后端重复结算。
const acceptanceBusyRef = useRef(false)
const [acceptanceBusy, setAcceptanceBusy] = useState(false)
async function runAcceptanceAction(action: () => void | Promise<void>) {
if (acceptanceBusyRef.current) return
acceptanceBusyRef.current = true
setAcceptanceBusy(true)
try {
await action()
} finally {
acceptanceBusyRef.current = false
setAcceptanceBusy(false)
}
}
return (
<>
<Modal
@@ -237,18 +254,33 @@ export function WorkerOrderModals({
<Space>
<Button onClick={onCloseAcceptance}>取消</Button>
{canSupplementAcceptedEvidence(editingOrder) ? (
<Button type="primary" onClick={() => acceptanceForm.submit()}>
<Button
type="primary"
loading={acceptanceBusy}
disabled={acceptanceBusy}
onClick={() => acceptanceForm.submit()}
>
补充图片
</Button>
) : (
<>
<Button
onClick={() => void onSaveAcceptanceImages(acceptanceForm.getFieldsValue())}
disabled={acceptanceBusy}
onClick={() =>
void runAcceptanceAction(() =>
onSaveAcceptanceImages(acceptanceForm.getFieldsValue()),
)
}
>
{hasSubmittedAcceptance(editingOrder) ? '保存修改' : '保存暂存'}
</Button>
{canSubmitAcceptance(editingOrder) ? (
<Button type="primary" onClick={() => acceptanceForm.submit()}>
<Button
type="primary"
loading={acceptanceBusy}
disabled={acceptanceBusy}
onClick={() => acceptanceForm.submit()}
>
提交验收
</Button>
) : null}
@@ -258,7 +290,11 @@ export function WorkerOrderModals({
) : null
}
>
<Form form={acceptanceForm} layout="vertical" onFinish={onSubmitAcceptance}>
<Form
form={acceptanceForm}
layout="vertical"
onFinish={(values) => runAcceptanceAction(() => onSubmitAcceptance(values))}
>
{!editingOrder || !canSupplementAcceptedEvidence(editingOrder) ? (
<Form.Item label="完成说明" name="note">
<Input.TextArea rows={3} placeholder="可补充代练结果、注意事项或截图说明。" />
@@ -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))}'),
'表单提交也必须走在途锁',
)
})