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:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user