diff --git a/apps/backend/scripts/audit-work-order-hall-pricing.ts b/apps/backend/scripts/audit-work-order-hall-pricing.ts index d470c665..79c6dc43 100644 --- a/apps/backend/scripts/audit-work-order-hall-pricing.ts +++ b/apps/backend/scripts/audit-work-order-hall-pricing.ts @@ -21,7 +21,7 @@ const rows = await query( FROM work_orders WHERE product_rule_id IS NOT NULL AND assigned_worker_id IS NULL - AND status IN ('pending_material', 'unassigned', 'open') + AND status = 'open' AND NOT (COALESCE(material_json, '{}'::jsonb) ? 'consignment') ORDER BY id`, ) @@ -81,7 +81,7 @@ for (const workOrder of workOrders) { const share = shareAggregates.get(Number(workOrder.id)) const joinedQuantity = Number(share?.joined_quantity || 0) const lockedShareReward = Number(share?.locked_share_reward || 0) - const severity = workOrder.status === 'open' ? 'critical' : 'warning' + const severity = 'critical' as const if (!rule) { issues.push({ diff --git a/apps/backend/src/db/migrations/165_work_order_price_sync_scope.sql b/apps/backend/src/db/migrations/165_work_order_price_sync_scope.sql new file mode 100644 index 00000000..f4c89421 --- /dev/null +++ b/apps/backend/src/db/migrations/165_work_order_price_sync_scope.sql @@ -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 工单;未发布工单在进入大厅时物化最新版本'; diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index c47f4c09..4f46e58f 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -132,14 +132,14 @@ export async function getWorkProductRuleById( return result.rows[0] || null } -/** 查询尚未形成不可变履约合同、需要跟随模板价格更新的工单。 */ +/** 查询已经进入接单大厅、需要立即跟随模板价格更新的工单。 */ export async function listWorkOrdersForPriceSyncByProductRuleId( ruleId: number | string, client?: Pick, ): Promise { 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(sql, [Number(ruleId)]) diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index 0daa4b5d..59528226 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -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) 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 8bdc710a..0dc33935 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 @@ -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', diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index fa1268b4..c4a434fe 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -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 diff --git a/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts b/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts index c71792a9..c15a0c2d 100644 --- a/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-hall-pricing-service.ts @@ -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 { @@ -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 { 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' } } diff --git a/docs/生产升级操作手册.md b/docs/生产升级操作手册.md index 8a90eb7d..a010dc28 100644 --- a/docs/生产升级操作手册.md +++ b/docs/生产升级操作手册.md @@ -3,7 +3,7 @@ type: 生产运维操作手册 description: order_site 生产结构升级、数据切换、验收与回滚的唯一执行步骤。 tags: [生产, 部署, 数据库, 迁移, 备份, 回滚, 财务] status: current -version: 1.2 +version: 1.3 updated_at: 2026-11-14 --- @@ -26,14 +26,15 @@ updated_at: 2026-11-14 > > 2026-11-14 增量待发布基线:在 `161` 之上依次新增 > `162_fulfillment_queue_buckets.sql`、`163_work_order_hall_price_version.sql`、 -> `164_template_product_cost_visibility.sql`。 +> `164_template_product_cost_visibility.sql`、`165_work_order_price_sync_scope.sql`。 > `162` 建立履约待办四桶只读投影、补载体扫描专用索引和新单优先扫描口径; > `163` 增加接单大厅价格成本版本引用、改价同步候选索引和版本闸门,不重写历史工单金额。 > `164` 增加接单模板到标准商品的显式关联,修复无订单模板商品落入未路由、无法先配置成本的问题。 +> `165` 将模板改价同步范围收敛到 `open`,未发布工单在发布时物化最新价,避免批量改价扫描数万行。 > 两项迁移都不改写原始订单、履约尝试或售后事实;仍须完成停写、最终备份、隔离恢复验证、 > 迁移、健康检查、履约审计和大厅价格审计后才可恢复营业。 -### 1.1 迁移 `151 → 164` 增量门禁 +### 1.1 迁移 `151 → 165` 增量门禁 - 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。 - 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160` @@ -48,7 +49,8 @@ updated_at: 2026-11-14 改价同步候选索引;正在使用寄卖现货的合同态工单豁免版本闸门。 - `164` 新增 `work_product_rules.canonical_product_id` 及唯一索引,并重建 `v_product_current`,让无订单的模板商品明确归属接单平台成本范围。 -- 用测试库完成 `001 → 164` 空库迁移,并在隔离库验证 `151 → 164`。 +- `165` 删除未发布工单的模板改价候选索引;改价 API 只允许扫描并更新 `open` 工单。 +- 用测试库完成 `001 → 165` 空库迁移,并在隔离库验证 `151 → 165`。 - 迁移后必须验证新建停用模板能在“接单平台”成本页签看到并配置成本,且不出现在“未路由”。 - 迁移后执行 `npm run audit:work-order-hall-pricing`,`critical` 必须为 0。 - 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库; @@ -452,8 +454,8 @@ docker compose --profile tools run --rm --no-deps tools \ - 寄卖合同态工单不参与模板价审计覆盖。 - 新建停用模板必须已出现在“接单平台”标准商品范围,并能先配置成本;不得显示为“未路由”。 -`warning` 可包含尚未发布的 `pending_material / unassigned` 工单价格待同步项, -但必须由发布负责人在恢复营业前执行模板同步或记录结构化待办,不得手工猜价。 +审计只扫描已进入大厅的 `open` 工单。`pending_material / unassigned` 保留旧价格是预期行为, +发布入口会强制物化最新成本版本,不应为这些未发布工单批量补写价格。 ### 6.4 财务重建 dry-run diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 979d8740..c7939523 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.20 +version: 2.21 updated_at: 2026-11-14 --- @@ -12,7 +12,7 @@ updated_at: 2026-11-14 > 状态:**唯一权威文档**。 > 最后校准:2026-11-14。 > 生产基线:迁移 `151`。 -> 开发基线:迁移 `164`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `165`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -367,8 +367,10 @@ manual fulfillment 不是新的价格真源。 - 模板工单必须记录 `work_orders.hall_price_version_id`,指向本次价格物化使用的成本版本。 -- `pending_material`、`unassigned`、`open` 工单在模板改价时必须同事务更新;改价失败时 - 成本版本、模板价格投影和工单价格全部回滚。 +- 模板改价只立即刷新已经在大厅中的 `open` 工单;`pending_material`、`unassigned` 不提前扫单。 + 这些工单进入大厅时必须在同一发布事务内按最新生效成本版本物化价格。 +- 未发布工单不得因为模板改价被逐张补写价格或事件;改价失败时成本版本和已同步的大厅工单 + 必须全部回滚。 - 普通工单按模板计价方式和订单原始规格数量计算。 - 拼单工单只刷新尚未加入的份额;已加入份额的 `unit_reward`、`share_reward` 是参与时的 合同快照,不得被后续模板改价重写。 @@ -1041,7 +1043,7 @@ docs/archive/ ## 十三、当前下一步 -截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `164`。迁移 `152` 保护订单来源快照, +截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `165`。迁移 `152` 保护订单来源快照, 迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置; 迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、 任务凭证引用和最终约束;迁移 `162` 建立履约待办四桶只读投影、补载体专用索引和新单优先扫描口径。 @@ -1049,12 +1051,14 @@ docs/archive/ 该迁移不重写历史工单金额,寄卖合同态显式豁免。 迁移 `164` 增加接单模板到标准商品的显式关联,并同步商品渠道投影,保证新模板在无订单时也能 进入接单平台成本范围。 +迁移 `165` 删除覆盖未发布工单的改价候选索引,模板改价只刷新 `open` 工单;待完善和未分配 +工单在发布时物化最新价格,避免每次改价扫描数万行。 迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录, 不再作为 CK 导出门禁。 1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。 2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。 -3. `147 → 164` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、 +3. `147 → 165` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、 财务和大厅价格审计;无法确定的数据进入未路由或待分类,不做猜测。 4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。