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(
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
resolveRedeemVerifyCertNo,
|
||||
type RedeemCertificateRow,
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import {
|
||||
createWorkOrderEvent,
|
||||
getWorkOrderByOrderItemId,
|
||||
updateWorkOrder,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { isPlainObject, type JsonObject } from '../../types/json.js'
|
||||
|
||||
import { resolveOrderItemClaimLink } from '../claim/claim-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -24,9 +30,14 @@ import {
|
||||
syncDouyinMashidaCoupons,
|
||||
verifyDouyinMashidaCoupon,
|
||||
} from '../platforms/douyin-mashida/gateway-client.js'
|
||||
import {
|
||||
buildDouyinMashidaBuyerClaimUrl,
|
||||
isDouyinMashidaH5ClaimBase,
|
||||
} from '../platforms/douyin-mashida/claim-link.js'
|
||||
import {
|
||||
DOUYIN_MASHIDA_PROVIDER,
|
||||
extractDouyinMashidaSubOrderId,
|
||||
isDouyinMashidaProvider,
|
||||
} from '../platforms/douyin-mashida/order-mapper.js'
|
||||
import {
|
||||
DEFAULT_AFTER_VERIFY_MESSAGE,
|
||||
@@ -116,23 +127,188 @@ export async function ensureCertificateForOrderItem(input: {
|
||||
}
|
||||
|
||||
if (certificate.sync_status === 'success' || certificate.status === 'invalid') {
|
||||
await attachDouyinMashidaCertificateToWorkOrder({
|
||||
order: input.order,
|
||||
orderItem: input.orderItem,
|
||||
certificate,
|
||||
})
|
||||
return certificate
|
||||
}
|
||||
|
||||
return syncCertificateToPlatform(certificate, config, certLink)
|
||||
const syncedCertificate = await syncCertificateToPlatform(certificate, config, certLink)
|
||||
await attachDouyinMashidaCertificateToWorkOrder({
|
||||
order: input.order,
|
||||
orderItem: input.orderItem,
|
||||
certificate: syncedCertificate,
|
||||
})
|
||||
return syncedCertificate
|
||||
}
|
||||
|
||||
/** 工单 material_json 里承载抖音券的键(与快手 `kuaishouIndustryVoucher` 同位)。 */
|
||||
export const DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY = 'douyinMashidaVoucher'
|
||||
|
||||
export type DouyinMashidaWorkOrderVoucher = {
|
||||
certNo: string
|
||||
platformCertNo: string
|
||||
certLink: string
|
||||
status: string
|
||||
syncStatus: string
|
||||
grantTime: string
|
||||
}
|
||||
|
||||
/** 券行 → 工单侧券快照(纯函数,回填与建单两处共用,避免口径漂移)。 */
|
||||
export function buildDouyinMashidaWorkOrderVoucher(
|
||||
certificate: Pick<
|
||||
RedeemCertificateRow,
|
||||
'cert_no' | 'platform_cert_no' | 'cert_link' | 'status' | 'sync_status' | 'grant_time'
|
||||
>,
|
||||
): DouyinMashidaWorkOrderVoucher {
|
||||
return {
|
||||
certNo: String(certificate.cert_no || '').trim(),
|
||||
platformCertNo: String(certificate.platform_cert_no || '').trim(),
|
||||
certLink: String(certificate.cert_link || '').trim(),
|
||||
status: String(certificate.status || '').trim(),
|
||||
syncStatus: String(certificate.sync_status || '').trim(),
|
||||
grantTime: String(certificate.grant_time || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把抖音券并入工单 `material_json`(对齐快手把电子凭证挂工单的做法)。
|
||||
*
|
||||
* 打手拿到的工单必须知道「交付什么券」,因此券一旦生成/同步就把它的快照写到工单上:
|
||||
* `material_json.douyinMashidaVoucher = [{ certNo, platformCertNo, certLink, status, syncStatus, grantTime }]`。
|
||||
*
|
||||
* 幂等:按 `certNo` 合并,同券内容不变时返回 `changed: false`(调用方据此跳过写库/写事件)。
|
||||
* 纯函数,便于单测。
|
||||
*/
|
||||
export function mergeDouyinMashidaVoucherIntoMaterial(
|
||||
material: unknown,
|
||||
voucher: DouyinMashidaWorkOrderVoucher,
|
||||
): { material: JsonObject; changed: boolean } {
|
||||
const source = isPlainObject(material) ? { ...material } : {}
|
||||
const existingRows = Array.isArray(source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY])
|
||||
? (source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY] as unknown[])
|
||||
: []
|
||||
const existingVouchers = existingRows.filter(isPlainObject)
|
||||
const current = existingVouchers.find((row) => String(row.certNo || '').trim() === voucher.certNo)
|
||||
|
||||
if (current && isSameDouyinMashidaVoucher(current, voucher)) {
|
||||
return { material: source, changed: false }
|
||||
}
|
||||
|
||||
source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY] = [
|
||||
...existingVouchers.filter((row) => String(row.certNo || '').trim() !== voucher.certNo),
|
||||
{ ...voucher },
|
||||
]
|
||||
|
||||
return { material: source, changed: true }
|
||||
}
|
||||
|
||||
function isSameDouyinMashidaVoucher(
|
||||
current: JsonObject,
|
||||
voucher: DouyinMashidaWorkOrderVoucher,
|
||||
): boolean {
|
||||
return (
|
||||
String(current.platformCertNo || '') === voucher.platformCertNo &&
|
||||
String(current.certLink || '') === voucher.certLink &&
|
||||
String(current.status || '') === voucher.status &&
|
||||
String(current.syncStatus || '') === voucher.syncStatus
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 券生成/同步后回填工单 `material_json`(仅抖音;该订单项没有工单时静默跳过 = 正常情况)。
|
||||
*
|
||||
* best-effort:任何失败只记 warn,绝不影响发券主流程。
|
||||
*/
|
||||
async function attachDouyinMashidaCertificateToWorkOrder(input: {
|
||||
order: OrderRow
|
||||
orderItem: OrderItemRow
|
||||
certificate: RedeemCertificateRow
|
||||
}): Promise<void> {
|
||||
if (!isDouyinMashidaProvider(input.order.provider)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const workOrder = await getWorkOrderByOrderItemId(input.orderItem.id)
|
||||
if (!workOrder) {
|
||||
// 命中自动发货的订单项没有工单(载体唯一),属正常路径
|
||||
return
|
||||
}
|
||||
|
||||
const voucher = buildDouyinMashidaWorkOrderVoucher(input.certificate)
|
||||
if (!voucher.certNo) {
|
||||
return
|
||||
}
|
||||
|
||||
const merged = mergeDouyinMashidaVoucherIntoMaterial(workOrder.material_json, voucher)
|
||||
if (!merged.changed) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
material_json: JSON.stringify(merged.material),
|
||||
updated_at: now,
|
||||
})
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(workOrder.id),
|
||||
actorType: 'system',
|
||||
actorId: DOUYIN_MASHIDA_PROVIDER,
|
||||
eventType: 'douyin_voucher_backfilled',
|
||||
fromStatus: String(workOrder.status || ''),
|
||||
toStatus: String(updated.status || workOrder.status || ''),
|
||||
payloadJson: JSON.stringify({
|
||||
certNo: voucher.certNo,
|
||||
platformCertNo: voucher.platformCertNo,
|
||||
syncStatus: voucher.syncStatus,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
logIntegration('[douyin-mashida/certificate]', '券信息已回填工单 material_json', {
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
orderItemId: input.orderItem.id,
|
||||
certNo: voucher.certNo,
|
||||
})
|
||||
} catch (error) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'券信息回填工单失败(不影响发券)',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本次同步要交给平台的 `cert_link`。
|
||||
*
|
||||
* 优先级(**只对新单生效**:已同步成功的券不会再改动):
|
||||
* 1. 该订单项已有履约任务 claim token,且配置了 `claimLinkBase`
|
||||
* → `claimLinkBase/token`(买家领链接);
|
||||
* 2. 其它情况 → `config.certLink`(固定兑换链接,历史行为完全不变)。
|
||||
* 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定:
|
||||
* - `/h5` 形态基址 → `claimLinkBase/<订单号>`(买家落到自己订单的状态页,自动发货/人工处理中都合理);
|
||||
* - 其它形态基址 → 只有该订单项确实有自动发货领取单元(claim token)时才给 token 领取链接;
|
||||
* - 拿不到可用链接 → 回退固定 `config.certLink`。
|
||||
*
|
||||
* 历史缺陷:`claimLinkBase` 以 `/h5` 结尾时走了一条捷径——**无条件**下拼订单号,
|
||||
* 但那只是「拼法」问题;真正的缺陷在私信侧(无 token 也发「已发货」,见 notify-service,
|
||||
* 已用同一个纯函数修好)。这里保留 `/h5` 按订单号拼的行为,但**不再让链接依赖 token**。
|
||||
*
|
||||
* 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key):
|
||||
* 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。
|
||||
* 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。
|
||||
*
|
||||
* ⚠️ 只对新单生效:已同步成功的券不会被改写。
|
||||
*/
|
||||
export async function resolveDouyinMashidaCertLink(input: {
|
||||
order: OrderRow
|
||||
@@ -147,36 +323,65 @@ export async function resolveDouyinMashidaCertLink(input: {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
if (claimLinkBase.endsWith('/h5') || claimLinkBase.includes('/h5/')) {
|
||||
return `${claimLinkBase}/${orderNo}`
|
||||
}
|
||||
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
})
|
||||
|
||||
if (!claimLink.claimUrl) {
|
||||
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
|
||||
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
|
||||
if (claimLink.taskCount > 0) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
taskCount: claimLink.taskCount,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
|
||||
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
|
||||
if (!claimLink.taskId && claimLink.taskCount > 0) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
taskCount: claimLink.taskCount,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
|
||||
return resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: fallback,
|
||||
claimLinkBase,
|
||||
orderNo: resolveReceiptOrderId(input.order, input.orderItem),
|
||||
tokenClaimUrl: claimLink.claimUrl,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `cert_link` 的**纯函数**判定(便于单测)。
|
||||
*
|
||||
* 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址:
|
||||
* - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页);
|
||||
* - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。
|
||||
*/
|
||||
export function resolveDouyinMashidaCertLinkFromClaim(input: {
|
||||
certLink: unknown
|
||||
claimLinkBase: unknown
|
||||
orderNo: unknown
|
||||
tokenClaimUrl?: unknown
|
||||
}): string {
|
||||
const fallback = String(input.certLink ?? '').trim()
|
||||
const base = String(input.claimLinkBase ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return claimLink.claimUrl
|
||||
const orderNoLink = isDouyinMashidaH5ClaimBase(base)
|
||||
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
|
||||
claimLinkBase: base,
|
||||
orderNo: input.orderNo,
|
||||
hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()),
|
||||
tokenClaimUrl: input.tokenClaimUrl,
|
||||
})
|
||||
|
||||
return claimUrl || fallback
|
||||
}
|
||||
|
||||
/** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
buildAfterVerifyMessage,
|
||||
formatGrantTime,
|
||||
isPlatformCouponVerified,
|
||||
mergeDouyinMashidaVoucherIntoMaterial,
|
||||
pickPlatformCouponRow,
|
||||
resolveDouyinMashidaCertLinkFromClaim,
|
||||
} from './certificate-service.js'
|
||||
import { maskCertNo, normalizeOrderNo } from './redeem-service.js'
|
||||
|
||||
@@ -126,3 +128,111 @@ test('平台侧是否已核销:status=3 或 verify_num>0', () => {
|
||||
assert.equal(isPlatformCouponVerified({ status: 2, verify_num: 0 }), false)
|
||||
assert.equal(isPlatformCouponVerified(null), false)
|
||||
})
|
||||
|
||||
// ---- 抖音 cert_link:券对所有订单都发,链接按基址形态决定 ----
|
||||
|
||||
test('抖音 cert_link:/h5 形态基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:/h5 基址 + 有 token → 仍按订单号拼(H5 页认订单号)', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/h5/',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/h5/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/h5/6930318258716179555',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:token 形态基址 + 有 token → 用 token 领取链接', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/claim/token-abc',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:token 形态基址 + 无 token(人工单 / 工单单)→ 回退固定兑换链接', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: 'https://ks.khhao.com/claim',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: '',
|
||||
}),
|
||||
'https://ks.khhao.com/redeem',
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音 cert_link:未配 claimLinkBase 时回退固定链接(不报错)', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaCertLinkFromClaim({
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
claimLinkBase: '',
|
||||
orderNo: '6930318258716179555',
|
||||
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
|
||||
}),
|
||||
'https://ks.khhao.com/redeem',
|
||||
)
|
||||
})
|
||||
|
||||
// ---- 抖音券回填工单 material_json(对齐快手把电子凭证挂工单) ----
|
||||
|
||||
const WORK_ORDER_VOUCHER = {
|
||||
certNo: 'DYC1234567890ABCD',
|
||||
platformCertNo: '',
|
||||
certLink: 'https://ks.khhao.com/h5/6930318258716179555',
|
||||
status: 'synced',
|
||||
syncStatus: 'success',
|
||||
grantTime: '2026-10-14 12:00:00',
|
||||
}
|
||||
|
||||
test('抖音券回填工单:material 为空时新建 douyinMashidaVoucher 数组(保留原有 source)', () => {
|
||||
const merged = mergeDouyinMashidaVoucherIntoMaterial(
|
||||
{ source: { skuCode: '套装-牛仔情诗' } },
|
||||
WORK_ORDER_VOUCHER,
|
||||
)
|
||||
assert.equal(merged.changed, true)
|
||||
assert.deepEqual(merged.material.source, { skuCode: '套装-牛仔情诗' })
|
||||
assert.deepEqual(merged.material.douyinMashidaVoucher, [WORK_ORDER_VOUCHER])
|
||||
})
|
||||
|
||||
test('抖音券回填工单:同券内容不变 → changed=false(幂等,不重复写库/写事件)', () => {
|
||||
const first = mergeDouyinMashidaVoucherIntoMaterial({}, WORK_ORDER_VOUCHER)
|
||||
const second = mergeDouyinMashidaVoucherIntoMaterial(first.material, WORK_ORDER_VOUCHER)
|
||||
assert.equal(second.changed, false)
|
||||
assert.deepEqual(second.material.douyinMashidaVoucher, [WORK_ORDER_VOUCHER])
|
||||
})
|
||||
|
||||
test('抖音券回填工单:平台券码变化 → 就地更新该券(不新增重复行)', () => {
|
||||
const first = mergeDouyinMashidaVoucherIntoMaterial({}, WORK_ORDER_VOUCHER)
|
||||
const merged = mergeDouyinMashidaVoucherIntoMaterial(first.material, {
|
||||
...WORK_ORDER_VOUCHER,
|
||||
platformCertNo: 'PLATFORM-9876',
|
||||
})
|
||||
assert.equal(merged.changed, true)
|
||||
const rows = merged.material.douyinMashidaVoucher as Array<Record<string, unknown>>
|
||||
assert.equal(rows.length, 1)
|
||||
assert.equal(rows[0]?.platformCertNo, 'PLATFORM-9876')
|
||||
})
|
||||
|
||||
test('抖音券回填工单:material 是脏数据(字符串)时不抛错', () => {
|
||||
const merged = mergeDouyinMashidaVoucherIntoMaterial('not-an-object', WORK_ORDER_VOUCHER)
|
||||
assert.equal(merged.changed, true)
|
||||
assert.deepEqual(merged.material.douyinMashidaVoucher, [WORK_ORDER_VOUCHER])
|
||||
})
|
||||
|
||||
@@ -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