feat(admin-web): 新增履约渠道页,含待分类队列与渠道规则
- 待分类按 SKU 聚合,可直接指定渠道(含第三方卡券仅统计渠道) - 渠道规则支持新增/删除,展示优先级与作用范围 - 问题队列按类型筛选,统一使用 PercentTable
This commit is contained in:
@@ -24,6 +24,7 @@ const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage'))
|
||||
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
|
||||
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
|
||||
const AdminFulfillmentChannelPage = lazy(() => import('@/pages/admin/AdminFulfillmentChannelPage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
|
||||
@@ -153,6 +154,7 @@ function AdminApplication() {
|
||||
<Route path="finance/ledger" element={<AdminFinanceLedgerPage />} />
|
||||
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
|
||||
<Route path="cost-center" element={<AdminCostCenterPage />} />
|
||||
<Route path="fulfillment-channel" element={<AdminFulfillmentChannelPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
|
||||
<Route path="fund" element={<AdminFundPage />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccountBookOutlined,
|
||||
ApartmentOutlined,
|
||||
BranchesOutlined,
|
||||
AuditOutlined,
|
||||
LineChartOutlined,
|
||||
WalletOutlined,
|
||||
@@ -21,6 +22,11 @@ export const FINANCE_NAV_ITEMS = [
|
||||
label: '商品与成本中心',
|
||||
icon: <ApartmentOutlined />,
|
||||
},
|
||||
{
|
||||
key: '/admin/fulfillment-channel',
|
||||
label: '履约渠道',
|
||||
icon: <BranchesOutlined />,
|
||||
},
|
||||
{
|
||||
key: '/admin/fund',
|
||||
label: '钱包管理',
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 履约渠道页:待分类队列 + 渠道规则 + 问题队列。
|
||||
*
|
||||
* 设计口径(2026-10-31 v2):
|
||||
* · 渠道由 L1 规则决定,不再由「有没有任务」反推;
|
||||
* · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音;
|
||||
* · 第三方卡券是「只统计」渠道:不建任务、不发领取页。
|
||||
*/
|
||||
import { PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import {
|
||||
assignChannelBySku,
|
||||
deleteChannelRule,
|
||||
fetchChannelExceptions,
|
||||
fetchChannelRules,
|
||||
fetchPendingChannelGroups,
|
||||
runFinancePipeline,
|
||||
saveChannelRule,
|
||||
type ChannelRule,
|
||||
type PendingChannelGroup,
|
||||
} from '@/services/admin'
|
||||
import { formatRedeemAmount } from '@/utils/redeem'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'work_order', label: '接单平台' },
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券(仅统计)' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
]
|
||||
|
||||
const CHANNEL_COLOR: Record<string, string> = {
|
||||
work_order: 'blue',
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'purple',
|
||||
manual: 'orange',
|
||||
}
|
||||
|
||||
const MATCH_KEY_OPTIONS = [
|
||||
{ value: 'sku_code', label: '平台 SKU' },
|
||||
{ value: 'internal_sku', label: '内部 SKU' },
|
||||
{ value: 'sku_name', label: 'SKU 名称' },
|
||||
]
|
||||
|
||||
export default function AdminFulfillmentChannelPage() {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<PageHeader
|
||||
title="履约渠道"
|
||||
description="新单先按规则判定渠道;没有规则的单进待分类队列,收入单独计入未分类桶。"
|
||||
/>
|
||||
<FinanceSubNav />
|
||||
<Tabs
|
||||
defaultActiveKey="pending"
|
||||
items={[
|
||||
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
|
||||
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
|
||||
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selections, setSelections] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-pending', keyword],
|
||||
queryFn: () => fetchPendingChannelGroups({ page: 1, pageSize: 100, keyword }),
|
||||
})
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
assignChannelBySku({
|
||||
provider: row.provider,
|
||||
shopId: row.shop_id,
|
||||
skuCode: row.sku_code,
|
||||
channel: selections[rowKey(row)] || '',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
message.success(`已指定 ${result.data.updated} 个订单项`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: () => message.error('指定渠道失败'),
|
||||
})
|
||||
|
||||
const pipelineMutation = useMutation({
|
||||
mutationFn: () => runFinancePipeline({ days: 3 }),
|
||||
onSuccess: () => {
|
||||
message.success('管道已执行')
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: () => message.error('执行管道失败'),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '来源', dataIndex: 'provider', width: 120 },
|
||||
{ title: '店铺', dataIndex: 'shop_id', width: 140 },
|
||||
{ title: 'SKU', dataIndex: 'sku_code', width: 220 },
|
||||
{ title: '商品名', dataIndex: 'sku_name', ellipsis: true },
|
||||
{ title: '订单数', dataIndex: 'item_count', width: 90 },
|
||||
{
|
||||
title: '收入',
|
||||
dataIndex: 'revenue_fen',
|
||||
width: 120,
|
||||
render: (value: number) => formatRedeemAmount(value),
|
||||
},
|
||||
{
|
||||
title: '最近出现',
|
||||
dataIndex: 'last_seen_at',
|
||||
width: 160,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '指定渠道',
|
||||
width: 320,
|
||||
render: (_: unknown, row: PendingChannelGroup) => (
|
||||
<Space>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 180 }}
|
||||
placeholder="选择渠道"
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={selections[rowKey(row)]}
|
||||
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!selections[rowKey(row)]}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
指定
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={`待分类(${query.data?.data?.total ?? 0} 个 SKU)`}
|
||||
extra={
|
||||
<Space>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="SKU / 商品名"
|
||||
style={{ width: 220 }}
|
||||
onSearch={(value) => setKeyword(value)}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={pipelineMutation.isPending}
|
||||
onClick={() => pipelineMutation.mutate()}
|
||||
>
|
||||
立即跑一遍管道
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="待分类订单的收入已经计入「未分类」桶,不会丢失;指定渠道后会自动进入商品归一与成本核算。"
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey={(row) => rowKey(row)}
|
||||
size="small"
|
||||
loading={query.isFetching}
|
||||
dataSource={query.data?.data?.items || []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RulesPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-rules'],
|
||||
queryFn: () => fetchChannelRules(),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => saveChannelRule(values as never),
|
||||
onSuccess: () => {
|
||||
message.success('规则已保存')
|
||||
setOpen(false)
|
||||
form.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||
},
|
||||
onError: () => message.error('保存规则失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteChannelRule(id),
|
||||
onSuccess: () => {
|
||||
message.success('规则已删除')
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||
},
|
||||
onError: () => message.error('删除规则失败'),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '来源', dataIndex: 'provider', width: 120, render: (v: string) => v || '不限' },
|
||||
{ title: '店铺', dataIndex: 'shop_id', width: 140, render: (v: string) => v || '不限' },
|
||||
{
|
||||
title: '匹配键',
|
||||
dataIndex: 'match_key_type',
|
||||
width: 110,
|
||||
render: (v: string) => MATCH_KEY_OPTIONS.find((item) => item.value === v)?.label || v,
|
||||
},
|
||||
{ title: '匹配值', dataIndex: 'match_key' },
|
||||
{
|
||||
title: '渠道',
|
||||
dataIndex: 'channel',
|
||||
width: 140,
|
||||
render: (value: string) => (
|
||||
<Tag color={CHANNEL_COLOR[value] || 'default'}>
|
||||
{CHANNEL_OPTIONS.find((item) => item.value === value)?.label || value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '优先级', dataIndex: 'priority', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (value: boolean) => (value ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: unknown, row: ChannelRule) => (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(row.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={`渠道规则(${query.data?.data?.length ?? 0})`}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
|
||||
新增规则
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
接单平台由接单模板匹配决定,不在此表维护;这里负责自动发货、人工、第三方卡券三个渠道与运营强制覆盖。
|
||||
同优先级命中多个渠道时订单保持待分类并进「配置冲突」。
|
||||
</Typography.Paragraph>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isFetching}
|
||||
dataSource={query.data?.data || []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新增渠道规则"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saveMutation.isPending}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ matchKeyType: 'sku_code', channel: 'third_party_voucher', priority: 10 }}
|
||||
onFinish={(values) => saveMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item name="provider" label="来源(留空=不限)">
|
||||
<Input placeholder="91kaquan / pdd / douyin_mashida" />
|
||||
</Form.Item>
|
||||
<Form.Item name="shopId" label="店铺(留空=不限)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="matchKeyType" label="匹配键" rules={[{ required: true }]}>
|
||||
<Select options={MATCH_KEY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="matchKey" label="匹配值" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel" label="渠道" rules={[{ required: true }]}>
|
||||
<Select options={CHANNEL_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="优先级(越小越优先)">
|
||||
<InputNumber min={1} max={999} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked" initialValue>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ExceptionsPanel() {
|
||||
const [reason, setReason] = useState('')
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-exceptions', reason],
|
||||
queryFn: () => fetchChannelExceptions({ reason, status: 'open', page: 1, pageSize: 100 }),
|
||||
})
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'classification_missing', label: '待分类' },
|
||||
{ value: 'routing_conflict', label: '规则冲突' },
|
||||
{ value: 'product_mapping_missing', label: '未归一' },
|
||||
{ value: 'cost_missing', label: '成本缺失' },
|
||||
{ value: 'manual_config_missing', label: '人工 SKU 未配置' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'reason',
|
||||
width: 200,
|
||||
render: (value: string) => (
|
||||
<Tag>{REASON_OPTIONS.find((item) => item.value === value)?.label || value}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '范围', dataIndex: 'scope_key', ellipsis: true },
|
||||
{ title: '涉及订单项', dataIndex: 'item_count', width: 110 },
|
||||
{
|
||||
title: '最近出现',
|
||||
dataIndex: 'last_seen_at',
|
||||
width: 160,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="问题队列(按 SKU 聚合)"
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={reason}
|
||||
options={REASON_OPTIONS}
|
||||
onChange={setReason}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isFetching}
|
||||
dataSource={query.data?.data?.items || []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function rowKey(row: PendingChannelGroup) {
|
||||
return `${row.provider}|${row.shop_id}|${row.sku_code}`
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
/** 待分类队列(按 SKU 聚合)。 */
|
||||
export type PendingChannelGroup = {
|
||||
provider: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
export type PendingChannelGroupListResult = {
|
||||
items: PendingChannelGroup[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 渠道规则(L1)。 */
|
||||
export type ChannelRule = {
|
||||
id: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
match_key_type: 'sku_code' | 'internal_sku' | 'sku_name'
|
||||
match_key: string
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
priority: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ChannelException = {
|
||||
id: number
|
||||
reason: string
|
||||
status: string
|
||||
scope_key: string
|
||||
item_count: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
detail_json: unknown
|
||||
}
|
||||
|
||||
export type ChannelExceptionListResult = {
|
||||
items: ChannelException[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type PipelineRunResult = Record<string, unknown>
|
||||
|
||||
export function fetchPendingChannelGroups(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
|
||||
}
|
||||
|
||||
export function fetchChannelExceptions(
|
||||
params: {
|
||||
reason?: string
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<ChannelExceptionListResult>('/api/v1/admin/fulfillment/exceptions', params)
|
||||
}
|
||||
|
||||
export function assignChannelBySku(payload: {
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
|
||||
}
|
||||
|
||||
export function assignChannelToItems(payload: {
|
||||
orderItemIds: number[]
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
|
||||
}
|
||||
|
||||
export function fetchChannelRules(params: { channel?: string } = {}) {
|
||||
return apiGet<ChannelRule[]>('/api/v1/admin/fulfillment/rules', params)
|
||||
}
|
||||
|
||||
export function saveChannelRule(payload: {
|
||||
id?: number
|
||||
provider?: string
|
||||
shopId?: string
|
||||
matchKeyType: string
|
||||
matchKey: string
|
||||
channel: string
|
||||
priority?: number
|
||||
enabled?: boolean
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ id: number }>('/api/v1/admin/fulfillment/rules', payload)
|
||||
}
|
||||
|
||||
export function deleteChannelRule(ruleId: number) {
|
||||
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/fulfillment/rules/${ruleId}`)
|
||||
}
|
||||
|
||||
export function runFinancePipeline(payload: { days?: number } = {}) {
|
||||
return apiPost<PipelineRunResult>('/api/v1/admin/fulfillment/pipeline/run', payload, {
|
||||
timeout: 300_000,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './finance-ledger'
|
||||
export * from './cost-center'
|
||||
export * from './fulfillment-channel'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
export * from './audit-logs'
|
||||
|
||||
Reference in New Issue
Block a user