fix(fulfillment): 封堵重复载体并完善统一SKU映射
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user