Files
order_site/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx
T

558 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 人工 SKU 配置 Tab:按人工标准商品配置渠道与成本。
*
* 口径(2026-10-24 确认):
* * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),商品名称仅供识别;
* * 同名 SKU 算同一个商品,跨渠道共用一份配置;
* * 客服人工有四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金;
* * 积分换算率按平台不同(南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元);
* * 消耗积分填 0 = 成本不固定,客服确认履约时手填实际成本。
*
* 这里没有匹配引擎、没有打分:就是一张带统计的 SKU 配置清单。
*/
import {
DeleteOutlined,
EditOutlined,
MinusCircleOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
deleteAdminManualSku,
fetchAdminManualPlatforms,
fetchAdminManualSkuOverview,
fetchAdminManualSkus,
saveAdminManualConfig,
} from '@/services/admin'
import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import {
formatCreditQuantity,
formatCreditUnitPriceLabel,
formatCreditUnitPriceYuan,
} from '@/utils/credit-unit-price'
type ConfigFormValues = {
channels: Array<{
platformKey: string
creditQuantity: number
}>
note?: string
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
lewan: 'purple',
rock: 'cyan',
fund_cash: 'orange',
on_account: 'gold',
}
function isCashChannel(platformKey: string) {
return platformKey === 'fund_cash' || platformKey === 'on_account'
}
function channelQuantityText(platformKey: string, creditQuantity: number) {
const quantity = formatCreditQuantity(creditQuantity)
return isCashChannel(platformKey) ? `¥${quantity}` : `${quantity} 积分`
}
function platformOptionLabel(platformKey: string, label: string, creditUnitPriceFen: number) {
if (isCashChannel(platformKey)) return `${label} · 按元填写`
return `${label} · ${formatCreditUnitPriceLabel(creditUnitPriceFen)}`
}
export function ManualSkuConfigPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfigFormValues>()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState<'all' | 'configured' | 'unconfigured'>('unconfigured')
const [platformKey, setPlatformKey] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<ManualSkuItem | null>(null)
const listQuery = useQuery({
queryKey: ['admin-cost-center-manual-skus', keyword, status, platformKey, page, pageSize],
queryFn: () =>
fetchAdminManualSkus({
keyword: keyword || undefined,
status: status === 'all' ? '' : status,
platformKey: platformKey || undefined,
page,
pageSize,
}),
})
const overviewQuery = useQuery({
queryKey: ['admin-cost-center-manual-skus-overview'],
queryFn: fetchAdminManualSkuOverview,
})
const platformQuery = useQuery({
queryKey: ['admin-cost-center-manual-platforms'],
queryFn: fetchAdminManualPlatforms,
})
const overview = overviewQuery.data?.data
const platforms = platformQuery.data?.data.items || []
const platformMap = new Map(platforms.map((item) => [item.key, item]))
const saveMutation = useMutation({
mutationFn: (values: ConfigFormValues) =>
saveAdminManualConfig({
skuKey: editing?.skuKey || '',
channels: values.channels.map((channel) => ({
platformKey: channel.platformKey,
creditQuantity: Number(channel.creditQuantity || 0),
})),
note: values.note || '',
}),
onSuccess: (response) => {
const summary = response.data.syncSummary
const syncHint = summary
? `;自动关联标准商品并重试 ${summary.pendingAssigned} 个待处理订单项`
: ''
message.success(`配置已保存${syncHint}`)
setModalOpen(false)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center'] })
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '保存失败')
},
})
const deleteMutation = useMutation({
mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey),
onSuccess: (response) => {
message.success(
`人工标准商品已归档;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`,
)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '删除失败')
},
})
function openConfig(item: ManualSkuItem) {
setEditing(item)
const configuredChannels = item.config?.channels?.length
? item.config.channels
: item.config
? [
{
platformKey: item.config.platformKey,
creditQuantity: item.config.creditQuantity,
},
]
: [{ platformKey: '', creditQuantity: 0 }]
form.setFieldsValue({
channels: configuredChannels.map((channel) => ({
platformKey: channel.platformKey,
creditQuantity: channel.creditQuantity,
})),
note: item.config?.note || '',
})
setModalOpen(true)
}
const columns: TableColumnsType<ManualSkuItem> = [
{
title: '人工标准商品',
dataIndex: 'skuKey',
width: 300,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.displayName || row.skuKey}</Typography.Text>
<Typography.Text
type="secondary"
copyable={{ text: row.skuKey }}
style={{ fontSize: 12 }}
>
SKU:{row.skuKey}
</Typography.Text>
</div>
),
},
{ title: '店铺', dataIndex: 'shopCount', width: 70, align: 'right' },
{ title: '订单数', dataIndex: 'orderCount', width: 90, align: 'right' },
{ title: '订单项', dataIndex: 'itemCount', width: 90, align: 'right' },
{
title: '收入',
dataIndex: 'revenueFen',
width: 110,
align: 'right',
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(0)}`,
},
{
title: '履约渠道',
key: 'platform',
width: 150,
render: (_, row) => {
if (!row.config) {
return <Typography.Text type="secondary">未配置</Typography.Text>
}
const channels = row.config.channels?.length
? row.config.channels
: [
{
platformKey: row.config.platformKey,
platformLabel: row.config.platformLabel,
creditQuantity: row.config.creditQuantity,
creditUnitPriceFen: row.config.creditUnitPriceFen,
costFen: row.config.costFen,
},
]
return (
<Space size={4} direction="vertical">
{channels.map((channel) => (
<div key={`${row.id}-${channel.platformKey}`}>
<Tag color={PLATFORM_TAG_COLORS[channel.platformKey] || 'default'}>
{channel.platformLabel}
</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{channelQuantityText(channel.platformKey, channel.creditQuantity)}
{' · '}¥{((channel.costFen || 0) / 100).toFixed(2)}
</Typography.Text>
</div>
))}
</Space>
)
},
},
{
title: '成本',
key: 'cost',
width: 110,
align: 'right',
render: (_, row) =>
row.config ? (
(row.config.channels?.length || 0) > 1 ? (
<Tag color="blue">按实际渠道</Tag>
) : row.config.costMode === 'fixed' ? (
<Typography.Text>¥{(row.config.costFen / 100).toFixed(2)}</Typography.Text>
) : (
<Tag color="orange">不固定·客服填</Tag>
)
) : (
'-'
),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
<Popconfirm
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
归档
</Button>
</Popconfirm>
</Space>
),
},
]
function previewChannelCost(platformKey: string, creditQuantity: number) {
const unitPrice = platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
return Number(creditQuantity) > 0 ? Math.round(Number(creditQuantity) * unitPrice) : 0
}
return (
<Card bordered={false} title="人工 SKU 配置">
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工标准商品与成本配置一一对应"
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={6}>
<Statistic title="人工标准商品" value={overview?.totalSku ?? 0} />
</Col>
<Col span={6}>
<Statistic
title="已配置"
value={overview?.configuredSku ?? 0}
valueStyle={{ color: '#389e0d' }}
/>
</Col>
<Col span={6}>
<Statistic
title="未配置"
value={overview?.unconfiguredSku ?? 0}
valueStyle={{ color: '#cf1322' }}
/>
</Col>
<Col span={6}>
<Statistic
title="未配置覆盖的订单项"
value={overview?.unconfiguredItems ?? 0}
suffix="单"
valueStyle={{ color: '#d46b08' }}
/>
</Col>
</Row>
<Space wrap style={{ marginBottom: 12 }}>
<Input.Search
placeholder="搜索标准商品或 SKU"
allowClear
style={{ width: 220 }}
onSearch={(value) => {
setKeyword(value.trim())
setPage(1)
}}
/>
<Select
value={status}
style={{ width: 120 }}
onChange={(value) => {
setStatus(value)
setPage(1)
}}
options={[
{ value: 'unconfigured', label: '未配置' },
{ value: 'configured', label: '已配置' },
{ value: 'all', label: '全部' },
]}
/>
<Select
value={platformKey}
allowClear
placeholder="全部渠道"
style={{ width: 140 }}
onChange={(value) => {
setPlatformKey(value || '')
setPage(1)
}}
options={platforms.map((item) => ({ value: item.key, label: item.label }))}
/>
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
刷新
</Button>
</Space>
<PercentTable
rowKey="id"
size="small"
columns={columns}
dataSource={listQuery.data?.data.items || []}
loading={listQuery.isLoading}
scroll={{ x: 1020 }}
pagination={buildAdminTablePagination({
page,
pageSize,
total: listQuery.data?.data.pagination.total ?? 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
if (nextPageSize !== pageSize) setPageSize(nextPageSize)
},
})}
/>
<Modal
open={modalOpen}
title={`配置人工履约 · ${editing?.skuKey || ''}`}
okText="保存配置"
cancelText="取消"
confirmLoading={saveMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => saveMutation.mutate(values))
}}
onCancel={() => setModalOpen(false)}
destroyOnHidden
width={560}
>
<Alert
type="warning"
showIcon
style={{ marginBottom: 14 }}
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
/>
<Form<ConfigFormValues> form={form} layout="vertical">
<Form.List
name="channels"
rules={[
{
validator: async (_, channels) => {
if (!Array.isArray(channels) || channels.length === 0) {
throw new Error('请至少配置一个履约渠道')
}
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{fields.map((field, index) => (
<div key={field.key} className="manual-channel-config-item">
<div className="manual-channel-config-head">
<Typography.Text strong>履约渠道 {index + 1}</Typography.Text>
<Button
type="text"
danger
title="移除此渠道"
aria-label={`移除履约渠道 ${index + 1}`}
icon={<MinusCircleOutlined />}
disabled={fields.length <= 1}
onClick={() => remove(field.name)}
/>
</div>
<Form.Item
name={[field.name, 'platformKey']}
label="履约渠道"
rules={[{ required: true, message: '请选择履约渠道' }]}
>
<Select
placeholder="请选择履约渠道"
options={platforms.map((item) => ({
value: item.key,
label: platformOptionLabel(item.key, item.label, item.creditUnitPriceFen),
}))}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const platformKey = String(
form.getFieldValue(['channels', field.name, 'platformKey']) || '',
)
const creditQuantity = Number(
form.getFieldValue(['channels', field.name, 'creditQuantity']) || 0,
)
const cashChannel = isCashChannel(platformKey)
const unitPriceFen =
platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
const costFen = previewChannelCost(platformKey, creditQuantity)
return (
<Row gutter={12}>
<Col xs={24} sm={12}>
<Form.Item
name={[field.name, 'creditQuantity']}
label={
!platformKey
? '消耗 / 金额'
: cashChannel
? '每单金额'
: '每单消耗积分'
}
rules={[
{ required: true, message: '请输入消耗或金额' },
{
validator: async (_, value) => {
const quantity = Number(value)
if (!Number.isFinite(quantity) || quantity < 0) {
throw new Error('请输入大于等于 0 的数字')
}
if (!cashChannel && !Number.isInteger(quantity)) {
throw new Error('积分必须是整数')
}
},
},
]}
extra={
platformKey
? cashChannel
? '单位:元,最多两位小数'
: `换算率:${formatCreditUnitPriceLabel(unitPriceFen)}`
: '积分填整数,现金和统一结算填金额'
}
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={cashChannel ? 2 : 0}
step={cashChannel ? 0.01 : 1}
style={{ width: '100%' }}
placeholder={cashChannel ? '例如 12.50' : '例如 10'}
addonAfter={
platformKey ? (cashChannel ? '元' : '积分') : undefined
}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="折算成本" style={{ marginBottom: 0 }}>
<div className="manual-channel-cost-preview">
<Typography.Text strong={costFen > 0}>
{costFen > 0
? `¥${(costFen / 100).toFixed(2)}`
: '成本不固定,由客服确认时填写'}
</Typography.Text>
{platformKey && !cashChannel ? (
<Typography.Text type="secondary">
1积分={formatCreditUnitPriceYuan(unitPriceFen)}
</Typography.Text>
) : null}
</div>
</Form.Item>
</Col>
</Row>
)
}}
</Form.Item>
</div>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
添加履约渠道
</Button>
<Form.ErrorList errors={errors} />
</Space>
)}
</Form.List>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</Card>
)
}