fix(fulfillment): 封堵重复载体并完善统一SKU映射

This commit is contained in:
yml2213
2026-11-03 12:28:05 +08:00
parent 3ba808b814
commit e6763882f5
27 changed files with 1506 additions and 45 deletions
@@ -35,14 +35,19 @@ import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import {
assignChannelByInternalSku,
deleteInternalSkuMapping,
deleteChannelRules,
fetchChannelExceptions,
fetchChannelRules,
fetchInternalSkuMappings,
fetchPendingChannelGroups,
fetchPipelineStatus,
runFinancePipeline,
type FinancePipelineStatus,
type InternalSkuMapping,
type InternalSkuMappingMatchType,
saveChannelRule,
saveInternalSkuMapping,
type ChannelRule,
type PendingChannelGroup,
} from '@/services/admin'
@@ -74,6 +79,17 @@ const CHANNEL_SOURCE_LABEL: Record<string, string> = {
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
const INTERNAL_SKU_MAPPING_TYPES: Array<{
value: InternalSkuMappingMatchType
label: string
}> = [
{ value: 'sku_code', label: '平台 SKU 编码' },
{ value: 'sku_nick', label: 'SKU 昵称' },
{ value: 'out_sku_sn', label: '外部 SKU 编码' },
{ value: 'spec', label: '规格值' },
{ value: 'sku_name', label: '商品名称' },
]
export default function AdminFulfillmentChannelPage() {
return (
<div className="page-container">
@@ -82,6 +98,7 @@ export default function AdminFulfillmentChannelPage() {
defaultActiveKey="pending"
items={[
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
{ key: 'mappings', label: 'SKU 映射', children: <InternalSkuMappingsPanel /> },
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
{ key: 'pipelines', label: '管道状态', children: <PipelinesPanel /> },
@@ -119,7 +136,7 @@ function PendingPanel() {
}
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: () => message.error('指定渠道失败'),
onError: (error) => message.error(error instanceof Error ? error.message : '指定渠道失败'),
})
const pipelineMutation = useMutation({
@@ -260,6 +277,234 @@ function PendingPanel() {
)
}
function InternalSkuMappingsPanel() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<InternalSkuMapping | null>(null)
const query = useQuery({
queryKey: ['fulfillment-internal-sku-mappings', keyword, page, pageSize],
queryFn: () => fetchInternalSkuMappings({ keyword, page, pageSize }),
})
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) =>
saveInternalSkuMapping({
provider: String(values.provider || '').trim(),
shopId: String(values.shopId || '').trim(),
matchType: values.matchType as InternalSkuMappingMatchType,
matchValue: String(values.matchValue || ''),
internalSku: String(values.internalSku || ''),
priority: Number(values.priority || 100),
note: String(values.note || ''),
enabled: values.enabled !== false,
}),
onSuccess: () => {
message.success('SKU 映射已保存,后续管道将按新映射归一')
setOpen(false)
setEditing(null)
form.resetFields()
void queryClient.invalidateQueries({ queryKey: ['fulfillment-internal-sku-mappings'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '保存 SKU 映射失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteInternalSkuMapping(id),
onSuccess: () => {
message.success('SKU 映射已删除')
void queryClient.invalidateQueries({ queryKey: ['fulfillment-internal-sku-mappings'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '删除 SKU 映射失败'),
})
function openEditor(row?: InternalSkuMapping) {
setEditing(row || null)
form.resetFields()
form.setFieldsValue(
row
? {
provider: row.provider,
shopId: row.shop_id,
matchType: row.match_type,
matchValue: row.match_value,
internalSku: row.internal_sku,
priority: row.priority,
note: row.note,
enabled: row.enabled,
}
: {
provider: '',
shopId: '',
matchType: 'sku_code',
priority: 100,
enabled: true,
},
)
setOpen(true)
}
const columns = [
{
title: '来源平台',
dataIndex: 'provider',
width: 120,
render: (value: string) => value || <Tag color="blue">全部平台</Tag>,
},
{
title: '店铺',
dataIndex: 'shop_id',
width: 150,
ellipsis: true,
render: (value: string) => value || <Tag color="blue">全部店铺</Tag>,
},
{
title: '匹配字段',
dataIndex: 'match_type',
width: 130,
render: (value: InternalSkuMappingMatchType) =>
INTERNAL_SKU_MAPPING_TYPES.find((item) => item.value === value)?.label || value,
},
{ title: '平台匹配值', dataIndex: 'match_value', ellipsis: true },
{ title: '中文内部 SKU', dataIndex: 'internal_sku', ellipsis: true },
{ title: '优先级', dataIndex: 'priority', width: 90 },
{
title: '状态',
dataIndex: 'enabled',
width: 80,
render: (value: boolean) => (value ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>),
},
{
title: '操作',
width: 150,
render: (_: unknown, row: InternalSkuMapping) => (
<Space>
<Button size="small" onClick={() => openEditor(row)}>
编辑
</Button>
<Button
danger
size="small"
loading={deleteMutation.isPending}
onClick={() =>
Modal.confirm({
title: '删除 SKU 映射',
content: `${row.provider || '全部平台'} / ${row.shop_id || '全部店铺'} / ${row.match_value}`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => deleteMutation.mutateAsync(row.id),
})
}
>
删除
</Button>
</Space>
),
},
]
return (
<Card
title={`SKU 映射(${query.data?.data?.total ?? 0})`}
extra={
<Space>
<Input.Search
allowClear
placeholder="平台 SKU / 中文 SKU"
style={{ width: 220 }}
onSearch={(value) => {
setKeyword(value.trim())
setPage(1)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
新增映射
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="平台 SKU 只在这里映射为中文内部 SKU。店铺级映射优先于平台级,平台级优先于全局;渠道配置仍然只按中文内部 SKU 全局生效。"
/>
<PercentTable
rowKey="id"
size="small"
loading={query.isFetching}
dataSource={query.data?.data?.items || []}
columns={columns}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
pageSize: query.data?.data?.pageSize || pageSize,
total: query.data?.data?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
},
})}
/>
<Modal
title={editing ? '编辑 SKU 映射' : '新增 SKU 映射'}
open={open}
onCancel={() => {
setOpen(false)
setEditing(null)
}}
onOk={() => form.submit()}
confirmLoading={saveMutation.isPending}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
initialValues={{ matchType: 'sku_code', priority: 100, enabled: true }}
onFinish={(values) => saveMutation.mutate(values)}
>
<Form.Item name="provider" label="来源平台">
<Input placeholder="留空表示全部平台" />
</Form.Item>
<Form.Item name="shopId" label="店铺 ID">
<Input placeholder="留空表示全部店铺" />
</Form.Item>
<Form.Item name="matchType" label="匹配字段" rules={[{ required: true }]}>
<Select options={INTERNAL_SKU_MAPPING_TYPES} />
</Form.Item>
<Form.Item name="matchValue" label="平台匹配值" rules={[{ required: true }]}>
<Input placeholder="例如平台 SKU、SKU 昵称或规格值" />
</Form.Item>
<Form.Item
name="internalSku"
label="中文内部 SKU"
rules={[{ required: true, message: '请输入包含中文的内部 SKU' }]}
>
<Input placeholder="例如:幸运币2个" />
</Form.Item>
<Form.Item name="priority" label="优先级" extra="数字越小越优先">
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</Card>
)
}
function RulesPanel() {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
@@ -287,7 +532,7 @@ function RulesPanel() {
form.resetFields()
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
},
onError: () => message.error('保存规则失败'),
onError: (error) => message.error(error instanceof Error ? error.message : '保存规则失败'),
})
const deleteMutation = useMutation({
@@ -42,6 +42,34 @@ export type ChannelRuleListResult = {
pageSize: number
}
export type InternalSkuMappingMatchType =
| 'sku_code'
| 'sku_nick'
| 'out_sku_sn'
| 'spec'
| 'sku_name'
export type InternalSkuMapping = {
id: number
provider: string
shop_id: string
match_type: InternalSkuMappingMatchType
match_value: string
internal_sku: string
priority: number
enabled: boolean
source: string
note: string
updated_at: string
}
export type InternalSkuMappingListResult = {
items: InternalSkuMapping[]
total: number
page: number
pageSize: number
}
export type ChannelException = {
id: number
reason: string
@@ -142,6 +170,39 @@ export function fetchChannelRules(
return apiGet<ChannelRuleListResult>('/api/v1/admin/fulfillment/rules', params)
}
export function fetchInternalSkuMappings(
params: {
keyword?: string
provider?: string
shopId?: string
matchType?: string
page?: number
pageSize?: number
} = {},
) {
return apiGet<InternalSkuMappingListResult>(
'/api/v1/admin/fulfillment/internal-sku-mappings',
params,
)
}
export function saveInternalSkuMapping(payload: {
provider?: string
shopId?: string
matchType: InternalSkuMappingMatchType
matchValue: string
internalSku: string
priority?: number
note?: string
enabled?: boolean
}) {
return apiPost<{ id: number }>('/api/v1/admin/fulfillment/internal-sku-mappings', payload)
}
export function deleteInternalSkuMapping(id: number) {
return apiDelete<boolean>(`/api/v1/admin/fulfillment/internal-sku-mappings/${id}`)
}
export function saveChannelRule(payload: {
id?: number
matchKeyType: string