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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user