From 10464fea89b6cae346c5e2dd799dc6b5d01d0303 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 14 Oct 2026 15:57:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor(product-match):=20=E6=8A=BD=E7=A6=BB?= =?UTF-8?q?=E6=8E=A5=E5=8D=95=E6=A8=A1=E6=9D=BF=E5=8C=B9=E9=85=8D=E5=BC=95?= =?UTF-8?q?=E6=93=8E=EF=BC=88=E6=9D=A5=E6=BA=90=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?/=E5=BC=95=E6=93=8E/=E4=BB=B7=E6=A0=BC=E6=94=B6=E5=8F=A3=20+=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8C=89=E8=AE=A2=E5=8D=95=E9=A1=B9=E8=AF=95?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 --- .../routes/admin/worker-platform/config.ts | 12 + .../product-match/adapters/douyin-mashida.ts | 72 +++ .../product-match/adapters/kuaishou-91.ts | 74 +++ .../services/product-match/adapters/pdd.ts | 59 +++ .../product-match/adapters/registry.ts | 65 +++ .../services/product-match/adapters/shared.ts | 74 +++ .../services/product-match/adapters/types.ts | 23 + .../src/services/product-match/compile.ts | 223 +++++++++ .../src/services/product-match/context.ts | 49 ++ .../src/services/product-match/engine.ts | 273 +++++++++++ .../src/services/product-match/index.ts | 45 ++ .../src/services/product-match/normalize.ts | 122 +++++ .../src/services/product-match/pricing.ts | 69 +++ .../product-match/product-match.test.ts | 367 ++++++++++++++ .../src/services/product-match/types.ts | 122 +++++ .../admin-product-match-test-service.ts | 136 ++++++ .../src/services/worker-platform/index.ts | 1 + .../worker-platform/work-order-pricing.ts | 107 +---- .../work-product-match-mappers.ts | 454 +++--------------- .../pages/admin/panels/ProductMatchPanel.tsx | 112 +++++ .../src/services/admin/worker-platform.ts | 49 ++ docs/抖音-码速达/00-总览.md | 7 +- docs/抖音-码速达/10-三层分流设计.md | 271 +++++++++++ docs/抖音-码速达/11-接单模板待补清单.md | 115 +++++ docs/抖音-码速达/12-按SKU分类改造总设计.md | 432 +++++++++++++++++ docs/接单模板匹配引擎重构设计.md | 362 ++++++++++++++ 26 files changed, 3218 insertions(+), 477 deletions(-) create mode 100644 apps/backend/src/services/product-match/adapters/douyin-mashida.ts create mode 100644 apps/backend/src/services/product-match/adapters/kuaishou-91.ts create mode 100644 apps/backend/src/services/product-match/adapters/pdd.ts create mode 100644 apps/backend/src/services/product-match/adapters/registry.ts create mode 100644 apps/backend/src/services/product-match/adapters/shared.ts create mode 100644 apps/backend/src/services/product-match/adapters/types.ts create mode 100644 apps/backend/src/services/product-match/compile.ts create mode 100644 apps/backend/src/services/product-match/context.ts create mode 100644 apps/backend/src/services/product-match/engine.ts create mode 100644 apps/backend/src/services/product-match/index.ts create mode 100644 apps/backend/src/services/product-match/normalize.ts create mode 100644 apps/backend/src/services/product-match/pricing.ts create mode 100644 apps/backend/src/services/product-match/product-match.test.ts create mode 100644 apps/backend/src/services/product-match/types.ts create mode 100644 apps/backend/src/services/worker-platform/admin-product-match-test-service.ts create mode 100644 docs/抖音-码速达/10-三层分流设计.md create mode 100644 docs/抖音-码速达/11-接单模板待补清单.md create mode 100644 docs/抖音-码速达/12-按SKU分类改造总设计.md create mode 100644 docs/接单模板匹配引擎重构设计.md diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 6dff4e4c..3e9aed02 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -30,6 +30,7 @@ import { saveAdminWorkerProductMatchConfig, syncAdminWorkProductRulePrice, testAdminKuaishouProductMatch, + testAdminProductMatchByOrderItem, } from '../../../services/worker-platform/index.js' import { createJsonHandler, requireAdminRoles } from '../session.js' @@ -335,6 +336,17 @@ router.post( }), ) +// 按订单项 ID 试匹配:走与发单侧完全一致的来源适配器 + 引擎 + 价格,任何来源都适用。 +router.post( + '/worker-platform/product-match-test-item', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => testAdminProductMatchByOrderItem(req.body || {}), { + successMessage: '匹配测试完成', + errorMessage: '匹配测试失败', + scope: '[admin/worker-platform/product-match-test-item]', + }), +) + router.get( '/worker-platform/product-match-logs', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/product-match/adapters/douyin-mashida.ts b/apps/backend/src/services/product-match/adapters/douyin-mashida.ts new file mode 100644 index 00000000..39378b63 --- /dev/null +++ b/apps/backend/src/services/product-match/adapters/douyin-mashida.ts @@ -0,0 +1,72 @@ +import { + normalizeExternalMatchId, + normalizeMatchId, + normalizeMatchText, + normalizeTextList, +} from '../normalize.js' +import { buildShopKey, readItemSnapshotFields, uniqueKeys } from './shared.js' +import type { ProductMatchAdapter } from './types.js' + +/** + * 来源:抖音(码速达 / douyin_mashida,platform=doudian)。 + * + * 关键约定: + * - **匹配文本只有内部 SKU**:优先 `item.sku_code`(落地口径恒为内部 SKU, + * 见 fulfillment/product-resolution-service.ts),为空时回落 `item.sku_name`; + * 引流商品标题(`productTitle` / `externalSkuName` 那串关键词)一律进 `promoTitle`,永不参与匹配; + * - 店铺键命名空间 `douyin:`(订单 shop_id,例如 douyin:305760014); + * - 文本兜底允许:因为匹配文本已限定为内部 SKU(干净规格名),不再有引流词表误命中的风险。 + * 历史单里 `sku_name` 可能是引流标题,但那时 `sku_code` 也是同一个长串, + * 本适配器优先取 `sku_code`,P3 换成显式条目后可完全去掉文本兜底。 + */ +export const douyinMashidaAdapter: ProductMatchAdapter = { + sourceKey: 'douyin-mashida', + + matches(order) { + const provider = String(order.provider || '') + .trim() + .toLowerCase() + const platform = String(order.platform || '') + .trim() + .toLowerCase() + return provider === 'douyin_mashida' || platform === 'doudian' + }, + + build(order, item) { + const { snapshot, quantity } = readItemSnapshotFields(item) + const internalSku = normalizeMatchText(item.sku_code || item.sku_name) + const promoTitle = normalizeMatchText(snapshot.productName || snapshot.productTitle || '') + return { + sourceKey: 'douyin-mashida', + provider: String(order.provider || ''), + platform: String(order.platform || ''), + shopKeys: [buildShopKey('douyin', order.shop_id)].filter(Boolean), + hasSellerContext: false, + item: { + itemId: normalizeMatchId(snapshot.productId || snapshot.product_id), + relItemId: normalizeMatchId(snapshot.outProductId || snapshot.out_product_id), + skuId: normalizeMatchId(snapshot.skuId || snapshot.sku_id), + relSkuId: normalizeExternalMatchId(snapshot.outSkuId || snapshot.out_sku_id), + skuCodes: uniqueKeys( + [item.sku_code, snapshot.externalSkuCode, snapshot.outSkuId, snapshot.outProductId].map( + normalizeMatchId, + ), + ), + skuNick: internalSku, + // 只有内部 SKU 参与文本匹配:sku_name 在历史单里可能是引流长串,仅在 sku_code 缺失时兜底。 + skuTexts: normalizeTextList([ + String(item.sku_code || '').trim() ? item.sku_code : item.sku_name, + ]), + // 抖音的商品标题就是引流词表:只放 promoTitle,itemTitle 留空避免被任何条件命中。 + itemTitle: '', + promoTitle, + }, + quantity, + raw: snapshot, + } + }, + + allowsTextFallback() { + return true + }, +} diff --git a/apps/backend/src/services/product-match/adapters/kuaishou-91.ts b/apps/backend/src/services/product-match/adapters/kuaishou-91.ts new file mode 100644 index 00000000..305da65d --- /dev/null +++ b/apps/backend/src/services/product-match/adapters/kuaishou-91.ts @@ -0,0 +1,74 @@ +import { normalizeExternalMatchId, normalizeMatchId, normalizeMatchText } from '../normalize.js' +import { + buildShopKey, + legacySkuCodeCandidates, + legacySkuTextCandidates, + readItemSnapshotFields, + uniqueKeys, +} from './shared.js' +import type { ProductMatchAdapter } from './types.js' + +/** + * 来源:91 回调 + 快手 send-code(一个来源,两条数据路)。 + * + * - 91 回调是订单骨架权威源,快手 send-code 是数据补全源与兜底源; + * - 店铺键命名空间 `kuaishou:`:卖家上下文取 send-code sellerId,同时兼容订单 shop_id + * (旧实现规则级 shop_id 判定用的是订单 shop_id); + * - SKU 文本候选沿用旧实现列表,保证 91 侧既网匹配结果不变。 + */ +export const kuaishou91Adapter: ProductMatchAdapter = { + sourceKey: 'kuaishou-91', + + matches(order) { + const provider = String(order.provider || '') + .trim() + .toLowerCase() + const platform = String(order.platform || '') + .trim() + .toLowerCase() + return provider === '91kaquan' || platform === 'kuaishou' + }, + + build(order, item) { + const { snapshot, sendCode, outSkuSn, quantity } = readItemSnapshotFields(item) + const sellerId = String(sendCode.sellerId || '').trim() + return { + sourceKey: 'kuaishou-91', + provider: String(order.provider || ''), + platform: String(order.platform || ''), + shopKeys: uniqueKeys([ + buildShopKey('kuaishou', sellerId), + buildShopKey('kuaishou', order.shop_id), + ]), + hasSellerContext: Boolean(sellerId), + item: { + itemId: normalizeMatchId(sendCode.itemId), + relItemId: normalizeExternalMatchId( + sendCode.relItemId || snapshot.goodsId || snapshot.goods_id, + ), + skuId: normalizeMatchId(sendCode.skuId), + relSkuId: normalizeExternalMatchId(sendCode.relSkuId || outSkuSn || snapshot.spec), + skuCodes: legacySkuCodeCandidates({ item, snapshot, outSkuSn }), + skuNick: normalizeMatchText( + sendCode.skuNick || + sendCode.skuName || + outSkuSn || + snapshot.externalSkuName || + snapshot.spec, + ), + skuTexts: legacySkuTextCandidates({ item, snapshot, sendCode, outSkuSn }), + itemTitle: normalizeMatchText( + sendCode.itemTitle || snapshot.goodsName || snapshot.goods_name || '', + ), + promoTitle: normalizeMatchText(snapshot.productTitle || ''), + }, + quantity, + raw: snapshot, + } + }, + + allowsTextFallback({ hasSellerContext, legacyFallbackEnabled }) { + // 旧语义:无卖家上下文时无条件兜底;有卖家上下文时需后台显式开启。 + return !hasSellerContext || legacyFallbackEnabled + }, +} diff --git a/apps/backend/src/services/product-match/adapters/pdd.ts b/apps/backend/src/services/product-match/adapters/pdd.ts new file mode 100644 index 00000000..4367a7d2 --- /dev/null +++ b/apps/backend/src/services/product-match/adapters/pdd.ts @@ -0,0 +1,59 @@ +import { normalizeExternalMatchId, normalizeMatchId, normalizeMatchText } from '../normalize.js' +import { + buildShopKey, + legacySkuCodeCandidates, + legacySkuTextCandidates, + readItemSnapshotFields, +} from './shared.js' +import type { ProductMatchAdapter } from './types.js' + +/** + * 来源:拼多多。 + * + * 店铺键命名空间 `pdd:`;SKU 文本候选沿用旧实现(`out_sku_sn` 是拼多多的主匹配键); + * 旧实现里拼多多允许用模板名兜底,这里保持一致(过渡期兼容,P3 换成显式条目后消失)。 + */ +export const pddAdapter: ProductMatchAdapter = { + sourceKey: 'pdd', + + matches(order) { + const provider = String(order.provider || '') + .trim() + .toLowerCase() + const platform = String(order.platform || '') + .trim() + .toLowerCase() + return provider === 'pdd' || platform === 'pdd' + }, + + build(order, item) { + const { snapshot, sendCode, outSkuSn, quantity } = readItemSnapshotFields(item) + const goodsId = String(snapshot.goods_id || snapshot.goodsId || '').trim() + const spec = String(snapshot.spec || '').trim() + const goodsName = String(snapshot.goods_name || snapshot.goodsName || '').trim() + return { + sourceKey: 'pdd', + provider: String(order.provider || ''), + platform: String(order.platform || ''), + shopKeys: [buildShopKey('pdd', order.shop_id)].filter(Boolean), + hasSellerContext: false, + item: { + itemId: normalizeMatchId(goodsId), + relItemId: normalizeMatchId(goodsId), + skuId: '', + relSkuId: normalizeExternalMatchId(outSkuSn || spec), + skuCodes: legacySkuCodeCandidates({ item, snapshot, outSkuSn }), + skuNick: normalizeMatchText(outSkuSn || spec || snapshot.externalSkuName || ''), + skuTexts: legacySkuTextCandidates({ item, snapshot, sendCode, outSkuSn }), + itemTitle: normalizeMatchText(goodsName), + promoTitle: '', + }, + quantity, + raw: snapshot, + } + }, + + allowsTextFallback() { + return true + }, +} diff --git a/apps/backend/src/services/product-match/adapters/registry.ts b/apps/backend/src/services/product-match/adapters/registry.ts new file mode 100644 index 00000000..a4464803 --- /dev/null +++ b/apps/backend/src/services/product-match/adapters/registry.ts @@ -0,0 +1,65 @@ +import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' +import { normalizeMatchId, normalizeMatchText, normalizeTextList } from '../normalize.js' +import { buildShopKey } from './shared.js' +import { douyinMashidaAdapter } from './douyin-mashida.js' +import { kuaishou91Adapter } from './kuaishou-91.js' +import { pddAdapter } from './pdd.js' +import type { ProductMatchAdapter } from './types.js' + +/** + * 兜底适配器:未知来源。 + * + * 只使用订单项自身的标准字段,不做任何文本兜底(宁可未命中,也不猜)。 + */ +const unknownAdapter: ProductMatchAdapter = { + sourceKey: 'unknown', + + matches() { + return true + }, + + build(order, item) { + const snapshot = + item.item_snapshot_json && typeof item.item_snapshot_json === 'object' + ? (item.item_snapshot_json as Record) + : {} + return { + sourceKey: 'unknown', + provider: String(order.provider || ''), + platform: String(order.platform || ''), + shopKeys: [buildShopKey(String(order.provider || 'unknown'), order.shop_id)].filter(Boolean), + hasSellerContext: false, + item: { + itemId: normalizeMatchId(snapshot.productId || snapshot.goods_id || snapshot.goodsId), + relItemId: normalizeMatchId(snapshot.outProductId || snapshot.out_sku_sn), + skuId: normalizeMatchId(snapshot.skuId || snapshot.sku_id), + relSkuId: normalizeMatchId(snapshot.outSkuId || snapshot.outSkuSn), + skuCodes: normalizeTextList([item.sku_code]).map(normalizeMatchId).filter(Boolean), + skuNick: normalizeMatchText(item.sku_name || item.sku_code), + skuTexts: normalizeTextList([item.sku_code, item.sku_name]), + itemTitle: normalizeMatchText(snapshot.productName || snapshot.goods_name || ''), + promoTitle: normalizeMatchText(snapshot.productTitle || ''), + }, + quantity: Math.max(1, Number(item.quantity || 1) || 1), + raw: snapshot, + } + }, + + allowsTextFallback() { + return false + }, +} + +/** 适配器注册表:顺序=优先级(抖音/拼多多/快手91 互斥,最后兜底)。 */ +const ADAPTERS: ProductMatchAdapter[] = [douyinMashidaAdapter, pddAdapter, kuaishou91Adapter] + +export function resolveProductMatchAdapter(order: OrderRow): ProductMatchAdapter { + return ADAPTERS.find((adapter) => adapter.matches(order)) ?? unknownAdapter +} + +export function resolveProductMatchSourceKey(order: OrderRow) { + return resolveProductMatchAdapter(order).sourceKey +} + +export type { ProductMatchAdapter } +export type AdapterInput = { order: OrderRow; item: OrderItemRow } diff --git a/apps/backend/src/services/product-match/adapters/shared.ts b/apps/backend/src/services/product-match/adapters/shared.ts new file mode 100644 index 00000000..9fa2da88 --- /dev/null +++ b/apps/backend/src/services/product-match/adapters/shared.ts @@ -0,0 +1,74 @@ +import type { JsonObject } from '../../../types/json.js' +import type { OrderItemRow } from '../../../types/repository/rows.js' +import { safeParseJson } from '../../admin/admin-query-utils.js' +import { normalizeMatchId, normalizeTextList } from '../normalize.js' + +export type ItemSnapshotFields = { + snapshot: JsonObject + sendCode: JsonObject + outSkuSn: string + quantity: number +} + +/** 读取订单项快照与快手 send-code 上下文(缺失时为空对象)。 */ +export function readItemSnapshotFields(item: OrderItemRow): ItemSnapshotFields { + const snapshot = safeParseJson(item.item_snapshot_json) + const sendCode = safeParseJson(snapshot.kuaishouSendCode) + return { + snapshot, + sendCode, + outSkuSn: String(snapshot.out_sku_sn || snapshot.outSkuSn || '').trim(), + quantity: Math.max(1, Number(item.quantity || 1) || 1), + } +} + +/** + * 旧实现的 SKU 文本候选列表(顺序即可信度): + * `[sku_name, externalSkuName, out_sku_sn, spec, externalSkuNameNormalized, sendCode.skuNick, sendCode.itemTitle]` + * + * 注意:引流标题(`snapshot.productTitle`)**不在**此列表内;抖音适配器只取内部 SKU。 + */ +export function legacySkuTextCandidates(input: { + item: OrderItemRow + snapshot: JsonObject + sendCode: JsonObject + outSkuSn: string +}): string[] { + const { item, snapshot, sendCode, outSkuSn } = input + return normalizeTextList([ + item.sku_name, + snapshot.externalSkuName, + outSkuSn, + snapshot.spec, + snapshot.externalSkuNameNormalized, + sendCode.skuNick, + sendCode.itemTitle, + ]) +} + +/** 旧实现的 SKU 编码候选列表:`[sku_code, externalSkuCode, out_sku_sn, externalItemId]`。 */ +export function legacySkuCodeCandidates(input: { + item: OrderItemRow + snapshot: JsonObject + outSkuSn: string +}): string[] { + const { item, snapshot, outSkuSn } = input + return Array.from( + new Set( + [item.sku_code, snapshot.externalSkuCode, outSkuSn, snapshot.externalItemId] + .map(normalizeMatchId) + .filter(Boolean), + ), + ) +} + +/** 命名空间化店铺键(带前缀,保持唯一且可跨来源共存)。 */ +export function buildShopKey(namespace: string, value: unknown): string { + const id = String(value ?? '').trim() + return id ? `${namespace}:${id}` : '' +} + +/** 去重后的非空键列表。 */ +export function uniqueKeys(keys: string[]): string[] { + return Array.from(new Set(keys.filter(Boolean))) +} diff --git a/apps/backend/src/services/product-match/adapters/types.ts b/apps/backend/src/services/product-match/adapters/types.ts new file mode 100644 index 00000000..e968791f --- /dev/null +++ b/apps/backend/src/services/product-match/adapters/types.ts @@ -0,0 +1,23 @@ +import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' +import type { ProductMatchContext, ProductMatchSourceKey } from '../types.js' + +/** + * 来源适配器:把某个来源的订单信息归一成平台无关的 ProductMatchContext。 + * 引擎只认上下文,任何平台差异都止步于此层。 + */ +export type ProductMatchAdapter = { + sourceKey: ProductMatchSourceKey + /** 该订单是否属于本来源。 */ + matches(order: OrderRow): boolean + /** 归一化订单信息。 */ + build(order: OrderRow, item: OrderItemRow): ProductMatchContext + /** + * 是否允许「模板名/商品名文本兜底」条目参与该来源的匹配。 + * + * 过渡兼容语义(与旧实现等价): + * - 无卖家上下文时旧实现无条件启用兜底; + * - 有卖家上下文(快手 send-code)时需后台显式开关; + * - 抖音恒为 false:抖音商品标题是引流关键词表,文本兜底必然误命中。 + */ + allowsTextFallback(input: { hasSellerContext: boolean; legacyFallbackEnabled: boolean }): boolean +} diff --git a/apps/backend/src/services/product-match/compile.ts b/apps/backend/src/services/product-match/compile.ts new file mode 100644 index 00000000..7f797b5c --- /dev/null +++ b/apps/backend/src/services/product-match/compile.ts @@ -0,0 +1,223 @@ +import type { + WorkProductRuleMappingRow, + WorkProductRuleRow, +} from '../../repositories/worker-platform/index.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { normalizeMatchId, normalizeMatchIdList, normalizeMatchText } from './normalize.js' +import type { ProductMatchCondition, ProductMatchEntry, ProductMatchTextMode } from './types.js' + +export type CompileLegacyEntriesOptions = { + /** + * 是否编译「模板名 / 商品名」文本条件(旧实现的"旧规则兜底")。 + * 由来源适配器的 allowsTextFallback 决定;关闭时来源只按确定性条件匹配。 + */ + allowTextFallback: boolean +} + +/** 条件强度层级:结构化条件 > SKU 精确映射 > 数量系列映射 > 商品默认映射 > 纯文本条件。 */ +const STRUCTURED_BASE_SCORE = 300 +const MAPPING_BASE_SCORE = { + sku_exact: 1000, + sku_series: 800, + product_default: 500, +} as const + +/** + * 把旧的「接单模板 + 商品绑定」编译成匹配条目(过渡兼容层)。 + * + * 这一层是**唯一**保留历史差异的地方:新表条目(P3)不经过它,引擎始终只有一套打分。 + * 编译必须与 `work-product-match-mappers.ts` 旧实现语义等价,改动需同步回放回归。 + */ +export function compileLegacyMatchEntries( + rules: WorkProductRuleRow[], + mappings: WorkProductRuleMappingRow[], + options: CompileLegacyEntriesOptions, +): ProductMatchEntry[] { + const entries: ProductMatchEntry[] = [] + const rulesById = new Map(rules.map((rule) => [Number(rule.id), rule])) + + for (const rule of rules) { + if (!rule.enabled) continue + entries.push(...buildRuleEntries(rule, options)) + } + + for (const mapping of mappings) { + if (!mapping.enabled) continue + const rule = rulesById.get(Number(mapping.rule_id)) + if (!rule || !rule.enabled) continue + const entry = buildMappingEntry(rule, mapping) + if (entry) entries.push(entry) + } + + return entries +} + +function buildRuleEntries( + rule: WorkProductRuleRow, + options: CompileLegacyEntriesOptions, +): ProductMatchEntry[] { + const entries: ProductMatchEntry[] = [] + const shopKeys = normalizeMatchId(rule.shop_id) + ? [`kuaishou:${normalizeMatchId(rule.shop_id)}`] + : [] + + // 结构化 match_json:旧实现只在有卖家上下文时生效,且要求所有已配置字段全部命中。 + const match = safeParseJson(rule.match_json) + const structuredAll: ProductMatchCondition[] = [] + const structuredShopKeys = [...shopKeys] + const matchSellerId = normalizeMatchId(match.sellerId) + if (matchSellerId) structuredShopKeys.push(`kuaishou:${matchSellerId}`) + const matchItemId = normalizeMatchId(match.itemId) + if (matchItemId) structuredAll.push({ field: 'itemId', value: matchItemId, mode: 'exact' }) + const matchRelItemId = normalizeMatchId(match.relItemId) + if (matchRelItemId) + structuredAll.push({ field: 'relItemId', value: matchRelItemId, mode: 'exact' }) + const matchSkuId = normalizeMatchId(match.skuId) + if (matchSkuId) structuredAll.push({ field: 'skuId', value: matchSkuId, mode: 'exact' }) + const matchRelSkuId = normalizeMatchId(match.relSkuId) + if (matchRelSkuId) structuredAll.push({ field: 'relSkuId', value: matchRelSkuId, mode: 'exact' }) + const matchItemTitle = normalizeMatchText(match.itemTitle) + if (matchItemTitle) { + structuredAll.push({ + field: 'itemTitle', + value: matchItemTitle, + mode: match.itemTitleMatchType === 'exact' ? 'exact' : 'contains', + }) + } + const matchSkuNick = normalizeMatchText(match.skuNick) + if (matchSkuNick) { + structuredAll.push({ + field: 'skuNick', + value: matchSkuNick, + mode: match.skuNickMatchType === 'exact' ? 'exact' : 'contains', + }) + } + // 只配了 sellerId 的"空条件结构化"(旧实现会整店命中)不编译:属于历史脏配置, + // 新模型一律要求显式判定维度(见 docs/接单模板匹配引擎重构设计.md §2.3)。 + if (structuredAll.length > 0) { + entries.push({ + key: `legacy:rule:${rule.id}:structured`, + ruleId: Number(rule.id), + mappingId: null, + rule, + all: structuredAll, + any: [], + bonus: [], + sourceKeys: [], + shopKeys: uniqueKeys(structuredShopKeys), + qtyMin: 0, + qtyMax: 0, + baseScore: STRUCTURED_BASE_SCORE, + fromRuleName: false, + }) + } + + if (!options.allowTextFallback) return entries + + // 模板名/商品名文本条件 + 模板编码条件(旧实现:任一命中即可)。 + const any: ProductMatchCondition[] = [] + const productName = normalizeMatchText(rule.product_name) + if (productName) { + any.push({ + field: 'skuText', + value: productName, + mode: normalizeRuleMatchType(rule.match_type), + }) + } + const skuCode = normalizeMatchId(rule.sku_code) + if (skuCode) any.push({ field: 'skuCode', value: skuCode, mode: 'exact' }) + if (any.length > 0) { + entries.push({ + key: `legacy:rule:${rule.id}:name`, + ruleId: Number(rule.id), + mappingId: null, + rule, + all: [], + any, + bonus: [], + sourceKeys: [], + shopKeys, + qtyMin: 0, + qtyMax: 0, + baseScore: 0, + fromRuleName: true, + }) + } + + return entries +} + +function buildMappingEntry( + rule: WorkProductRuleRow, + mapping: WorkProductRuleMappingRow, +): ProductMatchEntry | 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 shopKeys = normalizeMatchIdList(mapping.seller_ids_json).map( + (sellerId) => `kuaishou:${sellerId}`, + ) + const mappingItemIds = normalizeMatchIdList(mapping.rel_item_id) + const mappingItemTitle = normalizeMatchText(mapping.item_title) + const mappingSkuNick = normalizeMatchText(mapping.sku_nick) + const mappingRelSkuId = normalizeMatchId(mapping.rel_sku_id) + + const all: ProductMatchCondition[] = [] + const any: ProductMatchCondition[] = [] + const bonus: ProductMatchCondition[] = [] + + if (mappingType === 'product_default') { + // 旧实现:配了商品 ID 就要求商品 ID 命中(标题只加分);只配标题则要求标题命中。 + if (mappingItemIds.length > 0) { + for (const itemId of mappingItemIds) + any.push({ field: 'relItemId', value: itemId, mode: 'exact' }) + if (mappingItemTitle) { + bonus.push({ field: 'itemTitle', value: mappingItemTitle, mode: 'contains' }) + } + } else if (mappingItemTitle) { + any.push({ field: 'itemTitle', value: mappingItemTitle, mode: 'contains' }) + } + } else if (mappingType === 'sku_exact') { + // 旧实现:SKU 规格文本或关联 SKU 任一命中即可(两者都命中时得分更高)。 + if (mappingRelSkuId) any.push({ field: 'relSkuId', value: mappingRelSkuId, mode: 'exact' }) + if (mappingSkuNick) any.push({ field: 'skuNick', value: mappingSkuNick, mode: 'exact' }) + } else { + // SKU 数量系列:默认模糊(兼容 秘钥/密钥 写法差异),exact 时要求完全一致。 + if (mappingSkuNick) { + any.push({ + field: 'skuNick', + value: mappingSkuNick, + mode: mapping.sku_match_mode === 'exact' ? 'series-exact' : 'series', + }) + } + } + + if (any.length === 0 && all.length === 0) return null + + return { + key: `legacy:mapping:${mapping.id}`, + ruleId: Number(rule.id), + mappingId: Number(mapping.id), + rule, + all, + any, + bonus, + sourceKeys: [], + shopKeys, + qtyMin: 0, + qtyMax: 0, + baseScore: MAPPING_BASE_SCORE[mappingType], + fromRuleName: false, + } +} + +function normalizeRuleMatchType(value: unknown): ProductMatchTextMode { + return String(value ?? '').trim() === 'exact' ? 'exact' : 'contains' +} + +function uniqueKeys(values: string[]): string[] { + return Array.from(new Set(values.filter(Boolean))) +} diff --git a/apps/backend/src/services/product-match/context.ts b/apps/backend/src/services/product-match/context.ts new file mode 100644 index 00000000..c1151a82 --- /dev/null +++ b/apps/backend/src/services/product-match/context.ts @@ -0,0 +1,49 @@ +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { safeParseJson } from '../admin/admin-query-utils.js' +import { resolveProductMatchAdapter } from './adapters/registry.js' +import { normalizeExternalMatchId } from './normalize.js' +import type { ProductMatchContext } from './types.js' + +/** 构建平台无关匹配上下文(来源适配器负责字段归一)。 */ +export function buildProductMatchContext(order: OrderRow, item: OrderItemRow): ProductMatchContext { + return resolveProductMatchAdapter(order).build(order, item) +} + +/** 该来源是否允许「模板名/商品名文本兜底」条目参与匹配(过渡兼容策略)。 */ +export function isTextFallbackAllowed(input: { + order: OrderRow + hasSellerContext: boolean + legacyFallbackEnabled: boolean +}): boolean { + return resolveProductMatchAdapter(input.order).allowsTextFallback({ + hasSellerContext: input.hasSellerContext, + legacyFallbackEnabled: input.legacyFallbackEnabled, + }) +} + +/** + * 兼容旧投影:快手发码上下文(后台匹配测试与日志仍按这些字段展示)。 + */ +export function resolveKuaishouWorkProductMatchContext( + item: Pick, +) { + 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(), + } +} diff --git a/apps/backend/src/services/product-match/engine.ts b/apps/backend/src/services/product-match/engine.ts new file mode 100644 index 00000000..8d94a12c --- /dev/null +++ b/apps/backend/src/services/product-match/engine.ts @@ -0,0 +1,273 @@ +import { + normalizeMatchId, + normalizeMatchText, + normalizeSkuSeriesEquivalence, + normalizeSkuSeriesName, +} from './normalize.js' +import type { + ProductMatchCandidate, + ProductMatchCondition, + ProductMatchConditionField, + ProductMatchContext, + ProductMatchDecision, + ProductMatchDiagnosisItem, + ProductMatchEntry, +} from './types.js' + +type EvaluatedCondition = { matched: boolean; weight: number; specificity: number } + +/** 标识类字段:按标识语义(小写、'0' 视为空)比较。 */ +const ID_FIELDS = new Set([ + 'skuCode', + 'skuId', + 'relSkuId', + 'itemId', + 'relItemId', +]) + +const EXACT_WEIGHTS: Record = { + skuCode: 500, + skuId: 900, + relSkuId: 900, + skuText: 800, + skuNick: 800, + itemId: 300, + relItemId: 300, + itemTitle: 200, +} + +const CONTAINS_WEIGHTS: Record = { + skuCode: 400, + skuId: 500, + relSkuId: 500, + skuText: 400, + skuNick: 400, + itemId: 200, + relItemId: 200, + itemTitle: 200, +} + +/** 数量系列(剥离「5个」后的系列名)命中权重:介于编码精确与文本包含之间。 */ +const SERIES_WEIGHT = 600 + +/** 文本包含命中时的"具体度"加成上限:更长的模板名更具体(旧实现语义,见回放回归)。 */ +const SPECIFICITY_CAP = 80 + +/** + * 匹配引擎:纯函数,只读上下文与条目,不碰数据库、不判平台。 + * + * 规则: + * - 来源/店铺条件:配置了就必须命中(未配置=不限); + * - all:全部命中;any:至少命中一个;bonus:命中加分、不命中不淘汰; + * - 最高分唯一 → matched;并列最高 → ambiguous(不猜);无候选 → unmatched。 + */ +export function resolveProductMatchDecision( + context: ProductMatchContext, + entries: ProductMatchEntry[], +): ProductMatchDecision { + const passed: Array<{ entry: ProductMatchEntry; score: number }> = [] + const diagnosis: ProductMatchDiagnosisItem[] = [] + + for (const entry of entries) { + const blockedBy: string[] = [] + + if (entry.sourceKeys.length > 0 && !entry.sourceKeys.includes(context.sourceKey)) { + blockedBy.push( + `source_mismatch(entry=${entry.sourceKeys.join('/')}, order=${context.sourceKey})`, + ) + } + if ( + entry.shopKeys.length > 0 && + !entry.shopKeys.some((shopKey) => context.shopKeys.includes(shopKey)) + ) { + blockedBy.push( + `shop_mismatch(entry=${entry.shopKeys.join('/')}, order=${context.shopKeys.join('/') || '-'})`, + ) + } + if (entry.qtyMin > 0 && context.quantity < entry.qtyMin) { + blockedBy.push(`quantity_below_min(entry=${entry.qtyMin}, order=${context.quantity})`) + } + if (entry.qtyMax > 0 && context.quantity > entry.qtyMax) { + blockedBy.push(`quantity_above_max(entry=${entry.qtyMax}, order=${context.quantity})`) + } + + const scored: EvaluatedCondition[] = [] + for (const condition of entry.all) { + const evaluated = evaluateCondition(condition, context) + if (!evaluated.matched) { + blockedBy.push(`all_not_matched:${describeCondition(condition)}`) + continue + } + scored.push(evaluated) + } + + if (entry.any.length > 0) { + const anyMatched = entry.any + .map((condition) => evaluateCondition(condition, context)) + .filter((evaluated) => evaluated.matched) + if (anyMatched.length === 0) { + blockedBy.push(`any_not_matched:${entry.any.map(describeCondition).join('|')}`) + } else { + scored.push(...anyMatched) + } + } + + if (blockedBy.length > 0) { + diagnosis.push({ key: entry.key, ruleKey: entry.rule.rule_key, blockedBy }) + continue + } + + for (const condition of entry.bonus) { + const evaluated = evaluateCondition(condition, context) + if (evaluated.matched) scored.push(evaluated) + } + + const score = + entry.baseScore + + scored.reduce((sum, evaluated) => sum + evaluated.weight + evaluated.specificity, 0) + passed.push({ entry, score }) + } + + if (passed.length === 0) { + return { + status: 'unmatched', + rule: null, + mappingId: null, + entryKey: null, + score: 0, + candidates: [], + diagnosis: diagnosis.slice(0, 10), + } + } + + passed.sort((left, right) => right.score - left.score || right.entry.ruleId - left.entry.ruleId) + const top = passed[0] + if (!top) { + return { + status: 'unmatched', + rule: null, + mappingId: null, + entryKey: null, + score: 0, + candidates: [], + diagnosis: diagnosis.slice(0, 10), + } + } + + const tied = passed.filter((candidate) => candidate.score === top.score) + if (tied.length > 1) { + return { + status: 'ambiguous', + rule: null, + mappingId: null, + entryKey: null, + score: top.score, + candidates: tied.slice(0, 5).map(toCandidate), + diagnosis: diagnosis.slice(0, 10), + } + } + + return { + status: 'matched', + rule: top.entry.rule, + mappingId: top.entry.mappingId, + entryKey: top.entry.key, + score: top.score, + candidates: passed.slice(0, 5).map(toCandidate), + diagnosis: [], + } +} + +function toCandidate(input: { entry: ProductMatchEntry; score: number }): ProductMatchCandidate { + return { + key: input.entry.key, + ruleKey: input.entry.rule.rule_key, + ruleId: input.entry.ruleId, + mappingId: input.entry.mappingId, + score: input.score, + } +} + +function describeCondition(condition: ProductMatchCondition): string { + return `${condition.field}:${condition.mode}:${condition.value}` +} + +function contextValues(context: ProductMatchContext, field: ProductMatchConditionField): string[] { + switch (field) { + case 'skuText': + return context.item.skuTexts + case 'skuNick': + return [context.item.skuNick] + case 'skuCode': + return context.item.skuCodes + case 'skuId': + return [context.item.skuId] + case 'relSkuId': + return [context.item.relSkuId] + case 'itemId': + return [context.item.itemId] + case 'relItemId': + return [context.item.relItemId] + case 'itemTitle': + return [context.item.itemTitle] + default: + return [] + } +} + +function evaluateCondition( + condition: ProductMatchCondition, + context: ProductMatchContext, +): EvaluatedCondition { + const expected = condition.value + if (!expected) return { matched: false, weight: 0, specificity: 0 } + + const isIdField = ID_FIELDS.has(condition.field) + const values = contextValues(context, condition.field) + + for (const value of values) { + if (!String(value || '').trim()) continue + + if (condition.mode === 'series' || condition.mode === 'series-exact') { + // series=fuzzy(兼容 秘钥/密钥 写法差异);series-exact 只剥数量,要求系列名完全一致。 + const actualSeries = + condition.mode === 'series' + ? normalizeSkuSeriesEquivalence(value) + : normalizeSkuSeriesName(value) + const expectedSeries = + condition.mode === 'series' + ? normalizeSkuSeriesEquivalence(expected) + : normalizeSkuSeriesName(expected) + if (!actualSeries || !expectedSeries) continue + const matched = + condition.mode === 'series-exact' + ? actualSeries === expectedSeries + : actualSeries.includes(expectedSeries) || expectedSeries.includes(actualSeries) + if (matched) return { matched: true, weight: SERIES_WEIGHT, specificity: 0 } + continue + } + + if (condition.mode === 'exact') { + const matched = isIdField + ? normalizeMatchId(value) === normalizeMatchId(expected) + : normalizeMatchText(value) === normalizeMatchText(expected) + if (matched) { + return { matched: true, weight: EXACT_WEIGHTS[condition.field], specificity: 0 } + } + continue + } + + const matched = isIdField + ? normalizeMatchId(value).includes(normalizeMatchId(expected)) + : normalizeMatchText(value).includes(normalizeMatchText(expected)) + if (matched) { + return { + matched: true, + weight: CONTAINS_WEIGHTS[condition.field], + specificity: isIdField ? 0 : Math.min(SPECIFICITY_CAP, normalizeMatchText(expected).length), + } + } + } + + return { matched: false, weight: 0, specificity: 0 } +} diff --git a/apps/backend/src/services/product-match/index.ts b/apps/backend/src/services/product-match/index.ts new file mode 100644 index 00000000..e96e841d --- /dev/null +++ b/apps/backend/src/services/product-match/index.ts @@ -0,0 +1,45 @@ +/** + * 接单模板匹配引擎(平台无关)对外出口。 + * + * - 来源适配器:`kuaishou-91`(91 回调 + 快手 send-code 合成一个来源)/ `pdd` / `douyin-mashida`; + * - 引擎:条件打分 + 决策(并列最高=ambiguous,不猜)+ 诊断(差在哪一维); + * - 价格/数量/拼单:`pricing.ts` 唯一实现。 + * + * 设计见 docs/接单模板匹配引擎重构设计.md。 + */ +export type { + ProductMatchCandidate, + ProductMatchCondition, + ProductMatchConditionField, + ProductMatchContext, + ProductMatchDecision, + ProductMatchDiagnosisItem, + ProductMatchEntry, + ProductMatchSourceKey, + ProductMatchTextMode, +} from './types.js' +export { compileLegacyMatchEntries, type CompileLegacyEntriesOptions } from './compile.js' +export { + buildProductMatchContext, + isTextFallbackAllowed, + resolveKuaishouWorkProductMatchContext, +} from './context.js' +export { resolveProductMatchDecision } from './engine.js' +export { + resolveProductMatchAdapter, + resolveProductMatchSourceKey, + type ProductMatchAdapter, +} from './adapters/registry.js' +export { + isWorkOrderSharingFilled, + resolveSkuNameQuantity, + resolveWorkOrderQuantity, + resolveWorkOrderRulePricing, + resolveWorkOrderSharingEnabled, +} from './pricing.js' +export { + normalizeMatchId, + normalizeMatchText, + normalizeSkuSeriesEquivalence, + parseSkuTextQuantity, +} from './normalize.js' diff --git a/apps/backend/src/services/product-match/normalize.ts b/apps/backend/src/services/product-match/normalize.ts new file mode 100644 index 00000000..2071be52 --- /dev/null +++ b/apps/backend/src/services/product-match/normalize.ts @@ -0,0 +1,122 @@ +/** + * 匹配用的文本/标识归一化。语义与旧实现(work-product-match-mappers.ts)保持一致, + * 以免在抽离过程中改变既网匹配结果。 + */ + +/** 文本归一:NFKC、小写、去空白。 */ +export function normalizeMatchText(value: unknown): string { + return String(value ?? '') + .normalize('NFKC') + .toLowerCase() + .replace(/[\s\u3000]+/g, '') + .trim() +} + +/** 标识归一:去首尾空格、小写;`'0'` 视为空。 */ +export function normalizeMatchId(value: unknown): string { + const normalized = String(value ?? '').trim() + return normalized === '0' ? '' : normalized.toLowerCase() +} + +/** 关联标识归一:仅把 `'0'` 视为空,保留大小写(与旧实现一致)。 */ +export function normalizeExternalMatchId(value: unknown): string { + const normalized = String(value ?? '').trim() + return normalized === '0' ? '' : normalized +} + +/** 去重后的标识列表。 */ +export function normalizeMatchIdList(value: unknown): string[] { + 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), + ), + ) +} + +/** 归一后的非空文本列表(保序去重)。 */ +export function normalizeTextList(values: unknown[]): string[] { + const out: string[] = [] + for (const value of values) { + const normalized = normalizeMatchText(value) + if (normalized && !out.includes(normalized)) out.push(normalized) + } + return out +} + +/** + * 去除 SKU 中的数量规格后得到系列名(「指挥官密钥10个」→「指挥官密钥」)。 + */ +export function normalizeSkuSeriesName(value: unknown): string { + return normalizeMatchText(value).replace( + /(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g, + '', + ) +} + +/** 异体字归一:秘钥/密匙/秘匙 → 密钥(同一商品的常见写法差异)。 */ +export function normalizeSkuEquivalence(value: string): string { + return value.replace(/秘钥|密匙|秘匙/g, '密钥') +} + +/** 数量系列文本(剥数量 + 异体字归一)。 */ +export function normalizeSkuSeriesEquivalence(value: unknown): string { + return normalizeSkuEquivalence(normalizeSkuSeriesName(value)) +} + +/** 从 SKU 文本里解析数量;解析不到返回 null。 */ +export function parseSkuTextQuantity(productName: unknown): number | null { + const text = String(productName ?? '').trim() + if (!text) return null + + const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/) + if (arabicMatch?.[1]) { + 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 null +} + +const CHINESE_DIGITS: Record = { + 零: 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 +} diff --git a/apps/backend/src/services/product-match/pricing.ts b/apps/backend/src/services/product-match/pricing.ts new file mode 100644 index 00000000..4f013ec2 --- /dev/null +++ b/apps/backend/src/services/product-match/pricing.ts @@ -0,0 +1,69 @@ +import { parseSkuTextQuantity } from './normalize.js' + +/** + * 数量与计价(全项目唯一实现):数量归一 → 单价 × 数量 / 固定金额 → 拼单份数。 + * + * 发单建工单、后台改单、模板改价同步大厅、寄卖建议价全部走这里,避免同口径多处实现。 + */ + +/** 从商品名/SKU 文本解析数量(解析不到按 1 计)。 */ +export function resolveSkuNameQuantity(productName: unknown): number { + return parseSkuTextQuantity(productName) ?? 1 +} + +/** + * 工单数量归一:优先取 SKU 文本中的规格数量(如「指挥官密钥5个」→ 5), + * 文本无数量时回落到订单项数量字段。 + */ +export function resolveWorkOrderQuantity(input: { + skuText: unknown + fallbackQuantity: unknown +}): number { + const parsed = parseSkuTextQuantity(input.skuText) + if (parsed !== null) return parsed + return Math.max(1, Number(input.fallbackQuantity || 1) || 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) + ) +} diff --git a/apps/backend/src/services/product-match/product-match.test.ts b/apps/backend/src/services/product-match/product-match.test.ts new file mode 100644 index 00000000..cbe2aef6 --- /dev/null +++ b/apps/backend/src/services/product-match/product-match.test.ts @@ -0,0 +1,367 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { + WorkProductRuleMappingRow, + WorkProductRuleRow, +} from '../../repositories/worker-platform/index.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { + buildProductMatchContext, + compileLegacyMatchEntries, + resolveProductMatchDecision, + resolveProductMatchSourceKey, + resolveWorkOrderQuantity, + resolveWorkOrderRulePricing, +} from './index.js' + +function buildOrder(overrides: Partial = {}): OrderRow { + return { + id: 1, + provider: '91kaquan', + platform: 'kuaishou', + shop_id: '3676797936', + shop_name: '测试店铺', + platform_order_id: '2623001922675146', + order_status: 'paid', + pay_status: 'paid', + buyer_id: '', + buyer_name: '', + receiver_contact: '', + total_amount: 9900, + currency: 'CNY', + raw_payload_json: {}, + paid_at: null, + created_at: '2026-10-14T00:00:00.000Z', + updated_at: '2026-10-14T00:00:00.000Z', + ...overrides, + } +} + +function buildItem(overrides: Partial = {}): OrderItemRow { + return { + id: 1, + order_id: 1, + sku_code: '指挥官密钥5个', + sku_name: '指挥官密钥5个', + quantity: 1, + spec_json: {}, + item_snapshot_json: {}, + created_at: '2026-10-14T00:00:00.000Z', + updated_at: '2026-10-14T00:00:00.000Z', + ...overrides, + } +} + +function buildRule(overrides: Partial = {}): WorkProductRuleRow { + return { + id: 1, + rule_key: 'rule-1', + provider: '', + platform: '', + shop_id: '', + sku_code: '', + product_name: '', + match_type: 'contains', + category_id: null, + enabled: true, + auto_create: true, + reward_amount: 0, + unit_price_fen: 0, + required_deposit_amount: 0, + deposit_threshold_amount: 20000, + sharing_enabled: false, + sharing_total_quantity: 1, + sharing_unit_reward: 0, + sharing_auto_from_order: false, + timeout_minutes: 0, + timeout_policy: 'reopen', + requirement_json: {}, + match_json: {}, + sort_order: 100, + created_at: '2026-10-14T00:00:00.000Z', + updated_at: '2026-10-14T00:00:00.000Z', + ...overrides, + } +} + +function buildMapping( + overrides: Partial = {}, +): WorkProductRuleMappingRow { + return { + id: 1, + rule_id: 1, + seller_ids_json: ['3676797936'], + rel_item_id: '', + item_title: '', + rel_sku_id: '', + sku_nick: '', + sku_match_mode: 'fuzzy', + mapping_type: 'sku_series', + enabled: true, + created_at: '2026-10-14T00:00:00.000Z', + updated_at: '2026-10-14T00:00:00.000Z', + ...overrides, + } +} + +/** 抖音真实形态:内部 SKU 干净,商品标题是引流关键词表。 */ +function buildDouyinItem(): OrderItemRow { + const promoTitle = '和平精英指挥官密钥秘钥出售隐藏款密钥碎梦浮光珍宝礼盒潮汐礼券' + return buildItem({ + id: 334088, + order_id: 334139, + sku_code: '指挥官密钥5个', + sku_name: '指挥官密钥5个', + item_snapshot_json: { + source: 'douyin_mashida', + shopId: '305760014', + productId: '3845284495162999020', + skuId: '3381288076340261', + outSkuId: '', + code: '指挥官密钥5个', + productName: promoTitle, + productTitle: promoTitle, + externalSkuName: promoTitle, + externalSkuCode: '指挥官密钥5个', + }, + }) +} + +const douyinOrder = buildOrder({ + id: 334139, + provider: 'douyin_mashida', + platform: 'doudian', + shop_id: '305760014', + shop_name: '向日葵游戏交易店', +}) + +const commanderRule = buildRule({ + id: 9, + rule_key: 'bb', + product_name: '指挥官密钥', + unit_price_fen: 950, + sharing_enabled: true, + sharing_auto_from_order: true, +}) + +const giftBoxRule = buildRule({ + id: 15, + rule_key: 'cc', + product_name: '碎梦浮光珍宝礼盒', + unit_price_fen: 85, +}) + +function decide(input: { + order: OrderRow + item: OrderItemRow + rules: WorkProductRuleRow[] + mappings?: WorkProductRuleMappingRow[] + legacyFallbackEnabled?: boolean +}) { + const context = buildProductMatchContext(input.order, input.item) + const entries = compileLegacyMatchEntries(input.rules, input.mappings ?? [], { + allowTextFallback: sourceAllowsTextFallback(input.order, context.hasSellerContext), + }) + return { context, decision: resolveProductMatchDecision(context, entries) } +} + +function sourceAllowsTextFallback(order: OrderRow, hasSellerContext: boolean): boolean { + // 与门面(work-product-match-mappers.ts)保持同一策略:这里只测"允许兜底"的路径。 + if (order.provider === 'douyin_mashida' || order.provider === 'pdd') return true + return !hasSellerContext +} + +test('抖音:引流标题不参与匹配,按内部 SKU 命中正确模板(不再命中碎梦浮光珍宝礼盒)', () => { + const { context, decision } = decide({ + order: douyinOrder, + item: buildDouyinItem(), + rules: [giftBoxRule, commanderRule], + }) + + assert.equal(context.sourceKey, 'douyin-mashida') + assert.deepEqual(context.shopKeys, ['douyin:305760014']) + assert.deepEqual(context.item.skuTexts, ['指挥官密钥5个']) + assert.equal(context.item.itemTitle, '') + assert.ok(context.item.promoTitle.includes('碎梦浮光珍宝礼盒')) + + assert.equal(decision.status, 'matched') + assert.equal(decision.rule?.rule_key, 'bb') +}) + +test('抖音:命中指挥官模板后按数量计价并开拼单(9.5 元/个 × 5 份)', () => { + const item = buildDouyinItem() + const skuQuantity = resolveWorkOrderQuantity({ + skuText: item.sku_code, + fallbackQuantity: item.quantity, + }) + const pricing = resolveWorkOrderRulePricing({ + skuQuantity, + unitPriceFen: commanderRule.unit_price_fen, + fixedRewardAmount: commanderRule.reward_amount, + sharingEnabled: commanderRule.sharing_enabled === true, + sharingAutoFromOrder: commanderRule.sharing_auto_from_order === true, + sharingTotalQuantity: commanderRule.sharing_total_quantity, + sharingUnitReward: commanderRule.sharing_unit_reward, + }) + + assert.equal(skuQuantity, 5) + assert.equal(pricing.sharingTotalQuantity, 5) + assert.equal(pricing.sharingUnitReward, 950) + assert.equal(pricing.rewardAmount, 4750) +}) + +test('店铺键按来源命名空间隔离:抖音订单不会命中快手店铺条件', () => { + const mapping = buildMapping({ + rule_id: commanderRule.id, + seller_ids_json: ['305760014'], + sku_nick: '指挥官密钥', + }) + const { decision } = decide({ + order: douyinOrder, + item: buildDouyinItem(), + rules: [commanderRule], + mappings: [mapping], + }) + + // 快手命名空间的映射不参与抖音匹配;命中来自抖音自身的 SKU 文本条件。 + assert.equal(decision.status, 'matched') + assert.equal(decision.mappingId, null) +}) + +test('抖音:内部 SKU 之外无文本可用时未命中(不猜),并给出店铺维度诊断', () => { + const rule = buildRule({ id: 15, rule_key: 'cc', product_name: '碎梦浮光珍宝礼盒' }) + const mapping = buildMapping({ + rule_id: rule.id, + seller_ids_json: ['1088713114'], + sku_nick: '碎梦浮光珍宝礼盒', + }) + const { decision } = decide({ + order: douyinOrder, + item: buildDouyinItem(), + rules: [rule], + mappings: [mapping], + }) + + assert.equal(decision.status, 'unmatched') + assert.ok( + decision.diagnosis.some((item) => item.blockedBy.some((r) => r.startsWith('shop_mismatch'))), + ) +}) + +test('拼多多:out_sku_sn 是主匹配键', () => { + const rule = buildRule({ id: 517, rule_key: 'pdd-rule', product_name: '安卓血宴1钻' }) + const order = buildOrder({ provider: 'pdd', platform: 'pdd', shop_id: 'xiangrikui' }) + const item = buildItem({ + sku_code: '安卓血宴1钻', + sku_name: '安卓血宴1钻', + item_snapshot_json: { + goods_id: '961856378217', + goods_name: '和平精英地铁逃生苏尔南暗潮怒嚎血宴', + spec: '安卓区(qv互通),苏尔南-血宴1钻', + out_sku_sn: '安卓血宴1钻', + }, + }) + + const { context, decision } = decide({ order, item, rules: [rule] }) + assert.deepEqual(context.shopKeys, ['pdd:xiangrikui']) + assert.equal(decision.status, 'matched') + assert.equal(decision.rule?.id, 517) +}) + +test('快手91:有卖家上下文且未开兜底时,模板名条件不参与匹配', () => { + const context = buildProductMatchContext( + buildOrder(), + buildItem({ + sku_name: '1个精英尊尚专属礼盒', + item_snapshot_json: { + kuaishouSendCode: { + sellerId: '3676797936', + itemTitle: '和平精英密钥指挥官特种兵侦察兵密钥', + skuNick: '1个精英尊尚专属礼盒', + }, + }, + }), + ) + const entries = compileLegacyMatchEntries( + [buildRule({ id: 2, rule_key: 'soldier', product_name: '特种兵' })], + [], + { allowTextFallback: !context.hasSellerContext }, + ) + + assert.equal(context.hasSellerContext, true) + const decision = resolveProductMatchDecision(context, entries) + assert.equal(decision.status, 'unmatched') + assert.deepEqual(decision.candidates, []) +}) + +test('快手91:映射受店铺条件约束,跨店铺不命中', () => { + const buildSenderItem = (sellerId: string) => + buildItem({ + sku_name: '指挥官密钥10个', + item_snapshot_json: { + kuaishouSendCode: { sellerId, skuNick: '指挥官密钥10个' }, + }, + }) + const mapping = buildMapping({ + rule_id: commanderRule.id, + seller_ids_json: ['3676797936'], + sku_nick: '指挥官密钥', + mapping_type: 'sku_series', + }) + + const hit = decide({ + order: buildOrder({ shop_id: '3676797936' }), + item: buildSenderItem('3676797936'), + rules: [commanderRule], + mappings: [mapping], + }) + assert.equal(hit.decision.status, 'matched') + assert.equal(hit.decision.mappingId, 1) + + const miss = decide({ + order: buildOrder({ shop_id: '9999999999' }), + item: buildSenderItem('9999999999'), + rules: [commanderRule], + mappings: [mapping], + }) + assert.equal(miss.decision.status, 'unmatched') +}) + +test('并列最高分不猜:进入 ambiguous', () => { + const item = buildItem({ + sku_code: '1个精英尊尚专属礼盒', + sku_name: '1个精英尊尚专属礼盒', + }) + const order = buildOrder({ provider: 'pdd', platform: 'pdd' }) + const { decision } = decide({ + order, + item, + rules: [ + buildRule({ id: 1, rule_key: 'same-a', product_name: '精英尊尚专属礼盒' }), + buildRule({ id: 2, rule_key: 'same-b', product_name: '精英尊尚专属礼盒' }), + ], + }) + + assert.equal(decision.status, 'ambiguous') + assert.equal(decision.rule, null) + assert.equal(decision.candidates.length, 2) +}) + +test('来源判定:91 / 拼多多 / 抖音各自归到唯一来源', () => { + assert.equal(resolveProductMatchSourceKey(buildOrder()), 'kuaishou-91') + assert.equal( + resolveProductMatchSourceKey(buildOrder({ provider: 'pdd', platform: 'pdd' })), + 'pdd', + ) + assert.equal( + resolveProductMatchSourceKey(buildOrder({ provider: 'douyin_mashida', platform: 'doudian' })), + 'douyin-mashida', + ) +}) + +test('数量归一:文本规格优先,其次订单项数量', () => { + assert.equal(resolveWorkOrderQuantity({ skuText: '指挥官密钥5个', fallbackQuantity: 1 }), 5) + assert.equal(resolveWorkOrderQuantity({ skuText: '指挥官密钥三个', fallbackQuantity: 1 }), 3) + assert.equal(resolveWorkOrderQuantity({ skuText: '套装-牛仔情诗', fallbackQuantity: 3 }), 3) +}) diff --git a/apps/backend/src/services/product-match/types.ts b/apps/backend/src/services/product-match/types.ts new file mode 100644 index 00000000..d256ee0a --- /dev/null +++ b/apps/backend/src/services/product-match/types.ts @@ -0,0 +1,122 @@ +import type { JsonObject } from '../../types/json.js' +import type { WorkProductRuleRow } from '../../repositories/worker-platform/index.js' + +/** + * 匹配来源(适配器粒度)。 + * + * 注意粒度是「来源」而不是「平台」:`kuaishou-91` 覆盖 91 回调与快手 send-code 两条数据路 + * (91=订单骨架权威源,快手=数据补全源与兜底源),它们共享同一份匹配上下文与店铺键空间。 + */ +export type ProductMatchSourceKey = 'kuaishou-91' | 'pdd' | 'douyin-mashida' | 'unknown' + +/** SKU/文本类条件的匹配方式。series = 数量系列(剥离「5个」),series-exact 额外要求剥离后完全相等。 */ +export type ProductMatchTextMode = 'exact' | 'contains' | 'series' | 'series-exact' + +/** 条件可作用的上下文字段(全部来自适配器归一化后的订单信息)。 */ +export type ProductMatchConditionField = + | 'skuText' + | 'skuNick' + | 'skuCode' + | 'skuId' + | 'relSkuId' + | 'itemId' + | 'relItemId' + | 'itemTitle' + +export type ProductMatchCondition = { + field: ProductMatchConditionField + value: string + mode: ProductMatchTextMode +} + +/** + * 平台无关的匹配上下文。适配器只负责把来源原始字段归一到这里; + * 引擎只读这里,不读快照、不判平台。 + */ +export type ProductMatchContext = { + sourceKey: ProductMatchSourceKey + provider: string + platform: string + /** 店铺键(命名空间化,如 kuaishou:4661340439 / pdd:xiangrikui / douyin:305760014);空数组=该来源无店铺上下文。 */ + shopKeys: string[] + /** 是否具备卖家上下文(快手 send-code 卖家 ID);过渡期用于决定是否允许文本兜底条目。 */ + hasSellerContext: boolean + item: { + /** 平台商品 ID */ + itemId: string + /** 关联商品 ID(快手 relItemId / 拼多多 goods_id 等) */ + relItemId: string + /** 平台 SKU ID */ + skuId: string + /** 关联 SKU ID(快手 relSkuId 等) */ + relSkuId: string + /** 商家外部 SKU 编码 / 平台编码候选(用于编码精确匹配) */ + skuCodes: string[] + /** SKU 规格文本(单值,结构化条件用) */ + skuNick: string + /** SKU 文本候选(文本条件用,顺序=可信度);不含引流标题 */ + skuTexts: string[] + /** 商品标题(默认不参与匹配,需条件显式声明) */ + itemTitle: string + /** 引流标题(关键词堆砌):只用于展示与日志,永不参与匹配 */ + promoTitle: string + } + quantity: number + /** 原始载荷:只写日志。 */ + raw: JsonObject +} + +/** 匹配条目:作用域条件(AND)+ 匹配条件(all=AND、any=OR)。 */ +export type ProductMatchEntry = { + /** 条目标识:新表 `entry:`;兼容编译 `legacy:rule:` / `legacy:mapping:`。 */ + key: string + ruleId: number + mappingId: number | null + rule: WorkProductRuleRow + /** 必须全部命中(AND)。 */ + all: ProductMatchCondition[] + /** 至少命中一个(OR);空数组=无条件。 */ + any: ProductMatchCondition[] + /** 加分条件:命中加分、不命中不淘汰(用于旧实现的 +400/+200 之类加成)。 */ + bonus: ProductMatchCondition[] + /** 适用来源;空数组=不限来源。 */ + sourceKeys: ProductMatchSourceKey[] + /** 适用店铺键;空数组=不限店铺。 */ + shopKeys: string[] + /** 数量区间(0=不限)。 */ + qtyMin: number + qtyMax: number + /** + * 条目基础分:命中即得。 + * 用于表达"条件强度"层级(结构化/映射类条件 > 纯文本条件),避免文本条件与确定性条件同分。 + */ + baseScore: number + /** 兼容编译标记:条件来自旧「模板名/商品名」文本兜底。 */ + fromRuleName: boolean +} + +export type ProductMatchCandidate = { + key: string + ruleKey: string + ruleId: number + mappingId: number | null + score: number +} + +/** 未命中诊断:条目被哪一维挡住。 */ +export type ProductMatchDiagnosisItem = { + key: string + ruleKey: string + blockedBy: string[] +} + +export type ProductMatchDecision = { + status: 'matched' | 'unmatched' | 'ambiguous' + rule: WorkProductRuleRow | null + mappingId: number | null + entryKey: string | null + score: number + candidates: ProductMatchCandidate[] + /** 仅在 unmatched / ambiguous 时填充(最多 10 条)。 */ + diagnosis: ProductMatchDiagnosisItem[] +} diff --git a/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts b/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts new file mode 100644 index 00000000..8f778f51 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts @@ -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>[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 +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 7ebeb7de..a24ef7ef 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -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' diff --git a/apps/backend/src/services/worker-platform/work-order-pricing.ts b/apps/backend/src/services/worker-platform/work-order-pricing.ts index cac77015..a9a6ed50 100644 --- a/apps/backend/src/services/worker-platform/work-order-pricing.ts +++ b/apps/backend/src/services/worker-platform/work-order-pricing.ts @@ -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 = { - 零: 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' 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 6b67f7a3..c852a02a 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 @@ -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, -): 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, -) { - 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 { diff --git a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx index 1c936d5d..c37f5ede 100644 --- a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx @@ -38,11 +38,14 @@ import { cleanupAdminWorkProductMatchLogs, saveAdminWorkerProductMatchConfig, testAdminKuaishouProductMatch, + testAdminProductMatchByItem, + type AdminProductMatchTestByItemResult, } from '@/services/admin' import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform' import type { AdminKuaishouIndustryShopOption } from '@/types/admin' import { formatAdminDateTime } from '@/utils/admin-time' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { formatMoney } from './shared' export default function ProductMatchPanel() { const { message } = App.useApp() @@ -52,6 +55,11 @@ export default function ProductMatchPanel() { const [testResult, setTestResult] = useState< Awaited>['data'] | null >(null) + const [testItemId, setTestItemId] = useState(null) + const [itemTestResult, setItemTestResult] = useState( + null, + ) + const [testingByItem, setTestingByItem] = useState(false) const [selectedLog, setSelectedLog] = useState(null) const [activeTab, setActiveTab] = useState('sources') const [logStatus, setLogStatus] = useState('') @@ -180,6 +188,22 @@ export default function ProductMatchPanel() { } } + async function runItemMatchTest() { + if (!testItemId) { + message.warning('请输入订单项 ID') + return + } + setTestingByItem(true) + try { + const response = await testAdminProductMatchByItem(testItemId) + setItemTestResult(response.data) + } catch (error) { + message.error(error instanceof Error ? error.message : '试匹配失败') + } finally { + setTestingByItem(false) + } + } + async function updateLegacyFallback(legacyFallbackEnabled: boolean) { try { await saveAdminWorkerProductMatchConfig({ legacyFallbackEnabled }) @@ -353,6 +377,94 @@ export default function ProductMatchPanel() { label: '匹配测试', children: (
+ + + setTestItemId(typeof value === 'number' ? value : null)} + /> + + + 读真实订单项 → 来源适配器 → 匹配引擎 → 数量/单价/拼单;抖音、拼多多、91 + 都适用。 + + + {itemTestResult ? ( +
+ + + {itemTestResult.sourceKey} + + + + + + {itemTestResult.rule?.productName || itemTestResult.rule?.ruleKey || '-'} + + + {itemTestResult.orderItem.matchSkuTexts.join(' / ') || '-'} + + + {itemTestResult.shopKeys.join(' / ') || '-'} + + + {itemTestResult.allowTextFallback ? '允许' : '不允许'} + + + {itemTestResult.plan ? `${itemTestResult.plan.skuQuantity} 份` : '-'} + + + {itemTestResult.plan + ? formatMoney( + itemTestResult.plan.pricingMode === 'unit' + ? itemTestResult.plan.unitPriceFen + : itemTestResult.plan.rewardAmount, + ) + : '-'} + + + + {itemTestResult.plan + ? formatMoney(itemTestResult.plan.rewardAmount) + : '-'} + + + + {itemTestResult.plan?.sharingEnabled + ? `开 · ${itemTestResult.plan.sharingTotalQuantity} 份 × ${formatMoney(itemTestResult.plan.sharingUnitReward)}` + : '关'} + + + {itemTestResult.entryKey || '-'} + + + {itemTestResult.order.shopName || itemTestResult.order.shopId} + + + +
+ ) : null} +