From 2ba2656d589ded9d1bcc039f3863e1ca945d42f4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 31 Oct 2026 19:05:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E5=95=86=E5=93=81=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E6=8C=89=E6=8E=A5=E5=8D=95=E6=A8=A1=E6=9D=BF/?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E9=85=8D=E7=BD=AE=E7=9B=B4=E6=8E=A5=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=EF=BC=8C=E5=87=8F=E5=B0=91=E6=89=8B=E5=B7=A5=E6=98=A0?= =?UTF-8?q?=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接单平台:用工单的 product_rule_id → tpl: 直接归一,不再依赖渠道映射 - 人工渠道:直接使用人工 SKU 配置里指定的商品 - 成本中心「未映射订单项」排除断代归档行,避免断代后仍显示历史未映射量 --- .../cost-center-overview-service.ts | 1 + .../product-normalize-service.test.ts | 32 ++++++++++++++- .../cost-center/product-normalize-service.ts | 39 ++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) 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 56221c95..004c11c4 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 @@ -88,6 +88,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} SELECT COUNT(*)::int FROM order_items oi WHERE oi.canonical_product_id IS NULL + AND oi.fulfillment_status <> 'archived' AND ($1::text IS NULL OR oi.fulfillment_mode = $1) ) AS unmapped_order_item_count `, diff --git a/apps/backend/src/services/cost-center/product-normalize-service.test.ts b/apps/backend/src/services/cost-center/product-normalize-service.test.ts index 2f785537..f0714185 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.test.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.test.ts @@ -42,7 +42,11 @@ const mappings = [ }, ] -const index = buildMappingIndex(mappings, [{ sku: 'aff-sku', productId: 21 }]) +const index = buildMappingIndex( + mappings, + [{ sku: 'aff-sku', productId: 21 }], + [{ ruleKey: 'rule-abc', productId: 31 }], +) function item(patch: Record = {}) { return { @@ -54,6 +58,8 @@ function item(patch: Record = {}) { normalized_sku_name: null as string | null, normalized_sku_series: null as string | null, fulfillment_channel: 'work_order' as string | null, + work_rule_key: null as string | null, + manual_config_product_id: null as number | null, ...patch, } } @@ -93,3 +99,27 @@ test('商品归一:名称未命中时按名称系列匹配', () => { test('商品归一:全部未命中返回 null,不自动建商品', () => { assert.equal(resolveProductId(item({ sku_code: 'nope' }), index), null) }) + +test('商品归一:接单平台按模板 rule_key 直接命中 tpl 商品,无需渠道映射', () => { + assert.equal( + resolveProductId( + item({ fulfillment_channel: 'work_order', sku_code: 'unknown', work_rule_key: 'rule-abc' }), + index, + ), + 31, + ) +}) + +test('商品归一:人工渠道直接使用配置里指定的商品', () => { + assert.equal( + resolveProductId( + item({ + fulfillment_channel: 'manual', + sku_code: 'unknown', + manual_config_product_id: 44, + }), + index, + ), + 44, + ) +}) diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index 932e7a56..31f70148 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -32,6 +32,7 @@ export type MappingIndex = { bySkuSeries: Map byNameSeries: Map affSkus: Map + templateRules: Map } type ItemRow = { @@ -43,6 +44,8 @@ type ItemRow = { normalized_sku_name: string | null normalized_sku_series: string | null fulfillment_channel: string | null + work_rule_key: string | null + manual_config_product_id: number | null } function key(...parts: string[]): string { @@ -52,6 +55,7 @@ function key(...parts: string[]): string { export function buildMappingIndex( mappings: MappingRow[], affiliateProducts: Array<{ sku: string; productId: number }>, + templateProducts: Array<{ ruleKey: string; productId: number }> = [], ): MappingIndex { const index: MappingIndex = { bySku: new Map(), @@ -59,6 +63,7 @@ export function buildMappingIndex( bySkuSeries: new Map(), byNameSeries: new Map(), affSkus: new Map(), + templateRules: new Map(), } for (const mapping of mappings) { @@ -90,6 +95,10 @@ export function buildMappingIndex( index.affSkus.set(String(product.sku).trim(), product.productId) } + for (const product of templateProducts) { + index.templateRules.set(String(product.ruleKey).trim(), product.productId) + } + return index } @@ -101,6 +110,17 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n const nameKey = String(item.normalized_sku_name || '').trim() const seriesKey = String(item.normalized_sku_series || '').trim() + // 人工渠道:配置里已经指定商品,直接用,不需要再建映射。 + if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) { + return Number(item.manual_config_product_id) + } + + // 接单平台:模板即商品来源(tpl:),无需渠道映射。 + if (item.fulfillment_channel === 'work_order' && item.work_rule_key) { + const templateProduct = index.templateRules.get(String(item.work_rule_key).trim()) + if (templateProduct) return templateProduct + } + if (item.fulfillment_channel === 'affiliate_dash' && skuCode) { const aff = index.affSkus.get(skuCode) if (aff) return aff @@ -161,9 +181,14 @@ export async function normalizeOrderItemProducts( oi.sku_name, oi.normalized_sku_name, oi.normalized_sku_series, - oi.fulfillment_channel + oi.fulfillment_channel, + wpr.rule_key AS work_rule_key, + cfg.canonical_product_id AS manual_config_product_id FROM order_items oi JOIN orders o ON o.id = oi.order_id + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id + LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id WHERE oi.fulfillment_status = 'assigned' AND oi.canonical_product_id IS NULL AND oi.id > $1 @@ -232,11 +257,23 @@ async function loadMappingIndex(): Promise { `, ) + const templateProducts = await query<{ canonical_sku: string; id: number }>( + ` + SELECT id, canonical_sku + FROM product_catalog + WHERE status = 'active' AND canonical_sku LIKE 'tpl:%' + `, + ) + return buildMappingIndex( mappings.rows, affiliateProducts.rows.map((row) => ({ sku: String(row.canonical_sku).slice(4), productId: Number(row.id), })), + templateProducts.rows.map((row) => ({ + ruleKey: String(row.canonical_sku).slice(4), + productId: Number(row.id), + })), ) }