refactor(product-match): 抽离接单模板匹配引擎(来源适配器/引擎/价格收口 + 后台按订单项试匹配)

新增 services/product-match:来源适配器(kuaishou-91 合成 91+快手 / pdd / douyin-mashida)+ 平台无关匹配上下文 + 条件打分引擎(并列最高=ambiguous 不猜、逐维诊断)+ 价格数量拼单唯一实现。

- 商店键命名空间化(kuaishou:/pdd:/douyin:),店铺为可选作用域:留空即可按订单信息匹配
- 抖音匹配文本只取内部 SKU,引流标题只进 promoTitle,itemTitle 恒空(修复 0.85 元误命中碎梦浮光珍宝礼盒、拼单未开)
- work-product-match-mappers 改为门面(签名不变),work-order-pricing 改为转发
- 新增后台「按订单项试匹配」接口与面板:走发单同一套链路,返回模板/数量/单价/总额/拼单
- 生产样本回放:91/PDD 6000 条决策 0 差异;抖音 5 条全为修正

设计见 docs/接单模板匹配引擎重构设计.md
This commit is contained in:
yml2213
2026-10-14 15:58:21 +08:00
parent 4b081d88c5
commit 10464fea89
26 changed files with 3218 additions and 477 deletions
@@ -0,0 +1,136 @@
/**
* 后台「按订单项试匹配」:用生产同一套链路(来源适配器 → 引擎 → 价格)重放一张已存在的订单项,
* 直接给出命中模板、数量、单价、接单总额与拼单结果,用于上线前验证与排障。
*
* 与旧的 `product-match-test`(按粘贴的原始报文自行推导字段)不同:这里读真实 `orders` / `order_items`,
* 因此抖音、拼多多、91+快手 都走各自适配器,结果与发单侧完全一致。
*/
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
import { getOrderItemById } from '../../repositories/order-item-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import {
listWorkProductRuleMappings,
listWorkProductRules,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import {
buildProductMatchContext,
compileLegacyMatchEntries,
isTextFallbackAllowed,
resolveProductMatchDecision,
resolveWorkOrderQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled,
} from '../product-match/index.js'
import { mapWorkProductRule } from './work-product-match-mappers.js'
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
export async function testAdminProductMatchByOrderItem(payload: JsonObject = {}) {
const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id)
if (!orderItemId) {
throw createHttpError('缺少订单项 ID', {
statusCode: 400,
errorCode: 'order_item_id_required',
})
}
const item = await getOrderItemById(orderItemId)
if (!item) {
throw createHttpError('订单项不存在', {
statusCode: 404,
errorCode: 'order_item_not_found',
})
}
const order = await getOrderById(item.order_id)
if (!order) {
throw createHttpError('订单不存在', { statusCode: 404, errorCode: 'order_not_found' })
}
const [rules, mappings] = await Promise.all([
listWorkProductRules({ enabled: true }),
listWorkProductRuleMappings({ enabled: true }),
])
const config = getWorkerProductMatchConfig()
const context = buildProductMatchContext(order, item)
const allowTextFallback = isTextFallbackAllowed({
order,
hasSellerContext: context.hasSellerContext,
legacyFallbackEnabled: config.legacyFallbackEnabled,
})
const entries = compileLegacyMatchEntries(rules, mappings, { allowTextFallback })
const decision = resolveProductMatchDecision(context, entries)
return {
order: {
id: Number(order.id),
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
platformOrderId: order.platform_order_id,
totalAmount: Number(order.total_amount || 0),
},
orderItem: {
id: Number(item.id),
skuCode: item.sku_code,
skuName: item.sku_name,
quantity: Number(item.quantity || 1),
matchSkuTexts: context.item.skuTexts,
},
sourceKey: context.sourceKey,
hasSellerContext: context.hasSellerContext,
allowTextFallback,
shopKeys: context.shopKeys,
status: decision.status,
rule: decision.rule ? mapWorkProductRule(decision.rule) : null,
mappingId: decision.mappingId,
entryKey: decision.entryKey,
score: decision.score,
candidates: decision.candidates,
diagnosis: decision.diagnosis,
plan: decision.rule ? buildWorkOrderPlan(decision.rule, item) : null,
counts: { enabledRules: rules.length, enabledMappings: mappings.length },
}
}
/** 与发单侧同口径的工单计划:数量 / 单价 / 接单总额 / 拼单。 */
function buildWorkOrderPlan(
rule: Awaited<ReturnType<typeof listWorkProductRules>>[number],
item: { sku_code: string; sku_name: string; quantity: number },
) {
const skuQuantity = resolveWorkOrderQuantity({
skuText: item.sku_name || item.sku_code,
fallbackQuantity: item.quantity,
})
const unitPriceFen = Number(rule.unit_price_fen || 0)
const pricing = resolveWorkOrderRulePricing({
skuQuantity,
unitPriceFen,
fixedRewardAmount: Number(rule.reward_amount || 0),
sharingEnabled: rule.sharing_enabled === true,
sharingAutoFromOrder: rule.sharing_auto_from_order === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
})
const sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode)
? false
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, pricing.sharingTotalQuantity)
return {
skuQuantity,
unitPriceFen,
pricingMode: unitPriceFen > 0 ? 'unit' : 'fixed',
rewardAmount: pricing.rewardAmount,
sharingEnabled,
sharingTotalQuantity: sharingEnabled ? pricing.sharingTotalQuantity : 1,
sharingUnitReward: sharingEnabled ? pricing.sharingUnitReward : 0,
acceptanceMode: isFriendGiftAcceptanceMode(rule.acceptance_mode) ? 'friend_gift' : 'standard',
}
}
function normalizeOptionalId(value: unknown): number | null {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0) return null
return parsed
}
@@ -26,6 +26,7 @@ export * from './admin-worker-finance-service.js'
export * from './admin-worker-catalog-service.js'
export * from './admin-worker-leaderboard-service.js'
export * from './admin-kuaishou-match-service.js'
export * from './admin-product-match-test-service.js'
export * from './admin-worker-config-service.js'
export * from './admin-worker-user-service.js'
export * from './admin-worker-risk-service.js'
@@ -1,96 +1,11 @@
export function resolveSkuNameQuantity(productName: unknown): number {
const text = String(productName || '').trim()
if (!text) return 1
const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/)
if (arabicMatch) {
const parsed = Number(arabicMatch[1])
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed
}
const chineseMatch = text.match(/([零一二三四五六七八九十百千两]+)\s*(?:个|份|张|枚)/)
if (chineseMatch?.[1]) {
const parsed = parseChineseNumber(chineseMatch[1])
if (parsed > 0) return parsed
}
return 1
}
export function resolveWorkOrderRulePricing(input: {
skuQuantity: number
unitPriceFen: number
fixedRewardAmount: number
sharingEnabled: boolean
sharingAutoFromOrder: boolean
sharingTotalQuantity: number
sharingUnitReward: number
}) {
const skuQuantity = Math.max(1, Number(input.skuQuantity) || 1)
const unitPriceFen = Math.max(0, Number(input.unitPriceFen) || 0)
const sharingTotalQuantity = input.sharingAutoFromOrder
? skuQuantity
: Math.max(1, Number(input.sharingTotalQuantity) || 1)
const sharingUnitReward = input.sharingAutoFromOrder
? unitPriceFen
: Math.max(0, Number(input.sharingUnitReward) || 0)
const rewardAmount = input.sharingEnabled
? sharingTotalQuantity * sharingUnitReward
: unitPriceFen > 0
? unitPriceFen * skuQuantity
: Math.max(0, Number(input.fixedRewardAmount) || 0)
return { sharingTotalQuantity, sharingUnitReward, rewardAmount }
}
export function resolveWorkOrderSharingEnabled(
configuredEnabled: boolean,
totalQuantity: number,
): boolean {
return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1
}
export function isWorkOrderSharingFilled(
sharingEnabled: boolean,
totalQuantity: number,
joinedQuantity: number,
): boolean {
return (
sharingEnabled &&
Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1)
)
}
const CHINESE_DIGITS: Record<string, number> = {
零: 0,
一: 1,
二: 2,
两: 2,
三: 3,
四: 4,
五: 5,
六: 6,
七: 7,
八: 8,
九: 9,
}
function parseChineseNumber(text: string): number {
let section = 0
let digit = 0
for (const char of text) {
if (char === '十') {
section += (digit || 1) * 10
digit = 0
} else if (char === '百') {
section += (digit || 1) * 100
digit = 0
} else if (char === '千') {
section += (digit || 1) * 1000
digit = 0
} else if (char in CHINESE_DIGITS) {
digit = CHINESE_DIGITS[char] ?? 0
}
}
return section + digit
}
/**
* 价格/数量计算已迁到 `services/product-match/pricing.ts`(匹配引擎的一部分)。
* 本文件保留为转发层,避免调用方一次性大改;新代码请直接从 product-match 引入。
*/
export {
isWorkOrderSharingFilled,
resolveSkuNameQuantity,
resolveWorkOrderQuantity,
resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled,
} from '../product-match/pricing.js'
@@ -1,17 +1,39 @@
/**
* 接单模板匹配的门面(过渡期)。
*
* 实现在 `services/product-match/`(来源适配器 + 引擎 + 价格),本文件只保留:
* - 旧的对外函数签名(发单服务、后台匹配测试/重匹配、测试都在用);
* - 旧配置 → 匹配条目的兼容编译调用;
* - 未命中诊断(把条目被哪一维挡住翻译成旧的拒绝原因文案)。
*
* 新增来源请改 `services/product-match/adapters/`,不要在这里加平台分支。
*/
import {
type WorkProductRuleMappingRow,
type WorkProductRuleRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
buildProductMatchContext,
compileLegacyMatchEntries,
isTextFallbackAllowed,
normalizeMatchId,
normalizeMatchText,
resolveKuaishouWorkProductMatchContext,
resolveProductMatchDecision,
type ProductMatchDiagnosisItem,
} from '../product-match/index.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { normalizeRequirementFields } from './work-order-requirement-fields.js'
export { resolveKuaishouWorkProductMatchContext }
export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
if (!rule) return null
const requirement = safeParseJson(rule.requirement_json)
@@ -78,17 +100,13 @@ export type WorkProductRuleMatchDecision = {
reason: 'matched' | 'unmatched' | 'ambiguous'
score: number
candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }>
/** 未命中/歧义时:每个候选条目差在哪一维(最多 10 条)。 */
diagnosis?: ProductMatchDiagnosisItem[]
}
export type KuaishouWorkProductMatchContext = {
sellerId: string
itemId: string
relItemId: string
skuId: string
relSkuId: string
itemTitle: string
skuNick: string
}
export type KuaishouWorkProductMatchContext = ReturnType<
typeof resolveKuaishouWorkProductMatchContext
>
export function resolveMatchingProductRuleDecision(
order: OrderRow,
@@ -97,280 +115,29 @@ export function resolveMatchingProductRuleDecision(
mappings: WorkProductRuleMappingRow[] = [],
options: { legacyFallbackEnabled?: boolean } = {},
): WorkProductRuleMatchDecision {
const context = resolveKuaishouWorkProductMatchContext(item)
const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule]))
// 快手发码订单默认只允许确定性商品映射;非快手订单保留原有规则,避免影响其他来源。
const useLegacyFallback = !context.sellerId || options.legacyFallbackEnabled === true
const candidates = [
...mappings.map((mapping) => scoreWorkProductRuleMapping(context, mapping, rulesById)),
...(useLegacyFallback ? rules.map((rule) => scoreWorkProductRule(order, item, rule)) : []),
]
.filter(
(candidate): candidate is { rule: WorkProductRuleRow; score: number; mappingId?: number } =>
Boolean(candidate),
)
.sort((left, right) => right.score - left.score || Number(right.rule.id) - Number(left.rule.id))
const top = candidates[0]
if (!top) {
return { rule: null, mappingId: null, reason: 'unmatched', score: 0, candidates: [] }
}
const tied = candidates.filter((candidate) => candidate.score === top.score)
if (tied.length > 1) {
return {
rule: null,
mappingId: null,
reason: 'ambiguous',
score: top.score,
candidates: tied.map((candidate) => ({
ruleKey: candidate.rule.rule_key,
score: candidate.score,
mappingId: candidate.mappingId || null,
})),
}
}
const context = buildProductMatchContext(order, item)
const allowTextFallback = isTextFallbackAllowed({
order,
hasSellerContext: context.hasSellerContext,
legacyFallbackEnabled: options.legacyFallbackEnabled === true,
})
const entries = compileLegacyMatchEntries(rules, mappings, { allowTextFallback })
const decision = resolveProductMatchDecision(context, entries)
return {
rule: top.rule,
mappingId: top.mappingId || null,
reason: 'matched',
score: top.score,
candidates: candidates.slice(0, 5).map((candidate) => ({
ruleKey: candidate.rule.rule_key,
rule: decision.rule,
mappingId: decision.mappingId,
reason: decision.status,
score: decision.score,
candidates: decision.candidates.map((candidate) => ({
ruleKey: candidate.ruleKey,
score: candidate.score,
mappingId: candidate.mappingId || null,
mappingId: candidate.mappingId,
})),
diagnosis: decision.diagnosis,
}
}
export function resolveKuaishouWorkProductMatchContext(
item: Pick<OrderItemRow, 'item_snapshot_json'>,
): KuaishouWorkProductMatchContext {
const snapshot = safeParseJson(item.item_snapshot_json)
const source = safeParseJson(snapshot.kuaishouSendCode)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
return {
sellerId: String(source.sellerId || '').trim(),
itemId: String(source.itemId || '').trim(),
relItemId: normalizeExternalMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
skuId: String(source.skuId || snapshot.sku_id || '').trim(),
relSkuId: normalizeExternalMatchId(source.relSkuId || outSkuSn || snapshot.spec),
itemTitle: String(source.itemTitle || snapshot.goodsName || snapshot.goods_name || '').trim(),
skuNick: String(
source.skuNick ||
source.skuName ||
outSkuSn ||
snapshot.externalSkuName ||
snapshot.spec ||
'',
).trim(),
}
}
function scoreWorkProductRuleMapping(
context: KuaishouWorkProductMatchContext,
mapping: WorkProductRuleMappingRow,
rulesById: Map<number, WorkProductRuleRow>,
) {
if (!mapping.enabled) return null
const rule = rulesById.get(Number(mapping.rule_id))
if (!rule || !rule.enabled) return null
if (
!mappingSellerIds(mapping.seller_ids_json).some((sellerId) =>
sameMatchId(context.sellerId, sellerId),
)
) {
return null
}
const mappingType =
mapping.mapping_type === 'sku_series'
? 'sku_series'
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
? 'sku_exact'
: 'product_default'
const mappedItemIds = normalizeMatchIdList(mapping.rel_item_id)
const mappedTitle = normalizeMatchText(mapping.item_title)
const hasContextItemId = Boolean(normalizeMatchId(context.relItemId))
const itemIdMatched = mappedItemIds.some((mappedItemId) =>
sameMatchId(context.relItemId, mappedItemId),
)
const titleMatched = Boolean(
mappedTitle && matchNameCondition(normalizeMatchText(context.itemTitle), mappedTitle, true),
)
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
if (mappingType === 'product_default' || hasProductScope) {
// 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。
if (
(mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) ||
(mappedItemIds.length === 0 && !titleMatched)
) {
return null
}
}
let score = mappingType === 'sku_exact' ? 10_000 : mappingType === 'sku_series' ? 8_000 : 5_000
if (itemIdMatched) score += 400
if (titleMatched) score += 200
if (mappingType === 'sku_exact') {
const mappedSkuId = normalizeMatchId(mapping.rel_sku_id)
const mappedSkuNick = normalizeMatchText(mapping.sku_nick)
if (!mappedSkuId && !mappedSkuNick) return null
const skuIdMatched = Boolean(mappedSkuId && sameMatchId(context.relSkuId, mappedSkuId))
const skuNickMatched = Boolean(
mappedSkuNick && matchNameCondition(normalizeMatchText(context.skuNick), mappedSkuNick, true),
)
if (!skuIdMatched && !skuNickMatched) {
return null
}
if (skuIdMatched) score += 400
if (skuNickMatched) score += 200
}
if (mappingType === 'sku_series') {
const skuMatchMode = mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy'
if (!matchSkuSeriesName(context.skuNick, mapping.sku_nick, skuMatchMode)) return null
score += 200
}
return { rule, score, mappingId: Number(mapping.id) }
}
function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) {
if (!matchesRuleProviderPlatform(rule, order)) return null
const snapshot = safeParseJson(item.item_snapshot_json)
const source = safeParseJson(snapshot.kuaishouSendCode)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
const context = {
sellerId: normalizeMatchId(source.sellerId),
itemId: normalizeMatchId(source.itemId),
relItemId: normalizeMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
skuId: normalizeMatchId(source.skuId),
relSkuId: normalizeMatchId(source.relSkuId || outSkuSn || snapshot.spec),
itemTitle: normalizeMatchText(
source.itemTitle || snapshot.goodsName || snapshot.goods_name || '',
),
// 拼多多等平台:结构化匹配也要读 out_sku_sn,避免有编码却未命中
skuNick: normalizeMatchText(
source.skuNick || source.skuName || outSkuSn || snapshot.externalSkuName || snapshot.spec,
),
}
const ruleShopId = normalizeMatchId(rule.shop_id)
if (
ruleShopId &&
ruleShopId !== normalizeMatchId(order.shop_id) &&
ruleShopId !== context.sellerId
) {
return null
}
const match = safeParseJson(rule.match_json)
const hasStructuredMatch = Object.entries(match).some(
([key, value]) =>
!['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(),
)
// 结构化 match_json(sellerId/skuNick 等)是快手发码路径;拼多多等无店铺上下文的订单
// 若误入该分支,常因缺 sellerId 直接失败,且不会回退到模板名/out_sku_sn。
// 仅当订单带有店铺上下文时才走结构化匹配。
if (hasStructuredMatch && context.sellerId) {
let score = 100
if (!matchCondition(context.sellerId, match.sellerId)) return null
if (match.sellerId) score += 1000
if (!matchCondition(context.itemId, match.itemId)) return null
if (match.itemId) score += 800
if (!matchCondition(context.relItemId, match.relItemId)) return null
if (match.relItemId) score += 700
if (!matchCondition(context.skuId, match.skuId)) return null
if (match.skuId) score += 1000
if (!matchCondition(context.relSkuId, match.relSkuId)) return null
if (match.relSkuId) score += 900
const itemTitle = normalizeMatchText(match.itemTitle)
if (itemTitle) {
const exact = match.itemTitleMatchType === 'exact'
if (!matchNameCondition(context.itemTitle, itemTitle, exact)) return null
score += exact ? 600 : 250
}
const skuNick = normalizeMatchText(match.skuNick)
if (skuNick) {
const exact = match.skuNickMatchType === 'exact'
if (!matchNameCondition(context.skuNick, skuNick, exact)) return null
score += exact ? 800 : 350
}
return { rule, score }
}
const skuCodes = [item.sku_code, snapshot.externalSkuCode, outSkuSn, snapshot.externalItemId]
.map(normalizeMatchId)
.filter(Boolean)
const skuNames = [
item.sku_name,
snapshot.externalSkuName,
outSkuSn,
snapshot.spec,
snapshot.externalSkuNameNormalized,
source.skuNick,
source.itemTitle,
]
.map(normalizeMatchText)
.filter(Boolean)
const ruleSkuCode = normalizeMatchId(rule.sku_code)
const ruleProductName = normalizeMatchText(rule.product_name)
const skuCodeMatched = Boolean(ruleSkuCode && skuCodes.includes(ruleSkuCode))
const exactProductName = Boolean(
ruleProductName && (skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName)),
)
const containsProductName = Boolean(
ruleProductName &&
!exactProductName &&
rule.match_type !== 'exact' &&
(skuNames.some((value) => value.includes(ruleProductName)) ||
skuCodes.some((value) => value.includes(ruleProductName))),
)
// sku_code 与 product_name 任一命中即可
if (!skuCodeMatched && !exactProductName && !containsProductName) return null
// 同分冲突常见场景:短名「安卓血宴5钻」contains 命中长编码「安卓血宴5钻带2璀璨」
// 精确相等优先;contains 则更长的模板名更具体、分更高
let score = 100
if (exactProductName) {
score = 250
} else if (containsProductName) {
score = 100 + Math.min(80, ruleProductName.length)
}
if (skuCodeMatched) score += exactProductName || containsProductName ? 40 : 120
return { rule, score }
}
/** 拼多多常复用原快手模板;来源/平台残留 91kaquan、kuaishou 时不拦截 out_sku_sn 模板名匹配。 */
function matchesRuleProviderPlatform(rule: WorkProductRuleRow, order: OrderRow) {
if (
matchesOptionalText(rule.provider, order.provider) &&
matchesOptionalText(rule.platform, order.platform)
) {
return true
}
const orderProvider = String(order.provider || '')
.trim()
.toLowerCase()
const orderPlatform = String(order.platform || '')
.trim()
.toLowerCase()
const orderIsPdd = orderProvider === 'pdd' || orderPlatform === 'pdd'
if (!orderIsPdd) return false
const ruleProvider = String(rule.provider || '')
.trim()
.toLowerCase()
const rulePlatform = String(rule.platform || '')
.trim()
.toLowerCase()
const providerOk = !ruleProvider || ruleProvider === 'pdd' || ruleProvider === '91kaquan'
const platformOk = !rulePlatform || rulePlatform === 'pdd' || rulePlatform === 'kuaishou'
return providerOk && platformOk
}
export type WorkProductRuleRejectSample = {
ruleKey: string
productName: string
@@ -381,18 +148,27 @@ export type WorkProductRuleRejectSample = {
reason: string
}
/** 诊断未命中原因:找出与订单 SKU/标题相近但被过滤的模板。 */
/** 诊断未命中原因:找出与订单 SKU/标题相近但被挡住的模板。 */
export function diagnoseUnmatchedProductRules(
order: OrderRow,
item: OrderItemRow,
rules: WorkProductRuleRow[],
limit = 8,
): WorkProductRuleRejectSample[] {
const context = buildProductMatchContext(order, item)
// 诊断始终编译文本条件,才能解释"为什么模板名没命中"。
const entries = compileLegacyMatchEntries(rules, [], { allowTextFallback: true })
const decision = resolveProductMatchDecision(context, entries)
const blockedByEntryKey = new Map(
decision.diagnosis.map((entry) => [entry.key, entry.blockedBy] as const),
)
const snapshot = safeParseJson(item.item_snapshot_json)
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
const needle = normalizeMatchText(
outSkuSn || item.sku_name || item.sku_code || snapshot.spec || '',
)
const samples: WorkProductRuleRejectSample[] = []
for (const rule of rules) {
if (!rule.enabled) continue
@@ -402,32 +178,13 @@ export function diagnoseUnmatchedProductRules(
!needle ||
productName.includes(needle) ||
needle.includes(productName) ||
(skuCode && (skuCode.includes(needle) || needle.includes(skuCode)))
(skuCode !== '' && (skuCode.includes(needle) || needle.includes(skuCode)))
if (!related && needle) continue
const scored = scoreWorkProductRule(order, item, rule)
if (scored) continue
let reason = 'name_or_sku_not_matched'
if (!matchesRuleProviderPlatform(rule, order)) {
reason = `provider_platform_mismatch(rule=${rule.provider || '-'}/${rule.platform || '-'}, order=${order.provider || '-'}/${order.platform || '-'})`
} else {
const ruleShopId = normalizeMatchId(rule.shop_id)
const source = safeParseJson(snapshot.kuaishouSendCode)
const sellerId = normalizeMatchId(source.sellerId)
if (ruleShopId && ruleShopId !== normalizeMatchId(order.shop_id) && ruleShopId !== sellerId) {
reason = `shop_id_mismatch(rule=${rule.shop_id}, order=${order.shop_id || '-'})`
} else {
const match = safeParseJson(rule.match_json)
const hasStructuredMatch = Object.entries(match).some(
([key, value]) =>
!['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(),
)
if (hasStructuredMatch && sellerId) {
reason = 'structured_match_failed'
}
}
}
const blockedBy = entries
.filter((entry) => entry.ruleId === Number(rule.id))
.flatMap((entry) => blockedByEntryKey.get(entry.key) ?? [])
if (blockedBy.length === 0) continue
samples.push({
ruleKey: rule.rule_key,
@@ -436,88 +193,29 @@ export function diagnoseUnmatchedProductRules(
platform: rule.platform || '',
shopId: rule.shop_id || '',
skuCode: rule.sku_code || '',
reason,
reason: summarizeRejectReason(blockedBy),
})
if (samples.length >= limit) break
}
return samples
}
function matchCondition(actual: string, expected: unknown) {
const value = normalizeMatchId(expected)
return !value || actual === value
}
function matchNameCondition(actual: string, expected: string, exact: boolean) {
return Boolean(actual) && (exact ? actual === expected : actual.includes(expected))
}
function normalizeMatchId(value: unknown) {
const normalized = String(value || '').trim()
return normalized === '0' ? '' : normalized.toLowerCase()
}
function normalizeMatchIdList(value: unknown) {
const rawValues = Array.isArray(value) ? value : [value]
return Array.from(
new Set(
rawValues
.flatMap((entry) => String(entry || '').split(/[,,;;\s\n\r]+/))
.map(normalizeMatchId)
.filter(Boolean),
),
function summarizeRejectReason(blockedBy: string[]): string {
const shopBlocked = blockedBy.find((reason) => reason.startsWith('shop_mismatch'))
if (shopBlocked) return shopBlocked
const sourceBlocked = blockedBy.find((reason) => reason.startsWith('source_mismatch'))
if (sourceBlocked) return sourceBlocked
const quantityBlocked = blockedBy.find((reason) => reason.startsWith('quantity_'))
if (quantityBlocked) return quantityBlocked
const structuredBlocked = blockedBy.find(
(reason) =>
reason.includes(':skuId:') ||
reason.includes(':relSkuId:') ||
reason.includes(':itemId:') ||
reason.includes(':relItemId:'),
)
}
function normalizeExternalMatchId(value: unknown) {
const normalized = String(value || '').trim()
return normalized === '0' ? '' : normalized
}
function sameMatchId(left: unknown, right: unknown) {
const normalizedLeft = normalizeMatchId(left)
const normalizedRight = normalizeMatchId(right)
return Boolean(normalizedLeft) && normalizedLeft === normalizedRight
}
function normalizeMatchText(value: unknown) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\u3000]+/g, '')
.trim()
}
function mappingSellerIds(value: unknown) {
const parsed = Array.isArray(value) ? value : safeParseJson(value)
const values = Array.isArray(parsed) ? parsed : []
return Array.from(new Set(values.map(normalizeMatchId).filter(Boolean)))
}
/**
* 去除 SKU 中的数量规格后得到系列名。
* 例如“指挥官密钥10个”和“1个盛夏礼卡”分别归一为“指挥官密钥”“盛夏礼卡”。
*/
function normalizeSkuSeriesName(value: unknown) {
return normalizeMatchText(value).replace(
/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g,
'',
)
}
function matchSkuSeriesName(actual: unknown, expected: unknown, matchMode: 'fuzzy' | 'exact') {
const normalizedActual = normalizeSkuSeriesName(actual)
const normalizedExpected = normalizeSkuSeriesName(expected)
if (!normalizedActual || !normalizedExpected) return false
if (matchMode === 'exact') return normalizedActual === normalizedExpected
const fuzzyActual = normalizeFuzzySkuSeriesName(normalizedActual)
const fuzzyExpected = normalizeFuzzySkuSeriesName(normalizedExpected)
return fuzzyActual.includes(fuzzyExpected) || fuzzyExpected.includes(fuzzyActual)
}
function normalizeFuzzySkuSeriesName(value: string) {
return value.replace(/秘钥|密匙|秘匙/g, '密钥')
if (structuredBlocked) return 'structured_match_failed'
return 'name_or_sku_not_matched'
}
export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {