feat(cost-center): 统一商品成本中心为全系统唯一标准价格源并闭环改价与工单同步

- 核心架构:将商品与成本中心确立为全系统唯一标准价格源,接单模板价格在接单平台禁止编辑并提示收敛至成本中心,后端 saveAdminWorkProductRule 严格拦截价格篡改
- 改价与同步:标准商品目录与自动发货专属面板提供统一改价弹窗,支持追溯/立即生效/指定时间;改价后自动或手动将新价格同步至接单大厅待抢单工单,保持下游业务一致
- 缺陷修复:修复 cost_versions 追溯改价时因同生效时间违反 chk_cost_versions_period 检查约束及 uq_cost_versions_scope 唯一索引冲突的问题,采用 active 版本就地更新机制
- 分类树优化:工单二级分类树限定仅在工单来源展示,自动发货与人工客服彻底隐藏分类树并重置过滤条件
- 验证方式:通过后端 784 项全量测试、前后端 tsc 类型检查及前端生产构建打包
This commit is contained in:
yml2213
2026-10-25 15:21:34 +08:00
parent 55a3e0df9c
commit b7ad69831a
11 changed files with 1824 additions and 236 deletions
@@ -1,18 +1,43 @@
/**
* 自动发货 SKU Tab:上游 SKU 目录与订单项回填。
* 自动发货 SKU Tab:上游 SKU 目录、采购成本配置与订单项快照同步。
*
* 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:<skuCode>`,
* 不写渠道映射,也不维护游戏分类。
* 成本按上游商品配置采购单价(cost_versions),并在改价/初次配置后重算成本快照。
*/
import { ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import { Alert, App, Button, Card, Space, Switch, Tag, Typography } from 'antd'
import {
DownloadOutlined,
EditOutlined,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Radio,
Space,
Switch,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useState } from 'react'
import {
backfillAdminAffiliateDashSkus,
fetchAdminProductCatalog,
importAdminAffiliateDashPricesBaseline,
setAdminAffiliateProductPrice,
syncAdminAffiliateDashCostSnapshots,
syncAdminAffiliateDashProducts,
updateAdminProductCatalog,
} from '@/services/admin'
@@ -22,8 +47,15 @@ import { formatAdminDateTime } from '@/utils/admin-time'
import { useCostCenter } from './CostCenterContext'
interface PriceFormValues {
priceYuan: number
effectiveMode: 'retroactive' | 'now' | 'custom'
customEffectiveDate?: dayjs.Dayjs
changeReason?: string
}
export function AffiliateSkuPanel() {
const { message } = App.useApp()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const { canConfig, refreshAll } = useCostCenter()
@@ -32,6 +64,14 @@ export function AffiliateSkuPanel() {
const [statusUpdatingId, setStatusUpdatingId] = useState<number | null>(null)
const [syncing, setSyncing] = useState(false)
const [backfilling, setBackfilling] = useState(false)
const [importingBaseline, setImportingBaseline] = useState(false)
const [syncingSnapshots, setSyncingSnapshots] = useState(false)
// 价格弹窗状态
const [priceModalOpen, setPriceModalOpen] = useState(false)
const [selectedProduct, setSelectedProduct] = useState<ProductCatalogItem | null>(null)
const [priceSubmitting, setPriceSubmitting] = useState(false)
const [priceForm] = Form.useForm<PriceFormValues>()
const productsQuery = useQuery({
queryKey: ['admin-cost-center-products', 'affiliate_dash', page, pageSize],
@@ -84,6 +124,108 @@ export function AffiliateSkuPanel() {
}
}
function handleImportBaseline() {
modal.confirm({
title: '导入上游采购成本基线',
content:
'将从系统内置的 38 条上游商品价格清单导入采购成本版本(生效时间默认追溯至 2026-07-10,与历史订单匹配)。是否继续?',
okText: '确认导入',
cancelText: '取消',
onOk: async () => {
setImportingBaseline(true)
try {
const res = await importAdminAffiliateDashPricesBaseline()
message.success(
`已成功导入上游价格基线:新增商品 ${res.data.createdProductCount} 个,新增成本版本 ${res.data.createdVersionCount} 个,更新生效时间 ${res.data.updatedVersionCount} 个`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '导入上游价格基线失败')
} finally {
setImportingBaseline(false)
}
},
})
}
function handleSyncSnapshots() {
modal.confirm({
title: '重算自动发货预估成本快照',
content:
'将为全部已归一的自动发货历史订单重新计算预估成本快照,自动消除因缺少采购价导致的 cost_missing 异常,并重算对应周期的销售事实。确定执行?',
okText: '确认重算',
cancelText: '取消',
onOk: async () => {
setSyncingSnapshots(true)
try {
const res = await syncAdminAffiliateDashCostSnapshots()
const snap = res.data.snapshotResult
message.success(
`重算完成!已生成成本快照 ${snap.createdCount} 条,已消除/解决异常 ${snap.resolvedExceptionCount} 条`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '重算成本快照失败')
} finally {
setSyncingSnapshots(false)
}
},
})
}
function openPriceModal(product: ProductCatalogItem) {
setSelectedProduct(product)
const currentYuan =
product.activeCostAmountFen !== null && product.activeCostAmountFen !== undefined
? Number((product.activeCostAmountFen / 100).toFixed(2))
: undefined
priceForm.setFieldsValue({
priceYuan: currentYuan,
effectiveMode: 'retroactive',
changeReason: currentYuan ? '上游采购成本调整' : '首次配置自动发货采购成本',
})
setPriceModalOpen(true)
}
async function submitPrice(values: PriceFormValues) {
if (!selectedProduct) return
const amountFen = Math.round(Number(values.priceYuan) * 100)
if (!amountFen || amountFen <= 0) {
message.error('请输入有效的采购单价')
return
}
let effectiveFrom: string | undefined
let retroactive = false
if (values.effectiveMode === 'retroactive') {
retroactive = true
effectiveFrom = '2026-07-10T00:00:00+08:00'
} else if (values.effectiveMode === 'now') {
effectiveFrom = new Date().toISOString()
} else if (values.effectiveMode === 'custom' && values.customEffectiveDate) {
effectiveFrom = values.customEffectiveDate.startOf('day').toISOString()
}
setPriceSubmitting(true)
try {
await setAdminAffiliateProductPrice(selectedProduct.id, {
amountFen,
effectiveFrom,
retroactive,
changeReason: values.changeReason,
})
message.success(`「${selectedProduct.canonicalName}」采购单价已设置为 ¥${values.priceYuan}`)
setPriceModalOpen(false)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '设置采购成本失败')
} finally {
setPriceSubmitting(false)
}
}
async function toggleStatus(row: ProductCatalogItem, checked: boolean) {
if (!canConfig) return
setStatusUpdatingId(row.id)
@@ -100,126 +242,275 @@ export function AffiliateSkuPanel() {
}
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()}
/>
<>
<Card
title={
<Space size={6} wrap>
<Typography.Text strong>自动发货 SKU</Typography.Text>
<Tag color="green">{total}</Tag>
</Space>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="自动发货只按上游货源 SKU 配采购成本:订单项直接按 aff:<skuCode> 归一,不走渠道映射;这里不维护游戏分类和商品上下架,那些只属于接单平台。"
/>
<PercentTable
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
}
extra={
canConfig ? (
<Space wrap>
<Button
icon={<DownloadOutlined />}
loading={importingBaseline}
onClick={handleImportBaseline}
>
导入基线价格
</Button>
<Button
icon={<SyncOutlined />}
loading={syncingSnapshots}
onClick={handleSyncSnapshots}
>
重算成本快照
</Button>
<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="自动发货订单直接按 aff:<skuCode> 归一,不走渠道映射。点击右侧「配置成本」可录入采购单价;已配置的商品可通过「重算成本快照」消除历史缺失异常。"
/>
<PercentTable
tableLayout="fixed"
rowKey="id"
size="small"
dataSource={products}
loading={productsQuery.isLoading}
scroll={{ x: 1180 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showTotal: (count) => `共 ${count} 个 SKU`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
columns={[
{
title: 'SKU 名称 / 编码',
dataIndex: 'canonicalName',
width: 300,
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: 140,
render: (_, row) => {
if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) {
return (
<Space direction="vertical" size={0}>
<Typography.Text strong style={{ color: '#cf1322', fontSize: 14 }}>
¥{(row.activeCostAmountFen / 100).toFixed(2)}
</Typography.Text>
{row.activeCostEffectiveFrom ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
生效: {String(row.activeCostEffectiveFrom).slice(0, 10)}
</Typography.Text>
) : null}
</Space>
)
}
return <Tag color="error">未配置</Tag>
},
},
{
title: '履约任务',
width: 110,
render: (_, row) => `${row.affiliateTaskCount} 单`,
},
{
title: '最近履约',
width: 170,
render: (_, row) =>
row.affiliateLastFulfilledAt
? formatAdminDateTime(row.affiliateLastFulfilledAt)
: '-',
},
{
title: '成本版本',
dataIndex: 'costVersionCount',
width: 100,
render: (value: number) =>
value > 0 ? (
<Typography.Text type="success">{value} 个版本</Typography.Text>
) : (
<Tag>未配置</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
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>
),
},
{
title: '操作',
width: 110,
fixed: 'right',
render: (_, row) => (
<Button
type="link"
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 }}
icon={<EditOutlined />}
disabled={!canConfig}
onClick={() => openPriceModal(row)}
>
{value === 'active' ? '启用' : '归档'}
</Typography.Text>
{row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined
? '改价'
: '配置成本'}
</Button>
),
},
]}
/>
</Card>
<Modal
title={`配置自动发货采购成本 - ${selectedProduct?.canonicalName || ''}`}
open={priceModalOpen}
onCancel={() => setPriceModalOpen(false)}
onOk={() => priceForm.submit()}
confirmLoading={priceSubmitting}
okText="保存并生效"
cancelText="取消"
destroyOnClose
>
<Form form={priceForm} layout="vertical" onFinish={submitPrice}>
<Form.Item label="标准商品">
<Typography.Text strong>
{selectedProduct?.canonicalName}({selectedProduct?.canonicalSku})
</Typography.Text>
</Form.Item>
<Form.Item
name="priceYuan"
label="采购单价 (元)"
rules={[
{ required: true, message: '请输入采购单价' },
{
type: 'number',
min: 0.01,
message: '采购单价必须大于 0',
},
]}
extra="上游每发货 1 件扣除的采购成本金额"
>
<InputNumber
style={{ width: '100%' }}
precision={2}
min={0.01}
step={1}
addonAfter="元 / 件"
placeholder="例如: 15.00"
/>
</Form.Item>
<Form.Item
name="effectiveMode"
label="生效时间范围"
rules={[{ required: true, message: '请选择生效时间' }]}
>
<Radio.Group>
<Space direction="vertical">
<Radio value="retroactive">
<Space direction="vertical" size={0}>
<Typography.Text strong>追溯至历史订单 (推荐)</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
自历史业务起点 (2026-07-10) 生效,重算快照后可直接消除历史 cost_missing 异常
</Typography.Text>
</Space>
</Radio>
<Radio value="now">
<span>自当前时间立即生效(仅对后续订单生效)</span>
</Radio>
<Radio value="custom">
<span>指定生效日期</span>
</Radio>
</Space>
),
},
]}
/>
</Card>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.effectiveMode !== cur.effectiveMode}>
{({ getFieldValue }) =>
getFieldValue('effectiveMode') === 'custom' ? (
<Form.Item
name="customEffectiveDate"
label="指定生效日期"
rules={[{ required: true, message: '请选择生效日期' }]}
>
<DatePicker style={{ width: '100%' }} placeholder="选择生效日期" />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="changeReason" label="变更说明 / 备注">
<Input placeholder="例如:上游供货商调价" />
</Form.Item>
</Form>
</Modal>
</>
)
}
@@ -6,6 +6,7 @@
*/
import {
DeleteOutlined,
DollarOutlined,
EditOutlined,
FolderOpenOutlined,
PlusOutlined,
@@ -17,9 +18,12 @@ import {
App,
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Switch,
@@ -27,19 +31,23 @@ import {
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
applyAdminTemplateProductBootstrap,
backfillAdminAffiliateDashSkus,
backfillAdminWorkOrderProductSkus,
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
normalizeAdminManualProducts,
previewAdminTemplateProductBootstrap,
setAdminProductPrice,
syncAdminAffiliateDashProducts,
syncAdminProductPrice,
updateAdminProductCatalog,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
@@ -68,18 +76,36 @@ export function ProductCatalogPanel() {
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
const [normalizingManualProducts, setNormalizingManualProducts] = useState(false)
const [syncingAffiliate, setSyncingAffiliate] = useState(false)
const [backfillingAffiliate, setBackfillingAffiliate] = 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 [priceModalOpen, setPriceModalOpen] = useState(false)
const [pricingProduct, setPricingProduct] = useState<ProductCatalogItem | null>(null)
const [pricingSubmitting, setPricingSubmitting] = useState(false)
const [syncingProductId, setSyncingProductId] = useState<number | null>(null)
const [priceForm] = Form.useForm()
// 游戏分类与二级目录结构仅为接单平台(work_order)独有,自动发货与人工客服不使用
const isWorkOrder = productSourceFilter === 'work_order'
const showCategoryBar = isWorkOrder
useEffect(() => {
if (!isWorkOrder) {
setProductRootCategoryId(null)
setProductSubCategoryId(null)
}
}, [isWorkOrder])
const productsQuery = useQuery({
queryKey: [
'admin-cost-center-products',
productSourceFilter,
productKeyword,
productRootCategoryId,
productSubCategoryId,
isWorkOrder ? productRootCategoryId : null,
isWorkOrder ? productSubCategoryId : null,
productPage,
productPageSize,
],
@@ -87,8 +113,8 @@ export function ProductCatalogPanel() {
fetchAdminProductCatalog({
keyword: productKeyword || undefined,
carrierType: productSourceFilter,
rootCategoryId: productRootCategoryId ?? undefined,
categoryId: productSubCategoryId ?? undefined,
rootCategoryId: isWorkOrder ? (productRootCategoryId ?? undefined) : undefined,
categoryId: isWorkOrder ? (productSubCategoryId ?? undefined) : undefined,
page: productPage,
pageSize: productPageSize,
}),
@@ -275,6 +301,44 @@ export function ProductCatalogPanel() {
}
}
async function syncAffiliateProducts() {
setSyncingAffiliate(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 {
setSyncingAffiliate(false)
}
}
async function backfillAffiliateSkus() {
setBackfillingAffiliate(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 {
setBackfillingAffiliate(false)
}
}
function openCreateProduct() {
setEditingProduct(null)
productForm.resetFields()
@@ -318,6 +382,103 @@ export function ProductCatalogPanel() {
}
}
function openSetProductPrice(item: ProductCatalogItem) {
setPricingProduct(item)
priceForm.resetFields()
priceForm.setFieldsValue({
amountYuan:
item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined
? (item.activeCostAmountFen / 100).toFixed(2)
: undefined,
effectiveScope: 'retroactive',
changeReason: '在商品与成本中心统一调整价格',
syncOpenOrders: true,
syncSnapshots: true,
})
setPriceModalOpen(true)
}
async function submitProductPrice(values: {
amountYuan: number | string
effectiveScope: 'retroactive' | 'immediate' | 'custom'
effectiveFromCustom?: string
changeReason?: string
syncOpenOrders?: boolean
}) {
if (!pricingProduct) return
const yuan = Number(values.amountYuan)
if (!Number.isFinite(yuan) || yuan <= 0) {
message.error('价格必须大于 0')
return
}
const amountFen = Math.round(yuan * 100)
let effectiveFrom: string | undefined
let retroactive = false
if (values.effectiveScope === 'retroactive') {
retroactive = true
effectiveFrom = '2026-07-10T00:00:00+08:00'
} else if (values.effectiveScope === 'custom' && values.effectiveFromCustom) {
effectiveFrom = values.effectiveFromCustom
}
setPricingSubmitting(true)
try {
const response = await setAdminProductPrice(pricingProduct.id, {
amountFen,
effectiveFrom,
retroactive,
changeReason: values.changeReason || '在商品与成本中心统一调整价格',
syncOpenOrders: values.syncOpenOrders !== false,
})
const result = response.data
let extraHint = ''
if (result.syncedOrders) {
extraHint = `,已同步 ${result.syncedOrders.updatedCount} 笔工单大厅待抢单`
}
message.success(`标准价格配置成功(¥${(amountFen / 100).toFixed(2)})${extraHint}`)
setPriceModalOpen(false)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '配置标准价格失败')
} finally {
setPricingSubmitting(false)
}
}
function handleSyncProductPrice(item: ProductCatalogItem) {
const isTpl = item.canonicalSku.startsWith('tpl:')
const isAff = item.canonicalSku.startsWith('aff:')
const currentPriceText =
item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined
? `当前价格 ¥${(item.activeCostAmountFen / 100).toFixed(2)}`
: '当前配置'
const targetDesc = isTpl
? '接单大厅所有该模板未领取的待抢单工单'
: isAff
? '历史自动发货成本快照与销售事实'
: '下游关联业务'
modal.confirm({
title: '同步标准价格',
content: `确认将「${item.canonicalName}」的 ${currentPriceText} 同步至${targetDesc}?同步后将以新价格进行履约与结算。`,
okText: '确认同步',
cancelText: '取消',
onOk: async () => {
setSyncingProductId(item.id)
try {
const response = await syncAdminProductPrice(item.id)
message.success(response.data.message || '价格同步完成')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步价格失败')
} finally {
setSyncingProductId(null)
}
},
})
}
async function toggleProductStatus(row: ProductCatalogItem, checked: boolean) {
if (!canConfig) return
setProductStatusUpdatingId(row.id)
@@ -412,30 +573,59 @@ export function ProductCatalogPanel() {
title: '已绑定渠道',
dataIndex: 'mappingCount',
width: 130,
render: (value: number) =>
value > 0 ? (
render: (value: number, row) => {
if (row.source === 'affiliate_dash') {
return <Tag color="default">直连无需映射</Tag>
}
if (row.source === 'manual') {
return <Tag color="default">内部SKU无需映射</Tag>
}
return value > 0 ? (
<Tag color="green">已匹配 {value} 条</Tag>
) : (
<Tag style={{ color: '#94a3b8' }}>未绑定</Tag>
),
)
},
},
{
title: '成本版本',
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>
),
width: 140,
render: (value: number, row) => {
if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) {
return (
<div
style={{ cursor: canConfig ? 'pointer' : 'default' }}
onClick={() => canConfig && openSetProductPrice(row)}
title={canConfig ? '点击调整价格' : undefined}
>
<Space direction="vertical" size={0}>
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{(row.activeCostAmountFen / 100).toFixed(2)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{value} 个版本 {canConfig ? '· 点击改价' : ''}
</Typography.Text>
</Space>
</div>
)
}
return (
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => canConfig && openSetProductPrice(row)}
>
{value > 0 ? `${value} 个版本 · 改价` : row.source === 'work_order' ? '配置结算成本' : '未配置 · 立即配置'}
</Button>
)
},
},
{
title: '状态',
dataIndex: 'status',
width: 120,
width: 110,
render: (value: ProductCatalogItem['status'], row) => (
<Space size={6}>
<Switch
@@ -458,11 +648,33 @@ export function ProductCatalogPanel() {
},
{
title: '操作',
width: 100,
width: 180,
fixed: 'right',
render: (_, row) =>
canConfig ? (
<Space size={2}>
<Space size={2} wrap>
<Button
type="link"
size="small"
icon={<DollarOutlined />}
style={{ paddingInline: 4 }}
onClick={() => openSetProductPrice(row)}
>
{row.activeCostAmountFen ? '改价' : '配置成本'}
</Button>
{row.canonicalSku.startsWith('tpl:') ? (
<Button
type="link"
size="small"
icon={<SyncOutlined spin={syncingProductId === row.id} />}
style={{ paddingInline: 4, color: '#1677ff' }}
title="同步此模板在接单大厅的待抢单工单价格"
loading={syncingProductId === row.id}
onClick={() => handleSyncProductPrice(row)}
>
同步工单
</Button>
) : null}
<Button
type="text"
size="small"
@@ -497,27 +709,73 @@ export function ProductCatalogPanel() {
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={<SyncOutlined />}
loading={normalizingManualProducts}
onClick={() => void normalizeManualProductCatalog()}
>
归一人工商品
</Button>
{productSourceFilter === 'affiliate_dash' ? (
<>
<Button
icon={<SyncOutlined />}
loading={syncingAffiliate}
onClick={() => void syncAffiliateProducts()}
>
同步自动发货商品
</Button>
<Button
icon={<SyncOutlined />}
loading={backfillingAffiliate}
onClick={() => void backfillAffiliateSkus()}
>
回填自动发货 SKU
</Button>
</>
) : productSourceFilter === 'manual' ? (
<Button
icon={<SyncOutlined />}
loading={normalizingManualProducts}
onClick={() => void normalizeManualProductCatalog()}
>
归一人工商品
</Button>
) : productSourceFilter === 'work_order' ? (
<>
<Button
icon={<SyncOutlined />}
loading={bootstrapPreviewing || bootstrapApplying}
onClick={() => void runTemplateBootstrap()}
>
从接单模板初始化
</Button>
<Button
icon={<SyncOutlined />}
loading={backfillingWorkOrderSkus}
onClick={() => void backfillWorkOrderProducts()}
>
按工单回填商品
</Button>
</>
) : (
<>
<Button
icon={<SyncOutlined />}
loading={bootstrapPreviewing || bootstrapApplying}
onClick={() => void runTemplateBootstrap()}
>
从接单模板初始化
</Button>
<Button
icon={<SyncOutlined />}
loading={backfillingWorkOrderSkus}
onClick={() => void backfillWorkOrderProducts()}
>
按工单回填商品
</Button>
<Button
icon={<SyncOutlined />}
loading={normalizingManualProducts}
onClick={() => void normalizeManualProductCatalog()}
>
归一人工商品
</Button>
</>
)}
<Button
icon={<ReloadOutlined />}
loading={productsQuery.isFetching}
@@ -530,75 +788,79 @@ export function ProductCatalogPanel() {
) : 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)
{showCategoryBar ? (
<>
<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)
}}
>
全部 ({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>
))}
items={[
{ key: 'all', label: `全部 (${productCatalogTotal})` },
...productCategoryTree.map((root) => ({
key: String(root.categoryId),
label: `${root.name} (${root.total})`,
})),
...(productUncategorizedTotal > 0
? [{ key: 'uncategorized', label: `未分类 (${productUncategorizedTotal})` }]
: []),
]}
/>
</div>
</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}
</>
) : null}
<Space style={{ marginBottom: 16 }} wrap>
@@ -677,6 +939,173 @@ export function ProductCatalogPanel() {
</Form.Item>
</Form>
</Modal>
{/* 配置标准价格弹窗 */}
<Modal
title={
<Space size={8}>
<DollarOutlined style={{ color: '#1677ff' }} />
<span>
配置标准价格 - {pricingProduct?.canonicalName || pricingProduct?.canonicalSku}
</span>
</Space>
}
open={priceModalOpen}
onCancel={() => setPriceModalOpen(false)}
footer={null}
destroyOnClose
width={560}
>
{pricingProduct ? (
<Form
form={priceForm}
layout="vertical"
onFinish={submitProductPrice}
style={{ marginTop: 16 }}
>
<div
style={{
background: '#f8fafc',
padding: '12px 16px',
borderRadius: 8,
marginBottom: 16,
border: '1px solid #e2e8f0',
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
标准商品:
</Typography.Text>
<div style={{ fontWeight: 600, fontSize: 14, color: '#0f172a' }}>
{pricingProduct.canonicalName}{' '}
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
({pricingProduct.canonicalSku})
</Typography.Text>
</div>
<div style={{ marginTop: 6, fontSize: 12, color: '#64748b' }}>
当前生效价格:
<strong style={{ color: '#cf1322', marginInlineStart: 4 }}>
{pricingProduct.activeCostAmountFen !== null &&
pricingProduct.activeCostAmountFen !== undefined
? `¥${(pricingProduct.activeCostAmountFen / 100).toFixed(2)}`
: '未配置'}
</strong>
<span style={{ marginInline: 8 }}>·</span>
来源:
<Tag style={{ marginInlineStart: 4 }}>
{pricingProduct.source === 'affiliate_dash'
? '自动发货'
: pricingProduct.source === 'work_order'
? '接单模板 / 工单'
: '人工客服'}
</Tag>
</div>
</div>
<Form.Item
name="amountYuan"
label={
pricingProduct.source === 'affiliate_dash'
? '采购单价(元)'
: '标准结算单价(元)'
}
rules={[{ required: true, message: '请填写价格' }]}
extra={
pricingProduct.source === 'affiliate_dash'
? '上游每发货 1 件扣除的采购成本金额'
: '接单打手发单/验收的基础结算单价'
}
>
<InputNumber
min={0.01}
step={0.5}
precision={2}
addonAfter={`元 / ${pricingProduct.unitName || '件'}`}
style={{ width: '100%' }}
placeholder="0.00"
/>
</Form.Item>
<Form.Item
name="effectiveScope"
label="生效时间范围"
rules={[{ required: true, message: '请选择生效时间范围' }]}
>
<Radio.Group style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Radio value="retroactive">
<strong>追溯至历史订单(推荐)</strong>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
自历史业务起点 (2026-07-10) 生效,直接更新基线并消除历史 cost_missing 异常
</div>
</Radio>
<Radio value="immediate">
<strong>自当前时间立即生效(仅对后续订单生效)</strong>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
保留旧版本历史,自新版本创建时刻起计算后续履约结算
</div>
</Radio>
<Radio value="custom">
<strong>指定生效日期</strong>
</Radio>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.effectiveScope !== curr.effectiveScope}>
{({ getFieldValue }) =>
getFieldValue('effectiveScope') === 'custom' ? (
<Form.Item
name="effectiveFromCustom"
label="指定生效时间"
rules={[{ required: true, message: '请填写生效时间' }]}
>
<Input placeholder="YYYY-MM-DDTHH:mm:ss+08:00" />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="changeReason" label="变更说明 / 备注">
<Input placeholder="例如:成本中心统一定价调整 / 上游采购成本调整" />
</Form.Item>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<Form.Item
name="syncOpenOrders"
valuePropName="checked"
style={{ marginBottom: 12 }}
>
<Checkbox>
<strong>立即同步至接单大厅待抢单工单(推荐)</strong>
<div style={{ color: '#64748b', fontSize: 12 }}>
接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。
</div>
</Checkbox>
</Form.Item>
) : null}
{pricingProduct.source === 'affiliate_dash' ? (
<Form.Item
name="syncSnapshots"
valuePropName="checked"
style={{ marginBottom: 12 }}
>
<Checkbox>
<strong>立即重算成本快照并回填历史(推荐)</strong>
<div style={{ color: '#64748b', fontSize: 12 }}>
自动重新计算历史所有发货任务的成本快照,更新财务数据。
</div>
</Checkbox>
</Form.Item>
) : null}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 }}>
<Button onClick={() => setPriceModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={pricingSubmitting}>
保存并生效
</Button>
</div>
</Form>
) : null}
</Modal>
</>
)
}
@@ -11,6 +11,7 @@ import {
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Alert,
Button,
Card,
Divider,
@@ -925,6 +926,24 @@ export default function ProductRulesPanel() {
}}
onFinish={saveRule}
>
{editingRule ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="全系统标准价格已统一收敛至【商品与成本中心】"
description={
<span>
接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往
<a href="#/admin/cost-center" style={{ marginLeft: 4, fontWeight: 500 }}>
【商品与成本中心】
</a>
进行修改。
</span>
}
/>
) : null}
{/* 基础信息 */}
<div className="form-grid-3col">
<Form.Item
@@ -967,12 +986,29 @@ export default function ProductRulesPanel() {
label="按件单价"
name="unitPrice"
rules={[{ required: true, message: '请填写单价' }]}
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
>
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
<InputNumber
min={0.01}
step={1}
addonAfter="元"
className="full-width"
disabled={Boolean(editingRule)}
/>
</Form.Item>
) : (
<Form.Item label="接单金额" name="rewardAmount">
<InputNumber min={0} step={1} addonAfter="元" className="full-width" />
<Form.Item
label="接单金额"
name="rewardAmount"
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
>
<InputNumber
min={0}
step={1}
addonAfter="元"
className="full-width"
disabled={Boolean(editingRule)}
/>
</Form.Item>
)
}
@@ -117,6 +117,53 @@ export function backfillAdminAffiliateDashSkus() {
}>('/api/v1/admin/cost-center/affiliate-products/backfill')
}
export function importAdminAffiliateDashPricesBaseline() {
return apiPost<{
total: number
createdProductCount: number
createdVersionCount: number
updatedVersionCount: number
skippedCount: number
}>('/api/v1/admin/cost-center/affiliate-products/price-baseline')
}
export function setAdminAffiliateProductPrice(
productId: number,
payload: {
amountFen: number
effectiveFrom?: string
retroactive?: boolean
changeReason?: string
},
) {
return apiPost<{
success: boolean
costVersionId: number
amountFen: number
effectiveFrom: string
message: string
}>(`/api/v1/admin/cost-center/affiliate-products/${productId}/price`, payload)
}
export function syncAdminAffiliateDashCostSnapshots() {
return apiPost<{
snapshotResult: {
range: { fromDate: string; toDate: string }
createdCount: number
exceptionCreatedCount: number
resolvedExceptionCount: number
}
factsResult: {
fromDate: string
toDate: string
deletedCount: number
insertedCount: number
batchCount: number
}
}>('/api/v1/admin/cost-center/affiliate-products/sync-snapshots')
}
export function updateAdminProductCatalog(productId: number, payload: Record<string, unknown>) {
return apiPatch<{ product: ProductCatalogItem }>(
`/api/v1/admin/cost-center/products/${productId}`,
@@ -417,3 +464,43 @@ export function confirmAdminManualFulfillment(payload: {
payload,
)
}
export function setAdminProductPrice(
productId: number,
payload: {
amountFen: number
effectiveFrom?: string
retroactive?: boolean
changeReason?: string
syncOpenOrders?: boolean
},
) {
return apiPost<{
success: boolean
costVersionId: number
amountFen: number
effectiveFrom: string
syncedOrders?: {
candidateCount: number
updatedCount: number
updatedNormalCount: number
updatedPartialSharingCount: number
}
message?: string
}>(`/api/v1/admin/cost-center/products/${productId}/price`, payload)
}
export function syncAdminProductPrice(productId: number) {
return apiPost<{
success: boolean
type: string
synced?: {
candidateCount: number
updatedCount: number
updatedNormalCount: number
updatedPartialSharingCount: number
}
message: string
}>(`/api/v1/admin/cost-center/products/${productId}/sync-price`, {})
}
@@ -49,6 +49,8 @@ export interface ProductCatalogItem {
attributes: Record<string, unknown>
mappingCount: number
costVersionCount: number
activeCostAmountFen?: number | null
activeCostEffectiveFrom?: string | null
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
affiliateTaskCount: number
affiliateLastFulfilledAt: string | null