修复接单模板商品成本范围可见性
This commit is contained in:
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user