From 2f03b966347c5530c4258918bdab6a40a9796162 Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 15 Nov 2026 00:21:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A5=E5=8D=95=E5=A4=A7?= =?UTF-8?q?=E5=8E=85=E4=BB=B7=E6=A0=BC=E7=89=A9=E5=8C=96=E9=81=97=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-cancel-repo.ts | 6 + .../worker-platform/work-order-issue-repo.ts | 5 + .../worker-platform/work-order-share-repo.ts | 11 +- ...ork-order-hall-pricing.integration.test.ts | 99 +++++++++++++- .../work-order-hall-pricing-routing.test.ts | 122 ++++++++++++++++++ docs/统一订单履约与财务模型-权威版.md | 4 +- 6 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/work-order-hall-pricing-routing.test.ts diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index 13d5733f..cc9e17e7 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index ed7bd11f..902325b5 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -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( ` diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 904a0d50..20f027ad 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -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 ? '' diff --git a/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts b/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts index 0dc33935..3cf1b7a1 100644 --- a/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts +++ b/apps/backend/src/services/cost-center/work-order-hall-pricing.integration.test.ts @@ -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(), diff --git a/apps/backend/src/services/worker-platform/work-order-hall-pricing-routing.test.ts b/apps/backend/src/services/worker-platform/work-order-hall-pricing-routing.test.ts new file mode 100644 index 00000000..c1857f90 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-hall-pricing-routing.test.ts @@ -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 { + 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 前必须物化大厅价格') +}) diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index f9b5a406..98d990a3 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.23 +version: 2.24 updated_at: 2026-11-15 --- @@ -376,6 +376,8 @@ manual fulfillment 合同快照,不得被后续模板改价重写。 - 撤单、退单审核、超时重开、问题单退回大厅、拼单份额退回大厅等所有重新进入 `open` 的路径, 必须先经过统一的大厅价格物化入口,再修改状态。 +- 拼单份额取消、减少或超时后,即使母单状态仍然是 `open`,也必须重新物化; + 新空出的未拼份额按当前模板价计算,不能因为“状态没变化”跳过价格更新。 - 数据库使用迁移 `163` 的版本闸门拒绝“模板工单携带空版本或已失效版本进入 `open`”。 正在使用寄卖现货的合同态工单显式豁免,不得用模板价覆盖寄卖结算金额。 - 模板批量改价提交后必须整体清空接单大厅列表缓存并广播全量刷新,不能依赖逐张工单事件补齐。