refactor(cost-center): 精简成本中心,人工成本以 SKU 配置为唯一真源
- 删除「成本项目」「成本版本」「成本预览」三个 Tab:成本项只有 3 条系统内置,版本金额只是快照挂载点 - 新增只读「发单价格」面板,仅在接单平台来源显示,改价仍走接单模板后同步 - 成本预览改为弹窗,从成本异常行和订单详情「查成本」进入;人工单显示 SKU 配置成本并禁止生成快照 - 成本异常新增按原因聚合摘要接口,列表支持 reason/since 筛选,默认只看近 24 小时 - 迁移 111:把 sys_manual_service 的历史 ¥88 全局版本清零;预估同步与成本缺失判定排除 manual_actual
This commit is contained in:
@@ -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. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 > 标准商品 > 店铺
|
||||
> 来源 > 全局 命中;
|
||||
</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 是积分(三平台积分互不通用),备用金是现金。
|
||||
|
||||
Reference in New Issue
Block a user