feat(cost-center): 人工 SKU 配置自动同步商品与订单

This commit is contained in:
yml2213
2026-11-03 15:37:14 +08:00
parent eeb9fada63
commit efe706e601
7 changed files with 189 additions and 26 deletions
@@ -11,11 +11,15 @@ import type { CostItem, CostModel, CostPreview, ProductSource } from '@/types/ad
import type { CostScope } from './shared'
export type CostCenterTab = 'products' | 'mappings' | 'cost' | 'exceptions'
export type CostCenterContextValue = {
canConfig: boolean
/** 当前来源筛选:all / affiliate_dash / work_order / manual。 */
scope: CostScope
setScope: (scope: CostScope) => void
activeTab: CostCenterTab
setActiveTab: (tab: CostCenterTab) => void
scopeLabel: string
/** 商品/映射按 canonical_sku 前缀筛选。 */
productSourceFilter?: ProductSource
@@ -34,7 +34,7 @@ import { ChannelMappingPanel } from './ChannelMappingPanel'
import CostConfigPanel from './CostConfigPanel'
import { CostExceptionPanel } from './CostExceptionPanel'
import { ProductCatalogPanel } from './ProductCatalogPanel'
import { CostCenterContext } from './CostCenterContext'
import { CostCenterContext, type CostCenterTab } from './CostCenterContext'
import type { CostScope } from './shared'
const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
@@ -61,8 +61,7 @@ const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
},
]
const TABS = ['products', 'mappings', 'cost', 'exceptions'] as const
type CostCenterTab = (typeof TABS)[number]
const TABS: readonly CostCenterTab[] = ['products', 'mappings', 'cost', 'exceptions']
function isCostCenterTab(value: unknown): value is CostCenterTab {
return typeof value === 'string' && (TABS as readonly string[]).includes(value)
@@ -130,6 +129,8 @@ export default function CostCenterShell() {
canConfig,
scope,
setScope,
activeTab,
setActiveTab,
scopeLabel,
productSourceFilter,
channelFilter,
@@ -143,13 +143,17 @@ export function ManualSkuConfigPanel() {
creditQuantity: values.creditQuantity,
note: values.note || '',
}),
onSuccess: () => {
message.success('配置已保存')
onSuccess: (response) => {
const summary = response.data.syncSummary
const syncHint = summary
? `;自动关联标准商品并重试 ${summary.pendingAssigned} 个待处理订单项`
: ''
message.success(`配置已保存${syncHint}`)
setModalOpen(false)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center'] })
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '保存失败')
@@ -290,8 +294,8 @@ export function ManualSkuConfigPanel() {
type="info"
showIcon
style={{ marginBottom: 14 }}
message="配置主键是内部 SKU,不是商品名称"
description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。消耗积分 × 平台换算率就是这单的人工成本,确认履约时直接扣减,不需要再去成本版本里配一次;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
message="人工履约只需在这里配置一次"
description="保存时会自动创建或关联 man: 标准商品、同步统一渠道规则、回填最近 30 天订单,并重试待处理的人工渠道订单。消耗积分 × 平台换算率就是这单的人工成本,不需要再去标准商品或渠道规则页重复配置;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
@@ -418,7 +422,7 @@ export function ManualSkuConfigPanel() {
showIcon
style={{ marginBottom: 14 }}
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
description="渠道提前配置好,客服确认履约时不需要选择。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
description="保存后自动同步标准商品、统一渠道规则和订单匹配;不需要再去其它页面重复设置。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
/>
<Form<ConfigFormValues> form={form} layout="vertical">
<Form.Item
@@ -95,7 +95,7 @@ function DisabledTemplateSyncOption({
export function ProductCatalogPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
@@ -635,6 +635,13 @@ export function ProductCatalogPanel() {
dataIndex: 'costVersionCount',
width: 140,
render: (value: number, row) => {
if (row.source === 'manual') {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
以人工 SKU 配置为准
</Typography.Text>
)
}
if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) {
return (
<div
@@ -700,15 +707,29 @@ export function ProductCatalogPanel() {
render: (_, row) =>
canConfig ? (
<Space size={2} wrap>
<Button
type="link"
size="small"
icon={<DollarOutlined />}
style={{ paddingInline: 4 }}
onClick={() => openSetProductPrice(row)}
>
{row.activeCostAmountFen ? '改价' : '配置成本'}
</Button>
{row.source === 'manual' ? (
<Button
type="link"
size="small"
style={{ paddingInline: 4 }}
onClick={() => {
setScope('manual')
setActiveTab('cost')
}}
>
人工履约配置
</Button>
) : (
<Button
type="link"
size="small"
icon={<DollarOutlined />}
style={{ paddingInline: 4 }}
onClick={() => openSetProductPrice(row)}
>
{row.activeCostAmountFen ? '改价' : '配置成本'}
</Button>
)}
{row.canonicalSku.startsWith('tpl:') ? (
<Button
type="link"
@@ -419,6 +419,11 @@ export interface ManualFulfillmentConfig {
note?: string
createdAt?: string
updatedAt?: string
syncSummary?: {
canonicalProductId: number | null
pendingAssigned: number
rematchedItems: number
}
}
export interface ManualSkuListResult {