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:
yml2213
2026-10-14 13:00:49 +08:00
parent e8fbb28cf4
commit 4b081d88c5
6 changed files with 599 additions and 81 deletions
@@ -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(