优化接单模板改价同步范围
This commit is contained in:
@@ -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' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user