From 31b6d2d65ffc39b0abd6424512429b0a46c72ae4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 17:07:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(cost-center):=20=E5=BD=92=E6=A1=A3=E5=B7=B2?= =?UTF-8?q?=E8=A2=AB=E5=85=B6=E4=BB=96=E6=B8=A0=E9=81=93=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E7=9A=84=E4=BA=BA=E5=B7=A5=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/cost-center-catalog.ts | 17 +++ .../cost-center/cost-center-mappers.ts | 20 ++- .../cost-center-overview-service.ts | 45 +++++-- .../services/cost-center/cost-center-types.ts | 1 + .../services/cost-center/cost-center.test.ts | 7 ++ ...manual-product-cleanup.integration.test.ts | 84 +++++++++++++ .../cost-center/product-catalog-service.ts | 117 ++++++++++++++++++ .../admin/cost-center/ProductCatalogPanel.tsx | 67 ++++++++-- .../src/services/admin/cost-center.ts | 17 +++ apps/frontend/src/types/admin/cost-center.ts | 1 + docs/成本中心与履约模型.md | 1 + 11 files changed, 361 insertions(+), 16 deletions(-) create mode 100644 apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index c884b599..213d4cc7 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -10,6 +10,7 @@ import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-as import { archiveHistoricalManualProducts, + archiveSupersededManualProducts, backfillAdminNormalizedQuantities, backfillAdminProductMappings, createAdminChannelMapping, @@ -178,6 +179,22 @@ router.post( }), ) +router.post( + '/cost-center/products/archive-superseded-manual', + requireAdminPermission('finance.config'), + createJsonHandler((req) => archiveSupersededManualProducts(bodyOf(req)), { + successMessage: '重复人工归一商品已归档', + errorMessage: '归档重复人工归一商品失败', + scope: '[admin/cost-center/products/archive-superseded-manual]', + audit: (_req, data) => ({ + action: 'cost_superseded_manual_products_archived', + targetType: 'product_catalog', + targetId: '', + data: data as Record, + }), + }), +) + router.patch( '/cost-center/products/:productId', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 210c1847..fac496db 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -34,12 +34,27 @@ export function productSourceOf(canonicalSku: string): string { } /** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */ -export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string { +export function productSourceFilterSql( + paramIndex: number, + column = 'p.canonical_sku', + nameColumn = 'p.canonical_name', +): string { return `( ($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%') OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%')) OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%') - OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%') + OR ( + $${paramIndex} = 'manual' + AND ${column} LIKE 'man:%' + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels live_channel + WHERE live_channel.enabled = TRUE + AND live_channel.is_fallback = FALSE + AND live_channel.channel <> 'manual' + AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn}) + ) + ) )` } @@ -121,6 +136,7 @@ export function mapProduct(row: ProductRow) { row.template_sharing_enabled === null || row.template_sharing_enabled === undefined ? null : row.template_sharing_enabled === true, + activePrimaryChannel: row.active_primary_channel || null, affiliateTaskCount: Number(row.affiliate_task_count || 0), affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, createdAt: row.created_at, diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index b7204500..3c8f58c4 100644 --- a/apps/backend/src/services/cost-center/cost-center-overview-service.ts +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -42,7 +42,18 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%') OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%')) OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%') - OR ($1 = 'manual' AND p.canonical_sku LIKE 'man:%') + OR ( + $1 = 'manual' + AND p.canonical_sku LIKE 'man:%' + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels live_channel + WHERE live_channel.enabled = TRUE + AND live_channel.is_fallback = FALSE + AND live_channel.channel <> 'manual' + AND BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name) + ) + ) ) ), scoped_cost_models AS ( @@ -99,11 +110,21 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ) const row = result.rows[0] const [productSourceResult, costModelResult] = await Promise.all([ - query<{ canonical_sku: string; total: number }>( - `SELECT canonical_sku, COUNT(*)::int AS total - FROM product_catalog - WHERE status = 'active' - GROUP BY canonical_sku`, + query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>( + `SELECT p.canonical_sku, + ( + SELECT c.channel + FROM internal_sku_channels c + WHERE c.enabled = TRUE + AND c.is_fallback = FALSE + AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + ORDER BY c.priority, c.channel + LIMIT 1 + ) AS active_primary_channel, + COUNT(*)::int AS total + FROM product_catalog p + WHERE p.status = 'active' + GROUP BY p.canonical_sku, p.canonical_name`, ), query<{ cost_model: string; total: number }>( `SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`, @@ -137,7 +158,9 @@ function normalizeScope(raw: unknown): CostCenterScope | null { } /** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */ -function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) { +function productSourceCountsOf( + rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>, +) { const counts: Record = { affiliate_dash: 0, work_order: 0, @@ -146,6 +169,14 @@ function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: numbe } for (const row of rows) { const source = productSourceOf(row.canonical_sku) + // man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。 + if ( + source === 'manual' && + row.active_primary_channel && + row.active_primary_channel !== 'manual' + ) { + continue + } const key = source || 'uncategorized' counts[key] = (counts[key] || 0) + toNumber(row.total) } diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts index 16bf35f7..99cc9c99 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -29,6 +29,7 @@ export type ProductRow = { template_pricing_mode?: string | null template_enabled?: boolean | null template_sharing_enabled?: boolean | null + active_primary_channel?: string | null affiliate_task_count?: number affiliate_last_fulfilled_at?: string | null } 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 a3f63456..56cdba0c 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,13 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => { assert.ok(productSourceFilterSql(1).includes("'tpv:%'")) }) +test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => { + const sql = productSourceFilterSql(1) + assert.ok(sql.includes(`$1 = 'manual'`)) + assert.ok(sql.includes("live_channel.channel <> 'manual'")) + assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)')) +}) + test('tpv: 商品来源归为第三方卡券', async () => { const { productSourceOf } = await import('./cost-center-mappers.js') const assert = (await import('node:assert/strict')).default diff --git a/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts b/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts new file mode 100644 index 00000000..de9faff7 --- /dev/null +++ b/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts @@ -0,0 +1,84 @@ +/** 旧人工归一商品清理集成测试(真实 PostgreSQL)。 */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String( + process.env.LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL || '', +).trim() + +test( + '已被其他主渠道接管的历史 man 商品只归档并修正兜底指向', + { skip: !TEST_DATABASE_URL ? '未设置 LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { archiveSupersededManualProducts } = await import('./product-catalog-service.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const internalSku = `清理测试人工商品-${suffix}` + const manSku = `man:${internalSku}` + const liveSku = `aff:cleanup-${suffix}` + let manProductId = 0 + let liveProductId = 0 + + try { + const inserted = await query<{ id: number; canonical_sku: string }>( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES + ($1, $3, '人工客服', '件', 'active', '{"source":"manual_normalization"}'::jsonb, NOW(), NOW()), + ($2, $3, '自动发货', '件', 'active', '{}'::jsonb, NOW(), NOW()) + RETURNING id, canonical_sku + `, + [manSku, liveSku, internalSku], + ) + manProductId = Number(inserted.rows.find((row) => row.canonical_sku === manSku)?.id) + liveProductId = Number(inserted.rows.find((row) => row.canonical_sku === liveSku)?.id) + assert.ok(manProductId > 0) + assert.ok(liveProductId > 0) + + await query( + ` + INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at + ) VALUES + ($1, 'affiliate_dash', 10, FALSE, TRUE, $3, '{}'::jsonb, 'test', NOW(), NOW()), + ($1, 'manual', 90, TRUE, TRUE, $2, '{}'::jsonb, 'test', NOW(), NOW()) + `, + [internalSku, manProductId, liveProductId], + ) + + const preview = await archiveSupersededManualProducts() + assert.equal(preview.candidateCount, 1) + assert.ok(preview.sample.some((row) => Number(row.id) === manProductId)) + + const result = await archiveSupersededManualProducts({ execute: true }) + assert.equal(result.archivedCount, 1) + assert.equal(result.repointedChannelCount, 1) + + const products = await query<{ canonical_sku: string; status: string }>( + 'SELECT canonical_sku, status FROM product_catalog WHERE id = ANY($1::bigint[])', + [[manProductId, liveProductId]], + ) + assert.equal(products.rows.find((row) => row.canonical_sku === manSku)?.status, 'archived') + assert.equal(products.rows.find((row) => row.canonical_sku === liveSku)?.status, 'active') + const fallback = await query<{ canonical_product_id: number }>( + `SELECT canonical_product_id + FROM internal_sku_channels + WHERE internal_sku = $1 AND channel = 'manual'`, + [internalSku], + ) + assert.equal(Number(fallback.rows[0]?.canonical_product_id), liveProductId) + } finally { + if (manProductId > 0 || liveProductId > 0) { + await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku]) + await query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [ + [manProductId, liveProductId], + ]) + } + await closeDb() + } + }, +) 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 ce900ae8..601e9d1b 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -160,6 +160,15 @@ export async function listAdminProducts(payload: JsonObject = {}) { END AS template_pricing_mode, tpl_rule.enabled AS template_enabled, tpl_rule.sharing_enabled AS template_sharing_enabled, + ( + SELECT c.channel + FROM internal_sku_channels c + WHERE c.enabled = TRUE + AND c.is_fallback = FALSE + AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + ORDER BY c.priority, c.channel + LIMIT 1 + ) AS active_primary_channel, CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id, CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name, root.id AS root_category_id, @@ -481,6 +490,114 @@ export async function archiveHistoricalManualProducts(payload: JsonObject = {}) } } +type SupersededManualProductRow = { + id: number + canonical_sku: string + canonical_name: string + primary_channel: string + target_product_id: number + target_product_sku: string +} + +/** + * 归档已被其他渠道主规则接管的旧人工归一商品。 + * + * `man:` 只表示旧人工来源,不代表当前主渠道。同名内部 SKU 已有自动发货、 + * 接单平台或卡券主渠道后,旧 man: 商品不再参与新路由,只需从当前目录归档。 + * 历史订单、成本版本和成本快照全部保留;仍指向旧商品的手动兜底改指当前主商品。 + */ +export async function archiveSupersededManualProducts(payload: JsonObject = {}) { + const execute = payload.execute === true || payload.execute === 'true' + return withTransaction(async (client) => { + const candidates = await client.query( + ` + SELECT + p.id, + p.canonical_sku, + p.canonical_name, + live.channel AS primary_channel, + target.id AS target_product_id, + target.canonical_sku AS target_product_sku + FROM product_catalog p + JOIN LATERAL ( + SELECT c.channel, c.canonical_product_id + FROM internal_sku_channels c + WHERE c.enabled = TRUE + AND c.is_fallback = FALSE + AND c.channel <> 'manual' + AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + ORDER BY c.priority, c.channel + LIMIT 1 + ) live ON TRUE + JOIN product_catalog target + ON target.id = live.canonical_product_id + AND target.status = 'active' + WHERE p.status = 'active' + AND p.canonical_sku LIKE 'man:%' + AND p.attributes_json->>'source' = 'manual_normalization' + ORDER BY p.id + FOR UPDATE OF p + `, + ) + const rows = candidates.rows.map((row) => ({ + ...row, + id: Number(row.id), + target_product_id: Number(row.target_product_id), + })) + if (!execute || rows.length === 0) { + return { + dryRun: !execute, + candidateCount: rows.length, + archivedCount: 0, + repointedChannelCount: 0, + sample: rows.slice(0, 20), + } + } + + const repointed = await client.query( + ` + UPDATE internal_sku_channels c + SET canonical_product_id = input.target_product_id, + updated_at = NOW() + FROM unnest($1::text[], $2::bigint[], $3::bigint[]) + AS input(internal_sku, source_product_id, target_product_id) + WHERE c.internal_sku = input.internal_sku + AND c.channel = 'manual' + AND c.canonical_product_id = input.source_product_id + `, + [ + rows.map((row) => row.canonical_name), + rows.map((row) => row.id), + rows.map((row) => row.target_product_id), + ], + ) + const archived = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb, + updated_at = NOW() + WHERE id = ANY($2::bigint[]) AND status = 'active' + `, + [ + JSON.stringify({ + legacyManualArchived: true, + legacyManualArchiveReason: 'superseded_by_active_primary_channel', + legacyManualArchivedAt: nowIso(), + }), + rows.map((row) => row.id), + ], + ) + return { + dryRun: false, + candidateCount: rows.length, + archivedCount: archived.rowCount || 0, + repointedChannelCount: repointed.rowCount || 0, + sample: rows.slice(0, 20), + } + }) +} + type MappingProductGroupRow = { canonical_product_id: number canonical_sku: string diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 831e02db..48ec4e32 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -43,6 +43,7 @@ import dayjs from '@/lib/dayjs' import { applyAdminTemplateProductBootstrap, archiveAdminHistoricalManualProducts, + archiveAdminSupersededManualProducts, createAdminProductCatalog, deleteAdminProductCatalog, fetchAdminProductCatalog, @@ -56,6 +57,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi import { useCostCenter } from './CostCenterContext' import { + FULFILLMENT_MODE_LABELS, PRODUCT_SOURCE_OPTIONS, PRODUCT_SOURCE_TAG_COLORS, labelOf, @@ -108,6 +110,7 @@ export function ProductCatalogPanel() { const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false) + const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false) const bootstrapSyncOptionsRef = useRef({ includeDisabledTemplates: false }) const [productPage, setProductPage] = useState(1) const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) @@ -487,6 +490,41 @@ export function ProductCatalogPanel() { } } + async function archiveSupersededManuals() { + setArchivingSupersededManuals(true) + try { + const preview = (await archiveAdminSupersededManualProducts()).data + if (preview.candidateCount === 0) { + message.info('没有已被其他渠道接管的人工归一商品') + return + } + modal.confirm({ + title: '归档跨渠道重复的人工归一商品', + content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`, + okText: '确认归档', + cancelText: '取消', + onOk: async () => { + setArchivingSupersededManuals(true) + try { + const result = (await archiveAdminSupersededManualProducts({ execute: true })).data + message.success( + `已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败') + } finally { + setArchivingSupersededManuals(false) + } + }, + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败') + } finally { + setArchivingSupersededManuals(false) + } + } + function deleteProduct(item: ProductCatalogItem) { const affiliateHint = item.source === 'affiliate_dash' @@ -554,6 +592,12 @@ export function ProductCatalogPanel() { {row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'} ) : null} + {row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? ( + + 当前主渠道: + {FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel} + + ) : null} ), @@ -716,13 +760,22 @@ export function ProductCatalogPanel() { canConfig ? ( {productSourceFilter === 'manual' ? ( - + <> + + + ) : null} {productSourceFilter === 'work_order' ? ( <> diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 3671398a..61503fcf 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -105,6 +105,23 @@ export function archiveAdminHistoricalManualProducts(payload: { execute?: boolea }>('/api/v1/admin/cost-center/products/archive-historical-manual', payload) } +export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) { + return apiPost<{ + dryRun: boolean + candidateCount: number + archivedCount: number + repointedChannelCount: number + sample: Array<{ + id: number + canonical_sku: string + canonical_name: string + primary_channel: string + target_product_id: number + target_product_sku: string + }> + }>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload) +} + export function syncAdminAffiliateDashProducts() { return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>( '/api/v1/admin/cost-center/affiliate-products/sync', diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 3f4c9542..2f3c4cfd 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -54,6 +54,7 @@ export interface ProductCatalogItem { templatePricingMode?: 'fixed' | 'unit' | null templateEnabled?: boolean | null templateSharingEnabled?: boolean | null + activePrimaryChannel?: FulfillmentChannel | null /** 自动发货履约任务数,实时取自 fulfillment_tasks。 */ affiliateTaskCount: number affiliateLastFulfilledAt: string | null diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 3bac7074..8bc65685 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -395,6 +395,7 @@ fulfillment_decided_at / fulfillment_reason 4. **商品来源不能反推渠道**:`man:` 商品也可能由自动发货履约,判定只认规则与证据。 5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。 6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。 +7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。 ---