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:
@@ -7,7 +7,9 @@ import {
|
||||
sumPendingUnfreezeByOrderIds,
|
||||
type ConsignmentInventoryRow,
|
||||
} 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 {
|
||||
normalizeDateString,
|
||||
normalizePage,
|
||||
@@ -63,9 +65,37 @@ export async function getAdminWorkOrderEvents(workOrderId: number | string) {
|
||||
return { items: await mapWorkOrderEvents(events, { includeWorkerSummary: true }) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
/**
|
||||
* 后台接单工单列表的筛选条件(纯函数,便于单测)。
|
||||
*
|
||||
* 「显式单号检索」是**排他**的:用户粘进来的是唯一单号,表达的是「我就要这一单」,
|
||||
* 任何其它筛选都不该把它藏起来。线上反复出现的「按工单号搜不到 / 某些状态搜不到」
|
||||
* 有三条成因,全部在这里收口:
|
||||
* 1. 拼单母单一旦有人参与,运营态就从 open 派生为 in_progress / pending_acceptance
|
||||
* (见 adminOperationalStatusExpression),于是「单号 + 待接单」必然搜不到——
|
||||
* 没人参与时反而搜得到,这正是「有人接单后就搜不到」的现象;
|
||||
* 2. 「单号 + 接单人员 / 分类 / 打手等级 / 创建时间 / 验收模式 / 赠送阶段」这类
|
||||
* 排他筛选同样会把目标单过滤掉(前端搜索按钮只清了其中一部分);
|
||||
* 3. 待办通知跳转留下的「定位工单」(workOrderId) 会一直生效,
|
||||
* 之后搜任何别的单号都会被它挡掉(界面上只有一个小 Alert,极易忽略)。
|
||||
* 所以单号检索时只保留关键字,其余筛选一律不下推。
|
||||
*/
|
||||
export function resolveAdminWorkOrderListFilters(query: JsonObject = {}): ListInput {
|
||||
const keyword = String(query.keyword || '').trim()
|
||||
if (isExplicitOrderNoLookupKeyword(keyword)) {
|
||||
// 只按单号查:其余字段显式给空值,保证统计缓存指纹与列表口径一致。
|
||||
return {
|
||||
statuses: [],
|
||||
workOrderId: 0,
|
||||
categoryId: 0,
|
||||
keyword,
|
||||
workerSharingId: 0,
|
||||
workerLevelIds: [],
|
||||
vipEvidencePending: false,
|
||||
useOperationalStatus: true,
|
||||
}
|
||||
}
|
||||
|
||||
const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id) || 0
|
||||
const categoryId = normalizeOptionalId(query.categoryId ?? query.category_id) || 0
|
||||
const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0
|
||||
@@ -78,11 +108,11 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
const createdTo = normalizeDateString(query.createdTo)
|
||||
const acceptanceMode = String(query.acceptanceMode || '').trim()
|
||||
const giftPhase = String(query.giftPhase || '').trim()
|
||||
const filters = {
|
||||
return {
|
||||
statuses,
|
||||
workOrderId,
|
||||
categoryId,
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
keyword,
|
||||
workerSharingId: workerId,
|
||||
workerLevelIds,
|
||||
vipEvidencePending,
|
||||
@@ -92,6 +122,12 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
...(createdFrom ? { createdFrom } : {}),
|
||||
...(createdTo ? { createdTo } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const filters = resolveAdminWorkOrderListFilters(query)
|
||||
const [{ items }, statistics] = await Promise.all([
|
||||
listWorkOrders({ page, pageSize, includeTotal: false, ...filters }),
|
||||
workOrderStatisticsCache.getOrSet(buildWorkOrderStatisticsCacheKey(filters), () =>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveAdminWorkOrderListFilters } from './admin-work-order-query-service.js'
|
||||
import { resolveWorkerMyOrderStatusFilter } from './worker-my-order-service.js'
|
||||
|
||||
/**
|
||||
* 这组用例锁的是线上反复复发的那个坑:**按工单号搜索却搜不到**。
|
||||
*
|
||||
* 生产现象(客服/管理后台「接单平台 → 接单工单」):
|
||||
* - 拼单母单没人参与时按单号能搜到,一旦有人接单/参与就搜不到;
|
||||
* - 非拼单的单子在某些状态下同样搜不到;
|
||||
* - 修过几次都没断根(历次只在弹窗按钮和实时补丁上打补丁)。
|
||||
*
|
||||
* 根因不止一个,但都在「排他筛选把唯一单号藏起来」这一类里:
|
||||
* 1. 拼单母单有份额后运营态由 open 派生为 in_progress / pending_acceptance,
|
||||
* 「单号 + 待接单」组合必然为空;
|
||||
* 2. 「单号 + 接单人员 / 分类 / 打手等级 / 创建时间 / 验收模式 / 赠送阶段」直接过滤掉目标单;
|
||||
* 3. 待办通知跳转留下的「定位工单」会长期生效,之后搜任何单号都被它挡住。
|
||||
*
|
||||
* 因此规则统一收口:**显式单号检索时,除了可见范围与分页,不再下推任何筛选**。
|
||||
*/
|
||||
test('后台:显式单号检索时忽略状态等排他筛选,只按单号查', () => {
|
||||
const filters = resolveAdminWorkOrderListFilters({
|
||||
keyword: 'WO26101213031939',
|
||||
status: 'open',
|
||||
workerId: '7',
|
||||
categoryId: '12',
|
||||
workerLevelIds: '1,2',
|
||||
acceptanceMode: 'internal_consignment',
|
||||
giftPhase: 'gift_ready',
|
||||
createdFrom: '2026-10-01',
|
||||
createdTo: '2026-10-12',
|
||||
workOrderId: '123',
|
||||
vipEvidencePending: '1',
|
||||
})
|
||||
|
||||
assert.equal(filters.keyword, 'WO26101213031939')
|
||||
// 状态:拼单母单一旦有份额,运营态就不再是 open —— 必须放掉
|
||||
assert.deepEqual(filters.statuses, [])
|
||||
assert.equal(filters.vipEvidencePending, false)
|
||||
// 接单人员:目标单可能根本不在这名打手的可见集合里
|
||||
assert.equal(filters.workerSharingId, 0)
|
||||
// 分类 / 等级 / 验收模式 / 赠送阶段 / 创建时间 / 定位工单
|
||||
assert.equal(filters.categoryId, 0)
|
||||
assert.deepEqual(filters.workerLevelIds, [])
|
||||
assert.equal(filters.acceptanceMode, undefined)
|
||||
assert.equal(filters.giftPhase, undefined)
|
||||
assert.equal(filters.createdFrom, undefined)
|
||||
assert.equal(filters.createdTo, undefined)
|
||||
assert.equal(filters.workOrderId, 0)
|
||||
})
|
||||
|
||||
test('后台:平台单号与关联单号同样享受单号检索的排他豁免', () => {
|
||||
for (const keyword of ['2627502003065030', 'SUB26100301127318', 'INV26092619393769']) {
|
||||
const filters = resolveAdminWorkOrderListFilters({ keyword, status: 'open', workerId: '7' })
|
||||
assert.deepEqual(filters.statuses, [], `${keyword} 的状态筛选应被忽略`)
|
||||
assert.equal(filters.workerSharingId, 0, `${keyword} 的接单人员筛选应被忽略`)
|
||||
}
|
||||
})
|
||||
|
||||
test('后台:普通关键词(商品名/昵称)仍按原样下推全部筛选', () => {
|
||||
const filters = resolveAdminWorkOrderListFilters({
|
||||
keyword: '暗潮',
|
||||
status: 'open,in_progress',
|
||||
workerId: '7',
|
||||
categoryId: '12',
|
||||
workerLevelIds: '1,2',
|
||||
acceptanceMode: 'friend_gift',
|
||||
giftPhase: 'friend_countdown',
|
||||
createdFrom: '2026-10-01',
|
||||
createdTo: '2026-10-12',
|
||||
workOrderId: '123',
|
||||
vipEvidencePending: '1',
|
||||
})
|
||||
|
||||
assert.deepEqual(filters.statuses, ['open', 'in_progress'])
|
||||
assert.equal(filters.vipEvidencePending, true)
|
||||
assert.equal(filters.workerSharingId, 7)
|
||||
assert.equal(filters.categoryId, 12)
|
||||
assert.deepEqual(filters.workerLevelIds, [1, 2])
|
||||
assert.equal(filters.acceptanceMode, 'friend_gift')
|
||||
assert.equal(filters.giftPhase, 'friend_countdown')
|
||||
assert.equal(filters.createdFrom, '2026-10-01')
|
||||
assert.equal(filters.createdTo, '2026-10-12')
|
||||
assert.equal(filters.workOrderId, 123)
|
||||
})
|
||||
|
||||
test('打手端:显式单号检索忽略状态页签,但可见范围必须保留', () => {
|
||||
const explicit = resolveWorkerMyOrderStatusFilter('WO26101213031939', ['in_progress'], true)
|
||||
assert.deepEqual(explicit.statuses, [])
|
||||
assert.equal(explicit.vipEvidencePending, false)
|
||||
|
||||
// 普通关键词保持原状态筛选(否则「代练中」页签会突然显示别的状态)
|
||||
const fuzzy = resolveWorkerMyOrderStatusFilter('暗潮', ['in_progress'], true)
|
||||
assert.deepEqual(fuzzy.statuses, ['in_progress'])
|
||||
assert.equal(fuzzy.vipEvidencePending, true)
|
||||
})
|
||||
@@ -23,6 +23,7 @@ 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 { nowIso } from '../../utils/time.js'
|
||||
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
|
||||
import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js'
|
||||
@@ -61,6 +62,27 @@ import {
|
||||
* 否则逾期结算会被跳过。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 打手「我的订单」的状态筛选项(纯函数,便于单测)。
|
||||
*
|
||||
* 管理后台的「显式单号检索」规则同样适用:粘进来的工单号就是要找的那一单,
|
||||
* 前端的状态页签(默认停在「代练中」)不该把它挡掉——打手按工单号查自己的单,
|
||||
* 却因为单据已流转到「待验收 / 已验收 / 问题单」而搜不到,线上同样出现过。
|
||||
*
|
||||
* 注意:这里**只**放掉状态与 VIP 待补图这类展示筛选,
|
||||
* 可见范围 workerSharingId 必须保留,否则打手能搜到别人的单。
|
||||
*/
|
||||
export function resolveWorkerMyOrderStatusFilter(
|
||||
keyword: string,
|
||||
statuses: string[],
|
||||
vipEvidencePending: boolean,
|
||||
): { statuses: string[]; vipEvidencePending: boolean } {
|
||||
if (isExplicitOrderNoLookupKeyword(keyword)) {
|
||||
return { statuses: [], vipEvidencePending: false }
|
||||
}
|
||||
return { statuses, vipEvidencePending }
|
||||
}
|
||||
|
||||
export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) {
|
||||
requireActiveWorkerSession(session)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
@@ -70,21 +92,26 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const statuses = normalizeStatuses(query.status)
|
||||
const keyword = String(query.keyword || '').trim()
|
||||
const effectiveStatusFilter = resolveWorkerMyOrderStatusFilter(
|
||||
keyword,
|
||||
statuses,
|
||||
vipEvidencePending,
|
||||
)
|
||||
const cacheKey = buildMyOrderCacheKey(worker.id, 'list', [
|
||||
page,
|
||||
pageSize,
|
||||
statuses,
|
||||
effectiveStatusFilter.statuses,
|
||||
keyword,
|
||||
vipEvidencePending,
|
||||
effectiveStatusFilter.vipEvidencePending,
|
||||
])
|
||||
return myOrderListCache.getOrSet(cacheKey, async () => {
|
||||
const { items, total } = await listWorkOrders({
|
||||
page,
|
||||
pageSize,
|
||||
statuses,
|
||||
statuses: effectiveStatusFilter.statuses,
|
||||
keyword,
|
||||
workerSharingId: worker.id,
|
||||
vipEvidencePending,
|
||||
vipEvidencePending: effectiveStatusFilter.vipEvidencePending,
|
||||
sort: 'worker_claimed_at_desc',
|
||||
})
|
||||
const workOrderIds = items.map((item) => Number(item.id))
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
isExplicitOrderNoLookupKeyword,
|
||||
isPlatformOrderIdLookupKeyword,
|
||||
isWorkOrderNoLookupKeyword,
|
||||
resolveRelatedWorkOrderLookupKind,
|
||||
@@ -59,3 +60,34 @@ test('关联单号识别不得误伤工单号、平台单号与普通关键词',
|
||||
assert.equal(resolveRelatedWorkOrderLookupKind(''), null)
|
||||
assert.equal(resolveRelatedWorkOrderLookupKind(null), null)
|
||||
})
|
||||
|
||||
/**
|
||||
* 显式单号检索的总判定。后端用它决定「不下推排他筛选」,前端用它决定「不删行」,
|
||||
* 两边覆盖的范围必须一致,否则又会出现「后台搜不到 / 行被删掉」这类只修一半的问题。
|
||||
*/
|
||||
test('显式单号检索覆盖工单号 / 平台单号 / 关联单号', () => {
|
||||
for (const orderNo of [
|
||||
'WO26101213031939',
|
||||
'wo26101213031939',
|
||||
' WO26101213031939 ',
|
||||
'WOb320ee0b39b9',
|
||||
'20247230034021572',
|
||||
'260915-091435834962130',
|
||||
'SUB26100301127318',
|
||||
'INV26092619393769',
|
||||
'ASde06bcb25aca',
|
||||
'DTb946808bcd5b',
|
||||
]) {
|
||||
assert.equal(isExplicitOrderNoLookupKeyword(orderNo), true, `${orderNo} 应识别为显式单号`)
|
||||
}
|
||||
})
|
||||
|
||||
test('显式单号检索不得误判商品名 / 昵称 / 短词', () => {
|
||||
for (const keyword of ['暗潮', '无限潮汐', '皮肤店', 'WO12', '12345', 'SUBWAY', '', null]) {
|
||||
assert.equal(
|
||||
isExplicitOrderNoLookupKeyword(keyword),
|
||||
false,
|
||||
`${String(keyword)} 不应识别为显式单号`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,24 @@ export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
|
||||
return /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为「显式单号检索」——工单号 / 平台单号 / 关联单号(SUB/INV/AS/DT)。
|
||||
*
|
||||
* 语义:用户粘进来的是唯一单号,表达的是「我就要这一单」。
|
||||
* 前端(utils/admin-realtime-work-orders.ts 的 isExplicitOrderNoSearch)用它保证
|
||||
* 实时快照补丁不把正在看的行删掉;后端用它保证排他性筛选不把这单藏起来。
|
||||
* 两侧必须覆盖同一批形态,改这里就要同步改那边(反之亦然)。
|
||||
*/
|
||||
export function isExplicitOrderNoLookupKeyword(value: unknown): boolean {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return false
|
||||
return (
|
||||
isWorkOrderNoLookupKeyword(trimmed) ||
|
||||
isPlatformOrderIdLookupKeyword(trimmed) ||
|
||||
resolveRelatedWorkOrderLookupKind(trimmed) !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联单号类型:号码本身不在 work_orders 上,必须经关联表反查工单 id。
|
||||
* - consignment_submission: 寄售线索号 SUB... → 产出的库存 → 被工单消耗后的工单
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -410,6 +410,30 @@ test('模糊关键词不享受单号护栏:状态可证伪时仍按原设计
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 服务端 operation='remove' 曾经直接短路成 mismatch,绕过了上面的单号护栏,
|
||||
* 等于「显式单号检索不删行」这条规则在最需要它的路径上失效。现在交给判定函数统一处理。
|
||||
*/
|
||||
test('服务端 remove 事件不得绕过显式单号护栏;模糊关键词仍可被移除', () => {
|
||||
const explicit = parseAdminWorkOrderListFilters(
|
||||
buildQueryKey({ keyword: 'WO26101213031939', statuses: 'open' }),
|
||||
)!
|
||||
assert.equal(
|
||||
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot({ status: 'accepted' }), explicit, {
|
||||
operation: 'remove',
|
||||
}),
|
||||
'unknown',
|
||||
'按单号搜出来的行,即便服务端发 remove 也不能删(工单并未删除)',
|
||||
)
|
||||
|
||||
const fuzzy = parseAdminWorkOrderListFilters(buildQueryKey({ keyword: '暗潮' }))!
|
||||
assert.equal(
|
||||
evaluateAdminWorkOrderRealtimeMatch(buildSnapshot(), fuzzy, { operation: 'remove' }),
|
||||
'mismatch',
|
||||
'普通关键词仍要保留「服务端说没了就移除」的语义',
|
||||
)
|
||||
})
|
||||
|
||||
test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', () => {
|
||||
for (const orderNo of ['WO26100217194691', 'OD26100217', '2627502003065030', 'wo12345678']) {
|
||||
assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
matchesWorkerWorkOrderKeyword,
|
||||
patchWorkerWorkOrderInfiniteEnvelope,
|
||||
patchWorkerWorkOrderListEnvelope,
|
||||
resolveWorkerMyOrderRealtimeVerdict,
|
||||
} from '../src/utils/worker-realtime-work-orders.ts'
|
||||
|
||||
function buildRow(overrides: Record<string, unknown> = {}) {
|
||||
@@ -116,3 +117,74 @@ test('无限下拉:只有第 1 页允许插入,其余页只就地更新', ()
|
||||
assert.equal(itemsOf(patched.pages[0]).length, 3, '第 1 页应插入')
|
||||
assert.equal(itemsOf(patched.pages[1]).length, 2, '第 2 页不得插入')
|
||||
})
|
||||
|
||||
/**
|
||||
* 「我的订单」状态页签的实时删行判定。
|
||||
*
|
||||
* 生产现象(与后台同源):按工单号搜出来的那一单,状态一流转(代练中 → 待验收 / 已验收)
|
||||
* 就被实时补丁按"状态不匹配"删掉,列表变空、必须刷新整页。
|
||||
* 页签只是展示偏好:后端按单号查时已经忽略状态,前端不能再拿状态删行。
|
||||
*/
|
||||
test('显式单号检索:状态页签不匹配也不得删行(状态流转后仍要能搜到)', () => {
|
||||
const row = buildRow({ status: 'in_progress' })
|
||||
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||
item: buildRow({ status: 'pending_acceptance' }),
|
||||
statusFilter: 'in_progress',
|
||||
keyword: 'WO26093017345168',
|
||||
operation: 'upsert',
|
||||
page: 1,
|
||||
explicitOrderNo: true,
|
||||
})
|
||||
assert.equal(verdict.operation, 'upsert', '单号检索下状态不能证伪')
|
||||
assert.equal(verdict.shouldInsert, true, '命中的单号仍可插入第 1 页')
|
||||
|
||||
const updated = patchWorkerWorkOrderListEnvelope(
|
||||
buildEnvelope([row]),
|
||||
{ operation: verdict.operation, data: buildRow({ status: 'pending_acceptance' }) },
|
||||
verdict.shouldInsert,
|
||||
)
|
||||
assert.equal(itemsOf(updated).length, 1, '行不能被删掉')
|
||||
assert.equal(
|
||||
(itemsOf(updated)[0] as { status?: string }).status,
|
||||
'pending_acceptance',
|
||||
'应就地更新成最新状态',
|
||||
)
|
||||
})
|
||||
|
||||
test('模糊关键词:状态页签不匹配仍按原设计移除行', () => {
|
||||
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||
item: buildRow({ status: 'accepted' }),
|
||||
statusFilter: 'in_progress',
|
||||
keyword: '暗潮',
|
||||
operation: 'upsert',
|
||||
page: 1,
|
||||
explicitOrderNo: false,
|
||||
})
|
||||
assert.equal(verdict.operation, 'remove')
|
||||
assert.equal(verdict.shouldInsert, false)
|
||||
})
|
||||
|
||||
test('服务端 remove(打手已失去该单)仍然生效,单号护栏不误挡', () => {
|
||||
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||
item: buildRow({ status: 'in_progress' }),
|
||||
statusFilter: '',
|
||||
keyword: 'WO26093017345168',
|
||||
operation: 'remove',
|
||||
page: 1,
|
||||
explicitOrderNo: true,
|
||||
})
|
||||
assert.equal(verdict.operation, 'remove', '服务端明确说单子已不可见时必须移除')
|
||||
})
|
||||
|
||||
test('非第 1 页不得插入,只做就地更新', () => {
|
||||
const verdict = resolveWorkerMyOrderRealtimeVerdict({
|
||||
item: buildRow({ workOrderId: 8888 }),
|
||||
statusFilter: '',
|
||||
keyword: 'WO26093017345168',
|
||||
operation: 'upsert',
|
||||
page: 2,
|
||||
explicitOrderNo: true,
|
||||
})
|
||||
assert.equal(verdict.operation, 'upsert')
|
||||
assert.equal(verdict.shouldInsert, false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user