From 90de6b9419c8b19703327be6867287767cd944b9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 14:27:32 +0800 Subject: [PATCH] =?UTF-8?q?perf(worker):=20=E5=8D=95=E5=8F=B7=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8F=AF=E8=B5=B0=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E7=9A=84=E7=AD=89=E5=80=BC/=E5=89=8D=E7=BC=80?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E5=85=B3=E8=81=94=E5=8D=95?= =?UTF-8?q?=E5=8F=B7=E5=8F=8D=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:后台「按单号检索」在平台单号/工单号分支里 OR 了一支 `work_order_no ILIKE '%'||$n||'%'`。前置通配符非 sargable,任何 B-Tree 都用不上, 整条查询退化成 work_orders 的 Seq Scan。 生产实测(10.3 万行 / 572MB,pg_stat_statements 46 小时窗口): - 列表查询 + 统计查询用同一份 filters 并行执行,一次检索打两遍全表; 两者合计占全库 66% 的磁盘读(1.1TB/46h),单次读 26,442 个块(约 211MB)、398ms、返回 1 行。 - 收敛后同库同参数:等值 0.19ms,前缀 30ms(约 2000 倍)。 - 真实统计查询端到端 760ms → 61.6ms。 改动: 1. 平台单号/工单号分支收敛为「等值 + 前缀」,与 order-repo / task-repo 既有写法一致; 工单号含随机小写段(10.2 万行里 7.4 万行非全大写),保留 LOWER() 比较。 2. 迁移 090 补 text_pattern_ops 前缀索引:当前库 collation 为 en_US.utf8, 前缀 LIKE 在默认 B-Tree 上会退化成全索引扫(实测 438ms)。 3. 新增关联单号反查分支:SUB/INV/AS/DT 不在 work_orders 上, 经关联表反查工单 id,四条链路均命中唯一索引。 4. 前端 isExplicitOrderNoSearch 同步补齐(否则搜到的单会被状态页签挡掉、 实时补丁还会把行删掉)。 验证:生产 EXPLAIN 逐条比对 + 14 项仓储单测 + 6 项分类器单测。 --- .../090_work_order_lookup_pattern_indexes.sql | 29 +++++++ .../repositories/worker-platform-repo.test.ts | 82 +++++++++++++++++++ .../worker-platform/work-order-query-repo.ts | 71 +++++++++++++++- .../src/utils/platform-order-id.test.ts | 32 +++++++- apps/backend/src/utils/platform-order-id.ts | 48 +++++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 2 +- .../src/utils/admin-realtime-work-orders.ts | 12 ++- .../tests/admin-realtime-work-orders.test.ts | 19 +++++ 8 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/db/migrations/090_work_order_lookup_pattern_indexes.sql diff --git a/apps/backend/src/db/migrations/090_work_order_lookup_pattern_indexes.sql b/apps/backend/src/db/migrations/090_work_order_lookup_pattern_indexes.sql new file mode 100644 index 00000000..abc11d78 --- /dev/null +++ b/apps/backend/src/db/migrations/090_work_order_lookup_pattern_indexes.sql @@ -0,0 +1,29 @@ +-- 090_work_order_lookup_pattern_indexes.sql —— 后台「按单号检索」的前缀索引缺口。 +-- +-- 背景:work-order-query-repo 的单号检索分支曾经 OR 了一支 `work_order_no ILIKE '%'||$n||'%'`, +-- 前置通配符非 sargable,把整条查询拖成 work_orders 的 Seq Scan。该 SQL 已在代码侧收敛为 +-- 「等值 + 前缀」两段(与 order-repo / task-repo 一致),但前缀段还需要本迁移的索引支撑。 +-- +-- 生产实测(2026-10-03,10.3 万行 / 572MB,datcollate = en_US.utf8): +-- 收敛前:Seq Scan,Rows Removed by Filter: 102,631,26,442 buffers(≈211MB),398ms +-- pg_stat_statements 46 小时窗口里,同一形状的列表查询 + 统计查询合计占全库 66% 磁盘读 +-- +-- 为什么不能直接用已有索引: +-- idx_work_orders_platform_order / work_orders_work_order_no_key 都是默认 collation 的 +-- B-Tree,而当前库 collation 为 en_US.utf8,前缀 LIKE 无法转换成范围条件 —— 实测 +-- `platform_order_id LIKE '262740%'` 退化成 Index Only Scan 全索引 + Rows Removed by +-- Filter: 99,471,438ms,几乎等于全表扫。text_pattern_ops 才能让前缀走真正的范围扫描。 +CREATE INDEX IF NOT EXISTS idx_work_orders_platform_order_pattern + ON work_orders (platform_order_id text_pattern_ops); + +-- 工单号必须大小写不敏感:10.2 万行里有 7.4 万行的随机段含小写(生成器见 utils/random.ts), +-- 所以检索条件用 LOWER(work_order_no) 比较,索引也必须建在同一个表达式上。 +-- 实测:等值命中 0.048ms(Bitmap Index Scan),前缀 'WO2610' 走 Bitmap Heap Scan 30~234ms +-- (较收敛前的 398ms Seq Scan 已是量级改善,且不再产生 200MB 级缓冲读)。 +CREATE INDEX IF NOT EXISTS idx_work_orders_work_order_no_lower_pattern + ON work_orders (LOWER(work_order_no) text_pattern_ops); + +COMMENT ON INDEX idx_work_orders_platform_order_pattern IS + '后台平台单号前缀检索:en_US.utf8 collation 下前缀 LIKE 需要 text_pattern_ops 才能走范围扫'; +COMMENT ON INDEX idx_work_orders_work_order_no_lower_pattern IS + '后台工单号大小写不敏感的等值/前缀检索(工单号随机段含小写)'; diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index 4b1160f0..c8aa0633 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -116,6 +116,88 @@ test('空白搜索词不生成全表 LIKE 条件', () => { assert.deepEqual(query.params, []) }) +test('平台单号检索只生成可走索引的等值/前缀条件,不得出现前置通配符', () => { + const query = buildWorkOrderWhere({ keyword: '2627401680611492' }) + + assert.match( + query.whereClause, + /\(wo\.platform_order_id = \$1 OR wo\.platform_order_id LIKE \$1 \|\| '%'\)/, + ) + // 前置通配符非 sargable:一旦混进来,整条查询会退化成 work_orders 全表扫, + // 生产实测每次读 211MB / 398ms,列表与统计并行触发一次打两遍。 + assert.doesNotMatch(query.whereClause, /ILIKE\s*'%'/) + assert.deepEqual(query.params, ['2627401680611492']) +}) + +test('工单号检索大小写不敏感且只走索引,不得出现前置通配符', () => { + 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\) \|\| '%'\)/, + ) + assert.doesNotMatch(query.whereClause, /ILIKE\s*'%'/) + assert.deepEqual(query.params, ['WO26100113008697']) +}) + +test('SUB/INV/AS/DT 关联单号经关联表反查工单,不回落到通配符', () => { + // 寄售线索号:SUB → result_inventory_id → bound_work_order_id + const sub = buildWorkOrderWhere({ keyword: 'SUB26100301127318' }) + assert.match(sub.whereClause, /FROM consignment_submissions cs/) + assert.match(sub.whereClause, /ci\.bound_work_order_id IS NOT NULL/) + assert.doesNotMatch(sub.whereClause, /LIKE LOWER/) + assert.deepEqual(sub.params, ['SUB26100301127318']) + + // 寄售库存号:INV → bound_work_order_id + const inv = buildWorkOrderWhere({ keyword: 'INV26100310477194' }) + assert.match(inv.whereClause, /FROM consignment_inventories ci/) + assert.doesNotMatch(inv.whereClause, /LIKE LOWER/) + + // 售后案号:AS → work_order_id + const ase = buildWorkOrderWhere({ keyword: 'ASde06bcb25aca' }) + assert.match(ase.whereClause, /FROM worker_after_sales_cases ac/) + assert.match(ase.whereClause, /ac\.work_order_id IS NOT NULL/) + assert.doesNotMatch(ase.whereClause, /LIKE LOWER/) + + // 履约任务号:DT → work_orders.task_id(历史数据为空,但链路必须保留) + const dt = buildWorkOrderWhere({ keyword: 'DTb946808bcd5b' }) + assert.match(dt.whereClause, /FROM fulfillment_tasks ft/) + assert.match(dt.whereClause, /wo_task\.task_id = ft\.id/) + assert.doesNotMatch(dt.whereClause, /LIKE LOWER/) +}) + +test('普通关键词仍然走 trgm 表达式模糊搜索', () => { + const query = buildWorkOrderWhere({ keyword: '指挥官密钥' }) + + assert.match(query.whereClause, /LIKE LOWER\(\$\d+\)/) + assert.deepEqual(query.params, ['%指挥官密钥%']) +}) + +test('单号检索分支不得回退成裸列模糊匹配(源码级防退化守卫)', async () => { + const { readFile } = await import('node:fs/promises') + const src = await readFile( + new URL('./worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + + // 这里是 46 小时窗口内占全库 66% 磁盘读的形状:裸列 + 前置通配符。 + assert.doesNotMatch( + src, + /wo\.work_order_no ILIKE '%' \|\|/, + '工单号模糊匹配必须改用 trgm 表达式,或收敛为等值/前缀', + ) + assert.doesNotMatch( + src, + /wo\.work_order_no ILIKE \$\{paramIndex\} \|\|/, + '工单号前缀匹配必须用 LOWER() 走 idx_work_orders_work_order_no_lower_pattern', + ) + assert.match( + src, + /platform_order_id LIKE \$\{paramIndex\} \|\| '%'/, + '平台单号前缀条件必须保留(迁移 090 的 text_pattern_ops 索引依赖它)', + ) +}) + test('大厅候选按分类上限取队首后再受全局上限限制', () => { const query = buildWorkOrderWhere({ hallCandidateLimit: 20, 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 833a5d43..27aaef3b 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 @@ -9,6 +9,8 @@ import { import { isPlatformOrderIdLookupKeyword, isWorkOrderNoLookupKeyword, + resolveRelatedWorkOrderLookupKind, + type RelatedWorkOrderLookupKind, } from '../../utils/platform-order-id.js' export const WORK_ORDER_SELECT = ` @@ -141,6 +143,54 @@ function workOrderSearchExpression(alias = 'wo') { )` } +/** + * 关联单号(SUB / INV / AS / DT)反查工单的条件。 + * + * 这些号码不存在于 work_orders 上,只能经关联表回溯。四条链路分别命中: + * consignment_submissions_submission_no_key / consignment_inventories_inventory_no_key + * / worker_after_sales_cases_case_no_key / fulfillment_tasks_task_no_key + * 全部是唯一索引等值查找,因此不会出现「按单号搜索反而全表扫」的情况。 + * + * 回溯不到时不返回任何行(而不是回落到通配符搜索)——SUB/INV 在库存被工单消耗前 + * 本来就没有对应工单,此时"查不到"是正确结果。 + */ +function buildRelatedWorkOrderLookupFilter( + kind: RelatedWorkOrderLookupKind, + paramIndex: string, +): string { + if (kind === 'consignment_submission') { + return `wo.id IN ( + SELECT ci.bound_work_order_id + FROM consignment_submissions cs + JOIN consignment_inventories ci ON ci.id = cs.result_inventory_id + WHERE cs.submission_no = ${paramIndex} + AND ci.bound_work_order_id IS NOT NULL + )` + } + if (kind === 'consignment_inventory') { + return `wo.id IN ( + SELECT ci.bound_work_order_id + FROM consignment_inventories ci + WHERE ci.inventory_no = ${paramIndex} + AND ci.bound_work_order_id IS NOT NULL + )` + } + if (kind === 'after_sales_case') { + return `wo.id IN ( + SELECT ac.work_order_id + FROM worker_after_sales_cases ac + WHERE ac.case_no = ${paramIndex} + AND ac.work_order_id IS NOT NULL + )` + } + return `wo.id IN ( + SELECT wo_task.id + FROM fulfillment_tasks ft + JOIN work_orders wo_task ON wo_task.task_id = ft.id + WHERE ft.task_no = ${paramIndex} + )` +} + /** * 打手订单个人状态:拼单按个人份额状态计算。 * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, @@ -930,18 +980,35 @@ export function buildWorkOrderWhere({ filters.push(`wo.id = $${params.length}`) } if (normalizedKeyword) { + // 单号检索只允许「可走索引的等值/前缀」条件,与 order-repo / task-repo 的既有写法保持一致。 + // 这两支曾经额外 OR 了 `work_order_no ILIKE '%' || $n || '%'`:前置通配符非 sargable, + // 任何 B-Tree 都用不上,会把整条查询拖成 work_orders 的 Seq Scan。 + // 生产实测(10.3 万行 / 572MB,pg_stat_statements 46 小时窗口): + // 后台按平台单号检索 → Seq Scan 每次读 26,442 个块(约 211MB)、398ms、返回 1 行; + // 列表与统计用同一份 filters 并行执行,一次检索打两遍全表, + // 两者合计占全库 66% 的磁盘读(1.1TB/46h)。 + // 收敛后同库同参数:Index Scan 0.19ms,前缀检索 30ms(约 2000 倍)。 + const relatedLookupKind = resolveRelatedWorkOrderLookupKind(normalizedKeyword) if (isPlatformOrderIdLookupKeyword(normalizedKeyword)) { params.push(normalizedKeyword) const paramIndex = `$${params.length}` filters.push( - `(wo.platform_order_id = ${paramIndex} OR wo.platform_order_id LIKE ${paramIndex} || '%' OR wo.work_order_no ILIKE '%' || ${paramIndex} || '%')`, + `(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( - `(wo.work_order_no = ${paramIndex} OR wo.work_order_no ILIKE ${paramIndex} || '%' OR wo.work_order_no ILIKE '%' || ${paramIndex} || '%' OR LOWER(wo.work_order_no) = LOWER(${paramIndex}))`, + `(LOWER(wo.work_order_no) = LOWER(${paramIndex}) OR LOWER(wo.work_order_no) LIKE LOWER(${paramIndex}) || '%')`, ) + } else if (relatedLookupKind) { + // 关联单号(SUB/INV/AS/DT)本身不在 work_orders 上,必须先经关联表反查工单 id。 + // 四条链路都命中唯一索引,属于索引驱动的等值查询;查不到时直接返回空集, + // 不会像通配符那样退化成全表扫。 + params.push(normalizedKeyword) + filters.push(buildRelatedWorkOrderLookupFilter(relatedLookupKind, `$${params.length}`)) } else { params.push(`%${normalizedKeyword}%`) filters.push(`${workOrderSearchExpression()} LIKE LOWER($${params.length})`) diff --git a/apps/backend/src/utils/platform-order-id.test.ts b/apps/backend/src/utils/platform-order-id.test.ts index 7732b9ae..9d6b0617 100644 --- a/apps/backend/src/utils/platform-order-id.test.ts +++ b/apps/backend/src/utils/platform-order-id.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { isPlatformOrderIdLookupKeyword, isWorkOrderNoLookupKeyword } from './platform-order-id.js' +import { + isPlatformOrderIdLookupKeyword, + isWorkOrderNoLookupKeyword, + resolveRelatedWorkOrderLookupKind, +} from './platform-order-id.js' test('识别纯数字平台订单号', () => { assert.equal(isPlatformOrderIdLookupKeyword('20247230034021572'), true) @@ -29,3 +33,29 @@ test('拒绝普通关键词', () => { assert.equal(isPlatformOrderIdLookupKeyword(''), false) assert.equal(isWorkOrderNoLookupKeyword('皮肤店'), false) }) + +test('识别需要反查工单的关联单号', () => { + // 生产真实值 + assert.equal(resolveRelatedWorkOrderLookupKind('SUB26100301127318'), 'consignment_submission') + assert.equal(resolveRelatedWorkOrderLookupKind('INV26092619393769'), 'consignment_inventory') + assert.equal(resolveRelatedWorkOrderLookupKind('ASde06bcb25aca'), 'after_sales_case') + assert.equal(resolveRelatedWorkOrderLookupKind('DTb946808bcd5b'), 'fulfillment_task') + + // 大小写不敏感,前后空格容忍 + assert.equal(resolveRelatedWorkOrderLookupKind(' sub26100301127318 '), 'consignment_submission') + assert.equal(resolveRelatedWorkOrderLookupKind('asDE06BCB25ACA'), 'after_sales_case') +}) + +test('关联单号识别不得误伤工单号、平台单号与普通关键词', () => { + // WO 走自己的分支,不能被关联识别截胡 + assert.equal(resolveRelatedWorkOrderLookupKind('WO26100314105769'), null) + // 平台单号(纯数字 / 带连字符)同样走自己的分支 + assert.equal(resolveRelatedWorkOrderLookupKind('2627602160531614'), null) + assert.equal(resolveRelatedWorkOrderLookupKind('261003-072613926530472'), null) + // 前缀相近但不是单号的普通词 + assert.equal(resolveRelatedWorkOrderLookupKind('SUBWAY'), null) + assert.equal(resolveRelatedWorkOrderLookupKind('ASO优化'), null) + assert.equal(resolveRelatedWorkOrderLookupKind('DT'), null) + assert.equal(resolveRelatedWorkOrderLookupKind(''), null) + assert.equal(resolveRelatedWorkOrderLookupKind(null), null) +}) diff --git a/apps/backend/src/utils/platform-order-id.ts b/apps/backend/src/utils/platform-order-id.ts index d0a68fb5..9dc30682 100644 --- a/apps/backend/src/utils/platform-order-id.ts +++ b/apps/backend/src/utils/platform-order-id.ts @@ -14,3 +14,51 @@ export function isWorkOrderNoLookupKeyword(value: unknown): boolean { const trimmed = String(value || '').trim() return /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed) } + +/** + * 关联单号类型:号码本身不在 work_orders 上,必须经关联表反查工单 id。 + * - consignment_submission: 寄售线索号 SUB... → 产出的库存 → 被工单消耗后的工单 + * - consignment_inventory: 寄售库存号 INV... → 被工单消耗后的工单 + * - after_sales_case: 售后案号 AS... → 直接带 work_order_id + * - fulfillment_task: 履约任务号 DT... → work_orders.task_id + * + * 说明:这里按 schema 上的关联链路判定,不看当前数据填充率。 + * 例如 SUB/INV 目前只有约一半能关联到工单(库存未被消耗前本就没有工单), + * DT 的 work_orders.task_id 目前全为 NULL(历史数据), + * 但链路是通的,后续数据填上后即可直接命中。 + */ +export type RelatedWorkOrderLookupKind = + | 'consignment_submission' + | 'consignment_inventory' + | 'after_sales_case' + | 'fulfillment_task' + +const RELATED_WORK_ORDER_PATTERNS: Array<{ + kind: RelatedWorkOrderLookupKind + pattern: RegExp +}> = [ + // SUB + 年月日时分 + 4 位随机,形如 SUB26100301127318 + { kind: 'consignment_submission', pattern: /^SUB\d{8,}$/i }, + // INV + 年月日时分 + 4 位随机,形如 INV26100310477194 + { kind: 'consignment_inventory', pattern: /^INV\d{8,}$/i }, + // AS + 12 位随机 hex,形如 ASde06bcb25aca + { kind: 'after_sales_case', pattern: /^AS[0-9a-f]{8,}$/i }, + // DT + 12 位随机 hex,形如 DTb946808bcd5b + { kind: 'fulfillment_task', pattern: /^DT[0-9a-f]{8,}$/i }, +] + +/** + * 判断关键词是否是「需要反查工单」的关联单号。 + */ +export function resolveRelatedWorkOrderLookupKind( + value: unknown, +): RelatedWorkOrderLookupKind | null { + const trimmed = String(value || '').trim() + if (!trimmed) return null + for (const item of RELATED_WORK_ORDER_PATTERNS) { + if (item.pattern.test(trimmed)) { + return item.kind + } + } + return null +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 979ea773..5fa730da 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -1802,7 +1802,7 @@ export default function WorkOrdersPanel() { { const nextVal = event.target.value diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index 1ee9d0ce..c0edd08e 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -100,11 +100,15 @@ function matchesCreatedRange( } /** - * 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号。 + * 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号 / 关联单号。 * * 两处依赖它,必须保持一致,所以放在这里而不是面板内部: * 1. 面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶); * 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉。 + * + * 关联单号(寄卖提报号 SUB / 仓库号 INV / 售后案号 AS / 履约任务号 DT)与后端 + * work-order-query-repo 的反查分支一一对应:客服界面「只认寄卖单号」, + * 粘进来的就是 SUB,漏掉它会导致搜到的单被状态页签挡掉。 */ export function isExplicitOrderNoSearch(text: unknown): boolean { const trimmed = String(text || '').trim() @@ -112,7 +116,11 @@ export function isExplicitOrderNoSearch(text: unknown): boolean { return ( /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed) || /^\d{6,}(-\d{6,})?$/.test(trimmed) || - /^OD[0-9a-zA-Z]{4,}$/i.test(trimmed) + /^OD[0-9a-zA-Z]{4,}$/i.test(trimmed) || + /^SUB\d{8,}$/i.test(trimmed) || + /^INV\d{8,}$/i.test(trimmed) || + /^AS[0-9a-f]{8,}$/i.test(trimmed) || + /^DT[0-9a-f]{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 91918a8a..b273ebd1 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -418,3 +418,22 @@ test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', () assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`) } }) + +test('关联单号(寄卖/仓库/售后/履约)也算显式单号检索', () => { + // 客服界面「只认寄卖单号」,粘进来的就是 SUB;漏判会让搜到的单被状态页签挡掉 + for (const orderNo of [ + 'SUB26100301127318', + 'INV26100310477194', + 'ASde06bcb25aca', + 'DTb946808bcd5b', + // 大小写与空格容忍 + ' sub26100301127318 ', + ]) { + assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`) + } + + // 前缀相近但不是单号的普通词,不能被误判(误判会清掉客服的筛选条件) + for (const keyword of ['SUBWAY', 'ASO优化', 'DT', 'INV', 'SUB123', 'ASzzz']) { + assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`) + } +})