refactor(finance): 成本模型与履约方式正交,拆分成本中心巨型文件

后端:
- 删除 services/admin/admin-cost-center-service.ts(4,248 行),拆为
  services/cost-center/ 下 5 个模块:
  cost-center-shared(类型/枚举/归一化/映射/costModelScopeSql)、
  cost-center-overview(总览与选项)、
  product-catalog(商品与渠道映射)、
  template-bootstrap(模板引导、自动发货同步、工单证据回填)、
  cost-definition(成本项/版本/异常/预览/快照)
- cost_items.carrier_type / cost_type 改为单一 cost_model;专属模型只对
  对应履约方式生效,通用模型对所有履约方式生效
- cost_snapshots / cost_exceptions 删除载体列;按履约方式筛异常改用
  IN 半连接(248k 异常下 EXISTS 逐行回表 2.7s -> 170ms)
- sales_daily_facts.carrier_type -> fulfillment_mode
- 人工支出按 cost_model 归类;履约方式只读 order_items.fulfillment_mode
- 移除 syncOpenCostExceptionCarrierTypes:异常表已无载体列可同步

前端:
- AdminCostCenterPage.tsx(2,887 行 -> 18 行转发),拆为
  pages/admin/cost-center/ 下 8 个 Panel + shared + CostCenterContext
- CostCarrierType 删除;新增 FulfillmentMode / ProductSource / CostModel
- 销售毛利页 carrierType -> fulfillmentMode

