feat(fulfillment): 统一中文内部 SKU 履约路由
This commit is contained in:
@@ -35,9 +35,6 @@ const AdminDouyinMashidaCertificatePage = lazy(
|
||||
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
|
||||
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
|
||||
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
|
||||
const AdminPlatformFulfillmentPage = lazy(
|
||||
() => import('@/pages/admin/platform/AdminPlatformFulfillmentPage'),
|
||||
)
|
||||
const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage'))
|
||||
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
|
||||
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
|
||||
@@ -187,7 +184,10 @@ function AdminApplication() {
|
||||
<Route element={<RequireRole roles={['admin']} />}>
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
|
||||
<Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} />
|
||||
<Route
|
||||
path="platform-fulfillment"
|
||||
element={<Navigate to="/admin/fulfillment-channel" replace />}
|
||||
/>
|
||||
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
OrderedListOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
SoundOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
@@ -287,7 +286,6 @@ export default function AdminLayout() {
|
||||
children: [
|
||||
{ key: '/admin/users', icon: <TeamOutlined />, label: '后台用户' },
|
||||
{ key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' },
|
||||
{ key: '/admin/platform-fulfillment', icon: <ShopOutlined />, label: '履约配置' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useEffect, useState } from 'react'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import {
|
||||
assignChannelBySku,
|
||||
assignChannelByInternalSku,
|
||||
deleteChannelRules,
|
||||
fetchChannelExceptions,
|
||||
fetchChannelRules,
|
||||
@@ -64,6 +64,14 @@ const CHANNEL_COLOR: Record<string, string> = {
|
||||
manual: 'orange',
|
||||
}
|
||||
|
||||
const CHANNEL_SOURCE_LABEL: Record<string, string> = {
|
||||
operator: '运营配置',
|
||||
manual_config: '人工 SKU 配置',
|
||||
voucher: '第三方卡券',
|
||||
capability: '商品能力迁移',
|
||||
backfill: '历史回填',
|
||||
}
|
||||
|
||||
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
||||
|
||||
export default function AdminFulfillmentChannelPage() {
|
||||
@@ -97,9 +105,7 @@ function PendingPanel() {
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
assignChannelBySku({
|
||||
provider: row.provider,
|
||||
shopId: row.shop_id,
|
||||
assignChannelByInternalSku({
|
||||
internalSku: row.internal_sku,
|
||||
channel: selections[rowKey(row)] || '',
|
||||
}),
|
||||
@@ -126,8 +132,19 @@ function PendingPanel() {
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '来源', dataIndex: 'provider', width: 120 },
|
||||
{ title: '店铺', dataIndex: 'shop_id', width: 140 },
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'providers',
|
||||
width: 120,
|
||||
render: (value: string[] | undefined) => value?.join(' / ') || '-',
|
||||
},
|
||||
{
|
||||
title: '店铺',
|
||||
dataIndex: 'shop_ids',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (value: string[] | undefined) => value?.join(' / ') || '-',
|
||||
},
|
||||
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
|
||||
{
|
||||
title: '平台SKU',
|
||||
@@ -284,7 +301,7 @@ function RulesPanel() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: () => message.error('删除规则失败'),
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'),
|
||||
})
|
||||
|
||||
function confirmDeleteRules(ids: number[]) {
|
||||
@@ -319,6 +336,19 @@ function RulesPanel() {
|
||||
),
|
||||
},
|
||||
{ title: '优先级', dataIndex: 'priority', width: 90 },
|
||||
{
|
||||
title: '载体参数',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: ChannelRule) => formatCarrierParams(row),
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 140,
|
||||
render: (value: string) =>
|
||||
CHANNEL_SOURCE_LABEL[value] || (value.startsWith('rule:') ? '规则迁移' : value || '-'),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
@@ -333,6 +363,7 @@ function RulesPanel() {
|
||||
danger
|
||||
size="small"
|
||||
loading={deleteMutation.isPending}
|
||||
disabled={!canDeleteChannelRule(row)}
|
||||
onClick={() => confirmDeleteRules([row.id])}
|
||||
>
|
||||
删除
|
||||
@@ -377,6 +408,7 @@ function RulesPanel() {
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRuleIds,
|
||||
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
|
||||
getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }),
|
||||
}}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: query.data?.data?.page || page,
|
||||
@@ -441,11 +473,12 @@ function ExceptionsPanel() {
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'classification_missing', label: '待分类' },
|
||||
{ value: 'routing_conflict', label: '规则冲突' },
|
||||
{ value: 'routing_conflict', label: '渠道冲突' },
|
||||
{ value: 'product_mapping_missing', label: '未归一' },
|
||||
{ value: 'cost_missing', label: '成本缺失' },
|
||||
{ value: 'manual_config_missing', label: '人工 SKU 未配置' },
|
||||
{ value: 'manual_cost_missing', label: '人工未确认履约' },
|
||||
{ value: 'carrier_missing', label: '缺少履约载体' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
@@ -469,7 +502,7 @@ function ExceptionsPanel() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="问题队列(按 SKU 聚合)"
|
||||
title="问题队列(按中文内部 SKU 聚合)"
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
@@ -608,5 +641,21 @@ function PipelinesPanel() {
|
||||
}
|
||||
|
||||
function rowKey(row: PendingChannelGroup) {
|
||||
return `${row.provider}|${row.shop_id}|${row.internal_sku}`
|
||||
return row.internal_sku
|
||||
}
|
||||
|
||||
function formatCarrierParams(row: ChannelRule) {
|
||||
const key =
|
||||
row.channel === 'affiliate_dash'
|
||||
? row.config_json.affiliateSku
|
||||
: row.channel === 'work_order'
|
||||
? row.config_json.workRuleKey
|
||||
: row.channel === 'manual'
|
||||
? row.config_json.manualConfigId
|
||||
: row.config_json.voucherSku
|
||||
return key ? String(key) : '待补全'
|
||||
}
|
||||
|
||||
function canDeleteChannelRule(row: ChannelRule) {
|
||||
return row.source === 'operator' || row.source.startsWith('rule:')
|
||||
}
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminFulfillmentRoutingConfig,
|
||||
previewAdminFulfillmentRouting,
|
||||
saveAdminFulfillmentRoutingConfig,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFulfillmentRoutingConfig,
|
||||
AdminFulfillmentRoutingPreviewResult,
|
||||
AdminFulfillmentRoutingRule,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
|
||||
|
||||
type EditableRoutingRule = Omit<AdminFulfillmentRoutingRule, 'normalizedProductName'> & {
|
||||
normalizedProductName?: string
|
||||
}
|
||||
|
||||
const ROUTING_EXECUTORS = [
|
||||
{ key: 'affiliate_dash', label: '联盟发货', color: 'gold' },
|
||||
{ key: 'manual_dispatch', label: '人工履约', color: 'default' },
|
||||
]
|
||||
|
||||
export default function AdminPlatformFulfillmentPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="履约配置"
|
||||
description="维护 91 卡券商品到 affiliate-dash / 人工履约的路由规则。"
|
||||
/>
|
||||
|
||||
{!hasAdminRole('admin') ? (
|
||||
<Card>
|
||||
<Empty description="仅管理员可以查看履约配置" />
|
||||
</Card>
|
||||
) : (
|
||||
<FulfillmentRoutingPanel />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FulfillmentRoutingPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [priority, setPriority] = useState<string[]>(['affiliate_dash'])
|
||||
const [unmatchedExecutorKey, setUnmatchedExecutorKey] = useState('')
|
||||
const [executors, setExecutors] = useState<AdminFulfillmentRoutingConfig['executors']>({})
|
||||
const [rules, setRules] = useState<EditableRoutingRule[]>([])
|
||||
const [previewInput, setPreviewInput] = useState('')
|
||||
const [previewResult, setPreviewResult] = useState<AdminFulfillmentRoutingPreviewResult | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const enabledRuleCount = rules.filter((rule) => rule.enabled !== false).length
|
||||
const selectedExecutorKey = previewResult?.fulfillmentRoute?.selectedExecutorKey || ''
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [])
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminFulfillmentRoutingConfig()
|
||||
hydrateConfig(response.data)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取履约路由配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
setSaving(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await saveAdminFulfillmentRoutingConfig({
|
||||
enabled,
|
||||
defaultExecutorPriority: priority,
|
||||
unmatchedExecutorKey,
|
||||
executors,
|
||||
rules: rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
enabled: rule.enabled !== false,
|
||||
productName: rule.productName,
|
||||
normalizedProductName: rule.normalizedProductName || '',
|
||||
matchType: rule.matchType,
|
||||
executorKey: rule.executorKey,
|
||||
priority: rule.priority,
|
||||
notes: rule.notes,
|
||||
})),
|
||||
})
|
||||
hydrateConfig(response.data)
|
||||
showSuccess('履约路由配置已保存')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存履约路由配置失败'
|
||||
setErrorMessage(message)
|
||||
showError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function previewRoute() {
|
||||
const productName = previewInput.trim()
|
||||
if (!productName) {
|
||||
showError('请输入 91 商品名')
|
||||
return
|
||||
}
|
||||
|
||||
setPreviewing(true)
|
||||
setPreviewResult(null)
|
||||
|
||||
try {
|
||||
const response = await previewAdminFulfillmentRouting(productName)
|
||||
setPreviewResult(response.data)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '预览履约路由失败')
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateConfig(data: AdminFulfillmentRoutingConfig) {
|
||||
setEnabled(data.enabled !== false)
|
||||
setPriority(normalizeRoutingPriority(data.defaultExecutorPriority))
|
||||
setUnmatchedExecutorKey(data.unmatchedExecutorKey || '')
|
||||
setExecutors(normalizeRoutingExecutors(data.executors))
|
||||
setRules(Array.isArray(data.rules) ? data.rules.map((rule) => normalizeRoutingRule(rule)) : [])
|
||||
}
|
||||
|
||||
function movePriority(executorKey: string, offset: -1 | 1) {
|
||||
const currentIndex = priority.indexOf(executorKey)
|
||||
const nextIndex = currentIndex + offset
|
||||
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= priority.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = [...priority]
|
||||
const [item] = next.splice(currentIndex, 1)
|
||||
next.splice(nextIndex, 0, item)
|
||||
setPriority(next)
|
||||
}
|
||||
|
||||
function updateExecutorEnabled(executorKey: string, nextEnabled: boolean) {
|
||||
setExecutors({
|
||||
...executors,
|
||||
[executorKey]: {
|
||||
enabled: nextEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateRule(index: number, patch: Partial<EditableRoutingRule>) {
|
||||
setRules((current) =>
|
||||
current.map((rule, ruleIndex) =>
|
||||
ruleIndex === index
|
||||
? {
|
||||
...rule,
|
||||
...patch,
|
||||
}
|
||||
: rule,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
const productName = previewInput.trim()
|
||||
const nextId = `${Date.now()}-${rules.length + 1}`
|
||||
setRules([
|
||||
{
|
||||
id: nextId,
|
||||
enabled: true,
|
||||
productName,
|
||||
normalizedProductName: '',
|
||||
matchType: 'exact',
|
||||
executorKey: 'affiliate_dash',
|
||||
priority: 100,
|
||||
notes: '',
|
||||
},
|
||||
...rules,
|
||||
])
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
setRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Spin />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="metric-grid four">
|
||||
<MetricCard label="路由状态" value={enabled ? '启用' : '停用'} />
|
||||
<MetricCard
|
||||
label="商品规则"
|
||||
value={String(rules.length)}
|
||||
detail={`启用 ${enabledRuleCount} 条`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="全局优先级"
|
||||
value={`${priority.length} 个通道`}
|
||||
detail={priority.map(formatRoutingExecutorLabel).join(' > ') || '未配置'}
|
||||
/>
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadConfig}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="通道开关">
|
||||
<div className="platform-routing-grid">
|
||||
<div className="field-switch">
|
||||
<span>履约路由</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
{ROUTING_EXECUTORS.map((executor) => (
|
||||
<div key={executor.key} className="field-switch">
|
||||
<span>{executor.label}</span>
|
||||
<Switch
|
||||
checked={executors[executor.key]?.enabled !== false}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={(checked) => updateExecutorEnabled(executor.key, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="全局优先级">
|
||||
<Space direction="vertical" className="full-width" size={10}>
|
||||
{priority.map((executorKey, index) => (
|
||||
<div key={executorKey} className="platform-routing-priority-row">
|
||||
<Space>
|
||||
<Tag color={resolveRoutingExecutorColor(executorKey)}>
|
||||
{index + 1}. {formatRoutingExecutorLabel(executorKey)}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => movePriority(executorKey, -1)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === priority.length - 1}
|
||||
onClick={() => movePriority(executorKey, 1)}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
<Select
|
||||
value={unmatchedExecutorKey}
|
||||
options={[
|
||||
{ label: '未命中时保持未配置', value: '' },
|
||||
{ label: '未命中时转人工履约', value: 'manual_dispatch' },
|
||||
]}
|
||||
onChange={(value) => setUnmatchedExecutorKey(String(value || ''))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="路由预览"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={addRule}>
|
||||
新增规则
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space.Compact className="full-width">
|
||||
<Input
|
||||
allowClear
|
||||
value={previewInput}
|
||||
placeholder="91 商品名 / productNo"
|
||||
onChange={(event) => setPreviewInput(event.target.value)}
|
||||
onPressEnter={previewRoute}
|
||||
/>
|
||||
<Button icon={<SearchOutlined />} loading={previewing} onClick={previewRoute}>
|
||||
预览路由
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{previewResult ? (
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={previewResult.isConfigured ? 'success' : 'warning'}
|
||||
showIcon
|
||||
message={
|
||||
previewResult.isConfigured
|
||||
? `最终路由:${formatRoutingExecutorLabel(selectedExecutorKey)}`
|
||||
: '未命中可用履约通道'
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>
|
||||
{previewResult.fulfillmentRoute?.reason || previewResult.normalizedProductName}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
{previewResult.fulfillmentRoute?.candidates.map((candidate) => (
|
||||
<Tag
|
||||
key={candidate.executorKey}
|
||||
color={candidate.available ? 'green' : 'default'}
|
||||
>
|
||||
{formatRoutingExecutorLabel(candidate.executorKey)}
|
||||
{candidate.available ? ' 可用' : ' 未命中'}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="商品路由规则"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button icon={<PlusOutlined />} onClick={addRule}>
|
||||
新增规则
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
|
||||
保存规则
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{rules.length === 0 ? (
|
||||
<Empty description="暂无商品路由规则" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width" size={12}>
|
||||
{rules.map((rule, index) => (
|
||||
<div key={rule.id} className="platform-route-rule">
|
||||
<div className="platform-route-rule-head">
|
||||
<Space wrap>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(checked) => updateRule(index, { enabled: checked })}
|
||||
/>
|
||||
<Typography.Text strong>{rule.productName || '未命名规则'}</Typography.Text>
|
||||
</Space>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeRule(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="platform-route-rule-grid">
|
||||
<Input
|
||||
value={rule.productName}
|
||||
placeholder="91 商品名 / productNo"
|
||||
onChange={(event) => updateRule(index, { productName: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={rule.matchType}
|
||||
options={[
|
||||
{ label: '精确', value: 'exact' },
|
||||
{ label: '包含', value: 'contains' },
|
||||
]}
|
||||
onChange={(matchType) =>
|
||||
updateRule(index, {
|
||||
matchType: matchType === 'contains' ? 'contains' : 'exact',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={rule.executorKey}
|
||||
options={ROUTING_EXECUTORS.map((executor) => ({
|
||||
label: executor.label,
|
||||
value: executor.key,
|
||||
}))}
|
||||
onChange={(executorKey) =>
|
||||
updateRule(index, { executorKey: String(executorKey || '') })
|
||||
}
|
||||
/>
|
||||
<InputNumber
|
||||
value={rule.priority}
|
||||
min={0}
|
||||
max={9999}
|
||||
onChange={(priorityValue) =>
|
||||
updateRule(index, { priority: Number(priorityValue || 0) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={rule.notes}
|
||||
placeholder="备注"
|
||||
onChange={(event) => updateRule(index, { notes: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<Card className="metric-card">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{detail ? <small>{detail}</small> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRoutingPriority(value: unknown) {
|
||||
const rawItems = Array.isArray(value) ? value : []
|
||||
const allowed = ROUTING_EXECUTORS.filter((executor) => executor.key !== 'manual_dispatch').map(
|
||||
(executor) => executor.key,
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
const items = rawItems
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter((item) => allowed.includes(item) && !seen.has(item) && (seen.add(item), true))
|
||||
|
||||
return items.length > 0 ? items : ['affiliate_dash']
|
||||
}
|
||||
|
||||
function normalizeRoutingExecutors(value: unknown): AdminFulfillmentRoutingConfig['executors'] {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as AdminFulfillmentRoutingConfig['executors'])
|
||||
: {}
|
||||
|
||||
return ROUTING_EXECUTORS.reduce<AdminFulfillmentRoutingConfig['executors']>(
|
||||
(result, executor) => {
|
||||
result[executor.key] = {
|
||||
enabled: source[executor.key]?.enabled !== false,
|
||||
}
|
||||
return result
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRoutingRule(rule: AdminFulfillmentRoutingRule): EditableRoutingRule {
|
||||
const productName = String(rule.productName || '').trim()
|
||||
const executorKey = String(rule.executorKey || '').trim()
|
||||
|
||||
return {
|
||||
id: String(rule.id || `${productName}-${executorKey}` || createRuleId()).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
matchType: rule.matchType === 'contains' ? 'contains' : 'exact',
|
||||
executorKey: executorKey || 'affiliate_dash',
|
||||
priority: Number(rule.priority || 0) || 0,
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function formatRoutingExecutorLabel(executorKey: unknown) {
|
||||
const key = String(executorKey || '').trim()
|
||||
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.label || key || '-'
|
||||
}
|
||||
|
||||
function resolveRoutingExecutorColor(executorKey: unknown) {
|
||||
const key = String(executorKey || '').trim()
|
||||
return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.color || 'default'
|
||||
}
|
||||
|
||||
function createRuleId() {
|
||||
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
/** 待分类队列(按内部 SKU 聚合)。 */
|
||||
export type PendingChannelGroup = {
|
||||
provider: string
|
||||
shop_id: string
|
||||
internal_sku: string
|
||||
sku_name: string
|
||||
providers: string[]
|
||||
shop_ids: string[]
|
||||
sku_codes: string[]
|
||||
pending_reasons: string[]
|
||||
item_count: number
|
||||
@@ -29,6 +29,10 @@ export type ChannelRule = {
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
priority: number
|
||||
enabled: boolean
|
||||
is_fallback: boolean
|
||||
source: string
|
||||
config_json: Record<string, unknown>
|
||||
note: string
|
||||
}
|
||||
|
||||
export type ChannelRuleListResult = {
|
||||
@@ -98,9 +102,7 @@ export function fetchChannelExceptions(
|
||||
return apiGet<ChannelExceptionListResult>('/api/v1/admin/fulfillment/exceptions', params)
|
||||
}
|
||||
|
||||
export function assignChannelBySku(payload: {
|
||||
provider: string
|
||||
shopId: string
|
||||
export function assignChannelByInternalSku(payload: {
|
||||
internalSku: string
|
||||
channel: string
|
||||
note?: string
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
AdminFulfillmentRoutingConfig,
|
||||
AdminFulfillmentRoutingPreviewResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function fetchAdminFulfillmentRoutingConfig() {
|
||||
return apiGet<AdminFulfillmentRoutingConfig>('/api/v1/admin/platform-config/fulfillment-routing')
|
||||
}
|
||||
|
||||
export function saveAdminFulfillmentRoutingConfig(payload: Partial<AdminFulfillmentRoutingConfig>) {
|
||||
return apiPost<AdminFulfillmentRoutingConfig>(
|
||||
'/api/v1/admin/platform-config/fulfillment-routing',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function previewAdminFulfillmentRouting(productName: string) {
|
||||
return apiPost<AdminFulfillmentRoutingPreviewResult>(
|
||||
'/api/v1/admin/platform-config/fulfillment-routing/preview',
|
||||
{ productName },
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,5 @@ export * from './notifications'
|
||||
export * from './scheduled-jobs'
|
||||
export * from './kuaishou-industry'
|
||||
export * from './affiliate-dash'
|
||||
export * from './fulfillment-routing'
|
||||
export * from './pdd'
|
||||
export * from './douyin-mashida'
|
||||
|
||||
@@ -124,10 +124,6 @@ export type {
|
||||
AdminKuaishouIndustryRefreshTokenResponse,
|
||||
AdminKuaishouIndustryAuthorizationCodePayload,
|
||||
AdminKuaishouIndustryExchangeCodeResponse,
|
||||
AdminFulfillmentRoutingConfig,
|
||||
AdminFulfillmentRoutingExecutorConfig,
|
||||
AdminFulfillmentRoutingPreviewResult,
|
||||
AdminFulfillmentRoutingRule,
|
||||
AdminAffiliateDashConfig,
|
||||
AdminAffiliateDashEffectiveConfig,
|
||||
AdminAffiliateDashConfigResponse,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
export interface AdminFulfillmentRoutingExecutorConfig {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentRoutingRule {
|
||||
id: string
|
||||
enabled: boolean
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
matchType: 'exact' | 'contains'
|
||||
executorKey: string
|
||||
priority: number
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentRoutingConfig {
|
||||
filePath: string
|
||||
enabled: boolean
|
||||
defaultExecutorPriority: string[]
|
||||
unmatchedExecutorKey: string
|
||||
executors: Record<string, AdminFulfillmentRoutingExecutorConfig>
|
||||
rules: AdminFulfillmentRoutingRule[]
|
||||
}
|
||||
|
||||
export interface AdminFulfillmentRoutingPreviewResult {
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
resolvedSkuCode: string
|
||||
resolvedSkuName: string
|
||||
isConfigured: boolean
|
||||
fulfillmentRoute: null | {
|
||||
selectedExecutorKey: string
|
||||
selectedRuleId: string
|
||||
reason: string
|
||||
matchedRule: null | {
|
||||
id: string
|
||||
productName: string
|
||||
executorKey: string
|
||||
priority: number
|
||||
}
|
||||
candidates: Array<{
|
||||
executorKey: string
|
||||
available: boolean
|
||||
reason?: string
|
||||
}>
|
||||
skipped: Array<{
|
||||
executorKey: string
|
||||
reason: string
|
||||
}>
|
||||
}
|
||||
cloudtentacles: Record<string, unknown> | null
|
||||
kuaishouFeifei: Record<string, unknown> | null
|
||||
}
|
||||
@@ -36,13 +36,6 @@ export type {
|
||||
AdminAffiliateDashWallet,
|
||||
} from './affiliate-dash'
|
||||
|
||||
export type {
|
||||
AdminFulfillmentRoutingConfig,
|
||||
AdminFulfillmentRoutingExecutorConfig,
|
||||
AdminFulfillmentRoutingPreviewResult,
|
||||
AdminFulfillmentRoutingRule,
|
||||
} from './fulfillment-routing'
|
||||
|
||||
export type {
|
||||
AdminPddStoreItem,
|
||||
AdminPddConfig,
|
||||
|
||||
Reference in New Issue
Block a user