Files
order_site/apps/frontend/src/utils/admin-realtime-work-orders.ts
T

357 lines
15 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
/** 店铺筛选(订单归属店铺,可多选精确匹配)。 */
shopNames: 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(),
shopNames: String(raw.shopNames || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
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
}
const ZERO_WIDTH_CHARS = /[\u200b-\u200d\ufeff]/g
/**
* 检索文本归一化:NFKC(全角字母/数字 → 半角)+ 去掉零宽字符 + trim。
* 与后端 utils/platform-order-id.ts 的 normalizeLookupKeyword 同一口径:
* 从网页/Excel/聊天复制过来的单号常夹带零宽字符或全角字符,
* 肉眼看不出来却会让匹配全部落空("明明贴对了却搜不到")。
*/
export function normalizeSearchText(value: unknown): string {
return String(value ?? '')
.normalize('NFKC')
.replace(ZERO_WIDTH_CHARS, '')
.trim()
}
/**
* 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号 / 关联单号。
*
* 工单号前缀写成 `W[oOkK0]`:现役前缀是 `WK`(与 0 不会看混),
* 历史前缀 `WO` 里的字母 O 与数字 0 在界面字体里几乎无法区分,
* 用户手输/转抄极易写成 `W0…`(生产 12.7 万单全以 WO 开头、'o' 只在第 2 位)。
* 后端 buildWorkOrderNoKeywordVariants 会同时匹配 W0/WO/WK 三种写法,两侧必须一致。
*
* 三处依赖它,必须保持一致,所以放在这里而不是面板内部:
* 1. 后台面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶);
* 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉;
* 3. 打手端「我的订单」页签与实时补丁(见 worker-realtime-work-orders.ts)同一规则,
* 那边的调用方从这里导入,避免再写第二份判定。
*
* 后端 counterpart:utils/platform-order-id.ts 的 isExplicitOrderNoLookupKeyword ——
* 它决定 SQL 不再下推排他筛选(状态/接单人员/分类/日期/定位单)。改这里要同步改那边。
*
* 关联单号(寄卖提报号 SUB / 仓库号 INV / 售后案号 AS / 履约任务号 DT)与后端
* work-order-query-repo 的反查分支一一对应:客服界面「只认寄卖单号」,
* 粘进来的就是 SUB,漏掉它会导致搜到的单被状态页签挡掉。
*/
export function isExplicitOrderNoSearch(text: unknown): boolean {
const trimmed = normalizeSearchText(text)
if (!trimmed) return false
return (
/^W[oOkK0][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,
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 有份额时会被显示成
// 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() 判定;
// - 打手等级:只在服务端按接单人与拼单参与者计算;
// - 店铺:口径是订单归属店铺(orders.shop_name),快照里只有工单 material 的货源店铺名,
// 两者不可互换,无法证伪;
// - VIP 待补图:混在状态列表里的虚拟值,判定依赖 acceptance_json 多个字段与 NOW()。
// 注:显式单号检索已在函数开头短路,走不到这里。
if (filters.acceptanceMode) return 'unknown'
if (vipEvidencePendingActive) return 'unknown'
if (filters.shopNames.length > 0) 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,
}),
}
: {}),
},
}
}