fix(worker-platform): 按工单号搜不到彻底收口 —— 显式单号检索不再被任何排他筛选吞掉

## 修的是什么

线上反复复发的「后台接单平台按工单号搜不到、某些状态尤其明显」,根因不是一处,
而是同一类问题在 5 个地方各留了一半(历次修复都只补了一处,所以总复发):

1. 状态筛选走运营态派生:拼单母单一旦有人参与,open → in_progress /
   pending_acceptance,于是「单号 + 待接单」永远为空(没人参与时反而搜得到),
   这正是「有人接单后就搜不到」;非拼单单子被接单后同理。
2. 「单号 + 接单人员 / 分类 / 打手等级 / 创建时间」照样过滤掉目标单
   (接单人员是 INNER JOIN 可见集合,不在集合里整行消失)。
   d6523635 只在面板查询按钮里清了 状态/赠送阶段/验收模式,这三个没清。
3. 待办通知跳转(buildNotificationActionPath 带 ?workOrderId=)设下的
   「定位工单」是 wo.id = X 排他条件,不手点「取消定位」就一直生效,
   之后搜任何别的单号都是空 —— 这条路径历次修复从未涉及。
4. AdminLayout 把服务端 operation='remove' 直接判成 mismatch,
   绕过了「显式单号不删行」的护栏。
5. 打手端「我的订单」默认页签固定「代练中」,搜索不联动页签、
   实时补丁又拿状态当可证伪条件删行 → 打手端同类问题(此前只修了关键词匹配)。

## 修法(收口到单一真源,不再按按钮打补丁)

后端:
- 新增 isExplicitOrderNoLookupKeyword()(工单号 / 平台单号 / SUB·INV·AS·DT);
- 后台列表筛选抽成纯函数 resolveAdminWorkOrderListFilters():显式单号检索时
  不下推 状态/VIP待补图/验收模式/赠送阶段/接单人员/分类/打手等级/创建时间/定位工单;
- 打手端 resolveWorkerMyOrderStatusFilter():只放掉状态与 VIP 待补图,
  **严格保留 workerSharingId 可见范围**(否则打手能搜到别人的单)。

前端:
- admin-realtime-work-orders.ts:显式单号检索直接判 unknown(含服务端 remove),
  remove 的短路移交给判定函数;
- WorkOrdersPanel:单号检索时清空全部排他筛选并提示「已按单号精准检索,自动忽略…」,
  通知定位工单时也清掉其它筛选,避免残留;
- WorkerOrdersPage:单号检索自动切「全部订单」;WorkerLayout 走新的
  resolveWorkerMyOrderRealtimeVerdict(),与后台同策。

刻意不改(避免越权/召回不可用单):抢单大厅(候选必须是 open)、
寄卖手动匹配候选(只做未接单整单)。

## 验证

