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
@@ -9,7 +9,10 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { ListInput } from '../../repositories/worker-platform/types.js'
import type { JsonObject } from '../../types/json.js'
import { isExplicitOrderNoLookupKeyword } from '../../utils/platform-order-id.js'
import {
isExplicitOrderNoLookupKeyword,
normalizeLookupKeyword,
} from '../../utils/platform-order-id.js'
import {
normalizeDateString,
normalizePage,
@@ -81,7 +84,7 @@ export async function getAdminWorkOrderEvents(workOrderId: number | string) {
* 所以单号检索时只保留关键字,其余筛选一律不下推。
*/
export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListInput {
const keyword = String(query.keyword || '').trim()
const keyword = normalizeLookupKeyword(query.keyword)
if (isExplicitOrderNoLookupKeyword(keyword)) {
// 只按单号查:其余字段显式给空值,保证统计缓存指纹与列表口径一致。
return {
@@ -6,6 +6,7 @@ import {
listWorkOrders,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { normalizeLookupKeyword } from '../../utils/platform-order-id.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
import {
@@ -44,7 +45,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const permissions = resolveWorkerPermissions(worker)
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
const hallConfig = getWorkerHallConfig()
const keyword = String(query.keyword || '').trim()
const keyword = normalizeLookupKeyword(query.keyword)
const cacheKey = JSON.stringify([worker.id, page, pageSize, keyword, categoryId || 0])
return hallListCache.getOrSet(cacheKey, async () => {
const visibleAfterIso =
@@ -23,7 +23,10 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { isExplicitOrderNoLookupKeyword } from '../../utils/platform-order-id.js'
import {
isExplicitOrderNoLookupKeyword,
normalizeLookupKeyword,
} from '../../utils/platform-order-id.js'
import { nowIso } from '../../utils/time.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js'
@@ -91,7 +94,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false)
const permissions = resolveWorkerPermissions(worker)
const statuses = normalizeStatuses(query.status)
const keyword = String(query.keyword || '').trim()
const keyword = normalizeLookupKeyword(query.keyword)
const effectiveStatusFilter = resolveWorkerMyOrderStatusFilter(
keyword,
statuses,