diff --git a/apps/backend/src/db/migrations/164_template_product_cost_visibility.sql b/apps/backend/src/db/migrations/164_template_product_cost_visibility.sql
new file mode 100644
index 00000000..f5f3c8e5
--- /dev/null
+++ b/apps/backend/src/db/migrations/164_template_product_cost_visibility.sql
@@ -0,0 +1,81 @@
+-- 164_template_product_cost_visibility.sql
+--
+-- 接单模板的标准商品在创建时还没有订单,也不一定已有内部 SKU 主路由。
+-- 旧版 v_product_current 只从 internal_sku_id -> sku_channel_routes 推导当前渠道,
+-- 导致新模板商品落入“未路由”,成本中心“接单平台”页签无法配置成本。
+--
+-- 这里建立模板到标准商品的显式关系。接单模板本身就是工作单渠道配置,
+-- 商品渠道由该关系明确归属,不从 canonical_sku 前缀、商品名或订单反推。
+
+ALTER TABLE work_product_rules
+ ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT
+ REFERENCES product_catalog(id) ON DELETE RESTRICT;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_work_product_rules_canonical_product
+ ON work_product_rules (canonical_product_id)
+ WHERE canonical_product_id IS NOT NULL;
+
+COMMENT ON COLUMN work_product_rules.canonical_product_id IS
+ '接单模板对应的标准成本商品;用于无订单时确定成本归属,不参与订单履约路由';
+
+UPDATE work_product_rules rule
+SET canonical_product_id = product.id,
+ updated_at = NOW()
+FROM product_catalog product
+WHERE rule.canonical_product_id IS NULL
+ AND product.source_kind = 'work_order'
+ AND product.status = 'active'
+ AND product.canonical_sku = LEFT('tpl:' || BTRIM(rule.rule_key), 64);
+
+CREATE OR REPLACE VIEW v_product_current AS
+SELECT
+ product.*,
+ sku.internal_sku_key,
+ COALESCE(
+ primary_route.channel,
+ CASE
+ WHEN template_product.id IS NOT NULL THEN 'work_order'
+ ELSE NULL
+ END
+ ) AS active_primary_channel,
+ COALESCE(fallback_routes.channels, ARRAY[]::text[]) AS fallback_channels,
+ manual_config.id AS manual_config_id,
+ (manual_config.id IS NOT NULL) AS has_active_manual_config
+FROM product_catalog product
+LEFT JOIN internal_skus sku ON sku.id = product.internal_sku_id
+LEFT JOIN work_product_rules template_product
+ ON template_product.canonical_product_id = product.id
+ AND product.source_kind = 'work_order'
+LEFT JOIN LATERAL (
+ SELECT route.channel
+ FROM sku_channel_routes route
+ WHERE route.internal_sku_id = product.internal_sku_id
+ AND route.enabled = TRUE
+ AND route.route_role = 'primary'
+ ORDER BY route.priority, route.channel
+ LIMIT 1
+) primary_route ON TRUE
+LEFT JOIN LATERAL (
+ SELECT ARRAY_AGG(route.channel ORDER BY route.priority, route.channel) AS channels
+ FROM sku_channel_routes route
+ WHERE route.internal_sku_id = product.internal_sku_id
+ AND route.enabled = TRUE
+ AND route.route_role = 'fallback'
+) fallback_routes ON TRUE
+LEFT JOIN LATERAL (
+ SELECT config.id
+ FROM manual_fulfillment_configs config
+ WHERE config.enabled = TRUE
+ AND (
+ config.canonical_product_id = product.id
+ OR (
+ sku.internal_sku_key IS NOT NULL
+ AND canonicalize_internal_sku(config.sku_key) = sku.internal_sku_key
+ )
+ )
+ ORDER BY config.id
+ LIMIT 1
+) manual_config ON TRUE;
+
+COMMENT ON VIEW v_product_current IS
+ '标准商品统一投影:内部 SKU 商品从主路由取渠道;接单模板商品由模板-商品显式关系归属 work_order';
diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts
index 36d066ef..e4051b8c 100644
--- a/apps/backend/src/repositories/worker-platform/types.ts
+++ b/apps/backend/src/repositories/worker-platform/types.ts
@@ -281,6 +281,7 @@ export type WorkCategoryRow = {
export type WorkProductRuleRow = {
id: number
rule_key: string
+ canonical_product_id?: number | null
provider: string
platform: string
shop_id: string
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 9e0ef899..0daa4b5d 100644
--- a/apps/backend/src/services/cost-center/cost-center.test.ts
+++ b/apps/backend/src/services/cost-center/cost-center.test.ts
@@ -287,6 +287,21 @@ test('接单模板改价必须在同一事务中同步成本版本与模板投
assert.ok(source.includes('cost_work_order_future_price_not_supported'))
})
+test('接单模板商品通过显式关联进入接单平台成本范围', async () => {
+ const { readFile } = await import('node:fs/promises')
+ const [projectionSource, migration] = await Promise.all([
+ readFile(new URL('./work-order-template-product-service.ts', import.meta.url), 'utf8'),
+ readFile(
+ new URL('../../db/migrations/164_template_product_cost_visibility.sql', import.meta.url),
+ 'utf8',
+ ),
+ ])
+ assert.ok(projectionSource.includes('SET canonical_product_id = $1'))
+ assert.ok(migration.includes('work_product_rules.canonical_product_id'))
+ assert.ok(migration.includes('template_product.canonical_product_id = product.id'))
+ assert.ok(migration.includes("WHEN template_product.id IS NOT NULL THEN 'work_order'"))
+})
+
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/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts
index cbaec7f1..63f2a939 100644
--- a/apps/backend/src/services/cost-center/product-catalog-service.ts
+++ b/apps/backend/src/services/cost-center/product-catalog-service.ts
@@ -319,6 +319,12 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes
errorCode: 'cost_voucher_product_managed_by_config',
})
}
+ if (canonicalSku.startsWith('tpl:')) {
+ throw createHttpError('接单模板商品由接单模板自动创建,请先保存接单模板', {
+ statusCode: 409,
+ errorCode: 'cost_template_product_managed_by_rule',
+ })
+ }
if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) {
throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', {
statusCode: 400,
@@ -415,13 +421,16 @@ export async function deleteAdminProduct(productId: number | string) {
version_count: number
snapshot_count: number
order_item_count: number
+ template_rule_count: number
}>(
`
SELECT
(SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count,
(SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count,
(SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count,
- (SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count
+ (SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count,
+ (SELECT COUNT(*)::int FROM work_product_rules WHERE canonical_product_id = $1)
+ AS template_rule_count
`,
[id],
)
@@ -435,6 +444,8 @@ export async function deleteAdminProduct(productId: number | string) {
blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`)
if (toNumber(usage?.order_item_count) > 0)
blockers.push(`${toNumber(usage?.order_item_count)} 个订单项`)
+ if (toNumber(usage?.template_rule_count) > 0)
+ blockers.push(`${toNumber(usage?.template_rule_count)} 个接单模板`)
if (blockers.length > 0) {
throw createHttpError(`商品仍被引用(${blockers.join('、')}),不能删除;请改为归档`, {
statusCode: 409,
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 5314c58c..8bdc710a 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
@@ -300,8 +300,8 @@ test(
'UPDATE sku_channel_routes SET canonical_product_id = NULL WHERE canonical_product_id = $1',
[productId],
)
- await query('DELETE FROM product_catalog WHERE id = $1', [productId])
await query('DELETE FROM work_product_rules WHERE id = $1', [ruleId])
+ await query('DELETE FROM product_catalog WHERE id = $1', [productId])
}
if (adminId > 0) {
await query('DELETE FROM admin_users WHERE id = $1', [adminId])
diff --git a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts
index fdeb3647..2541d819 100644
--- a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts
+++ b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts
@@ -19,16 +19,27 @@ test(
const { closeDb, query } = await import('../../db/client.js')
const { saveAdminWorkProductRule } =
await import('../worker-platform/admin-worker-catalog-service.js')
- const { setAdminProductPrice } = await import('./product-catalog-service.js')
+ const { listAdminProducts, setAdminProductPrice } = await import('./product-catalog-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const productName = `集成测试接单模板-${suffix}`
let ruleId = 0
let productId = 0
+ let temporaryAdminId = 0
try {
- const admin = await query<{ id: number; username: string; display_name: string }>(
+ let admin = await query<{ id: number; username: string; display_name: string }>(
'SELECT id, username, display_name FROM admin_users ORDER BY id LIMIT 1',
)
+ if (!admin.rows[0]) {
+ admin = await query<{ id: number; username: string; display_name: string }>(
+ `INSERT INTO admin_users (
+ username, password_hash, role, status, created_at, updated_at
+ ) VALUES ($1, 'x', 'admin', 'active', NOW(), NOW())
+ RETURNING id, username, display_name`,
+ [`template_cost_admin_${suffix}`],
+ )
+ temporaryAdminId = Number(admin.rows[0]?.id || 0)
+ }
const adminUser = admin.rows[0]
assert.ok(adminUser, '集成测试库至少需要一个后台账号')
@@ -54,6 +65,30 @@ test(
)
assert.equal(product.rows[0]?.canonical_sku, `tpl:${created.rule.ruleKey}`)
assert.equal(product.rows[0]?.status, 'active')
+ const relation = await query<{ canonical_product_id: number }>(
+ 'SELECT canonical_product_id FROM work_product_rules WHERE id = $1',
+ [ruleId],
+ )
+ assert.equal(Number(relation.rows[0]?.canonical_product_id), productId)
+
+ const workOrderProducts = await listAdminProducts({
+ channel: 'work_order',
+ keyword: productName,
+ pageSize: 20,
+ })
+ assert.ok(
+ workOrderProducts.items.some((item) => Number(item.id) === productId),
+ '未产生订单时,新模板商品必须已出现在接单平台成本范围',
+ )
+ const unroutedProducts = await listAdminProducts({
+ channel: 'unrouted',
+ keyword: productName,
+ pageSize: 20,
+ })
+ assert.ok(
+ !unroutedProducts.items.some((item) => Number(item.id) === productId),
+ '模板商品不应再被当成未路由商品',
+ )
await assert.rejects(
() =>
@@ -251,8 +286,11 @@ test(
'UPDATE sku_channel_routes SET canonical_product_id = NULL WHERE canonical_product_id = $1',
[productId],
)
- await query('DELETE FROM product_catalog WHERE id = $1', [productId])
await query('DELETE FROM work_product_rules WHERE id = $1', [ruleId])
+ await query('DELETE FROM product_catalog WHERE id = $1', [productId])
+ }
+ if (temporaryAdminId > 0) {
+ await query('DELETE FROM admin_users WHERE id = $1', [temporaryAdminId])
}
await closeDb()
}
diff --git a/apps/backend/src/services/cost-center/work-order-template-product-service.ts b/apps/backend/src/services/cost-center/work-order-template-product-service.ts
index 4358944f..8ae7f6af 100644
--- a/apps/backend/src/services/cost-center/work-order-template-product-service.ts
+++ b/apps/backend/src/services/cost-center/work-order-template-product-service.ts
@@ -130,9 +130,20 @@ export async function syncWorkOrderTemplateProduct(
now,
],
)
+ const productId = Number(result.rows[0]?.id || 0)
+ if (!productId) {
+ throw createHttpError('接单模板标准商品投影失败', { statusCode: 500 })
+ }
+ await runQuery(
+ client,
+ `UPDATE work_product_rules
+ SET canonical_product_id = $1
+ WHERE id = $2`,
+ [productId, rule.id],
+ )
return {
rule,
- productId: Number(result.rows[0]?.id || 0),
+ productId,
hasActiveCost,
status: result.rows[0]?.status === 'archived' ? 'archived' : 'active',
}
diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx
index e2260a81..22f138bd 100644
--- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx
+++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx
@@ -911,7 +911,7 @@ export function ProductCatalogPanel() {
name="canonicalSku"
label="标准 SKU"
rules={[{ required: true, message: '请输入标准 SKU' }]}
- extra="来源由前缀决定:aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。"
+ extra="aff: 自动发货 / wo: 工单证据;tpl: 接单模板和 man: 人工商品分别由对应配置自动创建。"
>
diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx
index 68ae2e02..f533afa9 100644
--- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx
+++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx
@@ -905,7 +905,10 @@ export default function ProductRulesPanel() {
description={
在这里选择“固定金额”或“按 SKU 按件”;具体金额与价格同步统一由
-
+
商品与成本中心
管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。
diff --git a/docs/生产升级操作手册.md b/docs/生产升级操作手册.md
index 7badc57e..8a90eb7d 100644
--- a/docs/生产升级操作手册.md
+++ b/docs/生产升级操作手册.md
@@ -3,7 +3,7 @@ type: 生产运维操作手册
description: order_site 生产结构升级、数据切换、验收与回滚的唯一执行步骤。
tags: [生产, 部署, 数据库, 迁移, 备份, 回滚, 财务]
status: current
-version: 1.1
+version: 1.2
updated_at: 2026-11-14
---
@@ -25,13 +25,15 @@ updated_at: 2026-11-14
> 不得按旧的 `147–150` 一条路径直接上线。
>
> 2026-11-14 增量待发布基线:在 `161` 之上依次新增
-> `162_fulfillment_queue_buckets.sql`、`163_work_order_hall_price_version.sql`。
+> `162_fulfillment_queue_buckets.sql`、`163_work_order_hall_price_version.sql`、
+> `164_template_product_cost_visibility.sql`。
> `162` 建立履约待办四桶只读投影、补载体扫描专用索引和新单优先扫描口径;
> `163` 增加接单大厅价格成本版本引用、改价同步候选索引和版本闸门,不重写历史工单金额。
+> `164` 增加接单模板到标准商品的显式关联,修复无订单模板商品落入未路由、无法先配置成本的问题。
> 两项迁移都不改写原始订单、履约尝试或售后事实;仍须完成停写、最终备份、隔离恢复验证、
> 迁移、健康检查、履约审计和大厅价格审计后才可恢复营业。
-### 1.1 迁移 `151 → 163` 增量门禁
+### 1.1 迁移 `151 → 164` 增量门禁
- 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。
- 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160`
@@ -44,7 +46,10 @@ updated_at: 2026-11-14
`v_fulfillment_queue`、补载体部分索引存在;
- `163` 新增 `work_orders.hall_price_version_id`、模板工单进入 `open` 的版本闸门和
改价同步候选索引;正在使用寄卖现货的合同态工单豁免版本闸门。
-- 用测试库完成 `001 → 163` 空库迁移,并在隔离库验证 `151 → 163`。
+- `164` 新增 `work_product_rules.canonical_product_id` 及唯一索引,并重建
+ `v_product_current`,让无订单的模板商品明确归属接单平台成本范围。
+- 用测试库完成 `001 → 164` 空库迁移,并在隔离库验证 `151 → 164`。
+- 迁移后必须验证新建停用模板能在“接单平台”成本页签看到并配置成本,且不出现在“未路由”。
- 迁移后执行 `npm run audit:work-order-hall-pricing`,`critical` 必须为 0。
- 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库;
必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。
@@ -431,7 +436,7 @@ docker compose --profile tools run --rm --no-deps tools \
### 6.3.1 接单大厅价格审计
-迁移 `163` 后必须执行:
+迁移 `163`、`164` 后必须执行:
```bash
docker compose --profile tools run --rm --no-deps tools \
@@ -445,6 +450,7 @@ docker compose --profile tools run --rm --no-deps tools \
- 普通工单金额和拼单剩余份额单价必须与当前模板一致。
- 已加入拼单份额的合同金额不得因审计或改价被重写。
- 寄卖合同态工单不参与模板价审计覆盖。
+- 新建停用模板必须已出现在“接单平台”标准商品范围,并能先配置成本;不得显示为“未路由”。
`warning` 可包含尚未发布的 `pending_material / unassigned` 工单价格待同步项,
但必须由发布负责人在恢复营业前执行模板同步或记录结构化待办,不得手工猜价。
diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md
index 23a7f441..979d8740 100644
--- a/docs/统一订单履约与财务模型-权威版.md
+++ b/docs/统一订单履约与财务模型-权威版.md
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
status: authoritative
-version: 2.19
+version: 2.20
updated_at: 2026-11-14
---
@@ -12,7 +12,7 @@ updated_at: 2026-11-14
> 状态:**唯一权威文档**。
> 最后校准:2026-11-14。
> 生产基线:迁移 `151`。
-> 开发基线:迁移 `163`(仅本地,尚未部署生产)。
+> 开发基线:迁移 `164`(仅本地,尚未部署生产)。
>
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
@@ -380,6 +380,20 @@ manual fulfillment
- 只读审计命令为 `npm run audit:work-order-hall-pricing`;`open` 工单的版本、金额或份额单价
不一致属于 critical,必须停止继续放大并修复,不自动猜测补价。
+### 3.4.4 接单模板标准商品归属
+
+接单模板和它的标准成本商品通过 `work_product_rules.canonical_product_id` 显式关联。
+该关系只用于确定成本商品归属和页面范围,不用于订单履约路由。
+
+- 新模板创建时必须同步生成 `tpl:` 标准商品并写入该关联。
+- 即使还没有订单、没有命中内部 SKU、没有 `sku_channel_routes`,模板商品也必须出现在
+ “商品与成本中心 → 接单平台”范围,能够先配置成本再启用模板。
+- 模板商品不得因为缺少内部 SKU 路由而显示为“未路由”;“未路由”只用于确实等待渠道配置的内部
+ SKU 商品。
+- 不得从 `tpl:` 前缀、商品名或模板名称反推该关系。
+- 已有模板商品在迁移 `164` 中按模板生成的标准 SKU 一次性回填关联;删除仍被模板引用的商品时
+ 必须拒绝并提示改为归档。
+
### 3.5 商品与财务域
标准商品目标结构:
@@ -1027,18 +1041,20 @@ docs/archive/
## 十三、当前下一步
-截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `163`。迁移 `152` 保护订单来源快照,
+截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `164`。迁移 `152` 保护订单来源快照,
迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置;
迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、
任务凭证引用和最终约束;迁移 `162` 建立履约待办四桶只读投影、补载体专用索引和新单优先扫描口径。
迁移 `163` 增加接单大厅价格成本版本引用、改价同步候选索引和旧版本进入大厅的数据库闸门;
该迁移不重写历史工单金额,寄卖合同态显式豁免。
+迁移 `164` 增加接单模板到标准商品的显式关联,并同步商品渠道投影,保证新模板在无订单时也能
+进入接单平台成本范围。
迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录,
不再作为 CK 导出门禁。
1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。
2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。
-3. `147 → 163` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、
+3. `147 → 164` 按生产升级手册分批执行,迁移后刷新统计并完成商品、履约、
财务和大厅价格审计;无法确定的数据进入未路由或待分类,不做猜测。
4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后,
再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。