feat(cost-center): 支持删除第三方卡券商品
This commit is contained in:
@@ -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'"))
|
||||
})
|
||||
Reference in New Issue
Block a user