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 { 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, 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, }), } : {}), }, } }