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
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isAffiliateDashCarrierSnapshot } from './sync-worker-orders-service.js'
// 载体唯一不变式:一个单子只有一个执行载体。
// 命中 affiliate_dash 自动发货的订单项(抖音按 SKU 分类后在 snapshot 标注)不再建工单。
test('载体唯一守卫:分类为 affiliate_dash 的订单项不建工单', () => {
assert.equal(
isAffiliateDashCarrierSnapshot({ classification: { channel: 'affiliate_dash' } }),
true,
)
})
test('载体唯一守卫:多余的空白/大小写仍然命中(保守判定)', () => {
assert.equal(
isAffiliateDashCarrierSnapshot({ classification: { channel: ' affiliate_dash ' } }),
true,
)
})
test('载体唯一守卫:未命中分类 / 无字段 / 脏数据一律放行(不误伤发单)', () => {
assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: 'unmatched' } }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: '' } }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: {} }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: 'affiliate_dash' }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: null }), false)
assert.equal(isAffiliateDashCarrierSnapshot({ classification: ['affiliate_dash'] }), false)
assert.equal(isAffiliateDashCarrierSnapshot({}), false)
assert.equal(isAffiliateDashCarrierSnapshot(null), false)
assert.equal(isAffiliateDashCarrierSnapshot(undefined), false)
assert.equal(isAffiliateDashCarrierSnapshot('affiliate_dash'), false)
})
@@ -16,6 +16,7 @@ import {
listWorkProductRules,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
import { findRedeemCertificateByOrder } from '../../repositories/redeem-certificate-repo.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { generateWorkOrderNo, randomId } from '../../utils/random.js'
@@ -23,6 +24,16 @@ import { nowIso } from '../../utils/time.js'
import { logDebug, logInfo, logWarn } from '../../utils/logger.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { createWorkOrderMaterialAdminNotification } from '../admin/admin-notification-service.js'
import {
DOUYIN_MASHIDA_PROVIDER,
extractDouyinMashidaSubOrderId,
isDouyinMashidaProvider,
} from '../platforms/douyin-mashida/order-mapper.js'
import {
buildDouyinMashidaWorkOrderVoucher,
mergeDouyinMashidaVoucherIntoMaterial,
type DouyinMashidaWorkOrderVoucher,
} from '../redeem/certificate-service.js'
import {
mapWorkOrderAdmin,
normalizeRequirementFields,
@@ -49,6 +60,19 @@ export async function syncWorkerOrdersForSourceOrder(
const decisions: Array<Record<string, unknown>> = []
for (const item of orderItems) {
// 载体唯一不变式(一个单子只有一个执行载体):
// 命中 affiliate_dash 自动发货的订单项(抖音在 snapshot.classification 标注)不再建工单。
if (isAffiliateDashCarrierSnapshot(safeParseJson(item.item_snapshot_json))) {
skipped.push({ orderItemId: Number(item.id), reason: 'affiliate_dash_carrier_taken' })
decisions.push(
buildWorkerConsumerDecision(item, {
result: 'skipped',
reason: 'affiliate_dash_carrier_taken',
}),
)
continue
}
const match = resolveMatchingProductRuleDecision(order, item, rules, mappings, {
legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled,
})
@@ -182,6 +206,31 @@ export async function syncWorkerOrdersForSourceOrder(
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
const now = nowIso()
const materialComplete = fields.every((field) => !field.required)
// 工单素材 = source 摘要 +(抖音)该单已有的券快照:打手要能看到「交付什么券」。
// 券通常在发券后回填(certificate-service),这里覆盖「运营补模板后重新匹配」的建单路径。
let material: JsonObject = {
source: {
orderId: Number(order.id),
orderItemId: Number(item.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
skuCode: item.sku_code,
skuName: item.sku_name,
skuQuantity,
quantity: Number(item.quantity || 1),
orderAmountFen: Number(order.total_amount || 0),
currency: order.currency || 'CNY',
syncSource: options.source || 'source_order',
...options.sourceMetadata,
},
}
const voucher = await resolveDouyinMashidaWorkOrderVoucher(order, item)
if (voucher) {
material = mergeDouyinMashidaVoucherIntoMaterial(material, voucher).material
}
const workOrder = await createWorkOrder({
workOrderNo: generateWorkOrderNo(),
productRuleId: Number(rule.id),
@@ -203,24 +252,7 @@ export async function syncWorkerOrdersForSourceOrder(
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
giftCooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(rule.gift_cooldown_minutes),
materialJson: JSON.stringify({
source: {
orderId: Number(order.id),
orderItemId: Number(item.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
skuCode: item.sku_code,
skuName: item.sku_name,
skuQuantity,
quantity: Number(item.quantity || 1),
orderAmountFen: Number(order.total_amount || 0),
currency: order.currency || 'CNY',
syncSource: options.source || 'source_order',
...options.sourceMetadata,
},
}),
materialJson: JSON.stringify(material),
requirementJson: JSON.stringify({ fields }),
now,
})
@@ -307,6 +339,70 @@ export async function syncWorkerOrdersForSourceOrder(
}
}
/**
* 抖音单在建工单时,把该订单项**已生成**的券快照取出来(用于写进工单素材)。
*
* 正常顺序是「先建工单 → 再发券」,券由 `certificate-service` 回填;
* 但运营补完接单模板后「重新匹配」会**先有券、后建工单**,这条路径靠本函数补上。
* 非抖音订单、查不到券、查询失败一律返回 null(不阻塞发单)。
*/
async function resolveDouyinMashidaWorkOrderVoucher(
order: OrderRow,
item: OrderItemRow,
): Promise<DouyinMashidaWorkOrderVoucher | null> {
if (!isDouyinMashidaProvider(order.provider)) {
return null
}
const receiptOrderId = String(
extractDouyinMashidaSubOrderId(item.item_snapshot_json) || order.platform_order_id || '',
).trim()
if (!receiptOrderId) {
return null
}
try {
const certificate = await findRedeemCertificateByOrder({
provider: DOUYIN_MASHIDA_PROVIDER,
orderId: receiptOrderId,
})
if (!certificate) {
return null
}
const voucher = buildDouyinMashidaWorkOrderVoucher(certificate)
return voucher.certNo ? voucher : null
} catch (error) {
logWarn('[worker-order-consumer]', '读取抖音券失败(工单素材不含券,不阻塞建单)', {
orderId: order.id,
orderItemId: item.id,
receiptOrderId,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
/**
* 该订单项的执行载体是否**已被 affiliate_dash 自动发货占用**。
*
* 依据:抖音按 SKU 分类后写入的 `snapshot.classification.channel === 'affiliate_dash'`
* (见 `fulfillment/product-resolution-service.ts`)。
* 一个单子只能有一个执行载体:自动发货与派打手互斥,命中前者时不再建工单。
*/
export function isAffiliateDashCarrierSnapshot(snapshot: unknown): boolean {
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
return false
}
const classification = (snapshot as { classification?: unknown }).classification
if (!classification || typeof classification !== 'object' || Array.isArray(classification)) {
return false
}
return String((classification as { channel?: unknown }).channel || '').trim() === 'affiliate_dash'
}
function buildWorkerConsumerDecision(
item: OrderItemRow,
detail: Record<string, unknown>,