perf(worker): 单号检索收敛为可走索引的等值/前缀,并支持关联单号反查
问题:后台「按单号检索」在平台单号/工单号分支里 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 项分类器单测。
This commit is contained in:
@@ -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
|
||||
'后台工单号大小写不敏感的等值/前缀检索(工单号随机段含小写)';
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1802,7 +1802,7 @@ export default function WorkOrdersPanel() {
|
||||
<Input
|
||||
allowClear
|
||||
value={keywordInput}
|
||||
placeholder="工单号 / 平台单号 / 商品名 / 游戏ID / 游戏昵称"
|
||||
placeholder="工单号 / 平台单号 / 寄卖单号 / 商品名 / 游戏ID / 游戏昵称"
|
||||
style={{ width: 280 }}
|
||||
onChange={(event) => {
|
||||
const nextVal = event.target.value
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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} 不应识别为单号`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user