修复接单大厅价格物化遗漏

This commit is contained in:
yml
2026-11-15 00:21:04 +08:00
parent e77f96ee3c
commit 2f03b96634
6 changed files with 237 additions and 10 deletions
@@ -285,6 +285,12 @@ export async function returnAssignedWorkOrderToHall(input: {
Number(wallet?.frozen_deposit_amount || 0),
)
// 所有重新进入大厅的路径都必须先物化当前模板价和成本版本;
// 直接更新为 open 会被迁移 163 的数据库闸门拒绝。
await assertWorkOrderHallPriceReadyWithClient(client, input.workOrderId, {
now: input.now,
actorName: input.actorName || '',
})
await client.query(
`
UPDATE work_orders
@@ -678,6 +678,11 @@ export async function settleOverdueWorkOrder(input: {
// 重匹配不再进入内部匹配池:超时结算后先回到待抢单,由超时服务立即试一次自动匹配。
const targetStatus = isRematch || input.policy === 'reopen' ? 'open' : 'cancelled'
if (targetStatus === 'open') {
await assertWorkOrderHallPriceReadyWithClient(client, input.workOrderId, {
now: input.now,
})
}
await client.query(
`
@@ -783,16 +783,13 @@ export async function recomputeWorkOrderSharingStatusWithClient(
const nextStatus =
allocated >= total && joinedCount === 0 && submittedCount > 0 ? 'pending_acceptance' : 'open'
// 已全是 accepted 且母单已验收时不再回退(上面的终态分支已挡住)
if (allocated === 0 && acceptedCount === 0 && workOrder.status === 'open') {
return workOrder
}
if (String(workOrder.status || '') === nextStatus) {
return workOrder
}
// 即使母单仍为 open,份额取消/减少也会增加未拼数量,必须重新按当前模板价物化。
if (nextStatus === 'open') {
await assertWorkOrderHallPriceReadyWithClient(client, workOrderId, { now })
}
if (String(workOrder.status || '') === nextStatus) {
return getWorkOrderByIdWithClient(client, workOrderId)
}
const hallQueueAssignments = options.preserveHallQueue
? ''
@@ -23,6 +23,12 @@ test(
await import('../worker-platform/work-order-hall-pricing-service.js')
const { publishUnassignedWorkOrder } =
await import('../../repositories/worker-platform/work-order-management-repo.js')
const { returnAssignedWorkOrderToHall } =
await import('../../repositories/worker-platform/work-order-cancel-repo.js')
const { cancelWorkOrderShare } =
await import('../../repositories/worker-platform/work-order-settlement-repo.js')
const { settleOverdueWorkOrder } =
await import('../../repositories/worker-platform/work-order-issue-repo.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const productName = `大厅价格收口集成测试-${suffix}`
const createdWorkOrderIds: number[] = []
@@ -172,6 +178,37 @@ test(
assert.notEqual(secondVersionId, firstVersionId)
assert.equal(Number(secondPrice.syncedOrders?.candidateCount || 0), 1)
const returnToHallId = await insertWorkOrder({
label: 'return-to-hall',
status: 'in_progress',
rewardAmount: 9999,
})
await query(
`UPDATE work_orders
SET assigned_worker_id = $1,
assigned_at = NOW(),
updated_at = NOW()
WHERE id = $2`,
[workerId, returnToHallId],
)
const returned = await returnAssignedWorkOrderToHall({
workOrderId: returnToHallId,
now: new Date().toISOString(),
actorName: 'integration-test',
})
assert.equal(returned.order?.status, 'open')
const returnedPrice = await query<{
reward_amount: number
hall_price_version_id: number
}>(
`SELECT reward_amount, hall_price_version_id
FROM work_orders
WHERE id = $1`,
[returnToHallId],
)
assert.equal(Number(returnedPrice.rows[0]?.reward_amount), 2670)
assert.equal(Number(returnedPrice.rows[0]?.hall_price_version_id), secondVersionId)
const normalRows = await query<{
id: number
reward_amount: number
@@ -207,13 +244,71 @@ test(
assert.equal(Number(sharingRow.rows[0]?.reward_amount), 2640 + 2 * 1335)
assert.equal(Number(sharingRow.rows[0]?.sharing_unit_reward), 1335)
assert.equal(Number(sharingRow.rows[0]?.hall_price_version_id), secondVersionId)
const share = await query<{ share_reward: number; unit_reward: number }>(
`SELECT share_reward, unit_reward FROM work_order_shares WHERE work_order_id = $1`,
const share = await query<{ id: number; share_reward: number; unit_reward: number }>(
`SELECT id, share_reward, unit_reward
FROM work_order_shares
WHERE work_order_id = $1`,
[sharingId],
)
assert.equal(Number(share.rows[0]?.share_reward), 2640)
assert.equal(Number(share.rows[0]?.unit_reward), 1320)
const cancelledShare = await cancelWorkOrderShare({
workOrderId: sharingId,
shareId: Number(share.rows[0]!.id),
now: new Date().toISOString(),
reason: '大厅价格回归:撤掉已拼份额',
actorName: 'integration-test',
})
assert.equal(cancelledShare.failureReason, null)
const afterShareCancellation = await query<{
status: string
reward_amount: number
sharing_unit_reward: number
hall_price_version_id: number
}>(
`SELECT status, reward_amount, sharing_unit_reward, hall_price_version_id
FROM work_orders
WHERE id = $1`,
[sharingId],
)
assert.equal(afterShareCancellation.rows[0]?.status, 'open')
assert.equal(Number(afterShareCancellation.rows[0]?.reward_amount), 4 * 1335)
assert.equal(Number(afterShareCancellation.rows[0]?.sharing_unit_reward), 1335)
assert.equal(Number(afterShareCancellation.rows[0]?.hall_price_version_id), secondVersionId)
const overdueId = await insertWorkOrder({
label: 'overdue-reopen',
status: 'in_progress',
rewardAmount: 9999,
})
await query(
`UPDATE work_orders
SET assigned_worker_id = $1,
assigned_at = NOW() - INTERVAL '10 minutes',
deadline_at = NOW() - INTERVAL '1 minute',
updated_at = NOW() - INTERVAL '10 minutes'
WHERE id = $2`,
[workerId, overdueId],
)
const overdue = await settleOverdueWorkOrder({
workOrderId: overdueId,
policy: 'reopen',
now: new Date().toISOString(),
})
assert.equal(overdue.order?.status, 'open')
const overduePrice = await query<{
reward_amount: number
hall_price_version_id: number
}>(
`SELECT reward_amount, hall_price_version_id
FROM work_orders
WHERE id = $1`,
[overdueId],
)
assert.equal(Number(overduePrice.rows[0]?.reward_amount), 2670)
assert.equal(Number(overduePrice.rows[0]?.hall_price_version_id), secondVersionId)
const published = await publishUnassignedWorkOrder({
workOrderId: unassignedId,
now: new Date().toISOString(),
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict'
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const BACKEND_SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
async function listTypeScriptFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
const fullPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...(await listTypeScriptFiles(fullPath)))
} else if (
entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.test.ts') &&
!entry.name.endsWith('.integration.test.ts')
) {
files.push(fullPath)
}
}
return files
}
test('所有直接写 open 的接单仓库调用都必须先物化大厅价格', async () => {
const files = (
await Promise.all([
listTypeScriptFiles(path.join(BACKEND_SRC, 'repositories')),
listTypeScriptFiles(path.join(BACKEND_SRC, 'services')),
])
).flat()
const directWrites: string[] = []
for (const file of files) {
const source = await readFile(file, 'utf8')
const pattern = /UPDATE\s+work_orders\s+SET\s+([\s\S]*?)\bWHERE\b/g
for (const match of source.matchAll(pattern)) {
if (!/\bstatus\s*=\s*'open'/.test(match[1] || '')) continue
const matchIndex = Number(match.index || 0)
const functionStart = Math.max(
source.lastIndexOf('\nexport async function ', matchIndex),
source.lastIndexOf('\nexport function ', matchIndex),
source.lastIndexOf('\nasync function ', matchIndex),
source.lastIndexOf('\nfunction ', matchIndex),
)
const before = source.slice(functionStart, matchIndex)
assert.match(
before,
/assertWorkOrderHallPriceReadyWithClient\s*\(/,
`${path.relative(BACKEND_SRC, file)} 存在未经大厅价格物化直接写 open 的路径`,
)
directWrites.push(path.relative(BACKEND_SRC, file))
}
}
assert.deepEqual(
directWrites.sort(),
[
'repositories/worker-platform/work-order-cancel-repo.ts',
'repositories/worker-platform/work-order-cancel-repo.ts',
'repositories/worker-platform/work-order-management-repo.ts',
'repositories/worker-platform/work-order-settlement-repo.ts',
'repositories/worker-platform/worker-cancel-request-repo.ts',
'services/worker-platform/consignment-match-engine-service.ts',
].sort(),
)
})
test('拼单份额变化后即使母单仍是 open 也必须重新物化大厅价格', async () => {
const source = await readFile(
path.join(BACKEND_SRC, 'repositories/worker-platform/work-order-share-repo.ts'),
'utf8',
)
const functionStart = source.indexOf(
'export async function recomputeWorkOrderSharingStatusWithClient',
)
assert.ok(functionStart >= 0)
const functionSource = source.slice(functionStart)
const assertIndex = functionSource.indexOf('assertWorkOrderHallPriceReadyWithClient(')
const unchangedStatusIndex = functionSource.indexOf(
"if (String(workOrder.status || '') === nextStatus)",
)
assert.ok(assertIndex >= 0, '拼单重算缺少大厅价格物化')
assert.ok(unchangedStatusIndex > assertIndex, '状态未变化时不得在价格物化前提前返回')
})
test('参数化写入 open 的超时和问题单路径必须先物化大厅价格', async () => {
const source = await readFile(
path.join(BACKEND_SRC, 'repositories/worker-platform/work-order-issue-repo.ts'),
'utf8',
)
for (const functionName of ['resolveProblemWorkOrder', 'settleOverdueWorkOrder']) {
const start = source.indexOf(`export async function ${functionName}`)
assert.ok(start >= 0, `缺少 ${functionName}`)
const nextFunction = source.indexOf('\nexport async function ', start + 1)
const functionSource = source.slice(start, nextFunction < 0 ? undefined : nextFunction)
const updateIndex = functionSource.indexOf('UPDATE work_orders')
const assertIndex = functionSource.indexOf('assertWorkOrderHallPriceReadyWithClient(')
assert.ok(assertIndex >= 0, `${functionName} 缺少大厅价格物化`)
assert.ok(assertIndex < updateIndex, `${functionName} 必须在写 open 前物化大厅价格`)
}
})
test('通用工单更新写 open 时必须先物化大厅价格', async () => {
const source = await readFile(
path.join(BACKEND_SRC, 'repositories/worker-platform/work-order-management-repo.ts'),
'utf8',
)
const start = source.indexOf('export async function updateWorkOrder(')
assert.ok(start >= 0)
const nextFunction = source.indexOf('\nexport async function ', start + 1)
const functionSource = source.slice(start, nextFunction < 0 ? undefined : nextFunction)
const openBranchIndex = functionSource.indexOf("if (patch.status === 'open')")
const assertIndex = functionSource.indexOf('assertWorkOrderHallPriceReadyWithClient(')
assert.ok(openBranchIndex >= 0, '通用更新缺少 open 分支')
assert.ok(assertIndex > openBranchIndex, '通用更新写 open 前必须物化大厅价格')
})