feat(cost-center): 渠道映射改为诊断优先,新增未归一队列

- 顶部统计卡:标准商品 / 映射 / 未归一订单项 / 未归一 SKU 组
- 新增「未归一」队列:按来源+店铺+平台SKU+履约方式聚合,行内建映射并预填
- 目录表从 7 列精简到 4 列,匹配方式移入展开明细
- 回填订单项后自动把已归一的 product_mapping_missing 异常收口
- 新增 /cost-center/mappings/diagnostics 与 /mappings/unmapped,按履约方式拆分聚合避免全表扫描
This commit is contained in:
yml2213
2026-10-24 16:36:16 +08:00
parent 956ef5d695
commit e5122d116a
5 changed files with 787 additions and 159 deletions
@@ -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),
@@ -1,26 +1,36 @@
/**
* 渠道映射 Tab:来源 + 店铺 + 平台 SKU -> 标准商品。
*
* 采用「诊断优先」结构:
* 1. 顶部统计卡:标准商品 / 映射条数 / 未归一订单项 / 未归一 SKU 组;
* 2. 未归一队列:按来源 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射;
* 3. 映射目录:只保留 4 列(标准商品 / 分类 / 渠道覆盖 / 映射),匹配方式进展开行。
*
* 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
*/
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'
import { ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Statistic,
Switch,
Tabs,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
@@ -28,15 +38,23 @@ import {
backfillAdminProductMappings,
createAdminChannelProductMapping,
deleteAdminChannelProductMapping,
fetchAdminChannelMappingDiagnostics,
fetchAdminChannelProductMappings,
fetchAdminUnmappedMappingGroups,
updateAdminChannelProductMapping,
} from '@/services/admin'
import type { ChannelProductMapping, ChannelProductMappingGroup } from '@/types/admin'
import type {
ChannelProductMapping,
ChannelProductMappingGroup,
FulfillmentMode,
UnmappedMappingGroup,
} from '@/types/admin'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminLocalTablePagination,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { useCostCenter } from './CostCenterContext'
import {
@@ -46,19 +64,54 @@ import {
type MappingFormValues,
} from './shared'
const FULFILLMENT_MODE_LABELS: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
const FULFILLMENT_MODE_COLORS: Record<string, string> = {
work_order: 'blue',
affiliate_dash: 'green',
legacy_platform: 'default',
manual: 'orange',
}
export function ChannelMappingPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, options, refreshAll } = useCostCenter()
const [mappingForm] = Form.useForm<MappingFormValues>()
const [view, setView] = useState<'catalog' | 'queue'>('catalog')
const [mappingKeyword, setMappingKeyword] = useState('')
const [mappingProvider, setMappingProvider] = useState('')
const [mappingPage, setMappingPage] = useState(1)
const [mappingPageSize, setMappingPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [expandedMappingProductIds, setExpandedMappingProductIds] = useState<string[]>([])
const [mappingDetailPageSize, setMappingDetailPageSize] = useState(10)
const [queueKeyword, setQueueKeyword] = useState('')
const [queueProvider, setQueueProvider] = useState('')
const [queuePage, setQueuePage] = useState(1)
const [queuePageSize, setQueuePageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [mappingModalOpen, setMappingModalOpen] = useState(false)
const [editingMapping, setEditingMapping] = useState<ChannelProductMapping | null>(null)
const [fromQueue, setFromQueue] = useState(false)
const providers = options?.providers || []
const diagnosticsQuery = useQuery({
queryKey: ['admin-cost-center-mapping-diagnostics', productSourceFilter],
queryFn: () =>
fetchAdminChannelMappingDiagnostics({
carrierType: productSourceFilter,
fulfillmentMode: productSourceFilter,
}),
})
const diagnostics = diagnosticsQuery.data?.data
const mappingsQuery = useQuery({
queryKey: [
@@ -77,12 +130,37 @@ export function ChannelMappingPanel() {
page: mappingPage,
pageSize: mappingPageSize,
}),
enabled: view === 'catalog',
})
const queueQuery = useQuery({
queryKey: [
'admin-cost-center-unmapped-mappings',
productSourceFilter,
queueKeyword,
queueProvider,
queuePage,
queuePageSize,
],
queryFn: () =>
fetchAdminUnmappedMappingGroups({
fulfillmentMode: productSourceFilter,
provider: queueProvider || undefined,
keyword: queueKeyword || undefined,
page: queuePage,
pageSize: queuePageSize,
}),
enabled: view === 'queue',
})
const mappingGroups = mappingsQuery.data?.data.groups || []
const mappingTotal = mappingsQuery.data?.data.pagination.mappingTotal || 0
const mappingGroupTotal = mappingsQuery.data?.data.pagination.total || 0
const providers = options?.providers || []
const unmappedGroups = queueQuery.data?.data.items || []
const unmappedGroupTotal = queueQuery.data?.data.pagination.total || 0
const unmappedItemTotal = queueQuery.data?.data.pagination.itemTotal || 0
const productOptions =
options?.products
.filter((item) => !productSourceFilter || item.source === productSourceFilter)
@@ -91,11 +169,26 @@ export function ChannelMappingPanel() {
label: `${item.canonicalName}(${item.canonicalSku})`,
})) || []
function openCreateMapping() {
function invalidateMappingData() {
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mapping-diagnostics'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-unmapped-mappings'] })
}
function openCreateMapping(prefill?: {
provider?: string
shopId?: string
platformSkuCode?: string
platformSkuName?: string
}) {
setEditingMapping(null)
setFromQueue(Boolean(prefill))
mappingForm.resetFields()
mappingForm.setFieldsValue({
provider: providers[0] || '91kaquan',
provider: prefill?.provider || mappingProvider || queueProvider || providers[0] || '91kaquan',
shopId: prefill?.shopId || '',
platformSkuCode: prefill?.platformSkuCode || '',
platformSkuName: prefill?.platformSkuName || '',
matchType: 'sku_code',
quantityMultiplier: 1,
confidence: 'manual',
@@ -106,6 +199,7 @@ export function ChannelMappingPanel() {
}
function openEditMapping(item: ChannelProductMapping) {
setFromQueue(false)
setEditingMapping(item)
mappingForm.setFieldsValue({
provider: item.provider,
@@ -128,12 +222,27 @@ export function ChannelMappingPanel() {
if (editingMapping) {
await updateAdminChannelProductMapping(editingMapping.id, values)
message.success('渠道映射已更新')
} else {
await createAdminChannelProductMapping(values)
message.success('渠道映射已创建')
setMappingModalOpen(false)
refreshAll()
invalidateMappingData()
return
}
await createAdminChannelProductMapping(values)
setMappingModalOpen(false)
refreshAll()
invalidateMappingData()
if (fromQueue) {
modal.confirm({
title: '映射已创建',
content:
'要把历史订单项也归一到这个标准商品吗?回填会按全部渠道映射重扫未归一订单项,并把已归一的异常自动收口。',
okText: '立即回填',
cancelText: '稍后',
onOk: () => runBackfill(),
})
} else {
message.success('渠道映射已创建')
}
} catch (error) {
message.error(error instanceof Error ? error.message : '保存渠道映射失败')
}
@@ -151,6 +260,7 @@ export function ChannelMappingPanel() {
await deleteAdminChannelProductMapping(item.id)
message.success('渠道映射已删除')
refreshAll()
invalidateMappingData()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除渠道映射失败')
}
@@ -158,25 +268,30 @@ export function ChannelMappingPanel() {
})
}
async function runBackfill() {
try {
const response = await backfillAdminProductMappings({ limit: 20000, all: true })
const { updatedCount, resolvedExceptionCount, batches, remainingCount } = response.data
message.success(
`已回填 ${updatedCount} 条订单项映射(${batches} 批)${
resolvedExceptionCount > 0 ? `,收口 ${resolvedExceptionCount} 条异常` : ''
}${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`,
)
refreshAll()
invalidateMappingData()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填商品映射失败')
}
}
function backfillMappings() {
modal.confirm({
title: '回填订单项商品映射',
content:
'按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项。',
'按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项;已归一的异常会自动收口。',
okText: '开始回填',
cancelText: '取消',
onOk: async () => {
try {
const response = await backfillAdminProductMappings({ limit: 20000, all: true })
const { updatedCount, batches, remainingCount } = response.data
message.success(
`已回填 ${updatedCount} 条订单项映射(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填商品映射失败')
}
},
onOk: () => runBackfill(),
})
}
@@ -201,6 +316,87 @@ export function ChannelMappingPanel() {
})
}
const queueColumns: TableColumnsType<UnmappedMappingGroup> = [
{
title: '来源 / 店铺',
width: 220,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text>{providerLabel(row.provider)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.shopId || '全局(未按店铺区分)'}
</Typography.Text>
</Space>
),
},
{
title: '平台 SKU',
width: 320,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: row.skuCode || row.skuName }}>
{row.skuCode || '(无编码)'}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{ tooltip: row.skuName }}
>
{row.skuName || '-'}
</Typography.Text>
</Space>
),
},
{
title: '履约方式',
dataIndex: 'fulfillmentMode',
width: 120,
render: (value: string) => (
<Tag color={FULFILLMENT_MODE_COLORS[value] || 'default'}>
{FULFILLMENT_MODE_LABELS[value] || value || '未知'}
</Tag>
),
},
{
title: '订单项',
dataIndex: 'itemCount',
width: 110,
render: (value: number) => (
<Space size={4}>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text type="secondary">项</Typography.Text>
</Space>
),
},
{
title: '最近订单时间',
dataIndex: 'lastSeenAt',
width: 180,
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
},
{
title: '操作',
width: 120,
render: (_, row) =>
canConfig ? (
<Button
size="small"
type="primary"
onClick={() =>
openCreateMapping({
provider: row.provider,
shopId: row.shopId,
platformSkuCode: row.skuCode,
platformSkuName: row.skuName,
})
}
>
建映射
</Button>
) : null,
},
]
const mappingDetailColumns: TableColumnsType<ChannelProductMapping> = [
{ title: '来源', dataIndex: 'provider', width: 130, render: providerLabel },
{
@@ -212,7 +408,7 @@ export function ChannelMappingPanel() {
{
title: '平台 SKU',
dataIndex: 'platformSkuCode',
width: 220,
width: 230,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{value || row.platformSkuName || '-'}</Typography.Text>
@@ -225,17 +421,16 @@ export function ChannelMappingPanel() {
{
title: '匹配方式',
dataIndex: 'matchType',
width: 160,
width: 180,
render: (value: ChannelProductMapping['matchType']) => (
<Tag color={value === 'sku_code' ? 'green' : 'blue'}>{mappingMatchTypeLabel(value)}</Tag>
),
},
{ title: '倍率', dataIndex: 'quantityMultiplier', width: 70 },
{ title: '置信度', dataIndex: 'confidence', width: 85 },
{
title: '启用',
dataIndex: 'enabled',
width: 75,
width: 80,
render: (value: boolean) => (
<Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag>
),
@@ -243,7 +438,6 @@ export function ChannelMappingPanel() {
{
title: '操作',
width: 140,
fixed: 'right',
render: (_, row) =>
canConfig ? (
<Space size={4}>
@@ -262,30 +456,31 @@ export function ChannelMappingPanel() {
{
title: '标准商品',
dataIndex: 'canonicalName',
width: 260,
width: 340,
render: (value: string, row) => (
<Space size={8} align="start">
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space size={6} wrap>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: row.canonicalSku }}>
{row.canonicalSku} · {row.unitName}
</Typography.Text>
{row.status !== 'active' ? <Tag>已归档</Tag> : null}
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.canonicalSku} · {row.unitName}
</Typography.Text>
</Space>
),
},
{
title: '分类',
dataIndex: 'category',
width: 120,
width: 130,
render: (value: string) =>
value || <Typography.Text type="secondary">未分类</Typography.Text>,
},
{
title: '覆盖范围',
width: 220,
title: '渠道覆盖',
width: 260,
render: (_, row) => {
const rowProviders = Array.from(new Set(row.mappings.map((item) => item.provider)))
const shops = new Set(row.mappings.map((item) => `${item.provider}:${item.shopId || ''}`))
@@ -293,56 +488,40 @@ export function ChannelMappingPanel() {
return (
<Space size={[4, 4]} wrap>
{rowProviders.map((provider) => (
<Tag key={provider}>{providerLabel(provider)}</Tag>
<Tag key={provider} color="blue">
{providerLabel(provider)}
</Tag>
))}
<Tag color="blue">{shops} 个店铺键</Tag>
<Tag>{shops} 个店铺键</Tag>
</Space>
)
},
},
{
title: '映射',
width: 150,
width: 180,
render: (_, row) => {
const enabledCount = row.mappings.filter((item) => item.enabled).length
const disabledCount = row.mappings.length - enabledCount
return (
<Space size={4}>
<Tag color="blue">{row.mappings.length} 条</Tag>
<Typography.Text type="secondary">启用 {enabledCount}</Typography.Text>
<Space direction="vertical" size={0}>
<Typography.Text strong>{row.mappings.length} 条</Typography.Text>
{disabledCount > 0 ? (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
启用 {enabledCount} · 停用 {disabledCount}
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
全部启用
</Typography.Text>
)}
</Space>
)
},
},
{
title: '匹配方式',
width: 280,
render: (_, row) => {
const matchTypes = Array.from(new Set(row.mappings.map((item) => item.matchType)))
return (
<Space size={[4, 4]} wrap>
{matchTypes.map((matchType) => (
<Tag key={matchType} color={matchType === 'sku_code' ? 'green' : 'blue'}>
{mappingMatchTypeLabel(matchType)}
</Tag>
))}
</Space>
)
},
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : 'default'}>
{value === 'active' ? '启用' : '归档'}
</Tag>
),
},
{
title: '目录',
width: 90,
fixed: 'right',
title: '明细',
width: 110,
render: (_, row) => {
const key = String(row.canonicalProductId)
const expanded = expandedMappingProductIds.includes(key)
@@ -357,21 +536,25 @@ export function ChannelMappingPanel() {
)
}}
>
{expanded ? '收起' : '展开'}
{expanded ? '收起明细' : '查看明细'}
</Button>
)
},
},
]
const queueEmpty = !queueQuery.isLoading && unmappedGroupTotal === 0
const sourceLabel = productSourceFilter
? (FULFILLMENT_MODE_LABELS[productSourceFilter] ?? productSourceFilter)
: '全部来源'
return (
<>
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>渠道映射目录</Typography.Text>
<Tag color="blue">{mappingTotal} 条映射</Tag>
<Tag>{mappingGroupTotal} 个标准商品</Tag>
<Typography.Text strong>渠道映射</Typography.Text>
<Tag color="blue">{sourceLabel}</Tag>
</Space>
}
extra={
@@ -381,102 +564,243 @@ export function ChannelMappingPanel() {
回填订单项
</Button>
<Button onClick={backfillQuantities}>回填数量</Button>
<Button type="primary" onClick={openCreateMapping}>
<Button type="primary" onClick={() => openCreateMapping()}>
新建映射
</Button>
</Space>
) : null
}
>
<Space style={{ marginBottom: 16 }} wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="标准商品 / 平台 SKU"
value={mappingKeyword}
onChange={(event) => {
setMappingKeyword(event.target.value)
setMappingPage(1)
setExpandedMappingProductIds([])
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={mappingProvider || undefined}
onChange={(value) => {
setMappingProvider(value || '')
setMappingPage(1)
setExpandedMappingProductIds([])
}}
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
style={{ width: 180 }}
/>
<Button
onClick={() =>
setExpandedMappingProductIds(
mappingGroups.map((group) => String(group.canonicalProductId)),
)
}
>
展开本页
</Button>
<Button onClick={() => setExpandedMappingProductIds([])}>收起全部</Button>
</Space>
<PercentTable<ChannelProductMappingGroup>
tableLayout="fixed"
rowKey={(row) => String(row.canonicalProductId)}
size="small"
columns={mappingGroupColumns}
dataSource={mappingGroups}
loading={mappingsQuery.isLoading}
scroll={{ x: 1100 }}
expandable={{
expandedRowKeys: expandedMappingProductIds,
onExpandedRowsChange: (keys) =>
setExpandedMappingProductIds(keys.map((key) => String(key))),
expandRowByClick: true,
expandedRowRender: (group) => (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text type="secondary">
该标准商品下共有 {group.mappings.length} 条渠道映射
</Typography.Text>
<PercentTable<ChannelProductMapping>
tableLayout="fixed"
rowKey="id"
size="small"
columns={mappingDetailColumns}
dataSource={group.mappings}
pagination={buildAdminLocalTablePagination({
pageSize: mappingDetailPageSize,
size: 'small',
onChange: (_page, pageSize) => setMappingDetailPageSize(pageSize),
})}
scroll={{ x: 980 }}
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
{[
['标准商品', diagnostics?.productTotal ?? 0],
['渠道映射', diagnostics?.mappingTotal ?? 0],
['未归一订单项', diagnostics?.unmappedItemTotal ?? 0],
['未归一 SKU 组', diagnostics?.unmappedGroupTotal ?? 0],
].map(([label, value]) => (
<Col xs={12} xl={6} key={label}>
<Card size="small">
<Statistic
title={label}
value={Number(value)}
valueStyle={
label === '未归一订单项' || label === '未归一 SKU 组'
? { color: Number(value) > 0 ? '#d4380d' : '#389e0d' }
: undefined
}
/>
</Space>
),
}}
pagination={buildAdminTablePagination({
current: mappingPage,
pageSize: mappingPageSize,
total: mappingGroupTotal,
showTotal: (total) => `共 ${total} 个标准商品`,
onChange: (page, pageSize) => {
setMappingPage(page)
setMappingPageSize(pageSize)
setExpandedMappingProductIds([])
</Card>
</Col>
))}
</Row>
<Tabs
activeKey={view}
onChange={(key) => setView(key as 'catalog' | 'queue')}
items={[
{
key: 'catalog',
label: `映射目录 (${Number(diagnostics?.mappedProductTotal ?? mappingGroupTotal)})`,
children: (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{Number(diagnostics?.unmappedGroupTotal || 0) > 0 ? (
<Alert
type="warning"
showIcon
message={`还有 ${Number(diagnostics?.unmappedGroupTotal || 0)} 组平台 SKU 未归一(${Number(diagnostics?.unmappedItemTotal || 0)} 个订单项)`}
action={
<Button size="small" onClick={() => setView('queue')}>
去处理
</Button>
}
/>
) : (
<Alert
type="success"
showIcon
message={`${sourceLabel}的订单项已全部归一`}
description={`${Number(diagnostics?.productTotal || 0)} 个标准商品、${Number(diagnostics?.mappingTotal || 0)} 条映射已覆盖全部订单;新订单若命中不到映射,会在每日成本同步后出现在「未归一」队列。`}
/>
)}
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="标准商品 / 平台 SKU"
value={mappingKeyword}
onChange={(event) => {
setMappingKeyword(event.target.value)
setMappingPage(1)
setExpandedMappingProductIds([])
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={mappingProvider || undefined}
onChange={(value) => {
setMappingProvider(value || '')
setMappingPage(1)
setExpandedMappingProductIds([])
}}
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
style={{ width: 180 }}
/>
<Button
onClick={() =>
setExpandedMappingProductIds(
mappingGroups.map((group) => String(group.canonicalProductId)),
)
}
>
展开本页
</Button>
<Button onClick={() => setExpandedMappingProductIds([])}>收起全部</Button>
</Space>
<PercentTable<ChannelProductMappingGroup>
tableLayout="fixed"
rowKey={(row) => String(row.canonicalProductId)}
size="small"
columns={mappingGroupColumns}
dataSource={mappingGroups}
loading={mappingsQuery.isLoading}
scroll={{ x: 1020 }}
expandable={{
expandedRowKeys: expandedMappingProductIds,
onExpandedRowsChange: (keys) =>
setExpandedMappingProductIds(keys.map((key) => String(key))),
expandRowByClick: true,
expandedRowRender: (group) => (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text type="secondary">
该标准商品下共有 {group.mappings.length} 条渠道映射
</Typography.Text>
<PercentTable<ChannelProductMapping>
tableLayout="fixed"
rowKey="id"
size="small"
columns={mappingDetailColumns}
dataSource={group.mappings}
pagination={buildAdminLocalTablePagination({
pageSize: mappingDetailPageSize,
size: 'small',
onChange: (_page, pageSize) => setMappingDetailPageSize(pageSize),
})}
scroll={{ x: 1080 }}
/>
</Space>
),
}}
pagination={buildAdminTablePagination({
current: mappingPage,
pageSize: mappingPageSize,
total: mappingGroupTotal,
showTotal: (total) => `共 ${total} 个标准商品 / ${mappingTotal} 条映射`,
onChange: (page, pageSize) => {
setMappingPage(page)
setMappingPageSize(pageSize)
setExpandedMappingProductIds([])
},
})}
/>
</Space>
),
},
})}
{
key: 'queue',
label: `未归一 (${Number(diagnostics?.unmappedGroupTotal || 0)})`,
children: (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{queueEmpty ? (
<Alert
type="success"
showIcon
message={`${sourceLabel}没有未归一的平台 SKU`}
description="订单项全部命中了标准商品映射;这里为空即代表销售与成本都能按商品归集。"
/>
) : (
<Alert
type="info"
showIcon
message="每行是一个可以直接建映射的平台 SKU"
description="队列来自每日成本扫描;建好映射后点右上角「回填订单项」把历史订单归一,已归一的异常会自动收口。"
/>
)}
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="平台 SKU / 商品名 / 店铺"
value={queueKeyword}
onChange={(event) => {
setQueueKeyword(event.target.value)
setQueuePage(1)
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={queueProvider || undefined}
onChange={(value) => {
setQueueProvider(value || '')
setQueuePage(1)
}}
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
style={{ width: 180 }}
/>
<Button
icon={<ReloadOutlined />}
loading={queueQuery.isFetching || diagnosticsQuery.isFetching}
onClick={() => {
void queueQuery.refetch()
void diagnosticsQuery.refetch()
}}
>
刷新队列
</Button>
</Space>
<PercentTable<UnmappedMappingGroup>
tableLayout="fixed"
rowKey={(row) =>
`${row.provider}-${row.shopId}-${row.fulfillmentMode}-${row.skuCode}`
}
size="small"
columns={queueColumns}
dataSource={unmappedGroups}
loading={queueQuery.isLoading}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
current: queuePage,
pageSize: queuePageSize,
total: unmappedGroupTotal,
showTotal: (total) => `共 ${total} 组 / ${unmappedItemTotal} 个订单项`,
onChange: (page, pageSize) => {
setQueuePage(page)
setQueuePageSize(pageSize)
},
})}
/>
</Space>
),
},
]}
/>
</Card>
<Modal
title={editingMapping ? '编辑渠道映射' : '新建渠道映射'}
title={editingMapping ? '编辑渠道映射' : fromQueue ? '为平台 SKU 建映射' : '新建渠道映射'}
open={mappingModalOpen}
onCancel={() => setMappingModalOpen(false)}
onOk={() => mappingForm.submit()}
@@ -1,5 +1,6 @@
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/http'
import type {
ChannelMappingDiagnostics,
ChannelProductMapping,
ChannelProductMappingListResult,
CostCenterOptions,
@@ -27,6 +28,7 @@ import type {
ProductCatalogListResult,
TemplateProductBootstrapPreview,
TemplateProductBootstrapResult,
UnmappedMappingGroupListResult,
} from '@/types/admin'
export function fetchAdminCostCenterOverview() {
@@ -130,6 +132,17 @@ export function fetchAdminChannelProductMappings(params?: Record<string, unknown
return apiGet<ChannelProductMappingListResult>('/api/v1/admin/cost-center/mappings', params)
}
export function fetchAdminChannelMappingDiagnostics(params?: Record<string, unknown>) {
return apiGet<ChannelMappingDiagnostics>('/api/v1/admin/cost-center/mappings/diagnostics', params)
}
export function fetchAdminUnmappedMappingGroups(params?: Record<string, unknown>) {
return apiGet<UnmappedMappingGroupListResult>(
'/api/v1/admin/cost-center/mappings/unmapped',
params,
)
}
export function createAdminChannelProductMapping(payload: Record<string, unknown>) {
return apiPost<{ mapping: ChannelProductMapping }>('/api/v1/admin/cost-center/mappings', payload)
}
@@ -153,6 +166,7 @@ export function deleteAdminChannelProductMapping(mappingId: number) {
export function backfillAdminProductMappings(payload: Record<string, unknown>) {
return apiPost<{
updatedCount: number
resolvedExceptionCount: number
limit: number
batches: number
remainingCount: number
@@ -320,6 +320,31 @@ export interface CostExceptionListResult {
pagination: { page: number; pageSize: number; total: number }
}
export interface ChannelMappingDiagnostics {
fulfillmentMode: string | null
productTotal: number
mappingTotal: number
mappedProductTotal: number
unmappedGroupTotal: number
unmappedItemTotal: number
}
/** 未归一队列的一行 = 一个可以直接建映射的平台 SKU。 */
export interface UnmappedMappingGroup {
provider: string
shopId: string
skuCode: string
skuName: string
fulfillmentMode: string
itemCount: number
lastSeenAt: string | null
}
export interface UnmappedMappingGroupListResult {
items: UnmappedMappingGroup[]
pagination: { page: number; pageSize: number; total: number; itemTotal: number }
}
export interface CostExceptionSummary {
recentDays: number
openTotal: number