feat: 完善履约路由、订单服务与自动核销

This commit is contained in:
yml2213
2026-11-05 14:59:58 +08:00
parent 517d763f17
commit 1df88cc639
54 changed files with 3166 additions and 318 deletions
@@ -36,6 +36,7 @@ import { useEffect, useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import VoucherConsumePolicyPanel from './panels/VoucherConsumePolicyPanel'
import {
assignChannelByInternalSku,
deletePendingChannelGroup,
@@ -47,6 +48,7 @@ import {
fetchInternalSkuMappings,
fetchPendingChannelGroups,
fetchPipelineStatus,
retryPendingCarriers,
retryPendingWorkOrderRouting,
runFinancePipeline,
type FinancePipelineStatus,
@@ -118,6 +120,7 @@ export default function AdminFulfillmentChannelPage() {
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
{ key: 'pipelines', label: '管道状态', children: <PipelinesPanel /> },
{ key: 'voucher-consume', label: '自动核销', children: <VoucherConsumePolicyPanel /> },
]}
/>
</div>
@@ -178,6 +181,28 @@ function PendingPanel() {
message.error(error instanceof Error ? error.message : '重新匹配接单模板失败'),
})
const retryCarrierMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
retryPendingCarriers({ internalSku: row.internal_sku }),
onSuccess: (result) => {
const data = result.data
const detail = [
`扫描 ${data.scanned} 个订单项`,
`创建工单 ${data.workOrdersCreated} 个`,
`创建自动任务 ${data.affiliateTasksCreated} 个`,
`已分流 ${data.assigned} 个`,
].join(',')
if (data.remaining > 0) {
message.warning(`${detail},仍有 ${data.remaining} 个待处理`)
} else {
message.success(detail)
}
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '重试履约载体失败'),
})
const deleteMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
deletePendingChannelGroup({ internalSku: row.internal_sku }),
@@ -232,7 +257,25 @@ function PendingPanel() {
)
},
},
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
{
title: '内部SKU',
dataIndex: 'internal_sku',
width: 220,
render: (value: string, row: PendingChannelGroup) => (
<Space direction="vertical" size={2}>
<Typography.Text delete={!value}>{value || '未识别内部 SKU'}</Typography.Text>
{row.configured_channels?.length ? (
<Space size={2} wrap>
{row.configured_channels.map((channel) => (
<Tag key={channel} color={CHANNEL_COLOR[channel] || 'default'}>
{CHANNEL_OPTIONS.find((option) => option.value === channel)?.label || channel}
</Tag>
))}
</Space>
) : null}
</Space>
),
},
{
title: '平台SKU',
dataIndex: 'sku_codes',
@@ -243,18 +286,45 @@ function PendingPanel() {
{
title: '待分类原因',
dataIndex: 'pending_reasons',
width: 160,
render: (value: string[] | undefined) => {
width: 240,
render: (value: string[] | undefined, row: PendingChannelGroup) => {
const labels: Record<string, string> = {
no_internal_sku: '无内部SKU',
no_channel_capability: '无渠道能力',
no_carrier: '无履约载体',
routing_conflict: '渠道冲突',
}
return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'
const subreasonLabels: Record<string, string> = {
carrier_task_closed: '载体已关闭',
carrier_task_manual_review: '载体人工复核',
carrier_not_attempted: '尚未尝试补载体',
carrier_attempt_failed: '载体创建失败',
manual_config_missing: '缺人工配置',
work_rule_missing: '缺接单模板',
affiliate_mapping_missing: '缺自动发货映射',
existing_carrier_conflict: '已有其它载体',
carrier_config_ambiguous: '载体配置冲突',
}
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}
{row.locked_item_count > 0 ? (
<Typography.Text type="warning">运营锁定 {row.locked_item_count} 项</Typography.Text>
) : null}
</Space>
)
},
},
{ title: '订单数', dataIndex: 'item_count', width: 90 },
{ title: '订单项', dataIndex: 'item_count', width: 90 },
{
title: '收入',
dataIndex: 'revenue_fen',
@@ -269,7 +339,7 @@ function PendingPanel() {
},
{
title: '指定渠道',
width: 360,
width: 440,
render: (_: unknown, row: PendingChannelGroup) => (
<Space size={4}>
<Select
@@ -289,6 +359,18 @@ function PendingPanel() {
>
指定
</Button>
{row.configured_channels?.length ? (
<Tooltip title="重新确认渠道并补建工单或自动发货任务">
<Button
size="small"
icon={<SyncOutlined />}
loading={retryCarrierMutation.isPending}
onClick={() => retryCarrierMutation.mutate(row)}
>
补载体
</Button>
</Tooltip>
) : null}
<Tooltip title="删除待分类记录,不删除原始订单">
<Button
danger
@@ -2,8 +2,10 @@ import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
DownOutlined,
PlusOutlined,
ReloadOutlined,
RightOutlined,
SaveOutlined,
SearchOutlined,
} from '@ant-design/icons'
@@ -41,6 +43,7 @@ import {
retryAdminOrderServiceAutoPublishJob,
saveAdminOrderServiceProfile,
type AdminOrderServiceAutoPublishJob,
type AdminOrderServiceCategory,
type AdminOrderServiceProfile,
type OrderServiceField,
type OrderServiceMaterialConfirmation,
@@ -81,6 +84,11 @@ type OrderServiceFormValues = {
statusSteps: OrderServiceStatusSteps
}
type OrderServiceCategoryNode = {
category: AdminOrderServiceCategory
children: AdminOrderServiceCategory[]
}
const DEFAULT_SCREENSHOTS: ScreenshotForm = {
enabled: true,
required: true,
@@ -245,6 +253,7 @@ export default function OrderServicePanel() {
const [form] = Form.useForm<OrderServiceFormValues>()
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
const [categoryKeyword, setCategoryKeyword] = useState('')
const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<number>>(() => new Set())
const [fields, setFields] = useState<OrderServiceField[]>([])
const [guideImage, setGuideImage] = useState<UploadedFile | Record<string, unknown> | null>(null)
const [saving, setSaving] = useState(false)
@@ -268,22 +277,56 @@ export default function OrderServicePanel() {
[profiles],
)
const normalizedKeyword = categoryKeyword.trim().toLowerCase()
const filteredCategories = useMemo(
() =>
categories.filter((item) => {
if (!normalizedKeyword) return true
return (
item.name.toLowerCase().includes(normalizedKeyword) ||
item.categoryKey.toLowerCase().includes(normalizedKeyword)
)
}),
[categories, normalizedKeyword],
const categoryTree = useMemo<OrderServiceCategoryNode[]>(() => {
const childrenByParent = new Map<number, AdminOrderServiceCategory[]>()
const roots: AdminOrderServiceCategory[] = []
for (const category of categories) {
if (category.parentId) {
const children = childrenByParent.get(category.parentId) || []
children.push(category)
childrenByParent.set(category.parentId, children)
} else {
roots.push(category)
}
}
const matches = (category: AdminOrderServiceCategory) =>
!normalizedKeyword ||
category.name.toLowerCase().includes(normalizedKeyword) ||
category.categoryKey.toLowerCase().includes(normalizedKeyword)
return roots.flatMap((category) => {
const children = childrenByParent.get(category.categoryId) || []
if (!normalizedKeyword) return [{ category, children }]
if (matches(category)) return [{ category, children }]
const matchedChildren = children.filter(matches)
return matchedChildren.length > 0 ? [{ category, children: matchedChildren }] : []
})
}, [categories, normalizedKeyword])
const filteredCategoryCount = categoryTree.reduce(
(total, node) => total + 1 + node.children.length,
0,
)
const configuredCount = categories.filter((item) =>
profileByCategoryId.has(item.categoryId),
).length
const configuredCount = new Set(
profiles.filter((item) => item.status !== 'archived').map((item) => item.categoryId),
).size
const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null
const profile = (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null
const ownProfile =
(selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null
const parentCategory = selectedCategory?.parentId
? categories.find((item) => item.categoryId === selectedCategory.parentId) || null
: null
const parentProfileCandidate = parentCategory
? profileByCategoryId.get(parentCategory.categoryId) || null
: null
const parentProfile =
parentProfileCandidate?.currentVersionNo && parentProfileCandidate.status !== 'archived'
? parentProfileCandidate
: null
const profile = ownProfile || parentProfile
const inheritsParentProfile = !ownProfile && Boolean(parentProfile)
const runtimeInheritsParent =
Boolean(parentProfile) &&
(!ownProfile || ownProfile.status === 'archived' || !ownProfile.currentVersionNo)
const guideFiles = useMemo(() => {
const image = resolveGuideImage(guideImage)
return image ? [image] : []
@@ -311,7 +354,10 @@ export default function OrderServicePanel() {
function applyProfile(nextProfile: AdminOrderServiceProfile | null) {
form.setFieldsValue({
name: nextProfile?.name || selectedCategory?.name || '',
name:
inheritsParentProfile && selectedCategory
? selectedCategory.name
: nextProfile?.name || selectedCategory?.name || '',
title: nextProfile?.title || '',
description: nextProfile?.description || '',
guideImageDescription: nextProfile?.guideImageDescription || '',
@@ -436,7 +482,11 @@ export default function OrderServicePanel() {
successText: values.successText,
postSubmitText: values.postSubmitText,
})
message.success('草稿已保存;点击“发布新版本”后才会影响新工单')
message.success(
inheritsParentProfile
? '已创建二级分类独立草稿;发布后优先使用该配置'
: '草稿已保存;点击“发布新版本”后才会影响新工单',
)
await refresh()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
@@ -446,13 +496,13 @@ export default function OrderServicePanel() {
}
async function handlePublish() {
if (!profile) {
message.warning('请先保存配置草稿')
if (!ownProfile) {
message.warning('当前分类继承父级配置,请先保存二级分类独立草稿')
return
}
setPublishing(true)
try {
const response = await publishAdminOrderServiceProfile(profile.id)
const response = await publishAdminOrderServiceProfile(ownProfile.id)
const backfilled = Number(response.data.backfilledPendingOrders || 0)
message.success(
backfilled > 0
@@ -468,10 +518,10 @@ export default function OrderServicePanel() {
}
async function handleArchive() {
if (!profile) return
if (!ownProfile) return
setPublishing(true)
try {
await archiveAdminOrderServiceProfile(profile.id)
await archiveAdminOrderServiceProfile(ownProfile.id)
message.success('配置已归档;已有工单快照不受影响')
await refresh()
} catch (error) {
@@ -571,7 +621,7 @@ export default function OrderServicePanel() {
<Space size={6}>
<Text strong>接单分类</Text>
<Text type="secondary">
{filteredCategories.length}/{categories.length}
{filteredCategoryCount}/{categories.length}
</Text>
</Space>
<Input
@@ -584,34 +634,97 @@ export default function OrderServicePanel() {
/>
</div>
<div className="order-service-category-list">
{filteredCategories.map((category) => {
{categoryTree.map(({ category, children }) => {
const rowProfile = profileByCategoryId.get(category.categoryId)
const status = resolveProfileStatus(rowProfile)
const expanded =
Boolean(normalizedKeyword) || expandedCategoryIds.has(category.categoryId)
return (
<button
key={category.categoryId}
type="button"
className={`order-service-category-item${
category.categoryId === selectedCategoryId ? ' is-active' : ''
}`}
onClick={() => setSelectedCategoryId(category.categoryId)}
>
<span className="order-service-category-main">
<span className="order-service-category-name">{category.name}</span>
</span>
<span className="order-service-category-meta">
<span className="order-service-category-key">
{category.categoryKey}
{category.status === 'active' ? '' : ' · 已停用'}
<div className="order-service-category-group" key={category.categoryId}>
<button
type="button"
className={`order-service-category-item${
category.categoryId === selectedCategoryId ? ' is-active' : ''
}`}
onClick={() => {
setSelectedCategoryId(category.categoryId)
if (children.length === 0) return
setExpandedCategoryIds((current) => {
const next = new Set(current)
if (next.has(category.categoryId)) {
next.delete(category.categoryId)
} else {
next.add(category.categoryId)
}
return next
})
}}
>
<span className="order-service-category-main">
{children.length > 0 ? (
<span className="order-service-category-toggle">
{expanded ? <DownOutlined /> : <RightOutlined />}
</span>
) : (
<span className="order-service-category-toggle is-empty" />
)}
<span className="order-service-category-name">{category.name}</span>
{children.length > 0 ? (
<Tag className="order-service-category-count">
{children.length} 个二级
</Tag>
) : null}
</span>
<span className={`order-service-status-pill is-${status.tone}`}>
{status.label}
<span className="order-service-category-meta">
<span className="order-service-category-key">
{category.categoryKey}
{category.status === 'active' ? '' : ' · 已停用'}
</span>
<span className={`order-service-status-pill is-${status.tone}`}>
{status.label}
</span>
</span>
</span>
</button>
</button>
{expanded ? (
<div className="order-service-subcategory-list">
{children.map((child) => {
const childProfile = profileByCategoryId.get(child.categoryId)
const childStatus = resolveInheritedProfileStatus(
childProfile,
rowProfile,
)
return (
<button
key={child.categoryId}
type="button"
className={`order-service-category-item is-child${
child.categoryId === selectedCategoryId ? ' is-active' : ''
}`}
onClick={() => setSelectedCategoryId(child.categoryId)}
>
<span className="order-service-category-main">
<span className="order-service-category-name">{child.name}</span>
</span>
<span className="order-service-category-meta">
<span className="order-service-category-key">
{child.categoryKey}
{child.status === 'active' ? '' : ' · 已停用'}
</span>
<span
className={`order-service-status-pill is-${childStatus.tone}`}
>
{childStatus.label}
</span>
</span>
</button>
)
})}
</div>
) : null}
</div>
)
})}
{filteredCategories.length === 0 ? (
{categoryTree.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有匹配分类" />
) : null}
</div>
@@ -627,21 +740,39 @@ export default function OrderServicePanel() {
<Tag color={selectedCategory.status === 'active' ? 'green' : 'default'}>
{selectedCategory.status === 'active' ? '启用' : '停用'}
</Tag>
<Tag color={profile?.status === 'published' ? 'green' : 'orange'}>
{profile?.status === 'published'
? `线上 v${profile.currentVersionNo || 1}`
: profile?.currentVersionNo
? `线上 v${profile.currentVersionNo} · 有草稿`
: '尚未发布'}
<Tag
color={
ownProfile?.status === 'published'
? 'green'
: inheritsParentProfile
? 'blue'
: 'orange'
}
>
{ownProfile
? resolveProfileStatus(ownProfile).label
: inheritsParentProfile
? `继承「${parentCategory?.name || '父级'}」v${parentProfile?.currentVersionNo}`
: '尚未配置'}
</Tag>
{ownProfile && runtimeInheritsParent ? (
<Tag color="gold">
线上继承「{parentCategory?.name || '父级'}」v
{parentProfile?.currentVersionNo}
</Tag>
) : null}
</Space>
<Text type="secondary">分类标识:{selectedCategory.categoryKey}</Text>
</div>
<Space size={8} wrap>
<Button loading={publishing} onClick={() => void handlePublish()}>
<Button
disabled={!ownProfile}
loading={publishing}
onClick={() => void handlePublish()}
>
发布新版本
</Button>
{profile ? (
{ownProfile ? (
<Button danger loading={publishing} onClick={() => void handleArchive()}>
归档
</Button>
@@ -651,9 +782,17 @@ export default function OrderServicePanel() {
<Alert
className="order-service-tip"
type="info"
type={runtimeInheritsParent ? 'warning' : 'info'}
showIcon
message="一个分类复用一套资料配置,接单模板通过“所属分类”自动关联。"
message={
inheritsParentProfile
? `当前二级分类没有独立配置,线上直接继承一级分类「${parentCategory?.name || '父级'}」的已发布模板。修改并保存后会创建二级分类独立草稿。`
: ownProfile && runtimeInheritsParent
? `当前二级分类草稿尚未发布,线上仍继承一级分类「${parentCategory?.name || '父级'}」的已发布模板。`
: selectedCategory.parentId
? '二级分类优先使用独立配置;没有独立已发布配置时自动继承一级分类模板。'
: '一级分类作为默认模板,其下二级分类未独立配置时会自动继承。'
}
/>
<Form form={form} layout="vertical" onFinish={handleSave}>
@@ -1204,6 +1343,33 @@ function resolveProfileStatus(profile: AdminOrderServiceProfile | undefined) {
return { label: '已归档', tone: 'muted' }
}
function resolveInheritedProfileStatus(
profile: AdminOrderServiceProfile | undefined,
parentProfile: AdminOrderServiceProfile | undefined,
) {
if (profile) {
const status = resolveProfileStatus(profile)
if (profile.status === 'archived' && parentProfile?.currentVersionNo) {
return { label: `已归档 · 继承 v${parentProfile.currentVersionNo}`, tone: 'muted' }
}
if (
!profile.currentVersionNo &&
profile.status !== 'archived' &&
parentProfile?.currentVersionNo
) {
return { label: `草稿 · 继承 v${parentProfile.currentVersionNo}`, tone: 'warning' }
}
return status
}
if (parentProfile?.currentVersionNo && parentProfile.status !== 'archived') {
return {
label: `继承 v${parentProfile.currentVersionNo}`,
tone: 'inherited',
}
}
return { label: '未配置', tone: 'muted' }
}
function OrderServiceSectionTitle({
index,
title,
@@ -0,0 +1,377 @@
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
InputNumber,
Select,
Space,
Switch,
Tag,
Tooltip,
Typography,
message,
type TableColumnsType,
} from 'antd'
import { useEffect, useMemo, useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import {
fetchVoucherConsumeJobs,
fetchVoucherConsumePolicy,
retryVoucherConsumeJob,
saveVoucherConsumePolicy,
type VoucherChannelConsumePolicy,
type VoucherConsumeChannel,
type VoucherConsumeJob,
type VoucherConsumeMode,
type VoucherConsumePolicy,
type VoucherConsumeProvider,
} from '@/services/admin'
import { formatDateTime } from '@/utils/date-time'
const PROVIDER_LABEL: Record<VoucherConsumeProvider, string> = {
kuaishou_industry: '快手电子凭证',
douyin_mashida: '抖音码速达',
}
const CHANNEL_LABEL: Record<VoucherConsumeChannel, string> = {
affiliate_dash: '自动发货',
work_order: '接单工单',
manual: '人工客服',
third_party_voucher: '第三方卡券',
}
const MODE_OPTIONS: Record<
VoucherConsumeChannel,
Array<{ value: VoucherConsumeMode; label: string }>
> = {
affiliate_dash: [
{ value: 'after_link_generated', label: '领取链接生成后' },
{ value: 'after_task_created', label: '履约任务创建后' },
{ value: 'manual', label: '人工核销' },
],
work_order: [
{ value: 'after_work_order_published_or_assigned', label: '发布或指派后' },
{ value: 'after_work_order_published', label: '仅发布后' },
{ value: 'after_work_order_assigned', label: '仅指派后' },
{ value: 'after_task_created', label: '履约任务创建后' },
{ value: 'manual', label: '人工核销' },
],
manual: [
{ value: 'after_task_created', label: '人工任务创建后' },
{ value: 'manual', label: '人工核销' },
],
third_party_voucher: [{ value: 'manual', label: '未接入自动核销' }],
}
type PolicyRow = {
key: string
provider: VoucherConsumeProvider
channel: VoucherConsumeChannel
policy: VoucherChannelConsumePolicy
}
const JOB_STATUS: Record<string, { color: string; label: string }> = {
pending: { color: 'gold', label: '待执行' },
processing: { color: 'processing', label: '执行中' },
succeeded: { color: 'green', label: '成功' },
failed: { color: 'red', label: '失败' },
cancelled: { color: 'default', label: '已取消' },
}
export default function VoucherConsumePolicyPanel() {
const queryClient = useQueryClient()
const [draft, setDraft] = useState<VoucherConsumePolicy | null>(null)
const policyQuery = useQuery({
queryKey: ['voucher-consume-policy'],
queryFn: fetchVoucherConsumePolicy,
})
const jobsQuery = useQuery({
queryKey: ['voucher-consume-jobs'],
queryFn: () => fetchVoucherConsumeJobs({ page: 1, pageSize: 20 }),
refetchInterval: 10_000,
})
useEffect(() => {
const policy = policyQuery.data?.data
if (policy) {
setDraft(structuredClone(policy))
}
}, [policyQuery.data])
const saveMutation = useMutation({
mutationFn: (policy: VoucherConsumePolicy) => saveVoucherConsumePolicy(policy),
onSuccess: (response) => {
setDraft(structuredClone(response.data))
message.success('核销策略已保存')
void queryClient.invalidateQueries({ queryKey: ['voucher-consume-policy'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '保存核销策略失败')
},
})
const retryMutation = useMutation({
mutationFn: retryVoucherConsumeJob,
onSuccess: () => {
message.success('核销任务已重新排队')
void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '重试核销任务失败')
},
})
const rows = useMemo(() => buildPolicyRows(draft), [draft])
function updateChannel(
provider: VoucherConsumeProvider,
channel: VoucherConsumeChannel,
patch: Partial<VoucherChannelConsumePolicy>,
) {
setDraft((current) => {
if (!current) return current
return {
...current,
platforms: {
...current.platforms,
[provider]: {
...current.platforms[provider],
channels: {
...current.platforms[provider].channels,
[channel]: {
...current.platforms[provider].channels[channel],
...patch,
},
},
},
},
}
})
}
function updatePlatformEnabled(provider: VoucherConsumeProvider, enabled: boolean) {
setDraft((current) => {
if (!current) return current
return {
...current,
platforms: {
...current.platforms,
[provider]: {
...current.platforms[provider],
enabled,
},
},
}
})
}
const policyColumns: TableColumnsType<PolicyRow> = [
{
title: '平台',
dataIndex: 'provider',
width: 150,
render: (provider: VoucherConsumeProvider) => PROVIDER_LABEL[provider],
},
{
title: '履约渠道',
dataIndex: 'channel',
width: 140,
render: (channel: VoucherConsumeChannel) => CHANNEL_LABEL[channel],
},
{
title: '自动核销',
width: 100,
render: (_, row) => (
<Switch
size="small"
checked={row.policy.enabled}
disabled={row.channel === 'third_party_voucher'}
onChange={(enabled) => updateChannel(row.provider, row.channel, { enabled })}
/>
),
},
{
title: '触发时间',
width: 220,
render: (_, row) => (
<Select
value={row.policy.mode}
options={MODE_OPTIONS[row.channel]}
disabled={row.channel === 'third_party_voucher' || !row.policy.enabled}
onChange={(mode) => updateChannel(row.provider, row.channel, { mode })}
style={{ width: '100%' }}
/>
),
},
{
title: '延迟',
width: 130,
render: (_, row) => (
<InputNumber
min={0}
max={3600}
precision={0}
value={row.policy.delaySeconds}
disabled={row.channel === 'third_party_voucher' || !row.policy.enabled}
onChange={(value) =>
updateChannel(row.provider, row.channel, {
delaySeconds: Math.max(0, Number(value || 0)),
})
}
addonAfter="秒"
style={{ width: '100%' }}
/>
),
},
]
const jobColumns: TableColumnsType<VoucherConsumeJob> = [
{ title: '任务', dataIndex: 'id', width: 80 },
{
title: '平台',
dataIndex: 'provider',
width: 140,
render: (provider: VoucherConsumeProvider) => PROVIDER_LABEL[provider],
},
{
title: '渠道',
dataIndex: 'channel',
width: 110,
render: (channel: VoucherConsumeChannel) => CHANNEL_LABEL[channel],
},
{
title: '订单号',
dataIndex: 'platform_order_id',
width: 170,
render: (value: string) => value || '-',
},
{
title: '计划时间',
dataIndex: 'due_at',
width: 170,
render: (value: string) => formatDateTime(value),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (status: string) => {
const current = JOB_STATUS[status] || { color: 'default', label: status }
return <Tag color={current.color}>{current.label}</Tag>
},
},
{
title: '尝试',
width: 80,
render: (_, row) => `${row.attempt_count}/${row.max_attempts}`,
},
{
title: '错误',
dataIndex: 'last_error',
render: (value: string) =>
value ? (
<Tooltip title={value}>
<Typography.Text ellipsis style={{ maxWidth: 260 }}>
{value}
</Typography.Text>
</Tooltip>
) : (
'-'
),
},
{
title: '',
width: 80,
render: (_, row) =>
row.status === 'failed' || row.status === 'cancelled' ? (
<Button
size="small"
onClick={() => retryMutation.mutate(row.id)}
loading={retryMutation.isPending}
>
重试
</Button>
) : null,
},
]
return (
<Space direction="vertical" size={16} className="full-width">
<Space size={20} wrap>
<Space size={8}>
<Typography.Text strong>总开关</Typography.Text>
<Switch
checked={draft?.enabled !== false}
onChange={(enabled) =>
setDraft((current) => (current ? { ...current, enabled } : current))
}
/>
</Space>
{(['kuaishou_industry', 'douyin_mashida'] as const).map((provider) => (
<Space key={provider} size={8}>
<Typography.Text>{PROVIDER_LABEL[provider]}</Typography.Text>
<Switch
checked={draft?.platforms[provider].enabled !== false}
disabled={draft?.enabled === false}
onChange={(enabled) => updatePlatformEnabled(provider, enabled)}
/>
</Space>
))}
<Button
type="primary"
icon={<SaveOutlined />}
disabled={!draft}
loading={saveMutation.isPending}
onClick={() => draft && saveMutation.mutate(draft)}
>
保存
</Button>
</Space>
<PercentTable
rowKey={(row) => row.key}
size="small"
columns={policyColumns}
dataSource={rows}
pagination={false}
loading={policyQuery.isLoading}
/>
<Space className="full-width" style={{ justifyContent: 'space-between' }}>
<Typography.Title level={5} style={{ margin: 0 }}>
最近核销任务
</Typography.Title>
<Button
icon={<ReloadOutlined />}
onClick={() => void queryClient.invalidateQueries({ queryKey: ['voucher-consume-jobs'] })}
>
刷新
</Button>
</Space>
<PercentTable
rowKey="id"
size="small"
columns={jobColumns}
dataSource={jobsQuery.data?.data.items || []}
loading={jobsQuery.isLoading}
pagination={false}
scroll={{ x: 1100 }}
/>
</Space>
)
}
function buildPolicyRows(policy: VoucherConsumePolicy | null): PolicyRow[] {
if (!policy) return []
return (Object.keys(policy.platforms) as VoucherConsumeProvider[]).flatMap((provider) =>
(Object.keys(policy.platforms[provider].channels) as VoucherConsumeChannel[]).map(
(channel) => ({
key: `${provider}:${channel}`,
provider,
channel,
policy: policy.platforms[provider].channels[channel],
}),
),
)
}
@@ -726,9 +726,11 @@ export default function WorkOrdersPanel() {
workerId: assignWorkerId,
}),
(response) =>
response.data.voucherConsume.voucherCount > 0
? '电子凭证已自动核销,订单已指派给打手'
: '已指派打手',
!response.data.voucherConsume.ok
? '订单已指派,但自动核销未安排成功'
: response.data.voucherConsume.queuedCount > 0
? '订单已指派,自动核销任务已排队'
: '已指派打手',
)
if (!succeeded) return
setAssignOrder(null)
@@ -1017,10 +1019,10 @@ export default function WorkOrdersPanel() {
() => publishAdminWorkOrder(row.workOrderId),
(response) =>
!response.data.voucherConsume.ok
? '订单已发布,但电子凭证核销未完成'
? '订单已发布,但自动核销未安排成功'
: response.data.hallVisible
? response.data.voucherConsume.voucherCount > 0
? '订单已发布,电子凭证已顺带核销'
? response.data.voucherConsume.queuedCount > 0
? '订单已发布,自动核销任务已排队'
: '订单已展示在大厅'
: '订单已进入大厅等待队列',
)
@@ -3334,7 +3336,7 @@ export default function WorkOrdersPanel() {
>
<Space direction="vertical" size={12} className="full-width">
<Typography.Paragraph type="secondary">
指派后订单直接进入该打手的「我的订单」,不再出现在抢单大厅;有关联电子凭证时会先自动核销,再按订单规则冻结打手押金。核销后取消指派不会恢复电子凭证。
指派后订单直接进入该打手的「我的订单」,不再出现在抢单大厅;有关联电子凭证时会按核销策略安排延迟核销,并按订单规则冻结打手押金。
</Typography.Paragraph>
<Select
style={{ width: '100%' }}
@@ -184,6 +184,10 @@ export type WorkOrderVoucherConsumeResult = {
failedCount: number
voucherCodes: string[]
errorMessage: string
queuedCount: number
jobIds: number[]
dueAt: string
status: 'queued' | 'already_scheduled' | 'skipped' | 'failed'
}
export function fulfillWorkOrderWithConsignment(payload: {
@@ -9,6 +9,9 @@ export type PendingChannelGroup = {
shops: Array<{ id: string; name: string }>
sku_codes: string[]
pending_reasons: string[]
pending_subreasons: string[]
configured_channels: string[]
locked_item_count: number
item_count: number
revenue_fen: number
first_seen_at: string
@@ -100,6 +103,15 @@ export type PendingWorkOrderRetryResult = {
skipReasons: Record<string, number>
}
export type PendingCarrierRetryResult = {
scanned: number
workOrdersCreated: number
affiliateTasksCreated: number
assigned: number
failed: number
remaining: number
}
export type DeletePendingChannelGroupResult = {
archivedItems: number
preservedOrders: number
@@ -128,6 +140,64 @@ export type FinancePipelineStatus = {
} | null
}
export type VoucherConsumeProvider = 'kuaishou_industry' | 'douyin_mashida'
export type VoucherConsumeChannel =
| 'affiliate_dash'
| 'work_order'
| 'manual'
| 'third_party_voucher'
export type VoucherConsumeMode =
| 'manual'
| 'after_link_generated'
| 'after_task_created'
| 'after_work_order_published'
| 'after_work_order_assigned'
| 'after_work_order_published_or_assigned'
export type VoucherChannelConsumePolicy = {
enabled: boolean
mode: VoucherConsumeMode
delaySeconds: number
}
export type VoucherConsumePolicy = {
enabled: boolean
platforms: Record<
VoucherConsumeProvider,
{
enabled: boolean
channels: Record<VoucherConsumeChannel, VoucherChannelConsumePolicy>
}
>
}
export type VoucherConsumeJob = {
id: number
provider: VoucherConsumeProvider
channel: VoucherConsumeChannel
trigger_event: string
platform_order_id: string
order_id: number | null
order_item_id: number | null
task_id: number | null
work_order_id: number | null
due_at: string
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled'
attempt_count: number
max_attempts: number
last_error: string
created_at: string
updated_at: string
finished_at: string | null
}
export type VoucherConsumeJobListResult = {
items: VoucherConsumeJob[]
total: number
page: number
pageSize: number
}
export function fetchPendingChannelGroups(
params: {
page?: number
@@ -138,6 +208,30 @@ export function fetchPendingChannelGroups(
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
}
export function fetchVoucherConsumePolicy() {
return apiGet<VoucherConsumePolicy>('/api/v1/admin/fulfillment/voucher-consume-policy')
}
export function saveVoucherConsumePolicy(payload: VoucherConsumePolicy) {
return apiPost<VoucherConsumePolicy>('/api/v1/admin/fulfillment/voucher-consume-policy', payload)
}
export function fetchVoucherConsumeJobs(
params: { status?: string; page?: number; pageSize?: number } = {},
) {
return apiGet<VoucherConsumeJobListResult>(
'/api/v1/admin/fulfillment/voucher-consume-jobs',
params,
)
}
export function retryVoucherConsumeJob(id: number) {
return apiPost<VoucherConsumeJob | null>(
`/api/v1/admin/fulfillment/voucher-consume-jobs/${id}/retry`,
{},
)
}
export function fetchChannelExceptions(
params: {
reason?: string
@@ -158,6 +252,13 @@ export function retryPendingWorkOrderRouting(
)
}
export function retryPendingCarriers(payload: { internalSku: string }) {
return apiPost<PendingCarrierRetryResult>(
'/api/v1/admin/fulfillment/pending/retry-carriers',
payload,
)
}
export function deletePendingChannelGroup(payload: { internalSku: string }) {
return apiPost<DeletePendingChannelGroupResult>(
'/api/v1/admin/fulfillment/pending/delete',
@@ -46,6 +46,10 @@ type WorkOrderVoucherConsumeResult = {
failedCount: number
voucherCodes: string[]
errorMessage: string
queuedCount: number
jobIds: number[]
dueAt: string
status: 'queued' | 'already_scheduled' | 'skipped' | 'failed'
}
export type AdminLeaderboardPeriod = 'all' | 'daily' | 'weekly' | 'monthly'
+52
View File
@@ -1694,6 +1694,12 @@
overflow: auto;
}
.order-service-category-group {
display: grid;
gap: 2px;
min-width: 0;
}
.order-service-category-item {
display: grid;
gap: 4px;
@@ -1724,9 +1730,25 @@
.order-service-category-main {
display: flex;
gap: 4px;
align-items: flex-start;
}
.order-service-category-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 16px;
height: 19px;
color: #7a8699;
font-size: 10px;
}
.order-service-category-toggle.is-empty {
visibility: hidden;
}
.order-service-category-name {
min-width: 0;
font-size: 13px;
@@ -1736,6 +1758,30 @@
white-space: normal;
}
.order-service-category-count {
flex: none;
margin: 0 0 0 auto;
font-size: 10px;
line-height: 18px;
}
.order-service-subcategory-list {
display: grid;
gap: 2px;
padding-left: 12px;
}
.order-service-category-item.is-child {
padding: 8px 9px 8px 12px;
border-left: 2px solid #e5eaf2;
border-radius: 6px 9px 9px 6px;
}
.order-service-category-item.is-child:hover,
.order-service-category-item.is-child.is-active {
border-left-color: #69b1ff;
}
.order-service-category-meta {
display: flex;
gap: 8px;
@@ -1774,6 +1820,12 @@
border-color: #ffe58f;
}
.order-service-status-pill.is-inherited {
color: #0958d9;
background: #e6f4ff;
border-color: #91caff;
}
.order-service-status-pill.is-muted {
color: #8c8c8c;
background: #fafafa;
+10 -11
View File
@@ -2,22 +2,21 @@ import { message } from 'antd'
import type { WorkOrderVoucherConsumeResult } from '@/services/admin/consignment'
/**
* 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗,
* 之前有调用方无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。
* 统一出口:所有履约/指派/手动匹配的调用方都用这一个,避免文案与口径各自漂移。
*/
export function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) {
if (!voucherConsume || voucherConsume.ok) {
if (voucherConsume?.alreadyConsumedCount) {
message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`)
}
if (!voucherConsume) return
if (voucherConsume.queuedCount > 0) {
message.success(`自动核销已排队 ${voucherConsume.queuedCount} 个任务`)
return
}
if (voucherConsume.status === 'already_scheduled') {
message.info('核销任务已在队列中,本次未重复安排')
return
}
if (voucherConsume.ok) return
message.warning({
content: `状态已变更,但电子凭证核销失败:${
content: `状态已变更,但自动核销未安排成功:${
voucherConsume.errorMessage || '未知错误'
}。请到「抖音券核销」或快手电子凭证后台人工核销后再发货`,
}。请在核销任务或平台券后台人工处理`,
duration: 8,
})
}