merge: 合并 main 并保留寄卖内部匹配与客户退款
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* 设计口径(2026-10-31 v2):
|
||||
* · 渠道由 L1 规则决定,不再由「有没有任务」反推;
|
||||
* · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音;
|
||||
* · 第三方卡券是「只统计」渠道:不建任务、不发领取页。
|
||||
* · 第三方卡券由外部卡券库履约:不建内部任务、不发领取页,但支持平台券自动核销。
|
||||
*/
|
||||
import {
|
||||
DeleteOutlined,
|
||||
@@ -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,
|
||||
@@ -64,8 +66,9 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'work_order', label: '接单平台' },
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券(仅统计)' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'on_account', label: '统一结算' },
|
||||
]
|
||||
|
||||
const CHANNEL_COLOR: Record<string, string> = {
|
||||
@@ -73,6 +76,7 @@ const CHANNEL_COLOR: Record<string, string> = {
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'purple',
|
||||
manual: 'orange',
|
||||
on_account: 'gold',
|
||||
}
|
||||
|
||||
const CHANNEL_SOURCE_LABEL: Record<string, string> = {
|
||||
@@ -118,6 +122,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>
|
||||
@@ -130,6 +135,7 @@ function PendingPanel() {
|
||||
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],
|
||||
@@ -139,16 +145,26 @@ function PendingPanel() {
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
assignChannelByInternalSku({
|
||||
internalSku: row.internal_sku,
|
||||
groupKey: row.group_key,
|
||||
internalSku: row.internal_sku || '',
|
||||
mappedInternalSku: row.internal_sku
|
||||
? ''
|
||||
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
|
||||
channel: selections[rowKey(row)] || '',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const productMessage = result.data.draftProductId ? '和标准商品' : ''
|
||||
if (result.data.carrierFailed > 0) {
|
||||
const firstFailure = result.data.carrierFailures?.[0]
|
||||
message.warning(
|
||||
`已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`,
|
||||
`渠道${productMessage}已保存,${result.data.carrierFailed} 个订单项仍待补配置${
|
||||
firstFailure?.message ? `:${firstFailure.message}` : ''
|
||||
}`,
|
||||
)
|
||||
} else {
|
||||
message.success(`已指定 ${result.data.updated} 个订单项`)
|
||||
message.success(
|
||||
`已指定 ${result.data.updated} 个订单项${productMessage ? `并创建${productMessage.slice(1)}` : ''}`,
|
||||
)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
@@ -178,13 +194,35 @@ 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 }),
|
||||
deletePendingChannelGroup({ groupKey: row.group_key }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
message.success(
|
||||
`已删除 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
|
||||
`已忽略归档 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] })
|
||||
@@ -232,7 +270,39 @@ function PendingPanel() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
|
||||
{
|
||||
title: '内部SKU',
|
||||
dataIndex: 'internal_sku',
|
||||
width: 220,
|
||||
render: (value: string | null, row: PendingChannelGroup) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
{value ? (
|
||||
<Typography.Text>{value}</Typography.Text>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="填写内部 SKU"
|
||||
value={internalSkuDrafts[rowKey(row)] || ''}
|
||||
onChange={(event) =>
|
||||
setInternalSkuDrafts((current) => ({
|
||||
...current,
|
||||
[rowKey(row)]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{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 +313,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,13 +366,15 @@ function PendingPanel() {
|
||||
},
|
||||
{
|
||||
title: '指定渠道',
|
||||
width: 360,
|
||||
width: 440,
|
||||
render: (_: unknown, row: PendingChannelGroup) => (
|
||||
<Space size={4}>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 180 }}
|
||||
placeholder="选择渠道"
|
||||
placeholder={
|
||||
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
|
||||
}
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={selections[rowKey(row)]}
|
||||
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
|
||||
@@ -283,32 +382,47 @@ function PendingPanel() {
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!selections[rowKey(row)]}
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)]
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
指定
|
||||
保存渠道
|
||||
</Button>
|
||||
<Tooltip title="删除待分类记录,不删除原始订单">
|
||||
{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
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '删除待分类记录',
|
||||
title: '忽略并归档待分类记录',
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
将归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的成本快照、
|
||||
分类异常,重算对应销售事实与总账。
|
||||
将从待办中归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的
|
||||
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
原始订单和订单商品不会被删除;已关账期间禁止删除。
|
||||
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
okText: '确认归档',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteMutation.mutateAsync(row),
|
||||
@@ -359,7 +473,7 @@ function PendingPanel() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="删除只会归档对应订单项并清理财务成本记录,不会删除原始订单。配置错误优先修正模板/映射后重试匹配;确认无需履约或核算时,可直接删除。"
|
||||
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey={(row) => rowKey(row)}
|
||||
@@ -1114,7 +1228,7 @@ function PipelinesPanel() {
|
||||
}
|
||||
|
||||
function rowKey(row: PendingChannelGroup) {
|
||||
return row.internal_sku
|
||||
return row.group_key
|
||||
}
|
||||
|
||||
function formatCarrierParams(row: ChannelRule) {
|
||||
|
||||
@@ -40,6 +40,7 @@ const FULFILLMENT_MODE_OPTIONS = [
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'on_account', label: '统一结算' },
|
||||
{ value: 'unclassified', label: '未分类' },
|
||||
{ value: 'archived', label: '历史归档' },
|
||||
]
|
||||
@@ -49,6 +50,7 @@ const FULFILLMENT_MODE_LABEL: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
third_party_voucher: '第三方卡券',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ScanOutlined,
|
||||
SyncOutlined,
|
||||
@@ -50,14 +52,21 @@ import {
|
||||
saveAdminManualConfig,
|
||||
scanAdminManualSkus,
|
||||
} from '@/services/admin'
|
||||
import type { ManualSkuItem } from '@/types/admin'
|
||||
import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import {
|
||||
formatCreditQuantity,
|
||||
formatCreditUnitPriceLabel,
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '@/utils/credit-unit-price'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
type ConfigFormValues = {
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
}>
|
||||
note?: string
|
||||
}
|
||||
|
||||
@@ -66,13 +75,21 @@ const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
fund_cash: 'orange',
|
||||
on_account: 'gold',
|
||||
}
|
||||
|
||||
/** 按换算率把「消耗单位」翻译成人话。 */
|
||||
function unitLabel(platformKey: string, creditUnitPriceFen: number) {
|
||||
if (platformKey === 'fund_cash') return '元'
|
||||
if (creditUnitPriceFen >= 100) return '积分(1积分=1元)'
|
||||
return '积分(1积分=1分)'
|
||||
function isCashChannel(platformKey: string) {
|
||||
return platformKey === 'fund_cash' || platformKey === 'on_account'
|
||||
}
|
||||
|
||||
function channelQuantityText(platformKey: string, creditQuantity: number) {
|
||||
const quantity = formatCreditQuantity(creditQuantity)
|
||||
return isCashChannel(platformKey) ? `¥${quantity}` : `${quantity} 积分`
|
||||
}
|
||||
|
||||
function platformOptionLabel(platformKey: string, label: string, creditUnitPriceFen: number) {
|
||||
if (isCashChannel(platformKey)) return `${label} · 按元填写`
|
||||
return `${label} · ${formatCreditUnitPriceLabel(creditUnitPriceFen)}`
|
||||
}
|
||||
|
||||
export function ManualSkuConfigPanel() {
|
||||
@@ -139,8 +156,10 @@ export function ManualSkuConfigPanel() {
|
||||
mutationFn: (values: ConfigFormValues) =>
|
||||
saveAdminManualConfig({
|
||||
skuKey: editing?.skuKey || '',
|
||||
platformKey: values.platformKey,
|
||||
creditQuantity: values.creditQuantity,
|
||||
channels: values.channels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: Number(channel.creditQuantity || 0),
|
||||
})),
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
@@ -191,9 +210,21 @@ export function ManualSkuConfigPanel() {
|
||||
|
||||
function openConfig(item: ManualSkuItem) {
|
||||
setEditing(item)
|
||||
const configuredChannels = item.config?.channels?.length
|
||||
? item.config.channels
|
||||
: item.config
|
||||
? [
|
||||
{
|
||||
platformKey: item.config.platformKey,
|
||||
creditQuantity: item.config.creditQuantity,
|
||||
},
|
||||
]
|
||||
: [{ platformKey: '', creditQuantity: 0 }]
|
||||
form.setFieldsValue({
|
||||
platformKey: item.config?.platformKey || undefined,
|
||||
creditQuantity: item.config?.creditQuantity ?? undefined,
|
||||
channels: configuredChannels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: channel.creditQuantity,
|
||||
})),
|
||||
note: item.config?.note || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
@@ -229,20 +260,37 @@ export function ManualSkuConfigPanel() {
|
||||
title: '履约渠道',
|
||||
key: 'platform',
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
render: (_, row) => {
|
||||
if (!row.config) {
|
||||
return <Typography.Text type="secondary">未配置</Typography.Text>
|
||||
}
|
||||
const channels = row.config.channels?.length
|
||||
? row.config.channels
|
||||
: [
|
||||
{
|
||||
platformKey: row.config.platformKey,
|
||||
platformLabel: row.config.platformLabel,
|
||||
creditQuantity: row.config.creditQuantity,
|
||||
creditUnitPriceFen: row.config.creditUnitPriceFen,
|
||||
costFen: row.config.costFen,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<Space size={4} direction="vertical">
|
||||
<Tag color={PLATFORM_TAG_COLORS[row.config.platformKey] || 'default'}>
|
||||
{row.config.platformLabel || row.config.platformKey}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.config.creditQuantity}{' '}
|
||||
{unitLabel(row.config.platformKey, row.config.creditUnitPriceFen)}
|
||||
</Typography.Text>
|
||||
{channels.map((channel) => (
|
||||
<div key={`${row.id}-${channel.platformKey}`}>
|
||||
<Tag color={PLATFORM_TAG_COLORS[channel.platformKey] || 'default'}>
|
||||
{channel.platformLabel}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{channelQuantityText(channel.platformKey, channel.creditQuantity)}
|
||||
{' · '}¥{((channel.costFen || 0) / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未配置</Typography.Text>
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
@@ -251,7 +299,9 @@ export function ManualSkuConfigPanel() {
|
||||
align: 'right',
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
row.config.costMode === 'fixed' ? (
|
||||
(row.config.channels?.length || 0) > 1 ? (
|
||||
<Tag color="blue">按实际渠道</Tag>
|
||||
) : row.config.costMode === 'fixed' ? (
|
||||
<Typography.Text>¥{(row.config.costFen / 100).toFixed(2)}</Typography.Text>
|
||||
) : (
|
||||
<Tag color="orange">不固定·客服填</Tag>
|
||||
@@ -286,11 +336,10 @@ export function ManualSkuConfigPanel() {
|
||||
},
|
||||
]
|
||||
|
||||
const selectedPlatform = form.getFieldValue('platformKey')
|
||||
const selectedCredit = form.getFieldValue('creditQuantity')
|
||||
const previewUnitPrice = platformMap.get(selectedPlatform)?.creditUnitPriceFen ?? 0
|
||||
const previewCostFen =
|
||||
selectedPlatform && Number(selectedCredit) > 0 ? Number(selectedCredit) * previewUnitPrice : 0
|
||||
function previewChannelCost(platformKey: string, creditQuantity: number) {
|
||||
const unitPrice = platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
|
||||
return Number(creditQuantity) > 0 ? Math.round(Number(creditQuantity) * unitPrice) : 0
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="人工 SKU 配置">
|
||||
@@ -426,38 +475,138 @@ export function ManualSkuConfigPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
description="保存后自动同步标准商品、统一渠道规则和订单匹配;不需要再去其它页面重复设置。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
|
||||
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
<Form.List
|
||||
name="channels"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, channels) => {
|
||||
if (!Array.isArray(channels) || channels.length === 0) {
|
||||
throw new Error('请至少配置一个履约渠道')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`,
|
||||
}))}
|
||||
onChange={() => form.validateFields(['creditQuantity'])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="creditQuantity"
|
||||
label="每单消耗积分"
|
||||
rules={[{ required: true, message: '请输入消耗积分' }]}
|
||||
extra="填 0 表示成本不固定,客服确认履约时手填实际成本。"
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="例如 10" />
|
||||
</Form.Item>
|
||||
<Form.Item label="折算成本">
|
||||
<Typography.Text strong>
|
||||
{previewCostFen > 0
|
||||
? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)`
|
||||
: '成本不固定,由客服手填'}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key} className="manual-channel-config-item">
|
||||
<div className="manual-channel-config-head">
|
||||
<Typography.Text strong>履约渠道 {index + 1}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
title="移除此渠道"
|
||||
aria-label={`移除履约渠道 ${index + 1}`}
|
||||
icon={<MinusCircleOutlined />}
|
||||
disabled={fields.length <= 1}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={[field.name, 'platformKey']}
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择履约渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: platformOptionLabel(item.key, item.label, item.creditUnitPriceFen),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const platformKey = String(
|
||||
form.getFieldValue(['channels', field.name, 'platformKey']) || '',
|
||||
)
|
||||
const creditQuantity = Number(
|
||||
form.getFieldValue(['channels', field.name, 'creditQuantity']) || 0,
|
||||
)
|
||||
const cashChannel = isCashChannel(platformKey)
|
||||
const unitPriceFen =
|
||||
platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
|
||||
const costFen = previewChannelCost(platformKey, creditQuantity)
|
||||
return (
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name={[field.name, 'creditQuantity']}
|
||||
label={
|
||||
!platformKey
|
||||
? '消耗 / 金额'
|
||||
: cashChannel
|
||||
? '每单金额'
|
||||
: '每单消耗积分'
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入消耗或金额' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const quantity = Number(value)
|
||||
if (!Number.isFinite(quantity) || quantity < 0) {
|
||||
throw new Error('请输入大于等于 0 的数字')
|
||||
}
|
||||
if (!cashChannel && !Number.isInteger(quantity)) {
|
||||
throw new Error('积分必须是整数')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra={
|
||||
platformKey
|
||||
? cashChannel
|
||||
? '单位:元,最多两位小数'
|
||||
: `换算率:${formatCreditUnitPriceLabel(unitPriceFen)}`
|
||||
: '积分填整数,现金和统一结算填金额'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={cashChannel ? 2 : 0}
|
||||
step={cashChannel ? 0.01 : 1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={cashChannel ? '例如 12.50' : '例如 10'}
|
||||
addonAfter={
|
||||
platformKey ? (cashChannel ? '元' : '积分') : undefined
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="折算成本" style={{ marginBottom: 0 }}>
|
||||
<div className="manual-channel-cost-preview">
|
||||
<Typography.Text strong={costFen > 0}>
|
||||
{costFen > 0
|
||||
? `¥${(costFen / 100).toFixed(2)}`
|
||||
: '成本不固定,由客服确认时填写'}
|
||||
</Typography.Text>
|
||||
{platformKey && !cashChannel ? (
|
||||
<Typography.Text type="secondary">
|
||||
1积分={formatCreditUnitPriceYuan(unitPriceFen)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
|
||||
添加履约渠道
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
|
||||
@@ -130,6 +130,7 @@ export const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
third_party_voucher: '第三方卡券',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
}
|
||||
@@ -139,6 +140,7 @@ export const FULFILLMENT_MODE_COLORS: Record<string, string> = {
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'cyan',
|
||||
manual: 'orange',
|
||||
on_account: 'gold',
|
||||
archived: 'default',
|
||||
unclassified: 'gold',
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
FUND_CATEGORY_LABELS,
|
||||
@@ -387,7 +388,7 @@ export default function FundSupportView() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="我的钱包"
|
||||
description="四个平台钱包:南航 / 乐玩 / ROCK 积分与备用金现金。余额由流水自动计算,不能手工修改。"
|
||||
description="五种履约方式:南航 / 乐玩 / ROCK 积分、备用金现金、统一结算。积分余额与现金余额由流水自动计算,不能手工修改。"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -418,51 +419,61 @@ export default function FundSupportView() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<div className="support-wallet-grid">
|
||||
{myCreditWallets.map((wallet) => (
|
||||
<Col xs={24} sm={12} lg={6} key={wallet.platform.key}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>{wallet.platform.label}</span>
|
||||
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
|
||||
1积分=
|
||||
{wallet.platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
value={wallet.balanceQuantity}
|
||||
suffix="积分"
|
||||
valueStyle={{
|
||||
color: wallet.balanceQuantity > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Card key={wallet.platform.key}>
|
||||
<Statistic
|
||||
title="备用金现金"
|
||||
value={account ? account.balanceFen / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>{wallet.platform.label}</span>
|
||||
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
|
||||
{formatCreditUnitPriceLabel(wallet.platform.creditUnitPriceFen)}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
value={wallet.balanceQuantity}
|
||||
suffix="积分"
|
||||
valueStyle={{
|
||||
color: account && account.balanceFen > 0 ? undefined : '#999',
|
||||
color: wallet.balanceQuantity > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{account
|
||||
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
|
||||
: '尚未开通备用金账户'}
|
||||
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
<Card>
|
||||
<Statistic
|
||||
title="备用金现金"
|
||||
value={account ? account.balanceFen / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{
|
||||
color: account && account.balanceFen > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{account
|
||||
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
|
||||
: '尚未开通备用金账户'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>统一结算</span>
|
||||
<Tag color="gold">后对账</Tag>
|
||||
</Space>
|
||||
}
|
||||
value="按单生成"
|
||||
valueStyle={{ color: '#8c6d1f', fontSize: 22 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
生成虚拟账单,不扣积分或备用金
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{account ? (
|
||||
<Card>
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { PlatformCreditAccount, PlatformCreditEntry, PlatformCreditOwner }
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
@@ -342,7 +343,7 @@ export function PlatformCreditPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="南航 / 乐玩 / ROCK 三平台积分互不通用,每平台一个钱包"
|
||||
description="换算率按平台:南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元。可在下方按客服直接分配或调整积分;备用金现金在「现金钱包 / 对账中心」管理。"
|
||||
description="换算率按平台展示为人民币金额;南航、乐玩默认 1 积分 = 0.01 元,ROCK 默认 1 积分 = 1.00 元。可在下方按客服直接分配或调整积分;备用金现金在「现金钱包 / 对账中心」管理。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
@@ -359,7 +360,7 @@ export function PlatformCreditPanel() {
|
||||
suffix="积分"
|
||||
prefix={
|
||||
<Tag color={PLATFORM_TAG_COLORS[platform.key] || 'default'}>
|
||||
1积分={platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
{formatCreditUnitPriceLabel(platform.creditUnitPriceFen)}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
@@ -459,7 +460,7 @@ export function PlatformCreditPanel() {
|
||||
<Select
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`,
|
||||
label: `${item.label} · ${formatCreditUnitPriceLabel(item.creditUnitPriceFen)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -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,387 @@
|
||||
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: '人工客服',
|
||||
on_account: '统一结算',
|
||||
third_party_voucher: '第三方卡券',
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: Record<
|
||||
VoucherConsumeChannel,
|
||||
Array<{ value: VoucherConsumeMode; label: string }>
|
||||
> = {
|
||||
affiliate_dash: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'after_link_generated', label: '领取链接生成后' },
|
||||
{ value: 'after_task_created', label: '履约任务创建后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
work_order: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ 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_order_received', label: '收到订单后' },
|
||||
{ value: 'after_task_created', label: '人工任务创建后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
third_party_voucher: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
on_account: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ 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}
|
||||
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.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.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],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -71,9 +71,11 @@ import {
|
||||
fetchAdminWorkerProductNameSuffix,
|
||||
fetchAdminWorkerUsers,
|
||||
fetchAdminWorkOrders,
|
||||
executeAdminCustomerRefund,
|
||||
markAdminWorkOrderProblem,
|
||||
markAdminWorkOrderShareProblem,
|
||||
pinAdminWorkOrder,
|
||||
previewAdminCustomerRefund,
|
||||
publishAdminWorkOrder,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
@@ -86,6 +88,7 @@ import {
|
||||
updateAdminWorkOrderNote,
|
||||
updateAdminWorkOrderSharing,
|
||||
} from '@/services/admin'
|
||||
import type { CustomerRefundPreview } from '@/services/admin/worker-platform'
|
||||
import {
|
||||
checkWorkOrderConsignmentMatches,
|
||||
fulfillWorkOrderWithConsignment,
|
||||
@@ -205,6 +208,11 @@ type CancelSharingPartFormValues = {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type CustomerRefundFormValues = {
|
||||
reason: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
|
||||
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
|
||||
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
|
||||
@@ -306,6 +314,9 @@ export default function WorkOrdersPanel() {
|
||||
const canCancelOrder = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.cancel')
|
||||
: hasAdminPermission('worker_order.cancel')
|
||||
const canCustomerRefund = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.customer_refund')
|
||||
: hasAdminPermission('worker_order.customer_refund')
|
||||
const canCancelShare = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.cancel_share')
|
||||
: hasAdminPermission('worker_order.cancel_share')
|
||||
@@ -381,6 +392,12 @@ export default function WorkOrdersPanel() {
|
||||
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
|
||||
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelOrder, setCancelOrder] = useState<WorkOrder | null>(null)
|
||||
const [customerRefundOrder, setCustomerRefundOrder] = useState<WorkOrder | null>(null)
|
||||
const [customerRefundPreview, setCustomerRefundPreview] = useState<CustomerRefundPreview | null>(
|
||||
null,
|
||||
)
|
||||
const [customerRefundLoading, setCustomerRefundLoading] = useState(false)
|
||||
const [customerRefundSubmitting, setCustomerRefundSubmitting] = useState(false)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm<MaterialFormValues>()
|
||||
@@ -395,6 +412,7 @@ export default function WorkOrdersPanel() {
|
||||
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
|
||||
const [cancelSharingPartForm] = Form.useForm<CancelSharingPartFormValues>()
|
||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||
const [customerRefundForm] = Form.useForm<CustomerRefundFormValues>()
|
||||
const [editForm] = Form.useForm<{
|
||||
productName?: string
|
||||
platformOrderId?: string
|
||||
@@ -695,6 +713,46 @@ export default function WorkOrdersPanel() {
|
||||
cancelForm.setFieldsValue({ action: 'return_to_hall', reason: '' })
|
||||
}
|
||||
|
||||
async function openCustomerRefundModal(row: WorkOrder) {
|
||||
setCustomerRefundOrder(row)
|
||||
setCustomerRefundPreview(null)
|
||||
customerRefundForm.setFieldsValue({ reason: '客户退款', note: '' })
|
||||
setCustomerRefundLoading(true)
|
||||
try {
|
||||
const response = await previewAdminCustomerRefund(row.workOrderId)
|
||||
setCustomerRefundPreview(response.data)
|
||||
} catch (error) {
|
||||
setCustomerRefundOrder(null)
|
||||
message.error(error instanceof Error ? error.message : '客户退款预检失败')
|
||||
} finally {
|
||||
setCustomerRefundLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCustomerRefund(values: CustomerRefundFormValues) {
|
||||
if (!customerRefundOrder || !customerRefundPreview?.canExecute) return
|
||||
setCustomerRefundSubmitting(true)
|
||||
try {
|
||||
const response = await executeAdminCustomerRefund(customerRefundOrder.workOrderId, values)
|
||||
message.success(
|
||||
response.data.idempotent
|
||||
? '该客户退款已处理,无需重复操作'
|
||||
: `客户退款已完成,退款 ${formatMoney(customerRefundPreview.remainingFen)}`,
|
||||
)
|
||||
setCustomerRefundOrder(null)
|
||||
setCustomerRefundPreview(null)
|
||||
customerRefundForm.resetFields()
|
||||
await refreshAll()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-redeem-certificates'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-kuaishou-industry-vouchers'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '客户退款失败')
|
||||
} finally {
|
||||
setCustomerRefundSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function applyMaterialPaste(text: string) {
|
||||
if (!materialOrder) return
|
||||
const recognized = parseWorkerMaterialText(text)
|
||||
@@ -729,9 +787,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)
|
||||
@@ -1020,10 +1080,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
|
||||
? '订单已发布,自动核销任务已排队'
|
||||
: '订单已展示在大厅'
|
||||
: '订单已进入大厅等待队列',
|
||||
)
|
||||
@@ -2001,6 +2061,27 @@ export default function WorkOrdersPanel() {
|
||||
内部库存匹配
|
||||
</Button>
|
||||
) : null}
|
||||
{canCustomerRefund &&
|
||||
[
|
||||
'pending_material',
|
||||
'unassigned',
|
||||
'open',
|
||||
'in_progress',
|
||||
'pending_acceptance',
|
||||
'problem',
|
||||
].includes(String(row.rawStatus || row.status || '')) ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<RollbackOutlined />}
|
||||
loading={
|
||||
customerRefundLoading && customerRefundOrder?.workOrderId === row.workOrderId
|
||||
}
|
||||
onClick={() => void openCustomerRefundModal(row)}
|
||||
title="同意平台用户退款,并关闭工单、资料、领取链接和待核销任务"
|
||||
>
|
||||
客户退款
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (
|
||||
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
|
||||
撤单
|
||||
@@ -3125,6 +3206,80 @@ export default function WorkOrdersPanel() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`客户退款 · ${getDisplayOrderNo(customerRefundOrder)}`}
|
||||
open={Boolean(customerRefundOrder)}
|
||||
onCancel={() => {
|
||||
setCustomerRefundOrder(null)
|
||||
setCustomerRefundPreview(null)
|
||||
customerRefundForm.resetFields()
|
||||
}}
|
||||
onOk={() => customerRefundForm.submit()}
|
||||
okText="同意退款并关闭订单"
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: customerRefundLoading || !customerRefundPreview?.canExecute,
|
||||
}}
|
||||
confirmLoading={customerRefundSubmitting}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
>
|
||||
{customerRefundLoading ? (
|
||||
<Alert type="info" showIcon message="正在查询平台用户售后单..." />
|
||||
) : customerRefundPreview ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该操作会同意平台用户退款,并关闭订单下全部工单、资料提交、领取链接和待核销任务。"
|
||||
description="订单、退款、成本与资金流水会保留用于审计;存在已验收或已结算内容时系统会拒绝执行。"
|
||||
/>
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="平台">
|
||||
{customerRefundPreview.platform} / {customerRefundPreview.provider}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">
|
||||
{customerRefundPreview.platformOrderId}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台售后单号">
|
||||
{customerRefundPreview.externalAfterSaleId}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="退款金额">
|
||||
{formatMoney(customerRefundPreview.remainingFen)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关闭范围">
|
||||
{customerRefundPreview.workOrderCount} 个工单 / {customerRefundPreview.itemCount}{' '}
|
||||
个订单项
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{customerRefundPreview.notes.length > 0 ? (
|
||||
<Alert
|
||||
type={customerRefundPreview.canExecute ? 'warning' : 'error'}
|
||||
showIcon
|
||||
message={customerRefundPreview.canExecute ? '处理提示' : '预检未通过'}
|
||||
description={customerRefundPreview.notes.join(';')}
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
form={customerRefundForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => void submitCustomerRefund(values)}
|
||||
>
|
||||
<Form.Item
|
||||
label="退款原因"
|
||||
name="reason"
|
||||
rules={[{ required: true, message: '请填写退款原因' }]}
|
||||
>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item label="处理备注" name="note">
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`撤单处理 · ${getDisplayOrderNo(cancelOrder)}`}
|
||||
open={Boolean(cancelOrder)}
|
||||
@@ -3347,7 +3502,7 @@ export default function WorkOrdersPanel() {
|
||||
>
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<Typography.Paragraph type="secondary">
|
||||
指派后订单直接进入该打手的「我的订单」,不再出现在抢单大厅;有关联电子凭证时会先自动核销,再按订单规则冻结打手押金。核销后取消指派不会恢复电子凭证。
|
||||
指派后订单直接进入该打手的「我的订单」,不再出现在抢单大厅;有关联电子凭证时会按核销策略安排延迟核销,并按订单规则冻结打手押金。
|
||||
</Typography.Paragraph>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -67,7 +67,13 @@ const MESSAGE_TYPE_OPTIONS = [
|
||||
]
|
||||
|
||||
/** 默认本地处理白名单,作为 Select 的候选标签 */
|
||||
const DEFAULT_HANDLED_TOPICS = ['trade_TradePaid', 'trade_TradeCanceled', 'refund_RefundSuccess']
|
||||
const DEFAULT_HANDLED_TOPICS = [
|
||||
'trade_TradePaid',
|
||||
'trade_TradeCanceled',
|
||||
'refund_RefundCreated',
|
||||
'refund_RefundClosed',
|
||||
'refund_RefundSuccess',
|
||||
]
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user