- 销售毛利 / 收支明细 / 商品与成本中心 / 履约渠道 移除 PageHeader 大标题块 - 与钱包管理、总账与对账保持一致(这两页本来就没有大标题) - 成本中心的「刷新」按钮并入统计口径行,功能位置不变
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
/**
|
||
* 履约渠道页:待分类队列 + 渠道规则 + 问题队列。
|
||
*
|
||
* 设计口径(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 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">
|
||
<FinanceSubNav current="/admin/fulfillment-channel" />
|
||
<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}`
|
||
}
|