feat(fulfillment): 统一中文内部 SKU 履约路由
This commit is contained in:
@@ -1,34 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isAffiliateDashCarrierSnapshot } from './sync-worker-orders-service.js'
|
||||
import type {
|
||||
InternalSkuChannelPlan,
|
||||
InternalSkuChannelRow,
|
||||
} from '../fulfillment/internal-sku-channel-service.js'
|
||||
import { resolveWorkOrderChannelGate } from './sync-worker-orders-service.js'
|
||||
|
||||
// 载体唯一不变式:一个单子只有一个执行载体。
|
||||
// 命中 affiliate_dash 自动发货的订单项(抖音按 SKU 分类后在 snapshot 标注)不再建工单。
|
||||
function channel(patch: Partial<InternalSkuChannelRow> = {}): InternalSkuChannelRow {
|
||||
return {
|
||||
id: 1,
|
||||
internal_sku: '幸运币2个',
|
||||
channel: 'work_order',
|
||||
priority: 10,
|
||||
is_fallback: false,
|
||||
enabled: true,
|
||||
canonical_product_id: null,
|
||||
config_json: { workRuleKey: 'rule-lucky-coin' },
|
||||
source: 'operator',
|
||||
note: '',
|
||||
created_by_admin_user_id: null,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
test('载体唯一守卫:分类为 affiliate_dash 的订单项不建工单', () => {
|
||||
function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan {
|
||||
return {
|
||||
primary,
|
||||
fallbacks: [],
|
||||
attempts: primary ? [primary] : [],
|
||||
conflicts: [],
|
||||
}
|
||||
}
|
||||
|
||||
test('工单门禁:只有内部 SKU 明确配置 work_order 且提供模板才放行', () => {
|
||||
assert.deepEqual(resolveWorkOrderChannelGate(plan(channel())), {
|
||||
allowed: true,
|
||||
reason: '',
|
||||
workRuleKey: 'rule-lucky-coin',
|
||||
})
|
||||
})
|
||||
|
||||
test('工单门禁:其它统一渠道一律不放行', () => {
|
||||
for (const targetChannel of ['affiliate_dash', 'manual', 'third_party_voucher'] as const) {
|
||||
assert.equal(
|
||||
resolveWorkOrderChannelGate(plan(channel({ channel: targetChannel, config_json: {} })))
|
||||
.reason,
|
||||
`internal_sku_channel_${targetChannel}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('工单门禁:无配置、冲突、缺模板、模板不一致都不放行', () => {
|
||||
assert.equal(resolveWorkOrderChannelGate(undefined).reason, 'no_channel_capability')
|
||||
assert.equal(
|
||||
isAffiliateDashCarrierSnapshot({ classification: { channel: 'affiliate_dash' } }),
|
||||
true,
|
||||
resolveWorkOrderChannelGate({
|
||||
primary: null,
|
||||
fallbacks: [],
|
||||
attempts: [],
|
||||
conflicts: [channel()],
|
||||
}).reason,
|
||||
'routing_conflict',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderChannelGate(plan(channel({ config_json: {} }))).reason,
|
||||
'work_rule_missing',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderChannelGate(plan(channel()), 'another-rule').reason,
|
||||
'work_rule_mismatch',
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getLatestWorkProductMatchLogByOrderItemId,
|
||||
getWorkOrderByOrderItemId,
|
||||
listWorkOrdersByPlatformOrderId,
|
||||
listWorkProductRuleMappings,
|
||||
listWorkProductRules,
|
||||
type WorkOrderRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
@@ -38,14 +37,48 @@ import {
|
||||
mapWorkOrderAdmin,
|
||||
normalizeRequirementFields,
|
||||
resolveKuaishouWorkProductMatchContext,
|
||||
resolveMatchingProductRuleDecision,
|
||||
resolveSkuNameQuantity,
|
||||
resolveWorkOrderRulePricing,
|
||||
resolveWorkOrderSharingEnabled,
|
||||
} from './mappers.js'
|
||||
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
|
||||
import { registerWorkOrderShopName } from './work-order-shop-options-cache.js'
|
||||
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
|
||||
import {
|
||||
loadInternalSkuChannelPlans,
|
||||
type InternalSkuChannelPlan,
|
||||
} from '../fulfillment/internal-sku-channel-service.js'
|
||||
import {
|
||||
canonicalizeInternalSku,
|
||||
resolveInternalSkuKey,
|
||||
} from '../fulfillment/internal-sku-resolver.js'
|
||||
|
||||
export function resolveWorkOrderChannelGate(
|
||||
plan: InternalSkuChannelPlan | undefined,
|
||||
preferredWorkRuleKey?: string,
|
||||
): { allowed: boolean; reason: string; workRuleKey: string } {
|
||||
if (!plan?.primary) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: plan?.conflicts.length ? 'routing_conflict' : 'no_channel_capability',
|
||||
workRuleKey: '',
|
||||
}
|
||||
}
|
||||
if (plan.primary.channel !== 'work_order') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `internal_sku_channel_${plan.primary.channel}`,
|
||||
workRuleKey: '',
|
||||
}
|
||||
}
|
||||
const configuredWorkRuleKey = String(plan.primary.config_json.workRuleKey || '').trim()
|
||||
if (!configuredWorkRuleKey) {
|
||||
return { allowed: false, reason: 'work_rule_missing', workRuleKey: '' }
|
||||
}
|
||||
if (preferredWorkRuleKey && String(preferredWorkRuleKey).trim() !== configuredWorkRuleKey) {
|
||||
return { allowed: false, reason: 'work_rule_mismatch', workRuleKey: '' }
|
||||
}
|
||||
return { allowed: true, reason: '', workRuleKey: configuredWorkRuleKey }
|
||||
}
|
||||
|
||||
export async function syncWorkerOrdersForSourceOrder(
|
||||
order: OrderRow,
|
||||
@@ -58,59 +91,76 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
preferredWorkRuleKey?: string
|
||||
} = {},
|
||||
) {
|
||||
const [rules, mappings] = await Promise.all([
|
||||
listWorkProductRules({ enabled: true }),
|
||||
listWorkProductRuleMappings({ enabled: true }),
|
||||
])
|
||||
const productMatchConfig = getWorkerProductMatchConfig()
|
||||
const rules = await listWorkProductRules({ enabled: true })
|
||||
const created: WorkOrderRow[] = []
|
||||
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
||||
const decisions: Array<Record<string, unknown>> = []
|
||||
const internalSkuByItemId = new Map<number, string>()
|
||||
for (const item of orderItems) {
|
||||
internalSkuByItemId.set(
|
||||
Number(item.id),
|
||||
resolveInternalSkuKey({
|
||||
manualSkuKey: item.manual_sku_key,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
spec: safeParseJson(item.spec_json),
|
||||
snapshot: safeParseJson(item.item_snapshot_json),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()])
|
||||
|
||||
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' })
|
||||
const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id)))
|
||||
const channelPlan = channelPlans.get(internalSku)
|
||||
if (!internalSku) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'no_internal_sku' })
|
||||
decisions.push(
|
||||
buildWorkerConsumerDecision(item, {
|
||||
result: 'skipped',
|
||||
reason: 'affiliate_dash_carrier_taken',
|
||||
reason: 'no_internal_sku',
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey)
|
||||
if (!channelGate.allowed) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason })
|
||||
decisions.push(
|
||||
buildWorkerConsumerDecision(item, {
|
||||
result: 'skipped',
|
||||
reason: channelGate.reason,
|
||||
internalSku,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const preferredRuleKey = String(options.preferredWorkRuleKey || '').trim()
|
||||
const preferredRuleKey = channelGate.workRuleKey
|
||||
const preferredRule = preferredRuleKey
|
||||
? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null
|
||||
: null
|
||||
const match =
|
||||
preferredRuleKey && !preferredRule
|
||||
? {
|
||||
rule: null,
|
||||
mappingId: null,
|
||||
reason: 'unmatched' as const,
|
||||
score: 0,
|
||||
candidates: [],
|
||||
diagnosis: [
|
||||
{
|
||||
dimension: 'internal_sku_channel',
|
||||
reason: 'configured_work_rule_missing',
|
||||
},
|
||||
],
|
||||
}
|
||||
: preferredRule
|
||||
? {
|
||||
rule: preferredRule,
|
||||
mappingId: null,
|
||||
reason: 'matched' as const,
|
||||
score: 1,
|
||||
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
|
||||
}
|
||||
: resolveMatchingProductRuleDecision(order, item, rules, mappings, {
|
||||
legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled,
|
||||
})
|
||||
const match = !preferredRule
|
||||
? {
|
||||
rule: null,
|
||||
mappingId: null,
|
||||
reason: 'unmatched' as const,
|
||||
score: 0,
|
||||
candidates: [],
|
||||
diagnosis: [
|
||||
{
|
||||
dimension: 'internal_sku_channel',
|
||||
reason: 'configured_work_rule_missing',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
rule: preferredRule,
|
||||
mappingId: null,
|
||||
reason: 'matched' as const,
|
||||
score: 1,
|
||||
candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }],
|
||||
}
|
||||
const matchContext = resolveKuaishouWorkProductMatchContext(item)
|
||||
const snapshot = safeParseJson(item.item_snapshot_json)
|
||||
const sellerId =
|
||||
@@ -176,7 +226,7 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
})
|
||||
}
|
||||
if (!match.rule) {
|
||||
const reason = match.reason === 'ambiguous' ? 'rule_match_ambiguous' : 'rule_not_matched'
|
||||
const reason = 'work_rule_not_found'
|
||||
skipped.push({
|
||||
orderItemId: Number(item.id),
|
||||
reason,
|
||||
@@ -435,26 +485,6 @@ async function resolveDouyinMashidaWorkOrderVoucher(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 该订单项的执行载体是否**已被 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