优化履约待办分桶与补载体扫描
This commit is contained in:
@@ -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('重开历史载体必须二次确认且不会处理锁定项', () => {
|
||||
|
||||
Reference in New Issue
Block a user