后端 npm run check 全绿:682 pass / 3 skip(新增 6 项);
前端 npm run check 全绿:138 pass(新增 6 项,含「单号检索 + 实时事件不得删行」,
以及「服务端 remove 不得绕过单号护栏」的回归);format/lint/typecheck/build 通过。
无数据库迁移。
This commit is contained in:
yml2213
2026-10-12 15:48:30 +08:00
parent 966b804bfc
commit fe4d02d136
13 changed files with 443 additions and 26 deletions
+5 -2
View File
@@ -453,8 +453,11 @@ function patchAdminWorkOrderCaches(
}
// 三态判定:match 才允许插入顶部;mismatch 才允许移除;unknown 只原地更新已存在的行。
const verdict =
event.operation === 'remove' ? 'mismatch' : evaluateAdminWorkOrderRealtimeMatch(item, filters)
// 服务端的 operation 交给判定函数处理:显式单号检索下连 remove 都不能删行
// (旧写法在这里直接把 remove 判成 mismatch,绕过了单号护栏)。
const verdict = evaluateAdminWorkOrderRealtimeMatch(item, filters, {
operation: event.operation,
})
if (verdict === 'unknown') {
queryClient.setQueryData(
queryKey,
+17 -6
View File
@@ -14,10 +14,12 @@ import { Outlet, useLocation, useNavigate } from 'react-router'
import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker'
import { useIsMobile } from '@/utils/use-is-mobile'
import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders'
import {
evaluateWorkerKeywordVerdict,
patchWorkerWorkOrderInfiniteEnvelope,
patchWorkerWorkOrderListEnvelope,
resolveWorkerMyOrderRealtimeVerdict,
} from '@/utils/worker-realtime-work-orders'
import {
clearWorkerSession,
@@ -344,14 +346,23 @@ function patchWorkerMyOrderCaches(
}
// 状态可证伪:不符才允许删行;关键词不可证伪(后端搜的字段比前端多,含工单号),
// 找不到时只判 unknown —— 不删也不插,就地更新,避免"按工单号搜索时行被删空"。
const statusMatches = !status || status === String(item.status || '')
const keywordVerdict = evaluateWorkerKeywordVerdict(item, keyword)
const shouldInsert =
event.operation === 'upsert' && page === 1 && statusMatches && keywordVerdict === 'match'
const operation = event.operation === 'remove' || !statusMatches ? 'remove' : 'upsert'
// 显式单号检索时连状态也不可证伪:后端按单号查时已忽略状态页签,
// 这里再按状态删行就会让搜到的那一单在流转后消失(详见判定函数注释)。
const verdict = resolveWorkerMyOrderRealtimeVerdict({
item,
statusFilter: status,
keyword,
operation: event.operation,
page,
explicitOrderNo: isExplicitOrderNoSearch(keyword),
})
queryClient.setQueryData(
queryKey,
patchWorkerWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
patchWorkerWorkOrderListEnvelope(
current,
{ ...event, operation: verdict.operation },
verdict.shouldInsert,
),
)
}
}
@@ -370,6 +370,12 @@ export default function WorkOrdersPanel() {
setKeywordInput('')
setWorkerId(undefined)
setWorkerLevelIds([])
// 定位工单是**排他**条件(wo.id = X):只留它一个,其它筛选一并清掉,
// 否则之后按单号搜索时会被残留的接单人员/分类/日期/验收模式再次挡掉。
setCategoryId(undefined)
setAcceptanceMode(undefined)
setGiftPhase(undefined)
setCreatedRange(null)
setPage(1)
setSearchParams(
(current) => {
@@ -1073,11 +1079,47 @@ export default function WorkOrdersPanel() {
const trimmed = keywordInput.trim()
setKeyword(trimmed)
setPage(1)
// 管理员精准搜索工单号/平台单号时,自动清除排他的状态与阶段限制,避免状态流转后工单从视野中消失
if (isExplicitOrderNoSearch(trimmed)) {
if (giftPhase) setGiftPhase(undefined)
if (statuses.length > 0) setStatuses([])
if (acceptanceMode) setAcceptanceMode(undefined)
// 精准单号检索时清空**所有**排他筛选,而不是只清状态/阶段:
// 单号 + 接单人员 / 分类 / 创建日期 / 定位工单 同样会把目标单过滤掉
// (后端 resolveAdminWorkOrderListFilters 对单号检索也不再下推这些条件,
// 这里同步清空是为了让界面与结果一致,不出现"筛选项写着待接单、列表却给出进行中")。
if (!isExplicitOrderNoSearch(trimmed)) return
const cleared: string[] = []
if (statuses.length > 0) {
setStatuses([])
cleared.push('状态')
}
if (workerId) {
setWorkerId(undefined)
setFilterWorkerKeyword('')
cleared.push('接单人员')
}
if (categoryId) {
setCategoryId(undefined)
cleared.push('分类')
}
if (workerLevelIds.length > 0) {
setWorkerLevelIds([])
cleared.push('打手等级')
}
if (acceptanceMode) {
setAcceptanceMode(undefined)
cleared.push('验收模式')
}
if (giftPhase) {
setGiftPhase(undefined)
cleared.push('赠送阶段')
}
if (createdRange) {
setCreatedRange(null)
cleared.push('创建时间')
}
if (targetWorkOrderId) {
setTargetWorkOrderId(undefined)
cleared.push('定位工单')
}
if (cleared.length > 0) {
message.info(`已按单号精准检索,自动忽略:${cleared.join(' / ')}`)
}
}
@@ -21,6 +21,7 @@ import {
supplementWorkerAcceptedOrderEvidence,
} from '@/services/worker'
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders'
import { useIsMobile } from '@/utils/use-is-mobile'
import { buildWorkerOrderQuery } from '@/utils/worker-order-query'
import { WorkerOrderDesktopList } from './WorkerOrderDesktopList'
@@ -490,7 +491,12 @@ export default function WorkerOrdersPage() {
}
function applyKeywordSearch() {
setKeyword(keywordInput.trim())
const trimmed = keywordInput.trim()
// 显式单号(WO…/平台单号/关联单号)检索时后端会忽略状态页签
// (见 worker-my-order-service 的 resolveWorkerMyOrderStatusFilter),
// 前端同步切到「全部订单」,避免页签写着「代练中」却列出待验收/已验收的单。
if (isExplicitOrderNoSearch(trimmed)) setStatus('')
setKeyword(trimmed)
setPage(1)
}
@@ -102,9 +102,14 @@ function matchesCreatedRange(
/**
* 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号 / 关联单号。
*
* 两处依赖它,必须保持一致,所以放在这里而不是面板内部:
* 1. 面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶);
* 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉。
* 三处依赖它,必须保持一致,所以放在这里而不是面板内部:
* 1. 后台面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶);
* 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉;
* 3. 打手端「我的订单」页签与实时补丁(见 worker-realtime-work-orders.ts)同一规则,
* 那边的调用方从这里导入,避免再写第二份判定。
*
* 后端 counterpart:utils/platform-order-id.ts 的 isExplicitOrderNoLookupKeyword ——
* 它决定 SQL 不再下推排他筛选(状态/接单人员/分类/日期/定位单)。改这里要同步改那边。
*
* 关联单号(寄卖提报号 SUB / 仓库号 INV / 售后案号 AS / 履约任务号 DT)与后端
* work-order-query-repo 的反查分支一一对应:客服界面「只认寄卖单号」,
@@ -127,14 +132,20 @@ export function isExplicitOrderNoSearch(text: unknown): boolean {
export function evaluateAdminWorkOrderRealtimeMatch(
snapshot: unknown,
filters: AdminWorkOrderListFilters,
options: { operation?: unknown } = {},
): AdminWorkOrderRealtimeVerdict {
if (!isRecord(snapshot)) return 'unknown'
// 显式单号检索要排在最前面:用户就是在找这一单,无论它流转到什么状态都必须留在视野里。
// 只做原地更新,不插入也不移除 —— 否则状态流转(或前端对状态/接单人员/日期口径的复刻偏差)
// 会把正在看的单从结果里删掉,表现为「状态一变就搜不到,必须整页刷新」(生产已复现)。
// 注意:服务端的 operation='remove' 也必须被这条护栏挡住 —— 旧实现把它直接当成 mismatch,
// 等于绕过了这里的判定(同一类"只修一半"的坑)。
if (isExplicitOrderNoSearch(filters.keyword)) return 'unknown'
// 服务端明确说这一行已不存在(工单被删除)时才允许移除。
if (String(options.operation || '') === 'remove') return 'mismatch'
// 先跑所有「可以证伪」的条件:任何一条不满足就是明确的 mismatch(允许从列表移除)
//
// 状态必须用 rawStatus 比:快照的 `status` 是运营态(拼单母单 open 有份额时会被显示成
@@ -66,6 +66,43 @@ export function evaluateWorkerKeywordVerdict(item: unknown, keyword: string): 'm
return matchesWorkerWorkOrderKeyword(item, keyword) ? 'match' : 'unknown'
}
/**
* 「我的订单」实时快照的增删判定(与后台 evaluateAdminWorkOrderRealtimeMatch 同策)。
*
* 这里是那个线上坑在打手端的最后一块:状态页签(默认停在「代练中」)原本是**可证伪**条件,
* 只要页签与快照状态不一致就删行。而按工单号搜索时,状态只是展示偏好——
* 后端(resolveWorkerMyOrderStatusFilter)已经明确「单号检索忽略状态页签」,
* 前端若还按状态删行,「按工单号搜到的那一单」会在流转到待验收/已验收时凭空消失。
*
* explicitOrderNo 由调用方用共享的 isExplicitOrderNoSearch(keyword) 传入,
* 这样本模块保持零依赖(前端单测跑在 node --experimental-strip-types 下,无法解析 @/ 别名)。
*/
export function resolveWorkerMyOrderRealtimeVerdict(input: {
item: unknown
/** 当前状态页签;空串表示「全部订单」。 */
statusFilter: string
keyword: string
/** 服务端事件类型:upsert / remove / refresh。 */
operation?: unknown
page: number
explicitOrderNo: boolean
}): { operation: 'upsert' | 'remove'; shouldInsert: boolean } {
const item = isRecord(input.item) ? input.item : {}
const statusFilter = String(input.statusFilter || '')
const statusMatches = !statusFilter || statusFilter === String(item.status || '')
const statusProvable = input.explicitOrderNo || statusMatches
const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable
return {
operation: shouldRemove ? 'remove' : 'upsert',
shouldInsert:
!shouldRemove &&
String(input.operation || '') === 'upsert' &&
input.page === 1 &&
matchesWorkerWorkOrderKeyword(item, input.keyword),
}
}
/** 按工单号原地增删行,并同步分页总数(结构同后端 `{ data: { items, pagination } }`)。 */
export function patchWorkerWorkOrderListEnvelope(
current: unknown,