Files
order_site/docs/接单模板匹配引擎重构设计.md
yml2213 0de0fd7b58 chore(db): 将 108–115 补丁迁移收敛为 108–111
- 原 108/111/112/113/114 合并为 108_finance_cost_integrity:
  成本异常归档、人工成本完整性、影子总账、商品页/模板查询索引
- 原 115 注释迁移改为 111_work_order_share_cancel_reason_comment
- 新增 reconcile-migration-history.sql,用于已跑旧 112–115 的库
  重建 schema_migrations 顺序,不重复执行 DDL
- 已验证:001→111、101→111 两条升级路径;
  旧 115 与新 111 的 pg_dump schema 完全一致
- 测试/文档迁移编号同步到 103–111

验证:backend check 853(849 pass / 4 skip);frontend check 172 + build。
2026-10-27 12:37:21 +08:00

368 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接单模板匹配引擎重构设计
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;匹配业务逻辑未变)。
>
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
>
> **来源口径(2026-10-17 确认)**:全系统只有三个匹配来源——`kuaishou-91`、`pdd`、`douyin-mashida`。其中 **91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**:91 是订单骨架权威源,快手 send-code 是补全/兜底源;它们共享店铺键空间,**不是两个来源,也不要拆成两个适配器**。
---
## 1. 现状盘点(全部为生产实测 / 代码实证)
### 1.1 当前链路
```mermaid
graph LR
A["kuaishou-91<br/>91 回调(骨架)+ 快手 send-code(补全)"] --> ORD[("orders / order_items")]
C["PDD 同步"] --> ORD
D["抖音订单"] --> ORD
ORD --> CTX["匹配上下文构造<br/>resolveKuaishouWorkProductMatchContext"]
CTX --> ENG["打分<br/>work-product-match-mappers"]
CFG[("work_product_rules<br/>+ work_product_rule_mappings")] --> ENG
ENG --> WO["work_orders:价格 / 拼单 / 时限 / 要求字段"]
ENG --> LOG[("work_product_match_logs")]
ADM["后台:匹配测试 / 重匹配"] --> ENG
OTH["寄卖建议价 / 模板改价同步大厅"] -.-> CFG
```
### 1.2 四个匹配消费点(不是四个来源)
> 这里的“四个”是**代码里曾经各自实现匹配的消费点**,不是订单来源数量。订单来源始终只有 `kuaishou-91 / pdd / douyin-mashida` 三个。
| 消费者 | 位置 | 现在怎么匹配 | 问题 |
| --- | --- | --- | --- |
| 发单建工单 | `services/worker-platform/sync-worker-orders-service.ts:48-260` | `resolveMatchingProductRuleDecision` + 数量/价格/拼单各自解析 | 触发点分散、不重算 |
| 后台自测 / 重匹配 | `services/worker-platform/admin-kuaishou-match-service.ts:301-370` | **自己拼一套 order/item payload**,`isPdd` 靠 `sellerId.includes('pdd')` 猜(:310-315) | 与生产路径两套推导 |
| 履约 / 自动发货 | `services/fulfillment/product-resolution-service.ts:207/246/484-494` | `isOpen91KuaishouOrder` / `isDouyinMashidaOrder` + 路由规则 + affiliate_dash SKU 映射 | 与模板匹配互不知晓 |
| 周边 | 寄卖建议价 `consignment-matching-service.ts:622`;模板改价同步大厅 `admin-worker-catalog-service.ts:513-545` | 各自再算一遍规格/数量/价格 | 同口径三处实现 |
### 1.3 匹配条件当前怎么表达(根因)
1. **模板行自带匹配字段**:`work_product_rules.provider / platform / shop_id / sku_code / product_name / match_type / match_json`。→ "模板名"本身就是匹配键,于是出现"名字越长分越高"(`work-product-match-mappers.ts:336-342`)。
2. **商品绑定表要求店铺非空**:`work_product_rule_mappings` 有 CHECK `jsonb_array_length(seller_ids_json) > 0`(迁移 034),且打分函数第一关就是店铺命中(`:179-185`)。→ **不配店铺就无法使用"商品绑定"这一层**,而它恰恰是唯一表达 SKU 精确/数量系列的地方。
3. **上下文只有一个平台实现**:`resolveKuaishouWorkProductMatchContext` 只读 `snapshot.kuaishouSendCode`(`:147-169`)。其它平台靠别名兜(`out_sku_sn / externalSkuName / goodsId / spec`,`:241-257、303-316`)。
4. **兜底算法会被静默启用**:`:103 useLegacyFallback = !context.sellerId || 开关`,后台 `legacyFallbackEnabled=false`(生产实测值)也拦不住。
5. **匹配入口三处触发且不重算**:`syncWorkerOrdersForSourceOrder` 被 91 回调(`platforms/ninetyone/order-service.ts:187`)、订单通用入口(`order/order-service.ts:211/241`)、快手 send-code(`sync-work-orders-from-send-code.ts:141/224`)分别调用;服务内发现已有工单即 `already_exists` 跳过(`sync-worker-orders-service.ts:97`)→ **先到先匹配,后到的补全数据不会纠正错误结果**。注意:91 与快手 send-code 属于同一个 `kuaishou-91` 来源,这里是同一来源的两条到达通道,不是两个来源。
### 1.4 平台耦合清单
| 位置 | 耦合内容 | 后果 |
| --- | --- | --- |
| `work-product-match-mappers.ts:360-371` | 跨平台豁免只给 PDD(`provider=pdd / platform=pdd\|kuaishou`) | 新平台要么被挡、要么误命中 |
| `work-product-match-mappers.ts:103` | 缺 sellerId 就打开旧兜底 | 后台开关失效 |
| `work-product-match-mappers.ts:306-316` | 匹配文本含 `snapshot.externalSkuName`(抖音=引流标题) | 引流词命中错误模板 |
| `admin-kuaishou-match-service.ts:310-319` | `isPdd` 靠字符串猜 | 后台自测与生产不一致 |
| `product-resolution-service.ts:484-494` | `isOpen91KuaishouOrder` / `isDouyinMashidaOrder` | 履约侧第二套平台判断 |
| `services/platforms/` | 无平台注册表(无 index) | 新平台接入靠人肉对齐 |
### 1.5 订单信息实际有什么(生产实测 2026-10-14)
> 下表的 `provider=91kaquan` 是订单通道标识;匹配层仍统一归到 `sourceKey=kuaishou-91`。不要把 `provider` 当成匹配来源数量。
| 订单 provider | 订单项数 | 有 shop_id | 店铺数 | 有 kuaishouSendCode |
| --- | --- | --- | --- | --- |
| 91kaquan | 329,677 | 100% | 8 | 252,375(76.6%) |
| pdd | 4,443 | 100% | 4 | 0 |
| douyin_mashida | 17 | 100% | 1 | 0 |
**店铺键是三套命名空间**(互不相通):
| 来源 | shop_id 样例 | 说明 |
| --- | --- | --- |
| 91kaquan | `4661340439` `141242642` `91kaquan` | 快手 sellerId;`91kaquan` 是兜底值(21,044 单);历史脏:同店 shop_name 有「向日葵游戏服务店 / 91卡券 / 快手店铺-4661340439」三种写法 |
| pdd | `xiangrikui` `mutashuyu` | PDD 店铺 code |
| douyin_mashida | `305760014` | 抖音店铺 id(当前 0 条绑定含它) |
其它可用键(按来源):
| 来源 | 平台商品ID | 平台SKU ID | 商家外部SKU编码 | 规格/内部SKU文本 | 商品标题 | 数量 |
| --- | --- | --- | --- | --- | --- | --- |
| kuaishou-91 | `snapshot.productNo` | — | `externalSkuCode` | 有 send-code 时=`skuNick`(真实规格名);否则=`sku_name`(内部商品名) | `kuaishouSendCode.itemTitle` | `order_items.quantity` / 规格文本 |
| pdd | `goods_id` | — | `out_sku_sn` | `spec` | `goods_name` | `goods_number` |
| douyin-mashida | `productId` | `skuId` / `outSkuId` | `code`(内部 SKU) | `code`(内部 SKU) | `productName`(**就是引流标题**) | `item_num`(每件=1,数量在 SKU 文本里) |
> 结论:**"不绑店铺也能匹配"在数据上是成立的**——每个来源都有 ≥1 个稳定商品键(91 商品名 / PDD out_sku_sn / 抖音 内部 SKU);店铺只是"更精确的加分条件"。
---
## 2. 目标模型
### 2.1 分层
```mermaid
graph LR
S["来源适配器<br/>kuaishou-91 / pdd / douyin-mashida"] --> M["ProductMatchContext<br/>平台无关标准字段"]
M --> E["引擎(纯函数)<br/>条件打分 + 决策"]
CFG[("模板 work_product_rules<br/>+ 匹配条目 work_product_match_entries")] --> E
E --> P["WorkOrderPlan<br/>rule / entry / 数量 / 单价 / 总额 / 拼单 / 理由"]
P --> C1["发单建工单"]
P --> C2["后台试匹配 & 重匹配"]
P --> C3["大厅改价 / 寄卖建议价"]
DEJ["来源数据补全(快手 send-code 回填等)"] --> S
```
三层职责单一:**适配器只做字段归一**、**引擎只做打分决策(无 DB、无平台判断)**、**价格/拼单只有一处实现**。
### 2.2 ProductMatchContext(平台无关)
```ts
type ProductMatchContext = {
sourceKey: 'kuaishou-91' | 'pdd' | 'douyin-mashida' | 'unknown' // 来源(适配器),不是 provider
provider: string
platform: string // 仅展示/日志用,不参与匹配
shopKeys: string[] // 命名空间化店铺键:kuaishou:4661340439 / douyin:305760014 / pdd:xiangrikui;空数组=该来源无店铺上下文
hasSellerContext: boolean // 是否有卖家上下文(快手 send-code 卖家 ID)
item: {
itemId: string // 平台商品 ID
relItemId: string // 关联商品 ID(快手 relItemId / 拼多多 goods_id)
skuId: string // 平台 SKU ID
relSkuId: string // 关联 SKU ID
skuCodes: string[] // 编码候选(外部 SKU 编码 / 平台编码)
skuNick: string // SKU 规格文本(单值,结构化条件用)
skuTexts: string[] // SKU 文本候选(文本条件用,顺序=可信度);不含引流标题
itemTitle: string // 商品标题(默认不参与,需条件显式声明;抖音恒为空)
promoTitle: string // 引流标题:只展示、永不参与匹配
}
quantity: number // 已归一(规格文本 > 订单项数量 > 1)
raw: JsonObject // 仅写日志
}
```
硬性不变式:
- 匹配**只读** `item.*` 与 `quantity`;`promoTitle` / `raw` 永不进打分。
- 每个来源必须至少提供一个稳定商品键;缺失时引擎返回 `unmatched` + 原因(不允许静默降级到别家算法)。
- **店铺、来源都是可选作用域**:配置了就必须命中(过滤),不配=不限;两者都不配也能匹配。
### 2.3 匹配条目 MatchEntry(替代"店铺必填的绑定表")
一个模板可挂 0..N 条匹配条目;**每条条件都可选,`[]` / 空串 = 不限**;不含任何条件的条目 = 通配(对应现在的 `product_default`,必须显式创建)。
| 字段 | 含义 | 可空 |
| --- | --- | --- |
| `sourceKeys` | 适用来源(`kuaishou-91` / `pdd` / `douyin-mashida`) | ✅ 空 = 全部来源 |
| `shopKeys` | 适用店铺(命名空间键,多选) | ✅ **空 = 不限店铺(本次重点)** |
| `itemIds` / `relItemIds` | 平台商品 ID / 关联商品 ID(多值 OR) | ✅ |
| `skuIds` / `relSkuIds` | 平台 SKU ID / 关联 SKU ID(多值 OR) | ✅ |
| `skuText` / `skuNick` + 匹配方式 | 规格文本:`exact` / `contains` / `series`(剥数量,兼容密钥·秘钥)/ `series-exact` | ✅ |
| `itemTitle` + 匹配方式 | 商品标题:`off` / `exact` / `contains`(默认 **off**) | ✅ |
| `qtyMin` / `qtyMax` | 数量区间(0/0 = 不限),用于"5 个以上另一档价" | ✅ |
| `baseScore` | 条目基础分(条件强度层级,由编译/模板推导,非人工填写) | ❌ |
| `priority` | 同模板内排序;越小越优先(沿用 `sort_order ASC` 语义) | ❌ |
> 决策已定:**不做「平台」维度**,平台差异由「来源」表达(`kuaishou-91` / `pdd` / `douyin-mashida`),避免同一件事两处可配。
### 2.4 打分与决策(替换"模板名越长分越高")
条件分两类:**作用域**(来源/店铺,配了就必须命中)与**判定条件**(`all` 全中、`any` 至少一个、`bonus` 命中加分)。判定条件的权重按"确定性"排序:
| 条件 | 命中得分 |
| --- | --- |
| `relSkuId` / `skuId` 精确 | 900 |
| `skuText` / `skuNick` 精确 | 800 |
| 数量系列 `series` / `series-exact` | 600 |
| `skuCode` 精确(模板编码命中) | 500 |
| `skuText` / `skuNick` 包含(旧"模板名"路径) | 400 + 具体度(`min(80, 模板名长度)`,复刻历史"更长更具体"的排序语义) |
| `relItemId` / `itemId` 精确 | 300 |
| `itemTitle` 命中 | 200(仅在条件显式配置时参与) |
| 数量区间命中 | 不满足即淘汰;满足不加分 |
条目基础分(`baseScore`)表达层级:SKU 精确映射 1000 > 数量系列映射 800 > 商品默认映射 500 > 结构化条件 300 > 纯文本条件 0。
即:**映射类条件永远压过纯文本条件**(对应旧实现里 `sku_exact(10000) > sku_series(8000) > product_default(5000) > 模板名路径(≤290)` 的层级关系)。
决策:最高分唯一 → `matched`;并列最高 → `ambiguous`(**不猜**,记日志待人工/补配置);无候选 → `unmatched`。
`unmatched` / `ambiguous` 必须落诊断:每条候选差在哪一维(`source_mismatch` / `shop_mismatch` / `quantity_below_min` / `any_not_matched:<字段:模式:值>`),后台直接可读。
### 2.5 数量、价格、拼单(单一实现)
```
quantity = 规格文本数量系列(「指挥官密钥5个」→ 5)
> 订单项数量字段(order_items.quantity)
> 1
价格 = 模板.unit_price_fen > 0 ? 单价 × quantity : 固定 reward_amount
拼单 = 模板.sharing_enabled && quantity > 1 && 验收模式非 friend_gift
;auto_from_order 时使用订单 quantity 作为份数
```
> 注意:**规格文本优先于数量字段**是抖音的硬要求(订单项 `item_num` 恒为 1,数量写在 SKU 名里),
> 因此不能反过来"显式字段优先",否则「指挥官密钥5个」会按 1 个计价。
以上合并现 `work-order-pricing.ts`,作为唯一实现,被发单/后台改价/寄卖建议价共用。
### 2.6 触发点收敛
- 匹配**只在来源数据补全之后**执行一次;补全(快手 send-code 回填、抖音详情补齐)不得触发第二次写单据。
- 幂等:同一 `order_item` 已有工单则跳过(保留现行为),但**后台"重匹配"必须能整单重跑**(删除/更新由显式操作触发)。
- 建议入口:`order_items` 落库后统一发一条"匹配任务"(事件/队列表或同步调用),发单、日志、诊断共用同一结果。
---
## 3. 数据模型改造
### 3.1 新表 `work_product_match_entries`
```sql
CREATE TABLE IF NOT EXISTS work_product_match_entries (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT NOT NULL REFERENCES work_product_rules(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 100,
source_keys JSONB NOT NULL DEFAULT '[]'::jsonb, -- 空 = 全部来源
shop_keys JSONB NOT NULL DEFAULT '[]'::jsonb, -- 空 = 不限店铺(命名空间键)
item_id TEXT NOT NULL DEFAULT '',
out_item_id TEXT NOT NULL DEFAULT '',
sku_id TEXT NOT NULL DEFAULT '',
out_sku_id TEXT NOT NULL DEFAULT '',
sku_text TEXT NOT NULL DEFAULT '',
sku_match_mode TEXT NOT NULL DEFAULT 'contains'
CHECK (sku_match_mode IN ('exact', 'contains', 'series')),
title_text TEXT NOT NULL DEFAULT '',
title_match_mode TEXT NOT NULL DEFAULT 'off'
CHECK (title_match_mode IN ('off', 'exact', 'contains')),
qty_min INTEGER NOT NULL DEFAULT 0,
qty_max INTEGER NOT NULL DEFAULT 0,
is_wildcard BOOLEAN NOT NULL DEFAULT false, -- 通配条目(无条件匹配全部),需显式勾选
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
-- 要么显式声明通配,要么至少有一个判定维度,避免"空条件条目"意外抢单
CHECK (
is_wildcard
OR source_keys <> '[]'::jsonb OR shop_keys <> '[]'::jsonb
OR out_sku_id <> '' OR sku_id <> '' OR sku_text <> ''
OR out_item_id <> '' OR item_id <> '' OR title_text <> ''
OR qty_min > 0 OR qty_max > 0
)
);
CREATE INDEX IF NOT EXISTS work_product_match_entries_lookup_idx
ON work_product_match_entries (enabled, priority, id);
```
### 3.2 兼容与迁移(不动存量数据语义)
1. **模板行字段保留只读**:`product_name / match_type / sku_code / shop_id / provider / platform` 保留,迁移脚本为每个启用模板生成一条兼容条目:
- `product_name` 非空 → 条目 A:`skuText = product_name`, `skuMatchMode = match_type`(`exact|contains`);
- `sku_code` 非空 → 条目 B:`outSkuId = sku_code`(现在只有 6 条模板配了)。
2. **4 条商品绑定 → 条目**:`seller_ids_json → shopKeys`(补 `kuaishou:` 前缀)、`mapping_type=sku_exact → outSkuId/skuText + exact`、`sku_series → skuText + series + sku_match_mode`、`rel_item_id → itemId`、`item_title → titleText(contains)`。
3. **`work_product_rule_mappings` 双写期**:新写入写新表,旧表保留一个版本用于回滚对比,确认稳定后下线。
4. **匹配日志扩展**:`work_product_match_logs` 增加 `source_key / shop_key / candidates_json(逐维得分) / decision_reason`(现有列保留)。
5. 不改 `work_orders` 结构:匹配产物(rule_id + 价格/拼单)落库口径不变。
---
## 4. 代码结构
```
apps/backend/src/services/product-match/
types.ts # ProductMatchContext / MatchEntry / MatchDecision / WorkOrderPlan
context.ts # 组装上下文(读 order_items 标准字段 + 快照别名),不含平台分支
adapters/
kuaishou-91.ts # 91 回调 + 快手 send-code 两路数据合成同一上下文(共享店铺键空间)
pdd.ts
douyin-mashida.ts # skuText=内部SKU;promoTitle=引流标题(永不参与匹配)
registry.ts # sourceKey → adapter
engine.ts # 条件打分 + 决策 + 诊断(纯函数,无 DB / 无平台判断)
pricing.ts # 数量 / 单价 / 总额 / 拼单(收编 work-order-pricing.ts)
index.ts # 对外唯一入口:resolveWorkOrderPlan(order, item, { rules, entries })
```
对外只有一个函数出口;发单、后台自测、重匹配、寄卖建议价、大厅改价全部调用它。新增来源 = `adapters/<source>.ts` + registry 注册 + fixtures,**引擎不动**。
---
## 5. 后台 UI 调整(运营视角)
- 模板表单拆成两块:**① 模板(价格/时间/要求字段/验收模式/拼单)**、**② 匹配条件(可加多条条目)**。模板名称降级为纯展示名,**不再参与匹配**。
- 匹配条目表单:来源 / 平台 / 店铺(多选,**可留空**)/ 商品ID / SKU编码 / SKU文本 + 匹配方式(精确·包含·数量系列)/ 标题匹配(默认关闭)/ 数量区间 / 优先级。
- "商品绑定"列表页改为"匹配条目"列表:显示条件摘要 + 命中统计。
- 保留并强化"试匹配":粘贴订单信息 → 选来源 → 输出决策 + 每个候选的逐维得分 + 为什么没命中(替代现在只给模板名与拒绝原因的诊断)。
- 匹配日志增加来源/店铺/决策原因筛选。
---
## 6. 分期落地与风险护栏
| 期 | 内容 | 影响面 | 预估 |
| --- | --- | --- | --- |
| P0 | 兜底开关尊重后台配置;抖音适配层:`skuText` 用内部 SKU、`shop` 用 `order.shop_id`、`itemTitle` 标为 `promoTitle` | 仅抖音 | 0.5~1 天 |
| P1 | 抽 `product-match/`(context + adapters + engine + pricing),行为等价迁移;补每来源 golden fixtures | 全来源,行为等价 | 2~3 天 |
| P2 | 后台自测/重匹配、寄卖建议价、大厅改价统一走 `resolveWorkOrderPlan`;匹配触发点收敛到"补全后一次" | 全来源 | 1~2 天 |
| P3 | 新表 + 迁移脚本 + 后台 UI(可选条件/店铺可选/条目化)+ 旧表下线 | 含数据迁移 | 2~3 天 |
护栏:
- **91 冻结**:91 走 legacy 兼容条目 + golden fixtures 全量回归(关键样本:8 家店、有/无 send-code 两类单、数量系列 1/2/5/10 个)。
- **逐来源灰度**:新引擎先只对 `douyin-mashida` 生效,pdd 次之,91 最后;每步都能一键回退到旧路径。
- **不猜**:并列最高一律 `ambiguous`,配合后台"未命中/歧义"清单驱动补配置(避免再出现"悄悄选了错模板")。
- **数据零改动**:P0/P1 不发 migration;P3 的迁移只新增表与条目,不动 `work_orders` / 历史匹配。
---
## 7. 验收标准
1. 每来源一组 fixtures:给定订单信息(快照 + 商品字段)→ 期望 `{status, ruleId, quantity, rewardAmount, sharing}`;91 的 30+ 组历史样本结果与现网一致。
2. 抖音「指挥官密钥1个 / 5个」:命中 9.5 元/个模板、数量 5 时自动开 5 份拼单(当前为 0.85 元/个、拼单关)。
3. 店铺缺失场景:清空 `shopKeys` 的条目仍能命中;`shop` 为 null 时不产生 `ambiguous`。
4. 引流标题场景:商品标题含其它商品词时,只要条目未开启标题匹配,结果不受影响。
5. 后台"试匹配"输出与生产建单结果一致(当前两者是两套推导,这是必测项)。
6. 新增来源演练:只加一个 adapter + 配置即可跑通(以 fixtures 覆盖)。
---
## 8. 实施进展
### 8.1 已完成(P0 + P1,2026-10-14)
新增模块 `apps/backend/src/services/product-match/`:
| 文件 | 职责 |
| --- | --- |
| `types.ts` | `ProductMatchContext` / `ProductMatchEntry` / `ProductMatchDecision` |
| `normalize.ts` | 文本/标识归一、数量解析、数量系列(含密钥·秘钥等价) |
| `adapters/{kuaishou-91,pdd,douyin-mashida,registry}.ts` | 三个来源适配器 + 注册表(平台差异只在这里) |
| `context.ts` | 上下文构建 + 来源文本兜底策略 + 旧投影兼容 |
| `compile.ts` | 旧「模板 + 商品绑定」→ 匹配条目(唯一的历史差异落点) |
| `engine.ts` | 条件打分 + 决策(不猜)+ 逐维诊断 |
| `pricing.ts` | 数量 / 单价 / 拼单唯一实现(`work-order-pricing.ts` 改为转发) |
| `product-match.test.ts` | 每来源 fixtures(含抖音误命中回归) |
`worker-platform/work-product-match-mappers.ts` 改为门面:函数签名不变,内部调用新引擎(发单服务、后台匹配测试/重匹配、既有 19 个匹配用例全部无需改动)。
### 8.2 已验证
- **生产数据回放(6000 条近 21 天订单项)**:91kaquan 5845 条、pdd 138 条**决策 100% 一致**(matched/unmatched 分布与命中模板 id 全部相同)。
- 抖音 5 条命中变化,全部是修正:
- `指挥官密钥1个 / 5个`(订单 334081 / 334084 / 334088):`碎梦浮光珍宝礼盒 0.85 元/个、拼单关` → **`指挥官密钥 9.5 元/个、拼单随订单开 5 份`**;
- `突击兵密钥5个`(333380 / 333932):由引流标题误命中的 `指挥官密钥` → **`突击兵`**(与快手侧同商品的判定一致)。
- `npm run check`(2026-10-14 当时):format / sql guard / lint / typecheck / 727 用例(0 失败)/ build 全绿;2026-10-17 复核后端 740 项(737 pass / 3 skipped)、前端 143 项。
### 8.3 待办(P2 / P3)
- **P2**:后台「试匹配 / 重匹配」与发单共用同一入口(目前已共用匹配内核,派生字段仍是两套);寄卖建议价、大厅改价改走 `pricing.ts`;匹配触发点收敛到"来源数据补全后一次",去重不再依赖"已有工单就跳过"。
- **P3**:`work_product_match_entries` 建表 + 旧配置迁移(§3.2)+ 后台条目化 UI;之后删除 `compile.ts` 的兼容编译与 `legacyFallbackEnabled` 配置项。
- **存量**:抖音误命中的 3 张工单(132001 / 132003 / 132004,均 `pending_material`、无打手接单)在新代码上线后用后台「重匹配」重跑。
---
## 附录 A:来源 → 上下文映射
| 来源 | sourceKey | shopKeys | itemId / relItemId | skuId / relSkuId | skuCodes | skuNick / skuTexts | itemTitle | promoTitle |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 91 + 快手 | `kuaishou-91` | `kuaishou:<send-code sellerId>`、`kuaishou:<order.shop_id>` | `sendCode.itemId` / `sendCode.relItemId` | `sendCode.skuId` / `sendCode.relSkuId` | `sku_code`、`externalSkuCode`、`out_sku_sn`、`externalItemId` | 有 send-code:`skuNick`;文本候选沿用旧列表 | `sendCode.itemTitle` | `productTitle` |
| PDD | `pdd` | `pdd:<order.shop_id>` | `goods_id` / `goods_id` | — / `out_sku_sn` | 同上(旧列表) | `out_sku_sn` / 旧文本列表 | `goods_name` | — |
| 抖音 | `douyin-mashida` | `douyin:<order.shop_id>` | `productId` / `outProductId` | `skuId` / `outSkuId` | `sku_code`、`externalSkuCode`、`outSkuId`、`outProductId` | `sku_code`(内部 SKU);文本候选**仅** `sku_code`(缺失时 `sku_name`) | **恒为空** | `productName`(=引流标题) |
| 未知来源 | `unknown` | `<provider>:<order.shop_id>` | 快照字段 | 快照字段 | `sku_code` | `sku_code` / `sku_name` | 快照标题 | `productTitle` |
## 附录 B:旧字段 → 新概念
| 旧 | 新 |
| --- | --- |
| `work_product_rules.product_name` + `match_type` | 匹配条目 `skuText` + `skuMatchMode` |
| `work_product_rules.sku_code` | 匹配条目 `outSkuId` |
| `work_product_rules.provider / platform / shop_id` | 匹配条目 `sourceKeys / shopKeys`(全部可空;旧的 provider/platform 残留值一律忽略) |
| `work_product_rule_mappings.seller_ids_json` | 匹配条目 `shopKeys`(命名空间键,可空) |
| `mapping_type = product_default / sku_exact / sku_series` | 条目条件组合(无 SKU 条件 / `outSkuId` 或 `skuText` exact / `skuText` series) |
| `sku_match_mode = fuzzy / exact` | `skuMatchMode = contains / exact`(+ `series` 独立成档) |
| `legacyFallbackEnabled` | **删除**(不再有第二套算法) |