fix(fulfillment): 修复载体重试并完善核销任务列表

This commit is contained in:
yml2213
2026-11-05 18:36:10 +08:00
parent 89a68b5734
commit e8b76b3b73
19 changed files with 945 additions and 104 deletions
@@ -33,6 +33,7 @@ import {
} from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
@@ -51,6 +52,7 @@ import {
retryPendingCarriers,
retryPendingWorkOrderRouting,
runFinancePipeline,
unlockPendingChannelGroup,
type FinancePipelineStatus,
type InternalSkuMapping,
type InternalSkuMappingMatchType,
@@ -97,6 +99,24 @@ 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 INTERNAL_SKU_MAPPING_TYPES: Array<{
@@ -131,6 +151,7 @@ export default function AdminFulfillmentChannelPage() {
function PendingPanel() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
@@ -195,8 +216,11 @@ function PendingPanel() {
})
const retryCarrierMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
retryPendingCarriers({ internalSku: row.internal_sku || '' }),
mutationFn: (input: { row: PendingChannelGroup; retryInactiveCarrier: boolean }) =>
retryPendingCarriers({
internalSku: input.row.internal_sku || '',
retryInactiveCarrier: input.retryInactiveCarrier,
}),
onSuccess: (result) => {
const data = result.data
const detail = [
@@ -216,6 +240,19 @@ function PendingPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '重试履约载体失败'),
})
const unlockMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
unlockPendingChannelGroup({
groupKey: row.group_key,
internalSku: row.internal_sku || '',
}),
onSuccess: (result) => {
message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`)
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '解除运营锁定失败'),
})
const deleteMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
deletePendingChannelGroup({ groupKey: row.group_key }),
@@ -326,6 +363,10 @@ function PendingPanel() {
carrier_task_manual_review: '载体人工复核',
carrier_not_attempted: '尚未尝试补载体',
carrier_attempt_failed: '载体创建失败',
carrier_task_retry_pending: '载体失败待确认重试',
carrier_task_expired: '载体已过期',
carrier_task_user_refund: '用户已申请退款',
carrier_task_cancelled: '上游履约已取消',
manual_config_missing: '缺人工配置',
work_rule_missing: '缺接单模板',
affiliate_mapping_missing: '缺自动发货映射',
@@ -345,7 +386,9 @@ function PendingPanel() {
</Typography.Text>
) : null}
{row.locked_item_count > 0 ? (
<Typography.Text type="warning">运营锁定 {row.locked_item_count} 项</Typography.Text>
<Typography.Text type="warning">
已人工指定渠道,等待补配置 {row.locked_item_count} 项
</Typography.Text>
) : null}
</Space>
)
@@ -367,71 +410,146 @@ function PendingPanel() {
{
title: '指定渠道',
width: 440,
render: (_: unknown, row: PendingChannelGroup) => (
<Space size={4}>
<Select
size="small"
style={{ width: 180 }}
placeholder={
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
}
options={CHANNEL_OPTIONS}
value={selections[rowKey(row)]}
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
/>
<Button
size="small"
type="primary"
disabled={
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
!selections[rowKey(row)]
}
loading={assignMutation.isPending}
onClick={() => assignMutation.mutate(row)}
>
保存渠道
</Button>
{row.configured_channels?.length ? (
<Tooltip title="重新确认渠道并补建工单或自动发货任务">
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 hasUnlockedItems = row.locked_item_count < row.item_count
return (
<Space size={4} wrap>
<Select
size="small"
style={{ width: 180 }}
placeholder={
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
}
options={CHANNEL_OPTIONS}
value={selections[rowKey(row)]}
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
/>
<Button
size="small"
type="primary"
disabled={
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
!selections[rowKey(row)]
}
loading={assignMutation.isPending}
onClick={() => assignMutation.mutate(row)}
>
保存渠道
</Button>
{hasConfigIssue ? (
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
去补配置
</Button>
) : null}
{hasManualReview ? (
<Button size="small" onClick={() => void navigate('/admin/tasks')}>
处理人工复核
</Button>
) : null}
{hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? (
<Button
size="small"
icon={<SyncOutlined />}
danger
loading={retryCarrierMutation.isPending}
onClick={() => retryCarrierMutation.mutate(row)}
onClick={() =>
Modal.confirm({
title: '确认重开履约载体',
content:
'只会处理该 SKU 下未锁定、没有活跃载体的订单项。已有失败或关闭任务会保留为历史,并重新向上游创建一次履约尝试。',
okText: '确认重试',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () =>
retryCarrierMutation.mutateAsync({
row,
retryInactiveCarrier: true,
}),
})
}
>
补载体
确认重试
</Button>
) : null}
{!hasConfigIssue &&
!hasManualReview &&
!hasRetryableTerminal &&
!hasNonRetryableTerminal &&
hasCarrierIssue &&
row.configured_channels?.length &&
hasUnlockedItems ? (
<Tooltip title="只补建当前无活跃载体的未锁定订单,不会重开失败或关闭的旧任务">
<Button
size="small"
icon={<SyncOutlined />}
loading={retryCarrierMutation.isPending}
onClick={() =>
retryCarrierMutation.mutate({
row,
retryInactiveCarrier: false,
})
}
>
补载体
</Button>
</Tooltip>
) : null}
{row.locked_item_count > 0 ? (
<Button
size="small"
loading={unlockMutation.isPending}
onClick={() =>
Modal.confirm({
title: '解除运营锁定',
content: `将解除该分组中 ${row.locked_item_count} 个订单项的运营锁定。若渠道配置仍缺失,订单会继续留在待分类队列。`,
okText: '解除锁定',
cancelText: '取消',
onOk: () => unlockMutation.mutateAsync(row),
})
}
>
解除锁定
</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>
) : 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>
</Space>
),
</Space>
)
},
},
]
@@ -59,6 +59,7 @@ type TaskFilterForm = {
executorKey?: string
status?: string
dateRange?: unknown
scope?: 'current' | 'all'
}
type TaskListAction = 'retry' | 'manual_review' | 'close'
@@ -150,15 +151,26 @@ export default function AdminTasksPage() {
status: searchParams.get('status') || '',
dateFrom: searchParams.get('dateFrom') || '',
dateTo: searchParams.get('dateTo') || '',
scope: (searchParams.get('scope') === 'all' ? 'all' : 'current') as 'all' | 'current',
}
const queryParams = useMemo(
() => ({ page, pageSize, ...filters }),
() => ({
page,
pageSize,
keyword: filters.keyword,
executorKey: filters.executorKey,
status: filters.status,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
currentOnly: filters.scope !== 'all',
}),
[
filters.dateFrom,
filters.dateTo,
filters.executorKey,
filters.keyword,
filters.scope,
filters.status,
page,
pageSize,
@@ -175,12 +187,21 @@ export default function AdminTasksPage() {
keyword: filters.keyword || undefined,
executorKey: filters.executorKey || undefined,
status: filters.status || undefined,
scope: filters.scope,
dateRange:
filters.dateFrom && filters.dateTo
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
: undefined,
})
}, [filters.dateFrom, filters.dateTo, filters.executorKey, filters.keyword, filters.status, form])
}, [
filters.dateFrom,
filters.dateTo,
filters.executorKey,
filters.keyword,
filters.scope,
filters.status,
form,
])
const columns: TableColumnsType<AdminTaskListItem> = [
{
@@ -507,6 +528,7 @@ export default function AdminTasksPage() {
keyword: undefined,
executorKey: undefined,
status: undefined,
scope: 'current',
dateRange: undefined,
})
setSearchParams(cleanParams({ page: 1, pageSize }))
@@ -548,6 +570,16 @@ export default function AdminTasksPage() {
style={{ minWidth: 170 }}
/>
</Form.Item>
<Form.Item name="scope">
<Select
placeholder="任务范围"
options={[
{ value: 'current', label: '当前任务' },
{ value: 'all', label: '全部历史尝试' },
]}
style={{ minWidth: 140 }}
/>
</Form.Item>
<Form.Item name="dateRange">
<AdminRangePicker />
</Form.Item>
@@ -1,7 +1,8 @@
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'
import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Input,
InputNumber,
Select,
Space,
@@ -27,6 +28,7 @@ import {
type VoucherConsumePolicy,
type VoucherConsumeProvider,
} from '@/services/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatDateTime } from '@/utils/date-time'
const PROVIDER_LABEL: Record<VoucherConsumeProvider, string> = {
@@ -90,16 +92,63 @@ const JOB_STATUS: Record<string, { color: string; label: string }> = {
cancelled: { color: 'default', label: '已取消' },
}
const PROVIDER_FILTER_OPTIONS: Array<{ value: VoucherConsumeProvider; label: string }> = [
{ value: 'kuaishou_industry', label: PROVIDER_LABEL.kuaishou_industry },
{ value: 'douyin_mashida', label: PROVIDER_LABEL.douyin_mashida },
]
const CHANNEL_FILTER_OPTIONS: Array<{ value: VoucherConsumeChannel; label: string }> = [
{ value: 'affiliate_dash', label: CHANNEL_LABEL.affiliate_dash },
{ value: 'work_order', label: CHANNEL_LABEL.work_order },
{ value: 'manual', label: CHANNEL_LABEL.manual },
{ value: 'third_party_voucher', label: CHANNEL_LABEL.third_party_voucher },
{ value: 'on_account', label: CHANNEL_LABEL.on_account },
]
const JOB_STATUS_FILTER_OPTIONS: Array<{
value: VoucherConsumeJob['status']
label: string
}> = [
{ value: 'pending', label: JOB_STATUS.pending!.label },
{ value: 'processing', label: JOB_STATUS.processing!.label },
{ value: 'succeeded', label: JOB_STATUS.succeeded!.label },
{ value: 'failed', label: JOB_STATUS.failed!.label },
{ value: 'cancelled', label: JOB_STATUS.cancelled!.label },
]
export default function VoucherConsumePolicyPanel() {
const queryClient = useQueryClient()
const [draft, setDraft] = useState<VoucherConsumePolicy | null>(null)
const [keywordDraft, setKeywordDraft] = useState('')
const [keyword, setKeyword] = useState('')
const [providerFilter, setProviderFilter] = useState<VoucherConsumeProvider | ''>('')
const [channelFilter, setChannelFilter] = useState<VoucherConsumeChannel | ''>('')
const [statusFilter, setStatusFilter] = useState<VoucherConsumeJob['status'] | ''>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const policyQuery = useQuery({
queryKey: ['voucher-consume-policy'],
queryFn: fetchVoucherConsumePolicy,
})
const jobsQuery = useQuery({
queryKey: ['voucher-consume-jobs'],
queryFn: () => fetchVoucherConsumeJobs({ page: 1, pageSize: 20 }),
queryKey: [
'voucher-consume-jobs',
keyword,
providerFilter,
channelFilter,
statusFilter,
page,
pageSize,
],
queryFn: () =>
fetchVoucherConsumeJobs({
keyword: keyword || undefined,
provider: providerFilter || undefined,
channel: channelFilter || undefined,
status: statusFilter || undefined,
page,
pageSize,
}),
refetchInterval: 10_000,
})
@@ -134,6 +183,16 @@ export default function VoucherConsumePolicyPanel() {
})
const rows = useMemo(() => buildPolicyRows(draft), [draft])
const hasJobFilters = Boolean(keyword || providerFilter || channelFilter || statusFilter)
function resetJobFilters() {
setKeywordDraft('')
setKeyword('')
setProviderFilter('')
setChannelFilter('')
setStatusFilter('')
setPage(1)
}
function updateChannel(
provider: VoucherConsumeProvider,
@@ -348,25 +407,92 @@ export default function VoucherConsumePolicyPanel() {
loading={policyQuery.isLoading}
/>
<Space className="full-width" style={{ justifyContent: 'space-between' }}>
<Space className="full-width" style={{ justifyContent: 'space-between' }} wrap>
<Typography.Title level={5} style={{ margin: 0 }}>
最近核销任务
</Typography.Title>
<Button
icon={<ReloadOutlined />}
onClick={() => void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] })}
>
刷新
</Button>
<Space wrap>
<Input.Search
allowClear
prefix={<SearchOutlined />}
placeholder="任务号 / 订单号 / 平台单号"
value={keywordDraft}
onChange={(event) => {
const value = event.target.value
setKeywordDraft(value)
if (!value) {
setKeyword('')
setPage(1)
}
}}
onSearch={(value) => {
setKeyword(String(value || '').trim())
setPage(1)
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="全部平台"
value={providerFilter || undefined}
options={PROVIDER_FILTER_OPTIONS}
onChange={(value: VoucherConsumeProvider | undefined) => {
setProviderFilter(value || '')
setPage(1)
}}
style={{ width: 150 }}
/>
<Select
allowClear
placeholder="全部渠道"
value={channelFilter || undefined}
options={CHANNEL_FILTER_OPTIONS}
onChange={(value: VoucherConsumeChannel | undefined) => {
setChannelFilter(value || '')
setPage(1)
}}
style={{ width: 150 }}
/>
<Select
allowClear
placeholder="全部状态"
value={statusFilter || undefined}
options={JOB_STATUS_FILTER_OPTIONS}
onChange={(value: VoucherConsumeJob['status'] | undefined) => {
setStatusFilter(value || '')
setPage(1)
}}
style={{ width: 130 }}
/>
<Button disabled={!hasJobFilters} onClick={resetJobFilters}>
重置
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() =>
void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] })
}
>
刷新
</Button>
</Space>
</Space>
<PercentTable
rowKey="id"
size="small"
columns={jobColumns}
dataSource={jobsQuery.data?.data.items || []}
loading={jobsQuery.isLoading}
pagination={false}
scroll={{ x: 1100 }}
loading={jobsQuery.isFetching}
pagination={buildAdminTablePagination({
current: jobsQuery.data?.data.page || page,
pageSize: jobsQuery.data?.data.pageSize || pageSize,
total: jobsQuery.data?.data.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1200 }}
/>
</Space>
)
@@ -222,7 +222,14 @@ export function saveVoucherConsumePolicy(payload: VoucherConsumePolicy) {
}
export function fetchVoucherConsumeJobs(
params: { status?: string; page?: number; pageSize?: number } = {},
params: {
keyword?: string
provider?: VoucherConsumeProvider
channel?: VoucherConsumeChannel
status?: VoucherConsumeJob['status']
page?: number
pageSize?: number
} = {},
) {
return apiGet<VoucherConsumeJobListResult>(
'/api/v1/admin/fulfillment/voucher-consume-jobs',
@@ -257,13 +264,21 @@ export function retryPendingWorkOrderRouting(
)
}
export function retryPendingCarriers(payload: { internalSku: string }) {
export function retryPendingCarriers(payload: {
internalSku: string
retryInactiveCarrier?: boolean
includeLocked?: boolean
}) {
return apiPost<PendingCarrierRetryResult>(
'/api/v1/admin/fulfillment/pending/retry-carriers',
payload,
)
}
export function unlockPendingChannelGroup(payload: { groupKey?: string; internalSku?: string }) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload)
}
export function deletePendingChannelGroup(payload: { groupKey: string }) {
return apiPost<DeletePendingChannelGroupResult>(
'/api/v1/admin/fulfillment/pending/delete',