fix(worker-platform): 工单号前缀改 WK 并在检索侧容错 O/0,彻底解决「按工单号搜不到」

## 线上定位到的真正根因

客服搜 `WO26101213031939` 搜不到,但用同单的平台号 `2628501763808879` 搜得到。
到生产库核对后确认:

- 库里真实值 `work_order_no = WO26101213031939`(hex 574f… = 字母 O),状态 pending_acceptance;
- 用字母 O 的 SQL 分支 → 1 行;用数字 0(`W026101213031939`)→ 0 行;
- 全库 127,906 单**全部**以 `WO` 开头、`W0` 开头 0 单、字母 `o` 只出现在第 2 位。

即:前缀里的字母 O 被输成数字 0(`WO` / `W0` 在界面字体里几乎无法区分),
落进通配符分支后必然为空 —— 与前面几轮的「筛选把单藏起来」是两个独立成因,
这也解释了为什么改筛选逻辑一直没解决。

## 修法

1. 生成端换前缀:generateWorkOrderNo 产出 `WK…`(K 与 0 不会看混),从源头消掉歧义;
2. 检索端同时兼容三种写法(历史 WO 号无需迁移):
   - isWorkOrderNoLookupKeyword 放宽为 `^W[oOkK0]…`;
   - 新增 buildWorkOrderNoKeywordVariants:W0→[W0, WO, WK]、WO→[WO, W0]、WK→[WK],
     SQL 分支对每个候选下发「等值 + 前缀」条件(仍是索引条件,不会退化成全表扫);
   - 前端 isExplicitOrderNoSearch 同步(否则面板不清理筛选、实时补丁护栏也不生效);
3. 顺手加一层归一化:NFKC(全角→半角)+ 去零宽字符,前后端同一口径
   (normalizeLookupKeyword / normalizeSearchText),覆盖「粘贴带不可见字符」这类同族问题;
4. 寄卖匹配弹窗的占位提示更新为 WK... / WO...。

## 验证

- 后端 npm run check 全绿:692 tests(689 pass / 3 skip),新增
  work-order-keyword-lookup.test.ts 直接断言 SQL 参数与「不得出现前置通配符」;
