fix(fulfillment): 优先用平台字段映射中文SKU

This commit is contained in:
yml2213
2026-11-03 12:40:50 +08:00
parent ac3bb1f686
commit b3fb330ad9
4 changed files with 75 additions and 33 deletions
@@ -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,
@@ -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<boole
/**
* 订单项级内部 SKU 补全:
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
* - 没有中文时 → 查 internal_sku_mappings,把任何平台码映射成中文内部 SKU。
* - 明确的 skuNick/outSkuSn/skuCode 已有中文 → 不查库;
* - 否则先查 internal_sku_mappings,避免中文长标题抢占内部 SKU;
* - 没有映射时才由调用方回退中文标题。
*/
export async function resolveInternalSkuForOrderItem(input: {
provider?: string | null
@@ -256,7 +266,7 @@ export async function resolveInternalSkuForOrderItem(input: {
skuName?: unknown
}): Promise<{ internalSku: string; source: string; mappingId: number } | null> {
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
@@ -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)
}
@@ -30,6 +30,10 @@ type HasConfiguredOrderItemsInput = Omit<ResolveOrderItemForFulfillmentInput, 'i
items?: FulfillmentItem[]
}
type ResolveOrderItemForFulfillmentDeps = {
resolveInternalSkuForOrderItem?: typeof resolveInternalSkuForOrderItem
}
export type AffiliateDashSkuMatch = {
sku: string
productNo: string
@@ -54,19 +58,18 @@ export type ResolvedFulfillmentItem = FulfillmentItem & {
* 渠道选择、载体配置和路由优先级都不在这里判断;它们统一读取
* `internal_sku_channels`,避免平台 SKU 或旧路由抢先创建载体。
*/
export async function resolveOrderItemForFulfillment({
provider = '',
platform = '',
shopId = '',
item = {},
}: ResolveOrderItemForFulfillmentInput): Promise<ResolvedFulfillmentItem> {
export async function resolveOrderItemForFulfillment(
{ provider = '', platform = '', shopId = '', item = {} }: ResolveOrderItemForFulfillmentInput,
deps: ResolveOrderItemForFulfillmentDeps = {},
): Promise<ResolvedFulfillmentItem> {
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,