- 原 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。
368 lines
25 KiB
Markdown
368 lines
25 KiB
Markdown
# 接单模板匹配引擎重构设计
|
||
|
||
> 目标读者:后端 / 后台运营。状态:**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` | **删除**(不再有第二套算法) |
|