From b3fb330ad9dd0402c4f5c1b4936cab7e27f3fca8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 12:40:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(fulfillment):=20=E4=BC=98=E5=85=88=E7=94=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AD=97=E6=AE=B5=E6=98=A0=E5=B0=84=E4=B8=AD?= =?UTF-8?q?=E6=96=87SKU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fulfillment-routing.integration.test.ts | 15 ++++++ .../internal-sku-mapping-service.ts | 24 ++++++--- .../product-resolution-service.test.ts | 52 ++++++++++++------- .../fulfillment/product-resolution-service.ts | 17 +++--- 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 7edd1284..ce19868d 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -35,6 +35,7 @@ test( resolveInternalSkuMapping, upsertInternalSkuMapping, } = await import('./internal-sku-mapping-service.js') + const { resolveOrderItemForFulfillment } = await import('./product-resolution-service.js') const { updateOrderItemSourceSnapshot } = await import('../../repositories/order-item-repo.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const shopId = `it-routing-${suffix}` @@ -131,6 +132,20 @@ test( candidates: [{ matchType: 'sku_code', value: mappingValue }], }) assert.equal(resolvedMapping?.internalSku, mappingSku) + const resolvedLongTitleItem = await resolveOrderItemForFulfillment({ + provider: '91kaquan', + shopId, + item: { + externalItemId: `mapping-item-${suffix}`, + externalSkuCode: mappingValue, + externalSkuName: '平台长中文商品标题(不应成为内部 SKU)', + skuCode: mappingValue, + skuName: '平台长中文商品标题(不应成为内部 SKU)', + quantity: 1, + }, + }) + assert.equal(resolvedLongTitleItem.snapshot.internalSku, mappingSku) + assert.equal(resolvedLongTitleItem.skuName, mappingSku) const mappingPage = await listInternalSkuMappingsPage({ keyword: mappingSku, page: 1, diff --git a/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts index dab582b7..2c334fbc 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts @@ -49,7 +49,7 @@ export function normalizeInternalSkuMappingValue(value: unknown): string { /** * 按平台字段候选值查内部中文 SKU 映射。 * - * 优先级:精确 provider/shop > 全局;同范围按 priority 升序、id 升序。 + * 优先级:精确 provider/shop > 全局;同范围按候选字段顺序、priority、id 升序。 * 返回 null 表示没有映射,调用方回退 manual_sku_key 生成列。 */ export async function resolveInternalSkuMapping(input: { @@ -60,9 +60,10 @@ export async function resolveInternalSkuMapping(input: { const provider = String(input.provider || '').trim() const shopId = String(input.shopId || '').trim() const pairs = input.candidates - .map((candidate) => ({ + .map((candidate, candidatePriority) => ({ matchType: candidate.matchType, matchValue: normalizeInternalSkuMappingValue(candidate.value), + candidatePriority, })) .filter((candidate) => candidate.matchValue !== '') if (pairs.length === 0) return null @@ -71,7 +72,8 @@ export async function resolveInternalSkuMapping(input: { ` SELECT m.id, m.internal_sku, m.source FROM internal_sku_mappings m - JOIN unnest($3::text[], $4::text[]) AS c(match_type, match_value) + JOIN unnest($3::text[], $4::text[], $5::int[]) + AS c(match_type, match_value, candidate_priority) ON m.match_type = c.match_type AND canonicalize_internal_sku(m.match_value) = c.match_value WHERE m.enabled = TRUE @@ -82,11 +84,18 @@ export async function resolveInternalSkuMapping(input: { WHEN m.provider = $1 THEN 1 WHEN m.shop_id = $2 THEN 2 ELSE 3 END, + c.candidate_priority ASC, m.priority ASC, m.id ASC LIMIT 1 `, - [provider, shopId, pairs.map((pair) => pair.matchType), pairs.map((pair) => pair.matchValue)], + [ + provider, + shopId, + pairs.map((pair) => pair.matchType), + pairs.map((pair) => pair.matchValue), + pairs.map((pair) => pair.candidatePriority), + ], ) const row = result.rows[0] if (!row) return null @@ -245,8 +254,9 @@ export async function deleteInternalSkuMapping(mappingId: number): Promise { const spec = input.spec && typeof input.spec === 'object' ? input.spec : {} - const directCandidates = [spec.skuNick, spec.outSkuSn, input.skuCode, input.skuName] + const directCandidates = [spec.skuNick, spec.outSkuSn, input.skuCode] .map(normalizeInternalSkuMappingValue) .filter(Boolean) if (directCandidates.some(containsCjk)) return null diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts index e52c9e02..2b670440 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts @@ -100,12 +100,17 @@ function buildPlatformItem(input: { } } +const noInternalSkuMapping = async () => null + test('入口归一:91 平台先得到中文内部 SKU,不在此处决定渠道', async () => { - const item = await resolveOrderItemForFulfillment({ - provider: '91kaquan', - platform: 'kuaishou', - item: buildPlatformItem({ skuCode: 'external-001', skuName: '幸运币2个' }), - }) + const item = await resolveOrderItemForFulfillment( + { + provider: '91kaquan', + platform: 'kuaishou', + item: buildPlatformItem({ skuCode: 'external-001', skuName: '幸运币2个' }), + }, + { resolveInternalSkuForOrderItem: noInternalSkuMapping }, + ) assert.equal(item.skuName, '幸运币2个') assert.equal(item.snapshot.internalSku, '幸运币2个') @@ -116,11 +121,14 @@ test('入口归一:91 平台先得到中文内部 SKU,不在此处决定渠 }) test('入口归一:PDD 数字平台码由中文商品名归一,不把数字当内部 SKU', async () => { - const item = await resolveOrderItemForFulfillment({ - provider: 'pdd', - platform: 'pdd', - item: buildPlatformItem({ skuCode: '5003', skuName: '幸运币2个' }), - }) + const item = await resolveOrderItemForFulfillment( + { + provider: 'pdd', + platform: 'pdd', + item: buildPlatformItem({ skuCode: '5003', skuName: '幸运币2个' }), + }, + { resolveInternalSkuForOrderItem: noInternalSkuMapping }, + ) assert.equal(item.snapshot.internalSku, '幸运币2个') assert.equal(item.skuName, '幸运币2个') @@ -129,11 +137,14 @@ test('入口归一:PDD 数字平台码由中文商品名归一,不把数字 test('入口归一:抖音长标题不进内部 SKU,使用 sku_code 中的中文内部 SKU', async () => { const title = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用' - const item = await resolveOrderItemForFulfillment({ - provider: 'douyin_mashida', - platform: 'doudian', - item: buildPlatformItem({ skuCode: '浪漫天命', skuName: title }), - }) + const item = await resolveOrderItemForFulfillment( + { + provider: 'douyin_mashida', + platform: 'doudian', + item: buildPlatformItem({ skuCode: '浪漫天命', skuName: title }), + }, + { resolveInternalSkuForOrderItem: noInternalSkuMapping }, + ) assert.equal(item.snapshot.internalSku, '浪漫天命') assert.equal(item.skuName, '浪漫天命') @@ -151,10 +162,13 @@ test('入口归一:所有平台使用同一条中文 SKU 逻辑', async () => { provider: 'unknown', platform: 'unknown' }, ] for (const source of cases) { - const item = await resolveOrderItemForFulfillment({ - ...source, - item: buildPlatformItem({ skuCode: 'platform-code', skuName: ' 套装-浪漫天命 ' }), - }) + const item = await resolveOrderItemForFulfillment( + { + ...source, + item: buildPlatformItem({ skuCode: 'platform-code', skuName: ' 套装-浪漫天命 ' }), + }, + { resolveInternalSkuForOrderItem: noInternalSkuMapping }, + ) assert.equal(item.snapshot.internalSku, '套装-浪漫天命') assert.equal(item.isConfigured, false) } diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index b4422ddb..8dc36213 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -30,6 +30,10 @@ type HasConfiguredOrderItemsInput = Omit { +export async function resolveOrderItemForFulfillment( + { provider = '', platform = '', shopId = '', item = {} }: ResolveOrderItemForFulfillmentInput, + deps: ResolveOrderItemForFulfillmentDeps = {}, +): Promise { const externalItemId = pickFirstNonEmpty([item.externalItemId, item.itemId]) const externalSkuCode = pickFirstNonEmpty([item.externalSkuCode, item.skuCode, externalItemId]) const externalSkuName = pickFirstNonEmpty([item.externalSkuName, item.skuName, externalSkuCode]) const externalSkuNameNormalized = normalizeProductName(externalSkuName) const spec = isPlainObject(item.spec) ? item.spec : {} - const internalSkuMapping = await resolveInternalSkuForOrderItem({ + const resolveSkuMapping = deps.resolveInternalSkuForOrderItem || resolveInternalSkuForOrderItem + const internalSkuMapping = await resolveSkuMapping({ provider, shopId, spec,