From fe4d02d13606b04ccb84d1af0906fcb2d78043fe Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 12 Oct 2026 15:48:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-platform):=20=E6=8C=89=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E5=8F=B7=E6=90=9C=E4=B8=8D=E5=88=B0=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20=E2=80=94=E2=80=94=20=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E5=8D=95=E5=8F=B7=E6=A3=80=E7=B4=A2=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E4=BB=BB=E4=BD=95=E6=8E=92=E4=BB=96=E7=AD=9B=E9=80=89=E5=90=9E?= =?UTF-8?q?=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修的是什么 线上反复复发的「后台接单平台按工单号搜不到、某些状态尤其明显」,根因不是一处, 而是同一类问题在 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 通过。 无数据库迁移。 --- .../admin-work-order-query-service.ts | 46 ++++++++- .../order-no-search-filter.test.ts | 98 +++++++++++++++++++ .../worker-my-order-service.ts | 35 ++++++- .../src/utils/platform-order-id.test.ts | 32 ++++++ apps/backend/src/utils/platform-order-id.ts | 18 ++++ apps/frontend/src/layouts/AdminLayout.tsx | 7 +- apps/frontend/src/layouts/WorkerLayout.tsx | 23 +++-- .../pages/admin/panels/WorkOrdersPanel.tsx | 52 +++++++++- .../src/pages/worker/WorkerOrdersPage.tsx | 8 +- .../src/utils/admin-realtime-work-orders.ts | 17 +++- .../src/utils/worker-realtime-work-orders.ts | 37 +++++++ .../tests/admin-realtime-work-orders.test.ts | 24 +++++ .../tests/worker-realtime-work-orders.test.ts | 72 ++++++++++++++ 13 files changed, 443 insertions(+), 26 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/order-no-search-filter.test.ts diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index 4e92a60f..5bc7806a 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -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), () => diff --git a/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts b/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts new file mode 100644 index 00000000..2c830dc3 --- /dev/null +++ b/apps/backend/src/services/worker-platform/order-no-search-filter.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index f10eed15..912faf2f 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -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)) diff --git a/apps/backend/src/utils/platform-order-id.test.ts b/apps/backend/src/utils/platform-order-id.test.ts index 9d6b0617..ed5e956a 100644 --- a/apps/backend/src/utils/platform-order-id.test.ts +++ b/apps/backend/src/utils/platform-order-id.test.ts @@ -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)} 不应识别为显式单号`, + ) + } +}) diff --git a/apps/backend/src/utils/platform-order-id.ts b/apps/backend/src/utils/platform-order-id.ts index 9dc30682..57d74e63 100644 --- a/apps/backend/src/utils/platform-order-id.ts +++ b/apps/backend/src/utils/platform-order-id.ts @@ -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... → 产出的库存 → 被工单消耗后的工单 diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index fdf48a4e..04f887b0 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -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, diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index a5f8fdbf..68658ec5 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -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, + ), ) } } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 0b479ab7..28c3da74 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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(' / ')}`) } } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 18311724..1b67d786 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -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) } diff --git a/apps/frontend/src/utils/admin-realtime-work-orders.ts b/apps/frontend/src/utils/admin-realtime-work-orders.ts index c0edd08e..67937f54 100644 --- a/apps/frontend/src/utils/admin-realtime-work-orders.ts +++ b/apps/frontend/src/utils/admin-realtime-work-orders.ts @@ -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 有份额时会被显示成 diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 4a55e52c..2fa19f5f 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -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, diff --git a/apps/frontend/tests/admin-realtime-work-orders.test.ts b/apps/frontend/tests/admin-realtime-work-orders.test.ts index b273ebd1..214b7bb0 100644 --- a/apps/frontend/tests/admin-realtime-work-orders.test.ts +++ b/apps/frontend/tests/admin-realtime-work-orders.test.ts @@ -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} 应识别为单号`) diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 7447f3a3..192984b6 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -6,6 +6,7 @@ import { matchesWorkerWorkOrderKeyword, patchWorkerWorkOrderInfiniteEnvelope, patchWorkerWorkOrderListEnvelope, + resolveWorkerMyOrderRealtimeVerdict, } from '../src/utils/worker-realtime-work-orders.ts' function buildRow(overrides: Record = {}) { @@ -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) +})