同步补两个索引:idx_order_items_fulfillment_id(异常半连接)、
idx_work_categories_name(商品目录分类 lateral join)。
This commit is contained in:
yml2213
2026-10-23 16:06:25 +08:00
parent 5c549a2dc5
commit 63ae32d18c
33 changed files with 8107 additions and 7335 deletions
File diff suppressed because it is too large Load Diff
@@ -32,22 +32,21 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatMoney } from './panels/shared'
type FilterForm = { dateRange?: unknown; provider?: string; carrierType?: string }
type FilterForm = { dateRange?: unknown; provider?: string; fulfillmentMode?: string }
const CARRIER_OPTIONS = [
{ value: '', label: '全部载体' },
{ value: 'affiliate_dash', label: '自动发货' },
const FULFILLMENT_MODE_OPTIONS = [
{ value: '', label: '全部履约方式' },
{ value: 'work_order', label: '接单平台' },
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'legacy_platform', label: '历史平台' },
{ value: 'manual', label: '人工客服' },
{ value: 'shared', label: '共用' },
]
const CARRIER_LABEL: Record<string, string> = {
affiliate_dash: '自动发货',
const FULFILLMENT_MODE_LABEL: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
shared: '共用',
unknown: '未知',
}
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
@@ -65,8 +64,8 @@ export default function AdminSalesProfitPage() {
dateFrom: string
dateTo: string
provider: string
carrierType: string
}>({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
fulfillmentMode: string
}>({ dateFrom: '', dateTo: '', provider: '', fulfillmentMode: '' })
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [rebuilding, setRebuilding] = useState(false)
@@ -90,14 +89,14 @@ export default function AdminSalesProfitPage() {
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
provider: values.provider || '',
carrierType: values.carrierType || '',
fulfillmentMode: values.fulfillmentMode || '',
})
}
function resetFilters() {
form.resetFields()
setPage(1)
setFilters({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
setFilters({ dateFrom: '', dateTo: '', provider: '', fulfillmentMode: '' })
}
async function rebuildFacts() {
@@ -169,11 +168,13 @@ export default function AdminSalesProfitPage() {
},
]
const carrierColumns: TableColumnsType<NonNullable<typeof overview>['byCarrier'][number]> = [
const fulfillmentModeColumns: TableColumnsType<
NonNullable<typeof overview>['byFulfillmentMode'][number]
> = [
{
title: '履约载体',
dataIndex: 'carrierType',
render: (value: string) => CARRIER_LABEL[value] || value,
title: '履约方式',
dataIndex: 'fulfillmentMode',
render: (value: string) => FULFILLMENT_MODE_LABEL[value] || value,
},
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
@@ -209,10 +210,10 @@ export default function AdminSalesProfitPage() {
row.canonicalProductId ? value : <Tag color="orange">未归一</Tag>,
},
{
title: '载体',
dataIndex: 'carrierType',
title: '履约方式',
dataIndex: 'fulfillmentMode',
width: 100,
render: (value: string) => CARRIER_LABEL[value] || value,
render: (value: string) => FULFILLMENT_MODE_LABEL[value] || value,
},
{ title: '订单项', dataIndex: 'itemCount', width: 80 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', width: 120, render: formatMoney },
@@ -254,7 +255,7 @@ export default function AdminSalesProfitPage() {
initialValues={{
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
provider: '',
carrierType: '',
fulfillmentMode: '',
}}
>
<Form.Item name="dateRange" label="销售日期">
@@ -271,8 +272,8 @@ export default function AdminSalesProfitPage() {
]}
/>
</Form.Item>
<Form.Item name="carrierType" label="载体">
<Select style={{ width: 150 }} options={CARRIER_OPTIONS} />
<Form.Item name="fulfillmentMode" label="履约方式">
<Select style={{ width: 150 }} options={FULFILLMENT_MODE_OPTIONS} />
</Form.Item>
<Form.Item>
<Space>
@@ -412,14 +413,14 @@ export default function AdminSalesProfitPage() {
),
},
{
key: 'carrier',
label: '载体',
key: 'fulfillment-mode',
label: '履约方式',
children: (
<Table
rowKey="carrierType"
rowKey="fulfillmentMode"
size="small"
columns={carrierColumns}
dataSource={overview?.byCarrier || []}
columns={fulfillmentModeColumns}
dataSource={overview?.byFulfillmentMode || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 800 }}
@@ -0,0 +1,224 @@
/**
* 自动发货 SKU Tab:上游 SKU 目录与订单项回填。
*
* 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:<skuCode>`,
* 不写渠道映射,也不维护游戏分类。
*/
import { ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import { Alert, App, Button, Card, Space, Switch, Table, Tag, Typography } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
backfillAdminAffiliateDashSkus,
fetchAdminProductCatalog,
syncAdminAffiliateDashProducts,
updateAdminProductCatalog,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { useCostCenter } from './CostCenterContext'
export function AffiliateSkuPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const { canConfig, refreshAll } = useCostCenter()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [statusUpdatingId, setStatusUpdatingId] = useState<number | null>(null)
const [syncing, setSyncing] = useState(false)
const [backfilling, setBackfilling] = useState(false)
const productsQuery = useQuery({
queryKey: ['admin-cost-center-products', 'affiliate_dash', page, pageSize],
queryFn: () =>
fetchAdminProductCatalog({
carrierType: 'affiliate_dash',
page,
pageSize,
}),
})
const products = productsQuery.data?.data.items || []
const total = productsQuery.data?.data.pagination.total || 0
async function syncProducts() {
setSyncing(true)
try {
const result = await syncAdminAffiliateDashProducts()
message.success(
`已同步 ${result.data.productCount} 个自动发货商品(新增 ${result.data.createdCount},更新 ${result.data.updatedCount})`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步自动发货商品失败')
} finally {
setSyncing(false)
}
}
async function backfillSkus() {
setBackfilling(true)
try {
const response = await backfillAdminAffiliateDashSkus()
const result = response.data
const factsText = result.factsRebuild
? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实`
: ''
const remainingText =
result.remainingCount > 0
? `;仍有 ${result.remainingCount} 个订单项缺少可匹配的自动发货 SKU`
: ''
message.success(
`已回填 ${result.updatedCount} 个订单项,${result.skippedCount} 个已是归一状态${factsText}${remainingText}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填自动发货 SKU 失败')
} finally {
setBackfilling(false)
}
}
async function toggleStatus(row: ProductCatalogItem, checked: boolean) {
if (!canConfig) return
setStatusUpdatingId(row.id)
try {
await updateAdminProductCatalog(row.id, { status: checked ? 'active' : 'archived' })
message.success(checked ? 'SKU 已启用' : 'SKU 已归档')
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '更新 SKU 状态失败')
} finally {
setStatusUpdatingId(null)
}
}
return (
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>自动发货 SKU</Typography.Text>
<Tag color="green">{total}</Tag>
</Space>
}
extra={
canConfig ? (
<Space>
<Button icon={<SyncOutlined />} loading={syncing} onClick={() => void syncProducts()}>
同步自动发货商品
</Button>
<Button
icon={<SyncOutlined />}
loading={backfilling}
onClick={() => void backfillSkus()}
>
回填自动发货 SKU
</Button>
<Button
icon={<ReloadOutlined />}
loading={productsQuery.isFetching}
onClick={() => void productsQuery.refetch()}
/>
</Space>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="自动发货只按上游货源 SKU 配采购成本:订单项直接按 aff:<skuCode> 归一,不走渠道映射;这里不维护游戏分类和商品上下架,那些只属于接单平台。"
/>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
dataSource={products}
loading={productsQuery.isLoading}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showTotal: (count) => `共 ${count} 个 SKU`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
columns={[
{
title: 'SKU 名称',
dataIndex: 'canonicalName',
width: 320,
ellipsis: true,
render: (value: string, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{
tooltip: String(row.attributes?.skuCode || row.canonicalSku),
}}
>
{String(row.attributes?.skuCode || row.canonicalSku)}
</Typography.Text>
</Space>
),
},
{
title: '履约任务',
width: 110,
render: (_, row) => `${row.affiliateTaskCount} 单`,
},
{
title: '最近履约',
width: 180,
render: (_, row) =>
row.affiliateLastFulfilledAt
? formatAdminDateTime(row.affiliateLastFulfilledAt)
: '-',
},
{
title: '成本版本',
dataIndex: 'costVersionCount',
width: 110,
render: (value: number) => (value > 0 ? `${value} 个` : <Tag>未配置</Tag>),
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value: string, row) => (
<Space size={6}>
<Switch
size="small"
checked={value === 'active'}
loading={statusUpdatingId === row.id}
disabled={
!canConfig || (statusUpdatingId !== null && statusUpdatingId !== row.id)
}
onChange={(checked) => void toggleStatus(row, checked)}
/>
<Typography.Text
type={value === 'active' ? 'success' : 'secondary'}
style={{ fontSize: 12 }}
>
{value === 'active' ? '启用' : '归档'}
</Typography.Text>
</Space>
),
},
]}
/>
</Card>
)
}
@@ -0,0 +1,575 @@
/**
* 渠道映射 Tab:来源 + 店铺 + 平台 SKU -> 标准商品。
*
* 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
*/
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'
import {
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import {
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
createAdminChannelProductMapping,
deleteAdminChannelProductMapping,
fetchAdminChannelProductMappings,
updateAdminChannelProductMapping,
} from '@/services/admin'
import type { ChannelProductMapping, ChannelProductMappingGroup } from '@/types/admin'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminLocalTablePagination,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { useCostCenter } from './CostCenterContext'
import {
MAPPING_MATCH_TYPE_OPTIONS,
mappingMatchTypeLabel,
providerLabel,
type MappingFormValues,
} from './shared'
export function ChannelMappingPanel() {
const { message, modal } = App.useApp()
const { canConfig, productSourceFilter, options, refreshAll } = useCostCenter()
const [mappingForm] = Form.useForm<MappingFormValues>()
const [mappingKeyword, setMappingKeyword] = useState('')
const [mappingProvider, setMappingProvider] = useState('')
const [mappingPage, setMappingPage] = useState(1)
const [mappingPageSize, setMappingPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [expandedMappingProductIds, setExpandedMappingProductIds] = useState<string[]>([])
const [mappingDetailPageSize, setMappingDetailPageSize] = useState(10)
const [mappingModalOpen, setMappingModalOpen] = useState(false)
const [editingMapping, setEditingMapping] = useState<ChannelProductMapping | null>(null)
const mappingsQuery = useQuery({
queryKey: [
'admin-cost-center-mappings',
productSourceFilter,
mappingKeyword,
mappingProvider,
mappingPage,
mappingPageSize,
],
queryFn: () =>
fetchAdminChannelProductMappings({
keyword: mappingKeyword || undefined,
provider: mappingProvider || undefined,
carrierType: productSourceFilter,
page: mappingPage,
pageSize: mappingPageSize,
}),
})
const mappingGroups = mappingsQuery.data?.data.groups || []
const mappingTotal = mappingsQuery.data?.data.pagination.mappingTotal || 0
const mappingGroupTotal = mappingsQuery.data?.data.pagination.total || 0
const providers = options?.providers || []
const productOptions =
options?.products
.filter((item) => !productSourceFilter || item.source === productSourceFilter)
.map((item) => ({
value: item.id,
label: `${item.canonicalName}(${item.canonicalSku})`,
})) || []
function openCreateMapping() {
setEditingMapping(null)
mappingForm.resetFields()
mappingForm.setFieldsValue({
provider: providers[0] || '91kaquan',
matchType: 'sku_code',
quantityMultiplier: 1,
confidence: 'manual',
source: 'manual',
enabled: true,
})
setMappingModalOpen(true)
}
function openEditMapping(item: ChannelProductMapping) {
setEditingMapping(item)
mappingForm.setFieldsValue({
provider: item.provider,
shopId: item.shopId,
platformSkuCode: item.platformSkuCode,
platformSkuName: item.platformSkuName,
matchType: item.matchType,
canonicalProductId: item.canonicalProductId,
quantityMultiplier: item.quantityMultiplier,
confidence: item.confidence,
source: item.source,
enabled: item.enabled,
note: item.note,
})
setMappingModalOpen(true)
}
async function submitMapping(values: MappingFormValues) {
try {
if (editingMapping) {
await updateAdminChannelProductMapping(editingMapping.id, values)
message.success('渠道映射已更新')
} else {
await createAdminChannelProductMapping(values)
message.success('渠道映射已创建')
}
setMappingModalOpen(false)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存渠道映射失败')
}
}
function deleteMapping(item: ChannelProductMapping) {
modal.confirm({
title: '删除渠道映射',
content: `确认删除「${providerLabel(item.provider)} / ${item.shopId || '全局'} / ${item.platformSkuCode || item.platformSkuName || '-'}」这条映射?删除后该平台 SKU 将不再自动归一。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminChannelProductMapping(item.id)
message.success('渠道映射已删除')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除渠道映射失败')
}
},
})
}
function backfillMappings() {
modal.confirm({
title: '回填订单项商品映射',
content:
'按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项。',
okText: '开始回填',
cancelText: '取消',
onOk: async () => {
try {
const response = await backfillAdminProductMappings({ limit: 20000, all: true })
const { updatedCount, batches, remainingCount } = response.data
message.success(
`已回填 ${updatedCount} 条订单项映射(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填商品映射失败')
}
},
})
}
function backfillQuantities() {
modal.confirm({
title: '回填归一数量',
content: '按 SKU 名称/编码解析数量,自动分批处理全部订单项;解析不到时按 1 件记录。',
okText: '开始回填',
cancelText: '取消',
onOk: async () => {
try {
const response = await backfillAdminNormalizedQuantities({ limit: 20000, all: true })
const { updatedCount, remainingCount, batches } = response.data
message.success(
`已回填 ${updatedCount} 条归一数量(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条` : ''}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填归一数量失败')
}
},
})
}
const mappingDetailColumns: TableColumnsType<ChannelProductMapping> = [
{ title: '来源', dataIndex: 'provider', width: 130, render: providerLabel },
{
title: '店铺',
dataIndex: 'shopId',
width: 150,
render: (value: string) => value || <Tag>全局</Tag>,
},
{
title: '平台 SKU',
dataIndex: 'platformSkuCode',
width: 220,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{value || row.platformSkuName || '-'}</Typography.Text>
<Typography.Text type="secondary">
{value ? row.platformSkuName || '-' : row.platformSkuNameKey || '-'}
</Typography.Text>
</Space>
),
},
{
title: '匹配方式',
dataIndex: 'matchType',
width: 160,
render: (value: ChannelProductMapping['matchType']) => (
<Tag color={value === 'sku_code' ? 'green' : 'blue'}>{mappingMatchTypeLabel(value)}</Tag>
),
},
{ title: '倍率', dataIndex: 'quantityMultiplier', width: 70 },
{ title: '置信度', dataIndex: 'confidence', width: 85 },
{
title: '启用',
dataIndex: 'enabled',
width: 75,
render: (value: boolean) => (
<Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 140,
fixed: 'right',
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" onClick={() => openEditMapping(row)}>
编辑
</Button>
<Button size="small" danger onClick={() => deleteMapping(row)}>
删除
</Button>
</Space>
) : null,
},
]
const mappingGroupColumns: TableColumnsType<ChannelProductMappingGroup> = [
{
title: '标准商品',
dataIndex: 'canonicalName',
width: 260,
render: (value: string, row) => (
<Space size={8} align="start">
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: row.canonicalSku }}>
{row.canonicalSku} · {row.unitName}
</Typography.Text>
</Space>
</Space>
),
},
{
title: '分类',
dataIndex: 'category',
width: 120,
render: (value: string) =>
value || <Typography.Text type="secondary">未分类</Typography.Text>,
},
{
title: '覆盖范围',
width: 220,
render: (_, row) => {
const rowProviders = Array.from(new Set(row.mappings.map((item) => item.provider)))
const shops = new Set(row.mappings.map((item) => `${item.provider}:${item.shopId || ''}`))
.size
return (
<Space size={[4, 4]} wrap>
{rowProviders.map((provider) => (
<Tag key={provider}>{providerLabel(provider)}</Tag>
))}
<Tag color="blue">{shops} 个店铺键</Tag>
</Space>
)
},
},
{
title: '映射',
width: 150,
render: (_, row) => {
const enabledCount = row.mappings.filter((item) => item.enabled).length
return (
<Space size={4}>
<Tag color="blue">{row.mappings.length} 条</Tag>
<Typography.Text type="secondary">启用 {enabledCount}</Typography.Text>
</Space>
)
},
},
{
title: '匹配方式',
width: 280,
render: (_, row) => {
const matchTypes = Array.from(new Set(row.mappings.map((item) => item.matchType)))
return (
<Space size={[4, 4]} wrap>
{matchTypes.map((matchType) => (
<Tag key={matchType} color={matchType === 'sku_code' ? 'green' : 'blue'}>
{mappingMatchTypeLabel(matchType)}
</Tag>
))}
</Space>
)
},
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : 'default'}>
{value === 'active' ? '启用' : '归档'}
</Tag>
),
},
{
title: '目录',
width: 90,
fixed: 'right',
render: (_, row) => {
const key = String(row.canonicalProductId)
const expanded = expandedMappingProductIds.includes(key)
return (
<Button
size="small"
type="link"
onClick={(event) => {
event.stopPropagation()
setExpandedMappingProductIds((current) =>
current.includes(key) ? current.filter((item) => item !== key) : [...current, key],
)
}}
>
{expanded ? '收起' : '展开'}
</Button>
)
},
},
]
return (
<>
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>渠道映射目录</Typography.Text>
<Tag color="blue">{mappingTotal} 条映射</Tag>
<Tag>{mappingGroupTotal} 个标准商品</Tag>
</Space>
}
extra={
canConfig ? (
<Space>
<Button icon={<SyncOutlined />} onClick={backfillMappings}>
回填订单项
</Button>
<Button onClick={backfillQuantities}>回填数量</Button>
<Button type="primary" onClick={openCreateMapping}>
新建映射
</Button>
</Space>
) : null
}
>
<Space style={{ marginBottom: 16 }} wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="标准商品 / 平台 SKU"
value={mappingKeyword}
onChange={(event) => {
setMappingKeyword(event.target.value)
setMappingPage(1)
setExpandedMappingProductIds([])
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={mappingProvider || undefined}
onChange={(value) => {
setMappingProvider(value || '')
setMappingPage(1)
setExpandedMappingProductIds([])
}}
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
style={{ width: 180 }}
/>
<Button
onClick={() =>
setExpandedMappingProductIds(
mappingGroups.map((group) => String(group.canonicalProductId)),
)
}
>
展开本页
</Button>
<Button onClick={() => setExpandedMappingProductIds([])}>收起全部</Button>
</Space>
<Table<ChannelProductMappingGroup>
tableLayout="fixed"
rowKey={(row) => String(row.canonicalProductId)}
size="small"
columns={mappingGroupColumns}
dataSource={mappingGroups}
loading={mappingsQuery.isLoading}
scroll={{ x: 1100 }}
expandable={{
expandedRowKeys: expandedMappingProductIds,
onExpandedRowsChange: (keys) =>
setExpandedMappingProductIds(keys.map((key) => String(key))),
expandRowByClick: true,
expandedRowRender: (group) => (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text type="secondary">
该标准商品下共有 {group.mappings.length} 条渠道映射
</Typography.Text>
<Table<ChannelProductMapping>
tableLayout="fixed"
rowKey="id"
size="small"
columns={mappingDetailColumns}
dataSource={group.mappings}
pagination={buildAdminLocalTablePagination({
pageSize: mappingDetailPageSize,
size: 'small',
onChange: (_page, pageSize) => setMappingDetailPageSize(pageSize),
})}
scroll={{ x: 980 }}
/>
</Space>
),
}}
pagination={buildAdminTablePagination({
current: mappingPage,
pageSize: mappingPageSize,
total: mappingGroupTotal,
showTotal: (total) => `共 ${total} 个标准商品`,
onChange: (page, pageSize) => {
setMappingPage(page)
setMappingPageSize(pageSize)
setExpandedMappingProductIds([])
},
})}
/>
</Card>
<Modal
title={editingMapping ? '编辑渠道映射' : '新建渠道映射'}
open={mappingModalOpen}
onCancel={() => setMappingModalOpen(false)}
onOk={() => mappingForm.submit()}
okText="保存"
cancelText="取消"
width={620}
destroyOnClose
>
<Form form={mappingForm} layout="vertical" onFinish={submitMapping}>
<Form.Item name="provider" label="来源" rules={[{ required: true }]}>
<Select
showSearch
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
/>
</Form.Item>
<Form.Item name="shopId" label="店铺 ID">
<Input placeholder="留空表示该来源全局映射" />
</Form.Item>
<Form.Item name="matchType" label="匹配方式" rules={[{ required: true }]}>
<Select options={MAPPING_MATCH_TYPE_OPTIONS} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, current) => prev.matchType !== current.matchType}>
{({ getFieldValue }) => {
const matchType =
(getFieldValue('matchType') as ChannelProductMapping['matchType'] | undefined) ||
'sku_code'
return (
<>
<Form.Item
name="platformSkuCode"
label="平台 SKU"
rules={
matchType === 'sku_name_series'
? []
: [{ required: true, message: '请输入平台 SKU' }]
}
>
<Input placeholder="平台原始 sku_code / productNo" />
</Form.Item>
<Form.Item
name="platformSkuName"
label="平台商品名"
rules={
matchType === 'sku_code'
? []
: [{ required: true, message: '该匹配方式必须填写平台商品名' }]
}
>
<Input placeholder="精确名称或数量系列文本,例如:指挥官密钥5个" />
</Form.Item>
</>
)
}}
</Form.Item>
<Form.Item
name="canonicalProductId"
label="标准商品"
rules={[{ required: true, message: '请选择标准商品' }]}
>
<Select showSearch options={productOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="quantityMultiplier" label="数量倍率">
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="confidence" label="置信度">
<Select
options={[
{ value: 'exact', label: '精确' },
{ value: 'rule', label: '规则' },
{ value: 'manual', label: '人工' },
]}
/>
</Form.Item>
<Form.Item name="source" label="来源方式">
<Select
options={[
{ value: 'manual', label: '人工' },
{ value: 'import', label: '导入' },
{ value: 'rule', label: '规则' },
]}
/>
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
)
}
@@ -0,0 +1,104 @@
/**
* 成本中心页面上下文。
*
* 面板只读这里的查询结果与动作,不自己发请求,避免同一页多套口径。
*/
import { createContext, useContext } from 'react'
import type {
ChannelProductMapping,
CostException,
CostItem,
CostModel,
CostPreview,
CostVersion,
ProductCatalogItem,
ProductSource,
} from '@/types/admin'
import type { CostScope } from './shared'
export type CostCenterContextValue = {
canConfig: boolean
scope: CostScope
scopeLabel: string
/** 商品/映射按来源前缀筛选;成本项/版本/异常按履约方式筛选。 */
productSourceFilter?: ProductSource
fulfillmentModeFilter?: 'affiliate_dash' | 'work_order' | 'manual'
overview?: {
productCount: number
mappingCount: number
costItemCount: number
activeVersionCount: number
openExceptionCount: number
unmappedOrderItemCount: number
productSourceCounts?: Record<string, number>
costModelCounts?: Record<string, number>
}
options?: {
providers: string[]
products: Array<{
id: number
canonicalSku: string
canonicalName: string
unitName: string
source: ProductSource
}>
costItems: Array<{
id: number
code: string
name: string
pricingMode: CostItem['pricingMode']
costModel: CostModel
}>
rules: Array<{
id: number
ruleKey: string
productName: string
skuCode: string
provider: string
}>
}
products: ProductCatalogItem[]
mappingGroups: Array<{
canonicalProductId: number
canonicalSku: string
canonicalName: string
category: string
unitName: string
status: 'active' | 'archived'
mappings: ChannelProductMapping[]
}>
costItems: CostItem[]
versions: CostVersion[]
exceptions: CostException[]
productsLoading: boolean
mappingsLoading: boolean
costItemsLoading: boolean
versionsLoading: boolean
exceptionsLoading: boolean
refreshAll: () => void
}
export const CostCenterContext = createContext<CostCenterContextValue | null>(null)
export function useCostCenter() {
const value = useContext(CostCenterContext)
if (!value) {
throw new Error('useCostCenter 必须在成本中心外壳内使用')
}
return value
}
export type CostPreviewState = {
preview: CostPreview | null
previewing: boolean
snapshotting: boolean
previewOrderItemId: number | null
setPreviewOrderItemId: (value: number | null) => void
setPreview: (value: CostPreview | null) => void
}
@@ -0,0 +1,259 @@
/**
* 成本中心外壳:顶部来源切换、总览统计与 Tab 定义。
*
* 各 Tab 由独立 Panel 组件承载,自己取数、自己维护表单与弹窗,
* 避免单文件堆积成不可维护的巨型页面。
*/
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Col, Row, Statistic, Tabs, Tag, Typography } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { usePersistedState } from '@/hooks/usePersistedState'
import { fetchAdminCostCenterOptions, fetchAdminCostCenterOverview } from '@/services/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { AffiliateSkuPanel } from './AffiliateSkuPanel'
import { ChannelMappingPanel } from './ChannelMappingPanel'
import { CostExceptionPanel } from './CostExceptionPanel'
import { CostItemPanel } from './CostItemPanel'
import { CostPreviewPanel } from './CostPreviewPanel'
import { CostVersionPanel } from './CostVersionPanel'
import { ProductCatalogPanel } from './ProductCatalogPanel'
import { CostCenterContext } from './CostCenterContext'
import type { CostScope } from './shared'
const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
{
key: 'all',
label: '全部',
hint: '不区分来源,查看全部商品、映射、成本项与版本。',
},
{
key: 'affiliate_dash',
label: '自动发货',
hint: '只按上游货源 SKU 配采购成本,不维护商品目录和渠道映射。',
},
{
key: 'work_order',
label: '接单平台',
hint: '需要打手接单履约的商品;成本以打手报酬为主,可按接单规则或商品配价。',
},
{
key: 'manual',
label: '人工客服',
hint: '客服手工处理的商品;成本按人工实际支出逐单登记。',
},
]
const SCOPE_TAG_COLORS: Record<CostScope, string> = {
all: 'default',
affiliate_dash: 'green',
work_order: 'blue',
manual: 'orange',
}
function tabKeysForScope(scope: CostScope) {
return [
...(scope === 'all' || scope === 'work_order' ? ['products', 'mappings'] : []),
...(scope === 'affiliate_dash' ? ['affiliate-skus'] : []),
'cost-items',
'cost-versions',
'exceptions',
'preview',
]
}
export default function CostCenterShell() {
const queryClient = useQueryClient()
const canConfig = hasAdminPermission('finance.config')
const [scope, setScope] = usePersistedState<CostScope>(
'admin-cost-center:scope',
'all',
(value): value is CostScope => SCOPES.some((item) => item.key === value),
)
const [activeTab, setActiveTab] = usePersistedState<string>('admin-cost-center:tab', 'products')
const overviewQuery = useQuery({
queryKey: ['admin-cost-center-overview'],
queryFn: fetchAdminCostCenterOverview,
})
const optionsQuery = useQuery({
queryKey: ['admin-cost-center-options'],
queryFn: fetchAdminCostCenterOptions,
})
const overview = overviewQuery.data?.data
const options = optionsQuery.data?.data
const visibleTabKeys = tabKeysForScope(scope)
useEffect(() => {
if (!visibleTabKeys.includes(activeTab)) {
setActiveTab(visibleTabKeys[0] ?? 'cost-items')
}
}, [activeTab, setActiveTab, visibleTabKeys])
function refreshAll() {
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-items'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-versions'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions'] })
}
const scopeLabel =
scope === 'all' ? '全部' : (SCOPES.find((item) => item.key === scope)?.label ?? '')
const productSourceFilter = scope === 'all' ? undefined : scope
const fulfillmentModeFilter =
scope === 'all' ? undefined : (scope as 'affiliate_dash' | 'work_order' | 'manual')
return (
<CostCenterContext.Provider
value={{
canConfig,
scope,
scopeLabel,
productSourceFilter,
fulfillmentModeFilter,
overview,
options,
products: [],
mappingGroups: [],
costItems: [],
versions: [],
exceptions: [],
productsLoading: false,
mappingsLoading: false,
costItemsLoading: false,
versionsLoading: false,
exceptionsLoading: false,
refreshAll,
}}
>
<section className="page-stack">
<PageHeader
title="商品与成本中心"
description="先按来源(自动发货 / 接单平台 / 人工)分区,再把商品 SKU 归一、成本金额收口到版本;历史成本靠快照固化。"
extra={
<Button
icon={<ReloadOutlined />}
onClick={refreshAll}
loading={overviewQuery.isFetching}
>
刷新
</Button>
}
/>
<Row gutter={[16, 16]}>
{[
['标准商品', overview?.productCount || 0],
['渠道映射', overview?.mappingCount || 0],
['成本项目', overview?.costItemCount || 0],
['生效版本', overview?.activeVersionCount || 0],
['待处理异常', overview?.openExceptionCount || 0],
['未映射订单项', overview?.unmappedOrderItemCount || 0],
].map(([label, value]) => (
<Col xs={12} md={8} xl={4} key={label}>
<Card>
<Statistic title={label} value={value as number} />
</Card>
</Col>
))}
</Row>
<Card className="cost-center-carrier-card">
<Tabs
activeKey={scope}
onChange={(key) => {
const next = key as CostScope
setScope(next)
setActiveTab(
next === 'all' || next === 'work_order'
? 'products'
: next === 'affiliate_dash'
? 'affiliate-skus'
: 'cost-items',
)
}}
items={SCOPES.map((item) => {
const productCount =
item.key === 'all'
? overview?.productCount
: overview?.productSourceCounts?.[item.key]
const costItemCount = item.key === 'all' ? overview?.costItemCount : undefined
return {
key: item.key,
label: (
<span className="cost-center-scope-tab">
<span>{item.label}</span>
<Tag color={SCOPE_TAG_COLORS[item.key]}>
{Number(productCount || 0)} 商品
{costItemCount === undefined ? '' : ` / ${Number(costItemCount)} 成本项`}
</Tag>
</span>
),
children: <Typography.Text type="secondary">{item.hint}</Typography.Text>,
}
})}
/>
</Card>
{!canConfig ? (
<Alert
type="info"
showIcon
message="当前账号为只读权限"
description="可以查看商品、映射、成本版本和异常;新增/修改需要 finance.config 权限。"
/>
) : null}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'products',
label: '标准商品',
children: <ProductCatalogPanel />,
},
{
key: 'mappings',
label: '渠道映射',
children: <ChannelMappingPanel />,
},
{
key: 'affiliate-skus',
label: '自动发货 SKU',
children: <AffiliateSkuPanel />,
},
{
key: 'cost-items',
label: '成本项目',
children: <CostItemPanel />,
},
{
key: 'cost-versions',
label: '成本版本',
children: <CostVersionPanel />,
},
{
key: 'exceptions',
label: '成本异常',
children: <CostExceptionPanel />,
},
{
key: 'preview',
label: '成本预览',
children: <CostPreviewPanel />,
},
].filter((item) => visibleTabKeys.includes(item.key))}
/>
</section>
</CostCenterContext.Provider>
)
}
@@ -0,0 +1,184 @@
/**
* 成本异常 Tab:成本缺失 / 冲突队列。
*
* 异常表不再存履约载体,履约方式从 order_items.fulfillment_mode join 得到;
* 前端按履约方式筛选时传 fulfillmentMode 参数。
*/
import { App, Button, Card, Select, Space, Table, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { fetchAdminCostExceptions, resolveAdminCostException } from '@/services/admin'
import type { CostException, FulfillmentMode } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { useCostCenter } from './CostCenterContext'
const FULFILLMENT_MODE_TAG_COLORS: Record<string, string> = {
work_order: 'blue',
affiliate_dash: 'green',
legacy_platform: 'default',
manual: 'orange',
}
const FULFILLMENT_MODE_LABELS: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
export function CostExceptionPanel() {
const { message, modal } = App.useApp()
const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter()
const [status, setStatus] = useState('open')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const exceptionsQuery = useQuery({
queryKey: ['admin-cost-center-exceptions', fulfillmentModeFilter, status, page, pageSize],
queryFn: () =>
fetchAdminCostExceptions({
status: status || undefined,
fulfillmentMode: fulfillmentModeFilter,
page,
pageSize,
}),
})
const exceptions = exceptionsQuery.data?.data.items || []
const total = exceptionsQuery.data?.data.pagination.total || 0
function resolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
modal.confirm({
title: nextStatus === 'resolved' ? '标记异常已处理' : '忽略异常',
content: `确认处理「${item.reason}」?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await resolveAdminCostException(item.id, { status: nextStatus })
message.success('成本异常已处理')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '处理成本异常失败')
}
},
})
}
const columns: TableColumnsType<CostException> = [
{ title: '主体', dataIndex: 'subjectType', width: 110 },
{
title: '订单项',
dataIndex: 'orderItemId',
width: 100,
render: (value) => (value ? `#${value}` : '-'),
},
{
title: '履约方式',
dataIndex: 'fulfillmentMode',
width: 110,
render: (value: FulfillmentMode | '') =>
value ? (
<Tag color={FULFILLMENT_MODE_TAG_COLORS[value] || 'default'}>
{FULFILLMENT_MODE_LABELS[value] || value}
</Tag>
) : (
<Tag>未知</Tag>
),
},
{
title: '原因',
dataIndex: 'reason',
width: 360,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text>{value}</Typography.Text>
<Typography.Text type="secondary">{String(row.detail?.message || '')}</Typography.Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value: string) => (
<Tag color={value === 'open' ? 'red' : value === 'resolved' ? 'green' : 'default'}>
{value === 'open' ? '待处理' : value === 'resolved' ? '已处理' : '已忽略'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 180,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作',
width: 170,
render: (_, row) =>
canConfig && row.status === 'open' ? (
<Space>
<Button size="small" type="primary" onClick={() => resolve(row, 'resolved')}>
已处理
</Button>
<Button size="small" onClick={() => resolve(row, 'ignored')}>
忽略
</Button>
</Space>
) : null,
},
]
return (
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>成本缺失 / 冲突</Typography.Text>
<Tag color="blue">{total} 条</Tag>
</Space>
}
extra={
<Select
value={status}
onChange={(value) => {
setStatus(value)
setPage(1)
}}
options={[
{ value: 'open', label: '待处理' },
{ value: 'resolved', label: '已处理' },
{ value: 'ignored', label: '已忽略' },
{ value: '', label: '全部' },
]}
style={{ width: 140 }}
/>
}
>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={columns}
dataSource={exceptions}
loading={exceptionsQuery.isLoading}
scroll={{ x: 1100 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showTotal: (count) => `共 ${count} 条异常`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
)
}
@@ -0,0 +1,332 @@
/**
* 成本项 Tab:有哪些成本。
*
* 成本项 = 成本模型 + 计价方式;不再有「适用载体」列。
* 专属成本模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效,
* 通用成本模型(平台费 / 支付费 / 售后损失 / 其他)对所有履约方式生效。
*/
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import {
createAdminCostItem,
deleteAdminCostItem,
fetchAdminCostItems,
updateAdminCostItem,
} from '@/services/admin'
import type { CostItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { useCostCenter } from './CostCenterContext'
import {
COST_ITEM_PRESETS,
COST_MODEL_OPTIONS,
PRICING_OPTIONS,
costModelLabel,
labelOf,
type CostItemFormValues,
type CostItemPreset,
} from './shared'
export function CostItemPanel() {
const { message, modal } = App.useApp()
const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter()
const [costItemForm] = Form.useForm<CostItemFormValues>()
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<CostItem | null>(null)
const costItemsQuery = useQuery({
queryKey: ['admin-cost-center-cost-items', fulfillmentModeFilter, keyword, page, pageSize],
queryFn: () =>
fetchAdminCostItems({
keyword: keyword || undefined,
fulfillmentMode: fulfillmentModeFilter,
page,
pageSize,
}),
})
const costItems = costItemsQuery.data?.data.items || []
const total = costItemsQuery.data?.data.pagination.total || 0
const visiblePresets =
fulfillmentModeFilter === undefined
? COST_ITEM_PRESETS
: COST_ITEM_PRESETS.filter((preset) => presetAppliesToScope(preset, fulfillmentModeFilter))
function openCreate(preset?: CostItemPreset) {
setEditing(null)
costItemForm.resetFields()
costItemForm.setFieldsValue({
name: preset?.name,
costModel: preset?.costModel,
pricingMode: preset?.pricingMode || 'per_item',
unitName: preset?.unitName || '件',
status: 'active',
})
setModalOpen(true)
}
function openEdit(item: CostItem) {
setEditing(item)
costItemForm.setFieldsValue({
name: item.name,
costModel: item.costModel,
pricingMode: item.pricingMode,
unitName: item.unitName,
status: item.status,
note: item.note,
})
setModalOpen(true)
}
async function submit(values: CostItemFormValues) {
try {
if (editing) {
await updateAdminCostItem(editing.id, {
name: values.name,
costModel: values.costModel,
pricingMode: values.pricingMode,
unitName: values.unitName || '件',
status: values.status || 'active',
note: values.note || '',
})
message.success('成本项目已更新')
} else {
const created = await createAdminCostItem(values)
message.success(`成本项目已创建(编码 ${created.data.costItem.code})`)
}
setModalOpen(false)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存成本项目失败')
}
}
function remove(item: CostItem) {
modal.confirm({
title: '删除成本项目',
content: `确认删除「${item.name}(${item.code})」?存在成本版本、成本快照或接单规则引用时会被拒绝,请改用归档。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminCostItem(item.id)
message.success('成本项目已删除')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除成本项目失败')
}
},
})
}
const columns: TableColumnsType<CostItem> = [
{ title: '编码', dataIndex: 'code', width: 180 },
{ title: '名称', dataIndex: 'name', width: 320, ellipsis: true },
{
title: '成本模型',
dataIndex: 'costModel',
width: 170,
render: (value: string) => costModelLabel(value),
},
{
title: '计价',
dataIndex: 'pricingMode',
width: 120,
render: (value: string) => labelOf(PRICING_OPTIONS, value),
},
{ title: '版本数', dataIndex: 'versionCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : 'default'}>
{value === 'active' ? '启用' : '归档'}
</Tag>
),
},
{
title: '操作',
width: 150,
fixed: 'right',
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" onClick={() => openEdit(row)}>
编辑
</Button>
<Button size="small" danger onClick={() => remove(row)}>
删除
</Button>
</Space>
) : null,
},
]
return (
<>
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>成本项目</Typography.Text>
<Tag color="blue">{total} 个</Tag>
</Space>
}
extra={
canConfig ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreate()}>
新建成本项
</Button>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="成本项目 = 有哪些成本;成本版本 = 这笔成本多少钱、对谁生效"
description={
<Space direction="vertical" size={2}>
<span>
1. 先建成本项目:上游采购 / 接单平台打手报酬 / 人工客服实际支出 / 平台服务费等;
</span>
<span>
2. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 &gt; 标准商品 &gt; 店铺
&gt; 来源 &gt; 全局 命中;
</span>
<span>3. 改价只新增版本,历史订单走成本快照。</span>
</Space>
}
/>
{canConfig && visiblePresets.length > 0 ? (
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text type="secondary">常用成本项:</Typography.Text>
{visiblePresets.map((preset) => (
<Button key={preset.key} size="small" onClick={() => openCreate(preset)}>
+ {preset.label}
</Button>
))}
</Space>
) : null}
<Space style={{ marginBottom: 16 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="编码 / 名称"
value={keyword}
onChange={(event) => {
setKeyword(event.target.value)
setPage(1)
}}
style={{ width: 260 }}
/>
</Space>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={columns}
dataSource={costItems}
loading={costItemsQuery.isLoading}
scroll={{ x: 1100 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showTotal: (count) => `共 ${count} 个成本项`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
<Modal
title={editing ? '编辑成本项目' : '新建成本项目'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => costItemForm.submit()}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={costItemForm} layout="vertical" onFinish={submit}>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="编码由系统自动生成(按成本模型 + 序号),无需填写;创建后不可修改。"
/>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:接单平台打手报酬" />
</Form.Item>
<Form.Item
name="costModel"
label="成本模型"
rules={[{ required: true }]}
extra="上游采购 / 打手报酬 / 人工实际支出只对对应履约方式生效;平台费、支付费、售后损失、其他对所有履约方式生效。"
>
<Select options={COST_MODEL_OPTIONS} />
</Form.Item>
<Form.Item
name="pricingMode"
label="计价方式"
rules={[{ required: true }]}
extra="按件 = 数量 × 单价;按单 = 每单固定金额;按收入比例 = 订单金额 × 百分比。人工成本逐单按实际金额,不按工时。"
>
<Select options={PRICING_OPTIONS} />
</Form.Item>
<Form.Item name="unitName" label="单位">
<Input placeholder="件 / 单" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'archived', label: '归档' },
]}
/>
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
)
}
/** 常用模板是否适用于当前履约方式分区。 */
function presetAppliesToScope(
preset: CostItemPreset,
fulfillmentMode: 'affiliate_dash' | 'work_order' | 'manual',
) {
if (preset.costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
if (preset.costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
if (preset.costModel === 'manual_actual') return fulfillmentMode === 'manual'
return true
}
@@ -0,0 +1,192 @@
/**
* 成本预览 Tab:按订单项预览成本解析结果。
*
* 履约方式直接读 order_items.fulfillment_mode,页面只做展示,不允许改判。
*/
import {
App,
Alert,
Button,
Card,
Descriptions,
Empty,
InputNumber,
Space,
Table,
Typography,
} from 'antd'
import { useState } from 'react'
import { createAdminCostSnapshot, previewAdminCost } from '@/services/admin'
import type { CostPreview } from '@/types/admin'
import { formatMoney } from '../panels/shared'
import { useCostCenter } from './CostCenterContext'
import { costModelLabel, providerLabel } from './shared'
const FULFILLMENT_MODE_LABELS: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
export function CostPreviewPanel() {
const { message } = App.useApp()
const { canConfig, refreshAll } = useCostCenter()
const [previewOrderItemId, setPreviewOrderItemId] = useState<number | null>(null)
const [preview, setPreview] = useState<CostPreview | null>(null)
const [previewing, setPreviewing] = useState(false)
const [snapshotting, setSnapshotting] = useState(false)
async function runPreview() {
if (!previewOrderItemId) {
message.warning('请输入订单项 ID')
return
}
setPreviewing(true)
setPreview(null)
try {
const response = await previewAdminCost({ orderItemId: previewOrderItemId })
setPreview(response.data)
} catch (error) {
message.error(error instanceof Error ? error.message : '成本预览失败')
} finally {
setPreviewing(false)
}
}
async function createSnapshot() {
const orderItemId = preview?.context.orderItemId
if (!orderItemId) return
setSnapshotting(true)
try {
const response = await createAdminCostSnapshot({ orderItemId })
const created = response.data.createdCount
const exceptionCreated = response.data.exceptionCreatedCount
message.success(
created > 0
? `已生成 ${created} 条成本快照${exceptionCreated > 0 ? `,新增 ${exceptionCreated} 条异常` : ''}`
: `没有新增快照${exceptionCreated > 0 ? `,已记录 ${exceptionCreated} 条异常` : ',已有快照保持不变'}`,
)
setPreview(response.data.preview)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '生成成本快照失败')
} finally {
setSnapshotting(false)
}
}
return (
<Card title="按订单项预览成本">
<Space style={{ marginBottom: 16 }}>
<InputNumber
min={1}
placeholder="订单项 ID"
value={previewOrderItemId}
onChange={(value) => {
const nextId = value ? Number(value) : null
setPreviewOrderItemId(nextId)
if (nextId !== preview?.context.orderItemId) {
setPreview(null)
}
}}
style={{ width: 180 }}
/>
<Button type="primary" loading={previewing} onClick={runPreview}>
预览成本
</Button>
{canConfig ? (
<Button
onClick={createSnapshot}
loading={snapshotting}
disabled={!preview || preview.context.orderItemId !== previewOrderItemId}
>
生成成本快照
</Button>
) : null}
</Space>
{!preview ? (
<Empty description="输入订单项 ID 后查看商品映射与成本版本" />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="订单项">#{preview.context.orderItemId}</Descriptions.Item>
<Descriptions.Item label="来源/店铺">
{providerLabel(preview.context.provider)} / {preview.context.shopId || '全局'}
</Descriptions.Item>
<Descriptions.Item label="SKU">
{preview.context.skuCode} · {preview.context.skuName || '-'}
</Descriptions.Item>
<Descriptions.Item label="归一数量">
{Number(preview.context.normalizedQuantity || 0).toFixed(3)}
</Descriptions.Item>
<Descriptions.Item label="订单金额">
{formatMoney(preview.context.revenueFen)}
</Descriptions.Item>
<Descriptions.Item label="履约方式">
{FULFILLMENT_MODE_LABELS[preview.context.fulfillmentMode] ||
preview.context.fulfillmentMode}
</Descriptions.Item>
<Descriptions.Item label="标准商品">
{preview.product
? `${preview.product.canonicalName}(${preview.product.canonicalSku})`
: '未映射'}
</Descriptions.Item>
<Descriptions.Item label="成本合计">
<Typography.Text strong>{formatMoney(preview.totalCostFen)}</Typography.Text>
</Descriptions.Item>
</Descriptions>
{preview.exceptions.length > 0 ? (
<Alert
type="warning"
showIcon
message="成本异常"
description={preview.exceptions.map((item) => item.message).join(';')}
/>
) : null}
<Table
tableLayout="fixed"
rowKey={(row) => `${row.costItemId}-${row.costVersionId}`}
size="small"
pagination={false}
dataSource={preview.lines}
columns={[
{ title: '成本项', dataIndex: 'costItemName', width: 220 },
{
title: '成本模型',
dataIndex: 'costModel',
width: 140,
render: (value: string) => costModelLabel(value),
},
{ title: '范围', dataIndex: 'scopeType', width: 160 },
{
title: '数量',
dataIndex: 'quantity',
width: 90,
render: (value: number) => Number(value || 0).toFixed(3),
},
{
title: '单位成本',
dataIndex: 'unitAmountFen',
width: 110,
render: (value: number) => formatMoney(value),
},
{
title: '合计',
dataIndex: 'totalAmountFen',
width: 110,
render: (value: number) => formatMoney(value),
},
]}
/>
</Space>
)}
</Card>
)
}
@@ -0,0 +1,530 @@
/**
* 成本版本 Tab:给成本项定价。
*
* 一条版本 = 一个价格 + 一个生效区间 + 一个适用范围。
* 改价只新增版本,旧版本停用,历史订单按成本快照不变。
*/
import { PlusOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import dayjs from '@/lib/dayjs'
import {
createAdminCostVersion,
deleteAdminCostVersion,
fetchAdminCostVersions,
retireAdminCostVersion,
syncAdminWorkOrderTemplateCostVersions,
} from '@/services/admin'
import type { CostVersion } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from '../panels/shared'
import { useCostCenter } from './CostCenterContext'
import {
SCOPE_OPTIONS,
costModelLabel,
formatRate,
providerLabel,
type CostVersionFormValues,
} from './shared'
export function CostVersionPanel() {
const { message, modal } = App.useApp()
const { canConfig, fulfillmentModeFilter, options, refreshAll } = useCostCenter()
const [versionForm] = Form.useForm<CostVersionFormValues>()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [modalOpen, setModalOpen] = useState(false)
const [syncing, setSyncing] = useState(false)
const versionScopeType = Form.useWatch('scopeType', versionForm)
const versionCostItemId = Form.useWatch('costItemId', versionForm)
const versionsQuery = useQuery({
queryKey: [
'admin-cost-center-cost-versions',
fulfillmentModeFilter,
keyword,
status,
page,
pageSize,
],
queryFn: () =>
fetchAdminCostVersions({
keyword: keyword || undefined,
status: status || undefined,
fulfillmentMode: fulfillmentModeFilter,
page,
pageSize,
}),
})
const versions = versionsQuery.data?.data.items || []
const total = versionsQuery.data?.data.pagination.total || 0
const versionPricingMode = options?.costItems.find(
(item) => item.id === versionCostItemId,
)?.pricingMode
const providers = options?.providers || []
const productOptions =
options?.products.map((item) => ({
value: item.id,
label: `${item.canonicalName}(${item.canonicalSku})`,
})) || []
const costItemOptions =
options?.costItems.map((item) => ({
value: item.id,
label: `${item.name}(${item.code})`,
})) || []
const ruleOptions =
options?.rules.map((item) => ({
value: item.id,
label: `${item.productName || item.ruleKey} · #${item.id}`,
})) || []
function openCreate() {
versionForm.resetFields()
versionForm.setFieldsValue({
scopeType: fulfillmentModeFilter === 'affiliate_dash' ? 'product' : 'global',
effectiveFrom: dayjs(),
status: 'active',
})
setModalOpen(true)
}
async function submit(values: CostVersionFormValues) {
try {
const isPercent = versionPricingMode === 'percent_of_revenue'
const usesProvider = values.scopeType === 'provider' || values.scopeType === 'shop'
await createAdminCostVersion({
costItemId: values.costItemId,
scopeType: values.scopeType,
provider: usesProvider ? values.provider || '' : '',
shopId: values.scopeType === 'shop' ? values.shopId || '' : '',
canonicalProductId: values.scopeType === 'product' ? values.canonicalProductId : undefined,
workProductRuleId: values.scopeType === 'rule' ? values.workProductRuleId : undefined,
amountFen: isPercent ? 0 : Math.max(0, Math.round(Number(values.amountYuan || 0) * 100)),
rateBps: isPercent ? values.rateBps || 0 : 0,
effectiveFrom: values.effectiveFrom?.format('YYYY-MM-DD HH:mm:ss'),
effectiveTo: values.effectiveTo?.format('YYYY-MM-DD HH:mm:ss'),
status: values.status || 'active',
changeReason: values.changeReason || '',
})
message.success('成本版本已创建')
setModalOpen(false)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存成本版本失败')
}
}
function retire(item: CostVersion) {
modal.confirm({
title: '停用成本版本',
content: `确认停用「${item.costItemName} · ${item.scopeType}」这个版本?历史快照不会改变。`,
okText: '停用',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await retireAdminCostVersion(item.id, { changeReason: '后台手动停用' })
message.success('成本版本已停用')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '停用成本版本失败')
}
},
})
}
function remove(item: CostVersion) {
modal.confirm({
title: '删除成本版本',
content: `确认删除「${item.costItemName} · ${item.scopeType}」这个版本?已被成本快照引用的版本不可删除。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminCostVersion(item.id)
message.success('成本版本已删除')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除成本版本失败')
}
},
})
}
async function syncTemplateCosts() {
setSyncing(true)
try {
const response = await syncAdminWorkOrderTemplateCostVersions()
const result = response.data
message.success(
`已同步 ${result.activeVersionCount} 条模板发单金额(新建 ${result.createdCount},停用 ${result.retiredCount}),关联 ${result.linkedSnapshotCount} 条成本快照`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步接单模板发单金额失败')
} finally {
setSyncing(false)
}
}
const columns: TableColumnsType<CostVersion> = [
{
title: '成本项',
dataIndex: 'costItemName',
width: 240,
ellipsis: true,
render: (value: string, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
<Typography.Text type="secondary" ellipsis={{ tooltip: row.costItemCode }}>
{row.costItemCode}
</Typography.Text>
</Space>
),
},
{
title: '成本模型',
dataIndex: 'costItemCostModel',
width: 150,
render: (value: string) => costModelLabel(value),
},
{
title: '范围',
dataIndex: 'scopeType',
width: 200,
render: (value: string, row) => {
if (value === 'global') {
return row.isSystemPlaceholder ? '全局(系统占位)' : '全局'
}
if (value === 'provider') return providerLabel(row.provider)
if (value === 'shop') return `${providerLabel(row.provider)} / ${row.shopId}`
if (value === 'product') return row.canonicalName || `商品 #${row.canonicalProductId}`
const ruleLabel =
row.workProductRuleName ||
(row.workProductRuleId ? `规则 #${row.workProductRuleId}` : '历史模板版本')
const ruleSub =
row.workProductRuleKey ||
(row.workProductRuleId ? '' : row.changeReason || '模板已删除,历史版本保留')
return (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text ellipsis={{ tooltip: ruleLabel }}>{ruleLabel}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{ruleSub}
</Typography.Text>
</Space>
)
},
},
{
title: '用户实付均价',
width: 160,
render: (_, row) =>
row.revenueItemCount > 0 ? (
<Space direction="vertical" size={0}>
<span>{formatMoney(row.revenueAvgFen)}</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.revenueItemCount} 项 / 合计 {formatMoney(row.revenueFen)}
</Typography.Text>
</Space>
) : (
'-'
),
},
{
title: fulfillmentModeFilter === 'work_order' ? '发单金额/比例' : '金额/比例',
width: 170,
render: (_, row) => {
if (row.isSystemPlaceholder) return <Tag>按工单实际结算</Tag>
if (row.pricingMode === 'percent_of_revenue') return formatRate(row.rateBps)
if (row.amountKind === 'unit_price') {
return (
<Space direction="vertical" size={0}>
<span>{formatMoney(row.amountFen)} / 件</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.revenueItemCount > 0
? `平均实发 ${formatMoney(row.rewardAvgFen)} / 项`
: '暂无成交'}
</Typography.Text>
</Space>
)
}
return (
<Space direction="vertical" size={0}>
<span>{formatMoney(row.amountFen)}</span>
{row.revenueItemCount > 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
平均实发 {formatMoney(row.rewardAvgFen)} / 项
</Typography.Text>
) : null}
</Space>
)
},
},
{
title: '生效时间',
dataIndex: 'effectiveFrom',
width: 190,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<span>{row.isSystemPlaceholder ? '系统占位' : formatAdminDateTime(value)}</span>
<Typography.Text type="secondary">
{row.isSystemPlaceholder
? '按工单实际结算'
: row.effectiveTo
? `至 ${formatAdminDateTime(row.effectiveTo)}`
: '长期'}
</Typography.Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : value === 'draft' ? 'gold' : 'default'}>
{value === 'active' ? '生效' : value === 'draft' ? '草稿' : '停用'}
</Tag>
),
},
{
title: '操作',
width: 90,
fixed: 'right',
render: (_, row) => {
if (!canConfig) return null
if (row.status === 'active') {
return (
<Button size="small" danger onClick={() => retire(row)}>
停用
</Button>
)
}
return (
<Button size="small" danger onClick={() => remove(row)}>
删除
</Button>
)
},
},
]
return (
<>
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>成本版本(改价只新增版本)</Typography.Text>
<Tag color="blue">{total} 个</Tag>
</Space>
}
extra={
canConfig ? (
<Space>
{fulfillmentFilterAllowsTemplateSync(fulfillmentModeFilter) ? (
<Button
icon={<SyncOutlined />}
loading={syncing}
onClick={() => void syncTemplateCosts()}
>
同步模板发单金额
</Button>
) : null}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建版本
</Button>
</Space>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="成本版本 = 给成本项定价:一条版本只表达「多少钱 + 何时生效 + 对谁适用」"
description="接单平台按模板逐条建规则级版本;同一适用范围只保留一个生效版本,改价请新增版本。全局 0 元版本只是「按工单实际结算」的系统占位。"
/>
<Space style={{ marginBottom: 16 }} wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="成本项 / 商品"
value={keyword}
onChange={(event) => {
setKeyword(event.target.value)
setPage(1)
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="状态"
value={status || undefined}
onChange={(value) => {
setStatus(value || '')
setPage(1)
}}
options={[
{ value: 'active', label: '生效' },
{ value: 'draft', label: '草稿' },
{ value: 'retired', label: '停用' },
]}
style={{ width: 140 }}
/>
</Space>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={columns}
dataSource={versions}
loading={versionsQuery.isLoading}
scroll={{ x: 1400 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showTotal: (count) => `共 ${count} 个版本`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
<Modal
title="新建成本版本"
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => versionForm.submit()}
okText="保存"
cancelText="取消"
width={620}
destroyOnClose
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="一条版本 = 一个价格 + 一个生效区间 + 一个适用范围"
description="优先级:接单规则 > 标准商品 > 店铺 > 来源 > 全局。改价请新建版本,旧版本停用即可,历史订单按快照不变。"
/>
<Form form={versionForm} layout="vertical" onFinish={submit}>
<Form.Item name="costItemId" label="成本项" rules={[{ required: true }]}>
<Select showSearch options={costItemOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="scopeType" label="适用范围" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} />
</Form.Item>
{versionScopeType === 'provider' || versionScopeType === 'shop' ? (
<Form.Item
name="provider"
label="来源"
rules={[{ required: true, message: '请选择来源' }]}
>
<Select
showSearch
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
/>
</Form.Item>
) : null}
{versionScopeType === 'shop' ? (
<Form.Item
name="shopId"
label="店铺 ID"
rules={[{ required: true, message: '请输入店铺 ID' }]}
>
<Input placeholder="按店铺时必填" />
</Form.Item>
) : null}
{versionScopeType === 'product' ? (
<Form.Item
name="canonicalProductId"
label="标准商品"
rules={[{ required: true, message: '请选择标准商品' }]}
>
<Select showSearch options={productOptions} optionFilterProp="label" />
</Form.Item>
) : null}
{versionScopeType === 'rule' ? (
<Form.Item
name="workProductRuleId"
label="接单规则"
rules={[{ required: true, message: '请选择接单规则' }]}
>
<Select showSearch options={ruleOptions} optionFilterProp="label" />
</Form.Item>
) : null}
{versionPricingMode === 'percent_of_revenue' ? (
<Form.Item
name="rateBps"
label="比例(万分比,10000=100%)"
rules={[{ required: true, message: '请输入比例' }]}
>
<InputNumber min={1} max={10000} precision={0} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item
name="amountYuan"
label="固定金额(元)"
extra="按件时填单件成本;按单时填整单成本。"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item name="effectiveFrom" label="生效时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="effectiveTo" label="失效时间">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="changeReason" label="改价原因">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
)
}
/** 只有接单平台(或全部)才需要同步模板发单金额。 */
function fulfillmentFilterAllowsTemplateSync(
fulfillmentMode: 'affiliate_dash' | 'work_order' | 'manual' | undefined,
) {
return fulfillmentMode === undefined || fulfillmentMode === 'work_order'
}
@@ -0,0 +1,627 @@
/**
* 标准商品 Tab:商品目录、分类树、新建/编辑/归档/删除。
*
* 商品不再存「履约载体」;来源只由 canonical_sku 前缀表达:
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
*/
import {
DeleteOutlined,
EditOutlined,
FolderOpenOutlined,
PlusOutlined,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Form,
Input,
Modal,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
applyAdminTemplateProductBootstrap,
backfillAdminWorkOrderProductSkus,
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
previewAdminTemplateProductBootstrap,
updateAdminProductCatalog,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { useCostCenter } from './CostCenterContext'
import {
PRODUCT_SOURCE_OPTIONS,
PRODUCT_SOURCE_TAG_COLORS,
labelOf,
providerLabel,
type ProductFormValues,
} from './shared'
export function ProductCatalogPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
const [productKeyword, setProductKeyword] = useState('')
const [productRootCategoryId, setProductRootCategoryId] = useState<number | null>(null)
const [productSubCategoryId, setProductSubCategoryId] = useState<number | null>(null)
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
const [productPage, setProductPage] = useState(1)
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [productModalOpen, setProductModalOpen] = useState(false)
const [editingProduct, setEditingProduct] = useState<ProductCatalogItem | null>(null)
const productsQuery = useQuery({
queryKey: [
'admin-cost-center-products',
productSourceFilter,
productKeyword,
productRootCategoryId,
productSubCategoryId,
productPage,
productPageSize,
],
queryFn: () =>
fetchAdminProductCatalog({
keyword: productKeyword || undefined,
carrierType: productSourceFilter,
rootCategoryId: productRootCategoryId ?? undefined,
categoryId: productSubCategoryId ?? undefined,
page: productPage,
pageSize: productPageSize,
}),
})
const products = productsQuery.data?.data.items || []
const productTotal = productsQuery.data?.data.pagination.total || 0
const productCategoryTree = productsQuery.data?.data.categoryTree || []
const productUncategorizedTotal = productsQuery.data?.data.uncategorizedTotal || 0
const productCatalogTotal =
productCategoryTree.reduce((sum, root) => sum + root.total, 0) + productUncategorizedTotal
const selectedProductRoot =
productRootCategoryId && productRootCategoryId > 0
? productCategoryTree.find((root) => root.categoryId === productRootCategoryId) || null
: null
const productSubCategories = selectedProductRoot?.children || []
async function runTemplateBootstrap() {
setBootstrapPreviewing(true)
try {
const response = await previewAdminTemplateProductBootstrap()
const data = response.data
modal.confirm({
title: '从接单模板初始化商品与映射',
width: 720,
content: (
<Space direction="vertical" size={8}>
<span>
启用接单模板 <strong>{data.templateCount}</strong> 个:将新建{' '}
<strong>{data.productCreateCount}</strong> 个标准商品,复用{' '}
<strong>{data.productExistingCount}</strong> 个已有商品。
</span>
<span>
根据历史工单中已命中的模板,将建立 <strong>{data.mappingCreateCount}</strong>{' '}
条渠道映射:编码精确 <strong>{data.mappingCreateByMatchType.sku_code}</strong>{' '}
条、编码 + 名称系列 <strong>{data.mappingCreateByMatchType.sku_code_series}</strong>{' '}
条、 编码 + 精确名称 <strong>{data.mappingCreateByMatchType.sku_code_name}</strong>{' '}
条、名称系列兜底 <strong>{data.mappingCreateByMatchType.sku_name_series}</strong> 条。
</span>
<span>
预计可归一 <strong>{data.orderItemCoverCount}</strong> / {data.evidenceItemCount}{' '}
个有工单证据的订单项。
</span>
<span>
<strong>{data.templatesWithoutWorkOrders}</strong> 个模板暂无历史工单,仍会先建商品。
</span>
{data.mappingConflictCount > 0 ? (
<Alert
type="warning"
showIcon
message={`${data.mappingConflictCount} 组 SKU 即使按“编码 + 名称”仍命中多个模板,涉及 ${data.mappingConflictItemCount} 个历史订单项`}
description={
<Space direction="vertical" size={2}>
<span>
这些组合不会自动选择,也不会按最新工单覆盖;未列出的冲突可在再次预览时查看。
</span>
{data.conflicts.slice(0, 4).map((item) => (
<Typography.Text key={`${item.shopId}-${item.skuCode}-${item.skuName}`}>
{providerLabel(item.provider)} / {item.shopId || '全局'} ·{' '}
{item.skuName || item.skuCode} → {item.ruleKeys.join(' / ')}(
{item.itemCount} 单)
</Typography.Text>
))}
</Space>
}
/>
) : (
<Alert
type="success"
showIcon
message="所有可判定的历史证据都能唯一归属,没有需要人工处理的 SKU 冲突。"
/>
)}
<Typography.Text type="secondary">
数量系列(1个/2个/5个/10个)归为同一标准商品,数量写入
normalized_quantity,不会拆成多个商品,也不会重复乘倍率。
</Typography.Text>
</Space>
),
okText: '开始初始化并修复旧导入',
cancelText: '取消',
onOk: async () => {
setBootstrapApplying(true)
try {
const result = await applyAdminTemplateProductBootstrap()
const applied = result.data
const rebuildText =
applied.deletedImportedMappingCount > 0
? `,重建 ${applied.deletedImportedMappingCount} 条旧映射、重置 ${applied.resetOrderItemCount} 个订单项`
: ''
const conflictText =
applied.mappingConflictCount > 0
? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理`
: ''
message.success(
`已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '接单模板初始化失败')
throw error
} finally {
setBootstrapApplying(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取接单模板初始化预览失败')
} finally {
setBootstrapPreviewing(false)
}
}
async function backfillWorkOrderProducts() {
setBackfillingWorkOrderSkus(true)
try {
const response = await backfillAdminWorkOrderProductSkus()
const result = response.data
const factsText = result.factsRebuild
? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实`
: ''
const remainingText =
result.remainingCount > 0
? `;仍有 ${result.remainingCount} 个订单项没有可用的工单模板`
: ''
const tailText =
result.tailUpdatedCount > 0
? `,其中长尾商品 ${result.tailUpdatedCount} 个(新建商品 ${result.tailProductCount}、映射 ${result.tailMappingCount})`
: ''
message.success(
`已按工单模板回填 ${result.updatedCount} 个订单项${tailText},${result.skippedCount} 个已一致${factsText}${remainingText}`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '回填接单平台商品失败')
} finally {
setBackfillingWorkOrderSkus(false)
}
}
function openCreateProduct() {
setEditingProduct(null)
productForm.resetFields()
productForm.setFieldsValue({
unitName: '件',
status: 'active',
})
setProductModalOpen(true)
}
function openEditProduct(item: ProductCatalogItem) {
setEditingProduct(item)
productForm.setFieldsValue({
canonicalSku: item.canonicalSku,
canonicalName: item.canonicalName,
category: item.category,
unitName: item.unitName,
status: item.status,
})
setProductModalOpen(true)
}
async function submitProduct(values: ProductFormValues) {
try {
if (editingProduct) {
await updateAdminProductCatalog(editingProduct.id, {
canonicalName: values.canonicalName,
category: values.category || '',
unitName: values.unitName || '件',
status: values.status || 'active',
})
message.success('标准商品已更新')
} else {
await createAdminProductCatalog(values)
message.success('标准商品已创建')
}
setProductModalOpen(false)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存标准商品失败')
}
}
async function toggleProductStatus(row: ProductCatalogItem, checked: boolean) {
if (!canConfig) return
setProductStatusUpdatingId(row.id)
try {
await updateAdminProductCatalog(row.id, { status: checked ? 'active' : 'archived' })
message.success(checked ? '标准商品已启用' : '标准商品已归档')
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '更新商品状态失败')
} finally {
setProductStatusUpdatingId(null)
}
}
function deleteProduct(item: ProductCatalogItem) {
const affiliateHint =
item.source === 'affiliate_dash'
? '这是自动发货 SKU,删除后再次同步会自动生成;只想隐藏它请改为归档。'
: ''
modal.confirm({
title: '删除标准商品',
content: `确认删除「${item.canonicalName}(${item.canonicalSku})」?不可恢复;存在渠道映射、成本版本、成本快照或订单引用时会被拒绝,请改用归档。${affiliateHint}`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteAdminProductCatalog(item.id)
message.success('标准商品已删除')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除标准商品失败')
}
},
})
}
const productColumns: TableColumnsType<ProductCatalogItem> = [
{
title: '商品 / 分类',
dataIndex: 'canonicalName',
width: 460,
ellipsis: true,
render: (value: string, row) => (
<div style={{ minWidth: 0 }}>
<Typography.Text
strong
ellipsis={{ tooltip: value }}
style={{ display: 'block', fontSize: 14 }}
>
{value}
</Typography.Text>
<Space size={6} wrap style={{ marginTop: 3 }}>
<Tag
color="blue"
style={{
marginInlineEnd: 0,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: 11,
}}
>
{row.canonicalSku}
</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rootCategoryId
? `${row.rootCategoryName}${row.subCategoryName ? ` / ${row.subCategoryName}` : ''}`
: '未分类'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
单位:{row.unitName || '件'}
</Typography.Text>
</Space>
</div>
),
},
{
title: '来源',
dataIndex: 'source',
width: 110,
render: (value: string) =>
value ? (
<Tag color={PRODUCT_SOURCE_TAG_COLORS[value as never] || 'default'}>
{labelOf(PRODUCT_SOURCE_OPTIONS, value)}
</Tag>
) : (
<Tag>未分类</Tag>
),
},
{
title: '已绑定渠道',
dataIndex: 'mappingCount',
width: 130,
render: (value: number) =>
value > 0 ? (
<Tag color="green">已匹配 {value} 条</Tag>
) : (
<Tag style={{ color: '#94a3b8' }}>未绑定</Tag>
),
},
{
title: '成本版本',
dataIndex: 'costVersionCount',
width: 110,
render: (value: number, row) =>
value > 0 ? (
<Typography.Text type="success">{value} 个版本</Typography.Text>
) : row.source === 'work_order' ? (
<Tag color="blue">按工单实际结算</Tag>
) : (
<Typography.Text type="secondary">未配置</Typography.Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value: ProductCatalogItem['status'], row) => (
<Space size={6}>
<Switch
size="small"
checked={value === 'active'}
loading={productStatusUpdatingId === row.id}
disabled={
!canConfig || (productStatusUpdatingId !== null && productStatusUpdatingId !== row.id)
}
onChange={(checked) => void toggleProductStatus(row, checked)}
/>
<Typography.Text
type={value === 'active' ? 'success' : 'secondary'}
style={{ fontSize: 12 }}
>
{value === 'active' ? '启用' : '归档'}
</Typography.Text>
</Space>
),
},
{
title: '操作',
width: 100,
fixed: 'right',
render: (_, row) =>
canConfig ? (
<Space size={2}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
title="编辑标准商品"
aria-label="编辑标准商品"
onClick={() => openEditProduct(row)}
/>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
title="删除标准商品"
aria-label="删除标准商品"
onClick={() => deleteProduct(row)}
/>
</Space>
) : null,
},
]
return (
<>
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>标准商品目录</Typography.Text>
<Tag color="blue">{productTotal} 个商品</Tag>
</Space>
}
extra={
canConfig ? (
<Space size={6} wrap>
<Button
icon={<SyncOutlined />}
loading={bootstrapPreviewing || bootstrapApplying}
onClick={() => void runTemplateBootstrap()}
>
从接单模板初始化
</Button>
<Button
icon={<SyncOutlined />}
loading={backfillingWorkOrderSkus}
onClick={() => void backfillWorkOrderProducts()}
>
按工单回填商品
</Button>
<Button
icon={<ReloadOutlined />}
loading={productsQuery.isFetching}
onClick={() => void productsQuery.refetch()}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
新建商品
</Button>
</Space>
) : null
}
>
<div className="product-rules-category-tabs-bar">
<Tabs
activeKey={
productRootCategoryId === null
? 'all'
: productRootCategoryId === -1
? 'uncategorized'
: String(productRootCategoryId)
}
size="small"
onChange={(key) => {
if (key === 'all') {
setProductRootCategoryId(null)
setProductSubCategoryId(null)
} else if (key === 'uncategorized') {
setProductRootCategoryId(-1)
setProductSubCategoryId(null)
} else {
setProductRootCategoryId(Number(key))
setProductSubCategoryId(null)
}
setProductPage(1)
}}
items={[
{ key: 'all', label: `全部 (${productCatalogTotal})` },
...productCategoryTree.map((root) => ({
key: String(root.categoryId),
label: `${root.name} (${root.total})`,
})),
...(productUncategorizedTotal > 0
? [{ key: 'uncategorized', label: `未分类 (${productUncategorizedTotal})` }]
: []),
]}
/>
</div>
{selectedProductRoot && productSubCategories.length > 0 ? (
<div className="product-rules-sub-category-bar">
<div className="sub-category-bar-label">
<FolderOpenOutlined style={{ color: '#1677ff', fontSize: 13 }} />
<span>子分类:</span>
</div>
<div className="sub-category-pills">
<button
type="button"
className={`sub-category-pill ${productSubCategoryId === null ? 'active' : ''}`}
onClick={() => {
setProductSubCategoryId(null)
setProductPage(1)
}}
>
全部 ({selectedProductRoot.total})
</button>
{productSubCategories.map((child) => (
<button
key={child.categoryId}
type="button"
className={`sub-category-pill ${productSubCategoryId === child.categoryId ? 'active' : ''}`}
onClick={() => {
setProductSubCategoryId(child.categoryId)
setProductPage(1)
}}
>
{child.name}
<span className="pill-count">{child.total}</span>
</button>
))}
</div>
</div>
) : null}
<Space style={{ marginBottom: 16 }} wrap>
<Input
allowClear
prefix={<FolderOpenOutlined />}
placeholder="商品名称 / SKU / 分类"
value={productKeyword}
onChange={(event) => {
setProductKeyword(event.target.value)
setProductPage(1)
}}
style={{ width: 280 }}
/>
</Space>
<Table
tableLayout="fixed"
rowKey="id"
size="small"
columns={productColumns}
dataSource={products}
loading={productsQuery.isLoading}
rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')}
scroll={{ x: 1180 }}
pagination={buildAdminTablePagination({
current: productPage,
pageSize: productPageSize,
total: productTotal,
showTotal: (total) => `共 ${total} 个商品`,
onChange: (page, pageSize) => {
setProductPage(page)
setProductPageSize(pageSize)
},
})}
/>
</Card>
<Modal
title={editingProduct ? '编辑标准商品' : '新建标准商品'}
open={productModalOpen}
onCancel={() => setProductModalOpen(false)}
onOk={() => productForm.submit()}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={productForm} layout="vertical" onFinish={submitProduct}>
<Form.Item
name="canonicalSku"
label="标准 SKU"
rules={[{ required: true, message: '请输入标准 SKU' }]}
extra="来源由前缀决定:aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。"
>
<Input placeholder="例如:tpl:bb 或 aff:SKU-1" disabled={Boolean(editingProduct)} />
</Form.Item>
<Form.Item
name="canonicalName"
label="商品名称"
rules={[{ required: true, message: '请输入商品名称' }]}
>
<Input placeholder="例如:指挥官密钥" />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="例如:皮肤 / 礼包 / 密钥" />
</Form.Item>
<Form.Item name="unitName" label="单位">
<Input placeholder="件 / 个 / 颗" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'archived', label: '归档' },
]}
/>
</Form.Item>
</Form>
</Modal>
</>
)
}
@@ -0,0 +1,202 @@
/**
* 成本中心前端共享层:类型、常量、标签与页面上下文。
*
* 口径(2026-10-23 重写):
* 1. 履约方式唯一真源是 order_items.fulfillment_mode,前端只读不改;
* 2. 商品来源由 canonical_sku 前缀推导,前端不再存「商品载体」字段;
* 3. 成本项用 cost_model 表达「成本怎么算」,没有「适用载体」这一列。
*/
import type { Dayjs } from 'dayjs'
import type {
ChannelProductMapping,
CostItem,
CostModel,
CostVersion,
ProductSource,
} from '@/types/admin'
/** 顶部来源切换:全部 / 自动发货 / 接单平台 / 人工。 */
export type CostScope = 'all' | 'affiliate_dash' | 'work_order' | 'manual'
export type CostItemPreset = {
key: string
label: string
name: string
costModel: CostModel
pricingMode: CostItem['pricingMode']
unitName: string
}
export type ProductFormValues = {
canonicalSku: string
canonicalName: string
category?: string
unitName?: string
status?: 'active' | 'archived'
}
export type MappingFormValues = {
provider: string
shopId?: string
platformSkuCode?: string
platformSkuName?: string
matchType?: ChannelProductMapping['matchType']
canonicalProductId: number
quantityMultiplier?: number
confidence?: 'exact' | 'rule' | 'manual'
source?: 'manual' | 'import' | 'rule'
enabled?: boolean
note?: string
}
export type CostItemFormValues = {
name: string
costModel: CostModel
pricingMode: CostItem['pricingMode']
unitName?: string
status?: 'active' | 'archived'
note?: string
}
export type CostVersionFormValues = {
costItemId: number
scopeType: CostVersion['scopeType']
provider?: string
shopId?: string
canonicalProductId?: number
workProductRuleId?: number
amountYuan?: number
rateBps?: number
effectiveFrom?: Dayjs
effectiveTo?: Dayjs
status?: 'draft' | 'active'
changeReason?: string
}
/** 成本模型:只表达「这笔成本怎么算」。 */
export const COST_MODEL_OPTIONS = [
{ value: 'affiliate_purchase', label: '上游采购成本' },
{ value: 'work_order_reward', label: '接单平台打手报酬' },
{ value: 'manual_actual', label: '人工客服实际支出' },
{ value: 'platform_fee', label: '平台服务费' },
{ value: 'payment_fee', label: '支付通道费' },
{ value: 'after_sales_loss', label: '售后损失' },
{ value: 'other', label: '其他' },
]
/** 商品来源:由 canonical_sku 前缀推导。 */
export const PRODUCT_SOURCE_OPTIONS = [
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'work_order', label: '接单平台' },
{ value: 'manual', label: '人工客服' },
]
export const PRODUCT_SOURCE_TAG_COLORS: Record<ProductSource, string> = {
affiliate_dash: 'green',
work_order: 'blue',
manual: 'orange',
'': 'default',
}
export const PRICING_OPTIONS = [
{ value: 'per_item', label: '按件' },
{ value: 'per_order', label: '按单' },
{ value: 'percent_of_revenue', label: '按收入比例' },
]
export const SCOPE_OPTIONS = [
{ value: 'global', label: '全局' },
{ value: 'provider', label: '按来源' },
{ value: 'shop', label: '按店铺' },
{ value: 'product', label: '按标准商品' },
{ value: 'rule', label: '按接单规则' },
]
export const PROVIDER_LABELS: Record<string, string> = {
'91kaquan': '91 卡券(快手)',
pdd: '拼多多',
douyin_mashida: '抖音码速达',
}
export const MAPPING_MATCH_TYPE_OPTIONS: Array<{
value: ChannelProductMapping['matchType']
label: string
}> = [
{ value: 'sku_code', label: 'SKU 编码(唯一)' },
{ value: 'sku_code_name', label: 'SKU 编码 + 精确名称' },
{ value: 'sku_code_series', label: 'SKU 编码 + 名称系列' },
{ value: 'sku_name_series', label: 'SKU 名称系列兜底' },
]
/** 常用成本项模板:避免每次手填编码/模型/计价方式。 */
export const COST_ITEM_PRESETS: CostItemPreset[] = [
{
key: 'affiliate_purchase',
label: '上游采购成本',
name: '自动发货上游采购成本',
costModel: 'affiliate_purchase',
pricingMode: 'per_item',
unitName: '件',
},
{
key: 'work_order_reward',
label: '接单平台打手报酬',
name: '接单平台打手报酬',
costModel: 'work_order_reward',
pricingMode: 'per_order',
unitName: '单',
},
{
key: 'manual_actual',
label: '人工客服实际支出',
name: '人工客服履约成本',
costModel: 'manual_actual',
pricingMode: 'per_order',
unitName: '单',
},
{
key: 'platform_fee',
label: '平台服务费',
name: '平台服务费',
costModel: 'platform_fee',
pricingMode: 'percent_of_revenue',
unitName: '单',
},
{
key: 'payment_fee',
label: '支付通道费',
name: '支付通道费',
costModel: 'payment_fee',
pricingMode: 'percent_of_revenue',
unitName: '单',
},
{
key: 'after_sales_loss',
label: '售后损失',
name: '售后损失',
costModel: 'after_sales_loss',
pricingMode: 'per_order',
unitName: '单',
},
]
export function labelOf(options: Array<{ value: string; label: string }>, value: string) {
return options.find((item) => item.value === value)?.label || value || '-'
}
export function mappingMatchTypeLabel(value: ChannelProductMapping['matchType']) {
return MAPPING_MATCH_TYPE_OPTIONS.find((item) => item.value === value)?.label || value || '-'
}
export function providerLabel(value: string) {
return PROVIDER_LABELS[value] || value || '-'
}
export function formatRate(rateBps: number) {
return `${((Number(rateBps || 0) || 0) / 100).toFixed(2)}%`
}
export function costModelLabel(value: string) {
return labelOf(COST_MODEL_OPTIONS, value)
}