From cb064d7cd516cc2d00b660374c2bf4a3f165c6e3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 12 Oct 2026 16:51:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-platform):=20=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E5=8F=B7=E5=89=8D=E7=BC=80=E6=94=B9=20WK=20=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E4=BE=A7=E5=AE=B9=E9=94=99=20O/0=EF=BC=8C?= =?UTF-8?q?=E5=BD=BB=E5=BA=95=E8=A7=A3=E5=86=B3=E3=80=8C=E6=8C=89=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E5=8F=B7=E6=90=9C=E4=B8=8D=E5=88=B0=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 线上定位到的真正根因 客服搜 `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 的期望值同步为新形态(两条候选条件)。 --- .../repositories/worker-platform-repo.test.ts | 9 ++- .../work-order-keyword-lookup.test.ts | 55 +++++++++++++++ .../worker-platform/work-order-query-repo.ts | 22 ++++-- .../admin-work-order-query-service.ts | 7 +- .../worker-platform/worker-hall-service.ts | 3 +- .../worker-my-order-service.ts | 7 +- .../src/utils/platform-order-id.test.ts | 47 ++++++++++++- apps/backend/src/utils/platform-order-id.ts | 69 +++++++++++++++---- apps/backend/src/utils/random.ts | 13 +++- .../admin/ConsignmentManualMatchModal.tsx | 2 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 5 +- .../src/pages/worker/WorkerOrdersPage.tsx | 5 +- .../src/utils/admin-realtime-work-orders.ts | 24 ++++++- .../tests/admin-realtime-work-orders.test.ts | 24 ++++++- 14 files changed, 251 insertions(+), 41 deletions(-) create mode 100644 apps/backend/src/repositories/worker-platform/work-order-keyword-lookup.test.ts diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index 024016a5..4b798f18 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -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 关联单号经关联表反查工单,不回落到通配符', () => { diff --git a/apps/backend/src/repositories/worker-platform/work-order-keyword-lookup.test.ts b/apps/backend/src/repositories/worker-platform/work-order-keyword-lookup.test.ts new file mode 100644 index 00000000..8f5451c7 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-keyword-lookup.test.ts @@ -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'), '商品名不应误入工单号等值分支') +}) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 03510641..70208330 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -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。 // 四条链路都命中唯一索引,属于索引驱动的等值查询;查不到时直接返回空集, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index 5bc7806a..e99f8334 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -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 { diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index cddaa352..dfae9a6c 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -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 = diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 912faf2f..4b372edb 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -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, diff --git a/apps/backend/src/utils/platform-order-id.test.ts b/apps/backend/src/utils/platform-order-id.test.ts index ed5e956a..9df7d1ea 100644 --- a/apps/backend/src/utils/platform-order-id.test.ts +++ b/apps/backend/src/utils/platform-order-id.test.ts @@ -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), '') +}) diff --git a/apps/backend/src/utils/platform-order-id.ts b/apps/backend/src/utils/platform-order-id.ts index 57d74e63..d5813926 100644 --- a/apps/backend/src/utils/platform-order-id.ts +++ b/apps/backend/src/utils/platform-order-id.ts @@ -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 } } diff --git a/apps/backend/src/utils/random.ts b/apps/backend/src/utils/random.ts index 9c0defb0..adcfe68b 100644 --- a/apps/backend/src/utils/random.ts +++ b/apps/backend/src/utils/random.ts @@ -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}` } /** diff --git a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx index fc20f456..271ee90c 100644 --- a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx @@ -267,7 +267,7 @@ export default function ConsignmentManualMatchModal({ placeholder={ mode === 'order' ? '搜索物品名 / 寄卖单号 (SUB...) / 仓库号 (INV...) / 规格 / 供货打手' - : '搜索工单号 (WO...) / 平台单号 / 标题 / 规格' + : '搜索工单号 (WK... / WO...) / 平台单号 / 标题 / 规格' } prefix={} value={keyword} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 28c3da74..b7bd06b9 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -95,7 +95,7 @@ import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' -import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders' +import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { DeadlineCountdown, formatAcceptanceMode, @@ -1076,7 +1076,8 @@ export default function WorkOrdersPanel() { } function searchOrders() { - const trimmed = keywordInput.trim() + // 归一化(全角→半角、去零宽字符):粘贴来的单号常夹带不可见字符,肉眼查不出来。 + const trimmed = normalizeSearchText(keywordInput) setKeyword(trimmed) setPage(1) // 精准单号检索时清空**所有**排他筛选,而不是只清状态/阶段: diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 1b67d786..9b6b9a49 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -21,7 +21,7 @@ import { supplementWorkerAcceptedOrderEvidence, } from '@/services/worker' import type { UploadedFile, WorkOrder } from '@/types/worker-platform' -import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders' +import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { useIsMobile } from '@/utils/use-is-mobile' import { buildWorkerOrderQuery } from '@/utils/worker-order-query' import { WorkerOrderDesktopList } from './WorkerOrderDesktopList' @@ -491,7 +491,8 @@ export default function WorkerOrdersPage() { } function applyKeywordSearch() { - const trimmed = keywordInput.trim() + // 归一化(全角→半角、去零宽字符),与后端同一口径。 + const trimmed = normalizeSearchText(keywordInput) // 显式单号(WO…/平台单号/关联单号)检索时后端会忽略状态页签 // (见 worker-my-order-service 的 resolveWorkerMyOrderStatusFilter), // 前端同步切到「全部订单」,避免页签写着「代练中」却列出待验收/已验收的单。 diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index 67937f54..5389b769 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -99,9 +99,29 @@ function matchesCreatedRange( return true } +const ZERO_WIDTH_CHARS = /[\u200b-\u200d\ufeff]/g + +/** + * 检索文本归一化:NFKC(全角字母/数字 → 半角)+ 去掉零宽字符 + trim。 + * 与后端 utils/platform-order-id.ts 的 normalizeLookupKeyword 同一口径: + * 从网页/Excel/聊天复制过来的单号常夹带零宽字符或全角字符, + * 肉眼看不出来却会让匹配全部落空("明明贴对了却搜不到")。 + */ +export function normalizeSearchText(value: unknown): string { + return String(value ?? '') + .normalize('NFKC') + .replace(ZERO_WIDTH_CHARS, '') + .trim() +} + /** * 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号 / 关联单号。 * + * 工单号前缀写成 `W[oOkK0]`:现役前缀是 `WK`(与 0 不会看混), + * 历史前缀 `WO` 里的字母 O 与数字 0 在界面字体里几乎无法区分, + * 用户手输/转抄极易写成 `W0…`(生产 12.7 万单全以 WO 开头、'o' 只在第 2 位)。 + * 后端 buildWorkOrderNoKeywordVariants 会同时匹配 W0/WO/WK 三种写法,两侧必须一致。 + * * 三处依赖它,必须保持一致,所以放在这里而不是面板内部: * 1. 后台面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶); * 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉; @@ -116,10 +136,10 @@ function matchesCreatedRange( * 粘进来的就是 SUB,漏掉它会导致搜到的单被状态页签挡掉。 */ export function isExplicitOrderNoSearch(text: unknown): boolean { - const trimmed = String(text || '').trim() + const trimmed = normalizeSearchText(text) if (!trimmed) return false return ( - /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed) || + /^W[oOkK0][0-9a-zA-Z]{4,}$/i.test(trimmed) || /^\d{6,}(-\d{6,})?$/.test(trimmed) || /^OD[0-9a-zA-Z]{4,}$/i.test(trimmed) || /^SUB\d{8,}$/i.test(trimmed) || diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index 214b7bb0..21cc121e 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -4,6 +4,7 @@ import test from 'node:test' import { evaluateAdminWorkOrderRealtimeMatch, isExplicitOrderNoSearch, + normalizeSearchText, parseAdminWorkOrderListFilters, patchAdminWorkOrderListItems, shouldPrependAdminWorkOrder, @@ -435,14 +436,33 @@ test('服务端 remove 事件不得绕过显式单号护栏;模糊关键词仍 }) test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', () => { - for (const orderNo of ['WO26100217194691', 'OD26100217', '2627502003065030', 'wo12345678']) { + for (const orderNo of [ + // 现役前缀 WK + 'WK26101213031939', + 'wk26101213031939', + // 历史前缀 WO + 'WO26100217194691', + 'wo12345678', + // 把前缀的 O 看成 0(线上真实案例:用平台号搜得到、用工单号搜不到) + 'W026101213031939', + 'OD26100217', + '2627502003065030', + ]) { assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`) } - for (const keyword of ['暗潮', '无限潮汐', 'WO1', '', 'abcd']) { + for (const keyword of ['暗潮', '无限潮汐', 'WO1', 'WK', 'W0', '', 'abcd']) { assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`) } }) +test('isExplicitOrderNoSearch 容错:全角字符与零宽字符不影响识别', () => { + assert.equal(isExplicitOrderNoSearch(' WK26101213031939 '), true) + assert.equal(isExplicitOrderNoSearch('WK26101213031939\u200b'), true) + assert.equal(isExplicitOrderNoSearch('\ufeffWO26101213031939'), true) + // 归一化口径与后端 normalizeLookupKeyword 一致 + assert.equal(normalizeSearchText(' WO26101213031939\u200d '), 'WO26101213031939') +}) + test('关联单号(寄卖/仓库/售后/履约)也算显式单号检索', () => { // 客服界面「只认寄卖单号」,粘进来的就是 SUB;漏判会让搜到的单被状态页签挡掉 for (const orderNo of [