fix(fulfillment): 优先用平台字段映射中文SKU
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user