feat(cost-center): 支持删除第三方卡券商品

This commit is contained in:
yml2213
2026-11-05 17:48:24 +08:00
parent f67f00a7a5
commit a6fbb43955
6 changed files with 285 additions and 19 deletions
@@ -17,6 +17,7 @@ import {
createAdminProduct,
deleteAdminChannelMapping,
deleteAdminProduct,
deleteAdminVoucherProduct,
getAdminChannelMappingDiagnostics,
listAdminChannelMappings,
listAdminProducts,
@@ -416,4 +417,30 @@ router.post(
}),
)
router.delete(
'/cost-center/voucher-products/:productId',
requireAdminPermission('finance.config'),
createJsonHandler((req) => deleteAdminVoucherProduct(idOf(req, 'productId')), {
successMessage: '第三方卡券商品已处理',
errorMessage: '删除第三方卡券商品失败',
scope: '[admin/cost-center/voucher-products/:productId]',
audit: (req, data) => {
const result = data as {
action?: string
product?: { id?: number; canonicalSku?: string }
}
return {
action:
result.action === 'archived' ? 'voucher_product_archived' : 'voucher_product_deleted',
targetType: 'product_catalog',
targetId: String(result.product?.id || req.params.productId || ''),
data: {
canonicalSku: result.product?.canonicalSku || '',
action: result.action || '',
},
}
},
}),
)
export default router
@@ -291,6 +291,25 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
})
test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => {
const { readFile } = await import('node:fs/promises')
const [serviceSource, routeSource] = await Promise.all([
readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8'),
readFile(new URL('../../routes/admin/cost-center-catalog.ts', import.meta.url), 'utf8'),
])
const start = serviceSource.indexOf('export async function deleteAdminVoucherProduct')
const end = serviceSource.indexOf('/**\n * 批量归档旧人工归一商品', start)
const body = serviceSource.slice(start, end)
assert.ok(body.includes('withTransaction'), '卡券删除必须在事务内完成')
assert.ok(body.includes("action: 'archived'"), '有历史引用时必须自动归档')
assert.ok(body.includes("status = 'archived'"), '归档必须保留商品和历史成本')
assert.ok(body.includes('UPDATE channel_product_mappings'), '归档后必须停用旧映射,避免继续命中')
assert.ok(body.includes('DELETE FROM cost_versions'), '无历史引用时应清理采购价版本')
assert.ok(body.includes("channel = 'third_party_voucher'"), '必须停用对应卡券渠道规则')
assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'"))
})
test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
@@ -439,6 +439,152 @@ export async function deleteAdminProduct(productId: number | string) {
return { deleted: true, product: mapProduct(current) }
}
/**
* 删除第三方卡券商品。
*
* 卡券商品通常带采购价版本,不能套用普通商品“有任何成本版本就拒绝”的规则:
* - 没有任何订单项/映射/成本快照:删除未使用成本版本、渠道能力与卡券规则后硬删除;
* - 已有历史引用:归档商品并停用后续卡券规则,成本版本与快照全部保留。
*/
export async function deleteAdminVoucherProduct(productId: number | string) {
const id = normalizeId(productId, '商品 ID')
return withTransaction(async (client) => {
const currentResult = await client.query<ProductRow>(
`SELECT * FROM product_catalog WHERE id = $1 FOR UPDATE`,
[id],
)
const current = requiredRow(currentResult.rows[0], '商品不存在')
if (!current.canonical_sku.startsWith('tpv:')) {
throw createHttpError('该商品不是第三方卡券商品', {
statusCode: 400,
errorCode: 'voucher_product_type_invalid',
})
}
const usageResult = await client.query<{
mapping_count: number
snapshot_count: number
order_item_count: number
}>(
`
SELECT
(SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1)
AS mapping_count,
(
SELECT COUNT(*)::int
FROM cost_snapshots
WHERE canonical_product_id = $1
OR cost_version_id IN (
SELECT id FROM cost_versions WHERE canonical_product_id = $1
)
)
AS snapshot_count,
(SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1)
AS order_item_count
`,
[id],
)
const usage = usageResult.rows[0]
const mappingCount = toNumber(usage?.mapping_count)
const snapshotCount = toNumber(usage?.snapshot_count)
const orderItemCount = toNumber(usage?.order_item_count)
const internalSku = current.canonical_sku.slice(4)
await client.query(
`
UPDATE internal_sku_channels
SET enabled = FALSE,
is_fallback = FALSE,
updated_at = $3
WHERE channel = 'third_party_voucher'
AND (
canonical_product_id = $1
OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2)
)
`,
[id, internalSku, nowIso()],
)
await client.query(
`
UPDATE product_channel_capabilities
SET enabled = FALSE,
updated_at = $2
WHERE product_id = $1 AND channel = 'third_party_voucher'
`,
[id, nowIso()],
)
if (mappingCount > 0 || snapshotCount > 0 || orderItemCount > 0) {
await client.query(
`
UPDATE channel_product_mappings
SET enabled = FALSE,
note = CASE
WHEN note = '' THEN '第三方卡券商品删除后自动停用'
ELSE note
END,
updated_at = $2
WHERE canonical_product_id = $1
`,
[id, nowIso()],
)
const archived = await client.query<ProductRow>(
`
UPDATE product_catalog
SET status = 'archived',
attributes_json = attributes_json || jsonb_build_object(
'deletedAt', $2::text,
'deletedBy', 'voucher_product_panel'
),
updated_at = $2::timestamptz
WHERE id = $1
RETURNING *
`,
[id, nowIso()],
)
return {
action: 'archived' as const,
deleted: false,
product: mapProduct(requiredRow(archived.rows[0], '归档卡券商品失败')),
preserved: {
mappingCount,
snapshotCount,
orderItemCount,
},
}
}
const deletedVersions = await client.query(
`DELETE FROM cost_versions WHERE canonical_product_id = $1`,
[id],
)
await client.query(`DELETE FROM product_channel_capabilities WHERE product_id = $1`, [id])
await client.query(
`
DELETE FROM internal_sku_channels
WHERE channel = 'third_party_voucher'
AND (
canonical_product_id = $1
OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2)
)
`,
[id, internalSku],
)
await client.query('DELETE FROM product_catalog WHERE id = $1', [id])
return {
action: 'deleted' as const,
deleted: true,
product: mapProduct(current),
deletedCostVersionCount: deletedVersions.rowCount || 0,
preserved: {
mappingCount: 0,
snapshotCount: 0,
orderItemCount: 0,
},
}
})
}
/**
* 批量归档旧人工归一商品。
*
@@ -4,7 +4,7 @@
* 商品编码固定为 `tpv:<sku>`;成本写入 `sys_third_party_voucher_cost` 的
* 商品级版本,由成本快照管道消费。
*/
import { ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import { DeleteOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import {
Button,
Card,
@@ -12,6 +12,7 @@ import {
Input,
InputNumber,
Modal,
Popconfirm,
Space,
Tag,
Typography,
@@ -22,11 +23,13 @@ import { useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import {
deleteAdminVoucherProduct,
ensureVoucherChannelRules,
fetchAdminProductCatalog,
setAdminProductPrice,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { useCostCenter } from './CostCenterContext'
@@ -65,6 +68,20 @@ export default function VoucherSkuPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'),
})
const deleteMutation = useMutation({
mutationFn: (productId: number) => deleteAdminVoucherProduct(productId),
onSuccess: (response) => {
message.success(
response.data.action === 'archived'
? '该商品已有历史引用,已归档并停用后续卡券规则;历史成本已保留'
: '卡券商品及采购价配置已删除',
)
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
refreshAll()
},
onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'),
})
async function handleEnsureRules() {
setEnsuringRules(true)
try {
@@ -99,28 +116,50 @@ export default function VoucherSkuPanel() {
title: '生效时间',
dataIndex: 'activeCostEffectiveFrom',
width: 180,
render: (value?: string | null) => value || '—',
render: (value?: string | null) =>
value ? <span style={{ whiteSpace: 'nowrap' }}>{formatAdminDateTime(value)}</span> : '—',
},
{
title: '操作',
width: 110,
width: 180,
fixed: 'right' as const,
render: (_: unknown, row: ProductCatalogItem) => (
<Button
size="small"
disabled={!canConfig}
onClick={() => {
setSelected(row)
form.setFieldsValue({
amountFen:
row.activeCostAmountFen === null || row.activeCostAmountFen === undefined
? 0
: Number((row.activeCostAmountFen / 100).toFixed(2)),
changeReason: '第三方卡券采购价调整',
})
}}
>
改价
</Button>
<Space size={4}>
<Button
size="small"
disabled={!canConfig}
onClick={() => {
setSelected(row)
form.setFieldsValue({
amountFen:
row.activeCostAmountFen === null || row.activeCostAmountFen === undefined
? 0
: Number((row.activeCostAmountFen / 100).toFixed(2)),
changeReason: '第三方卡券采购价调整',
})
}}
>
改价
</Button>
<Popconfirm
title="删除该第三方卡券商品?"
description="没有历史引用时会彻底删除;已有历史订单或成本快照时会自动归档并停用后续卡券规则,历史成本保留。"
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMutation.mutate(row.id)}
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canConfig}
loading={deleteMutation.isPending && deleteMutation.variables === row.id}
>
删除
</Button>
</Popconfirm>
</Space>
),
},
]
@@ -555,3 +555,17 @@ export function ensureVoucherChannelRules() {
'/api/v1/admin/cost-center/voucher-products/ensure-rules',
)
}
export function deleteAdminVoucherProduct(productId: number) {
return apiDelete<{
action: 'deleted' | 'archived'
deleted: boolean
product: ProductCatalogItem
deletedCostVersionCount?: number
preserved: {
mappingCount: number
snapshotCount: number
orderItemCount: number
}
}>(`/api/v1/admin/cost-center/voucher-products/${productId}`)
}
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('第三方卡券面板提供安全删除入口并格式化生效时间', async () => {
const [panel, service] = await Promise.all([
readFile(
new URL('../src/pages/admin/cost-center/VoucherSkuPanel.tsx', import.meta.url),
'utf8',
),
readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'),
])
assert.ok(panel.includes('deleteAdminVoucherProduct'))
assert.ok(panel.includes('DeleteOutlined'))
assert.ok(panel.includes('Popconfirm'))
assert.ok(panel.includes('已有历史订单或成本快照时会自动归档'))
assert.ok(panel.includes('formatAdminDateTime(value)'))
assert.ok(service.includes('/api/v1/admin/cost-center/voucher-products/${productId}'))
assert.ok(service.includes("action: 'deleted' | 'archived'"))
})