Merge branch 'main' into dev
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Table as AntTable } from 'antd'
|
||||
import type { TableProps } from 'antd'
|
||||
import type { TablePaginationConfig, TableProps } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { normalizePercentColumns } from '@/utils/percent-table-layout'
|
||||
|
||||
@@ -12,7 +13,56 @@ import { normalizePercentColumns } from '@/utils/percent-table-layout'
|
||||
export default function PercentTable<RecordType extends object = Record<string, unknown>>(
|
||||
props: TableProps<RecordType>,
|
||||
) {
|
||||
const { columns, scroll, tableLayout: _tableLayout, ...rest } = props
|
||||
const { columns, scroll, tableLayout: _tableLayout, pagination, dataSource, ...rest } = props
|
||||
const paginationConfig = pagination && typeof pagination === 'object' ? pagination : null
|
||||
const paginationIsExternallyControlled = paginationConfig?.current !== undefined
|
||||
// 本地分页也必须由组件持有 current;只交给 AntD 内部状态时,父级重渲染会丢失翻页。
|
||||
const [localPagination, setLocalPagination] = useState(() => ({
|
||||
current: 1,
|
||||
pageSize: Number(paginationConfig?.defaultPageSize || paginationConfig?.pageSize || 10),
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (paginationIsExternallyControlled || !paginationConfig?.pageSize) return
|
||||
const nextPageSize = Number(paginationConfig.pageSize)
|
||||
if (!Number.isFinite(nextPageSize) || nextPageSize <= 0) return
|
||||
setLocalPagination((current) =>
|
||||
current.pageSize === nextPageSize ? current : { current: 1, pageSize: nextPageSize },
|
||||
)
|
||||
}, [paginationConfig?.pageSize, paginationIsExternallyControlled])
|
||||
|
||||
useEffect(() => {
|
||||
if (paginationIsExternallyControlled || !paginationConfig) return
|
||||
const total = Math.max(0, Number(paginationConfig.total ?? dataSource?.length ?? 0))
|
||||
const maxPage = Math.max(1, Math.ceil(total / Math.max(1, localPagination.pageSize)))
|
||||
if (localPagination.current > maxPage) {
|
||||
setLocalPagination((current) => ({ ...current, current: maxPage }))
|
||||
}
|
||||
}, [
|
||||
dataSource?.length,
|
||||
localPagination.current,
|
||||
localPagination.pageSize,
|
||||
paginationConfig,
|
||||
paginationIsExternallyControlled,
|
||||
])
|
||||
|
||||
const resolvedPagination: TablePaginationConfig | false | undefined = paginationConfig
|
||||
? paginationIsExternallyControlled
|
||||
? paginationConfig
|
||||
: {
|
||||
...paginationConfig,
|
||||
current: localPagination.current,
|
||||
pageSize: localPagination.pageSize,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
const normalizedPageSize = Math.max(1, Number(nextPageSize) || localPagination.pageSize)
|
||||
setLocalPagination({
|
||||
current: normalizedPageSize === localPagination.pageSize ? nextPage : 1,
|
||||
pageSize: normalizedPageSize,
|
||||
})
|
||||
paginationConfig.onChange?.(nextPage, nextPageSize)
|
||||
},
|
||||
}
|
||||
: pagination
|
||||
const reservedPercent =
|
||||
(props.rowSelection ? 4 : 0) +
|
||||
(props.expandable && typeof props.expandable === 'object' ? 4 : 0)
|
||||
@@ -24,7 +74,9 @@ export default function PercentTable<RecordType extends object = Record<string,
|
||||
return (
|
||||
<AntTable<RecordType>
|
||||
{...rest}
|
||||
dataSource={dataSource}
|
||||
columns={normalizedColumns}
|
||||
pagination={resolvedPagination}
|
||||
tableLayout="fixed"
|
||||
scroll={{ ...scrollConfig, x: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AccountBookOutlined,
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
BranchesOutlined,
|
||||
BugOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
@@ -182,6 +183,7 @@ export default function AdminLayout() {
|
||||
const isFinanceKey = [
|
||||
'/admin/sales-profit',
|
||||
'/admin/cost-center',
|
||||
'/admin/fulfillment-channel',
|
||||
'/admin/fund',
|
||||
'/admin/finance',
|
||||
'/admin/finance/ledger',
|
||||
@@ -198,6 +200,7 @@ export default function AdminLayout() {
|
||||
const financeSubItems: MenuProps['items'] = [
|
||||
{ key: '/admin/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
|
||||
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
|
||||
{ key: '/admin/fulfillment-channel', icon: <BranchesOutlined />, label: '履约渠道' },
|
||||
{ key: '/admin/fund', icon: <WalletOutlined />, label: '钱包管理' },
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '收支明细' },
|
||||
{ key: '/admin/finance/ledger', icon: <AuditOutlined />, label: '总账与对账' },
|
||||
@@ -846,6 +849,7 @@ function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
|
||||
if (pathname.startsWith('/admin/sales-profit')) return '/admin/sales-profit'
|
||||
if (pathname.startsWith('/admin/cost-center')) return '/admin/cost-center'
|
||||
if (pathname.startsWith('/admin/fulfillment-channel')) return '/admin/fulfillment-channel'
|
||||
if (pathname.startsWith('/admin/fund')) return '/admin/fund'
|
||||
if (pathname.startsWith('/admin/finance/ledger')) return '/admin/finance/ledger'
|
||||
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { Dayjs } from 'dayjs'
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
closeFinancePeriod,
|
||||
@@ -509,16 +510,15 @@ export default function AdminFinanceLedgerPage() {
|
||||
dataSource={entriesQuery.data?.data.items || []}
|
||||
columns={entryColumns}
|
||||
onRow={(row) => ({ onClick: () => setEntryDetail(row) })}
|
||||
pagination={{
|
||||
pagination={buildAdminTablePagination({
|
||||
current: entryPage,
|
||||
pageSize: entryPageSize,
|
||||
total: entriesQuery.data?.data.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => {
|
||||
setEntryPage(page)
|
||||
setEntryPageSize(size)
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
@@ -566,16 +566,15 @@ export default function AdminFinanceLedgerPage() {
|
||||
loading={reconciliationsQuery.isLoading}
|
||||
dataSource={reconciliationsQuery.data?.data.items || []}
|
||||
columns={reconColumns}
|
||||
pagination={{
|
||||
pagination={buildAdminTablePagination({
|
||||
current: reconPage,
|
||||
pageSize: reconPageSize,
|
||||
total: reconciliationsQuery.data?.data.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => {
|
||||
setReconPage(page)
|
||||
setReconPageSize(size)
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
* · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音;
|
||||
* · 第三方卡券是「只统计」渠道:不建任务、不发领取页。
|
||||
*/
|
||||
import { PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -24,13 +29,13 @@ import {
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import {
|
||||
assignChannelBySku,
|
||||
deleteChannelRule,
|
||||
deleteChannelRules,
|
||||
fetchChannelExceptions,
|
||||
fetchChannelRules,
|
||||
fetchPendingChannelGroups,
|
||||
@@ -43,6 +48,7 @@ import {
|
||||
} from '@/services/admin'
|
||||
import { formatRedeemAmount } from '@/utils/redeem'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'work_order', label: '接单平台' },
|
||||
@@ -58,11 +64,7 @@ const CHANNEL_COLOR: Record<string, string> = {
|
||||
manual: 'orange',
|
||||
}
|
||||
|
||||
const MATCH_KEY_OPTIONS = [
|
||||
{ value: 'sku_code', label: '平台 SKU' },
|
||||
{ value: 'internal_sku', label: '内部 SKU' },
|
||||
{ value: 'sku_name', label: 'SKU 名称' },
|
||||
]
|
||||
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
||||
|
||||
export default function AdminFulfillmentChannelPage() {
|
||||
return (
|
||||
@@ -84,11 +86,13 @@ export default function AdminFulfillmentChannelPage() {
|
||||
function PendingPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selections, setSelections] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-pending', keyword],
|
||||
queryFn: () => fetchPendingChannelGroups({ page: 1, pageSize: 100, keyword }),
|
||||
queryKey: ['fulfillment-pending', keyword, page, pageSize],
|
||||
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword }),
|
||||
})
|
||||
|
||||
const assignMutation = useMutation({
|
||||
@@ -100,7 +104,13 @@ function PendingPanel() {
|
||||
channel: selections[rowKey(row)] || '',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
message.success(`已指定 ${result.data.updated} 个订单项`)
|
||||
if (result.data.carrierFailed > 0) {
|
||||
message.warning(
|
||||
`已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`,
|
||||
)
|
||||
} else {
|
||||
message.success(`已指定 ${result.data.updated} 个订单项`)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: () => message.error('指定渠道失败'),
|
||||
@@ -135,6 +145,7 @@ function PendingPanel() {
|
||||
no_internal_sku: '无内部SKU',
|
||||
no_channel_capability: '无渠道能力',
|
||||
no_carrier: '无履约载体',
|
||||
routing_conflict: '渠道冲突',
|
||||
}
|
||||
return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'
|
||||
},
|
||||
@@ -188,7 +199,10 @@ function PendingPanel() {
|
||||
allowClear
|
||||
placeholder="SKU / 商品名"
|
||||
style={{ width: 220 }}
|
||||
onSearch={(value) => setKeyword(value)}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
@@ -207,7 +221,7 @@ function PendingPanel() {
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="待分类订单的收入已经计入「未分类」桶,不会丢失;指定渠道后会自动进入商品归一与成本核算。"
|
||||
message="指定渠道会写入该内部 SKU 的全局配置,并影响所有平台和店铺;系统会先确认载体创建成功,再进入商品归一与成本核算。"
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey={(row) => rowKey(row)}
|
||||
@@ -215,7 +229,15 @@ function PendingPanel() {
|
||||
loading={query.isFetching}
|
||||
dataSource={query.data?.data?.items || []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: query.data?.data?.page || page,
|
||||
pageSize: query.data?.data?.pageSize || pageSize,
|
||||
total: query.data?.data?.total || 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
@@ -224,12 +246,21 @@ function PendingPanel() {
|
||||
function RulesPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedRuleIds, setSelectedRuleIds] = useState<number[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-rules'],
|
||||
queryFn: () => fetchChannelRules(),
|
||||
queryKey: ['fulfillment-rules', page, pageSize],
|
||||
queryFn: () => fetchChannelRules({ page, pageSize }),
|
||||
})
|
||||
const ruleCount = query.data?.data?.total || 0
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(ruleCount / pageSize))
|
||||
if (page > maxPage) setPage(maxPage)
|
||||
}, [page, pageSize, ruleCount])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => saveChannelRule(values as never),
|
||||
@@ -243,17 +274,33 @@ function RulesPanel() {
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteChannelRule(id),
|
||||
onSuccess: () => {
|
||||
message.success('规则已删除')
|
||||
mutationFn: (ids: number[]) => deleteChannelRules(ids),
|
||||
onSuccess: (result) => {
|
||||
const missingHint = result.data.missingIds.length
|
||||
? `,${result.data.missingIds.length} 条已不存在`
|
||||
: ''
|
||||
message.success(`已删除 ${result.data.deletedCount} 条渠道规则${missingHint}`)
|
||||
setSelectedRuleIds([])
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: () => message.error('删除规则失败'),
|
||||
})
|
||||
|
||||
function confirmDeleteRules(ids: number[]) {
|
||||
if (ids.length === 0) return
|
||||
Modal.confirm({
|
||||
title: ids.length === 1 ? '删除渠道规则' : `批量删除 ${ids.length} 条渠道规则`,
|
||||
content: '删除后会立即影响新订单的渠道判定;历史订单和成本数据不会被删除。',
|
||||
okText: '确认删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteMutation.mutateAsync(ids),
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '来源', dataIndex: 'provider', width: 120, render: (v: string) => v || '不限' },
|
||||
{ title: '店铺', dataIndex: 'shop_id', width: 140, render: (v: string) => v || '不限' },
|
||||
{ title: '作用范围', width: 110, render: () => <Tag color="blue">全局</Tag> },
|
||||
{
|
||||
title: '匹配键',
|
||||
dataIndex: 'match_key_type',
|
||||
@@ -286,7 +333,7 @@ function RulesPanel() {
|
||||
danger
|
||||
size="small"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(row.id)}
|
||||
onClick={() => confirmDeleteRules([row.id])}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -296,9 +343,18 @@ function RulesPanel() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={`渠道规则(${query.data?.data?.length ?? 0})`}
|
||||
title={`统一渠道配置(${ruleCount})`}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRuleIds.length === 0}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => confirmDeleteRules(selectedRuleIds)}
|
||||
>
|
||||
批量删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -309,20 +365,32 @@ function RulesPanel() {
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
接单平台由接单模板匹配决定,不在此表维护;这里负责自动发货、人工、第三方卡券三个渠道与运营强制覆盖。
|
||||
同优先级命中多个渠道时订单保持待分类并进「配置冲突」。
|
||||
配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道;
|
||||
其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。
|
||||
</Typography.Paragraph>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isFetching}
|
||||
dataSource={query.data?.data || []}
|
||||
dataSource={query.data?.data?.items || []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRuleIds,
|
||||
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
|
||||
}}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: query.data?.data?.page || page,
|
||||
pageSize: query.data?.data?.pageSize || pageSize,
|
||||
total: ruleCount,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新增渠道规则"
|
||||
title="新增统一渠道配置"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
@@ -331,15 +399,13 @@ function RulesPanel() {
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ matchKeyType: 'sku_code', channel: 'third_party_voucher', priority: 10 }}
|
||||
initialValues={{
|
||||
matchKeyType: 'internal_sku',
|
||||
channel: 'third_party_voucher',
|
||||
priority: 10,
|
||||
}}
|
||||
onFinish={(values) => saveMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item name="provider" label="来源(留空=不限)">
|
||||
<Input placeholder="91kaquan / pdd / douyin_mashida" />
|
||||
</Form.Item>
|
||||
<Form.Item name="shopId" label="店铺(留空=不限)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="matchKeyType" label="匹配键" rules={[{ required: true }]}>
|
||||
<Select options={MATCH_KEY_OPTIONS} />
|
||||
</Form.Item>
|
||||
@@ -366,9 +432,11 @@ function RulesPanel() {
|
||||
|
||||
function ExceptionsPanel() {
|
||||
const [reason, setReason] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-exceptions', reason],
|
||||
queryFn: () => fetchChannelExceptions({ reason, status: 'open', page: 1, pageSize: 100 }),
|
||||
queryKey: ['fulfillment-exceptions', reason, page, pageSize],
|
||||
queryFn: () => fetchChannelExceptions({ reason, status: 'open', page, pageSize }),
|
||||
})
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
@@ -408,7 +476,10 @@ function ExceptionsPanel() {
|
||||
style={{ width: 180 }}
|
||||
value={reason}
|
||||
options={REASON_OPTIONS}
|
||||
onChange={setReason}
|
||||
onChange={(value) => {
|
||||
setReason(value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
@@ -422,7 +493,15 @@ function ExceptionsPanel() {
|
||||
loading={query.isFetching}
|
||||
dataSource={query.data?.data?.items || []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: query.data?.data?.page || page,
|
||||
pageSize: query.data?.data?.pageSize || pageSize,
|
||||
total: query.data?.data?.total || 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -835,7 +835,7 @@ export default function AdminTaskDetailPage() {
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, display: 'block', marginBottom: 6 }}
|
||||
>
|
||||
历史/备用 Token 链接:
|
||||
任务领取链接(统一 H5 订单号):
|
||||
</Typography.Text>
|
||||
<Space wrap align="center">
|
||||
{claimUrl ? (
|
||||
@@ -861,7 +861,7 @@ export default function AdminTaskDetailPage() {
|
||||
disabled={!claimUrl}
|
||||
onClick={copyClaimUrl}
|
||||
>
|
||||
复制 Token 链接
|
||||
复制领取链接
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
@@ -97,6 +97,7 @@ export default function CostCenterShell() {
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-cost-center-overview', scope],
|
||||
queryFn: () => fetchAdminCostCenterOverview({ scope }),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: ['admin-cost-center-options'],
|
||||
@@ -105,6 +106,9 @@ export default function CostCenterShell() {
|
||||
|
||||
const overview = overviewQuery.data?.data
|
||||
const options = optionsQuery.data?.data
|
||||
const costCenterFetching = useIsFetching({
|
||||
predicate: (query) => String(query.queryKey[0] ?? '').startsWith('admin-cost-center'),
|
||||
})
|
||||
|
||||
function refreshAll() {
|
||||
// 按前缀失效:新增一个 admin-cost-center-* 查询不需要再回来改这里。
|
||||
@@ -145,7 +149,7 @@ export default function CostCenterShell() {
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={refreshAll}
|
||||
loading={overviewQuery.isFetching}
|
||||
loading={costCenterFetching > 0}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -162,7 +166,7 @@ export default function CostCenterShell() {
|
||||
].map(([label, value]) => (
|
||||
<Col xs={12} md={8} xl={4} key={label}>
|
||||
<Card>
|
||||
<Statistic title={label} value={value as number} />
|
||||
<Statistic title={label} value={overview ? (value as number) : '—'} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
@@ -179,11 +183,13 @@ export default function CostCenterShell() {
|
||||
<Space size={4}>
|
||||
<span>{item.label}</span>
|
||||
<Tag>
|
||||
{Number(
|
||||
item.key === 'all'
|
||||
? (overview?.productTotalCount ?? overview?.productCount ?? 0)
|
||||
: (overview?.productSourceCounts?.[item.key] ?? 0),
|
||||
)}
|
||||
{overview
|
||||
? Number(
|
||||
item.key === 'all'
|
||||
? (overview.productTotalCount ?? overview.productCount ?? 0)
|
||||
: (overview.productSourceCounts?.[item.key] ?? 0),
|
||||
)
|
||||
: '—'}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
|
||||
*/
|
||||
import {
|
||||
ClearOutlined,
|
||||
DeleteOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
@@ -41,6 +42,8 @@ import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
applyAdminTemplateProductBootstrap,
|
||||
archiveAdminHistoricalManualProducts,
|
||||
archiveAdminSupersededManualProducts,
|
||||
createAdminProductCatalog,
|
||||
deleteAdminProductCatalog,
|
||||
fetchAdminProductCatalog,
|
||||
@@ -54,6 +57,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
|
||||
|
||||
import { useCostCenter } from './CostCenterContext'
|
||||
import {
|
||||
FULFILLMENT_MODE_LABELS,
|
||||
PRODUCT_SOURCE_OPTIONS,
|
||||
PRODUCT_SOURCE_TAG_COLORS,
|
||||
labelOf,
|
||||
@@ -93,7 +97,7 @@ export function ProductCatalogPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
|
||||
const [productForm] = Form.useForm<ProductFormValues>()
|
||||
// 初始化/回填只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
|
||||
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
|
||||
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(29, 'day').startOf('day'),
|
||||
dayjs().endOf('day'),
|
||||
@@ -105,6 +109,8 @@ export function ProductCatalogPanel() {
|
||||
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
|
||||
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
||||
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
||||
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
|
||||
const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false)
|
||||
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
|
||||
const [productPage, setProductPage] = useState(1)
|
||||
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
@@ -146,6 +152,7 @@ export function ProductCatalogPanel() {
|
||||
page: productPage,
|
||||
pageSize: productPageSize,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const products = productsQuery.data?.data.items || []
|
||||
@@ -174,7 +181,7 @@ export function ProductCatalogPanel() {
|
||||
const data = response.data
|
||||
bootstrapSyncOptionsRef.current.includeDisabledTemplates = false
|
||||
modal.confirm({
|
||||
title: '从接单模板全量同步商品与映射',
|
||||
title: '修复接单模板历史商品与映射',
|
||||
width: 720,
|
||||
content: (
|
||||
<Space direction="vertical" size={8}>
|
||||
@@ -273,7 +280,7 @@ export function ProductCatalogPanel() {
|
||||
? `(含停用模板 ${applied.disabledTemplateCount} 个,已建为归档商品)`
|
||||
: ''
|
||||
message.success(
|
||||
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
|
||||
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
|
||||
)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
@@ -344,7 +351,6 @@ export function ProductCatalogPanel() {
|
||||
: undefined,
|
||||
effectiveScope: 'immediate',
|
||||
changeReason: '在商品与成本中心统一调整价格',
|
||||
syncOpenOrders: true,
|
||||
syncSnapshots: true,
|
||||
})
|
||||
setPriceModalOpen(true)
|
||||
@@ -355,7 +361,6 @@ export function ProductCatalogPanel() {
|
||||
effectiveScope: 'immediate' | 'custom'
|
||||
effectiveFromCustom?: string
|
||||
changeReason?: string
|
||||
syncOpenOrders?: boolean
|
||||
}) {
|
||||
if (!pricingProduct) return
|
||||
const yuan = Number(values.amountYuan)
|
||||
@@ -375,7 +380,7 @@ export function ProductCatalogPanel() {
|
||||
amountFen,
|
||||
effectiveFrom,
|
||||
changeReason: values.changeReason || '在商品与成本中心统一调整价格',
|
||||
syncOpenOrders: values.syncOpenOrders !== false,
|
||||
syncOpenOrders: true,
|
||||
})
|
||||
const result = response.data
|
||||
let extraHint = ''
|
||||
@@ -383,6 +388,12 @@ export function ProductCatalogPanel() {
|
||||
extraHint = `,已同步 ${result.syncedOrders.updatedCount} 笔工单大厅待抢单`
|
||||
}
|
||||
message.success(`标准价格配置成功(¥${(amountFen / 100).toFixed(2)})${extraHint}`)
|
||||
if (pricingProduct.canonicalSku.startsWith('tpl:') && !pricingProduct.templateEnabled) {
|
||||
message.info('成本已配置;请回接单模板启用该模板后开始自动接单')
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
})
|
||||
setPriceModalOpen(false)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
@@ -441,6 +452,76 @@ export function ProductCatalogPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveHistoricalManuals() {
|
||||
setArchivingHistoricalManuals(true)
|
||||
try {
|
||||
const preview = (await archiveAdminHistoricalManualProducts()).data
|
||||
if (preview.candidateCount === 0) {
|
||||
message.info('没有需要归档的历史人工商品')
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: '归档历史人工商品',
|
||||
content: `将归档 ${preview.candidateCount} 个仅被已归档订单引用的历史人工商品。不会删除订单、订单项、成本或工单数据,商品只是从当前目录隐藏。`,
|
||||
okText: '确认归档',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setArchivingHistoricalManuals(true)
|
||||
try {
|
||||
const result = (await archiveAdminHistoricalManualProducts({ execute: true })).data
|
||||
message.success(`已归档 ${result.archivedCount} 个历史人工商品`)
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '归档历史人工商品失败')
|
||||
} finally {
|
||||
setArchivingHistoricalManuals(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取历史人工商品失败')
|
||||
} finally {
|
||||
setArchivingHistoricalManuals(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSupersededManuals() {
|
||||
setArchivingSupersededManuals(true)
|
||||
try {
|
||||
const preview = (await archiveAdminSupersededManualProducts()).data
|
||||
if (preview.candidateCount === 0) {
|
||||
message.info('没有已被其他渠道接管的人工归一商品')
|
||||
return
|
||||
}
|
||||
modal.confirm({
|
||||
title: '归档跨渠道重复的人工归一商品',
|
||||
content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`,
|
||||
okText: '确认归档',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setArchivingSupersededManuals(true)
|
||||
try {
|
||||
const result = (await archiveAdminSupersededManualProducts({ execute: true })).data
|
||||
message.success(
|
||||
`已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`,
|
||||
)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败')
|
||||
} finally {
|
||||
setArchivingSupersededManuals(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败')
|
||||
} finally {
|
||||
setArchivingSupersededManuals(false)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteProduct(item: ProductCatalogItem) {
|
||||
const affiliateHint =
|
||||
item.source === 'affiliate_dash'
|
||||
@@ -498,6 +579,22 @@ export function ProductCatalogPanel() {
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
单位:{row.unitName || '件'}
|
||||
</Typography.Text>
|
||||
{row.canonicalSku.startsWith('tpl:') ? (
|
||||
<Tag color={row.templateEnabled ? 'green' : 'orange'} style={{ marginInlineEnd: 0 }}>
|
||||
{row.templateEnabled ? '模板已启用' : '模板待启用'}
|
||||
</Tag>
|
||||
) : null}
|
||||
{row.templatePricingMode ? (
|
||||
<Tag color="geekblue" style={{ marginInlineEnd: 0 }}>
|
||||
{row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'}
|
||||
</Tag>
|
||||
) : null}
|
||||
{row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? (
|
||||
<Tag color="purple" style={{ marginInlineEnd: 0 }}>
|
||||
当前主渠道:
|
||||
{FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
@@ -659,6 +756,24 @@ export function ProductCatalogPanel() {
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Space size={6} wrap>
|
||||
{productSourceFilter === 'manual' ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
loading={archivingSupersededManuals}
|
||||
onClick={() => void archiveSupersededManuals()}
|
||||
>
|
||||
归档跨渠道重复商品
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
loading={archivingHistoricalManuals}
|
||||
onClick={() => void archiveHistoricalManuals()}
|
||||
>
|
||||
归档纯历史人工商品
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{productSourceFilter === 'work_order' ? (
|
||||
<>
|
||||
<AdminRangePicker
|
||||
@@ -673,7 +788,7 @@ export function ProductCatalogPanel() {
|
||||
loading={bootstrapPreviewing || bootstrapApplying}
|
||||
onClick={() => void runTemplateBootstrap()}
|
||||
>
|
||||
从接单模板初始化
|
||||
修复历史商品/映射
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -783,7 +898,7 @@ export function ProductCatalogPanel() {
|
||||
size="small"
|
||||
columns={productColumns}
|
||||
dataSource={products}
|
||||
loading={productsQuery.isLoading}
|
||||
loading={productsQuery.isFetching}
|
||||
rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')}
|
||||
scroll={{ x: 1180 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
@@ -902,16 +1017,38 @@ export function ProductCatalogPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`当前计价方式:${
|
||||
pricingProduct.templatePricingMode === 'unit' ? '按 SKU 按件' : '固定金额'
|
||||
}`}
|
||||
description="计价方式由接单模板统一设置;这里只维护对应金额。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label={
|
||||
pricingProduct.source === 'affiliate_dash' ? '采购单价(元)' : '标准结算单价(元)'
|
||||
pricingProduct.source === 'affiliate_dash'
|
||||
? '采购单价(元)'
|
||||
: pricingProduct.canonicalSku.startsWith('tpl:')
|
||||
? pricingProduct.templatePricingMode === 'unit'
|
||||
? '每件接单成本(元)'
|
||||
: '整单接单成本(元)'
|
||||
: '标准结算单价(元)'
|
||||
}
|
||||
rules={[{ required: true, message: '请填写价格' }]}
|
||||
extra={
|
||||
pricingProduct.source === 'affiliate_dash'
|
||||
? '上游每发货 1 件扣除的采购成本金额'
|
||||
: '接单打手发单/验收的基础结算单价'
|
||||
: pricingProduct.canonicalSku.startsWith('tpl:')
|
||||
? pricingProduct.templatePricingMode === 'unit'
|
||||
? `订单有 N 件时,本单接单成本 = 当前金额 × N`
|
||||
: '无论订单件数,当前金额就是整单接单成本'
|
||||
: '接单打手发单/验收的基础结算单价'
|
||||
}
|
||||
>
|
||||
<InputNumber
|
||||
@@ -936,8 +1073,13 @@ export function ProductCatalogPanel() {
|
||||
只影响之后的新订单;历史订单的成本与成交价已固化,不会被改价覆盖
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="custom">
|
||||
<Radio value="custom" disabled={pricingProduct.canonicalSku.startsWith('tpl:')}>
|
||||
<strong>指定生效时间(不能早于当前时间)</strong>
|
||||
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
|
||||
<div style={{ color: '#cf1322', fontSize: 12, marginTop: 2 }}>
|
||||
接单模板当前只支持立即生效,避免模板价格与成本版本不同步
|
||||
</div>
|
||||
) : null}
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
@@ -963,17 +1105,6 @@ export function ProductCatalogPanel() {
|
||||
<Input placeholder="例如:成本中心统一定价调整 / 上游采购成本调整" />
|
||||
</Form.Item>
|
||||
|
||||
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
|
||||
<Form.Item name="syncOpenOrders" valuePropName="checked" style={{ marginBottom: 12 }}>
|
||||
<Checkbox>
|
||||
<strong>立即同步至接单大厅待抢单工单(推荐)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>
|
||||
接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。
|
||||
</div>
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{pricingProduct.source === 'affiliate_dash' ? (
|
||||
<Form.Item name="syncSnapshots" valuePropName="checked" style={{ marginBottom: 12 }}>
|
||||
<Checkbox>
|
||||
|
||||
@@ -27,25 +27,25 @@ import {
|
||||
setAdminProductPrice,
|
||||
} from '@/services/admin'
|
||||
import type { ProductCatalogItem } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { useCostCenter } from './CostCenterContext'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
export default function VoucherSkuPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const { canConfig, refreshAll } = useCostCenter()
|
||||
const [page] = useState(1)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selected, setSelected] = useState<ProductCatalogItem | null>(null)
|
||||
const [ensuringRules, setEnsuringRules] = useState(false)
|
||||
const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['cost-center-voucher-products', page],
|
||||
queryKey: ['cost-center-voucher-products', page, pageSize],
|
||||
queryFn: () =>
|
||||
fetchAdminProductCatalog({
|
||||
carrierType: 'third_party_voucher',
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageSize,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -154,7 +154,15 @@ export default function VoucherSkuPanel() {
|
||||
loading={query.isFetching}
|
||||
dataSource={items}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* 发单价格 Tab:只读展示「接单模板 → 发单金额」的当前生效版本。
|
||||
* 发单价格 Tab:展示由标准商品成本自动投影出的接单模板当前生效版本。
|
||||
*
|
||||
* 接单平台的成本以打手报酬为主,金额由接单模板(work_product_rules)决定,
|
||||
* 这里只做核对视图;改价请改模板后点「同步模板发单金额」。
|
||||
* 这里只做核对视图;改价在「标准商品」中完成并自动同步。
|
||||
* 人工与自动发货的成本分别在各自 SKU 配置里维护,不在这里重复配置。
|
||||
*/
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { Alert, App, Button, Card, Input, Select, Space, Tag, Typography } from 'antd'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { Alert, Card, Input, Select, Space, Tag, Typography } from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { fetchAdminCostVersions, syncAdminWorkOrderTemplateCostVersions } from '@/services/admin'
|
||||
import { fetchAdminCostVersions } from '@/services/admin'
|
||||
import type { CostVersion } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
@@ -22,14 +22,12 @@ import { useCostCenter } from './CostCenterContext'
|
||||
import { formatRate } from './shared'
|
||||
|
||||
export function WorkOrderPricePanel() {
|
||||
const { message } = App.useApp()
|
||||
const { canConfig, refreshAll } = useCostCenter()
|
||||
const { canConfig } = useCostCenter()
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState('active')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: ['admin-cost-center-cost-versions', 'work_order', keyword, status, page, pageSize],
|
||||
@@ -46,22 +44,6 @@ export function WorkOrderPricePanel() {
|
||||
const versions = versionsQuery.data?.data.items || []
|
||||
const total = versionsQuery.data?.data.pagination.total || 0
|
||||
|
||||
async function syncTemplateCosts() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const response = await syncAdminWorkOrderTemplateCostVersions()
|
||||
const result = response.data
|
||||
message.success(
|
||||
`已同步 ${result.activeVersionCount} 条模板发单金额(新建 ${result.createdCount},停用 ${result.retiredCount}),关联 ${result.linkedSnapshotCount} 条成本快照`,
|
||||
)
|
||||
refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '同步接单模板发单金额失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<CostVersion> = [
|
||||
{
|
||||
title: '接单规则 / 模板',
|
||||
@@ -176,24 +158,14 @@ export function WorkOrderPricePanel() {
|
||||
<Tag color="blue">{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncing}
|
||||
onClick={() => void syncTemplateCosts()}
|
||||
>
|
||||
同步模板发单金额
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
extra={canConfig ? <Tag color="green">自动同步</Tag> : null}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="接单平台的成本只看这张表;改价请改接单模板,再点「同步模板发单金额」"
|
||||
description="人工客服的成本来自「人工 SKU 配置」里的消耗积分,自动发货的成本在「自动发货 SKU」里维护,二者都不在这里配置;系统内置成本项只有人工履约、自动发货采购、接单平台报酬 3 条。"
|
||||
message="接单平台成本的唯一写入口是【标准商品 → 配置成本】"
|
||||
description="改价保存后会自动更新成本版本、接单模板运行价格和本表;未领取的大厅工单同步新价,已领取工单保持原价。人工客服和自动发货成本分别在各自 SKU 配置中维护。"
|
||||
/>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import type { PlatformCreditAccount, PlatformCreditEntry, PlatformCreditOwner } from '@/types/admin'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
@@ -406,7 +407,7 @@ export function PlatformCreditPanel() {
|
||||
dataSource={visibleAccounts}
|
||||
loading={accountsQuery.isLoading}
|
||||
scroll={{ x: 1080 }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
pagination={buildAdminLocalTablePagination()}
|
||||
locale={{ emptyText: '还没有客服积分账户' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -880,18 +881,16 @@ export default function ConsignmentInventoryPanel() {
|
||||
dataSource={items}
|
||||
loading={inventoriesQuery.isLoading}
|
||||
scroll={{ x: 1590 }}
|
||||
pagination={{
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (t) => `共 ${t} 件物品`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
App,
|
||||
} from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -474,17 +475,16 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
dataSource={submissions}
|
||||
loading={submissionsQuery.isLoading}
|
||||
scroll={{ x: 1490 }}
|
||||
pagination={{
|
||||
pagination={buildAdminTablePagination({
|
||||
current: submissionPage,
|
||||
pageSize: submissionPageSize,
|
||||
total: submissionTotal,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条提报记录`,
|
||||
onChange: (p, ps) => {
|
||||
setSubmissionPage(p)
|
||||
setSubmissionPageSize(ps)
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@ import {
|
||||
import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform'
|
||||
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import {
|
||||
ADMIN_DEFAULT_PAGE_SIZE,
|
||||
buildAdminLocalTablePagination,
|
||||
buildAdminTablePagination,
|
||||
} from '@/utils/admin-pagination'
|
||||
import { formatMoney } from './shared'
|
||||
|
||||
export default function ProductMatchPanel() {
|
||||
@@ -366,7 +370,7 @@ export default function ProductMatchPanel() {
|
||||
loading={sourcesQuery.isLoading}
|
||||
columns={sourceColumns}
|
||||
dataSource={sources}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
pagination={buildAdminLocalTablePagination()}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
fetchAdminWorkProductRules,
|
||||
reprocessAdminKuaishouWorkOrderMatches,
|
||||
saveAdminWorkProductRule,
|
||||
syncAdminWorkProductRulePrice,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
CollectField,
|
||||
@@ -73,7 +71,6 @@ export default function ProductRulesPanel() {
|
||||
const hasInitializedRuleSelection = useRef(false)
|
||||
|
||||
const [editingRule, setEditingRule] = useState<WorkProductRule | null>(null)
|
||||
const [syncingPrice, setSyncingPrice] = useState(false)
|
||||
const [exportingRules, setExportingRules] = useState(false)
|
||||
const [activeMappingId, setActiveMappingId] = useState<number | 'new' | null>(null)
|
||||
const [sourcePrefill, setSourcePrefill] = useState<SourcePrefill | null>(null)
|
||||
@@ -296,9 +293,7 @@ export default function ProductRulesPanel() {
|
||||
itemTitleMatchType: rule.match.itemTitleMatchType,
|
||||
skuNickMatchType: rule.match.skuNickMatchType,
|
||||
categoryId: rule.categoryId || undefined,
|
||||
pricingMode: rule.unitPriceFen > 0 ? 'unit' : 'fixed',
|
||||
rewardAmount: rule.rewardAmount / 100,
|
||||
unitPrice: rule.unitPriceFen / 100,
|
||||
pricingMode: rule.pricingMode || 'fixed',
|
||||
sharingEnabled: rule.sharing?.enabled || false,
|
||||
sharingAutoFromOrder: rule.sharing?.autoFromOrder || false,
|
||||
sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined,
|
||||
@@ -342,9 +337,6 @@ export default function ProductRulesPanel() {
|
||||
itemTitleMatchType?: 'contains' | 'exact'
|
||||
skuNickMatchType?: 'contains' | 'exact'
|
||||
categoryId?: number
|
||||
rewardAmount?: number
|
||||
pricingMode?: string
|
||||
unitPrice?: number
|
||||
sharingEnabled?: boolean
|
||||
sharingAutoFromOrder?: boolean
|
||||
sharingTotalQuantity?: number
|
||||
@@ -358,10 +350,10 @@ export default function ProductRulesPanel() {
|
||||
fieldsText?: string
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
pricingMode?: 'fixed' | 'unit'
|
||||
sortOrder?: number
|
||||
}) {
|
||||
try {
|
||||
const unitMode = values.pricingMode === 'unit'
|
||||
const isFriendGift = values.acceptanceMode === 'friend_gift'
|
||||
if (isFriendGift && values.sharingEnabled === true) {
|
||||
message.error('好友赠送验收模式不支持拼单,请先关闭拼单')
|
||||
@@ -372,12 +364,11 @@ export default function ProductRulesPanel() {
|
||||
? Number(values.sharingTotalQuantity || 0) > 0 ||
|
||||
Number(values.sharingUnitReward || 0) > 0
|
||||
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
|
||||
const { pricingMode: _pricingMode, giftCooldownUnit, giftCooldownValue, ...payload } = values
|
||||
const { giftCooldownUnit, giftCooldownValue, ...payload } = values
|
||||
const res = await saveAdminWorkProductRule({
|
||||
...payload,
|
||||
ruleId: editingRule?.ruleId,
|
||||
ruleKey: editingRule?.ruleKey || '',
|
||||
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
||||
acceptanceMode: isFriendGift ? 'friend_gift' : 'standard',
|
||||
giftCooldownHours:
|
||||
isFriendGift && giftCooldownUnit !== 'minutes' ? Number(giftCooldownValue || 0) : 0,
|
||||
@@ -385,10 +376,16 @@ export default function ProductRulesPanel() {
|
||||
isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0,
|
||||
sharingEnabled: isFriendGift ? false : values.sharingEnabled === true,
|
||||
sharingAutoFromOrder:
|
||||
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
|
||||
values.pricingMode === 'unit' &&
|
||||
values.sharingEnabled === true &&
|
||||
!hasManualSharingOverride,
|
||||
})
|
||||
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
||||
const savedRule = res.data?.rule
|
||||
if (res.data?.costConfigurationRequired) {
|
||||
message.warning('模板已保存为停用。请到【商品与成本中心】配置成本后再启用')
|
||||
} else {
|
||||
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
||||
}
|
||||
if (savedRule) {
|
||||
editRule(savedRule)
|
||||
} else {
|
||||
@@ -401,42 +398,15 @@ export default function ProductRulesPanel() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'),
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSyncRulePrice() {
|
||||
if (!editingRule) return
|
||||
Modal.confirm({
|
||||
title: '同步模板价格',
|
||||
content:
|
||||
'将同步接单大厅中该模板的待抢单价格。部分拼单仅调整剩余份额价格,已加入份额报酬保持不变。',
|
||||
okText: '确认同步',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setSyncingPrice(true)
|
||||
try {
|
||||
const response = await syncAdminWorkProductRulePrice(editingRule.ruleId)
|
||||
const result = response.data
|
||||
message.success(
|
||||
`已同步 ${result.updatedCount} 单:待抢单 ${result.updatedNormalCount} 单,部分拼单 ${result.updatedPartialSharingCount} 单`,
|
||||
)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '同步价格失败')
|
||||
throw error
|
||||
} finally {
|
||||
setSyncingPrice(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleRuleEnabled(rule: WorkProductRule, enabled: boolean) {
|
||||
try {
|
||||
const hasManualSharingOverride = hasManualSharingInput(
|
||||
@@ -450,11 +420,12 @@ export default function ProductRulesPanel() {
|
||||
platform: rule.platform,
|
||||
productName: rule.productName,
|
||||
categoryId: rule.categoryId || undefined,
|
||||
rewardAmount: rule.rewardAmount / 100,
|
||||
unitPrice: rule.unitPriceFen / 100,
|
||||
pricingMode: rule.pricingMode,
|
||||
sharingEnabled: rule.sharing?.enabled || false,
|
||||
sharingAutoFromOrder:
|
||||
rule.unitPriceFen > 0 && rule.sharing?.enabled === true && !hasManualSharingOverride,
|
||||
rule.pricingMode === 'unit' &&
|
||||
rule.sharing?.enabled === true &&
|
||||
!hasManualSharingOverride,
|
||||
sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined,
|
||||
sharingUnitReward: hasManualSharingOverride
|
||||
? (rule.sharing?.unitReward || 0) / 100
|
||||
@@ -473,6 +444,9 @@ export default function ProductRulesPanel() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'),
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '修改状态失败')
|
||||
}
|
||||
@@ -782,19 +756,22 @@ export default function ProductRulesPanel() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '接单金额',
|
||||
title: '接单价格',
|
||||
key: 'reward',
|
||||
width: '20%',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
|
||||
{row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
{row.unitPriceFen > 0 ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
按件: {formatMoney(row.unitPriceFen)}
|
||||
{(row.pricingMode === 'unit' ? row.unitPriceFen : row.rewardAmount) > 0 ? (
|
||||
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
|
||||
{row.pricingMode === 'unit'
|
||||
? `${formatMoney(row.unitPriceFen)} / 件`
|
||||
: formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
) : (
|
||||
<Tag color="orange" style={{ width: 'fit-content', margin: 0 }}>
|
||||
待成本配置
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -891,11 +868,6 @@ export default function ProductRulesPanel() {
|
||||
+ 新建模板
|
||||
</Button>
|
||||
) : null}
|
||||
{editingRule ? (
|
||||
<Button loading={syncingPrice} size="middle" onClick={confirmSyncRulePrice}>
|
||||
同步价格
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" size="middle" htmlType="submit" form="product-rule-form">
|
||||
{editingRule ? '保存模板' : '创建模板'}
|
||||
</Button>
|
||||
@@ -913,10 +885,9 @@ export default function ProductRulesPanel() {
|
||||
matchType: 'contains',
|
||||
itemTitleMatchType: 'exact',
|
||||
skuNickMatchType: 'exact',
|
||||
pricingMode: 'fixed',
|
||||
rewardAmount: 25,
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
autoCreate: true,
|
||||
pricingMode: 'fixed',
|
||||
sharingEnabled: false,
|
||||
acceptanceMode: 'standard',
|
||||
giftCooldownUnit: 'hours',
|
||||
@@ -926,23 +897,21 @@ export default function ProductRulesPanel() {
|
||||
}}
|
||||
onFinish={saveRule}
|
||||
>
|
||||
{editingRule ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="全系统标准价格已统一收敛至【商品与成本中心】"
|
||||
description={
|
||||
<span>
|
||||
接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往
|
||||
<a href="#/admin/cost-center" style={{ marginLeft: 4, fontWeight: 500 }}>
|
||||
【商品与成本中心】
|
||||
</a>
|
||||
进行修改。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="接单模板维护计价方式和履约规则"
|
||||
description={
|
||||
<span>
|
||||
在这里选择“固定金额”或“按 SKU 按件”;具体金额与价格同步统一由
|
||||
<a href="#/admin/cost-center" style={{ margin: '0 4px', fontWeight: 500 }}>
|
||||
商品与成本中心
|
||||
</a>
|
||||
管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 基础信息 */}
|
||||
<div className="form-grid-3col">
|
||||
@@ -965,58 +934,27 @@ export default function ProductRulesPanel() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 计价方式 + 单价/金额 + 任务时限 */}
|
||||
{/* Row 2: 任务时限 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item label="计价方式" name="pricingMode">
|
||||
<Form.Item label="任务时限" name="timeoutMinutes">
|
||||
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="计价方式"
|
||||
name="pricingMode"
|
||||
rules={[{ required: true, message: '请选择计价方式' }]}
|
||||
extra="固定金额按整单;按 SKU 按件会把成本中心金额乘以订单件数"
|
||||
>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
className="full-width"
|
||||
options={[
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
{ value: 'unit', label: '按件计价' },
|
||||
{ value: 'unit', label: '按 SKU 按件' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('pricingMode') === 'unit' ? (
|
||||
<Form.Item
|
||||
label="按件单价"
|
||||
name="unitPrice"
|
||||
rules={[{ required: true, message: '请填写单价' }]}
|
||||
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
disabled={Boolean(editingRule)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
label="接单金额"
|
||||
name="rewardAmount"
|
||||
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
disabled={Boolean(editingRule)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="任务时限" name="timeoutMinutes">
|
||||
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 验收模式:好友赠送模式与拼单互斥 */}
|
||||
@@ -1126,11 +1064,19 @@ export default function ProductRulesPanel() {
|
||||
const sharingEnabled = getFieldValue('sharingEnabled')
|
||||
const unitMode = getFieldValue('pricingMode') === 'unit'
|
||||
const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift'
|
||||
const hasConfiguredPrice =
|
||||
Number(editingRule?.unitPriceFen || 0) > 0 ||
|
||||
Number(editingRule?.rewardAmount || 0) > 0
|
||||
|
||||
return (
|
||||
<div className="switches-inline-group span-2">
|
||||
<Form.Item label="启用模板" name="enabled" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
<Form.Item
|
||||
label="启用模板"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
tooltip={!hasConfiguredPrice ? '请先在商品与成本中心配置成本' : undefined}
|
||||
>
|
||||
<Switch size="small" disabled={!hasConfiguredPrice} />
|
||||
</Form.Item>
|
||||
<Form.Item label="自动建单" name="autoCreate" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
@@ -1139,9 +1085,15 @@ export default function ProductRulesPanel() {
|
||||
label="启用拼单"
|
||||
name="sharingEnabled"
|
||||
valuePropName="checked"
|
||||
tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined}
|
||||
tooltip={
|
||||
friendGiftMode
|
||||
? '好友赠送模式不支持拼单'
|
||||
: !hasConfiguredPrice
|
||||
? '请先在商品与成本中心配置成本'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Switch size="small" disabled={friendGiftMode} />
|
||||
<Switch size="small" disabled={friendGiftMode || !hasConfiguredPrice} />
|
||||
</Form.Item>
|
||||
|
||||
{sharingEnabled ? (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Tag,
|
||||
} from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -635,16 +636,15 @@ export default function RiskControlPanel() {
|
||||
dataSource={bans?.items || []}
|
||||
loading={bansQuery.isLoading}
|
||||
locale={{ emptyText: '暂无封禁记录' }}
|
||||
pagination={{
|
||||
pagination={buildAdminTablePagination({
|
||||
current: banPage,
|
||||
pageSize: banPageSize,
|
||||
total: bans?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setBanPage(nextPage)
|
||||
setBanPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -690,16 +690,15 @@ export default function RiskControlPanel() {
|
||||
columns={eventColumns}
|
||||
dataSource={events?.items || []}
|
||||
loading={eventsQuery.isLoading}
|
||||
pagination={{
|
||||
pagination={buildAdminTablePagination({
|
||||
current: page,
|
||||
pageSize,
|
||||
total: events?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -1697,7 +1697,7 @@ function AffiliateDashPlatformPanel() {
|
||||
<PercentTable<SkuMappingRow>
|
||||
rowKey={(row) => row.productNo}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
pagination={buildAdminLocalTablePagination()}
|
||||
dataSource={builtinSkuRows}
|
||||
columns={[
|
||||
{ title: '91 中文编码', dataIndex: 'productNo', key: 'productNo' },
|
||||
|
||||
@@ -64,17 +64,6 @@ export function applyAdminTemplateProductBootstrap(payload?: {
|
||||
)
|
||||
}
|
||||
|
||||
export function syncAdminWorkOrderTemplateCostVersions() {
|
||||
return apiPost<{
|
||||
ruleCount: number
|
||||
unitPriceRuleCount: number
|
||||
activeVersionCount: number
|
||||
createdCount: number
|
||||
retiredCount: number
|
||||
linkedSnapshotCount: number
|
||||
}>('/api/v1/admin/cost-center/work-order-template-costs/sync')
|
||||
}
|
||||
|
||||
export function backfillAdminWorkOrderProductSkus(payload?: {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
@@ -107,6 +96,32 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
|
||||
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
|
||||
}
|
||||
|
||||
export function archiveAdminHistoricalManualProducts(payload: { execute?: boolean } = {}) {
|
||||
return apiPost<{
|
||||
dryRun: boolean
|
||||
candidateCount: number
|
||||
archivedCount: number
|
||||
sample: Array<{ id: number; canonical_sku: string; canonical_name: string }>
|
||||
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
|
||||
}
|
||||
|
||||
export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) {
|
||||
return apiPost<{
|
||||
dryRun: boolean
|
||||
candidateCount: number
|
||||
archivedCount: number
|
||||
repointedChannelCount: number
|
||||
sample: Array<{
|
||||
id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
primary_channel: string
|
||||
target_product_id: number
|
||||
target_product_sku: string
|
||||
}>
|
||||
}>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload)
|
||||
}
|
||||
|
||||
export function syncAdminAffiliateDashProducts() {
|
||||
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
|
||||
'/api/v1/admin/cost-center/affiliate-products/sync',
|
||||
@@ -494,6 +509,7 @@ export function setAdminProductPrice(
|
||||
costVersionId: number
|
||||
amountFen: number
|
||||
effectiveFrom: string
|
||||
pricingMode?: 'fixed' | 'unit'
|
||||
syncedOrders?: {
|
||||
candidateCount: number
|
||||
updatedCount: number
|
||||
|
||||
@@ -24,15 +24,20 @@ export type PendingChannelGroupListResult = {
|
||||
/** 渠道规则(L1)。 */
|
||||
export type ChannelRule = {
|
||||
id: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
match_key_type: 'sku_code' | 'internal_sku' | 'sku_name'
|
||||
match_key_type: 'internal_sku'
|
||||
match_key: string
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
priority: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ChannelRuleListResult = {
|
||||
items: ChannelRule[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type ChannelException = {
|
||||
id: number
|
||||
reason: string
|
||||
@@ -100,7 +105,10 @@ export function assignChannelBySku(payload: {
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
|
||||
return apiPost<{ updated: number; carrierFailed: number; ruleCreated: boolean }>(
|
||||
'/api/v1/admin/fulfillment/assign',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function assignChannelToItems(payload: {
|
||||
@@ -108,17 +116,32 @@ export function assignChannelToItems(payload: {
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
|
||||
return apiPost<{ updated: number; carrierFailed: number }>(
|
||||
'/api/v1/admin/fulfillment/assign',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchChannelRules(params: { channel?: string } = {}) {
|
||||
return apiGet<ChannelRule[]>('/api/v1/admin/fulfillment/rules', params)
|
||||
export function switchChannelForItems(payload: {
|
||||
orderItemIds: number[]
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
switched: number
|
||||
failed: number
|
||||
errors: Array<{ orderItemId: number; reason: string }>
|
||||
}>('/api/v1/admin/fulfillment/switch', payload)
|
||||
}
|
||||
|
||||
export function fetchChannelRules(
|
||||
params: { channel?: string; page?: number; pageSize?: number } = {},
|
||||
) {
|
||||
return apiGet<ChannelRuleListResult>('/api/v1/admin/fulfillment/rules', params)
|
||||
}
|
||||
|
||||
export function saveChannelRule(payload: {
|
||||
id?: number
|
||||
provider?: string
|
||||
shopId?: string
|
||||
matchKeyType: string
|
||||
matchKey: string
|
||||
channel: string
|
||||
@@ -133,6 +156,14 @@ export function deleteChannelRule(ruleId: number) {
|
||||
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/fulfillment/rules/${ruleId}`)
|
||||
}
|
||||
|
||||
export function deleteChannelRules(ids: number[]) {
|
||||
return apiPost<{
|
||||
deletedCount: number
|
||||
deletedIds: number[]
|
||||
missingIds: number[]
|
||||
}>('/api/v1/admin/fulfillment/rules/batch-delete', { ids })
|
||||
}
|
||||
|
||||
export function fetchPipelineStatus() {
|
||||
return apiGet<FinancePipelineStatus[]>('/api/v1/admin/fulfillment/pipeline/status')
|
||||
}
|
||||
|
||||
@@ -291,8 +291,7 @@ export function saveAdminWorkProductRule(payload: {
|
||||
categoryId?: number
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
rewardAmount?: number
|
||||
unitPrice?: number
|
||||
pricingMode?: 'fixed' | 'unit'
|
||||
sharingEnabled?: boolean
|
||||
sharingAutoFromOrder?: boolean
|
||||
sharingTotalQuantity?: number
|
||||
@@ -306,7 +305,12 @@ export function saveAdminWorkProductRule(payload: {
|
||||
fieldsText?: string
|
||||
sortOrder?: number
|
||||
}) {
|
||||
return apiPost<{ rule: WorkProductRule }>('/api/v1/admin/worker-platform/product-rules', payload)
|
||||
return apiPost<{
|
||||
rule: WorkProductRule
|
||||
productId?: number
|
||||
productStatus?: 'active' | 'archived'
|
||||
costConfigurationRequired?: boolean
|
||||
}>('/api/v1/admin/worker-platform/product-rules', payload)
|
||||
}
|
||||
|
||||
export function syncAdminWorkProductRulePrice(ruleId: number) {
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface ProductCatalogItem {
|
||||
costVersionCount: number
|
||||
activeCostAmountFen?: number | null
|
||||
activeCostEffectiveFrom?: string | null
|
||||
templatePricingMode?: 'fixed' | 'unit' | null
|
||||
templateEnabled?: boolean | null
|
||||
templateSharingEnabled?: boolean | null
|
||||
activePrimaryChannel?: FulfillmentChannel | null
|
||||
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
|
||||
affiliateTaskCount: number
|
||||
affiliateLastFulfilledAt: string | null
|
||||
@@ -231,9 +235,6 @@ export interface TemplateProductBootstrapResult {
|
||||
resetOrderItemCount: number
|
||||
mappingConflictCount: number
|
||||
mappingConflictItemCount: number
|
||||
templateCostVersionCount: number
|
||||
templateCostCreatedCount: number
|
||||
templateCostRetiredCount: number
|
||||
}
|
||||
|
||||
export interface CostLine {
|
||||
|
||||
@@ -438,6 +438,7 @@ export type WorkProductRule = {
|
||||
autoCreate: boolean
|
||||
rewardAmount: number
|
||||
unitPriceFen: number
|
||||
pricingMode: 'fixed' | 'unit'
|
||||
sharing?: WorkOrderSharingConfig
|
||||
requirement: {
|
||||
fields: CollectField[]
|
||||
|
||||
@@ -87,22 +87,52 @@ export function describeMissingConfigFields(config: MissingConfigCheck): string[
|
||||
/**
|
||||
* 面板预览用的示例订单号(与真实 16 位平台订单号形态一致)。
|
||||
*/
|
||||
const CLAIM_LINK_PREVIEW_TOKEN = '2628301923197355'
|
||||
const CLAIM_LINK_PREVIEW_ORDER_NO = '2628301923197355'
|
||||
|
||||
/**
|
||||
* 拼一条领取链接预览:`claimLinkBase` 去尾斜杠 + '/' + token。
|
||||
* 规则与后端拼装一致(后端用配置里的 `claimLinkBase`)。
|
||||
* 基址为空返回空串(后端会回退到通用兑换链接)。
|
||||
* 拼一条领取链接预览:`<claimLinkBase>/<订单号>`。
|
||||
* 规则与后端一致,历史 `/#/claim`、`/claim` 基址也会归一化为 `/h5/订单号`。
|
||||
* 基址为空返回空串。
|
||||
*/
|
||||
export function buildDouyinMashidaClaimLinkPreview(
|
||||
claimLinkBase: string,
|
||||
token = CLAIM_LINK_PREVIEW_TOKEN,
|
||||
orderNo = CLAIM_LINK_PREVIEW_ORDER_NO,
|
||||
): string {
|
||||
const base = String(claimLinkBase ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!base) return ''
|
||||
return `${base}/${String(token ?? '').trim()}`
|
||||
const base = normalizeH5ClaimBase(claimLinkBase)
|
||||
const normalizedOrderNo = String(orderNo ?? '').trim()
|
||||
if (!base || !normalizedOrderNo) return ''
|
||||
return `${base}/${encodeURIComponent(normalizedOrderNo)}`
|
||||
}
|
||||
|
||||
/** 将历史 `/#/claim`、`/claim` 基址归一化为同站点 `/h5`,与后端生成口径一致。 */
|
||||
function normalizeH5ClaimBase(claimLinkBase: string): string {
|
||||
const raw = String(claimLinkBase ?? '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
const pathname = url.pathname.replace(/\/+$/, '')
|
||||
const h5Match = pathname.match(/\/h5(?:\/|$)/i)
|
||||
const claimMatch = pathname.match(/\/claim(?:\/|$)/i)
|
||||
|
||||
if (h5Match?.index !== undefined) {
|
||||
url.pathname = `${pathname.slice(0, h5Match.index)}/h5`
|
||||
} else if (claimMatch?.index !== undefined) {
|
||||
url.pathname = `${pathname.slice(0, claimMatch.index)}/h5`
|
||||
} else if (pathname && pathname !== '/') {
|
||||
url.pathname = `${pathname}/h5`
|
||||
} else {
|
||||
url.pathname = '/h5'
|
||||
}
|
||||
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user