feat(cost-center): 收敛第三方卡券商品与成本链路

This commit is contained in:
yml2213
2026-11-06 16:47:45 +08:00
parent 974044a4c6
commit e6b3835c9f
14 changed files with 763 additions and 110 deletions
@@ -737,6 +737,18 @@ export function ProductCatalogPanel() {
>
新增人工 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}>
新建商品
@@ -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)}
@@ -525,10 +525,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) {