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,
},
}
})
}
/**
* 批量归档旧人工归一商品。
*