refactor(cost-center): 精简成本中心,人工成本以 SKU 配置为唯一真源

- 删除「成本项目」「成本版本」「成本预览」三个 Tab:成本项只有 3 条系统内置,版本金额只是快照挂载点
- 新增只读「发单价格」面板,仅在接单平台来源显示,改价仍走接单模板后同步
- 成本预览改为弹窗,从成本异常行和订单详情「查成本」进入;人工单显示 SKU 配置成本并禁止生成快照
- 成本异常新增按原因聚合摘要接口,列表支持 reason/since 筛选,默认只看近 24 小时
- 迁移 111:把 sys_manual_service 的历史 ¥88 全局版本清零;预估同步与成本缺失判定排除 manual_actual
This commit is contained in:
yml2213
2026-10-24 16:07:25 +08:00
parent 9f2206430f
commit 956ef5d695
16 changed files with 904 additions and 1124 deletions
@@ -6,10 +6,10 @@
* - ProductCatalogPanel.tsx 标准商品
* - ChannelMappingPanel.tsx 渠道映射
* - AffiliateSkuPanel.tsx 自动发货 SKU
* - CostItemPanel.tsx 成本项
* - CostVersionPanel.tsx 成本版本
* - CostExceptionPanel.tsx 成本异常
* - CostPreviewPanel.tsx 成本预览
* - ManualSkuConfigPanel.tsx 人工 SKU 配置(消耗积分即人工成本)
* - WorkOrderPricePanel.tsx 发单价格(只读核对接单模板发单金额)
* - CostExceptionPanel.tsx 成本异常(按原因聚合 + 近 24 小时默认)
* - CostPreviewModal.tsx 成本解析弹窗(异常行 / 订单详情进入)
*/
import CostCenterShell from './cost-center/CostCenterShell'
@@ -34,6 +34,8 @@ import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display'
import { CostPreviewModal } from './cost-center/CostPreviewModal'
export default function AdminOrderDetailPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -42,6 +44,7 @@ export default function AdminOrderDetailPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [manualCostOpen, setManualCostOpen] = useState(false)
const [costPreviewItemId, setCostPreviewItemId] = useState<number | null>(null)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
@@ -349,6 +352,15 @@ export default function AdminOrderDetailPage() {
},
{ title: '数量', dataIndex: 'quantity', width: 90 },
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
{
title: '成本',
width: 110,
render: (_, item) => (
<Button size="small" onClick={() => setCostPreviewItemId(item.orderItemId)}>
查成本
</Button>
),
},
]}
/>
</Card>
@@ -408,6 +420,12 @@ export default function AdminOrderDetailPage() {
}))}
onClose={closeManualCost}
/>
<CostPreviewModal
open={costPreviewItemId !== null}
orderItemId={costPreviewItemId}
onClose={() => setCostPreviewItemId(null)}
/>
</section>
)
}
@@ -17,11 +17,9 @@ 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 { ManualSkuConfigPanel } from './ManualSkuConfigPanel'
import { ProductCatalogPanel } from './ProductCatalogPanel'
import { WorkOrderPricePanel } from './WorkOrderPricePanel'
import { CostCenterContext } from './CostCenterContext'
import type { CostScope } from './shared'
@@ -58,12 +56,10 @@ const SCOPE_TAG_COLORS: Record<CostScope, string> = {
function tabKeysForScope(scope: CostScope) {
return [
...(scope === 'all' || scope === 'work_order' ? ['products', 'mappings'] : []),
...(scope === 'affiliate_dash' ? ['affiliate-skus'] : []),
...(scope === 'all' || scope === 'affiliate_dash' ? ['affiliate-skus'] : []),
...(scope === 'all' || scope === 'manual' ? ['manual-skus'] : []),
'cost-items',
'cost-versions',
...(scope === 'all' || scope === 'work_order' ? ['prices'] : []),
'exceptions',
'preview',
]
}
@@ -93,7 +89,7 @@ export default function CostCenterShell() {
useEffect(() => {
if (!visibleTabKeys.includes(activeTab)) {
setActiveTab(visibleTabKeys[0] ?? 'cost-items')
setActiveTab(visibleTabKeys[0] ?? 'exceptions')
}
}, [activeTab, setActiveTab, visibleTabKeys])
@@ -102,9 +98,9 @@ export default function CostCenterShell() {
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'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions-summary'] })
}
const scopeLabel =
@@ -139,7 +135,7 @@ export default function CostCenterShell() {
<section className="page-stack">
<PageHeader
title="商品与成本中心"
description="先按来源(自动发货 / 接单平台 / 人工)分区,再把商品 SKU 归一、成本金额收口到版本;历史成本靠快照固化。"
description="商品 SKU 先归一;成本在各来源自己的配置里维护(人工 SKU 消耗积分 / 自动发货采购价 / 接单模板发单金额),历史成本靠快照固化。"
extra={
<Button
icon={<ReloadOutlined />}
@@ -155,8 +151,8 @@ export default function CostCenterShell() {
{[
['标准商品', overview?.productCount || 0],
['渠道映射', overview?.mappingCount || 0],
['成本项目', overview?.costItemCount || 0],
['生效版本', overview?.activeVersionCount || 0],
['系统成本项', overview?.costItemCount || 0],
['生效发单价格', overview?.activeVersionCount || 0],
['待处理异常', overview?.openExceptionCount || 0],
['未映射订单项', overview?.unmappedOrderItemCount || 0],
].map(([label, value]) => (
@@ -179,7 +175,7 @@ export default function CostCenterShell() {
? 'products'
: next === 'affiliate_dash'
? 'affiliate-skus'
: 'cost-items',
: 'manual-skus',
)
}}
items={SCOPES.map((item) => {
@@ -187,16 +183,12 @@ export default function CostCenterShell() {
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>
<Tag color={SCOPE_TAG_COLORS[item.key]}>{Number(productCount || 0)} 商品</Tag>
</span>
),
children: <Typography.Text type="secondary">{item.hint}</Typography.Text>,
@@ -210,7 +202,7 @@ export default function CostCenterShell() {
type="info"
showIcon
message="当前账号为只读权限"
description="可以查看商品、映射、成本版本和异常;新增/修改需要 finance.config 权限。"
description="可以查看商品、映射、发单价格和成本异常;新增/修改需要 finance.config 权限。"
/>
) : null}
@@ -239,25 +231,24 @@ export default function CostCenterShell() {
children: <ManualSkuConfigPanel />,
},
{
key: 'cost-items',
label: '成本项目',
children: <CostItemPanel />,
},
{
key: 'cost-versions',
label: '成本版本',
children: <CostVersionPanel />,
key: 'prices',
label: '发单价格',
children: <WorkOrderPricePanel />,
},
{
key: 'exceptions',
label: '成本异常',
label: (
<span>
成本异常
{Number(overview?.openExceptionCount || 0) > 0 ? (
<Tag color="red" style={{ marginInlineStart: 6 }}>
{Number(overview?.openExceptionCount || 0)}
</Tag>
) : null}
</span>
),
children: <CostExceptionPanel />,
},
{
key: 'preview',
label: '成本预览',
children: <CostPreviewPanel />,
},
].filter((item) => visibleTabKeys.includes(item.key))}
/>
</section>
@@ -1,21 +1,27 @@
/**
* 成本异常 Tab:成本缺失 / 冲突队列。
*
* 异常表不再存履约载体,履约方式从 order_items.fulfillment_mode join 得到;
* 前端按履约方式筛选时传 fulfillmentMode 参数。
* 历史批量扫描会一次性写入十几万条开放异常,直接全量列表没有可操作性:
* 页面先按原因聚合,默认只看近 24 小时,历史存量需要显式切换时间范围。
* 每行可直接打开「成本解析」定位是哪个 SKU 没归一、哪条成本没命中。
*/
import { App, Button, Card, Select, Space, Tag, Typography } from 'antd'
import { App, Button, Card, Col, Row, Select, Space, Statistic, Tag, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { fetchAdminCostExceptions, resolveAdminCostException } from '@/services/admin'
import {
fetchAdminCostExceptionSummary,
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'
import { CostPreviewModal } from './CostPreviewModal'
const FULFILLMENT_MODE_TAG_COLORS: Record<string, string> = {
work_order: 'blue',
@@ -31,20 +37,63 @@ const FULFILLMENT_MODE_LABELS: Record<string, string> = {
manual: '人工客服',
}
const REASON_LABELS: Record<string, string> = {
product_mapping_missing: '商品未归一',
cost_missing: '成本缺失',
}
const RANGE_OPTIONS = [
{ value: 1, label: '近 24 小时' },
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 0, label: '全部历史' },
]
export function CostExceptionPanel() {
const { message, modal } = App.useApp()
const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter()
const [status, setStatus] = useState('open')
const [rangeDays, setRangeDays] = useState(1)
const [reason, setReason] = useState<string | undefined>(undefined)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [previewItemId, setPreviewItemId] = useState<number | null>(null)
// 时间下界在取数时计算,避免渲染期调用 Date.now(React 纯度规则)。
const summaryQuery = useQuery({
queryKey: [
'admin-cost-center-exceptions-summary',
fulfillmentModeFilter,
rangeDays <= 0 ? 1 : rangeDays,
],
queryFn: () =>
fetchAdminCostExceptionSummary({
fulfillmentMode: fulfillmentModeFilter,
recentDays: rangeDays <= 0 ? 1 : rangeDays,
}),
})
const summary = summaryQuery.data?.data
const exceptionsQuery = useQuery({
queryKey: ['admin-cost-center-exceptions', fulfillmentModeFilter, status, page, pageSize],
queryKey: [
'admin-cost-center-exceptions',
fulfillmentModeFilter,
status,
rangeDays,
reason,
page,
pageSize,
],
queryFn: () =>
fetchAdminCostExceptions({
status: status || undefined,
fulfillmentMode: fulfillmentModeFilter,
reason,
since:
rangeDays > 0
? new Date(Date.now() - rangeDays * 24 * 60 * 60 * 1000).toISOString()
: undefined,
page,
pageSize,
}),
@@ -56,7 +105,7 @@ export function CostExceptionPanel() {
function resolve(item: CostException, nextStatus: 'resolved' | 'ignored') {
modal.confirm({
title: nextStatus === 'resolved' ? '标记异常已处理' : '忽略异常',
content: `确认处理「${item.reason}」?`,
content: `确认处理「${REASON_LABELS[item.reason] || item.reason}」?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
@@ -72,17 +121,29 @@ export function CostExceptionPanel() {
}
const columns: TableColumnsType<CostException> = [
{ title: '主体', dataIndex: 'subjectType', width: 110 },
{
title: '原因',
dataIndex: 'reason',
width: 300,
render: (value: string, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong>{REASON_LABELS[value] || value}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{String(row.detail?.message || '')}
</Typography.Text>
</Space>
),
},
{
title: '订单项',
dataIndex: 'orderItemId',
width: 100,
render: (value) => (value ? `#${value}` : '-'),
width: 110,
render: (value: number | null) => (value ? `#${value}` : '-'),
},
{
title: '履约方式',
dataIndex: 'fulfillmentMode',
width: 110,
width: 120,
render: (value: FulfillmentMode | '') =>
value ? (
<Tag color={FULFILLMENT_MODE_TAG_COLORS[value] || 'default'}>
@@ -92,21 +153,10 @@ export function CostExceptionPanel() {
<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,
width: 100,
render: (value: string) => (
<Tag color={value === 'open' ? 'red' : value === 'resolved' ? 'green' : 'default'}>
{value === 'open' ? '待处理' : value === 'resolved' ? '已处理' : '已忽略'}
@@ -121,18 +171,28 @@ export function CostExceptionPanel() {
},
{
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,
width: 230,
render: (_, row) => (
<Space wrap>
<Button
size="small"
disabled={!row.orderItemId}
onClick={() => setPreviewItemId(row.orderItemId)}
>
查成本
</Button>
{canConfig && row.status === 'open' ? (
<>
<Button size="small" type="primary" onClick={() => resolve(row, 'resolved')}>
已处理
</Button>
<Button size="small" onClick={() => resolve(row, 'ignored')}>
忽略
</Button>
</>
) : null}
</Space>
),
},
]
@@ -140,27 +200,94 @@ export function CostExceptionPanel() {
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>成本缺失 / 冲突</Typography.Text>
<Tag color="blue">{total} 条</Tag>
<Typography.Text strong>成本异常</Typography.Text>
<Tag color="red">待处理 {Number(summary?.openTotal ?? 0)}</Tag>
<Tag>
近 {rangeDays <= 0 ? 1 : rangeDays} 天新增 {Number(summary?.recentOpenTotal ?? 0)}
</Tag>
{Number(summary?.historicalOpenTotal ?? 0) > 0 ? (
<Tag color="default">历史存量 {Number(summary?.historicalOpenTotal ?? 0)}</Tag>
) : null}
</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 }}
/>
<Space wrap>
<Select
value={rangeDays}
onChange={(value) => {
setRangeDays(value)
setPage(1)
}}
options={RANGE_OPTIONS}
style={{ width: 130 }}
/>
<Select
value={status}
onChange={(value) => {
setStatus(value)
setPage(1)
}}
options={[
{ value: 'open', label: '待处理' },
{ value: 'resolved', label: '已处理' },
{ value: 'ignored', label: '已忽略' },
{ value: '', label: '全部状态' },
]}
style={{ width: 130 }}
/>
</Space>
}
>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
{(summary?.reasons || []).map((item) => (
<Col xs={24} md={12} xl={8} key={item.reason}>
<Card
size="small"
hoverable
onClick={() => {
setReason(reason === item.reason ? undefined : item.reason)
setPage(1)
}}
style={{
borderColor: reason === item.reason ? '#1677ff' : undefined,
cursor: 'pointer',
}}
>
<Statistic
title={REASON_LABELS[item.reason] || item.reason}
value={item.openCount}
suffix={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
待处理
</Typography.Text>
}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
近 {rangeDays <= 0 ? 1 : rangeDays} 天新增 {item.recentOpenCount}
</Typography.Text>
</Card>
</Col>
))}
</Row>
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text type="secondary">
默认只看近 24 小时;历史批量扫描(商品未归一 / 成本缺失)请切换到「全部历史」。
</Typography.Text>
{reason ? (
<Tag
closable
color="blue"
onClose={() => {
setReason(undefined)
setPage(1)
}}
>
{REASON_LABELS[reason] || reason}
</Tag>
) : null}
</Space>
<PercentTable
tableLayout="fixed"
rowKey="id"
@@ -168,7 +295,7 @@ export function CostExceptionPanel() {
columns={columns}
dataSource={exceptions}
loading={exceptionsQuery.isLoading}
scroll={{ x: 1100 }}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
@@ -180,6 +307,12 @@ export function CostExceptionPanel() {
},
})}
/>
<CostPreviewModal
open={previewItemId !== null}
orderItemId={previewItemId}
onClose={() => setPreviewItemId(null)}
/>
</Card>
)
}
@@ -1,320 +0,0 @@
/**
* 成本项 Tab:有哪些成本。
*
* 成本项 = 成本模型 + 计价方式;不再有「适用载体」列。
* 专属成本模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效,
* 通用成本模型(平台费 / 支付费 / 售后损失 / 其他)对所有履约方式生效。
*/
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import { Alert, App, Button, Card, Form, Input, Modal, Select, Space, Tag, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
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>
<PercentTable
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,212 @@
/**
* 成本解析弹窗:按订单项查看「映射到的商品 + 命中的成本版本 + 异常」。
*
* 从「成本异常」行和「订单详情 - 商品明细」进入;
* 人工履约不回版本金额,而是回人工 SKU 配置的消耗积分与换算率。
*/
import { App, Alert, Button, Descriptions, Empty, Modal, Space, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { createAdminCostSnapshot, previewAdminCost } from '@/services/admin'
import type { CostPreview } from '@/types/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatMoney } from '../panels/shared'
import { costModelLabel, providerLabel } from './shared'
const FULFILLMENT_MODE_LABELS: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
const PLATFORM_LABELS: Record<string, string> = {
nanhang: '南航积分',
lewan: '乐玩积分',
rock: 'ROCK 积分',
fund_cash: '备用金现金',
}
export type CostPreviewModalProps = {
open: boolean
orderItemId: number | null
onClose: () => void
}
export function CostPreviewModal({ open, orderItemId, onClose }: CostPreviewModalProps) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const canConfig = hasAdminPermission('finance.config')
const [snapshotting, setSnapshotting] = useState(false)
const previewQuery = useQuery({
queryKey: ['admin-cost-preview', orderItemId],
queryFn: () => previewAdminCost({ orderItemId }),
enabled: open && Boolean(orderItemId),
retry: false,
})
const preview = (previewQuery.data?.data || null) as CostPreview | null
const manualCost = preview?.manualCost || null
async function createSnapshot() {
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} 条异常` : ',已有快照保持不变'}`,
)
await queryClient.invalidateQueries({ queryKey: ['admin-cost-preview', orderItemId] })
} catch (error) {
message.error(error instanceof Error ? error.message : '生成成本快照失败')
} finally {
setSnapshotting(false)
}
}
return (
<Modal
open={open}
onCancel={onClose}
title="成本解析"
width={920}
footer={
<Space>
<Button onClick={onClose}>关闭</Button>
{canConfig && !manualCost ? (
<Button
type="primary"
loading={snapshotting}
disabled={
!preview ||
preview.exceptions.some((item) => item.reason === 'product_mapping_missing')
}
onClick={() => void createSnapshot()}
>
生成成本快照
</Button>
) : null}
</Space>
}
>
{!orderItemId ? (
<Empty description="缺少订单项 ID" />
) : previewQuery.isLoading ? (
<Empty description="正在解析成本…" />
) : previewQuery.error ? (
<Alert
type="error"
showIcon
message={
previewQuery.error instanceof Error ? previewQuery.error.message : '成本解析失败'
}
/>
) : !preview ? (
<Empty description="没有可展示的成本解析结果" />
) : (
<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="履约方式">
{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>
{manualCost ? (
<Alert
type="info"
showIcon
message={
manualCost.costMode === 'fixed' && manualCost.costFen !== null
? `人工成本 ${formatMoney(manualCost.costFen)}(${PLATFORM_LABELS[manualCost.platformKey] || manualCost.platformKey} ${manualCost.creditQuantity} 积分)`
: `人工成本不固定(${PLATFORM_LABELS[manualCost.platformKey] || manualCost.platformKey})`
}
description={
manualCost.costMode === 'fixed'
? `金额 = 人工 SKU 配置的消耗积分 ${manualCost.creditQuantity} × 换算率 ${formatMoney(manualCost.unitPriceFen)},在客服「确认履约」时扣减;成本版本的 0 元占位不参与计算,也不会生成预估快照。`
: '该 SKU 未配置固定消耗积分,客服确认履约时需要手填本次实际成本;成本版本不参与计算。'
}
/>
) : null}
{preview.exceptions.length > 0 ? (
<Alert
type="warning"
showIcon
message="成本异常"
description={preview.exceptions.map((item) => item.message).join(';')}
/>
) : null}
{preview.lines.length === 0 ? (
<Empty
description={
manualCost ? '人工成本按 SKU 配置结算,无版本成本行' : '没有命中成本版本'
}
/>
) : (
<PercentTable
tableLayout="fixed"
rowKey={(row: CostPreview['lines'][number]) =>
`${row.costItemId}-${row.costVersionId}`
}
size="small"
pagination={false}
dataSource={preview.lines}
columns={[
{ title: '成本项', dataIndex: 'costItemName', width: 220 },
{
title: '成本模型',
dataIndex: 'costModel',
width: 150,
render: (value: string) => costModelLabel(value),
},
{ title: '范围', dataIndex: 'scopeType', width: 150 },
{
title: '数量',
dataIndex: 'quantity',
width: 100,
render: (value: number) => Number(value || 0).toFixed(3),
},
{
title: '单位成本',
dataIndex: 'unitAmountFen',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '合计',
dataIndex: 'totalAmountFen',
width: 120,
render: (value: number) => formatMoney(value),
},
]}
/>
)}
</Space>
)}
</Modal>
)
}
@@ -1,182 +0,0 @@
/**
* 成本预览 Tab:按订单项预览成本解析结果。
*
* 履约方式直接读 order_items.fulfillment_mode,页面只做展示,不允许改判。
*/
import { App, Alert, Button, Card, Descriptions, Empty, InputNumber, Space, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
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}
<PercentTable
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>
)
}
@@ -1,530 +0,0 @@
/**
* 成本版本 Tab:给成本项定价。
*
* 一条版本 = 一个价格 + 一个生效区间 + 一个适用范围。
* 改价只新增版本,旧版本停用,历史订单按成本快照不变。
*/
import { PlusOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
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>
<PercentTable
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'
}
@@ -291,7 +291,7 @@ export function ManualSkuConfigPanel() {
showIcon
style={{ marginBottom: 14 }}
message="配置主键是内部 SKU,不是商品名称"
description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。客服确认履约时直接读配置,固定成本零输入。消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。消耗积分 × 平台换算率就是这单的人工成本,确认履约时直接扣减,不需要再去成本版本里配一次;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
@@ -0,0 +1,246 @@
/**
* 发单价格 Tab:只读展示「接单模板 → 发单金额」的当前生效版本。
*
* 接单平台的成本以打手报酬为主,金额由接单模板(work_product_rules)决定,
* 这里只做核对视图;改价请改模板后点「同步模板发单金额」。
* 人工与自动发货的成本分别在各自 SKU 配置里维护,不在这里重复配置。
*/
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'
import { Alert, App, Button, Card, Input, Select, Space, Tag, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { fetchAdminCostVersions, 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 { formatRate } from './shared'
export function WorkOrderPricePanel() {
const { message } = App.useApp()
const { canConfig, refreshAll } = useCostCenter()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState('active')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [syncing, setSyncing] = useState(false)
const versionsQuery = useQuery({
queryKey: ['admin-cost-center-cost-versions', 'work_order', keyword, status, page, pageSize],
queryFn: () =>
fetchAdminCostVersions({
keyword: keyword || undefined,
status: status || undefined,
fulfillmentMode: 'work_order',
page,
pageSize,
}),
})
const versions = versionsQuery.data?.data.items || []
const total = versionsQuery.data?.data.pagination.total || 0
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: 'workProductRuleName',
width: 300,
render: (_, row) => {
if (row.scopeType === 'global') {
return (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong>全局占位</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
按工单实际结算,金额取工单 reward_amount
</Typography.Text>
</Space>
)
}
const label =
row.workProductRuleName ||
(row.workProductRuleId ? `规则 #${row.workProductRuleId}` : '历史模板版本')
const sub =
row.workProductRuleKey ||
(row.workProductRuleId ? '' : row.changeReason || '模板已删除,历史版本保留')
return (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text ellipsis={{ tooltip: label }}>{label}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{sub}
</Typography.Text>
</Space>
)
},
},
{
title: '发单金额 / 比例',
width: 220,
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: '用户实付均价',
width: 190,
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: '生效时间',
dataIndex: 'effectiveFrom',
width: 200,
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: 100,
render: (value: string) => (
<Tag color={value === 'active' ? 'green' : value === 'draft' ? 'gold' : 'default'}>
{value === 'active' ? '生效' : value === 'draft' ? '草稿' : '停用'}
</Tag>
),
},
]
return (
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>发单价格(只读)</Typography.Text>
<Tag color="blue">{total} 条</Tag>
</Space>
}
extra={
canConfig ? (
<Button
icon={<SyncOutlined />}
loading={syncing}
onClick={() => void syncTemplateCosts()}
>
同步模板发单金额
</Button>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="接单平台的成本只看这张表;改价请改接单模板,再点「同步模板发单金额」"
description="人工客服的成本来自「人工 SKU 配置」里的消耗积分,自动发货的成本在「自动发货 SKU」里维护,二者都不在这里配置;系统内置成本项只有人工履约、自动发货采购、接单平台报酬 3 条。"
/>
<Space style={{ marginBottom: 16 }} wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="模板 / 商品 / 规则"
value={keyword}
onChange={(event) => {
setKeyword(event.target.value)
setPage(1)
}}
style={{ width: 260 }}
/>
<Select
value={status}
onChange={(value) => {
setStatus(value)
setPage(1)
}}
options={[
{ value: 'active', label: '生效' },
{ value: 'retired', label: '停用' },
{ value: 'draft', label: '草稿' },
{ value: '', label: '全部状态' },
]}
style={{ width: 140 }}
/>
</Space>
<PercentTable
tableLayout="fixed"
rowKey="id"
size="small"
columns={columns}
dataSource={versions}
loading={versionsQuery.isLoading}
scroll={{ x: 1120 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showTotal: (count) => `共 ${count} 条发单价格`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
)
}
@@ -6,6 +6,7 @@ import type {
CostCenterOverview,
CostException,
CostExceptionListResult,
CostExceptionSummary,
CostItem,
CostItemListResult,
CostPreview,
@@ -213,6 +214,10 @@ export function fetchAdminCostExceptions(params?: Record<string, unknown>) {
return apiGet<CostExceptionListResult>('/api/v1/admin/cost-center/exceptions', params)
}
export function fetchAdminCostExceptionSummary(params?: Record<string, unknown>) {
return apiGet<CostExceptionSummary>('/api/v1/admin/cost-center/exceptions/summary', params)
}
export function resolveAdminCostException(exceptionId: number, payload: Record<string, unknown>) {
return apiPatch<{ exception: CostException }>(
`/api/v1/admin/cost-center/exceptions/${exceptionId}`,
@@ -260,6 +260,14 @@ export interface CostPreview {
costModel: CostModel
}>
totalCostFen: number
/** 人工履约的成本真源:人工 SKU 配置的消耗积分 × 平台换算率(不来自成本版本)。 */
manualCost: {
platformKey: string
creditQuantity: number
unitPriceFen: number
costMode: 'fixed' | 'variable'
costFen: number | null
} | null
exceptions: Array<{ reason: string; message: string; detail: Record<string, unknown> }>
}
@@ -312,6 +320,14 @@ export interface CostExceptionListResult {
pagination: { page: number; pageSize: number; total: number }
}
export interface CostExceptionSummary {
recentDays: number
openTotal: number
recentOpenTotal: number
historicalOpenTotal: number
reasons: Array<{ reason: string; openCount: number; recentOpenCount: number }>
}
/**
* 人工履约渠道:客服人工有四个钱包。
* 南航/乐玩/ROCK 是积分(三平台积分互不通用),备用金是现金。