Files
order_site/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx
T
yml2213andClaude Opus 4.5 4bcb4e6b57 feat(finance): 人工履约 SKU 配置清单 + 四钱包(南航/乐玩/ROCK积分 + 备用金现金)
客服人工履约有四个钱包,成本与渠道在后台配置好,客服确认履约时零输入。

口径(2026-10-24 确认):
- 四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金。三平台积分互不通用,
  每客服每平台一个积分账户;现金复用现有 fund_accounts / fund_entries。
- 积分换算率按平台不同:南航 1 积分 = 1 分,乐玩 1 积分 = 1 分,
  ROCK 1 积分 = 1 元(100 分)。真源是履约档案 config_json.rechargeUnitPriceFen。
- 积分是货币,幸运币是商品;消耗积分与成本金额按换算率一一对应。
- 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
  不是商品标题(标题是引流)。同名 SKU 算同一商品,跨渠道共用一份配置。
- 配置链路没有匹配引擎、没有打分:扫描 → 配置 → 重新匹配。
  10 月口径下人工单 17,469 单 / 357 个内部 SKU,Top 50 覆盖 88% 单量。
  更早的老单没配置意义,不纳入统计口径。
- 消耗积分填 0 = 成本不固定,客服在确认履约时手填实际成本;
  渠道始终提前配好,客服不选。

迁移 110:
- manual_sku_registry:扫描到的内部 SKU 登记表(统计 + 配置状态)。
- manual_fulfillment_configs:内部 SKU → 渠道 + 消耗积分 + 成本模式。
- platform_credit_accounts / platform_credit_entries:三平台积分钱包与流水。
- order_items.fulfillment_config_id:订单项挂配置,重新匹配时回填。
- 4 条 manual_* 履约档案(manual_dispatch 执行器 + config_json 区分平台)。
  不复用 kuaishou_ct_assisted / kuaishou_feifei——那是已下线的历史自动发货通道。

性能:spec_json->>'skuNick' 无索引时扫描 2.4s / 回填 5.4s;
新增 idx_order_items_manual_internal_sku 表达式索引后回填降到 1.7s。

后端:manual-fulfillment-config-service.ts(扫描/清单/配置CRUD/重新匹配),
成本中心挂 7 个接口。
前端:成本中心新增「人工 SKU 配置」Tab(扫描、清单、配置弹窗、重新匹配)。

校验:后端 765 项 / 0 失败(含 check:sql 迁移护栏),前端 143 项全通过;
E2E 实测扫描 357 个 SKU、配置四平台换算正确、重新匹配回填 7,604 个订单项;
本地库迁移 110 执行后数据量与改造前一致。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:12:46 +08:00

