diff --git a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts index 9e3a2a26..2ea08d82 100644 --- a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts +++ b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts @@ -24,6 +24,7 @@ import { safeParseJson, } from '../admin/admin-query-utils.js' import { + diagnoseUnmatchedProductRules, mapWorkProductRule, normalizeBoolean, normalizeOptionalId, @@ -359,12 +360,26 @@ export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) { legacyFallbackEnabled: getWorkerProductMatchConfig().legacyFallbackEnabled, }) const context = resolveKuaishouWorkProductMatchContext(item) + const diagnostics = { + outSkuSn, + orderProvider: order.provider, + orderPlatform: order.platform, + orderShopId: order.shop_id, + contextSellerId: context.sellerId, + legacyFallbackEnabled: getWorkerProductMatchConfig().legacyFallbackEnabled, + useLegacyFallback: !context.sellerId || getWorkerProductMatchConfig().legacyFallbackEnabled, + enabledRules: rules.length, + enabledMappings: mappings.length, + rejectSamples: + decision.reason === 'matched' ? [] : diagnoseUnmatchedProductRules(order, item, rules, 10), + } return { context, status: decision.reason, mappingId: decision.mappingId, rule: decision.rule ? mapWorkProductRule(decision.rule) : null, candidates: decision.candidates, + diagnostics, } } diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 17651a93..6b9162d3 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -98,6 +98,8 @@ export { export { type KuaishouWorkProductMatchContext, + type WorkProductRuleRejectSample, + diagnoseUnmatchedProductRules, mapWorkProductRule, matchesOptionalText, normalizeWorkProductRuleMatch, diff --git a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts index 9913f4fb..bf8275e5 100644 --- a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts @@ -236,8 +236,7 @@ function scoreWorkProductRuleMapping( } function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkProductRuleRow) { - if (!matchesOptionalText(rule.provider, order.provider)) return null - if (!matchesOptionalText(rule.platform, order.platform)) return null + if (!matchesRuleProviderPlatform(rule, order)) return null const snapshot = safeParseJson(item.item_snapshot_json) const source = safeParseJson(snapshot.kuaishouSendCode) @@ -317,20 +316,121 @@ function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkPro .filter(Boolean) const ruleSkuCode = normalizeMatchId(rule.sku_code) const ruleProductName = normalizeMatchText(rule.product_name) + const skuCodeMatched = Boolean(ruleSkuCode && skuCodes.includes(ruleSkuCode)) + const productNameMatched = ruleProductName + ? rule.match_type === 'exact' + ? skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName) + : skuNames.some((value) => value.includes(ruleProductName)) || + skuCodes.some((value) => value.includes(ruleProductName)) + : false - if (ruleSkuCode && !skuCodes.includes(ruleSkuCode)) return null - if (!ruleProductName) { - return ruleSkuCode ? { rule, score: 120 } : null + // sku_code 与 product_name 任一命中即可(不再要求两者同时成立,避免误填的 sku_code 挡住 out_sku_sn) + if (!skuCodeMatched && !productNameMatched) return null + if (skuCodeMatched && productNameMatched) { + return { rule, score: rule.match_type === 'exact' ? 300 : 220 } } - if (rule.match_type === 'exact') { - return skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName) - ? { rule, score: ruleSkuCode ? 300 : 150 } - : null + if (skuCodeMatched) return { rule, score: 120 } + return { rule, score: rule.match_type === 'exact' ? 150 : 100 } +} + +/** 拼多多常复用原快手模板;来源/平台残留 91kaquan、kuaishou 时不拦截 out_sku_sn 模板名匹配。 */ +function matchesRuleProviderPlatform(rule: WorkProductRuleRow, order: OrderRow) { + if ( + matchesOptionalText(rule.provider, order.provider) && + matchesOptionalText(rule.platform, order.platform) + ) { + return true } - return skuNames.some((value) => value.includes(ruleProductName)) || - skuCodes.some((value) => value.includes(ruleProductName)) - ? { rule, score: ruleSkuCode ? 220 : 100 } - : null + 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 + provider: string + platform: string + shopId: string + skuCode: string + reason: string +} + +/** 诊断未命中原因:找出与订单 SKU/标题相近但被过滤的模板。 */ +export function diagnoseUnmatchedProductRules( + order: OrderRow, + item: OrderItemRow, + rules: WorkProductRuleRow[], + limit = 8, +): WorkProductRuleRejectSample[] { + 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 + const productName = normalizeMatchText(rule.product_name) + const skuCode = normalizeMatchId(rule.sku_code) + const related = + !needle || + productName.includes(needle) || + needle.includes(productName) || + (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' + } + } + } + + samples.push({ + ruleKey: rule.rule_key, + productName: rule.product_name || '', + provider: rule.provider || '', + platform: rule.platform || '', + shopId: rule.shop_id || '', + skuCode: rule.sku_code || '', + reason, + }) + if (samples.length >= limit) break + } + return samples } function matchCondition(actual: string, expected: unknown) { diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 2967c7e1..e55c214e 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -666,6 +666,53 @@ test('拼多多订单忽略模板残留的快手结构化 match_json,按 out_s assert.equal(decision.rule?.id, rule.id) }) +test('拼多多订单可命中来源/平台残留为 91kaquan/kuaishou 的模板名', () => { + const rule = buildProductRule({ + provider: '91kaquan', + platform: 'kuaishou', + product_name: '安卓怒嚎1钻', + match_type: 'contains', + }) + const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd' } + const item = { + ...buildOrderItemRow(), + sku_code: '安卓怒嚎1钻', + sku_name: '安卓怒嚎1钻', + item_snapshot_json: { + out_sku_sn: '安卓怒嚎1钻', + spec: '安卓区(qv互通),苏尔南-怒嚎1钻', + }, + } + + const decision = resolveMatchingProductRuleDecision(order, item, [rule], []) + assert.equal(decision.reason, 'matched') + assert.equal(decision.rule?.id, rule.id) +}) + +test('拼多多订单在 sku_code 误填时仍可按 product_name/out_sku_sn 命中', () => { + const rule = buildProductRule({ + provider: '', + platform: '', + sku_code: '961856378217', + product_name: '安卓怒嚎1钻', + match_type: 'contains', + }) + const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd' } + const item = { + ...buildOrderItemRow(), + sku_code: '安卓怒嚎1钻', + sku_name: '安卓怒嚎1钻', + item_snapshot_json: { + out_sku_sn: '安卓怒嚎1钻', + goods_id: '961856378217', + }, + } + + const decision = resolveMatchingProductRuleDecision(order, item, [rule], []) + assert.equal(decision.reason, 'matched') + assert.equal(decision.rule?.id, rule.id) +}) + test('拼多多订单缺少 out_sku_sn 且 spec 不含规则名时不命中', () => { const rule = buildProductRule({ provider: 'pdd', diff --git a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx index dc9d4320..1c936d5d 100644 --- a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx @@ -400,8 +400,19 @@ export default function ProductMatchPanel() { + {testResult.status !== 'matched' && + testResult.diagnostics?.rejectSamples?.length ? ( + + 相近未命中模板原因见上方 diagnostics.rejectSamples(常见:来源/平台写成 + 91kaquan/kuaishou、shop_id 绑错店、sku_code 与 out_sku_sn 不一致)。 + + ) : null} ) : null} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 3ed02648..3748f424 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -349,6 +349,26 @@ export function testAdminKuaishouProductMatch(rawPayload: string) { mappingId: number | null rule: WorkProductRule | null candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }> + diagnostics?: { + outSkuSn: string + orderProvider: string + orderPlatform: string + orderShopId: string + contextSellerId: string + legacyFallbackEnabled: boolean + useLegacyFallback: boolean + enabledRules: number + enabledMappings: number + rejectSamples: Array<{ + ruleKey: string + productName: string + provider: string + platform: string + shopId: string + skuCode: string + reason: string + }> + } }>('/api/v1/admin/worker-platform/product-match-test', { rawPayload }) }