feat(fulfillment): 建立履约统一状态视图与分类队列
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Segmented,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
@@ -99,26 +100,18 @@ const ENABLED_FILTER_OPTIONS = [
|
||||
{ value: 'false', label: '停用' },
|
||||
]
|
||||
|
||||
const PENDING_CONFIG_REASONS = new Set([
|
||||
'manual_config_missing',
|
||||
'work_rule_missing',
|
||||
'affiliate_mapping_missing',
|
||||
'carrier_config_ambiguous',
|
||||
])
|
||||
|
||||
const PENDING_RETRYABLE_TERMINAL_REASONS = new Set([
|
||||
'carrier_task_closed',
|
||||
'carrier_task_retry_pending',
|
||||
'carrier_task_expired',
|
||||
])
|
||||
|
||||
const PENDING_NON_RETRYABLE_TERMINAL_REASONS = new Set([
|
||||
'carrier_task_user_refund',
|
||||
'carrier_task_cancelled',
|
||||
])
|
||||
|
||||
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
||||
|
||||
const PENDING_ISSUE_CATEGORY_OPTIONS = [
|
||||
{ value: 'all', label: '全部问题' },
|
||||
{ value: 'classification', label: '未分类' },
|
||||
{ value: 'configuration', label: '配置待补' },
|
||||
{ value: 'carrier', label: '载体处理' },
|
||||
{ value: 'manual_review', label: '人工复核' },
|
||||
{ value: 'after_sales', label: '售后关闭' },
|
||||
{ value: 'system', label: '系统异常' },
|
||||
]
|
||||
|
||||
const INTERNAL_SKU_MAPPING_TYPES: Array<{
|
||||
value: InternalSkuMappingMatchType
|
||||
label: string
|
||||
@@ -153,14 +146,15 @@ function PendingPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [issueCategory, setIssueCategory] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selections, setSelections] = useState<Record<string, string>>({})
|
||||
const [internalSkuDrafts, setInternalSkuDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-pending', keyword, page, pageSize],
|
||||
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword }),
|
||||
queryKey: ['fulfillment-pending', keyword, issueCategory, page, pageSize],
|
||||
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, issueCategory }),
|
||||
})
|
||||
|
||||
const assignMutation = useMutation({
|
||||
@@ -172,6 +166,7 @@ function PendingPanel() {
|
||||
? ''
|
||||
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
|
||||
channel: selections[rowKey(row)] || '',
|
||||
issueCategory,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const productMessage = result.data.draftProductId ? '和标准商品' : ''
|
||||
@@ -216,10 +211,15 @@ function PendingPanel() {
|
||||
})
|
||||
|
||||
const retryCarrierMutation = useMutation({
|
||||
mutationFn: (input: { row: PendingChannelGroup; retryInactiveCarrier: boolean }) =>
|
||||
mutationFn: (input: {
|
||||
row: PendingChannelGroup
|
||||
retryInactiveCarrier: boolean
|
||||
issueCodes: string[]
|
||||
}) =>
|
||||
retryPendingCarriers({
|
||||
internalSku: input.row.internal_sku || '',
|
||||
retryInactiveCarrier: input.retryInactiveCarrier,
|
||||
issueCodes: input.issueCodes,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
@@ -245,6 +245,7 @@ function PendingPanel() {
|
||||
unlockPendingChannelGroup({
|
||||
groupKey: row.group_key,
|
||||
internalSku: row.internal_sku || '',
|
||||
issueCategory,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`)
|
||||
@@ -255,7 +256,7 @@ function PendingPanel() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
deletePendingChannelGroup({ groupKey: row.group_key }),
|
||||
deletePendingChannelGroup({ groupKey: row.group_key, issueCategory }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
message.success(
|
||||
@@ -372,19 +373,46 @@ function PendingPanel() {
|
||||
affiliate_mapping_missing: '缺自动发货映射',
|
||||
existing_carrier_conflict: '已有其它载体',
|
||||
carrier_config_ambiguous: '载体配置冲突',
|
||||
missing_internal_sku: '缺内部 SKU',
|
||||
missing_channel: '缺渠道配置',
|
||||
user_refund: '用户已申请退款',
|
||||
platform_cancelled: '上游履约已取消',
|
||||
carrier_manual_review: '载体人工复核',
|
||||
carrier_retry_pending: '载体失败待重试',
|
||||
carrier_expired: '载体已过期',
|
||||
carrier_closed: '载体已关闭',
|
||||
carrier_missing: '缺履约载体',
|
||||
state_inconsistent: '履约状态不一致',
|
||||
}
|
||||
const breakdown = row.issue_breakdown || []
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>
|
||||
{value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'}
|
||||
</Typography.Text>
|
||||
{row.pending_subreasons?.length ? (
|
||||
<Typography.Text type="secondary">
|
||||
{row.pending_subreasons
|
||||
.map((reason) => subreasonLabels[reason] || reason)
|
||||
.join(' / ')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{breakdown.length > 0 ? (
|
||||
breakdown.map((issue) => (
|
||||
<Typography.Text
|
||||
key={issue.issue_code}
|
||||
type={issue.issue_category === 'after_sales' ? 'danger' : undefined}
|
||||
>
|
||||
{subreasonLabels[issue.issue_code] || issue.issue_code} {issue.item_count} 项
|
||||
{issue.locked_item_count > 0 ? ` · 锁定 ${issue.locked_item_count}` : ''}
|
||||
</Typography.Text>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text>
|
||||
{value?.length
|
||||
? value.map((reason) => labels[reason] || reason).join(' / ')
|
||||
: '-'}
|
||||
</Typography.Text>
|
||||
{row.pending_subreasons?.length ? (
|
||||
<Typography.Text type="secondary">
|
||||
{row.pending_subreasons
|
||||
.map((reason) => subreasonLabels[reason] || reason)
|
||||
.join(' / ')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{row.locked_item_count > 0 ? (
|
||||
<Typography.Text type="warning">
|
||||
已人工指定渠道,等待补配置 {row.locked_item_count} 项
|
||||
@@ -411,16 +439,24 @@ function PendingPanel() {
|
||||
title: '指定渠道',
|
||||
width: 440,
|
||||
render: (_: unknown, row: PendingChannelGroup) => {
|
||||
const subreasons = row.pending_subreasons || []
|
||||
const hasConfigIssue = subreasons.some((reason) => PENDING_CONFIG_REASONS.has(reason))
|
||||
const hasManualReview = subreasons.includes('carrier_task_manual_review')
|
||||
const hasRetryableTerminal = subreasons.some((reason) =>
|
||||
PENDING_RETRYABLE_TERMINAL_REASONS.has(reason),
|
||||
)
|
||||
const hasNonRetryableTerminal = subreasons.some((reason) =>
|
||||
PENDING_NON_RETRYABLE_TERMINAL_REASONS.has(reason),
|
||||
)
|
||||
const hasCarrierIssue = row.pending_reasons.includes('no_carrier')
|
||||
const issueBreakdown = row.issue_breakdown || []
|
||||
const retryableIssueCodes = issueBreakdown
|
||||
.filter((issue) => issue.retryable)
|
||||
.map((issue) => issue.issue_code)
|
||||
const autoRetryableIssueCodes = issueBreakdown
|
||||
.filter((issue) => issue.auto_retryable)
|
||||
.map((issue) => issue.issue_code)
|
||||
const hasConfigIssue = Number(row.config_issue_item_count || 0) > 0
|
||||
const hasManualReview = Number(row.manual_review_item_count || 0) > 0
|
||||
const hasAfterSales = Number(row.after_sales_item_count || 0) > 0
|
||||
const hasSystemIssue = Number(row.system_issue_item_count || 0) > 0
|
||||
const hasRetryable = Number(row.retryable_unlocked_item_count || 0) > 0
|
||||
const hasAutoRetryable = Number(row.auto_retryable_unlocked_item_count || 0) > 0
|
||||
const hasOnlyRetryableIssues =
|
||||
issueBreakdown.length > 0 && issueBreakdown.every((issue) => issue.retryable)
|
||||
const hasOnlyAutoRetryableIssues =
|
||||
issueBreakdown.length > 0 && issueBreakdown.every((issue) => issue.auto_retryable)
|
||||
const canRunScopedActions = Boolean(issueCategory) || issueBreakdown.length <= 1
|
||||
const hasUnlockedItems = row.locked_item_count < row.item_count
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
@@ -439,24 +475,25 @@ function PendingPanel() {
|
||||
type="primary"
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)]
|
||||
!selections[rowKey(row)] ||
|
||||
!canRunScopedActions
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
保存渠道
|
||||
</Button>
|
||||
{hasConfigIssue ? (
|
||||
{canRunScopedActions && hasConfigIssue ? (
|
||||
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
|
||||
去补配置
|
||||
去补配置({row.config_issue_item_count})
|
||||
</Button>
|
||||
) : null}
|
||||
{hasManualReview ? (
|
||||
{canRunScopedActions && hasManualReview ? (
|
||||
<Button size="small" onClick={() => void navigate('/admin/tasks')}>
|
||||
处理人工复核
|
||||
处理人工复核({row.manual_review_item_count})
|
||||
</Button>
|
||||
) : null}
|
||||
{hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? (
|
||||
{canRunScopedActions && hasOnlyRetryableIssues && hasRetryable && hasUnlockedItems ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
@@ -464,8 +501,7 @@ function PendingPanel() {
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '确认重开履约载体',
|
||||
content:
|
||||
'只会处理该 SKU 下未锁定、没有活跃载体的订单项。已有失败或关闭任务会保留为历史,并重新向上游创建一次履约尝试。',
|
||||
content: `只会处理该 SKU 下 ${row.retryable_unlocked_item_count} 个未锁定、允许重试的订单项。退款、取消、人工复核和缺配置项不会被处理。已有失败或关闭任务会保留为历史。`,
|
||||
okText: '确认重试',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
@@ -473,18 +509,19 @@ function PendingPanel() {
|
||||
retryCarrierMutation.mutateAsync({
|
||||
row,
|
||||
retryInactiveCarrier: true,
|
||||
issueCodes: retryableIssueCodes,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
确认重试
|
||||
确认重试({row.retryable_unlocked_item_count})
|
||||
</Button>
|
||||
) : null}
|
||||
{!hasConfigIssue &&
|
||||
!hasManualReview &&
|
||||
!hasRetryableTerminal &&
|
||||
!hasNonRetryableTerminal &&
|
||||
hasCarrierIssue &&
|
||||
{canRunScopedActions &&
|
||||
hasOnlyAutoRetryableIssues &&
|
||||
hasAutoRetryable &&
|
||||
!hasAfterSales &&
|
||||
!hasSystemIssue &&
|
||||
row.configured_channels?.length &&
|
||||
hasUnlockedItems ? (
|
||||
<Tooltip title="只补建当前无活跃载体的未锁定订单,不会重开失败或关闭的旧任务">
|
||||
@@ -496,14 +533,15 @@ function PendingPanel() {
|
||||
retryCarrierMutation.mutate({
|
||||
row,
|
||||
retryInactiveCarrier: false,
|
||||
issueCodes: autoRetryableIssueCodes,
|
||||
})
|
||||
}
|
||||
>
|
||||
补载体
|
||||
补载体({row.auto_retryable_unlocked_item_count})
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{row.locked_item_count > 0 ? (
|
||||
{canRunScopedActions && row.locked_item_count > 0 ? (
|
||||
<Button
|
||||
size="small"
|
||||
loading={unlockMutation.isPending}
|
||||
@@ -520,33 +558,36 @@ function PendingPanel() {
|
||||
解除锁定
|
||||
</Button>
|
||||
) : null}
|
||||
<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>
|
||||
{canRunScopedActions ? (
|
||||
<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>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
@@ -555,7 +596,10 @@ function PendingPanel() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={`待分类(${query.data?.data?.total ?? 0} 个 SKU)`}
|
||||
title={`${
|
||||
PENDING_ISSUE_CATEGORY_OPTIONS.find((option) => option.value === (issueCategory || 'all'))
|
||||
?.label || '全部问题'
|
||||
}(${query.data?.data?.total ?? 0} 个 SKU)`}
|
||||
extra={
|
||||
<Space>
|
||||
<Input.Search
|
||||
@@ -593,6 +637,16 @@ function PendingPanel() {
|
||||
style={{ marginBottom: 12 }}
|
||||
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
|
||||
/>
|
||||
<Segmented
|
||||
block
|
||||
value={issueCategory || 'all'}
|
||||
options={PENDING_ISSUE_CATEGORY_OPTIONS}
|
||||
onChange={(value) => {
|
||||
setIssueCategory(value === 'all' ? '' : String(value))
|
||||
setPage(1)
|
||||
}}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey={(row) => rowKey(row)}
|
||||
size="small"
|
||||
|
||||
@@ -18,6 +18,24 @@ export type PendingChannelGroup = {
|
||||
revenue_fen: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
issue_breakdown: Array<{
|
||||
issue_code: string
|
||||
issue_category: string
|
||||
item_count: number
|
||||
locked_item_count: number
|
||||
retryable: boolean
|
||||
auto_retryable: boolean
|
||||
owner_role: string
|
||||
}>
|
||||
retryable_item_count: number
|
||||
retryable_unlocked_item_count: number
|
||||
auto_retryable_item_count: number
|
||||
auto_retryable_unlocked_item_count: number
|
||||
config_issue_item_count: number
|
||||
manual_review_item_count: number
|
||||
after_sales_item_count: number
|
||||
system_issue_item_count: number
|
||||
oldest_age_minutes: number
|
||||
}
|
||||
|
||||
export type PendingChannelGroupListResult = {
|
||||
@@ -208,6 +226,7 @@ export function fetchPendingChannelGroups(
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
issueCategory?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
|
||||
@@ -268,6 +287,7 @@ export function retryPendingCarriers(payload: {
|
||||
internalSku: string
|
||||
retryInactiveCarrier?: boolean
|
||||
includeLocked?: boolean
|
||||
issueCodes?: string[]
|
||||
}) {
|
||||
return apiPost<PendingCarrierRetryResult>(
|
||||
'/api/v1/admin/fulfillment/pending/retry-carriers',
|
||||
@@ -275,11 +295,15 @@ export function retryPendingCarriers(payload: {
|
||||
)
|
||||
}
|
||||
|
||||
export function unlockPendingChannelGroup(payload: { groupKey?: string; internalSku?: string }) {
|
||||
export function unlockPendingChannelGroup(payload: {
|
||||
groupKey?: string
|
||||
internalSku?: string
|
||||
issueCategory?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload)
|
||||
}
|
||||
|
||||
export function deletePendingChannelGroup(payload: { groupKey: string }) {
|
||||
export function deletePendingChannelGroup(payload: { groupKey: string; issueCategory?: string }) {
|
||||
return apiPost<DeletePendingChannelGroupResult>(
|
||||
'/api/v1/admin/fulfillment/pending/delete',
|
||||
payload,
|
||||
@@ -291,6 +315,7 @@ export function assignChannelByInternalSku(payload: {
|
||||
internalSku: string
|
||||
mappedInternalSku?: string
|
||||
channel: string
|
||||
issueCategory?: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
|
||||
Reference in New Issue
Block a user