fix(admin): 显式单号检索下,其它筛选不得把正在看的工单从实时补丁里删掉

## 修的是什么

实时补丁的判定链里,「状态 / 接单人员 / 日期范围」这些可证伪条件排在关键词护栏之前,
任一不成立就先返回 mismatch(允许删行),于是第 156 行那条「有关键词就不删行」的护栏
在带筛选时根本执行不到。

d6523635 只在 UI 侧补了一半:搜显式单号时清空 状态/赠送阶段/验收模式,
但**没清接单人员、分类、日期范围**,而 workerId 与 createdRange 同样会先返回 mismatch。
所以「单号 + 接单人员」或「单号 + 日期范围」仍会把行删掉。

实测(真实函数):
  单号 + 状态筛选      -> mismatch(删行)
  单号 + 接单人员筛选  -> mismatch(删行)
  单号 + 日期范围      -> mismatch(删行)

## 修法

只对「显式单号检索」提前短路为 unknown(只原地更新,不插不移):
  if (isExplicitOrderNoSearch(filters.keyword)) return 'unknown'

刻意**不**推翻既有设计:模糊关键词(产品名/昵称等)仍保留「状态可证伪即移除」的语义,
因此原来锁定该语义的用例继续通过。

顺带把 isExplicitOrderNoSearch 合并到 utils:此前面板与 AdminLayout 各有一份规则,
重复两份正是上次 tab 名单漏改那类问题的温床。

## 边界(重要)

**本次修复不覆盖「无任何其它筛选、只按单号搜索」的场景。**
实测该场景下判定本就是 unknown、行不会被删(状态原地更新、分页与统计一致),
因此线上反馈的「状态变化后搜不到、必须整页刷新」若发生在无筛选条件下,另有他因,尚未定位。
This commit is contained in:
yml2213
2026-10-02 17:51:39 +08:00
parent 6bfeb5a02a
commit 56e718a65d
3 changed files with 70 additions and 10 deletions
@@ -95,6 +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 {
DeadlineCountdown,
formatAcceptanceMode,
@@ -1068,16 +1069,6 @@ export default function WorkOrdersPanel() {
await doSubmit()
}
function isExplicitOrderNoSearch(text: string) {
const trimmed = text.trim()
if (!trimmed) return false
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)
)
}
function searchOrders() {
const trimmed = keywordInput.trim()
setKeyword(trimmed)
@@ -99,12 +99,34 @@ function matchesCreatedRange(
return true
}
/**
* 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号。
*
* 两处依赖它,必须保持一致,所以放在这里而不是面板内部:
* 1. 面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶);
* 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉。
*/
export function isExplicitOrderNoSearch(text: unknown): boolean {
const trimmed = String(text || '').trim()
if (!trimmed) return false
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)
)
}
export function evaluateAdminWorkOrderRealtimeMatch(
snapshot: unknown,
filters: AdminWorkOrderListFilters,
): AdminWorkOrderRealtimeVerdict {
if (!isRecord(snapshot)) return 'unknown'
// 显式单号检索要排在最前面:用户就是在找这一单,无论它流转到什么状态都必须留在视野里。
// 只做原地更新,不插入也不移除 —— 否则状态流转(或前端对状态/接单人员/日期口径的复刻偏差)
// 会把正在看的单从结果里删掉,表现为「状态一变就搜不到,必须整页刷新」(生产已复现)。
if (isExplicitOrderNoSearch(filters.keyword)) return 'unknown'
// 先跑所有「可以证伪」的条件:任何一条不满足就是明确的 mismatch(允许从列表移除)
//
// 状态必须用 rawStatus 比:快照的 `status` 是运营态(拼单母单 open 有份额时会被显示成
@@ -151,6 +173,7 @@ export function evaluateAdminWorkOrderRealtimeMatch(
// - 赠送阶段:gift_ready 是"冷却到点"的实时阶段,含 NOW() 判定;
// - 打手等级:只在服务端按接单人与拼单参与者计算;
// - VIP 待补图:混在状态列表里的虚拟值,判定依赖 acceptance_json 多个字段与 NOW()。
// 注:显式单号检索已在函数开头短路,走不到这里。
if (filters.acceptanceMode) return 'unknown'
if (vipEvidencePendingActive) return 'unknown'
if (filters.keyword) return 'unknown'
@@ -3,6 +3,7 @@ import test from 'node:test'
import {
evaluateAdminWorkOrderRealtimeMatch,
isExplicitOrderNoSearch,
parseAdminWorkOrderListFilters,
patchAdminWorkOrderListItems,
shouldPrependAdminWorkOrder,
@@ -372,3 +373,48 @@ test('关键词 / 赠送阶段 / 打手等级筛选:无法证真,只原地
'mismatch',
)
})
/**
* 显式单号检索的护栏。
*
* 生产现象:按工单号搜出来的那一行,在订单状态变化后会从结果里消失,必须整页刷新才回来。
* 成因是实时补丁的判定链里,「状态/接单人员/日期范围」这些可证伪条件排在关键词护栏之前,
* 一旦它们判定 mismatch 就允许把行删掉。而面板搜索时只清空了状态,没清接单人员/日期范围,
* 所以护栏还会被这些条件绕过。
*/
test('显式单号检索:状态流转后不得把正在看的单删掉', () => {
const cases: Array<Partial<AdminWorkOrderListFilters>> = [
{ keyword: 'WO26100217194691', statuses: 'in_progress' },
{ keyword: 'WO26100217194691', workerId: 7 },
{ keyword: 'WO26100217194691', createdFrom: '2026-10-05' },
{ keyword: 'OD26100217', statuses: 'open' },
{ keyword: '2627502003065030', statuses: 'open' },
]
for (const override of cases) {
const filters = parseAdminWorkOrderListFilters(buildQueryKey(override))!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters),
'unknown',
`${JSON.stringify(override)} 应判为 unknown(只原地更新),否则状态一变就搜不到`,
)
}
})
test('模糊关键词不享受单号护栏:状态可证伪时仍按原设计移除', () => {
const filters = parseAdminWorkOrderListFilters(
buildQueryKey({ keyword: '暗潮', statuses: 'open' }),
)!
assert.equal(
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), filters),
'mismatch',
)
})
test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', () => {
for (const orderNo of ['WO26100217194691', 'OD26100217', '2627502003065030', 'wo12345678']) {
assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`)
}
for (const keyword of ['暗潮', '无限潮汐', 'WO1', '', 'abcd']) {
assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`)
}
})