优化接单模板改价同步范围

This commit is contained in:
yml
2026-11-14 23:27:11 +08:00
parent ba6d5fb43b
commit 572c43ce0e
9 changed files with 98 additions and 28 deletions
@@ -0,0 +1,10 @@
-- 165_work_order_price_sync_scope.sql
--
-- 模板改价只需要刷新已经出现在大厅的 open 工单。
-- pending_material / unassigned 不属于大厅可见集合,发布时会统一物化最新成本版本;
-- 提前批量更新会把数万张待完善工单逐行写价格和事件,造成改价接口长时间阻塞。
DROP INDEX IF EXISTS idx_work_orders_price_sync_candidates;
COMMENT ON INDEX idx_work_orders_product_rule_open IS
'接单大厅模板改价候选:仅覆盖 open 工单;未发布工单在进入大厅时物化最新版本';
@@ -132,14 +132,14 @@ export async function getWorkProductRuleById(
return result.rows[0] || null
}
/** 查询尚未形成不可变履约合同、需要跟随模板价格更新的工单。 */
/** 查询已经进入接单大厅、需要立即跟随模板价格更新的工单。 */
export async function listWorkOrdersForPriceSyncByProductRuleId(
ruleId: number | string,
client?: Pick<PoolClient, 'query'>,
): Promise<WorkOrderRow[]> {
const sql = `${WORK_ORDER_SELECT}
WHERE wo.product_rule_id = $1
AND wo.status IN ('pending_material', 'unassigned', 'open')
AND wo.status = 'open'
ORDER BY wo.id ASC`
const result = client
? await client.query<WorkOrderRow>(sql, [Number(ruleId)])
@@ -302,6 +302,23 @@ test('接单模板商品通过显式关联进入接单平台成本范围', async
assert.ok(migration.includes("WHEN template_product.id IS NOT NULL THEN 'work_order'"))
})
test('模板批量改价只扫描大厅 open 工单,不遍历待完善和未分配工单', async () => {
const { readFile } = await import('node:fs/promises')
const [repoSource, migration] = await Promise.all([
readFile(
new URL('../../repositories/worker-platform/work-product-rule-repo.ts', import.meta.url),
'utf8',
),
readFile(
new URL('../../db/migrations/165_work_order_price_sync_scope.sql', import.meta.url),
'utf8',
),
])
assert.ok(repoSource.includes("AND wo.status = 'open'"))
assert.ok(!repoSource.includes("wo.status IN ('pending_material', 'unassigned', 'open')"))
assert.ok(migration.includes('DROP INDEX IF EXISTS idx_work_orders_price_sync_candidates'))
})
test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'third_party_voucher'), true)
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false)
@@ -11,7 +11,7 @@ import type { PoolClient } from 'pg'
const TEST_DATABASE_URL = String(process.env.WORK_ORDER_HALL_PRICING_TEST_DATABASE_URL || '').trim()
test(
'模板改价同步未发布工单、保留已加入拼单份额并阻止旧版本进入大厅',
'模板改价只同步大厅工单,未发布工单在发布时物化最新价并阻止旧版本进入大厅',
{ skip: !TEST_DATABASE_URL ? '未设置 WORK_ORDER_HALL_PRICING_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
@@ -170,7 +170,7 @@ test(
)
const secondVersionId = Number(secondPrice.costVersionId)
assert.notEqual(secondVersionId, firstVersionId)
assert.equal(Number(secondPrice.syncedOrders?.candidateCount || 0), 3)
assert.equal(Number(secondPrice.syncedOrders?.candidateCount || 0), 1)
const normalRows = await query<{
id: number
@@ -190,8 +190,8 @@ test(
version: Number(row.hall_price_version_id),
})),
[
{ id: pendingId, reward: 2670, version: secondVersionId },
{ id: unassignedId, reward: 2670, version: secondVersionId },
{ id: pendingId, reward: 2640, version: 0 },
{ id: unassignedId, reward: 2640, version: 0 },
],
)
@@ -222,6 +222,18 @@ test(
assert.equal(published.order?.status, 'open')
assert.equal(Number(published.order?.reward_amount), 2670)
await query(
`UPDATE work_orders SET status = 'unassigned', updated_at = NOW() WHERE id = $1`,
[pendingId],
)
const pendingPublished = await publishUnassignedWorkOrder({
workOrderId: pendingId,
now: new Date().toISOString(),
actorName: 'integration-test',
})
assert.equal(pendingPublished.order?.status, 'open')
assert.equal(Number(pendingPublished.order?.reward_amount), 2670)
const bypassId = await insertWorkOrder({
label: 'bypass',
status: 'unassigned',
@@ -65,7 +65,11 @@ import {
} from './mappers.js'
import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js'
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
import { materializeWorkOrderHallPriceWithClient } from './work-order-hall-pricing-service.js'
import {
getActiveWorkProductRuleWithClient,
getEffectiveCostVersionIdWithClient,
materializeWorkOrderHallPriceWithClient,
} from './work-order-hall-pricing-service.js'
import { invalidateAllWorkerHallListCache } from './worker-hall-service.js'
export async function listAdminWorkerLevels() {
@@ -630,7 +634,8 @@ export type WorkProductRulePriceSyncResult = {
/**
* 在调用方事务内同步模板价格。
*
* pending_material / unassigned 也必须在场,否则模板在发布前改价时工单会停留在旧价。
* 只刷新已经进入大厅的 open 工单。pending_material / unassigned 在发布前不展示价格,
* 发布入口会在同一事务内按最新成本版本物化,避免改价时扫描数万张未发布工单。
* 这里不发布实时事件;只有事务提交后才可以刷新大厅缓存和广播。
*/
export async function syncAdminWorkProductRulePriceWithClient(
@@ -649,6 +654,15 @@ export async function syncAdminWorkProductRulePriceWithClient(
skippedUnchangedCount: 0,
skippedUnavailableCount: 0,
}
if (candidates.length === 0) return result
const rule = await getActiveWorkProductRuleWithClient(client, ruleId)
const hallPriceVersionId = await getEffectiveCostVersionIdWithClient(client, ruleId, now)
if (!rule || !hallPriceVersionId) {
throw createHttpError('模板价格无法物化到现有大厅工单,已中止整批改价', {
statusCode: 409,
errorCode: 'work_order_hall_price_invalid',
})
}
for (const workOrder of candidates) {
if (workOrder.assigned_worker_id) {
@@ -658,6 +672,8 @@ export async function syncAdminWorkProductRulePriceWithClient(
const materialized = await materializeWorkOrderHallPriceWithClient(client, workOrder.id, {
now,
actorName,
rule,
hallPriceVersionId,
})
if (materialized.reason === 'invalid_pricing') {
throw createHttpError('模板价格无法物化到现有工单,已中止整批改价', {
@@ -684,7 +700,7 @@ export async function syncAdminWorkProductRulePriceWithClient(
return result
}
/** 将指定模板的当前价格同步到尚未形成履约合同的来源工单。 */
/** 将指定模板的当前价格同步到已经进入接单大厅的 open 工单。 */
export async function syncAdminWorkProductRulePrice(ruleId: number | string, actorName = '') {
const normalizedRuleId = normalizeOptionalId(ruleId)
const rule = normalizedRuleId ? await getWorkProductRuleById(normalizedRuleId) : null
@@ -106,7 +106,7 @@ async function lockActiveShares(client: Queryable, workOrderId: number) {
return result.rows.filter((share) => share.status !== 'cancelled')
}
async function getActiveRule(
export async function getActiveWorkProductRuleWithClient(
client: Queryable,
productRuleId: number,
): Promise<WorkProductRuleRow | null> {
@@ -117,7 +117,7 @@ async function getActiveRule(
return result.rows[0] || null
}
async function getEffectiveCostVersionId(
export async function getEffectiveCostVersionIdWithClient(
client: Queryable,
productRuleId: number,
now: string,
@@ -150,6 +150,8 @@ export async function materializeWorkOrderHallPriceWithClient(
now: string
actorName?: string
eventType?: string
rule?: WorkProductRuleRow
hallPriceVersionId?: number | null
},
): Promise<HallPriceMaterializationResult> {
const workOrder = await lockWorkOrder(client, workOrderId)
@@ -160,7 +162,8 @@ export async function materializeWorkOrderHallPriceWithClient(
})
}
const activeShares = await lockActiveShares(client, workOrder.id)
const activeShares =
workOrder.sharing_enabled === true ? await lockActiveShares(client, workOrder.id) : []
const joinedQuantity = activeShares.reduce(
(sum, share) => sum + Math.max(0, Number(share.quantity || 0)),
0,
@@ -195,7 +198,10 @@ export async function materializeWorkOrderHallPriceWithClient(
return { ...current, reason: 'no_template' }
}
const rule = await getActiveRule(client, productRuleId)
const rule =
Number(options.rule?.id || 0) === productRuleId
? options.rule!
: await getActiveWorkProductRuleWithClient(client, productRuleId)
if (!rule) {
return { ...current, reason: 'invalid_pricing' }
}
@@ -222,7 +228,10 @@ export async function materializeWorkOrderHallPriceWithClient(
return { ...current, reason: 'invalid_pricing' }
}
const hallPriceVersionId = await getEffectiveCostVersionId(client, productRuleId, options.now)
const hallPriceVersionId =
options.hallPriceVersionId !== undefined
? options.hallPriceVersionId
: await getEffectiveCostVersionIdWithClient(client, productRuleId, options.now)
if (!hallPriceVersionId) {
return { ...current, reason: 'invalid_pricing' }
}