feat(douyin-mashida): 抖音按 SKU 三层分类接入(对齐 91/快手)
- sku_code 恒为内部 SKU(不再被英文 sku 覆写);英文 sku / 引流标题分别落 snapshot - 命中映射 → affiliate_dash;未命中不再兜底 manual_dispatch(无执行载体 = 待人工,不建任务) - 抖音映射支持「常量表 + 后台 skuMapping 增量」,新增商品不必发版 - 载体唯一守卫:命中自动发货的订单项不再建工单 - 券 cert_link 与命中解耦(/h5 一律按订单号拼);券写入工单 material_json.douyinMashidaVoucher - 新增/修正 16 例单测;后端全量 717 例 / 0 失败
This commit is contained in:
@@ -142,31 +142,46 @@ test('抖音订单:内部 SKU 命中映射表 → 复用 affiliate_dash 通道
|
||||
|
||||
assert.equal(item.isConfigured, true)
|
||||
assert.equal(selectedExecutorKeyOf(item), 'affiliate_dash')
|
||||
// 走 affiliate_dash 通道后与 91 同口径:skuCode 是英文 sku、skuName 是中文商品名
|
||||
assert.equal(item.skuCode, 'lucky_coin_x2')
|
||||
// 抖音的 sku_code 是**分类键(内部 SKU)**:命中映射后也不再被英文 sku 覆写
|
||||
assert.equal(item.skuCode, '幸运币2个')
|
||||
assert.equal(item.skuName, '幸运币2个')
|
||||
// 快照里的 affiliateDash 就是 affiliate_dash 执行器/规划器要用的映射结果
|
||||
// 英文 sku 只落在快照里(affiliate_dash 执行器/规划器要用它)
|
||||
assert.deepEqual(item.snapshot.affiliateDash, {
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
productNo: '幸运币2个',
|
||||
sku: 'lucky_coin_x2',
|
||||
skuName: '幸运币2个',
|
||||
})
|
||||
// 引流长标题另存 productTitle(不再占 skuName)
|
||||
assert.equal(item.snapshot.productTitle, DOUYIN_SKIN_TITLE)
|
||||
// 分类结果(发单层据此维护「一个单子只有一个执行载体」)
|
||||
assert.deepEqual(item.snapshot.classification, {
|
||||
channel: 'affiliate_dash',
|
||||
canonicalSku: '幸运币2个',
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
})
|
||||
assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp')
|
||||
})
|
||||
|
||||
test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch', async () => {
|
||||
test('抖音订单:内部 SKU 未命中映射表 → 无执行载体(既不建任务也不写死人工)', async () => {
|
||||
const item = await resolveOrderItemForFulfillment({
|
||||
provider: 'douyin_mashida',
|
||||
platform: 'doudian',
|
||||
item: buildDouyinItem('不存在的皮肤'),
|
||||
})
|
||||
|
||||
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
|
||||
// 错误流程(已废弃):未命中写死 manual_dispatch → 强行建人工任务、抢占工单判定。
|
||||
// 正确流程:未命中不选执行器 → 由发单层先判接单模板(命中 → 工单),都不中即「待人工」。
|
||||
assert.equal(selectedExecutorKeyOf(item), '')
|
||||
assert.equal(item.isConfigured, false)
|
||||
assert.equal(item.snapshot.affiliateDash, null)
|
||||
// 引流长标题作为 skuName,内部 SKU 作为 skuCode
|
||||
assert.equal(item.skuCode, '不存在的皮肤')
|
||||
assert.equal(item.skuName, DOUYIN_SKIN_TITLE)
|
||||
assert.equal(item.skuName, '不存在的皮肤')
|
||||
assert.deepEqual(item.snapshot.classification, {
|
||||
channel: 'unmatched',
|
||||
canonicalSku: '不存在的皮肤',
|
||||
matchMode: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('抖音订单:平台闸门只认 douyin_mashida + doudian,其它组合仍不配置', async () => {
|
||||
@@ -194,7 +209,27 @@ test('抖音订单:不触发 affiliate-dash 平台的 sku 透传(严格按
|
||||
item: buildDouyinItem('lucky_coin_x2'),
|
||||
})
|
||||
|
||||
// 英文 sku 不是内部 SKU,抖音侧不做透传匹配,走人工
|
||||
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
|
||||
// 英文 sku 不是内部 SKU,抖音侧不做透传匹配 → 无执行载体
|
||||
assert.equal(selectedExecutorKeyOf(item), '')
|
||||
assert.equal(item.isConfigured, false)
|
||||
assert.equal(item.snapshot.affiliateDash, null)
|
||||
})
|
||||
|
||||
test('91 订单:不写抖音分类字段(历史快照形状与路由行为不变)', async () => {
|
||||
const item = await resolveOrderItemForFulfillment({
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
item: {
|
||||
itemId: '幸运币2个',
|
||||
externalItemId: '幸运币2个',
|
||||
externalSkuCode: '幸运币2个',
|
||||
externalSkuName: '幸运币2个',
|
||||
skuCode: '幸运币2个',
|
||||
skuName: '幸运币2个',
|
||||
quantity: 1,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal('classification' in item.snapshot, false)
|
||||
assert.equal('productTitle' in item.snapshot, false)
|
||||
})
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
getFulfillmentRoutingConfig,
|
||||
normalizeInternalSkuMatchKey,
|
||||
resolveFulfillmentRoute,
|
||||
type FulfillmentRouteResult,
|
||||
} from './routing-config-service.js'
|
||||
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
|
||||
import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js'
|
||||
import {
|
||||
AFFILIATE_DASH_SKU_MAPPING,
|
||||
mergeAffiliateDashSkuMapping,
|
||||
} from '../platforms/affiliate-dash/sku-mapping.js'
|
||||
import { mergeAffiliateDashSkuMapping } from '../platforms/affiliate-dash/sku-mapping.js'
|
||||
import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
|
||||
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
||||
import { normalizeProductMatchName } from '../order/product-match-utils.js'
|
||||
@@ -44,6 +42,20 @@ export type AffiliateDashSkuMatch = {
|
||||
matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough'
|
||||
}
|
||||
|
||||
/**
|
||||
* 抖音按 SKU 分类的结果(写进 `order_items.item_snapshot_json.classification`)。
|
||||
*
|
||||
* 只表达**履约侧**的判定:`affiliate_dash` = 这个单子的执行载体已定为自动发货;
|
||||
* `unmatched` = 没命中自动发货(后续由发单层决定是否派打手,都不中就是人工)。
|
||||
* 发单层据此维护「一个单子只有一个执行载体」的不变式。
|
||||
*/
|
||||
export type FulfillmentItemClassification = {
|
||||
channel: 'affiliate_dash' | 'unmatched'
|
||||
/** 分类键:抖音取内部 SKU(canonical SKU) */
|
||||
canonicalSku: string
|
||||
matchMode: string
|
||||
}
|
||||
|
||||
type FulfillmentItemCandidate = {
|
||||
externalItemId: string
|
||||
externalSkuCode: string
|
||||
@@ -53,6 +65,7 @@ type FulfillmentItemCandidate = {
|
||||
affiliateDashMatch: AffiliateDashSkuMatch | null
|
||||
fulfillmentRoute: FulfillmentRouteResult | null
|
||||
isConfigured: boolean
|
||||
classification?: FulfillmentItemClassification | null
|
||||
}
|
||||
|
||||
export type ResolvedFulfillmentItem = FulfillmentItem & {
|
||||
@@ -83,19 +96,25 @@ export async function resolveOrderItemForFulfillment({
|
||||
externalSkuNameNormalized,
|
||||
affiliateDashMatch,
|
||||
fulfillmentRoute,
|
||||
classification,
|
||||
} = candidate
|
||||
|
||||
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
|
||||
// 抖音:`sku_code` 是**分类键**(内部 SKU),必须稳定,不能被英文 sku 覆写;
|
||||
// 91 保持历史行为(命中映射后用英文 sku 覆盖),避免影响线上 32.9 万单。
|
||||
const isDouyinMashida = isDouyinMashidaOrder(provider, platform)
|
||||
const selectedAffiliateDash =
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH && !isDouyinMashida
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '',
|
||||
selectedAffiliateDash ? affiliateDashMatch?.sku : '',
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
// 运营侧展示用中文商品名;英文 sku 只写在 skuCode。
|
||||
// 抖音:展示用内部 SKU(商品标识),引流长标题另存 snapshot.productTitle。
|
||||
const resolvedSkuName = pickFirstNonEmpty([
|
||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||
? affiliateDashMatch?.productNo || affiliateDashMatch?.skuName
|
||||
: '',
|
||||
selectedAffiliateDash ? affiliateDashMatch?.productNo || affiliateDashMatch?.skuName : '',
|
||||
isDouyinMashida ? externalSkuCode : '',
|
||||
item.skuName,
|
||||
externalSkuName,
|
||||
resolvedSkuCode,
|
||||
@@ -121,6 +140,12 @@ export async function resolveOrderItemForFulfillment({
|
||||
}
|
||||
: null,
|
||||
isConfigured: candidate.isConfigured,
|
||||
// 抖音:引流长标题单独存档(skuName 已改存内部 SKU,标题不能丢)
|
||||
...(isDouyinMashida
|
||||
? { productTitle: pickFirstNonEmpty([item.skuName, externalSkuName]) }
|
||||
: {}),
|
||||
// 抖音:分类结果,供发单层维护「一个单子只有一个执行载体」与后台诊断
|
||||
...(classification ? { classification } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -243,14 +268,20 @@ async function resolveConfiguredItemCandidate({
|
||||
}
|
||||
|
||||
/**
|
||||
* 抖音码速达(抖店)订单分流。
|
||||
* 抖音码速达(抖店)订单分流 —— **按 SKU 分类**。
|
||||
*
|
||||
* 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品),
|
||||
* `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。
|
||||
* 因此分流都按内部 SKU 走:
|
||||
* - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 复用现成的 `affiliate_dash` 通道
|
||||
* (买家输 UID → 二维码 → 扫码绑定 → 比对 UID → 发货完成);
|
||||
* - 未命中 → 显式映射拿不到通道,按现有语义回退 `manual_dispatch`(接单平台/人工)。
|
||||
*
|
||||
* 判定顺序(与 91/快手 同一套框架):
|
||||
* 1. 内部 SKU 命中映射(常量表 + 后台增量)→ 指定 `affiliate_dash`(皮肤/道具自动发货);
|
||||
* 2. 未命中 → **不写死兜底**,交给 `resolveFulfillmentRoute`:商品路由规则 → 全局优先级
|
||||
* → `unmatchedExecutorKey`(生产配置为空串 → 不选执行器 = 不建履约任务);
|
||||
* 3. 都不命中 → 分类为 `unmatched`:由发单层决定是否派打手(命中接单模板 → 建工单),
|
||||
* 两者都不中就是人工(订单进「待人工」队列,判据 = 无任务且无工单)。
|
||||
*
|
||||
* ⚠️ 历史实现(错误流程,已废弃):未命中时**写死** `manual_dispatch`,
|
||||
* 导致未命中单被强行建人工任务(91 是「不建任务」),且抢占了工单层的判定。
|
||||
*/
|
||||
function resolveDouyinMashidaItemCandidate({
|
||||
item,
|
||||
@@ -270,7 +301,6 @@ function resolveDouyinMashidaItemCandidate({
|
||||
// 内部 SKU(平台 code,如「幸运币2个」)→ affiliate-dash 英文 SKU(与商品图同一张权威表)。
|
||||
// 皮肤/礼包类商品的交付**直接复用现成的 affiliate_dash 通道**:
|
||||
// 买家输 UID → 出二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成。
|
||||
// 不另建执行器、不另做一套页面(与旧快手皮肤自动发货完全一致)。
|
||||
const dashSku = matchDouyinMashidaAffiliateDashSku(internalSku)
|
||||
const affiliateDashMatch: AffiliateDashSkuMatch | null = dashSku
|
||||
? {
|
||||
@@ -283,24 +313,22 @@ function resolveDouyinMashidaItemCandidate({
|
||||
const fulfillmentRoute = resolveFulfillmentRoute({
|
||||
productName: productTitle,
|
||||
internalSku,
|
||||
// 命中 → 自动发货;未命中 → **显式**回落人工(接单平台)。
|
||||
// 不依赖全局 `unmatchedExecutorKey`:线上/联调环境该值可能是空串(dev 库就是空串),
|
||||
// 那样未命中的抖音订单会既不建任务、也不进人工,等于静默漏单。
|
||||
preferredExecutorKey: affiliateDashMatch
|
||||
? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||
: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
// 命中映射 = 运营显式意图 → 指定 affiliate_dash;
|
||||
// 未命中**不设 preferred**,让规则/优先级决定。
|
||||
preferredExecutorKey: affiliateDashMatch ? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH : '',
|
||||
candidates: [
|
||||
{
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||
available: Boolean(affiliateDashMatch),
|
||||
reason: affiliateDashMatch ? '' : '内部 SKU 未命中商品映射',
|
||||
},
|
||||
{
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
available: true,
|
||||
reason: '接单平台/人工兜底',
|
||||
},
|
||||
],
|
||||
// ⚠️ 抖音**不允许未命中兜底 `manual_dispatch`**(覆盖配置里的 unmatchedExecutorKey):
|
||||
// 履约判定发生在发单判定**之前**,若这里先建人工任务,命中接单模板的单就会出现
|
||||
// 「人工任务 + 工单」双载体,破坏「一个单子只有一个执行载体」的不变式。
|
||||
// 抖音的「人工」= 无执行载体(无任务无工单)→ 订单进「待人工」队列;
|
||||
// 运营仍可用商品路由规则显式把某个 SKU 指到人工(规则优先于本兜底设置)。
|
||||
config: { ...getFulfillmentRoutingConfig(), unmatchedExecutorKey: '' },
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -313,19 +341,29 @@ function resolveDouyinMashidaItemCandidate({
|
||||
affiliateDashMatch,
|
||||
fulfillmentRoute,
|
||||
isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey),
|
||||
classification: {
|
||||
channel: affiliateDashMatch ? 'affiliate_dash' : 'unmatched',
|
||||
canonicalSku: internalSku,
|
||||
matchMode: affiliateDashMatch?.matchMode || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部 SKU → affiliate-dash 英文 sku:只查权威表(与商品图 `resolveProductImageUrl` 同一套来源),
|
||||
* 保证「能路由到 affiliate-dash 自动发货」与「能取到商品图」永远一致。
|
||||
* 内部 SKU → affiliate-dash 英文 sku。
|
||||
*
|
||||
* 映射来源 = **权威常量表 + 后台增量覆盖**(`affiliate-dash` 配置面板的 `skuMapping`),
|
||||
* 与 91 分支同一套来源:新增抖音商品在后台加一行即可,不必改代码发版。
|
||||
*/
|
||||
export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string {
|
||||
const raw = String(internalSku ?? '').trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
return matchAffiliateDashSku(AFFILIATE_DASH_SKU_MAPPING, raw)
|
||||
return matchAffiliateDashSku(
|
||||
mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping),
|
||||
raw,
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveAffiliateDashSkuByProductNo(
|
||||
|
||||
Reference in New Issue
Block a user