461 lines
15 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,
ReloadOutlined,
ScanOutlined,
SyncOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
deleteAdminManualConfig,
fetchAdminManualPlatforms,
fetchAdminManualSkuOverview,
fetchAdminManualSkus,
rematchAdminManualFulfillments,
saveAdminManualConfig,
scanAdminManualSkus,
} from '@/services/admin'
import type { ManualSkuItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import dayjs from '@/lib/dayjs'
import type { Dayjs } from 'dayjs'
type ConfigFormValues = {
platformKey: string
creditQuantity: number
note?: string
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
lewan: 'purple',
rock: 'cyan',
fund_cash: 'orange',
}
/** 按换算率把「消耗单位」翻译成人话。 */
function unitLabel(platformKey: string, creditUnitPriceFen: number) {
if (platformKey === 'fund_cash') return '元'
if (creditUnitPriceFen >= 100) return '积分(1积分=1元)'
return '积分(1积分=1分)'
}
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 [scanRange, setScanRange] = useState<[Dayjs, Dayjs]>([
dayjs().startOf('month'),
dayjs().endOf('month'),
])
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 scanMutation = useMutation({
mutationFn: () =>
scanAdminManualSkus({
fromDate: scanRange[0].format('YYYY-MM-DD'),
toDate: scanRange[1].format('YYYY-MM-DD'),
}),
onSuccess: (response) => {
const result = response.data
message.success(`扫描完成,登记 ${result.registered} 个内部 SKU`)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '扫描失败')
},
})
const saveMutation = useMutation({
mutationFn: (values: ConfigFormValues) =>
saveAdminManualConfig({
skuKey: editing?.skuKey || '',
platformKey: values.platformKey,
creditQuantity: values.creditQuantity,
note: values.note || '',
}),
onSuccess: () => {
message.success('配置已保存')
setModalOpen(false)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '保存失败')
},
})
const deleteMutation = useMutation({
mutationFn: (skuKey: string) => deleteAdminManualConfig(skuKey),
onSuccess: () => {
message.success('配置已删除')
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '删除失败')
},
})
const rematchMutation = useMutation({
mutationFn: () => rematchAdminManualFulfillments(),
onSuccess: (response) => {
message.success(`重新匹配完成,回填 ${response.data.updatedCount} 个订单项`)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '重新匹配失败')
},
})
function openConfig(item: ManualSkuItem) {
setEditing(item)
form.setFieldsValue({
platformKey: item.config?.platformKey || undefined,
creditQuantity: item.config?.creditQuantity ?? undefined,
note: item.config?.note || '',
})
setModalOpen(true)
}
const columns: TableColumnsType<ManualSkuItem> = [
{
title: '内部 SKU(配置主键)',
dataIndex: 'skuKey',
width: 280,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong copyable={{ text: row.skuKey }}>
{row.skuKey}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.displayName || '(无名称)'}
</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) =>
row.config ? (
<Space size={4} direction="vertical">
<Tag color={PLATFORM_TAG_COLORS[row.config.platformKey] || 'default'}>
{row.config.platformLabel || row.config.platformKey}
</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.config.creditQuantity}{' '}
{unitLabel(row.config.platformKey, row.config.creditUnitPriceFen)}
</Typography.Text>
</Space>
) : (
<Typography.Text type="secondary">未配置</Typography.Text>
),
},
{
title: '成本',
key: 'cost',
width: 110,
align: 'right',
render: (_, row) =>
row.config ? (
row.config.costMode === 'fixed' ? (
<Typography.Text>¥{(row.config.costFen / 100).toFixed(2)}</Typography.Text>
) : (
<Tag color="orange">不固定·客服填</Tag>
)
) : (
'-'
),
},
{
title: '操作',
key: 'actions',
width: 130,
fixed: 'right',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
{row.configured ? (
<Popconfirm
title="删除该 SKU 的配置?"
description="删除后该 SKU 回到未配置状态,已回填的订单项不再带配置。"
okText="删除"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
) : null}
</Space>
),
},
]
const selectedPlatform = form.getFieldValue('platformKey')
const selectedCredit = form.getFieldValue('creditQuantity')
const previewUnitPrice = platformMap.get(selectedPlatform)?.creditUnitPriceFen ?? 0
const previewCostFen =
selectedPlatform && Number(selectedCredit) > 0 ? Number(selectedCredit) * previewUnitPrice : 0
return (
<Card bordered={false} title="人工 SKU 配置">
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="配置主键是内部 SKU,不是商品名称"
description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。客服确认履约时直接读配置,固定成本零输入。消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={6}>
<Statistic title="内部 SKU 总数" 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 }}>
<DatePicker.RangePicker
value={scanRange}
onChange={(value) => {
if (value?.[0] && value?.[1]) setScanRange([value[0], value[1]])
}}
/>
<Button
type="primary"
icon={<ScanOutlined />}
loading={scanMutation.isPending}
onClick={() => scanMutation.mutate()}
>
扫描内部 SKU
</Button>
<Button
icon={<SyncOutlined />}
loading={rematchMutation.isPending}
onClick={() => rematchMutation.mutate()}
>
重新匹配已配置
</Button>
<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>
<Table
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="渠道提前配置好,客服确认履约时不需要选择。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
/>
<Form<ConfigFormValues> form={form} layout="vertical">
<Form.Item
name="platformKey"
label="履约渠道"
rules={[{ required: true, message: '请选择履约渠道' }]}
>
<Select
placeholder="请选择渠道"
options={platforms.map((item) => ({
value: item.key,
label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`,
}))}
onChange={() => form.validateFields(['creditQuantity'])}
/>
</Form.Item>
<Form.Item
name="creditQuantity"
label="每单消耗积分"
rules={[{ required: true, message: '请输入消耗积分' }]}
extra="填 0 表示成本不固定,客服确认履约时手填实际成本。"
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="例如 10" />
</Form.Item>
<Form.Item label="折算成本">
<Typography.Text strong>
{previewCostFen > 0
? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)`
: '成本不固定,由客服手填'}
</Typography.Text>
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</Card>
)
}