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
@@ -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