重构:统一商品渠道投影并收敛权威文档
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user