feat(cost-center): 渠道映射改为诊断优先,新增未归一队列
- 顶部统计卡:标准商品 / 映射 / 未归一订单项 / 未归一 SKU 组 - 新增「未归一」队列:按来源+店铺+平台SKU+履约方式聚合,行内建映射并预填 - 目录表从 7 列精简到 4 列,匹配方式移入展开明细 - 回填订单项后自动把已归一的 product_mapping_missing 异常收口 - 新增 /cost-center/mappings/diagnostics 与 /mappings/unmapped,按履约方式拆分聚合避免全表扫描
This commit is contained in:
@@ -27,8 +27,10 @@ import {
|
||||
createAdminProduct,
|
||||
deleteAdminChannelMapping,
|
||||
deleteAdminProduct,
|
||||
getAdminChannelMappingDiagnostics,
|
||||
listAdminChannelMappings,
|
||||
listAdminProducts,
|
||||
listAdminUnmappedMappingGroups,
|
||||
updateAdminChannelMapping,
|
||||
updateAdminProduct,
|
||||
} from '../../services/cost-center/product-catalog-service.js'
|
||||
@@ -273,6 +275,26 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/mappings/diagnostics',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => getAdminChannelMappingDiagnostics(req.query as JsonObject), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取渠道映射诊断失败',
|
||||
scope: '[admin/cost-center/mappings/diagnostics]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/mappings/unmapped',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => listAdminUnmappedMappingGroups(req.query as JsonObject), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取未归一队列失败',
|
||||
scope: '[admin/cost-center/mappings/unmapped]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/mappings',
|
||||
requireAdminPermission('finance.config'),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-ma
|
||||
import { resolveSkuNameQuantity } from '../product-match/pricing.js'
|
||||
import {
|
||||
AFFILIATE_TASK_AGG_JOIN,
|
||||
FULFILLMENT_MODES,
|
||||
MAPPING_CONFIDENCES,
|
||||
MAPPING_SOURCES,
|
||||
PRODUCT_CATEGORY_JOINS,
|
||||
@@ -500,6 +501,227 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道映射诊断:商品/映射数量 + 未归一订单项与 SKU 组数。
|
||||
*
|
||||
* 未归一数据直接读成本异常队列(145k 行),不去扫 316k 订单项。
|
||||
* 按履约方式分组统计:带模式过滤时走单次聚合(约 80ms),
|
||||
* 「全部」时并行跑四种模式再求和,避免一次全表 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 fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode)
|
||||
if (fulfillmentMode) assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效')
|
||||
|
||||
const productParams: unknown[] = []
|
||||
let sourceSql = 'TRUE'
|
||||
if (source) {
|
||||
productParams.push(source)
|
||||
sourceSql = productSourceFilterSql(productParams.length)
|
||||
}
|
||||
const counts = await query<{
|
||||
product_total: number
|
||||
mapping_total: number
|
||||
mapping_product_total: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
(
|
||||
SELECT COUNT(*)::int FROM product_catalog p
|
||||
WHERE p.status = 'active' AND ${sourceSql}
|
||||
) 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}
|
||||
) 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}
|
||||
) AS mapping_product_total
|
||||
`,
|
||||
productParams,
|
||||
)
|
||||
|
||||
async function aggregateUnmapped(mode: string) {
|
||||
const result = await query<{ group_total: number; item_total: number }>(
|
||||
`
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
ce.detail_json->>'provider' AS provider,
|
||||
ce.detail_json->>'shopId' AS shop_id,
|
||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
||||
oi.fulfillment_mode AS fulfillment_mode,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
WHERE ce.reason = 'product_mapping_missing'
|
||||
AND ce.status = 'open'
|
||||
AND oi.fulfillment_mode = $1
|
||||
GROUP BY 1, 2, 3, 4
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::int AS group_total,
|
||||
COALESCE(SUM(item_count), 0)::int AS item_total
|
||||
FROM grouped
|
||||
`,
|
||||
[mode],
|
||||
)
|
||||
return {
|
||||
groupTotal: toNumber(result.rows[0]?.group_total),
|
||||
itemTotal: toNumber(result.rows[0]?.item_total),
|
||||
}
|
||||
}
|
||||
|
||||
const modes = fulfillmentMode
|
||||
? [fulfillmentMode]
|
||||
: ['work_order', 'affiliate_dash', 'legacy_platform', 'manual']
|
||||
// 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。
|
||||
const aggregates: Array<{ groupTotal: number; itemTotal: number }> = []
|
||||
for (const mode of modes) {
|
||||
aggregates.push(await aggregateUnmapped(mode))
|
||||
}
|
||||
const unmappedGroupTotal = aggregates.reduce((sum, item) => sum + item.groupTotal, 0)
|
||||
const unmappedItemTotal = aggregates.reduce((sum, item) => sum + item.itemTotal, 0)
|
||||
|
||||
return {
|
||||
fulfillmentMode: fulfillmentMode || null,
|
||||
productTotal: toNumber(counts.rows[0]?.product_total),
|
||||
mappingTotal: toNumber(counts.rows[0]?.mapping_total),
|
||||
mappedProductTotal: toNumber(counts.rows[0]?.mapping_product_total),
|
||||
unmappedGroupTotal,
|
||||
unmappedItemTotal,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未归一订单项队列:按「来源 + 店铺 + 平台 SKU + 履约方式」聚合。
|
||||
*
|
||||
* 每行代表一个可以直接建映射的平台 SKU,而不是 145k 条订单项流水。
|
||||
*/
|
||||
export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
const page = normalizePage(payload.page)
|
||||
const pageSize = normalizePageSize(payload.pageSize)
|
||||
const offset = (page - 1) * pageSize
|
||||
const params: unknown[] = []
|
||||
const clauses = [`ce.reason = 'product_mapping_missing'`, `ce.status = 'open'`]
|
||||
|
||||
const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode)
|
||||
if (fulfillmentMode) assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效')
|
||||
const provider = text(payload.provider)
|
||||
if (provider) {
|
||||
params.push(normalizeProviderKey(provider))
|
||||
clauses.push(`ce.detail_json->>'provider' = $${params.length}`)
|
||||
}
|
||||
const keyword = text(payload.keyword)
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
clauses.push(
|
||||
`(ce.detail_json->>'skuCode' ILIKE $${params.length}
|
||||
OR ce.detail_json->>'skuName' ILIKE $${params.length}
|
||||
OR ce.detail_json->>'shopId' ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
|
||||
// 分组总数单独算:SQL 守卫禁止窗口函数分页(会在 LIMIT 前消费全部候选行)。
|
||||
// 按履约方式拆开统计:命中索引,单模式约 90ms;「全部」时顺序跑四种模式。
|
||||
const modes = fulfillmentMode
|
||||
? [fulfillmentMode]
|
||||
: ['work_order', 'affiliate_dash', 'legacy_platform', 'manual']
|
||||
const totals: Array<{ groupTotal: number; itemTotal: number }> = []
|
||||
for (const mode of modes) {
|
||||
const totalParams = [...params, mode]
|
||||
const result = await query<{ group_total: number; item_total: number }>(
|
||||
`
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
ce.detail_json->>'provider' AS provider,
|
||||
ce.detail_json->>'shopId' AS shop_id,
|
||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
AND oi.fulfillment_mode = $${totalParams.length}
|
||||
GROUP BY 1, 2, 3
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::int AS group_total,
|
||||
COALESCE(SUM(item_count), 0)::int AS item_total
|
||||
FROM grouped
|
||||
`,
|
||||
totalParams,
|
||||
)
|
||||
totals.push({
|
||||
groupTotal: toNumber(result.rows[0]?.group_total),
|
||||
itemTotal: toNumber(result.rows[0]?.item_total),
|
||||
})
|
||||
}
|
||||
const groupTotal = totals.reduce((sum, item) => sum + item.groupTotal, 0)
|
||||
const itemTotal = totals.reduce((sum, item) => sum + item.itemTotal, 0)
|
||||
if (groupTotal === 0) {
|
||||
return {
|
||||
items: [],
|
||||
pagination: { page, pageSize, total: 0, itemTotal: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
const modeIndex = fulfillmentMode ? params.length + 1 : null
|
||||
const pageIndex = params.length + (modeIndex ? 2 : 1)
|
||||
const offsetIndex = pageIndex + 1
|
||||
const listParams = [...params, ...(fulfillmentMode ? [fulfillmentMode] : []), pageSize, offset]
|
||||
const modeClause = modeIndex ? `AND oi.fulfillment_mode = $${modeIndex}` : ''
|
||||
const result = await query<{
|
||||
provider: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
fulfillment_mode: string
|
||||
item_count: number
|
||||
last_seen_at: string | null
|
||||
}>(
|
||||
`
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
ce.detail_json->>'provider' AS provider,
|
||||
ce.detail_json->>'shopId' AS shop_id,
|
||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
||||
oi.fulfillment_mode AS fulfillment_mode,
|
||||
MAX(ce.detail_json->>'skuName') AS sku_name,
|
||||
MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
${modeClause}
|
||||
GROUP BY 1, 2, 3, 4
|
||||
)
|
||||
SELECT *
|
||||
FROM grouped g
|
||||
ORDER BY g.item_count DESC, g.provider ASC, g.shop_id ASC, g.sku_code ASC
|
||||
LIMIT $${pageIndex} OFFSET $${offsetIndex}
|
||||
`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map((row) => ({
|
||||
provider: row.provider || '',
|
||||
shopId: row.shop_id || '',
|
||||
skuCode: row.sku_code || '',
|
||||
skuName: row.sku_name || '',
|
||||
fulfillmentMode: row.fulfillment_mode || '',
|
||||
itemCount: toNumber(row.item_count),
|
||||
lastSeenAt: row.last_seen_at || null,
|
||||
})),
|
||||
pagination: { page, pageSize, total: groupTotal, itemTotal },
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminChannelMapping(payload: JsonObject, _session: AdminSession) {
|
||||
const provider = normalizeProviderKey(normalizeRequiredText(payload.provider, '来源', 64))
|
||||
assertKnownProvider(provider)
|
||||
@@ -774,6 +996,26 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
|
||||
if (!all || itemsResult.rows.length < limit) break
|
||||
}
|
||||
|
||||
// 回填后把已经归一的「商品未归一」异常一起收口,避免映射目录改完了
|
||||
// 未归一队列还挂着一批已处理完的旧异常。
|
||||
let resolvedExceptionCount = 0
|
||||
if (updatedCount > 0) {
|
||||
const resolved = await query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET status = 'resolved', resolved_at = NOW()
|
||||
WHERE ce.status = 'open'
|
||||
AND ce.subject_type = 'order_item'
|
||||
AND ce.reason = 'product_mapping_missing'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL
|
||||
)
|
||||
`,
|
||||
)
|
||||
resolvedExceptionCount = resolved.rowCount || 0
|
||||
}
|
||||
|
||||
const remainingResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
@@ -786,6 +1028,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
|
||||
)
|
||||
return {
|
||||
updatedCount,
|
||||
resolvedExceptionCount,
|
||||
limit,
|
||||
batches,
|
||||
remainingCount: Number(remainingResult.rows[0]?.total || 0),
|
||||
|
||||
Reference in New Issue
Block a user