问题:后台「按单号检索」在平台单号/工单号分支里 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 项分类器单测。
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
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,
|
||
}),
|
||
}
|
||
: {}),
|
||
},
|
||
}
|
||
}
|