- 前端 npm run check 全绿:139 tests;
- 既有用例 worker-platform-repo.test.ts 的期望值同步为新形态(两条候选条件)。
This commit is contained in:
yml2213
2026-10-12 16:51:08 +08:00
parent 56ca479c6b
commit cb064d7cd5
14 changed files with 251 additions and 41 deletions
@@ -152,15 +152,20 @@ test('平台单号检索只生成可走索引的等值/前缀条件,不得出
assert.deepEqual(query.params, ['2627401680611492'])
})
test('工单号检索大小写不敏感且只走索引,不得出现前置通配符', () => {
test('工单号检索大小写不敏感且只走索引,不得出现前置通配符(含 W0/WK 前缀容错)', () => {
const query = buildWorkOrderWhere({ keyword: 'WO26100113008697' })
assert.match(
query.whereClause,
/\(LOWER\(wo\.work_order_no\) = LOWER\(\$1\) OR LOWER\(wo\.work_order_no\) LIKE LOWER\(\$1\) \|\| '%'\)/,
)
// 前缀位的 O 与 0 视觉上分不清,两种写法都要下发;两者仍是等值/前缀条件(可走索引)
assert.match(
query.whereClause,
/\(LOWER\(wo\.work_order_no\) = LOWER\(\$2\) OR LOWER\(wo\.work_order_no\) LIKE LOWER\(\$2\) \|\| '%'\)/,
)
assert.doesNotMatch(query.whereClause, /ILIKE\s*'%'/)
assert.deepEqual(query.params, ['WO26100113008697'])
assert.deepEqual(query.params, ['WO26100113008697', 'W026100113008697'])
})
test('SUB/INV/AS/DT 关联单号经关联表反查工单,不回落到通配符', () => {
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildWorkOrderWhere } from './work-order-query-repo.js'
/**
* 锁住「按工单号搜索」的前缀容错。
*
* 生产事故:一个订单用平台号 2628501763808879 搜得到,用工单号搜不到——客服把
* `WO26101213031939` 输成了 `W026101213031939`(字母 O 看成数字 0,字体里几乎一样)。
* 生成端已改为 `WK` 前缀(不再有歧义),历史 `WO…` 号继续兼容,
* 检索侧对 W0 / WO / WK 三种写法都要命中,且必须保持可走索引。
*/
function keywordConditions(keyword: string) {
const { whereClause, params } = buildWorkOrderWhere({ keyword } as never)
return { whereClause: whereClause.replace(/\s+/g, ' '), params }
}
test('输入 W0… 时同时匹配 W0 / WO / WK 三种写法', () => {
const { whereClause, params } = keywordConditions('W026101213031939')
assert.deepEqual(params, ['W026101213031939', 'WO26101213031939', 'WK26101213031939'])
// 三个候选各自都是「等值 + 前缀」两个条件
assert.equal((whereClause.match(/LOWER\(wo\.work_order_no\) = LOWER\(\$\d+\)/g) || []).length, 3)
assert.equal((whereClause.match(/LIKE LOWER\(\$\d+\) \|\| '%'/g) || []).length, 3)
// 关键:不得出现前置通配符(会让 B-Tree 失效、退化成全表扫)
assert.ok(!whereClause.includes("'%' ||"), '工单号分支不能出现前置通配符')
})
test('输入 WO… 时补一个 W0… 候选(历史号被误读的情况)', () => {
const { whereClause, params } = keywordConditions('WO26101213031939')
assert.deepEqual(params, ['WO26101213031939', 'W026101213031939'])
assert.ok(whereClause.includes('LOWER(wo.work_order_no)'))
})
test('输入 WK… 时前缀无歧义,只查一种写法', () => {
const { whereClause, params } = keywordConditions('WK26101213031939')
assert.deepEqual(params, ['WK26101213031939'])
assert.equal((whereClause.match(/LOWER\(wo\.work_order_no\) = LOWER\(\$\d+\)/g) || []).length, 1)
})
test('关键词归一化:全角 / 零宽字符不影响命中', () => {
const withZeroWidth = keywordConditions('WK26101213031939\u200b')
assert.deepEqual(withZeroWidth.params, ['WK26101213031939'])
const fullWidth = keywordConditions('WK26101213031939')
assert.deepEqual(fullWidth.params, ['WK26101213031939'])
})
test('普通关键词仍走模糊搜索,不受单号容错影响', () => {
const { whereClause, params } = keywordConditions('暗潮')
assert.deepEqual(params, ['%暗潮%'])
assert.ok(whereClause.includes('LIKE LOWER($1)'))
assert.ok(!whereClause.includes('work_order_no) = LOWER'), '商品名不应误入工单号等值分支')
})
@@ -7,8 +7,10 @@ import {
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
import {
buildWorkOrderNoKeywordVariants,
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
normalizeLookupKeyword,
resolveRelatedWorkOrderLookupKind,
type RelatedWorkOrderLookupKind,
} from '../../utils/platform-order-id.js'
@@ -1094,7 +1096,8 @@ export function buildWorkOrderWhere({
const filters: string[] = []
const params: unknown[] = []
const joins: string[] = []
const normalizedKeyword = String(keyword || '').trim()
// 归一化:NFKC(全角→半角)+ 去零宽字符 + trim,避免"贴对了却搜不到"。
const normalizedKeyword = normalizeLookupKeyword(keyword)
if (createdFrom) {
params.push(createdFrom)
filters.push(
@@ -1249,13 +1252,18 @@ export function buildWorkOrderWhere({
`(wo.platform_order_id = ${paramIndex} OR wo.platform_order_id LIKE ${paramIndex} || '%')`,
)
} else if (isWorkOrderNoLookupKeyword(normalizedKeyword)) {
params.push(normalizedKeyword)
const paramIndex = `$${params.length}`
// 工单号含随机小写段(10.2 万行里 7.4 万行非全大写),必须 LOWER() 比较;
// 配套迁移 090 的 idx_work_orders_work_order_no_lower_pattern 才能让前缀检索走范围扫描。
filters.push(
`(LOWER(wo.work_order_no) = LOWER(${paramIndex}) OR LOWER(wo.work_order_no) LIKE LOWER(${paramIndex}) || '%')`,
// 工单号同时匹配 `WK…`(现役)/`WO…`(历史)/`W0…`(把 O 看成 0)三种写法:
// 单号检索的入口是人手输入,前缀位上的 O/0 极易混淆(生产真实投诉)。
// 候选写法都是「等值 + 前缀」,仍然走 idx_work_orders_work_order_no_lower_pattern,
// 不会像通配符那样退化成全表扫。
const variantConditions = buildWorkOrderNoKeywordVariants(normalizedKeyword).map(
(variant) => {
params.push(variant)
const variantParam = `$${params.length}`
return `(LOWER(wo.work_order_no) = LOWER(${variantParam}) OR LOWER(wo.work_order_no) LIKE LOWER(${variantParam}) || '%')`
},
)
filters.push(`(${variantConditions.join(' OR ')})`)
} else if (relatedLookupKind) {
// 关联单号(SUB/INV/AS/DT)本身不在 work_orders 上,必须先经关联表反查工单 id。
// 四条链路都命中唯一索引,属于索引驱动的等值查询;查不到时直接返回空集,