feat(fulfillment): 支持删除待分类和强制删除渠道规则

This commit is contained in:
yml2213
2026-11-04 13:52:11 +08:00
parent 209fa657ed
commit c31fa7db76
9 changed files with 760 additions and 48 deletions
@@ -38,8 +38,10 @@ import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import {
assignChannelByInternalSku,
deletePendingChannelGroup,
deleteInternalSkuMapping,
deleteChannelRules,
forceDeleteChannelRules,
fetchChannelExceptions,
fetchChannelRules,
fetchInternalSkuMappings,
@@ -176,6 +178,20 @@ function PendingPanel() {
message.error(error instanceof Error ? error.message : '重新匹配接单模板失败'),
})
const deleteMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
deletePendingChannelGroup({ internalSku: row.internal_sku }),
onSuccess: (result) => {
const data = result.data
message.success(
`已删除 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
)
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'),
})
const pipelineMutation = useMutation({
mutationFn: () => runFinancePipeline({ days: 3 }),
onSuccess: () => {
@@ -194,10 +210,27 @@ function PendingPanel() {
},
{
title: '店铺',
dataIndex: 'shop_ids',
width: 180,
ellipsis: true,
render: (value: string[] | undefined) => value?.join(' / ') || '-',
dataIndex: 'shops',
width: 210,
render: (_: unknown, row: PendingChannelGroup) => {
const shops =
row.shops?.length > 0
? row.shops
: (row.shop_ids || []).map((shopId) => ({ id: shopId, name: shopId }))
if (shops.length === 0) return '-'
return (
<Space direction="vertical" size={2}>
{shops.map((shop) => (
<span key={`${shop.id}-${shop.name}`}>
<Typography.Text>{shop.name}</Typography.Text>
{shop.name !== shop.id ? (
<Typography.Text type="secondary"> · {shop.id}</Typography.Text>
) : null}
</span>
))}
</Space>
)
},
},
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
{
@@ -236,9 +269,9 @@ function PendingPanel() {
},
{
title: '指定渠道',
width: 320,
width: 360,
render: (_: unknown, row: PendingChannelGroup) => (
<Space>
<Space size={4}>
<Select
size="small"
style={{ width: 180 }}
@@ -256,6 +289,33 @@ function PendingPanel() {
>
指定
</Button>
<Tooltip title="删除待分类记录,不删除原始订单">
<Button
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
onClick={() =>
Modal.confirm({
title: '删除待分类记录',
content: (
<div>
<p>
将归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的成本快照、
分类异常,重算对应销售事实与总账。
</p>
<p style={{ marginBottom: 0 }}>
原始订单和订单商品不会被删除;已关账期间禁止删除。
</p>
</div>
),
okText: '确认删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => deleteMutation.mutateAsync(row),
})
}
/>
</Tooltip>
</Space>
),
},
@@ -296,10 +356,10 @@ function PendingPanel() {
}
>
<Alert
type="info"
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="待分类来自真实订单,系统不提供直接删除,避免掩盖未履约收入。配置错误请修正模板/映射后点「重试接单匹配」,或通过「指定渠道」完成分流;系统会先确认载体创建成功,再进入商品归一与成本核算。"
message="删除只会归档对应订单项并清理财务成本记录,不会删除原始订单。配置错误优先修正模板/映射后重试匹配;确认无需履约或核算时,可直接删除。"
/>
<PercentTable
rowKey={(row) => rowKey(row)}
@@ -614,6 +674,20 @@ function RulesPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'),
})
const forceDeleteMutation = useMutation({
mutationFn: (ids: number[]) => forceDeleteChannelRules(ids),
onSuccess: (result) => {
const missingHint = result.data.missingIds.length
? `,${result.data.missingIds.length} 条已不存在`
: ''
message.success(`已强制删除 ${result.data.deletedCount} 条渠道规则${missingHint}`)
setSelectedRuleIds([])
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '强制删除规则失败'),
})
function resetFilters() {
setKeyword('')
setChannelFilter('')
@@ -635,6 +709,19 @@ function RulesPanel() {
})
}
function confirmForceDeleteRules(ids: number[]) {
if (ids.length === 0) return
Modal.confirm({
title: ids.length === 1 ? '强制删除渠道规则' : `批量强制删除 ${ids.length} 条渠道规则`,
content:
'将跳过系统来源保护,只删除统一渠道规则本身。历史订单和成本数据不会被删除;对应来源后续同步时,规则仍可能重新生成。',
okText: '确认强制删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => forceDeleteMutation.mutateAsync(ids),
})
}
const columns = [
{ title: '作用范围', width: 110, render: () => <Tag color="blue">全局</Tag> },
{
@@ -676,26 +763,30 @@ function RulesPanel() {
},
{
title: '操作',
width: 100,
width: 120,
render: (_: unknown, row: ChannelRule) => {
const button = (
if (!canDeleteChannelRule(row)) {
return (
<Button
danger
size="small"
loading={forceDeleteMutation.isPending}
onClick={() => confirmForceDeleteRules([row.id])}
>
强制删除
</Button>
)
}
return (
<Button
danger
size="small"
loading={deleteMutation.isPending}
disabled={!canDeleteChannelRule(row)}
onClick={() => confirmDeleteRules([row.id])}
>
删除
</Button>
)
return canDeleteChannelRule(row) ? (
button
) : (
<Tooltip title="系统生成或由来源配置维护,不能在统一渠道页直接删除;配置有误时请新增同 SKU 的正确规则覆盖。">
<span>{button}</span>
</Tooltip>
)
},
},
]
@@ -709,10 +800,10 @@ function RulesPanel() {
danger
icon={<DeleteOutlined />}
disabled={selectedRuleIds.length === 0}
loading={deleteMutation.isPending}
onClick={() => confirmDeleteRules(selectedRuleIds)}
loading={forceDeleteMutation.isPending}
onClick={() => confirmForceDeleteRules(selectedRuleIds)}
>
批量删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
批量强制删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
@@ -726,8 +817,8 @@ function RulesPanel() {
<Typography.Paragraph type="secondary">
配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道;
其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。
来源为商品能力迁移、人工配置、卡券或历史回填的系统规则受保护,配置有误时请新增同 SKU
的正确规则覆盖。
来源为商品能力迁移、人工配置、卡券或历史回填的系统规则默认受保护;确认配置错误时可使用
「强制删除」,但对应来源后续同步时仍可能重新生成。
</Typography.Paragraph>
<Space wrap style={{ marginBottom: 12 }}>
<Input
@@ -791,7 +882,6 @@ function RulesPanel() {
rowSelection={{
selectedRowKeys: selectedRuleIds,
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }),
}}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
@@ -42,7 +42,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
deleteAdminManualConfig,
deleteAdminManualSku,
fetchAdminManualPlatforms,
fetchAdminManualSkuOverview,
fetchAdminManualSkus,
@@ -161,13 +161,17 @@ export function ManualSkuConfigPanel() {
})
const deleteMutation = useMutation({
mutationFn: (skuKey: string) => deleteAdminManualConfig(skuKey),
onSuccess: () => {
message.success('配置已删除')
mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey),
onSuccess: (response) => {
message.success(
`人工分类已移除;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`,
)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '删除失败')
@@ -259,24 +263,24 @@ export function ManualSkuConfigPanel() {
{
title: '操作',
key: 'actions',
width: 130,
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
{row.configured ? (
<Popconfirm
title="删除该 SKU 的配置?"
description="删除后该 SKU 回到未配置状态,已回填的订单项不再带配置。"
okText="删除"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
) : null}
<Popconfirm
title="移除该人工分类?"
description="会删除人工登记与配置、撤销人工渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认移除"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
移除
</Button>
</Popconfirm>
</Space>
),
},
@@ -393,6 +393,18 @@ export function deleteAdminManualConfig(skuKey: string) {
)
}
export function deleteAdminManualSku(skuKey: string) {
return apiDelete<{
skuKey: string
affectedItems: number
deletedConfigs: number
deletedRegistry: number
deletedRules: number
refreshedDays: number
skippedClosedDays: number
}>(`/api/v1/admin/cost-center/manual-skus/${encodeURIComponent(skuKey)}`)
}
export function rematchAdminManualFulfillments(payload: { skuKey?: string } = {}) {
return apiPost<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
}
@@ -6,6 +6,7 @@ export type PendingChannelGroup = {
sku_name: string
providers: string[]
shop_ids: string[]
shops: Array<{ id: string; name: string }>
sku_codes: string[]
pending_reasons: string[]
item_count: number
@@ -99,6 +100,15 @@ export type PendingWorkOrderRetryResult = {
skipReasons: Record<string, number>
}
export type DeletePendingChannelGroupResult = {
archivedItems: number
preservedOrders: number
deletedCostSnapshots: number
resolvedExceptions: number
refreshedDays: number
skippedClosedDays: number
}
export type FinancePipelineStatus = {
pipeline: string
label: string
@@ -148,6 +158,13 @@ export function retryPendingWorkOrderRouting(
)
}
export function deletePendingChannelGroup(payload: { internalSku: string }) {
return apiPost<DeletePendingChannelGroupResult>(
'/api/v1/admin/fulfillment/pending/delete',
payload,
)
}
export function assignChannelByInternalSku(payload: {
internalSku: string
channel: string
@@ -252,6 +269,14 @@ export function deleteChannelRules(ids: number[]) {
}>('/api/v1/admin/fulfillment/rules/batch-delete', { ids })
}
export function forceDeleteChannelRules(ids: number[]) {
return apiPost<{
deletedCount: number
deletedIds: number[]
missingIds: number[]
}>('/api/v1/admin/fulfillment/rules/force-delete', { ids })
}
export function fetchPipelineStatus() {
return apiGet<FinancePipelineStatus[]>('/api/v1/admin/fulfillment/pipeline/status')
}