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
@@ -2,9 +2,11 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildWorkOrderNoKeywordVariants,
isExplicitOrderNoLookupKeyword,
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
normalizeLookupKeyword,
resolveRelatedWorkOrderLookupKind,
} from './platform-order-id.js'
@@ -19,12 +21,24 @@ test('识别拼多多带连字符的 order_sn', () => {
assert.equal(isPlatformOrderIdLookupKeyword('260912-123456789012345'), true)
})
test('识别统一内部工单号 (WO...)', () => {
test('识别统一内部工单号 (WK... 现役 / WO... 历史 / W0... 误输)', () => {
// 现役前缀
assert.equal(isWorkOrderNoLookupKeyword('WK26101213031939'), true)
assert.equal(isWorkOrderNoLookupKeyword('wk26101213031939'), true)
// 历史前缀
assert.equal(isWorkOrderNoLookupKeyword('WOb320ee0b39b9'), true)
assert.equal(isWorkOrderNoLookupKeyword('wob320ee0b39b9'), true)
assert.equal(isWorkOrderNoLookupKeyword('woB320EE0B39B9'), true)
assert.equal(isWorkOrderNoLookupKeyword('WO26091216358821'), true)
// 把前缀里的 O 看成 0 的误输(线上真实案例:用平台号搜得到、用工单号搜不到)
assert.equal(isWorkOrderNoLookupKeyword('W026101213031939'), true)
// 全角与零宽字符
assert.equal(isWorkOrderNoLookupKeyword('WK26101213031939'), true)
assert.equal(isWorkOrderNoLookupKeyword('WK26101213031939\u200b'), true)
// 负例
assert.equal(isWorkOrderNoLookupKeyword('WO12'), false)
assert.equal(isWorkOrderNoLookupKeyword('WK1'), false)
assert.equal(isWorkOrderNoLookupKeyword('20247230034021572'), false)
assert.equal(isWorkOrderNoLookupKeyword('WK'), false)
})
test('拒绝普通关键词', () => {
@@ -91,3 +105,32 @@ test('显式单号检索不得误判商品名 / 昵称 / 短词', () => {
)
}
})
/**
* 工单号检索的候选写法。前缀位是唯一可能出现 O/0 混淆的地方(生成器产出 `WO/WK` + 数字),
* 所以只在这一位展开候选,且每个候选都是等值/前缀条件,保持可走索引。
*/
test('工单号候选写法:W0 同时展开 WO 与 WK,WO 补 W0,WK 不再展开', () => {
assert.deepEqual(buildWorkOrderNoKeywordVariants('W026101213031939'), [
'W026101213031939',
'WO26101213031939',
'WK26101213031939',
])
assert.deepEqual(buildWorkOrderNoKeywordVariants('WO26101213031939'), [
'WO26101213031939',
'W026101213031939',
])
assert.deepEqual(buildWorkOrderNoKeywordVariants('WK26101213031939'), ['WK26101213031939'])
// 大小写与前缀字母保持原样(SQL 侧统一 LOWER 比较)
assert.deepEqual(buildWorkOrderNoKeywordVariants('wk2601'), ['wk2601'])
assert.deepEqual(buildWorkOrderNoKeywordVariants('w02601'), ['w02601', 'wO2601', 'wK2601'])
assert.deepEqual(buildWorkOrderNoKeywordVariants(''), [])
})
test('关键词归一化:全角转半角、去掉零宽字符、去首尾空白', () => {
assert.equal(normalizeLookupKeyword(' WK26101213031939 '), 'WK26101213031939')
assert.equal(normalizeLookupKeyword('WK26101213031939\u200b\u200d\ufeff'), 'WK26101213031939')
assert.equal(normalizeLookupKeyword('WO26101213031939\u00a0'), 'WO26101213031939')
assert.equal(normalizeLookupKeyword(null), '')
assert.equal(normalizeLookupKeyword(undefined), '')
})
+56 -13
View File
@@ -1,18 +1,61 @@
const ZERO_WIDTH_CHARS = /[\u200b-\u200d\ufeff]/g
/**
* 检索关键词归一化:NFKC(全角字母/数字 → 半角)+ 去掉零宽字符 + trim。
*
* 为什么要做:单号常被从网页、Excel、聊天记录里复制过来,很容易夹带
* 零宽字符(U+200B/FEFF 等)或全角字母数字——它们肉眼不可见,
* 但会让等值/前缀匹配全部落空,表现就是「明明贴对了却搜不到」。
*/
export function normalizeLookupKeyword(value: unknown): string {
return String(value ?? '')
.normalize('NFKC')
.replace(ZERO_WIDTH_CHARS, '')
.trim()
}
/**
* Detect platform order IDs that should use indexed equality/prefix lookup.
* Covers pure numeric IDs (快手等) and PDD style `digits-digits` (e.g. 260915-091435834962130).
*/
export function isPlatformOrderIdLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
return /^\d{6,}(-\d{6,})?$/.test(trimmed)
return /^\d{6,}(-\d{6,})?$/.test(normalizeLookupKeyword(value))
}
/**
* 识别内部统一工单号 (以 WO 开头且至少 4 位字符,如 WOb320ee0b39b9、WO26091216358821)。
* 识别内部统一工单号(前缀 + 至少 4 位字符,如 WK26101213031939、WO26091216358821)。
*
* 前缀刻意放宽为 `W[oOkK0]`:
* - 现役前缀是 `WK`(生成端 utils/random.generateWorkOrderNo),与数字 0 视觉不会混;
* - 历史前缀是 `WO`,而字母 O 与数字 0 在多数界面字体里几乎无法区分,
* 用户手输/转抄时极易写成 `W0…`(线上真实案例)。
* 实测生产 12.7 万单全部以 `WO` 开头、字母 `o` 只出现在第 2 位,
* 所以把 `W0…` 也认成工单号不会误伤别的东西。
*/
export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
return /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed)
return /^W[oOkK0][0-9a-zA-Z]{4,}$/i.test(normalizeLookupKeyword(value))
}
/**
* 工单号检索的候选写法:第 2 位上的 O / 0 / K 互为可能,全部匹配。
*
* - 输入 `W0…` → 可能是 `WO…`(历史号,用户把 O 看成 0)也可能是 `WK…`(现役号),两个都试;
* - 输入 `WO…` → 只补 `W0…`(0 与 O 视觉混淆);
* - 输入 `WK…` → 前缀无歧义,不补。
*
* 只在第 2 位做替换:生成器产出的是 `WK/WO` + 数字,历史 hex 形态号码里 `o` 也只在第 2 位。
* 所有候选都是等值/前缀条件,仍然走 idx_work_orders_work_order_no_lower_pattern,
* 不会像通配符那样退化成全表扫。
*/
export function buildWorkOrderNoKeywordVariants(value: unknown): string[] {
const normalized = normalizeLookupKeyword(value)
if (!normalized) return []
const first = normalized.slice(0, 1)
const second = normalized.slice(1, 2)
const rest = normalized.slice(2)
const alternates = second === '0' ? ['O', 'K'] : second === 'o' || second === 'O' ? ['0'] : []
return [normalized, ...alternates.map((letter) => `${first}${letter}${rest}`)]
}
/**
@@ -24,12 +67,12 @@ export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
* 两侧必须覆盖同一批形态,改这里就要同步改那边(反之亦然)。
*/
export function isExplicitOrderNoLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
if (!trimmed) return false
const normalized = normalizeLookupKeyword(value)
if (!normalized) return false
return (
isWorkOrderNoLookupKeyword(trimmed) ||
isPlatformOrderIdLookupKeyword(trimmed) ||
resolveRelatedWorkOrderLookupKind(trimmed) !== null
isWorkOrderNoLookupKeyword(normalized) ||
isPlatformOrderIdLookupKeyword(normalized) ||
resolveRelatedWorkOrderLookupKind(normalized) !== null
)
}
@@ -71,10 +114,10 @@ const RELATED_WORK_ORDER_PATTERNS: Array<{
export function resolveRelatedWorkOrderLookupKind(
value: unknown,
): RelatedWorkOrderLookupKind | null {
const trimmed = String(value || '').trim()
if (!trimmed) return null
const normalized = normalizeLookupKeyword(value)
if (!normalized) return null
for (const item of RELATED_WORK_ORDER_PATTERNS) {
if (item.pattern.test(trimmed)) {
if (item.pattern.test(normalized)) {
return item.kind
}
}
+10 -3
View File
@@ -9,8 +9,15 @@ export function randomId(prefix: string, bytes = 6): string {
}
/**
* Generate formatted internal work order number
* 生成系统标准内部工单号,如:WO26091216358821 (WO + 年月日 + 4位时分 + 4位随机)
* 生成统一工单号,形如 `WK26101213031939`。
*
* 前缀用 `WK` 而不是 `WO`:字母 O 与数字 0 在绝大多数界面字体里几乎无法区分,
* 客服/运营手输或转抄时会把 `WO…` 写成 `W0…`,线上真实出现过「按工单号搜不到」
* 的反复投诉(一个订单用平台号搜得到,用工单号搜不到)。
* `WK` 与 `0` 视觉上不会混淆,从生成端消掉这个歧义。
*
* 历史号码(WO…)无需迁移,检索侧继续兼容:见 utils/platform-order-id.ts
* 的 isWorkOrderNoLookupKeyword / buildWorkOrderNoKeywordVariants(W O W 0 W K 都认)。
*/
export function generateWorkOrderNo(date: Date = new Date()): string {
const yy = String(date.getFullYear()).slice(-2)
@@ -19,7 +26,7 @@ export function generateWorkOrderNo(date: Date = new Date()): string {
const hh = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
const rand = Math.floor(1000 + Math.random() * 9000)
return `WO${yy}${mm}${dd}${hh}${min}${rand}`
return `WK${yy}${mm}${dd}${hh}${min}${rand}`
}
/**