fix(consignment): 修复寄卖取消解绑、SUB 单号写空、拼单门禁与后台工单列表实时乱跳

1. 问题单处置「取消」后打手信息不消失:resolveProblemWorkOrder 的取消分支从未解绑寄卖现货,现货永久停在 consumed 占库不还,工单 material 上的寄售标记残留又让列表继续显示「供货打手」。取消时补上解绑;调用必须排在状态 UPDATE 之前——那条 UPDATE 会清空 friend_added_at,清完再解绑会把已确认加好友的货误判成可回池(二次出货);
2. 解绑函数在没有现货可解绑时直接 return,跳过了摘标记,于是「库存行早已不存在」的老帖永远摘不掉寄售标记(列表一直显示供货打手)。现在无论是否解绑到现货都摘标记,痕迹保留在 material.consignmentHistory 的 detachReason 里;
3. 内部现货履约写入的 sourceSubmissionNo 恒为空:取的是占库 `UPDATE ... RETURNING *` 的行,只带回本表列,没有 join 出来的 submission_no,导致工单列表把所有寄卖单都显示成「老单无提报单号」,SUB 单号这个唯一锚点丢失。改取带 join 的预读库存(内部现货资料、指派工单资料、指派事件留痕三处)。存量老单按业务口径不回填;
4. 后台接单工单列表实时乱跳:SSE 快照补丁按数组下标读 queryKey,面板 key 加了字段后下标全错(page 被读成 targetWorkOrderId → 任何页都被当成第 1 页),且完全没判「内部寄售匹配」等筛选,于是任意一条不在当前页的老单只要发生变更就被插到列表顶部。改为结构化 queryKey + 三态判定(match 才允许插顶、mismatch 才允许移除、其余只原地更新),插入还要求 page===1 且工单 id 大于当前首行;状态比对改用 rawStatus(快照的 status 是运营态,拼单母单会被显示成 in_progress),分类筛选因快照不带 parentCategoryId 归入不可证伪;
5. 新增「寄卖只做整单」硬门禁:已有人参与的拼单工单不参与现货匹配与库存提醒,履约入口一律返回 work_order_sharing_not_consignable,前端「手动匹配寄卖」入口按同一口径收敛;开了拼单但无人参与的整单不受影响,避免过度拦截。

验证:后端 520 项单测(517 pass / 3 skip)、前端 78 项全绿;并对真实开发库跑通问题单取消三类场景(老帖无库存行 / 回库 / 已加好友作废)、退回打手与退回大厅的反向用例(不得解绑)、履约写入真实 SUB 单号、拼单门禁四入口 + 未过度拦截反向用例。
This commit is contained in:
yml2213
2026-10-01 12:17:00 +08:00
parent 610c65bb34
commit 40ee36d72a
11 changed files with 869 additions and 95 deletions
@@ -0,0 +1,231 @@
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
}
export function evaluateAdminWorkOrderRealtimeMatch(
snapshot: unknown,
filters: AdminWorkOrderListFilters,
): AdminWorkOrderRealtimeVerdict {
if (!isRecord(snapshot)) 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: { items, pagination } }` 一致)。
* 行已存在时永远只做原地替换;只有 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[]
if (options.remove) {
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) {
nextItems = items.map((item, itemIndex) => (itemIndex === index ? snapshot : item))
} else if (options.insert) {
nextItems = [snapshot, ...items]
} else {
return current
}
}
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),
},
}
: {}),
},
}
}
@@ -33,6 +33,39 @@ export type ConsignmentWorkerDisplayInfo = {
secondaryInfo: string
}
/**
* 内部现货履约单:由客服/内部仓代交付,工单上没有接单打手。
*
* 口径必须与后端「内部寄售匹配」筛选(work-order-query-repo 的 internal_consignment 分支)一致:
* `material.consignment.inventoryId` 有值、`fulfillmentType ≠ worker_deposit`、
* `consignment.workerId` 缺失或为 0(后端按字符串 '0' 也视为无打手)、且工单未指派打手。
*/
export function isInternalConsignmentOrder(row: { material?: unknown; worker?: unknown }): boolean {
const consignment = asRecord(asRecord(row.material).consignment)
if (String(consignment.fulfillmentType || '').trim() === 'worker_deposit') return false
if (!consignment.inventoryId) return false
const rawWorkerId = consignment.workerId
const hasWorkerId =
rawWorkerId !== null &&
rawWorkerId !== undefined &&
String(rawWorkerId).trim() !== '' &&
String(rawWorkerId).trim() !== '0'
if (hasWorkerId) return false
return !row.worker
}
/** 是否寄卖(寄售)相关工单:内部现货履约与指派打手的押金履约都算。 */
export function isConsignmentOrder(row: { material?: unknown; gift?: unknown }): boolean {
const consignment = asRecord(asRecord(row.material).consignment)
const boosterFields = asRecord(asRecord(asRecord(row.gift).boosterMaterial).fields)
return Boolean(
consignment.inventoryId ||
consignment.inventoryNo ||
consignment.fulfillmentType ||
boosterFields.inventoryNo,
)
}
/** 解析寄卖库存/内部现货履约单的供货人与现货资料信息 */
export function resolveConsignmentWorkerDisplay(
order: WorkOrder,
@@ -80,3 +80,20 @@ export function filterManualMatchOptions<T>(
export function isManuallyMatchableOrderStatus(status: string): boolean {
return status === 'unassigned' || status === 'pending_material' || status === 'open'
}
/**
* 能否对这张工单「手动指定寄卖现货」= 状态允许 + 不是已有打手参与的拼单。
*
* 寄卖现货只做整单履约:后端 `fulfillWorkOrderWithConsignment` 会对拼单工单返回
* `work_order_sharing_not_consignable` 硬拦,前端按同一口径收敛入口,
* 避免"按钮点了才报错"。
*/
export function canManuallyMatchConsignmentOrder(order: {
status?: string | null
sharingProgress?: { joinedQuantity?: number | null } | null
sharingWorkers?: unknown[] | null
}): boolean {
if (!isManuallyMatchableOrderStatus(String(order.status || ''))) return false
if (Number(order.sharingProgress?.joinedQuantity || 0) > 0) return false
return !(Array.isArray(order.sharingWorkers) && order.sharingWorkers.length > 0)
}