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,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量归档旧人工归一商品。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user