优化履约待办分桶与补载体扫描

This commit is contained in:
yml
2026-11-14 19:49:54 +08:00
parent e97932dcdf
commit b03599eca7
17 changed files with 629 additions and 81 deletions
@@ -1,5 +1,5 @@
/**
* 履约渠道页:待分类队列 + 渠道规则 + 问题队列。
* 履约渠道页:履约待办队列 + 渠道规则 + 问题队列。
*
* 设计口径(2026-10-31 v2):
* · 渠道由 L1 规则决定,不再由「有没有任务」反推;
@@ -60,6 +60,7 @@ import {
saveChannelRule,
saveInternalSkuMapping,
type ChannelRule,
type FulfillmentQueueBucket,
type PendingChannelGroup,
} from '@/services/admin'
import { formatRedeemAmount } from '@/utils/redeem'
@@ -102,14 +103,14 @@ const ENABLED_FILTER_OPTIONS = [
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
const PENDING_ISSUE_CATEGORY_OPTIONS = [
{ value: 'all', label: '全部问题' },
{ value: 'classification', label: '未分类' },
const PENDING_QUEUE_BUCKET_OPTIONS: Array<{
value: FulfillmentQueueBucket
label: string
}> = [
{ value: 'actionable', label: '自动处理中' },
{ value: 'configuration', label: '配置待补' },
{ value: 'carrier', label: '载体处理' },
{ value: 'manual_review', label: '人工复核' },
{ value: 'after_sales', label: '售后关闭' },
{ value: 'system', label: '系统异常' },
{ value: 'operator_review', label: '人工处理' },
{ value: 'terminal', label: '售后/终止' },
]
const INTERNAL_SKU_MAPPING_TYPES: Array<{
@@ -130,7 +131,7 @@ export default function AdminFulfillmentChannelPage() {
<Tabs
defaultActiveKey="pending"
items={[
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
{ key: 'pending', label: '履约待办', children: <PendingPanel /> },
{ key: 'mappings', label: 'SKU 映射', children: <InternalSkuMappingsPanel /> },
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
@@ -146,15 +147,15 @@ function PendingPanel() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [keyword, setKeyword] = useState('')
const [issueCategory, setIssueCategory] = useState('')
const [queueBucket, setQueueBucket] = useState<FulfillmentQueueBucket>('actionable')
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, issueCategory, page, pageSize],
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, issueCategory }),
queryKey: ['fulfillment-pending', keyword, queueBucket, page, pageSize],
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, queueBucket }),
})
const assignMutation = useMutation({
@@ -166,7 +167,7 @@ function PendingPanel() {
? ''
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
channel: selections[rowKey(row)] || '',
issueCategory,
queueBucket,
}),
onSuccess: (result) => {
const productMessage = result.data.draftProductId ? '和标准商品' : ''
@@ -245,7 +246,7 @@ function PendingPanel() {
unlockPendingChannelGroup({
groupKey: row.group_key,
internalSku: row.internal_sku || '',
issueCategory,
queueBucket,
}),
onSuccess: (result) => {
message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`)
@@ -256,7 +257,7 @@ function PendingPanel() {
const deleteMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
deletePendingChannelGroup({ groupKey: row.group_key, issueCategory }),
deletePendingChannelGroup({ groupKey: row.group_key, queueBucket }),
onSuccess: (result) => {
const data = result.data
message.success(
@@ -456,7 +457,8 @@ function PendingPanel() {
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 canRunScopedActions = issueBreakdown.length <= 1
const canChangeChannel = queueBucket !== 'terminal'
const hasUnlockedItems = row.locked_item_count < row.item_count
return (
<Space size={4} wrap>
@@ -476,7 +478,8 @@ function PendingPanel() {
disabled={
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
!selections[rowKey(row)] ||
!canRunScopedActions
!canRunScopedActions ||
!canChangeChannel
}
loading={assignMutation.isPending}
onClick={() => assignMutation.mutate(row)}
@@ -607,8 +610,8 @@ function PendingPanel() {
return (
<Card
title={`${
PENDING_ISSUE_CATEGORY_OPTIONS.find((option) => option.value === (issueCategory || 'all'))
?.label || '全部问题'
PENDING_QUEUE_BUCKET_OPTIONS.find((option) => option.value === queueBucket)?.label ||
'履约待办'
}(${query.data?.data?.total ?? 0} 个 SKU)`}
extra={
<Space>
@@ -645,15 +648,20 @@ function PendingPanel() {
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
message="补载体自动扫描只处理“自动处理中”。缺配置、失败重试、人工复核、售后和已终止订单分别留在对应队列,不再占用自动扫描。指定渠道会先保存渠道和草稿标准商品;忽略归档只影响派生流程,不删除原始订单。"
/>
<Segmented
block
value={issueCategory || 'all'}
options={PENDING_ISSUE_CATEGORY_OPTIONS}
value={queueBucket}
options={PENDING_QUEUE_BUCKET_OPTIONS.map((option) => ({
value: option.value,
label: `${option.label} ${query.data?.data?.bucketCounts?.[option.value] ?? 0}`,
}))}
onChange={(value) => {
setIssueCategory(value === 'all' ? '' : String(value))
setQueueBucket(String(value) as FulfillmentQueueBucket)
setPage(1)
setSelections({})
setInternalSkuDrafts({})
}}
style={{ marginBottom: 12 }}
/>
@@ -1,5 +1,7 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
export type FulfillmentQueueBucket = 'actionable' | 'configuration' | 'operator_review' | 'terminal'
/** 待分类队列(按内部 SKU 聚合)。 */
export type PendingChannelGroup = {
group_key: string
@@ -12,6 +14,7 @@ export type PendingChannelGroup = {
sku_codes: string[]
pending_reasons: string[]
pending_subreasons: string[]
queue_buckets: FulfillmentQueueBucket[]
configured_channels: string[]
locked_item_count: number
item_count: number
@@ -43,6 +46,7 @@ export type PendingChannelGroupListResult = {
total: number
page: number
pageSize: number
bucketCounts: Record<FulfillmentQueueBucket, number>
}
/** 渠道规则(L1)。 */
@@ -230,6 +234,7 @@ export function fetchPendingChannelGroups(
pageSize?: number
keyword?: string
issueCategory?: string
queueBucket?: FulfillmentQueueBucket
} = {},
) {
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
@@ -302,11 +307,16 @@ export function unlockPendingChannelGroup(payload: {
groupKey?: string
internalSku?: string
issueCategory?: string
queueBucket?: FulfillmentQueueBucket
}) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload)
}
export function deletePendingChannelGroup(payload: { groupKey: string; issueCategory?: string }) {
export function deletePendingChannelGroup(payload: {
groupKey: string
issueCategory?: string
queueBucket?: FulfillmentQueueBucket
}) {
return apiPost<DeletePendingChannelGroupResult>(
'/api/v1/admin/fulfillment/pending/delete',
payload,
@@ -319,6 +329,7 @@ export function assignChannelByInternalSku(payload: {
mappedInternalSku?: string
channel: string
issueCategory?: string
queueBucket?: FulfillmentQueueBucket
note?: string
}) {
return apiPost<{
@@ -7,7 +7,7 @@ const panel = readFileSync(
'utf8',
)
test('待分类队列按问题类型拆分动作', () => {
test('履约待办按四个工作桶拆分动作', () => {
assert.ok(panel.includes('去补配置'))
assert.ok(panel.includes('处理人工复核'))
assert.ok(panel.includes('确认重试'))
@@ -15,9 +15,12 @@ test('待分类队列按问题类型拆分动作', () => {
assert.ok(panel.includes('等待补配置'))
assert.ok(panel.includes('用户已申请退款'))
assert.ok(panel.includes('上游履约已取消'))
assert.ok(panel.includes('PENDING_ISSUE_CATEGORY_OPTIONS'))
assert.ok(panel.includes('PENDING_QUEUE_BUCKET_OPTIONS'))
assert.ok(panel.includes("label: '自动处理中'"))
assert.ok(panel.includes("label: '配置待补'"))
assert.ok(panel.includes("label: '售后关闭'"))
assert.ok(panel.includes("label: '人工处理'"))
assert.ok(panel.includes("label: '售后/终止'"))
assert.ok(panel.includes("queueBucket !== 'terminal'"))
})
test('重开历史载体必须二次确认且不会处理锁定项', () => {