Files
order_site/apps/frontend/src/utils/admin-realtime-work-orders.ts
T
yml2213 90de6b9419 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 项分类器单测。
2026-10-03 14:27:32 +08:00

317 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dayjs from 'dayjs'
/**
* 后台「接单工单」列表的实时快照补丁规则。
*
* 背景:列表由 SSE 的 work_order.changed 快照原地增删(避免整页刷新导致抢单误点),
* 但补丁有两个必须同时满足的前提,否则列表会"乱跳":
* 1. 必须能读对当前列表的筛选条件。原来按数组下标读 queryKey,面板的 key 一加字段就全错——
* page 被读成了 targetWorkOrderId,于是**任何页都被当成第 1 页**(见 WorkOrdersPanel 的 key 结构);
* 2. 必须能证明这条快照确实属于当前筛选、且确实排在列表第一位,才允许插到顶部。
* 「内部寄售匹配」这类筛选原来完全没参与判定,于是普通单也会被插进寄售卖单列表。
*
* 判定分三态:
* - match:可证明命中当前筛选 → 允许原地更新,且满足排序条件时才插到顶部
* - mismatch:可证明已不命中 → 允许从列表移除
* - unknown:快照里没有足够信息复现该筛选 → 既不插入也不移除,只原地更新已存在的行,
* 等下一次真实拉取由服务端说了算
*
* 本模块刻意保持零运行时依赖(除 dayjs),因为前端单测跑在 node --experimental-strip-types 下,
* 不解析 `@/` 路径别名。
*/
export type AdminWorkOrderListFilters = {
statuses: string[]
keyword: string
workerId: number
categoryId: number
workerLevelIds: number[]
targetWorkOrderId: number
acceptanceMode: string
giftPhase: string
page: number
createdFrom: string
createdTo: string
}
export type AdminWorkOrderRealtimeVerdict = 'match' | 'mismatch' | 'unknown'
/**
* 状态筛选里的虚拟值:不是真实工单状态,而是「VIP 免审待补图」这个服务端派生筛选。
* 面板把它夹在状态列表里,同时另发 vipEvidencePending 参数,前端无法用单行快照复现。
*/
export const VIP_EVIDENCE_PENDING_STATUS = 'vip_evidence_pending'
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function toNumber(value: unknown): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
/**
* 从列表 queryKey 解析筛选条件。
*
* 只认结构化 key(`['admin-worker-platform-orders', { ...筛选字段 }]`)。
* 遇到旧的下标数组返回 null,调用方放弃插入/移除、交给服务端刷新——
* 按位置猜下标正是"乱跳"的根因,这里不再留任何位置猜测。
*/
export function parseAdminWorkOrderListFilters(
queryKey: readonly unknown[],
): AdminWorkOrderListFilters | null {
const raw = queryKey[1]
if (!isRecord(raw)) return null
return {
statuses: String(raw.statuses || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
keyword: String(raw.keyword || '').trim(),
workerId: toNumber(raw.workerId),
categoryId: toNumber(raw.categoryId),
workerLevelIds: String(raw.workerLevelIds || '')
.split(',')
.map((value) => toNumber(value))
.filter(Boolean),
targetWorkOrderId: toNumber(raw.targetWorkOrderId),
acceptanceMode: String(raw.acceptanceMode || '').trim(),
giftPhase: String(raw.giftPhase || '').trim(),
page: Math.max(1, toNumber(raw.page) || 1),
createdFrom: String(raw.createdFrom || '').trim(),
createdTo: String(raw.createdTo || '').trim(),
}
}
/** 创建时间筛选:面板用「天」粒度,后端按 Asia/Shanghai 的当天边界比较。 */
function matchesCreatedRange(
snapshot: Record<string, unknown>,
createdFrom: string,
createdTo: string,
): boolean {
if (!createdFrom && !createdTo) return true
const raw = String(snapshot.createdAt || '').trim()
if (!raw) return false
const day = dayjs(raw).format('YYYY-MM-DD')
if (createdFrom && day < createdFrom) return false
if (createdTo && day > createdTo) return false
return true
}
/**
* 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号 / 关联单号。
*
* 两处依赖它,必须保持一致,所以放在这里而不是面板内部:
* 1. 面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶);
* 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉。
*
* 关联单号(寄卖提报号 SUB / 仓库号 INV / 售后案号 AS / 履约任务号 DT)与后端
* work-order-query-repo 的反查分支一一对应:客服界面「只认寄卖单号」,
* 粘进来的就是 SUB,漏掉它会导致搜到的单被状态页签挡掉。
*/
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) ||
/^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)
)
}
export function evaluateAdminWorkOrderRealtimeMatch(
snapshot: unknown,
filters: AdminWorkOrderListFilters,
): AdminWorkOrderRealtimeVerdict {
if (!isRecord(snapshot)) return 'unknown'
// 显式单号检索要排在最前面:用户就是在找这一单,无论它流转到什么状态都必须留在视野里。
// 只做原地更新,不插入也不移除 —— 否则状态流转(或前端对状态/接单人员/日期口径的复刻偏差)
// 会把正在看的单从结果里删掉,表现为「状态一变就搜不到,必须整页刷新」(生产已复现)。
if (isExplicitOrderNoSearch(filters.keyword)) return 'unknown'
// 先跑所有「可以证伪」的条件:任何一条不满足就是明确的 mismatch(允许从列表移除)
//
// 状态必须用 rawStatus 比:快照的 `status` 是运营态(拼单母单 open 有份额时会被显示成
// in_progress / pending_acceptance),而后台列表的状态筛选走的是原始 wo.status
// (list 没开 useOperationalStatus)。拿运营态比会误判成 mismatch,把行从列表里删掉。
const vipEvidencePendingActive = filters.statuses.includes(VIP_EVIDENCE_PENDING_STATUS)
if (filters.statuses.length > 0 && !vipEvidencePendingActive) {
const rawStatus = String(snapshot.rawStatus || snapshot.status || '')
if (!filters.statuses.includes(rawStatus)) return 'mismatch'
}
if (filters.workerId > 0) {
const assignedWorkerId = toNumber((snapshot.worker as { workerId?: unknown } | null)?.workerId)
const sharingWorkerIds = Array.isArray(snapshot.sharingWorkers)
? snapshot.sharingWorkers.map((worker) =>
toNumber((worker as { workerId?: unknown })?.workerId),
)
: []
if (assignedWorkerId !== filters.workerId && !sharingWorkerIds.includes(filters.workerId)) {
return 'mismatch'
}
}
if (filters.categoryId > 0) {
// 后端分类筛选是「category_id = X 或 category_id 属于 X 的子分类」,而快照只带自身 categoryId
// (没有 parentCategoryId),所以除了"正好等于"以外都无法证伪——可能是子分类的行。
if (toNumber(snapshot.categoryId) !== filters.categoryId) return 'unknown'
}
if (
filters.targetWorkOrderId > 0 &&
toNumber(snapshot.workOrderId) !== filters.targetWorkOrderId
) {
return 'mismatch'
}
if (!matchesCreatedRange(snapshot, filters.createdFrom, filters.createdTo)) return 'mismatch'
// 以下筛选快照里没有足够信息复现,无法证真 → 不插入新行,也不移除已有行:
// - 验收模式(内部寄售匹配 / 好友赠送 / 标准验收):判定依赖 material 里多个字段与 SQL 口径,
// 前端复刻一份就有和后端漂移的风险,宁可只原地更新;
// - 关键词:后端跨 work_order_no / platform_order_id / product_name 与多个 material JSON 路径,
// 还有平台单号、工单号的特殊匹配分支;
// - 赠送阶段:gift_ready 是"冷却到点"的实时阶段,含 NOW() 判定;
// - 打手等级:只在服务端按接单人与拼单参与者计算;
// - VIP 待补图:混在状态列表里的虚拟值,判定依赖 acceptance_json 多个字段与 NOW()。
// 注:显式单号检索已在函数开头短路,走不到这里。
if (filters.acceptanceMode) return 'unknown'
if (vipEvidencePendingActive) return 'unknown'
if (filters.keyword) return 'unknown'
if (filters.giftPhase) return 'unknown'
if (filters.workerLevelIds.length > 0) return 'unknown'
return 'match'
}
/**
* 是否应该把这条快照插到列表顶部。
*
* 后台列表服务端排序是 `wo.id DESC`(pinnedFirst 只用于抢单大厅),
* 所以只有「排在当前第一行之前」的新单才允许插到顶部;否则它在服务端应该排在后面甚至别的页,
* 插到顶部就是肉眼可见的乱跳。
*/
export function shouldPrependAdminWorkOrder(items: readonly unknown[], snapshot: unknown): boolean {
if (!Array.isArray(items) || items.length === 0) return true
if (!isRecord(snapshot)) return false
const snapshotId = toNumber(snapshot.workOrderId)
if (snapshotId <= 0) return false
const firstName = items[0]
if (!isRecord(firstName)) return true
return snapshotId > toNumber(firstName.workOrderId)
}
/**
* 同步筛选统计卡(`data.statistics`)。
*
* 统计与列表是**同一个响应**里下发的,两边必须一起改:只改 items 不改 statistics,
* 就会出现"统计说还有 1 单、列表却是暂无数据"的自相矛盾界面(线上就是这样暴露的)。
* 注意 statusCounts 用的是**运营态**口径(后端 statistics 走 adminOperationalStatusExpression),
* 所以要按快照的 status 而非 rawStatus 记账。
*/
function adjustWorkOrderStatistics(
statistics: unknown,
change: { previousRow?: unknown; nextRow?: unknown; totalDelta: number },
): unknown {
if (!isRecord(statistics)) return statistics
const previousStatus = String(
(change.previousRow as { status?: unknown } | null | undefined)?.status || '',
)
const nextStatus = String(
(change.nextRow as { status?: unknown } | null | undefined)?.status || '',
)
const statusCounts = isRecord(statistics.statusCounts) ? { ...statistics.statusCounts } : null
if (statusCounts && previousStatus !== nextStatus) {
if (previousStatus) {
statusCounts[previousStatus] = Math.max(0, toNumber(statusCounts[previousStatus]) - 1)
}
if (nextStatus) {
statusCounts[nextStatus] = toNumber(statusCounts[nextStatus]) + 1
}
}
return {
...statistics,
totalCount: Math.max(0, toNumber(statistics.totalCount) + change.totalDelta),
...(statusCounts ? { statusCounts } : {}),
}
}
/**
* 按工单号原地增删列表行,并同步分页总数与筛选统计
* (结构与后端 `{ data: { items, pagination, statistics } }` 一致)。
* 行已存在时永远只做原地替换;只有 insert 为真、且列表里确实没有这一行时才插到顶部。
*/
export function patchAdminWorkOrderListItems(
current: unknown,
snapshot: unknown,
options: { remove: boolean; insert: boolean },
): unknown {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const items = current.data.items as unknown[]
const id = toNumber((snapshot as { workOrderId?: unknown } | null)?.workOrderId)
if (id <= 0) return current
let nextItems: unknown[]
let previousRow: unknown = null
let nextRow: unknown = null
if (options.remove) {
previousRow = items.find(
(item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) === id,
)
nextItems = items.filter(
(item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) !== id,
)
} else {
const index = items.findIndex(
(item) => toNumber((item as { workOrderId?: unknown })?.workOrderId) === id,
)
if (index >= 0) {
previousRow = items[index]
nextItems = items.map((item, itemIndex) => (itemIndex === index ? snapshot : item))
} else if (options.insert) {
nextItems = [snapshot, ...items]
} else {
return current
}
nextRow = snapshot
}
const delta = nextItems.length - items.length
const pagination = isRecord(current.data.pagination) ? current.data.pagination : null
return {
...current,
data: {
...current.data,
items: nextItems,
...(pagination && delta !== 0
? {
pagination: {
...pagination,
total: Math.max(0, toNumber(pagination.total) + delta),
},
}
: {}),
...(isRecord(current.data.statistics)
? {
statistics: adjustWorkOrderStatistics(current.data.statistics, {
previousRow,
nextRow,
totalDelta: delta,
}),
}
: {}),
},
}
}