refactor(cost-center): 收敛人工 SKU 自动创建流程
This commit is contained in:
@@ -484,7 +484,17 @@ function PendingPanel() {
|
||||
保存渠道
|
||||
</Button>
|
||||
{canRunScopedActions && hasConfigIssue ? (
|
||||
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!row.internal_sku}
|
||||
onClick={() => {
|
||||
const sku = String(row.internal_sku || '').trim()
|
||||
if (!sku) return
|
||||
void navigate(
|
||||
`/admin/cost-center?scope=manual&tab=cost&manualSku=${encodeURIComponent(sku)}&returnTo=${encodeURIComponent('/admin/fulfillment-channel')}`,
|
||||
)
|
||||
}}
|
||||
>
|
||||
去补配置({row.config_issue_item_count})
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import { usePersistedState } from '@/hooks/usePersistedState'
|
||||
@@ -73,7 +74,11 @@ function isCostScope(value: unknown): value is CostScope {
|
||||
|
||||
export default function CostCenterShell() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const canConfig = hasAdminPermission('finance.config')
|
||||
const requestedScope = String(searchParams.get('scope') || '').trim()
|
||||
const requestedTab = String(searchParams.get('tab') || '').trim()
|
||||
const handledRouteRequestRef = useRef('')
|
||||
|
||||
const [scope, setScope] = usePersistedState<CostScope>(
|
||||
'admin-cost-center:scope',
|
||||
@@ -87,6 +92,20 @@ export default function CostCenterShell() {
|
||||
)
|
||||
const mappingsApplicable = scope === 'all' || scope === 'work_order'
|
||||
|
||||
useEffect(() => {
|
||||
const routeRequest = `${requestedScope}\u0000${requestedTab}`
|
||||
if (routeRequest === '\u0000' || handledRouteRequestRef.current === routeRequest) return
|
||||
handledRouteRequestRef.current = routeRequest
|
||||
if (isCostScope(requestedScope) && requestedScope !== scope) {
|
||||
setScope(requestedScope)
|
||||
}
|
||||
const requestedTabAllowed =
|
||||
isCostCenterTab(requestedTab) && (requestedScope !== 'manual' || requestedTab !== 'mappings')
|
||||
if (requestedTabAllowed && requestedTab !== activeTab) {
|
||||
setActiveTab(requestedTab)
|
||||
}
|
||||
}, [activeTab, requestedScope, requestedTab, scope, setActiveTab, setScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mappingsApplicable && activeTab === 'mappings') {
|
||||
setActiveTab('products')
|
||||
|
||||
@@ -38,7 +38,8 @@ import {
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import {
|
||||
deleteAdminManualSku,
|
||||
@@ -55,7 +56,10 @@ import {
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '@/utils/credit-unit-price'
|
||||
|
||||
import { useCostCenter } from './CostCenterContext'
|
||||
|
||||
type ConfigFormValues = {
|
||||
skuKey: string
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
@@ -87,8 +91,15 @@ function platformOptionLabel(platformKey: string, label: string, creditUnitPrice
|
||||
|
||||
export function ManualSkuConfigPanel() {
|
||||
const { message } = App.useApp()
|
||||
const { canConfig } = useCostCenter()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [form] = Form.useForm<ConfigFormValues>()
|
||||
const handledDeepLinkRef = useRef('')
|
||||
const deepLinkSku = String(searchParams.get('manualSku') || searchParams.get('sku') || '').trim()
|
||||
const manualNewRequested = searchParams.get('manualNew') === '1'
|
||||
const returnTo = String(searchParams.get('returnTo') || '').trim()
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<'all' | 'configured' | 'unconfigured'>('unconfigured')
|
||||
@@ -125,7 +136,7 @@ export function ManualSkuConfigPanel() {
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: ConfigFormValues) =>
|
||||
saveAdminManualConfig({
|
||||
skuKey: editing?.skuKey || '',
|
||||
skuKey: String(values.skuKey || editing?.skuKey || '').trim(),
|
||||
channels: values.channels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: Number(channel.creditQuantity || 0),
|
||||
@@ -139,10 +150,14 @@ export function ManualSkuConfigPanel() {
|
||||
: ''
|
||||
message.success(`配置已保存${syncHint}`)
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
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'] })
|
||||
if (returnTo.startsWith('/admin/fulfillment-channel')) {
|
||||
void navigate(returnTo)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
@@ -180,6 +195,7 @@ export function ManualSkuConfigPanel() {
|
||||
]
|
||||
: [{ platformKey: '', creditQuantity: 0 }]
|
||||
form.setFieldsValue({
|
||||
skuKey: item.skuKey,
|
||||
channels: configuredChannels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: channel.creditQuantity,
|
||||
@@ -189,6 +205,36 @@ export function ManualSkuConfigPanel() {
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function openCreateConfig(skuKey = '') {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
skuKey: skuKey.trim(),
|
||||
channels: [{ platformKey: '', creditQuantity: 0 }],
|
||||
note: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfig) return
|
||||
const deepLinkKey = deepLinkSku || (manualNewRequested ? '__new__' : '')
|
||||
if (!deepLinkKey || handledDeepLinkRef.current === deepLinkKey) return
|
||||
handledDeepLinkRef.current = deepLinkKey
|
||||
if (deepLinkSku) {
|
||||
setKeyword(deepLinkSku)
|
||||
setStatus('all')
|
||||
}
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
skuKey: deepLinkSku,
|
||||
channels: [{ platformKey: '', creditQuantity: 0 }],
|
||||
note: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}, [canConfig, deepLinkSku, manualNewRequested, form])
|
||||
|
||||
const columns: TableColumnsType<ManualSkuItem> = [
|
||||
{
|
||||
title: '人工标准商品',
|
||||
@@ -276,24 +322,27 @@ export function ManualSkuConfigPanel() {
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
||||
{row.configured ? '编辑' : '配置'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="归档该人工标准商品?"
|
||||
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
|
||||
okText="确认归档"
|
||||
cancelText="取消"
|
||||
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
归档
|
||||
render: (_, row) =>
|
||||
canConfig ? (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
||||
{row.configured ? '编辑' : '配置'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
<Popconfirm
|
||||
title="归档该人工标准商品?"
|
||||
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
|
||||
okText="确认归档"
|
||||
cancelText="取消"
|
||||
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">只读</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -303,13 +352,23 @@ export function ManualSkuConfigPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="人工 SKU 配置">
|
||||
<Card
|
||||
bordered={false}
|
||||
title="人工 SKU 配置"
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreateConfig()}>
|
||||
新增人工 SKU
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="人工标准商品与成本配置一一对应"
|
||||
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
|
||||
message="人工标准商品由人工配置自动创建"
|
||||
description="新增时只需填写内部 SKU、实际人工渠道和每单成本,系统会自动创建或复用 man: 标准商品。保存后同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
|
||||
/>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
@@ -399,14 +458,17 @@ export function ManualSkuConfigPanel() {
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={`配置人工履约 · ${editing?.skuKey || ''}`}
|
||||
title={editing ? `配置人工履约 · ${editing.skuKey}` : '新增人工履约 SKU'}
|
||||
okText="保存配置"
|
||||
cancelText="取消"
|
||||
confirmLoading={saveMutation.isPending}
|
||||
onOk={() => {
|
||||
void form.validateFields().then((values) => saveMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
@@ -414,10 +476,37 @@ export function ManualSkuConfigPanel() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
message={
|
||||
editing
|
||||
? `商品名称:${editing.displayName || '(无)'}(仅供识别)`
|
||||
: '保存时自动创建标准商品,不需要先去商品目录手工新建'
|
||||
}
|
||||
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="skuKey"
|
||||
label="内部 SKU"
|
||||
rules={[
|
||||
{ required: true, message: '请输入内部 SKU' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (text && !/\p{Script=Han}/u.test(text)) {
|
||||
throw new Error('内部 SKU 必须包含中文')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra="保存后会生成 man:<内部 SKU> 标准商品,并统一用于订单渠道判定。"
|
||||
>
|
||||
<Input
|
||||
disabled={Boolean(editing)}
|
||||
placeholder="例如:破片手榴弹-兔兔白日梦"
|
||||
maxLength={60}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.List
|
||||
name="channels"
|
||||
rules={[
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
|
||||
*/
|
||||
import {
|
||||
ClearOutlined,
|
||||
DeleteOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
@@ -37,13 +36,12 @@ import PercentTable from '@/components/common/PercentTable'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
applyAdminTemplateProductBootstrap,
|
||||
archiveAdminHistoricalManualProducts,
|
||||
archiveAdminSupersededManualProducts,
|
||||
createAdminProductCatalog,
|
||||
deleteAdminProductCatalog,
|
||||
fetchAdminProductCatalog,
|
||||
@@ -95,6 +93,7 @@ function DisabledTemplateSyncOption({
|
||||
export function ProductCatalogPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
|
||||
const [productForm] = Form.useForm<ProductFormValues>()
|
||||
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
|
||||
@@ -109,8 +108,6 @@ export function ProductCatalogPanel() {
|
||||
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
|
||||
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
||||
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
||||
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
|
||||
const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false)
|
||||
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
|
||||
const [productPage, setProductPage] = useState(1)
|
||||
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
@@ -452,76 +449,6 @@ export function ProductCatalogPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveHistoricalManuals() {
|
||||
setArchivingHistoricalManuals(true)
|
||||
try {
|
||||
const preview = (await archiveAdminHistoricalManualProducts()).data
|
||||
if (preview.candidateCount === 0) {
|
||||
message.info('没有需要归档的历史人工商品')
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: '归档历史人工商品',
|
||||
content: `将归档 ${preview.candidateCount} 个仅被已归档订单引用的历史人工商品。不会删除订单、订单项、成本或工单数据,商品只是从当前目录隐藏。`,
|
||||
okText: '确认归档',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setArchivingHistoricalManuals(true)
|
||||
try {
|
||||
const result = (await archiveAdminHistoricalManualProducts({ execute: true })).data
|
||||
message.success(`已归档 ${result.archivedCount} 个历史人工商品`)
|
||||
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 {
|
||||
setArchivingHistoricalManuals(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取历史人工商品失败')
|
||||
} finally {
|
||||
setArchivingHistoricalManuals(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSupersededManuals() {
|
||||
setArchivingSupersededManuals(true)
|
||||
try {
|
||||
const preview = (await archiveAdminSupersededManualProducts()).data
|
||||
if (preview.candidateCount === 0) {
|
||||
message.info('没有已被其他渠道接管的人工归一商品')
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: '归档跨渠道重复的人工归一商品',
|
||||
content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`,
|
||||
okText: '确认归档',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setArchivingSupersededManuals(true)
|
||||
try {
|
||||
const result = (await archiveAdminSupersededManualProducts({ execute: true })).data
|
||||
message.success(
|
||||
`已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`,
|
||||
)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败')
|
||||
} finally {
|
||||
setArchivingSupersededManuals(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败')
|
||||
} finally {
|
||||
setArchivingSupersededManuals(false)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteProduct(item: ProductCatalogItem) {
|
||||
const affiliateHint =
|
||||
item.source === 'affiliate_dash'
|
||||
@@ -777,24 +704,6 @@ export function ProductCatalogPanel() {
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Space size={6} wrap>
|
||||
{productSourceFilter === 'manual' ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
loading={archivingSupersededManuals}
|
||||
onClick={() => void archiveSupersededManuals()}
|
||||
>
|
||||
归档跨渠道重复商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
loading={archivingHistoricalManuals}
|
||||
onClick={() => void archiveHistoricalManuals()}
|
||||
>
|
||||
归档纯历史人工商品
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{productSourceFilter === 'work_order' ? (
|
||||
<>
|
||||
<AdminRangePicker
|
||||
@@ -818,13 +727,34 @@ export function ProductCatalogPanel() {
|
||||
loading={productsQuery.isFetching}
|
||||
onClick={() => void productsQuery.refetch()}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
|
||||
新建商品
|
||||
</Button>
|
||||
{productSourceFilter === 'manual' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
void navigate('/admin/cost-center?scope=manual&tab=cost&manualNew=1')
|
||||
}
|
||||
>
|
||||
新增人工 SKU
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
|
||||
新建商品
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{productSourceFilter === 'manual' ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="人工标准商品由人工 SKU 配置自动创建"
|
||||
description="请到“成本配置 → 人工 SKU 配置”新增内部 SKU 并填写人工渠道和成本;保存时会自动创建或复用 man: 商品。"
|
||||
/>
|
||||
) : null}
|
||||
{showCategoryBar ? (
|
||||
<>
|
||||
<div className="product-rules-category-tabs-bar">
|
||||
|
||||
Reference in New Issue
Block a user