diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index 213d4cc7..a790e0e3 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -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 diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index 7633261d..d1c35136 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -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'`)) diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index acf6257b..617e379d 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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( + `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( + ` + 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, + }, + } + }) +} + /** * 批量归档旧人工归一商品。 * diff --git a/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx index 4d57465a..cb024348 100644 --- a/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx @@ -4,7 +4,7 @@ * 商品编码固定为 `tpv:`;成本写入 `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 ? {formatAdminDateTime(value)} : '—', }, { title: '操作', - width: 110, + width: 180, + fixed: 'right' as const, render: (_: unknown, row: ProductCatalogItem) => ( - + + + deleteMutation.mutate(row.id)} + > + + + ), }, ] diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 8adbb34c..fbd06c08 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -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}`) +} diff --git a/apps/frontend/tests/voucher-sku-delete-ui.test.ts b/apps/frontend/tests/voucher-sku-delete-ui.test.ts new file mode 100644 index 00000000..688a5159 --- /dev/null +++ b/apps/frontend/tests/voucher-sku-delete-ui.test.ts @@ -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'")) +})