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>
This commit is contained in:
yml2213
2026-10-24 13:12:46 +08:00
co-authored by Claude Opus 4.5
parent fb679d76f6
commit 4bcb4e6b57
7 changed files with 1693 additions and 0 deletions
@@ -20,6 +20,7 @@ import { CostExceptionPanel } from './CostExceptionPanel'
import { CostItemPanel } from './CostItemPanel'
import { CostPreviewPanel } from './CostPreviewPanel'
import { CostVersionPanel } from './CostVersionPanel'
import { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
import { ProductCatalogPanel } from './ProductCatalogPanel'
import { CostCenterContext } from './CostCenterContext'
import type { CostScope } from './shared'
@@ -58,6 +59,7 @@ function tabKeysForScope(scope: CostScope) {
return [
...(scope === 'all' || scope === 'work_order' ? ['products', 'mappings'] : []),
...(scope === 'affiliate_dash' ? ['affiliate-skus'] : []),
...(scope === 'all' || scope === 'manual' ? ['manual-skus'] : []),
'cost-items',
'cost-versions',
'exceptions',
@@ -231,6 +233,11 @@ export default function CostCenterShell() {
label: '自动发货 SKU',
children: <AffiliateSkuPanel />,
},
{
key: 'manual-skus',
label: '人工 SKU 配置',
children: <ManualSkuConfigPanel />,
},
{
key: 'cost-items',
label: '成本项目',
@@ -0,0 +1,460 @@
/**
* 人工 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>
)
}
@@ -11,6 +11,12 @@ import type {
CostPreview,
CostVersion,
CostVersionListResult,
ManualFulfillmentConfig,
ManualPlatformOption,
ManualRematchResult,
ManualSkuListResult,
ManualSkuOverview,
ManualSkuScanResult,
ProductCatalogItem,
ProductCatalogListResult,
TemplateProductBootstrapPreview,
@@ -221,3 +227,59 @@ export function createAdminCostSnapshot(payload: Record<string, unknown>) {
exceptionCreatedCount: number
}>('/api/v1/admin/cost-center/snapshots', payload)
}
/**
* 人工履约 SKU 配置:扫描 → 配置 → 重新匹配。
* 配置主键是内部 SKU(快手 skuNick / 拼多多 outSkuSn),同名 SKU 算同一商品。
*/
export function fetchAdminManualSkus(
params: {
page?: number
pageSize?: number
keyword?: string
status?: 'configured' | 'unconfigured' | ''
platformKey?: string
provider?: string
} = {},
) {
return apiGet<ManualSkuListResult>('/api/v1/admin/cost-center/manual-skus', params)
}
export function fetchAdminManualSkuOverview() {
return apiGet<ManualSkuOverview>('/api/v1/admin/cost-center/manual-skus/overview')
}
export function fetchAdminManualPlatforms() {
return apiGet<{ items: ManualPlatformOption[] }>('/api/v1/admin/cost-center/manual-platforms')
}
export function fetchAdminManualConfig(skuKey: string) {
return apiGet<ManualFulfillmentConfig | null>(
`/api/v1/admin/cost-center/manual-configs/${encodeURIComponent(skuKey)}`,
)
}
export function scanAdminManualSkus(payload: { fromDate: string; toDate: string }) {
return apiPost<ManualSkuScanResult>('/api/v1/admin/cost-center/manual-skus/scan', payload)
}
export function saveAdminManualConfig(payload: {
skuKey: string
platformKey: string
creditQuantity: number
canonicalProductId?: number | null
enabled?: boolean
note?: string
}) {
return apiPost<ManualFulfillmentConfig>('/api/v1/admin/cost-center/manual-configs', payload)
}
export function deleteAdminManualConfig(skuKey: string) {
return apiDelete<{ skuKey: string; deleted: number }>(
`/api/v1/admin/cost-center/manual-configs/${encodeURIComponent(skuKey)}`,
)
}
export function rematchAdminManualFulfillments(payload: { skuKey?: string } = {}) {
return apiPost<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
}
@@ -311,3 +311,81 @@ export interface CostExceptionListResult {
items: CostException[]
pagination: { page: number; pageSize: number; total: number }
}
/**
* 人工履约渠道:客服人工有四个钱包。
* 南航/乐玩/ROCK 是积分(三平台积分互不通用),备用金是现金。
*/
export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash'
/** 成本模式:fixed=按积分固定;variable=成本不固定,客服确认履约时手填。 */
export type ManualCostMode = 'fixed' | 'variable'
export interface ManualPlatformOption {
key: ManualPlatformKey
label: string
isCredit: boolean
creditUnitPriceFen: number
}
/** 人工 SKU 清单行:内部 SKU 是配置主键,名称仅供识别。 */
export interface ManualSkuItem {
id: number
skuKey: string
displayName: string
shopCount: number
orderCount: number
itemCount: number
revenueFen: number
firstSeenDate: string | null
lastSeenDate: string | null
scanFromDate: string | null
scanToDate: string | null
configured: boolean
config: ManualFulfillmentConfig | null
}
export interface ManualFulfillmentConfig {
id: number
skuKey: string
canonicalProductId: number | null
platformKey: ManualPlatformKey
platformLabel: string
creditQuantity: number
costMode: ManualCostMode
/** 成本(分)= 消耗积分 × 平台换算率(分/积分)。 */
costFen: number
/** 平台换算率(分/积分):南航/乐玩 1,ROCK 100(1 积分 = 1 元)。 */
creditUnitPriceFen: number
enabled: boolean
note?: string
createdAt?: string
updatedAt?: string
}
export interface ManualSkuListResult {
items: ManualSkuItem[]
pagination: { page: number; pageSize: number; total: number }
}
export interface ManualSkuOverview {
totalSku: number
configuredSku: number
unconfiguredSku: number
totalItems: number
configuredItems: number
unconfiguredItems: number
lastScanAt: string | null
}
export interface ManualSkuScanResult {
scanned: number
registered: number
fromDate: string
toDate: string
}
export interface ManualRematchResult {
updatedCount: number
skuKey: string
}