Merge branch 'main' into dev_match

This commit is contained in:
2026-11-06 10:14:43 +08:00
63 changed files with 7027 additions and 954 deletions
@@ -22,6 +22,7 @@ import {
Input,
InputNumber,
Modal,
Segmented,
Select,
Space,
Switch,
@@ -33,6 +34,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 +53,7 @@ import {
retryPendingCarriers,
retryPendingWorkOrderRouting,
runFinancePipeline,
unlockPendingChannelGroup,
type FinancePipelineStatus,
type InternalSkuMapping,
type InternalSkuMappingMatchType,
@@ -99,6 +102,16 @@ const ENABLED_FILTER_OPTIONS = [
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
@@ -131,15 +144,17 @@ export default function AdminFulfillmentChannelPage() {
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({
@@ -151,6 +166,7 @@ function PendingPanel() {
? ''
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
channel: selections[rowKey(row)] || '',
issueCategory,
}),
onSuccess: (result) => {
const productMessage = result.data.draftProductId ? '和标准商品' : ''
@@ -195,8 +211,16 @@ function PendingPanel() {
})
const retryCarrierMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
retryPendingCarriers({ internalSku: row.internal_sku || '' }),
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
const detail = [
@@ -216,9 +240,23 @@ 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 || '',
issueCategory,
}),
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 }),
deletePendingChannelGroup({ groupKey: row.group_key, issueCategory }),
onSuccess: (result) => {
const data = result.data
message.success(
@@ -326,26 +364,59 @@ 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: '缺自动发货映射',
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} 项</Typography.Text>
<Typography.Text type="warning">
已人工指定渠道,等待补配置 {row.locked_item_count} 项
</Typography.Text>
) : null}
</Space>
)
@@ -367,77 +438,168 @@ 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 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>
<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)] ||
!canRunScopedActions
}
loading={assignMutation.isPending}
onClick={() => assignMutation.mutate(row)}
>
保存渠道
</Button>
{canRunScopedActions && hasConfigIssue ? (
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
去补配置({row.config_issue_item_count})
</Button>
) : null}
{canRunScopedActions && hasManualReview ? (
<Button size="small" onClick={() => void navigate('/admin/tasks')}>
处理人工复核({row.manual_review_item_count})
</Button>
) : null}
{canRunScopedActions && hasOnlyRetryableIssues && hasRetryable && hasUnlockedItems ? (
<Button
size="small"
icon={<SyncOutlined />}
danger
loading={retryCarrierMutation.isPending}
onClick={() => retryCarrierMutation.mutate(row)}
onClick={() =>
Modal.confirm({
title: '确认重开履约载体',
content: `只会处理该 SKU 下 ${row.retryable_unlocked_item_count} 个未锁定、允许重试的订单项。退款、取消、人工复核和缺配置项不会被处理。已有失败或关闭任务会保留为历史。`,
okText: '确认重试',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () =>
retryCarrierMutation.mutateAsync({
row,
retryInactiveCarrier: true,
issueCodes: retryableIssueCodes,
}),
})
}
>
补载体
确认重试({row.retryable_unlocked_item_count})
</Button>
</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>
),
) : null}
{canRunScopedActions &&
hasOnlyAutoRetryableIssues &&
hasAutoRetryable &&
!hasAfterSales &&
!hasSystemIssue &&
row.configured_channels?.length &&
hasUnlockedItems ? (
<Tooltip title="只补建当前无活跃载体的未锁定订单,不会重开失败或关闭的旧任务">
<Button
size="small"
icon={<SyncOutlined />}
loading={retryCarrierMutation.isPending}
onClick={() =>
retryCarrierMutation.mutate({
row,
retryInactiveCarrier: false,
issueCodes: autoRetryableIssueCodes,
})
}
>
补载体({row.auto_retryable_unlocked_item_count})
</Button>
</Tooltip>
) : null}
{canRunScopedActions && 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}
{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>
)
},
},
]
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
@@ -475,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"
@@ -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,5 +1,5 @@
/**
* 人工 SKU 配置 Tab:扫描 → 配置 → 重新匹配。
* 人工 SKU 配置 Tab:按人工标准商品配置渠道与成本。
*
* 口径(2026-10-24 确认):
* * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),商品名称仅供识别;
@@ -16,8 +16,6 @@ import {
MinusCircleOutlined,
PlusOutlined,
ReloadOutlined,
ScanOutlined,
SyncOutlined,
} from '@ant-design/icons'
import {
Alert,
@@ -25,7 +23,6 @@ import {
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
@@ -48,9 +45,7 @@ import {
fetchAdminManualPlatforms,
fetchAdminManualSkuOverview,
fetchAdminManualSkus,
rematchAdminManualFulfillments,
saveAdminManualConfig,
scanAdminManualSkus,
} from '@/services/admin'
import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
@@ -59,8 +54,6 @@ import {
formatCreditUnitPriceLabel,
formatCreditUnitPriceYuan,
} from '@/utils/credit-unit-price'
import dayjs from '@/lib/dayjs'
import type { Dayjs } from 'dayjs'
type ConfigFormValues = {
channels: Array<{
@@ -102,10 +95,6 @@ export function ManualSkuConfigPanel() {
const [platformKey, setPlatformKey] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [scanRange, setScanRange] = useState<[Dayjs, Dayjs]>([
dayjs().startOf('month'),
dayjs().endOf('month'),
])
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<ManualSkuItem | null>(null)
@@ -133,25 +122,6 @@ export function ManualSkuConfigPanel() {
const platforms = platformQuery.data?.data.items || []
const platformMap = new Map(platforms.map((item) => [item.key, item]))
const scanMutation = useMutation({
mutationFn: () =>
scanAdminManualSkus({
fromDate: scanRange[0].format('YYYY-MM-DD'),
toDate: scanRange[1].format('YYYY-MM-DD'),
}),
onSuccess: (response) => {
const result = response.data
message.success(`扫描完成,登记 ${result.registered} 个内部 SKU`)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
queryKey: ['admin-cost-center-manual-skus-overview'],
})
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '扫描失败')
},
})
const saveMutation = useMutation({
mutationFn: (values: ConfigFormValues) =>
saveAdminManualConfig({
@@ -183,7 +153,7 @@ export function ManualSkuConfigPanel() {
mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey),
onSuccess: (response) => {
message.success(
`人工分类已移除;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`,
`人工标准商品已归档;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`,
)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
void queryClient.invalidateQueries({
@@ -197,17 +167,6 @@ export function ManualSkuConfigPanel() {
},
})
const rematchMutation = useMutation({
mutationFn: () => rematchAdminManualFulfillments(),
onSuccess: (response) => {
message.success(`重新匹配完成,回填 ${response.data.updatedCount} 个订单项`)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '重新匹配失败')
},
})
function openConfig(item: ManualSkuItem) {
setEditing(item)
const configuredChannels = item.config?.channels?.length
@@ -232,16 +191,18 @@ export function ManualSkuConfigPanel() {
const columns: TableColumnsType<ManualSkuItem> = [
{
title: '内部 SKU(配置主键)',
title: '人工标准商品',
dataIndex: 'skuKey',
width: 280,
width: 300,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong copyable={{ text: row.skuKey }}>
{row.skuKey}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.displayName || '(无名称)'}
<Typography.Text strong>{row.displayName || row.skuKey}</Typography.Text>
<Typography.Text
type="secondary"
copyable={{ text: row.skuKey }}
style={{ fontSize: 12 }}
>
SKU:{row.skuKey}
</Typography.Text>
</div>
),
@@ -321,14 +282,14 @@ export function ManualSkuConfigPanel() {
{row.configured ? '编辑' : '配置'}
</Button>
<Popconfirm
title="移除该人工分类?"
description="会删除人工登记与配置、撤销人工渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认移除"
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
移除
归档
</Button>
</Popconfirm>
</Space>
@@ -347,13 +308,13 @@ export function ManualSkuConfigPanel() {
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工履约只需在这里配置一次"
description="保存时会自动创建或关联 man: 标准商品、同步统一渠道规则、回填最近 30 天订单,并重试待处理的人工渠道订单。消耗积分 × 平台换算率就是这单的人工成本,不需要再去标准商品或渠道规则页重复配置;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
message="人工标准商品与成本配置一一对应"
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={6}>
<Statistic title="内部 SKU 总数" value={overview?.totalSku ?? 0} />
<Statistic title="人工标准商品" value={overview?.totalSku ?? 0} />
</Col>
<Col span={6}>
<Statistic
@@ -380,29 +341,8 @@ export function ManualSkuConfigPanel() {
</Row>
<Space wrap style={{ marginBottom: 12 }}>
<DatePicker.RangePicker
value={scanRange}
onChange={(value) => {
if (value?.[0] && value?.[1]) setScanRange([value[0], value[1]])
}}
/>
<Button
type="primary"
icon={<ScanOutlined />}
loading={scanMutation.isPending}
onClick={() => scanMutation.mutate()}
>
扫描内部 SKU
</Button>
<Button
icon={<SyncOutlined />}
loading={rematchMutation.isPending}
onClick={() => rematchMutation.mutate()}
>
重新匹配已配置
</Button>
<Input.Search
placeholder="搜索内部 SKU 或名称"
placeholder="搜索标准商品或 SKU"
allowClear
style={{ width: 220 }}
onSearch={(value) => {
@@ -4,7 +4,7 @@
* 商品编码固定为 `tpv:<sku>`;成本写入 `sys_third_party_voucher_cost` 的
* 商品级版本,由成本快照管道消费。
*/
import { ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import { DeleteOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import {
Button,
Card,
@@ -12,6 +12,7 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Space,
Tag,
Typography,
@@ -22,11 +23,13 @@ import { useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import {
deleteAdminVoucherProduct,
ensureVoucherChannelRules,
fetchAdminProductCatalog,
setAdminProductPrice,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { useCostCenter } from './CostCenterContext'
@@ -65,6 +68,20 @@ export default function VoucherSkuPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'),
})
const deleteMutation = useMutation({
mutationFn: (productId: number) => deleteAdminVoucherProduct(productId),
onSuccess: (response) => {
message.success(
response.data.action === 'archived'
? '该商品已有历史引用,已归档并停用后续卡券规则;历史成本已保留'
: '卡券商品及采购价配置已删除',
)
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'),
})
async function handleEnsureRules() {
setEnsuringRules(true)
try {
@@ -99,28 +116,50 @@ export default function VoucherSkuPanel() {
title: '生效时间',
dataIndex: 'activeCostEffectiveFrom',
width: 180,
render: (value?: string | null) => value || '—',
render: (value?: string | null) =>
value ? <span style={{ whiteSpace: 'nowrap' }}>{formatAdminDateTime(value)}</span> : '—',
},
{
title: '操作',
width: 110,
width: 180,
fixed: 'right' as const,
render: (_: unknown, row: ProductCatalogItem) => (
<Button
size="small"
disabled={!canConfig}
onClick={() => {
setSelected(row)
form.setFieldsValue({
amountFen:
row.activeCostAmountFen === null || row.activeCostAmountFen === undefined
? 0
: Number((row.activeCostAmountFen / 100).toFixed(2)),
changeReason: '第三方卡券采购价调整',
})
}}
>
改价
</Button>
<Space size={4}>
<Button
size="small"
disabled={!canConfig}
onClick={() => {
setSelected(row)
form.setFieldsValue({
amountFen:
row.activeCostAmountFen === null || row.activeCostAmountFen === undefined
? 0
: Number((row.activeCostAmountFen / 100).toFixed(2)),
changeReason: '第三方卡券采购价调整',
})
}}
>
改价
</Button>
<Popconfirm
title="删除该第三方卡券商品?"
description="没有历史引用时会彻底删除;已有历史订单或成本快照时会自动归档并停用后续卡券规则,历史成本保留。"
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMutation.mutate(row.id)}
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canConfig}
loading={deleteMutation.isPending && deleteMutation.variables === row.id}
>
删除
</Button>
</Popconfirm>
</Space>
),
},
]
@@ -6,6 +6,7 @@ import {
ThunderboltOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import {
Alert,
App,
@@ -29,7 +30,9 @@ import {
Tooltip,
Typography,
} from 'antd'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PercentTable from '@/components/common/PercentTable'
import { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
@@ -149,6 +152,7 @@ export default function ConsignmentInventoryPanel() {
const [series, setSeries] = useState<string>('')
const [diamondLevel, setDiamondLevel] = useState<number>(0)
const [allowMatching, setAllowMatching] = useState<string>('')
const [inboundRange, setInboundRange] = useState<[string, string] | null>(null)
const [keyword, setKeyword] = useState<string>('')
const [page, setPage] = useState<number>(1)
const [pageSize, setPageSize] = useState<number>(20)
@@ -206,6 +210,8 @@ export default function ConsignmentInventoryPanel() {
series,
diamondLevel,
allowMatching,
inboundRange?.[0],
inboundRange?.[1],
keyword,
page,
pageSize,
@@ -218,6 +224,8 @@ export default function ConsignmentInventoryPanel() {
series: series || undefined,
diamondLevel: diamondLevel > 0 ? diamondLevel : undefined,
allowMatching: allowMatching === 'yes' ? true : allowMatching === 'no' ? false : undefined,
dateFrom: inboundRange?.[0],
dateTo: inboundRange?.[1],
keyword: keyword || undefined,
page,
pageSize,
@@ -854,6 +862,21 @@ export default function ConsignmentInventoryPanel() {
]}
/>
<AdminRangePicker
allowClear
style={{ width: 250 }}
placeholder={['入库开始日期', '入库结束日期']}
value={inboundRange ? [dayjs(inboundRange[0]), dayjs(inboundRange[1])] : null}
onChange={(dates) => {
setInboundRange(
dates?.[0] && dates[1]
? [dates[0].format(ADMIN_DATE_FORMAT), dates[1].format(ADMIN_DATE_FORMAT)]
: null,
)
setPage(1)
}}
/>
<Input.Search
placeholder="搜索物品名/编号/收购人/店铺/卖家"
allowClear
@@ -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>
)
@@ -403,6 +403,7 @@ export function deleteAdminManualSku(skuKey: string) {
deletedConfigs: number
deletedRegistry: number
deletedRules: number
archivedProducts: number
refreshedDays: number
skippedClosedDays: number
}>(`/api/v1/admin/cost-center/manual-skus/${encodeURIComponent(skuKey)}`)
@@ -555,3 +556,17 @@ export function ensureVoucherChannelRules() {
'/api/v1/admin/cost-center/voucher-products/ensure-rules',
)
}
export function deleteAdminVoucherProduct(productId: number) {
return apiDelete<{
action: 'deleted' | 'archived'
deleted: boolean
product: ProductCatalogItem
deletedCostVersionCount?: number
preserved: {
mappingCount: number
snapshotCount: number
orderItemCount: number
}
}>(`/api/v1/admin/cost-center/voucher-products/${productId}`)
}
@@ -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)
@@ -222,7 +241,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,14 +283,27 @@ export function retryPendingWorkOrderRouting(
)
}
export function retryPendingCarriers(payload: { internalSku: string }) {
export function retryPendingCarriers(payload: {
internalSku: string
retryInactiveCarrier?: boolean
includeLocked?: boolean
issueCodes?: string[]
}) {
return apiPost<PendingCarrierRetryResult>(
'/api/v1/admin/fulfillment/pending/retry-carriers',
payload,
)
}
export function deletePendingChannelGroup(payload: { groupKey: 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; issueCategory?: string }) {
return apiPost<DeletePendingChannelGroupResult>(
'/api/v1/admin/fulfillment/pending/delete',
payload,
@@ -276,6 +315,7 @@ export function assignChannelByInternalSku(payload: {
internalSku: string
mappedInternalSku?: string
channel: string
issueCategory?: string
note?: string
}) {
return apiPost<{
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const page = readFileSync(new URL('../src/pages/admin/AdminTasksPage.tsx', import.meta.url), 'utf8')
test('任务页默认仅展示当前任务,并可切换全部历史尝试', () => {
assert.ok(page.includes("searchParams.get('scope') === 'all' ? 'all' : 'current'"))
assert.ok(page.includes('currentOnly: filters.scope !=='))
assert.ok(page.includes("label: '当前任务'"))
assert.ok(page.includes("label: '全部历史尝试'"))
})
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => {
const source = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
'utf8',
)
assert.ok(
source.includes("from '@/components/admin/AdminDatePicker'"),
'必须复用后台日期范围控件',
)
assert.ok(source.includes("placeholder={['入库开始日期', '入库结束日期']}"), '必须明确筛选口径')
assert.ok(source.includes('dateFrom: inboundRange?.[0]'), '开始日期必须透传后端')
assert.ok(source.includes('dateTo: inboundRange?.[1]'), '结束日期必须透传后端')
assert.ok(
source.includes('inboundRange?.[0],\n inboundRange?.[1],'),
'日期变化必须进入 React Query key 并触发重新查询',
)
assert.ok(
source
.slice(source.indexOf('<AdminRangePicker'), source.indexOf('<Input.Search'))
.includes('setPage(1)'),
'修改日期筛选后必须回到第一页',
)
})
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const panel = readFileSync(
new URL('../src/pages/admin/AdminFulfillmentChannelPage.tsx', import.meta.url),
'utf8',
)
test('待分类队列按问题类型拆分动作', () => {
assert.ok(panel.includes('去补配置'))
assert.ok(panel.includes('处理人工复核'))
assert.ok(panel.includes('确认重试'))
assert.ok(panel.includes('解除锁定'))
assert.ok(panel.includes('等待补配置'))
assert.ok(panel.includes('用户已申请退款'))
assert.ok(panel.includes('上游履约已取消'))
assert.ok(panel.includes('PENDING_ISSUE_CATEGORY_OPTIONS'))
assert.ok(panel.includes("label: '配置待补'"))
assert.ok(panel.includes("label: '售后关闭'"))
})
test('重开历史载体必须二次确认且不会处理锁定项', () => {
assert.ok(panel.includes('确认重开履约载体'))
assert.ok(panel.includes('retryInactiveCarrier: true'))
assert.ok(panel.includes('不会重开失败或关闭的旧任务'))
assert.ok(panel.includes('unlockPendingChannelGroup'))
assert.ok(panel.includes('hasOnlyRetryableIssues'))
assert.ok(panel.includes('issueCodes: retryableIssueCodes'))
assert.ok(panel.includes('issueCodes: autoRetryableIssueCodes'))
assert.ok(!panel.includes('PENDING_RETRYABLE_TERMINAL_REASONS'))
})
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('人工成本配置直接展示标准商品,不再要求按日期扫描内部 SKU', async () => {
const source = await readFile(
new URL('../src/pages/admin/cost-center/ManualSkuConfigPanel.tsx', import.meta.url),
'utf8',
)
assert.ok(source.includes('人工标准商品与成本配置一一对应'))
assert.ok(source.includes("title: '人工标准商品'"))
assert.ok(source.includes('SKU:{row.skuKey}'))
assert.ok(!source.includes('扫描内部 SKU'))
assert.ok(!source.includes('DatePicker.RangePicker'))
assert.ok(!source.includes('rematchAdminManualFulfillments'))
})
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const panel = readFileSync(
new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url),
'utf8',
)
test('最近核销任务提供关键词、平台、渠道和状态筛选', () => {
assert.ok(panel.includes('placeholder="任务号 / 订单号 / 平台单号"'))
assert.ok(panel.includes('placeholder="全部平台"'))
assert.ok(panel.includes('placeholder="全部渠道"'))
assert.ok(panel.includes('placeholder="全部状态"'))
assert.ok(panel.includes('resetJobFilters'))
})
test('最近核销任务使用服务端分页', () => {
assert.ok(panel.includes('buildAdminTablePagination'))
assert.ok(panel.includes('total: jobsQuery.data?.data.total'))
assert.ok(panel.includes('setPageSize(nextPageSize)'))
assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}'))
})
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('第三方卡券面板提供安全删除入口并格式化生效时间', async () => {
const [panel, service] = await Promise.all([
readFile(
new URL('../src/pages/admin/cost-center/VoucherSkuPanel.tsx', import.meta.url),
'utf8',
),
readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'),
])
assert.ok(panel.includes('deleteAdminVoucherProduct'))
assert.ok(panel.includes('DeleteOutlined'))
assert.ok(panel.includes('Popconfirm'))
assert.ok(panel.includes('已有历史订单或成本快照时会自动归档'))
assert.ok(panel.includes('formatAdminDateTime(value)'))
assert.ok(service.includes('/api/v1/admin/cost-center/voucher-products/${productId}'))
assert.ok(service.includes("action: 'deleted' | 'archived'"))
})