Merge branch 'main' into dev
This commit is contained in:
@@ -16,6 +16,7 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { extractPddOutSkuSn, type PddRawOrderItem } from '../platforms/pdd/pdd-order-mapper.js'
|
||||
import {
|
||||
normalizeDateString,
|
||||
normalizePage,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
safeParseJson,
|
||||
} from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
diagnoseUnmatchedProductRules,
|
||||
mapWorkProductRule,
|
||||
normalizeBoolean,
|
||||
normalizeOptionalId,
|
||||
@@ -251,15 +253,19 @@ export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) {
|
||||
for (const row of recentOrderItemsResult.rows) {
|
||||
const snapshot = safeParseJson(row.item_snapshot_json)
|
||||
const ksc = safeParseJson(snapshot.kuaishouSendCode)
|
||||
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
|
||||
const sellerId = normalizeKuaishouMatchId(ksc.sellerId || row.shop_id || row.platform)
|
||||
const relItemId = normalizeKuaishouMatchId(
|
||||
ksc.relItemId || row.sku_code || snapshot.goodsId || snapshot.goods_id,
|
||||
ksc.relItemId || snapshot.goodsId || snapshot.goods_id || row.sku_code,
|
||||
)
|
||||
const itemTitle = String(
|
||||
ksc.itemTitle || snapshot.goodsName || snapshot.goods_name || row.sku_name || '',
|
||||
).trim()
|
||||
const relSkuId = normalizeKuaishouMatchId(ksc.relSkuId || snapshot.spec || row.sku_code)
|
||||
const skuNick = String(ksc.skuNick || snapshot.spec || row.sku_name || '').trim()
|
||||
// 拼多多优先展示/分组 out_sku_sn,与生产匹配字段对齐
|
||||
const relSkuId = normalizeKuaishouMatchId(
|
||||
ksc.relSkuId || outSkuSn || snapshot.spec || row.sku_code,
|
||||
)
|
||||
const skuNick = String(ksc.skuNick || outSkuSn || snapshot.spec || row.sku_name || '').trim()
|
||||
|
||||
if (!sellerId || (!relItemId && !itemTitle)) continue
|
||||
|
||||
@@ -296,30 +302,45 @@ export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) {
|
||||
export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) {
|
||||
const body = resolveKuaishouMatchPayload(payload.rawPayload ?? payload.raw_payload ?? payload)
|
||||
const ext = safeParseJson(body.ext)
|
||||
const outSkuSn = extractPddOutSkuSn(body as PddRawOrderItem)
|
||||
const goodsId = String(body.goodsId || body.goods_id || '').trim()
|
||||
const goodsName = String(body.goodsName || body.goods_name || body.itemTitle || '').trim()
|
||||
const spec = String(body.spec || '').trim()
|
||||
const isPdd =
|
||||
body.platform === 'pdd' ||
|
||||
String(body.provider || '').trim() === 'pdd' ||
|
||||
String(body.sellerId || '').includes('pdd') ||
|
||||
Boolean(body.goodsName || body.goods_name || body.goodsId || body.goods_id)
|
||||
Boolean(outSkuSn) ||
|
||||
Boolean(body.order_sn && (goodsName || goodsId))
|
||||
// 与生产 mapper 对齐:匹配字段优先 out_sku_sn
|
||||
const skuCode = String(body.sku_code || outSkuSn || body.itemId || goodsId || '').trim()
|
||||
const skuName = String(body.sku_name || outSkuSn || spec || goodsName || '').trim()
|
||||
const order = {
|
||||
provider: String(body.provider || (isPdd ? 'pdd' : '91kaquan')).trim(),
|
||||
platform: String(body.platform || (isPdd ? 'pdd' : 'kuaishou')).trim(),
|
||||
shop_id: String(body.sellerId || body.shop_id || '').trim(),
|
||||
} as OrderRow
|
||||
const relItemId = normalizeKuaishouMatchId(ext.relItemId || body.goodsId || body.goods_id)
|
||||
const relSkuId = normalizeKuaishouMatchId(ext.relSkuId || body.spec)
|
||||
const itemTitle = String(body.itemTitle || body.goodsName || body.goods_name || '').trim()
|
||||
const skuNick = String(ext.skuNick || body.skuNick || body.spec || '').trim()
|
||||
const relItemId = normalizeKuaishouMatchId(ext.relItemId || goodsId)
|
||||
const relSkuId = normalizeKuaishouMatchId(ext.relSkuId || outSkuSn || body.spec)
|
||||
const itemTitle = String(body.itemTitle || goodsName || '').trim()
|
||||
const skuNick = String(ext.skuNick || body.skuNick || outSkuSn || body.spec || '').trim()
|
||||
const item = {
|
||||
id: 0,
|
||||
order_id: 0,
|
||||
sku_code: String(body.sku_code || body.itemId || relItemId || '').trim(),
|
||||
sku_name: String(body.sku_name || skuNick || itemTitle || '').trim(),
|
||||
quantity: Math.max(1, Number(body.num || body.quantity) || 1),
|
||||
sku_code: skuCode,
|
||||
sku_name: skuName,
|
||||
quantity: Math.max(1, Number(body.num || body.quantity || body.goods_number) || 1),
|
||||
spec_json: {},
|
||||
item_snapshot_json: {
|
||||
goodsId: body.goodsId || body.goods_id,
|
||||
goodsName: body.goodsName || body.goods_name,
|
||||
goods_id: body.goods_id || body.goodsId,
|
||||
goodsName,
|
||||
goods_name: body.goods_name || body.goodsName || goodsName,
|
||||
spec: body.spec,
|
||||
out_sku_sn: outSkuSn,
|
||||
outSkuSn,
|
||||
externalSkuCode: skuCode,
|
||||
externalSkuName: skuName,
|
||||
kuaishouSendCode: {
|
||||
sellerId: String(body.sellerId || order.shop_id || '').trim(),
|
||||
itemId: String(body.itemId || '').trim(),
|
||||
@@ -339,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@ export {
|
||||
|
||||
export {
|
||||
type KuaishouWorkProductMatchContext,
|
||||
type WorkProductRuleRejectSample,
|
||||
diagnoseUnmatchedProductRules,
|
||||
mapWorkProductRule,
|
||||
matchesOptionalText,
|
||||
normalizeWorkProductRuleMatch,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createWorkProductMatchLog,
|
||||
createWorkOrder,
|
||||
createWorkOrderEvent,
|
||||
getLatestWorkProductMatchLogByOrderItemId,
|
||||
getWorkOrderByOrderItemId,
|
||||
listWorkOrdersByPlatformOrderId,
|
||||
listWorkProductRuleMappings,
|
||||
@@ -19,7 +20,7 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { generateWorkOrderNo, randomId } from '../../utils/random.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { logInfo, logWarn } from '../../utils/logger.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 {
|
||||
@@ -62,11 +63,36 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
const itemTitle =
|
||||
matchContext.itemTitle ||
|
||||
String(snapshot.goodsName || snapshot.goods_name || item.sku_name || '').trim()
|
||||
const relSkuId = matchContext.relSkuId || String(snapshot.spec || item.sku_code || '').trim()
|
||||
const skuNick = matchContext.skuNick || String(snapshot.spec || item.sku_name || '').trim()
|
||||
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
|
||||
const relSkuId =
|
||||
matchContext.relSkuId || String(outSkuSn || snapshot.spec || item.sku_code || '').trim()
|
||||
const skuNick =
|
||||
matchContext.skuNick || String(outSkuSn || snapshot.spec || item.sku_name || '').trim()
|
||||
|
||||
// Record product match diagnostics for all platform orders (including PDD)
|
||||
if (sellerId || relItemId || itemTitle) {
|
||||
// 已有工单:跳过匹配日志,避免 PDD 定时同步对旧单反复刷「命中」日志。
|
||||
const existingWorkOrder = await getWorkOrderByOrderItemId(item.id)
|
||||
if (existingWorkOrder) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
|
||||
decisions.push(
|
||||
buildWorkerConsumerDecision(item, {
|
||||
result: 'existing',
|
||||
reason: 'already_exists',
|
||||
ruleId: match.rule?.id,
|
||||
ruleKey: match.rule?.rule_key,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const latestLog = await getLatestWorkProductMatchLogByOrderItemId(item.id)
|
||||
const sameMatchOutcome =
|
||||
latestLog &&
|
||||
String(latestLog.match_status || '') === match.reason &&
|
||||
Number(latestLog.rule_id || 0) === Number(match.rule?.id || 0) &&
|
||||
Number(latestLog.mapping_id || 0) === Number(match.mappingId || 0)
|
||||
|
||||
// 匹配结果未变时不重复写诊断日志(旧单未建工单也会被定时同步反复扫到)。
|
||||
if (!sameMatchOutcome && (sellerId || relItemId || itemTitle)) {
|
||||
await createWorkProductMatchLog({
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(item.id),
|
||||
@@ -119,18 +145,6 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (await getWorkOrderByOrderItemId(item.id)) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
|
||||
decisions.push(
|
||||
buildWorkerConsumerDecision(item, {
|
||||
result: 'existing',
|
||||
reason: 'already_exists',
|
||||
ruleId: rule.id,
|
||||
ruleKey: rule.rule_key,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
// 订单号跨订单唯一:同一订单的多个 SKU 件允许共享订单号(按件建单是既有设计),
|
||||
// 但订单号已挂在其他订单下时跳过,避免自动同步产生撞号工单。
|
||||
const samePlatformOrders = await listWorkOrdersByPlatformOrderId(order.platform_order_id)
|
||||
@@ -258,23 +272,29 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
}
|
||||
}
|
||||
|
||||
logInfo('[worker-order-consumer]', '发单平台消费者处理完成', {
|
||||
const summary = {
|
||||
source: options.source || 'source_order',
|
||||
orderId: order.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderItemCount: orderItems.length,
|
||||
createdCount: created.length,
|
||||
skippedCount: skipped.length,
|
||||
decisions,
|
||||
})
|
||||
skipReasons: formatDecisionReasonCounts(skipped),
|
||||
}
|
||||
|
||||
// 常态「全跳过」每轮 PDD upsert 都会触发,降到 debug,避免日志按天被切碎。
|
||||
if (created.length > 0) {
|
||||
logInfo('[worker-order-consumer]', '发单平台消费者处理完成', summary)
|
||||
} else {
|
||||
logDebug('[worker-order-consumer]', '发单平台消费者无新建工单', summary)
|
||||
}
|
||||
|
||||
const failedDecisions = decisions.filter((decision) => decision.result === 'failed')
|
||||
if (failedDecisions.length > 0) {
|
||||
logWarn('[worker-order-consumer]', '发单平台消费者存在未成功落库的订单项', {
|
||||
source: options.source || 'source_order',
|
||||
orderId: order.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
decisions: failedDecisions,
|
||||
...summary,
|
||||
failedCount: failedDecisions.length,
|
||||
failedReasons: formatDecisionReasonCounts(failedDecisions),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -309,3 +329,17 @@ function buildWorkerConsumerDecision(
|
||||
...detail,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDecisionReasonCounts(decisions: Array<Record<string, unknown>>) {
|
||||
const counts = summarizeDecisionReasons(decisions)
|
||||
return Object.keys(counts).length > 0 ? JSON.stringify(counts) : ''
|
||||
}
|
||||
|
||||
function summarizeDecisionReasons(decisions: Array<Record<string, unknown>>) {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const decision of decisions) {
|
||||
const reason = String(decision.reason || decision.result || 'unknown').trim() || 'unknown'
|
||||
counts[reason] = (counts[reason] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
@@ -149,14 +149,22 @@ export function resolveKuaishouWorkProductMatchContext(
|
||||
): 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),
|
||||
skuId: String(source.skuId || '').trim(),
|
||||
relSkuId: normalizeExternalMatchId(source.relSkuId),
|
||||
itemTitle: String(source.itemTitle || '').trim(),
|
||||
skuNick: String(source.skuNick || source.skuName || snapshot.externalSkuName || '').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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,19 +236,24 @@ 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)
|
||||
const outSkuSn = String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim()
|
||||
const context = {
|
||||
sellerId: normalizeMatchId(source.sellerId),
|
||||
itemId: normalizeMatchId(source.itemId),
|
||||
relItemId: normalizeMatchId(source.relItemId),
|
||||
relItemId: normalizeMatchId(source.relItemId || snapshot.goodsId || snapshot.goods_id),
|
||||
skuId: normalizeMatchId(source.skuId),
|
||||
relSkuId: normalizeMatchId(source.relSkuId),
|
||||
itemTitle: normalizeMatchText(source.itemTitle),
|
||||
skuNick: normalizeMatchText(source.skuNick || source.skuName || snapshot.externalSkuName),
|
||||
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 (
|
||||
@@ -256,7 +269,10 @@ function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkPro
|
||||
!['itemTitleMatchType', 'skuNickMatchType'].includes(key) && String(value || '').trim(),
|
||||
)
|
||||
|
||||
if (hasStructuredMatch) {
|
||||
// 结构化 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
|
||||
@@ -284,12 +300,14 @@ function scoreWorkProductRule(order: OrderRow, item: OrderItemRow, rule: WorkPro
|
||||
return { rule, score }
|
||||
}
|
||||
|
||||
const skuCodes = [item.sku_code, snapshot.externalSkuCode, snapshot.externalItemId]
|
||||
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,
|
||||
@@ -298,20 +316,131 @@ 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 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))),
|
||||
)
|
||||
|
||||
if (ruleSkuCode && !skuCodes.includes(ruleSkuCode)) return null
|
||||
if (!ruleProductName) {
|
||||
return ruleSkuCode ? { rule, score: 120 } : null
|
||||
// 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 (rule.match_type === 'exact') {
|
||||
return skuNames.includes(ruleProductName) || skuCodes.includes(ruleProductName)
|
||||
? { rule, score: ruleSkuCode ? 300 : 150 }
|
||||
: null
|
||||
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
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -576,6 +576,196 @@ test('商品映射不以公共词包含匹配,完整大标题不同则不命
|
||||
assert.equal(decision.rule, null)
|
||||
})
|
||||
|
||||
test('拼多多订单使用 out_sku_sn 字段精准匹配接单规则', () => {
|
||||
const rule = buildProductRule({
|
||||
provider: 'pdd',
|
||||
platform: 'pdd',
|
||||
sku_code: '安卓血宴3钻',
|
||||
product_name: '安卓血宴3钻',
|
||||
match_type: 'exact',
|
||||
})
|
||||
const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd' }
|
||||
const item = {
|
||||
...buildOrderItemRow(),
|
||||
sku_code: '安卓血宴3钻',
|
||||
sku_name: '安卓血宴3钻',
|
||||
item_snapshot_json: {
|
||||
goods_id: 12345678,
|
||||
goods_name: '和平精英苏尔南手枪皮肤',
|
||||
spec: '暗潮1钻',
|
||||
out_sku_sn: '安卓血宴3钻',
|
||||
},
|
||||
}
|
||||
|
||||
const decision = resolveMatchingProductRuleDecision(order, item, [rule], [])
|
||||
assert.equal(decision.reason, 'matched')
|
||||
assert.equal(decision.rule?.id, rule.id)
|
||||
})
|
||||
|
||||
test('拼多多订单在结构化 match_json 下仍可用 out_sku_sn 命中', () => {
|
||||
const rule = buildProductRule({
|
||||
provider: 'pdd',
|
||||
platform: 'pdd',
|
||||
product_name: '安卓怒嚎3钻',
|
||||
match_type: 'contains',
|
||||
match_json: {
|
||||
skuNick: '安卓怒嚎3钻',
|
||||
skuNickMatchType: 'contains',
|
||||
},
|
||||
})
|
||||
const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd', shop_id: 'pdd_shop' }
|
||||
const item = {
|
||||
...buildOrderItemRow(),
|
||||
sku_code: '安卓怒嚎3钻',
|
||||
sku_name: '安卓怒嚎3钻',
|
||||
item_snapshot_json: {
|
||||
goods_id: 961856378217,
|
||||
goods_name: '和平精英地铁逃生苏尔南暗潮怒嚎血宴苏尔南1-6钻手枪传世武器',
|
||||
spec: '安卓区(qv互通),苏尔南-怒嚎3钻',
|
||||
out_sku_sn: '安卓怒嚎3钻',
|
||||
},
|
||||
}
|
||||
|
||||
const decision = resolveMatchingProductRuleDecision(order, item, [rule], [])
|
||||
assert.equal(decision.reason, 'matched')
|
||||
assert.equal(decision.rule?.id, rule.id)
|
||||
})
|
||||
|
||||
test('拼多多订单忽略模板残留的快手结构化 match_json,按 out_sku_sn 命中模板名', () => {
|
||||
const rule = buildProductRule({
|
||||
provider: '',
|
||||
platform: '',
|
||||
product_name: '安卓怒嚎1钻',
|
||||
match_type: 'contains',
|
||||
// 历史保存残留的快手字段:若强制走结构化会因缺 sellerId 直接未命中
|
||||
match_json: {
|
||||
sellerId: '2174663396',
|
||||
relItemId: '961856378217',
|
||||
skuNick: '安卓怒嚎1钻',
|
||||
skuNickMatchType: 'exact',
|
||||
},
|
||||
})
|
||||
const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd', shop_id: 'pdd_shop_1' }
|
||||
const item = {
|
||||
...buildOrderItemRow(),
|
||||
sku_code: '安卓怒嚎1钻',
|
||||
sku_name: '安卓怒嚎1钻',
|
||||
item_snapshot_json: {
|
||||
goodsId: '961856378217',
|
||||
goods_id: '961856378217',
|
||||
spec: '安卓区(qv互通),苏尔南-怒嚎1钻',
|
||||
outSkuSn: '安卓怒嚎1钻',
|
||||
out_sku_sn: '安卓怒嚎1钻',
|
||||
},
|
||||
}
|
||||
|
||||
const decision = resolveMatchingProductRuleDecision(order, item, [rule], [], {
|
||||
legacyFallbackEnabled: false,
|
||||
})
|
||||
assert.equal(decision.reason, 'matched')
|
||||
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 在短模板名 contains 时可优先命中精确相等的长模板,避免冲突', () => {
|
||||
const longRule = buildProductRule({
|
||||
id: 101,
|
||||
rule_key: 'rule-79bf8b3006ea',
|
||||
product_name: '安卓血宴5钻带2璀璨',
|
||||
match_type: 'contains',
|
||||
})
|
||||
const shortRule = buildProductRule({
|
||||
id: 102,
|
||||
rule_key: 'rule-f93c368547e1',
|
||||
product_name: '安卓血宴5钻',
|
||||
match_type: 'contains',
|
||||
})
|
||||
const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd' }
|
||||
const item = {
|
||||
...buildOrderItemRow(),
|
||||
sku_code: '安卓血宴5钻带2璀璨',
|
||||
sku_name: '安卓血宴5钻带2璀璨',
|
||||
item_snapshot_json: {
|
||||
out_sku_sn: '安卓血宴5钻带2璀璨',
|
||||
spec: '安卓区(qv互通),苏尔南-血宴5钻带2璀璨',
|
||||
},
|
||||
}
|
||||
|
||||
const decision = resolveMatchingProductRuleDecision(order, item, [longRule, shortRule], [])
|
||||
assert.equal(decision.reason, 'matched')
|
||||
assert.equal(decision.rule?.rule_key, 'rule-79bf8b3006ea')
|
||||
assert.ok((decision.score || 0) > 100)
|
||||
})
|
||||
|
||||
test('拼多多订单缺少 out_sku_sn 且 spec 不含规则名时不命中', () => {
|
||||
const rule = buildProductRule({
|
||||
provider: 'pdd',
|
||||
platform: 'pdd',
|
||||
product_name: '安卓怒嚎3钻',
|
||||
match_type: 'contains',
|
||||
})
|
||||
const order = { ...buildOrderRow(), provider: 'pdd', platform: 'pdd' }
|
||||
const item = {
|
||||
...buildOrderItemRow(),
|
||||
sku_code: '961856378217',
|
||||
sku_name: '安卓区(qv互通),苏尔南-怒嚎3钻',
|
||||
item_snapshot_json: {
|
||||
goods_id: 961856378217,
|
||||
goods_name: '和平精英地铁逃生苏尔南暗潮怒嚎血宴苏尔南1-6钻手枪传世武器',
|
||||
spec: '安卓区(qv互通),苏尔南-怒嚎3钻',
|
||||
},
|
||||
}
|
||||
|
||||
const decision = resolveMatchingProductRuleDecision(order, item, [rule], [])
|
||||
assert.equal(decision.reason, 'unmatched')
|
||||
})
|
||||
|
||||
test('assertWorkerLoginAllowed blocks rejected (frozen) workers with worker_rejected', () => {
|
||||
assert.throws(
|
||||
() => assertWorkerLoginAllowed(buildWorkerUserRow({ status: 'rejected' })),
|
||||
|
||||
Reference in New Issue
Block a user