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