重构:统一商品渠道投影并收敛权威文档

This commit is contained in:
yml2213
2026-11-08 19:44:27 +08:00
parent 0a595acfa9
commit 1634502363
67 changed files with 2147 additions and 444 deletions
@@ -21,6 +21,18 @@ import type {
// 履约方式 / 商品来源 / 成本模型
// ---------------------------------------------------------------------------
/** 标准商品目录允许使用的当前渠道筛选,包含“未路由”这个投影状态。 */
export const PRODUCT_CURRENT_CHANNEL_FILTERS = [
'affiliate_dash',
'work_order',
'third_party_voucher',
'manual',
'on_account',
'unrouted',
] as const
export type ProductCurrentChannelFilter = (typeof PRODUCT_CURRENT_CHANNEL_FILTERS)[number]
/**
* 由 canonical_sku 前缀推导商品来源。
* 返回空串表示未分类;未分类商品不允许反推履约方式。
@@ -43,6 +55,20 @@ export function productSourceFilterSql(paramIndex: number, column = 'p.canonical
)`
}
/** 当前渠道筛选:标准商品页签的唯一筛选口径。 */
export function productChannelFilterSql(
paramIndex: number,
column = 'p.active_primary_channel',
): string {
return `(
($${paramIndex} = 'unrouted' AND ${column} IS NULL)
OR (
$${paramIndex} <> 'unrouted'
AND ${column} = $${paramIndex}
)
)`
}
/**
* 成本模型是否适用于该履约方式。
*
@@ -95,7 +121,8 @@ export function mapProduct(row: ProductRow) {
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
category: row.category || '',
source: productSourceOf(row.canonical_sku),
source: String(row.source_kind || productSourceOf(row.canonical_sku)),
sourceKind: String(row.source_kind || productSourceOf(row.canonical_sku)),
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
rootCategoryName: row.root_category_name || row.category || '',
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
@@ -122,6 +149,7 @@ export function mapProduct(row: ProductRow) {
? null
: row.template_sharing_enabled === true,
activePrimaryChannel: row.active_primary_channel || null,
fallbackChannels: Array.isArray(row.fallback_channels) ? row.fallback_channels : [],
affiliateTaskCount: Number(row.affiliate_task_count || 0),
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
createdAt: row.created_at,
@@ -2,20 +2,26 @@
* 成本中心总览与选项。
*
* 口径(2026-10-23 重写):
* 1. 商品来源统计按 canonical_sku 前缀分组,不再读 product_catalog.carrier_type;
* 1. 商品统计按 v_product_current.active_primary_channel 分组;
* 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。
*/
import { query } from '../../db/client.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js'
import { productChannelFilterSql } from './cost-center-mappers.js'
import { PROVIDERS, toNumber } from './cost-center-shared.js'
export type CostCenterScope = 'affiliate_dash' | 'work_order' | 'manual'
export type CostCenterScope =
| 'affiliate_dash'
| 'work_order'
| 'third_party_voucher'
| 'manual'
| 'on_account'
| 'unrouted'
/**
* 成本中心总览。
*
* scope 为空表示「全部来源」;传入具体来源时,商品/映射/成本项/版本/异常/未映射
* 六项统计都按该来源收敛,页面顶部数字才会和下面的列表对得上。
* scope 为空表示「全部渠道」;传入具体渠道时,商品/映射/成本项/版本/异常/未映射
* 六项统计都按该渠道收敛,页面顶部数字才会和下面的列表对得上。
*
* 来源与成本模型的对应关系:
* affiliate_dash → affiliate_purchase
@@ -36,9 +42,9 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
`
WITH scoped_products AS (
SELECT p.id
FROM product_catalog p
FROM v_product_current p
WHERE p.status = 'active'
AND ($1::text IS NULL OR ${productSourceFilterSql(1)})
AND ($1::text IS NULL OR ${productChannelFilterSql(1)})
),
scoped_cost_models AS (
SELECT CASE $1
@@ -46,6 +52,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
WHEN 'work_order' THEN 'work_order_reward'
WHEN 'third_party_voucher' THEN 'third_party_voucher_cost'
WHEN 'manual' THEN 'manual_actual'
WHEN 'on_account' THEN 'manual_actual'
END AS cost_model
)
SELECT
@@ -93,22 +100,12 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
)
const row = result.rows[0]
const [productSourceResult, costModelResult] = await Promise.all([
query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>(
`SELECT p.canonical_sku,
(
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
query<{ channel: string | null; total: number }>(
`SELECT p.active_primary_channel AS channel,
COUNT(*)::int AS total
FROM product_catalog p
FROM v_product_current p
WHERE p.status = 'active'
GROUP BY p.canonical_sku, p.canonical_name`,
GROUP BY p.active_primary_channel`,
),
query<{ cost_model: string; total: number }>(
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
@@ -134,6 +131,8 @@ const COST_CENTER_SCOPES = new Set([
'work_order',
'third_party_voucher',
'manual',
'on_account',
'unrouted',
])
function normalizeScope(raw: unknown): CostCenterScope | null {
@@ -141,27 +140,18 @@ function normalizeScope(raw: unknown): CostCenterScope | null {
return COST_CENTER_SCOPES.has(value) ? (value as CostCenterScope) : null
}
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
function productSourceCountsOf(
rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>,
) {
/** 商品当前渠道计数:总览和列表共用 v_product_current。 */
function productSourceCountsOf(rows: Array<{ channel: string | null; total: number }>) {
const counts: Record<string, number> = {
affiliate_dash: 0,
work_order: 0,
third_party_voucher: 0,
manual: 0,
uncategorized: 0,
on_account: 0,
unrouted: 0,
}
for (const row of rows) {
const source = productSourceOf(row.canonical_sku)
// man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。
if (
source === 'manual' &&
row.active_primary_channel &&
row.active_primary_channel !== 'manual'
) {
continue
}
const key = source || 'uncategorized'
const key = row.channel || 'unrouted'
counts[key] = (counts[key] || 0) + toNumber(row.total)
}
return counts
@@ -183,10 +173,12 @@ export async function getAdminCostCenterOptions() {
canonical_sku: string
canonical_name: string
unit_name: string
source_kind: string
active_primary_channel: string | null
}>(
`
SELECT id, canonical_sku, canonical_name, unit_name
FROM product_catalog
SELECT id, canonical_sku, canonical_name, unit_name, source_kind, active_primary_channel
FROM v_product_current
WHERE status = 'active'
ORDER BY canonical_name, id
LIMIT 5000
@@ -225,7 +217,8 @@ export async function getAdminCostCenterOptions() {
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
unitName: row.unit_name,
source: productSourceOf(row.canonical_sku),
source: row.source_kind,
activePrimaryChannel: row.active_primary_channel,
})),
costItems: costItemsResult.rows.map((row) => ({
id: Number(row.id),
@@ -29,7 +29,10 @@ export type ProductRow = {
template_pricing_mode?: string | null
template_enabled?: boolean | null
template_sharing_enabled?: boolean | null
source_kind?: string | null
active_primary_channel?: string | null
fallback_channels?: string[] | null
manual_config_id?: number | null
affiliate_task_count?: number
affiliate_last_fulfilled_at?: string | null
}
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { costModelAppliesTo } from './cost-center-mappers.js'
@@ -9,7 +10,7 @@ import {
} from './cost-center-shared.js'
import { calculateCostTotal, validateCostVersionScope } from './cost-resolution-service.js'
import { createAdminProduct, resolveMappingProductId } from './product-catalog-service.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { productChannelFilterSql, productSourceFilterSql } from './cost-center-mappers.js'
import {
buildTemplateMappingPlan,
buildTemplateProductSku,
@@ -289,6 +290,10 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false)
assert.ok(productSourceFilterSql(1).includes("'third_party_voucher'"))
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
const channelSql = productChannelFilterSql(1)
assert.ok(channelSql.includes('active_primary_channel'))
assert.ok(channelSql.includes("$1 = 'unrouted'"))
assert.ok(!channelSql.includes('canonical_sku'))
})
test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => {
@@ -310,11 +315,15 @@ test('第三方卡券删除:无历史引用硬删除,有历史引用自动
assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'"))
})
test('人工客服筛选保留所有可维护的 man 商品和显式兜底配置', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
assert.ok(sql.includes("LIKE 'man:%'"))
assert.ok(!sql.includes('live_channel'))
test('人工客服页签只按当前主渠道筛选,不能再按 man 前缀放回人工页', async () => {
const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8')
const sql = productChannelFilterSql(1)
assert.ok(sql.includes('active_primary_channel'))
assert.ok(sql.includes("$1 <> 'unrouted'"))
assert.ok(sql.includes('active_primary_channel = $1'))
assert.ok(!sql.includes('canonical_sku'))
assert.ok(source.includes('FROM v_product_current p'))
assert.ok(source.includes('productChannelFilterSql'))
})
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
@@ -325,7 +334,21 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫
)
assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id'))
assert.ok(!source.includes('ce.order_item_id IN ('))
assert.ok(source.includes('productSourceFilterSql(1)'))
assert.ok(source.includes('productChannelFilterSql(1)'))
})
test('运行期停止写入已废弃的 product_channel_capabilities', async () => {
const files = await Promise.all(
[
'./voucher-product-service.ts',
'./product-normalize-service.ts',
'./product-catalog-service.ts',
'../fulfillment/channel-assign-service.ts',
].map((path) => readFile(new URL(path, import.meta.url), 'utf8')),
)
for (const source of files) {
assert.ok(!source.includes('product_channel_capabilities'))
}
})
test('tpv: 商品来源归为第三方卡券', async () => {
@@ -27,7 +27,6 @@ import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-fa
import { syncAdminWorkProductRulePrice } from '../worker-platform/admin-worker-catalog-service.js'
import {
AFFILIATE_TASK_AGG_JOIN,
FULFILLMENT_CHANNELS,
MAPPING_CONFIDENCES,
MAPPING_SOURCES,
PRODUCT_CATEGORY_JOINS,
@@ -52,7 +51,8 @@ import {
normalizeRequiredText,
normalizeStatus,
parseJsonObject,
productSourceFilterSql,
productChannelFilterSql,
PRODUCT_CURRENT_CHANNEL_FILTERS,
requiredRow,
resolveMappingNameKey,
text,
@@ -64,13 +64,8 @@ import {
type WorkCategoryRow,
} from './cost-center-shared.js'
/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */
const PRODUCT_SOURCE_FILTERS = new Set([
'affiliate_dash',
'work_order',
'third_party_voucher',
'manual',
])
/** 标准商品页签的当前渠道筛选取值。 */
const PRODUCT_CHANNEL_FILTERS = new Set<string>(PRODUCT_CURRENT_CHANNEL_FILTERS)
/**
* 校验新建商品的 SKU 前缀。
@@ -108,17 +103,19 @@ export async function listAdminProducts(payload: JsonObject = {}) {
assertOneOf(status, PRODUCT_STATUSES, '商品状态无效')
baseParams.push(status)
baseClauses.push(`p.status = $${baseParams.length}`)
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) {
assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
baseParams.push(source)
baseClauses.push(productSourceFilterSql(baseParams.length))
// carrierType 参数仅为兼容旧前端,语义统一为「按当前主渠道筛选」。
const channel = text(
payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type,
)
if (channel) {
assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
baseParams.push(channel)
baseClauses.push(productChannelFilterSql(baseParams.length))
}
// 人工/接单平台商品不需要 affiliate 任务统计:跳过 30 天 fulfillment_tasks 聚合。
// 只有「全部」和「自动发货」Tab 才需要这条聚合,避免小库 IO 被无关数据拖慢。
const needsAffiliateTaskStats = !source || source === 'affiliate_dash'
const needsAffiliateTaskStats = !channel || channel === 'affiliate_dash'
const affiliateTaskJoinSql = needsAffiliateTaskStats ? AFFILIATE_TASK_AGG_JOIN : ''
const affiliateColumnsSql = needsAffiliateTaskStats
? 'COALESCE(aff.task_count, 0) AS affiliate_task_count, aff.last_fulfilled_at AS affiliate_last_fulfilled_at'
@@ -146,7 +143,7 @@ export async function listAdminProducts(payload: JsonObject = {}) {
const [countResult, result, categoryStatResult, categoryResult] = await Promise.all([
query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} ${where}`,
`SELECT COUNT(*)::int AS total FROM v_product_current p ${PRODUCT_CATEGORY_JOINS} ${where}`,
listParams,
),
query<ProductRow>(
@@ -156,16 +153,6 @@ export async function listAdminProducts(payload: JsonObject = {}) {
tpl_rule.pricing_mode AS template_pricing_mode,
tpl_rule.enabled AS template_enabled,
tpl_rule.sharing_enabled AS template_sharing_enabled,
(
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
root.id AS root_category_id,
@@ -228,7 +215,7 @@ export async function listAdminProducts(payload: JsonObject = {}) {
)
) AS active_cost_effective_from,
${affiliateColumnsSql}
FROM product_catalog p
FROM v_product_current p
${PRODUCT_CATEGORY_JOINS}
LEFT JOIN work_product_rules tpl_rule
ON p.canonical_sku LIKE 'tpl:%'
@@ -252,7 +239,7 @@ export async function listAdminProducts(payload: JsonObject = {}) {
query<ProductCategoryStatRow>(
`
SELECT c.id AS category_id, c.name, c.parent_id, COUNT(*)::int AS total
FROM product_catalog p
FROM v_product_current p
${PRODUCT_CATEGORY_JOINS}
${baseWhere}
GROUP BY c.id, c.name, c.parent_id
@@ -512,16 +499,6 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
`,
[id, internalSku, nowIso()],
)
await client.query(
`
UPDATE product_channel_capabilities
SET enabled = FALSE,
updated_at = $2
WHERE product_id = $1 AND channel = 'third_party_voucher'
`,
[id, nowIso()],
)
if (mappingCount > 0 || snapshotCount > 0 || orderItemCount > 0) {
await client.query(
`
@@ -566,7 +543,6 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
`DELETE FROM cost_versions WHERE canonical_product_id = $1`,
[id],
)
await client.query(`DELETE FROM product_channel_capabilities WHERE product_id = $1`, [id])
await client.query(
`
DELETE FROM internal_sku_channels
@@ -627,12 +603,14 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
params.push(canonicalProductId)
clauses.push(`m.canonical_product_id = $${params.length}`)
}
// carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) {
assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
params.push(source)
clauses.push(productSourceFilterSql(params.length))
// carrierType 仅为旧客户端兼容,筛选口径统一是商品当前主渠道。
const channel = text(
payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type,
)
if (channel) {
assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
params.push(channel)
clauses.push(productChannelFilterSql(params.length))
}
if (payload.enabled !== undefined && payload.enabled !== '') {
params.push(normalizeBoolean(payload.enabled))
@@ -652,7 +630,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
p.unit_name,
p.status
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
JOIN v_product_current p ON p.id = m.canonical_product_id
${where}
GROUP BY
p.id,
@@ -672,7 +650,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
COUNT(DISTINCT p.id)::int AS total_count,
COUNT(*)::int AS mapping_total
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
JOIN v_product_current p ON p.id = m.canonical_product_id
${where}
`,
params,
@@ -698,7 +676,7 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
p.canonical_sku,
p.canonical_name
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
JOIN v_product_current p ON p.id = m.canonical_product_id
${where}
${where ? 'AND' : 'WHERE'} m.canonical_product_id = ANY($${mappingParams.length}::bigint[])
ORDER BY
@@ -748,16 +726,16 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
* 「全部」时并行跑四种模式再求和,避免一次全表 hash join。
*/
export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}) {
const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source)
if (source) assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效')
const channel = text(payload.channel ?? payload.channel)
if (channel) assertOneOf(channel, FULFILLMENT_CHANNELS, '履约方式无效')
const channel = text(
payload.channel ?? payload.currentChannel ?? payload.carrierType ?? payload.carrier_type,
)
if (channel) assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
const productParams: unknown[] = []
let sourceSql = 'TRUE'
if (source) {
productParams.push(source)
sourceSql = productSourceFilterSql(productParams.length)
let productChannelSql = 'TRUE'
if (channel) {
productParams.push(channel)
productChannelSql = productChannelFilterSql(productParams.length)
}
const counts = await query<{
product_total: number
@@ -767,20 +745,20 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
`
SELECT
(
SELECT COUNT(*)::int FROM product_catalog p
WHERE p.status = 'active' AND ${sourceSql}
SELECT COUNT(*)::int FROM v_product_current p
WHERE p.status = 'active' AND ${productChannelSql}
) AS product_total,
(
SELECT COUNT(*)::int
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${sourceSql}
JOIN v_product_current p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${productChannelSql}
) AS mapping_total,
(
SELECT COUNT(DISTINCT m.canonical_product_id)::int
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${sourceSql}
JOIN v_product_current p ON p.id = m.canonical_product_id
WHERE m.enabled = TRUE AND ${productChannelSql}
) AS mapping_product_total
`,
productParams,
@@ -816,9 +794,12 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
}
}
const modes = channel
? [channel]
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
const modes =
channel === 'unrouted'
? []
: channel
? [channel]
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
// 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。
const aggregates: Array<{ groupTotal: number; itemTotal: number }> = []
for (const mode of modes) {
@@ -849,8 +830,14 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
const params: unknown[] = []
const clauses = [`ce.reason = 'product_mapping_missing'`, `ce.status = 'open'`]
const channel = text(payload.channel ?? payload.channel)
if (channel) assertOneOf(channel, FULFILLMENT_CHANNELS, '履约方式无效')
const channel = text(payload.channel ?? payload.currentChannel)
if (channel) assertOneOf(channel, PRODUCT_CHANNEL_FILTERS, '商品渠道无效')
if (channel === 'unrouted') {
return {
items: [],
pagination: { page, pageSize, total: 0, itemTotal: 0 },
}
}
const provider = text(payload.provider)
if (provider) {
params.push(normalizeProviderKey(provider))
@@ -210,15 +210,6 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
onlyWhenCanonicalProductDifferent: true,
})
await query(
`
INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source)
VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'auto')
ON CONFLICT (product_id, channel) DO NOTHING
`,
[productId],
)
await upsertInternalSkuChannel({
internalSku,
channel: 'third_party_voucher',
@@ -89,21 +89,6 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
throw createHttpError('卡券标准商品保存失败', { statusCode: 500 })
}
await client.query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at
) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, 'voucher', $2, $2)
ON CONFLICT (product_id, channel) DO UPDATE SET
priority = 10,
is_fallback = FALSE,
enabled = TRUE,
source = 'voucher',
updated_at = EXCLUDED.updated_at
`,
[productId, now],
)
await upsertInternalSkuChannelWithClient(client, {
internalSku: skuKey,
channel: 'third_party_voucher',
@@ -2146,15 +2146,6 @@ async function ensureDraftProductForChannel(
const productId = Number(product?.id || 0)
if (!productId || product?.status !== 'active') return null
await query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source
) VALUES ($1, $2, 10, FALSE, TRUE, 'auto')
ON CONFLICT (product_id, channel) DO NOTHING
`,
[productId, channel],
)
return productId
}
@@ -0,0 +1,145 @@
/**
* 内部 SKU 与渠道路由兼容层集成测试(需要真实 PostgreSQL)。
*
* INTERNAL_SKU_ROUTING_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/fulfillment/internal-sku-routing.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.INTERNAL_SKU_ROUTING_TEST_DATABASE_URL || '').trim()
test(
'旧 internal_sku_channels 写入会同步路由,商品来源与主渠道投影保持分离',
{ skip: !TEST_DATABASE_URL ? '未设置 INTERNAL_SKU_ROUTING_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const internalSku = `路由集成测试-${suffix}`
const canonicalSku = `man:${internalSku}`
let internalSkuId = 0
let productId = 0
try {
const createdSku = await query<{ id: string }>(
'SELECT ensure_canonical_internal_sku($1) AS id',
[internalSku],
)
internalSkuId = Number(createdSku.rows[0]?.id || 0)
assert.ok(internalSkuId > 0)
const createdProduct = await query<{ id: string; source_kind: string }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status, attributes_json,
created_at, updated_at
) VALUES ($1, $2, '集成测试', '件', 'active', '{}'::jsonb, NOW(), NOW())
RETURNING id, source_kind
`,
[canonicalSku, internalSku],
)
productId = Number(createdProduct.rows[0]?.id || 0)
assert.ok(productId > 0)
assert.equal(createdProduct.rows[0]?.source_kind, 'manual')
const beforeRoute = await query<{ active_primary_channel: string | null }>(
'SELECT active_primary_channel FROM v_product_current WHERE id = $1',
[productId],
)
assert.equal(
beforeRoute.rows[0]?.active_primary_channel,
null,
'man: 来源没有主路由时必须显示未路由',
)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
) VALUES ($1, 'manual', 10, FALSE, TRUE, $2, 'integration_test')
`,
[internalSku, productId],
)
const primary = await query<{ route_role: string; channel: string }>(
`
SELECT route_role, channel
FROM sku_channel_routes
WHERE internal_sku_id = $1 AND enabled = TRUE
`,
[internalSkuId],
)
assert.deepEqual(primary.rows, [{ route_role: 'primary', channel: 'manual' }])
const projection = await query<{
source_kind: string
active_primary_channel: string
internal_sku_id: string
}>(
`
SELECT source_kind, active_primary_channel, internal_sku_id
FROM v_product_current
WHERE id = $1
`,
[productId],
)
assert.equal(projection.rows[0]?.source_kind, 'manual')
assert.equal(projection.rows[0]?.active_primary_channel, 'manual')
assert.equal(Number(projection.rows[0]?.internal_sku_id), internalSkuId)
const { getAdminCostCenterOverview } =
await import('../cost-center/cost-center-overview-service.js')
const { listAdminProducts } = await import('../cost-center/product-catalog-service.js')
const overview = await getAdminCostCenterOverview({ scope: 'manual' })
const products = await listAdminProducts({ channel: 'manual', page: 1, pageSize: 100 })
assert.equal(products.pagination.total, overview.productCount)
assert.ok(products.items.every((product) => product.activePrimaryChannel === 'manual'))
await query(
`
UPDATE internal_sku_channels
SET is_fallback = TRUE, updated_at = NOW()
WHERE internal_sku = $1 AND channel = 'manual'
`,
[internalSku],
)
const fallback = await query<{ route_role: string }>(
'SELECT route_role FROM sku_channel_routes WHERE internal_sku_id = $1',
[internalSkuId],
)
assert.deepEqual(fallback.rows, [{ route_role: 'fallback' }])
await query(
`
UPDATE internal_sku_channels
SET is_fallback = FALSE, updated_at = NOW()
WHERE internal_sku = $1 AND channel = 'manual'
`,
[internalSku],
)
await assert.rejects(
() =>
query(
`
INSERT INTO sku_channel_routes (
internal_sku_id, channel, route_role, priority, enabled
) VALUES ($1, 'work_order', 'primary', 1, TRUE)
`,
[internalSkuId],
),
/uq_sku_channel_routes_primary|duplicate key/,
)
} finally {
if (productId > 0) {
await query('DELETE FROM product_catalog WHERE id = $1', [productId])
}
if (internalSkuId > 0) {
await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku])
await query('DELETE FROM internal_skus WHERE id = $1', [internalSkuId])
}
await closeDb()
}
},
)
@@ -93,7 +93,7 @@ function buildRuleEntries(
})
}
// 只配了 sellerId 的"空条件结构化"(旧实现会整店命中)不编译:属于历史脏配置,
// 新模型一律要求显式判定维度(见 docs/接单模板匹配引擎重构设计.md §2.3)。
// 新模型一律要求显式判定维度(见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md §2.3)。
if (structuredAll.length > 0) {
entries.push({
key: `legacy:rule:${rule.id}:structured`,
@@ -5,7 +5,7 @@
* - 引擎:条件打分 + 决策(并列最高=ambiguous,不猜)+ 诊断(差在哪一维);
* - 价格/数量/拼单:`pricing.ts` 唯一实现。
*
* 设计见 docs/接单模板匹配引擎重构设计.md。
* 设计见 docs/archive/2026-11-08/接单模板匹配引擎重构设计.md。
*/
export type {
ProductMatchCandidate,