Merge branch 'main' into dev_match
This commit is contained in:
@@ -183,9 +183,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
result.costPending
|
||||
? '履约已确认,已生成待对账的统一结算账单;实际成本待对账后入账'
|
||||
: result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
|
||||
@@ -484,7 +484,17 @@ function PendingPanel() {
|
||||
保存渠道
|
||||
</Button>
|
||||
{canRunScopedActions && hasConfigIssue ? (
|
||||
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!row.internal_sku}
|
||||
onClick={() => {
|
||||
const sku = String(row.internal_sku || '').trim()
|
||||
if (!sku) return
|
||||
void navigate(
|
||||
`/admin/cost-center?scope=manual&tab=cost&manualSku=${encodeURIComponent(sku)}&returnTo=${encodeURIComponent('/admin/fulfillment-channel')}`,
|
||||
)
|
||||
}}
|
||||
>
|
||||
去补配置({row.config_issue_item_count})
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
SearchOutlined,
|
||||
SendOutlined,
|
||||
SyncOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
fetchAdminKuaishouIndustryShops,
|
||||
fetchAdminKuaishouIndustryVouchers,
|
||||
listAdminKuaishouIndustryRefunds,
|
||||
reissueAndConsumeAdminKuaishouIndustryVoucher,
|
||||
resendAdminKuaishouIndustryVoucherCode,
|
||||
reverseAdminKuaishouIndustryVoucher,
|
||||
} from '@/services/admin'
|
||||
@@ -69,6 +73,7 @@ type IndustryActionKind =
|
||||
| 'destroy'
|
||||
| 'reverse'
|
||||
| 'resend'
|
||||
| 'reissue-consume'
|
||||
| 'refund-list'
|
||||
| 'refund-approve'
|
||||
| 'refund-disagree'
|
||||
@@ -120,6 +125,11 @@ export default function AdminKuaishouIndustryPage() {
|
||||
const [actionLoading, setActionLoading] = useState('')
|
||||
const [consumingVoucherId, setConsumingVoucherId] = useState<number | null>(null)
|
||||
const [reversingVoucherId, setReversingVoucherId] = useState<number | null>(null)
|
||||
const [reissueConsumeOpen, setReissueConsumeOpen] = useState(false)
|
||||
const [reissueConsumeMinutes, setReissueConsumeMinutes] = useState<number>(10)
|
||||
const [reissueConsumeReason, setReissueConsumeReason] =
|
||||
useState('历史发码失败补偿,补发后立即核销')
|
||||
const [reissueConsumeConfirmed, setReissueConsumeConfirmed] = useState(false)
|
||||
const [vouchers, setVouchers] = useState<AdminKuaishouIndustryVoucher[]>([])
|
||||
const [selectedVoucher, setSelectedVoucher] = useState<AdminKuaishouIndustryVoucher | null>(null)
|
||||
const [voucherTotal, setVoucherTotal] = useState(0)
|
||||
@@ -646,6 +656,64 @@ export default function AdminKuaishouIndustryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReissueConsumeModal() {
|
||||
if (!selectedVoucher && !String(toolForm.voucherCode || '').trim()) {
|
||||
showError('请先选择或填写本地券码')
|
||||
return
|
||||
}
|
||||
if (selectedVoucher) {
|
||||
const sendSucceeded = selectedVoucher.sendCallbackStatus === 'success'
|
||||
const stillValid = Number(selectedVoucher.validEndTime || 0) > Date.now()
|
||||
if (selectedVoucher.status !== 'UNUSED') {
|
||||
showError('仅未使用电子凭证可以补发并核销')
|
||||
return
|
||||
}
|
||||
if (sendSucceeded && stillValid) {
|
||||
showError('该券已发码成功且仍在有效期,请直接执行手动核销')
|
||||
return
|
||||
}
|
||||
}
|
||||
setReissueConsumeConfirmed(false)
|
||||
setReissueConsumeOpen(true)
|
||||
}
|
||||
|
||||
async function runReissueConsume() {
|
||||
if (!reissueConsumeConfirmed) {
|
||||
showError('请先确认已核实订单仍需履约')
|
||||
return
|
||||
}
|
||||
|
||||
setActionLoading('reissue-consume')
|
||||
try {
|
||||
const response = await reissueAndConsumeAdminKuaishouIndustryVoucher({
|
||||
...buildVoucherToolPayload(),
|
||||
validMinutes: reissueConsumeMinutes,
|
||||
reason: reissueConsumeReason,
|
||||
})
|
||||
publishActionResult(
|
||||
buildIndustryActionResultView('reissue-consume', '历史券补发并核销', response.data),
|
||||
)
|
||||
if (response.data.success) {
|
||||
showSuccess('原券码已同步到快手并完成核销')
|
||||
setReissueConsumeOpen(false)
|
||||
} else {
|
||||
showError(response.data.error || '历史券补发并核销失败')
|
||||
}
|
||||
await loadVouchers()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '历史券补发并核销失败'
|
||||
publishActionResult(
|
||||
buildIndustryActionResultView('reissue-consume', '历史券补发并核销', {
|
||||
success: false,
|
||||
error: message,
|
||||
}),
|
||||
)
|
||||
showError(message)
|
||||
} finally {
|
||||
setActionLoading('')
|
||||
}
|
||||
}
|
||||
|
||||
async function runDestroy() {
|
||||
setActionLoading('destroy')
|
||||
try {
|
||||
@@ -870,6 +938,98 @@ export default function AdminKuaishouIndustryPage() {
|
||||
onChange={(key) => setActiveTab(key as IndustryTab)}
|
||||
items={tabItems}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="历史券补发并核销"
|
||||
open={reissueConsumeOpen}
|
||||
okText="补发并核销"
|
||||
cancelText="取消"
|
||||
confirmLoading={actionLoading === 'reissue-consume'}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
!reissueConsumeConfirmed ||
|
||||
!String(reissueConsumeReason || '').trim() ||
|
||||
reissueConsumeMinutes < 1 ||
|
||||
reissueConsumeMinutes > 60,
|
||||
}}
|
||||
onOk={runReissueConsume}
|
||||
onCancel={() => setReissueConsumeOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该操作会重发原券码并立即核销"
|
||||
description="仅用于已确认订单仍需履约、但历史发码失败或凭证已过期的补偿场景。操作会先临时延长本地券有效期,发码成功后立即核销。"
|
||||
/>
|
||||
|
||||
<Descriptions
|
||||
size="small"
|
||||
bordered
|
||||
column={1}
|
||||
items={[
|
||||
{
|
||||
key: 'voucherCode',
|
||||
label: '本地券码',
|
||||
children:
|
||||
selectedVoucher?.voucherCode || String(toolForm.voucherCode || '').trim() || '-',
|
||||
},
|
||||
{
|
||||
key: 'oid',
|
||||
label: '平台订单',
|
||||
children:
|
||||
selectedVoucher?.oid ||
|
||||
String(toolForm.oid || toolForm.orderId || '').trim() ||
|
||||
'-',
|
||||
},
|
||||
{
|
||||
key: 'validEndTime',
|
||||
label: '原有效期至',
|
||||
children: selectedVoucher
|
||||
? formatAdminDateTime(
|
||||
new Date(Number(selectedVoucher.validEndTime || 0)).toISOString(),
|
||||
)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
key: 'sendError',
|
||||
label: '最近发码错误',
|
||||
children: selectedVoucher?.sendCallbackLastError || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<label className="kuaishou-industry-tool-field">
|
||||
<span>临时有效期(分钟)</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60}
|
||||
precision={0}
|
||||
value={reissueConsumeMinutes}
|
||||
onChange={(value) => setReissueConsumeMinutes(Number(value || 10))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="kuaishou-industry-tool-field">
|
||||
<span>补发原因</span>
|
||||
<Input
|
||||
maxLength={200}
|
||||
showCount
|
||||
value={reissueConsumeReason}
|
||||
onChange={(event) => setReissueConsumeReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Checkbox
|
||||
checked={reissueConsumeConfirmed}
|
||||
onChange={(event) => setReissueConsumeConfirmed(event.target.checked)}
|
||||
>
|
||||
我已核实该订单仍需履约,并确认补发后立即核销
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1210,6 +1370,15 @@ export default function AdminKuaishouIndustryPage() {
|
||||
>
|
||||
手动核销
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={actionLoading === 'reissue-consume'}
|
||||
disabled={!hasVoucherTarget}
|
||||
onClick={openReissueConsumeModal}
|
||||
>
|
||||
历史补发并核销
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<RollbackOutlined />}
|
||||
@@ -1677,6 +1846,7 @@ function buildIndustryActionResultView(
|
||||
const responseData = asRecord(response?.data)
|
||||
const success = resolveActionSuccess(kind, record, openApi)
|
||||
const platformMessage = pickFirstString([
|
||||
...(kind === 'reissue-consume' ? [record.error] : []),
|
||||
response?.error_msg,
|
||||
response?.msg,
|
||||
response?.sub_msg,
|
||||
@@ -1788,6 +1958,24 @@ function buildIndustryActionResultView(
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'reissue-consume') {
|
||||
pushHighlight(highlights, '执行阶段', String(record.stage || '-'))
|
||||
if (record.validMinutes) {
|
||||
pushHighlight(highlights, '临时有效期', `${String(record.validMinutes)} 分钟`)
|
||||
}
|
||||
for (const [label, value] of [
|
||||
['发码成功', record.sendSuccess],
|
||||
['核销成功', record.consumeSuccess],
|
||||
] as const) {
|
||||
if (typeof value === 'boolean') {
|
||||
pushHighlight(highlights, label, value ? '是' : '否')
|
||||
}
|
||||
}
|
||||
if (record.reason) {
|
||||
pushHighlight(highlights, '补发原因', String(record.reason))
|
||||
}
|
||||
}
|
||||
|
||||
if (platformMessage) {
|
||||
pushHighlight(highlights, '平台消息', platformMessage)
|
||||
}
|
||||
@@ -1855,6 +2043,12 @@ function buildActionSummary(
|
||||
: context.platformMessage || '重发发码失败。'
|
||||
}
|
||||
|
||||
if (kind === 'reissue-consume') {
|
||||
return success
|
||||
? '原券码已同步到快手并完成核销。'
|
||||
: context.platformMessage || '历史券补发并核销失败。'
|
||||
}
|
||||
|
||||
if (kind === 'refund-list') {
|
||||
return success
|
||||
? `查询成功,共 ${context.refundCount ?? 0} 条售后单,已填充列表。`
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import { usePersistedState } from '@/hooks/usePersistedState'
|
||||
@@ -73,7 +74,11 @@ function isCostScope(value: unknown): value is CostScope {
|
||||
|
||||
export default function CostCenterShell() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const canConfig = hasAdminPermission('finance.config')
|
||||
const requestedScope = String(searchParams.get('scope') || '').trim()
|
||||
const requestedTab = String(searchParams.get('tab') || '').trim()
|
||||
const handledRouteRequestRef = useRef('')
|
||||
|
||||
const [scope, setScope] = usePersistedState<CostScope>(
|
||||
'admin-cost-center:scope',
|
||||
@@ -87,6 +92,20 @@ export default function CostCenterShell() {
|
||||
)
|
||||
const mappingsApplicable = scope === 'all' || scope === 'work_order'
|
||||
|
||||
useEffect(() => {
|
||||
const routeRequest = `${requestedScope}\u0000${requestedTab}`
|
||||
if (routeRequest === '\u0000' || handledRouteRequestRef.current === routeRequest) return
|
||||
handledRouteRequestRef.current = routeRequest
|
||||
if (isCostScope(requestedScope) && requestedScope !== scope) {
|
||||
setScope(requestedScope)
|
||||
}
|
||||
const requestedTabAllowed =
|
||||
isCostCenterTab(requestedTab) && (requestedScope !== 'manual' || requestedTab !== 'mappings')
|
||||
if (requestedTabAllowed && requestedTab !== activeTab) {
|
||||
setActiveTab(requestedTab)
|
||||
}
|
||||
}, [activeTab, requestedScope, requestedTab, scope, setActiveTab, setScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mappingsApplicable && activeTab === 'mappings') {
|
||||
setActiveTab('products')
|
||||
|
||||
@@ -60,6 +60,7 @@ const REASON_LABELS: Record<string, string> = {
|
||||
cost_missing: '成本缺失',
|
||||
manual_config_missing: '人工 SKU 未配置',
|
||||
manual_cost_missing: '人工成本未记录',
|
||||
on_account_cost_pending: '统一结算待对账',
|
||||
carrier_missing: '缺少履约载体',
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ import {
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import {
|
||||
deleteAdminManualSku,
|
||||
@@ -55,7 +56,10 @@ import {
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '@/utils/credit-unit-price'
|
||||
|
||||
import { useCostCenter } from './CostCenterContext'
|
||||
|
||||
type ConfigFormValues = {
|
||||
skuKey: string
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
@@ -87,8 +91,15 @@ function platformOptionLabel(platformKey: string, label: string, creditUnitPrice
|
||||
|
||||
export function ManualSkuConfigPanel() {
|
||||
const { message } = App.useApp()
|
||||
const { canConfig } = useCostCenter()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [form] = Form.useForm<ConfigFormValues>()
|
||||
const handledDeepLinkRef = useRef('')
|
||||
const deepLinkSku = String(searchParams.get('manualSku') || searchParams.get('sku') || '').trim()
|
||||
const manualNewRequested = searchParams.get('manualNew') === '1'
|
||||
const returnTo = String(searchParams.get('returnTo') || '').trim()
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [status, setStatus] = useState<'all' | 'configured' | 'unconfigured'>('unconfigured')
|
||||
@@ -125,7 +136,7 @@ export function ManualSkuConfigPanel() {
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: ConfigFormValues) =>
|
||||
saveAdminManualConfig({
|
||||
skuKey: editing?.skuKey || '',
|
||||
skuKey: String(values.skuKey || editing?.skuKey || '').trim(),
|
||||
channels: values.channels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: Number(channel.creditQuantity || 0),
|
||||
@@ -139,10 +150,14 @@ export function ManualSkuConfigPanel() {
|
||||
: ''
|
||||
message.success(`配置已保存${syncHint}`)
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
if (returnTo.startsWith('/admin/fulfillment-channel')) {
|
||||
void navigate(returnTo)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
@@ -180,6 +195,7 @@ export function ManualSkuConfigPanel() {
|
||||
]
|
||||
: [{ platformKey: '', creditQuantity: 0 }]
|
||||
form.setFieldsValue({
|
||||
skuKey: item.skuKey,
|
||||
channels: configuredChannels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: channel.creditQuantity,
|
||||
@@ -189,6 +205,36 @@ export function ManualSkuConfigPanel() {
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function openCreateConfig(skuKey = '') {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
skuKey: skuKey.trim(),
|
||||
channels: [{ platformKey: '', creditQuantity: 0 }],
|
||||
note: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfig) return
|
||||
const deepLinkKey = deepLinkSku || (manualNewRequested ? '__new__' : '')
|
||||
if (!deepLinkKey || handledDeepLinkRef.current === deepLinkKey) return
|
||||
handledDeepLinkRef.current = deepLinkKey
|
||||
if (deepLinkSku) {
|
||||
setKeyword(deepLinkSku)
|
||||
setStatus('all')
|
||||
}
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
skuKey: deepLinkSku,
|
||||
channels: [{ platformKey: '', creditQuantity: 0 }],
|
||||
note: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}, [canConfig, deepLinkSku, manualNewRequested, form])
|
||||
|
||||
const columns: TableColumnsType<ManualSkuItem> = [
|
||||
{
|
||||
title: '人工标准商品',
|
||||
@@ -276,24 +322,27 @@ export function ManualSkuConfigPanel() {
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
||||
{row.configured ? '编辑' : '配置'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="归档该人工标准商品?"
|
||||
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
|
||||
okText="确认归档"
|
||||
cancelText="取消"
|
||||
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
归档
|
||||
render: (_, row) =>
|
||||
canConfig ? (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
||||
{row.configured ? '编辑' : '配置'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
<Popconfirm
|
||||
title="归档该人工标准商品?"
|
||||
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
|
||||
okText="确认归档"
|
||||
cancelText="取消"
|
||||
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">只读</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -303,13 +352,23 @@ export function ManualSkuConfigPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="人工 SKU 配置">
|
||||
<Card
|
||||
bordered={false}
|
||||
title="人工 SKU 配置"
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreateConfig()}>
|
||||
新增人工 SKU
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="人工标准商品与成本配置一一对应"
|
||||
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
|
||||
message="人工标准商品由人工配置自动创建"
|
||||
description="新增时只需填写内部 SKU、实际人工渠道和每单成本,系统会自动创建或复用 man: 标准商品。保存后同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
|
||||
/>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
@@ -399,14 +458,17 @@ export function ManualSkuConfigPanel() {
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={`配置人工履约 · ${editing?.skuKey || ''}`}
|
||||
title={editing ? `配置人工履约 · ${editing.skuKey}` : '新增人工履约 SKU'}
|
||||
okText="保存配置"
|
||||
cancelText="取消"
|
||||
confirmLoading={saveMutation.isPending}
|
||||
onOk={() => {
|
||||
void form.validateFields().then((values) => saveMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onCancel={() => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
@@ -414,10 +476,37 @@ export function ManualSkuConfigPanel() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
message={
|
||||
editing
|
||||
? `商品名称:${editing.displayName || '(无)'}(仅供识别)`
|
||||
: '保存时自动创建标准商品,不需要先去商品目录手工新建'
|
||||
}
|
||||
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="skuKey"
|
||||
label="内部 SKU"
|
||||
rules={[
|
||||
{ required: true, message: '请输入内部 SKU' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (text && !/\p{Script=Han}/u.test(text)) {
|
||||
throw new Error('内部 SKU 必须包含中文')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra="保存后会生成 man:<内部 SKU> 标准商品,并统一用于订单渠道判定。"
|
||||
>
|
||||
<Input
|
||||
disabled={Boolean(editing)}
|
||||
placeholder="例如:破片手榴弹-兔兔白日梦"
|
||||
maxLength={60}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.List
|
||||
name="channels"
|
||||
rules={[
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
|
||||
*/
|
||||
import {
|
||||
ClearOutlined,
|
||||
DeleteOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
@@ -37,13 +36,12 @@ import PercentTable from '@/components/common/PercentTable'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
applyAdminTemplateProductBootstrap,
|
||||
archiveAdminHistoricalManualProducts,
|
||||
archiveAdminSupersededManualProducts,
|
||||
createAdminProductCatalog,
|
||||
deleteAdminProductCatalog,
|
||||
fetchAdminProductCatalog,
|
||||
@@ -95,6 +93,7 @@ function DisabledTemplateSyncOption({
|
||||
export function ProductCatalogPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
|
||||
const [productForm] = Form.useForm<ProductFormValues>()
|
||||
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
|
||||
@@ -109,8 +108,6 @@ 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)
|
||||
@@ -452,76 +449,6 @@ 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'
|
||||
@@ -777,24 +704,6 @@ 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
|
||||
@@ -818,13 +727,46 @@ export function ProductCatalogPanel() {
|
||||
loading={productsQuery.isFetching}
|
||||
onClick={() => void productsQuery.refetch()}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
|
||||
新建商品
|
||||
</Button>
|
||||
{productSourceFilter === 'manual' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
void navigate('/admin/cost-center?scope=manual&tab=cost&manualNew=1')
|
||||
}
|
||||
>
|
||||
新增人工 SKU
|
||||
</Button>
|
||||
) : productSourceFilter === 'third_party_voucher' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
void navigate(
|
||||
'/admin/cost-center?scope=third_party_voucher&tab=cost&voucherNew=1',
|
||||
)
|
||||
}
|
||||
>
|
||||
新增卡券 SKU
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
|
||||
新建商品
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{productSourceFilter === 'manual' ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="人工标准商品由人工 SKU 配置自动创建"
|
||||
description="请到“成本配置 → 人工 SKU 配置”新增内部 SKU 并填写人工渠道和成本;保存时会自动创建或复用 man: 商品。"
|
||||
/>
|
||||
) : null}
|
||||
{showCategoryBar ? (
|
||||
<>
|
||||
<div className="product-rules-category-tabs-bar">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 商品编码固定为 `tpv:<sku>`;成本写入 `sys_third_party_voucher_cost` 的
|
||||
* 商品级版本,由成本快照管道消费。
|
||||
*/
|
||||
import { DeleteOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -19,13 +19,14 @@ import {
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import {
|
||||
deleteAdminVoucherProduct,
|
||||
ensureVoucherChannelRules,
|
||||
fetchAdminProductCatalog,
|
||||
saveAdminVoucherProduct,
|
||||
setAdminProductPrice,
|
||||
} from '@/services/admin'
|
||||
import type { ProductCatalogItem } from '@/types/admin'
|
||||
@@ -35,12 +36,20 @@ import { useCostCenter } from './CostCenterContext'
|
||||
|
||||
export default function VoucherSkuPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { canConfig, refreshAll } = useCostCenter()
|
||||
const handledCreateLinkRef = useRef(false)
|
||||
const voucherNewRequested = searchParams.get('voucherNew') === '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 [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>()
|
||||
const [createForm] = Form.useForm<{
|
||||
skuKey: string
|
||||
amountYuan: number
|
||||
note?: string
|
||||
}>()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['cost-center-voucher-products', page, pageSize],
|
||||
@@ -68,6 +77,25 @@ export default function VoucherSkuPanel() {
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: { skuKey: string; amountYuan: number; note?: string }) =>
|
||||
saveAdminVoucherProduct({
|
||||
skuKey: values.skuKey.trim(),
|
||||
amountFen: Math.round(values.amountYuan * 100),
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
message.success(
|
||||
`卡券 SKU 已保存并回填 ${response.data.linkedItemCount} 个订单项,补建成本快照 ${response.data.snapshotCreatedCount} 条`,
|
||||
)
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
|
||||
refreshAll()
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '保存卡券 SKU 失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (productId: number) => deleteAdminVoucherProduct(productId),
|
||||
onSuccess: (response) => {
|
||||
@@ -82,23 +110,15 @@ export default function VoucherSkuPanel() {
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'),
|
||||
})
|
||||
|
||||
async function handleEnsureRules() {
|
||||
setEnsuringRules(true)
|
||||
try {
|
||||
const result = await ensureVoucherChannelRules()
|
||||
message.success(
|
||||
`卡券商品 ${result.data.productCount} 个,已补齐渠道规则 ${result.data.created} 条`,
|
||||
)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '补齐卡券渠道规则失败')
|
||||
} finally {
|
||||
setEnsuringRules(false)
|
||||
}
|
||||
}
|
||||
|
||||
const items = query.data?.data.items || []
|
||||
const total = query.data?.data.pagination.total || 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfig || !voucherNewRequested || handledCreateLinkRef.current) return
|
||||
handledCreateLinkRef.current = true
|
||||
setCreateOpen(true)
|
||||
}, [canConfig, voucherNewRequested])
|
||||
|
||||
const columns = [
|
||||
{ title: '商品', dataIndex: 'canonicalName', ellipsis: true },
|
||||
{ title: '编码', dataIndex: 'canonicalSku', width: 220 },
|
||||
@@ -169,14 +189,11 @@ export default function VoucherSkuPanel() {
|
||||
title={`卡券商品采购价(${total})`}
|
||||
extra={
|
||||
<Space size={8}>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={ensuringRules}
|
||||
disabled={!canConfig}
|
||||
onClick={() => void handleEnsureRules()}
|
||||
>
|
||||
批量生成渠道规则
|
||||
</Button>
|
||||
{canConfig ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
新增卡券 SKU
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -184,8 +201,8 @@ export default function VoucherSkuPanel() {
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
卡券渠道只统计:命中规则后不创建任务、不发领取页,只记收入与采购成本。 商品编码以{' '}
|
||||
<Typography.Text code>tpv:</Typography.Text> 开头。
|
||||
新增时只需填写内部 SKU 和采购价,系统会自动创建 <Typography.Text code>tpv:</Typography.Text>{' '}
|
||||
商品、卡券渠道规则并回填已有订单。卡券不创建任务、不发领取页,只记收入与采购成本。
|
||||
</Typography.Paragraph>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
@@ -204,6 +221,50 @@ export default function VoucherSkuPanel() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新增第三方卡券 SKU"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="skuKey"
|
||||
label="内部 SKU"
|
||||
rules={[
|
||||
{ required: true, message: '请输入内部 SKU' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (text && !/\p{Script=Han}/u.test(text)) {
|
||||
throw new Error('内部 SKU 必须包含中文')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra="保存后自动生成 tpv:<内部 SKU> 标准商品和卡券渠道规则。"
|
||||
>
|
||||
<Input placeholder="例如:11月改名卡" maxLength={60} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="采购价(元)"
|
||||
rules={[{ required: true, message: '请输入采购价' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`设置采购价:${selected?.canonicalName || ''}`}
|
||||
open={Boolean(selected)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -34,18 +34,22 @@ import {
|
||||
createAdminFundExpense,
|
||||
createAdminFundReturn,
|
||||
fetchAdminFundMyAccount,
|
||||
fetchAdminOnAccountBills,
|
||||
fetchAdminPlatformCreditAccounts,
|
||||
fetchAdminPlatformCreditEntries,
|
||||
reconcileAdminOnAccountBill,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFundEntry,
|
||||
AdminFundExpense,
|
||||
AdminFundExpenseCategory,
|
||||
AdminFundPayeeType,
|
||||
OnAccountBill,
|
||||
PlatformCreditEntry,
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
@@ -79,14 +83,23 @@ type ReturnFormValues = {
|
||||
note?: string
|
||||
}
|
||||
|
||||
type OnAccountReconcileFormValues = {
|
||||
amountYuan: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export default function FundSupportView() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [expenseOpen, setExpenseOpen] = useState(false)
|
||||
const [returnOpen, setReturnOpen] = useState(false)
|
||||
const [reconcileBill, setReconcileBill] = useState<OnAccountBill | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [expenseForm] = Form.useForm<ExpenseFormValues>()
|
||||
const [returnForm] = Form.useForm<ReturnFormValues>()
|
||||
const [reconcileForm] = Form.useForm<OnAccountReconcileFormValues>()
|
||||
const canReconcileOnAccount = hasAdminPermission('finance.reconcile')
|
||||
|
||||
const myQuery = useQuery({
|
||||
queryKey: ['admin-fund-my'],
|
||||
@@ -110,9 +123,35 @@ export default function FundSupportView() {
|
||||
refetchOnMount: 'always',
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const onAccountBillsQuery = useQuery({
|
||||
queryKey: ['admin-on-account-bills', 'pending'],
|
||||
queryFn: () => fetchAdminOnAccountBills({ status: 'pending', pageSize: 100 }),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const creditAccounts = creditAccountsQuery.data?.data.items || []
|
||||
const creditEntries = creditEntriesQuery.data?.data.items || []
|
||||
const creditPlatforms = creditAccountsQuery.data?.data.platforms || []
|
||||
const onAccountBills = onAccountBillsQuery.data?.data.items || []
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: (values: OnAccountReconcileFormValues) =>
|
||||
reconcileAdminOnAccountBill(reconcileBill!.id, {
|
||||
amountFen: toFen(values.amountYuan),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success('统一结算账单已对账,实际成本已入账')
|
||||
setReconcileBill(null)
|
||||
reconcileForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '统一结算对账失败'),
|
||||
})
|
||||
|
||||
/** 客服自己的三个积分钱包,按平台顺序排好。 */
|
||||
const myCreditWallets = creditPlatforms.map((platform) => {
|
||||
@@ -132,6 +171,7 @@ export default function FundSupportView() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
|
||||
}
|
||||
|
||||
function openExpense() {
|
||||
@@ -155,6 +195,15 @@ export default function FundSupportView() {
|
||||
setReturnOpen(true)
|
||||
}
|
||||
|
||||
function openReconcileBill(bill: OnAccountBill) {
|
||||
setReconcileBill(bill)
|
||||
reconcileForm.setFieldsValue({
|
||||
amountYuan: bill.amountFen > 0 ? bill.amountFen / 100 : undefined,
|
||||
payeeName: bill.payeeName || '',
|
||||
note: bill.note || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function submitExpense(values: ExpenseFormValues) {
|
||||
if (!account) return
|
||||
setSubmitting(true)
|
||||
@@ -384,6 +433,56 @@ export default function FundSupportView() {
|
||||
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
|
||||
]
|
||||
|
||||
const onAccountBillColumns: TableColumnsType<OnAccountBill> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{
|
||||
title: '订单 / SKU',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text copyable={{ text: row.platformOrderId }}>
|
||||
{row.platformOrderId || `#${row.orderId}`}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.skuName || row.skuKey}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单收入',
|
||||
dataIndex: 'revenueFen',
|
||||
width: 120,
|
||||
render: (value: number) => formatFundAmount(value),
|
||||
},
|
||||
{
|
||||
title: '待结算金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 130,
|
||||
render: (value: number) => (value > 0 ? formatFundAmount(value) : '待填写'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!canReconcileOnAccount}
|
||||
onClick={() => openReconcileBill(row)}
|
||||
>
|
||||
对账
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
@@ -475,6 +574,38 @@ export default function FundSupportView() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={`统一结算待对账(${onAccountBillsQuery.data?.data.pagination.total || 0})`}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={onAccountBillsQuery.isFetching}
|
||||
onClick={() => void onAccountBillsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="统一结算金额未确认前不会生成 actual 成本快照"
|
||||
description="完成对账后,系统按实际金额生成成本快照并重算销售利润。"
|
||||
/>
|
||||
<PercentTable<OnAccountBill>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={onAccountBillColumns}
|
||||
dataSource={onAccountBills}
|
||||
loading={onAccountBillsQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 980 }}
|
||||
locale={{ emptyText: '没有待对账的统一结算账单' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{account ? (
|
||||
<Card>
|
||||
<Descriptions column={2} size="small">
|
||||
@@ -699,6 +830,43 @@ export default function FundSupportView() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`统一结算对账${reconcileBill ? ` · ${reconcileBill.billNo}` : ''}`}
|
||||
open={Boolean(reconcileBill)}
|
||||
onCancel={() => setReconcileBill(null)}
|
||||
onOk={() => reconcileForm.submit()}
|
||||
confirmLoading={reconcileMutation.isPending}
|
||||
okText="确认对账"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="确认后将按本次金额生成 actual 成本快照"
|
||||
description="订单收入不会改变;如果此前已有固定成本快照,会用本次实际结算金额替代。"
|
||||
/>
|
||||
<Form<OnAccountReconcileFormValues>
|
||||
form={reconcileForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => reconcileMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="实际结算金额(元)"
|
||||
rules={[{ required: true, message: '请输入实际结算金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payeeName" label="实际收款方">
|
||||
<Input maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="对账备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -315,6 +315,31 @@ export default function VoucherConsumePolicyPanel() {
|
||||
width: 170,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '核销码',
|
||||
dataIndex: 'voucher_codes',
|
||||
width: 210,
|
||||
render: (values: string[] | null | undefined) => {
|
||||
const codes = Array.isArray(values)
|
||||
? values.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (codes.length === 0) {
|
||||
return '-'
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
{codes.slice(0, 3).map((code) => (
|
||||
<Typography.Text key={code} copyable={{ text: code }} ellipsis>
|
||||
{code}
|
||||
</Typography.Text>
|
||||
))}
|
||||
{codes.length > 3 ? (
|
||||
<Typography.Text type="secondary">另有 {codes.length - 3} 个</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '计划时间',
|
||||
dataIndex: 'due_at',
|
||||
@@ -415,7 +440,7 @@ export default function VoucherConsumePolicyPanel() {
|
||||
<Input.Search
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="任务号 / 订单号 / 平台单号"
|
||||
placeholder="任务号 / 订单号 / 平台单号 / 核销码"
|
||||
value={keywordDraft}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
@@ -492,7 +517,7 @@ export default function VoucherConsumePolicyPanel() {
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1420 }}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
ManualSkuListResult,
|
||||
ManualSkuOverview,
|
||||
ManualSkuScanResult,
|
||||
OnAccountBill,
|
||||
OnAccountBillListResult,
|
||||
ProductCatalogItem,
|
||||
ProductCatalogListResult,
|
||||
TemplateProductBootstrapPreview,
|
||||
@@ -96,32 +98,6 @@ 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',
|
||||
@@ -551,10 +527,21 @@ export function syncAdminProductPrice(productId: number) {
|
||||
}>(`/api/v1/admin/cost-center/products/${productId}/sync-price`, {})
|
||||
}
|
||||
|
||||
export function ensureVoucherChannelRules() {
|
||||
return apiPost<{ created: number; productCount: number }>(
|
||||
'/api/v1/admin/cost-center/voucher-products/ensure-rules',
|
||||
)
|
||||
export function saveAdminVoucherProduct(payload: {
|
||||
skuKey: string
|
||||
amountFen: number
|
||||
effectiveFrom?: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
product: ProductCatalogItem
|
||||
costVersionId: number
|
||||
amountFen: number
|
||||
effectiveFrom: string
|
||||
linkedItemCount: number
|
||||
snapshotCreatedCount: number
|
||||
refreshedDays: number
|
||||
}>('/api/v1/admin/cost-center/voucher-products', payload)
|
||||
}
|
||||
|
||||
export function deleteAdminVoucherProduct(productId: number) {
|
||||
@@ -570,3 +557,26 @@ export function deleteAdminVoucherProduct(productId: number) {
|
||||
}
|
||||
}>(`/api/v1/admin/cost-center/voucher-products/${productId}`)
|
||||
}
|
||||
|
||||
export function fetchAdminOnAccountBills(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<OnAccountBillListResult>('/api/v1/admin/finance/on-account-bills', params)
|
||||
}
|
||||
|
||||
export function reconcileAdminOnAccountBill(
|
||||
billId: number,
|
||||
payload: { amountFen: number; payeeName?: string; note?: string },
|
||||
) {
|
||||
return apiPost<{
|
||||
bill: OnAccountBill
|
||||
orderItemId: number
|
||||
snapshotId: number
|
||||
amountFen: number
|
||||
}>(`/api/v1/admin/finance/on-account-bills/${billId}/reconcile`, payload)
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ export type VoucherConsumeJob = {
|
||||
attempt_count: number
|
||||
max_attempts: number
|
||||
last_error: string
|
||||
voucher_codes: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
finished_at: string | null
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
AdminKuaishouIndustryVoucherConsumeResult,
|
||||
AdminKuaishouIndustryVoucherDestroyResult,
|
||||
AdminKuaishouIndustryVoucherListResult,
|
||||
AdminKuaishouIndustryVoucherReissueConsumePayload,
|
||||
AdminKuaishouIndustryVoucherReissueConsumeResult,
|
||||
AdminKuaishouIndustryVoucherResendResult,
|
||||
AdminKuaishouIndustryVoucherToolPayload,
|
||||
} from '@/types/admin'
|
||||
@@ -84,6 +86,15 @@ export function resendAdminKuaishouIndustryVoucherCode(
|
||||
)
|
||||
}
|
||||
|
||||
export function reissueAndConsumeAdminKuaishouIndustryVoucher(
|
||||
payload: AdminKuaishouIndustryVoucherReissueConsumePayload,
|
||||
) {
|
||||
return apiPost<AdminKuaishouIndustryVoucherReissueConsumeResult>(
|
||||
'/api/v1/admin/kuaishou-industry/vouchers/reissue-consume',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function destroyAdminKuaishouIndustryVoucher(
|
||||
payload: AdminKuaishouIndustryVoucherToolPayload,
|
||||
) {
|
||||
|
||||
@@ -564,7 +564,7 @@ export interface ManualFulfillmentPendingListResult {
|
||||
export interface ManualFulfillmentConfirmResult {
|
||||
orderItemId: number
|
||||
orderId: number
|
||||
costSnapshotId: number
|
||||
costSnapshotId: number | null
|
||||
platformKey: string
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
@@ -573,4 +573,33 @@ export interface ManualFulfillmentConfirmResult {
|
||||
creditEntryId: number | null
|
||||
balanceAfter: number | null
|
||||
onAccountBillId: number | null
|
||||
costPending: boolean
|
||||
}
|
||||
|
||||
export interface OnAccountBill {
|
||||
id: number
|
||||
billNo: string
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
skuKey: string
|
||||
skuName: string
|
||||
canonicalProductId: number | null
|
||||
platformOrderId: string
|
||||
provider: string
|
||||
shopId: string
|
||||
revenueFen: number
|
||||
amountFen: number
|
||||
status: 'pending' | 'reconciled' | 'void'
|
||||
payeeName: string
|
||||
note: string
|
||||
metadata: Record<string, unknown>
|
||||
createdByAdminUserId: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
reconciledAt: string | null
|
||||
}
|
||||
|
||||
export interface OnAccountBillListResult {
|
||||
items: OnAccountBill[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ export type {
|
||||
AdminKuaishouIndustryShopListResult,
|
||||
AdminKuaishouIndustryVoucherConsumeResult,
|
||||
AdminKuaishouIndustryVoucherDestroyResult,
|
||||
AdminKuaishouIndustryVoucherReissueConsumeResult,
|
||||
AdminKuaishouIndustryVoucherReissueConsumePayload,
|
||||
AdminKuaishouIndustryVoucherResendResult,
|
||||
AdminKuaishouIndustryRefundListPayload,
|
||||
AdminKuaishouIndustryRefundApprovePayload,
|
||||
|
||||
@@ -94,6 +94,26 @@ export interface AdminKuaishouIndustryVoucherResendResult {
|
||||
voucher: AdminKuaishouIndustryVoucher | null
|
||||
}
|
||||
|
||||
export interface AdminKuaishouIndustryVoucherReissueConsumeResult {
|
||||
success: boolean
|
||||
stage: 'send' | 'consume' | 'completed' | string
|
||||
reason: string
|
||||
validMinutes: number
|
||||
originalValidEndTime: number
|
||||
validEndTime: number
|
||||
sendSuccess: boolean
|
||||
consumeSuccess: boolean
|
||||
response: Record<string, unknown> | null
|
||||
error: string
|
||||
voucher: AdminKuaishouIndustryVoucher | null
|
||||
task: null | {
|
||||
taskId: number
|
||||
taskNo: string
|
||||
status: string
|
||||
deliveryStatus: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminKuaishouIndustryRefundListPayload {
|
||||
sellerId?: string
|
||||
beginTime?: number | string
|
||||
@@ -149,3 +169,8 @@ export interface AdminKuaishouIndustryVoucherToolPayload {
|
||||
expressNo?: string
|
||||
etickets?: Array<{ id?: string; code?: string; num?: number | string }>
|
||||
}
|
||||
|
||||
export interface AdminKuaishouIndustryVoucherReissueConsumePayload extends AdminKuaishouIndustryVoucherToolPayload {
|
||||
validMinutes: number | string
|
||||
reason: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user