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