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
+38 -54
View File
@@ -52,6 +52,12 @@ import {
setAdminPermissions,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
evaluateAdminWorkOrderRealtimeMatch,
parseAdminWorkOrderListFilters,
patchAdminWorkOrderListItems,
shouldPrependAdminWorkOrder,
} from '@/utils/admin-realtime-work-orders'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
import dayjs from '@/lib/dayjs'
@@ -436,40 +442,43 @@ function patchAdminWorkOrderCaches(
queryClient: ReturnType<typeof useQueryClient>,
event: RealtimeEvent,
) {
const item = event.data as {
workOrderId?: unknown
status?: unknown
productName?: unknown
platformOrderId?: unknown
worker?: { workerId?: unknown } | null
}
const item = event.data
for (const [queryKey, current] of queryClient.getQueriesData({
queryKey: ['admin-worker-platform-orders'],
})) {
const statuses = String(queryKey[1] || '')
.split(',')
.filter(Boolean)
const keyword = String(queryKey[2] || '')
const workerId = Number(queryKey[3] || 0)
const workerLevelIds = String(queryKey[4] || '')
.split(',')
.map((value) => Number(value))
.filter(Boolean)
const targetWorkOrderId = Number(queryKey[5] || 0)
const page = Number(queryKey[6] || 1)
// 等级筛选的匹配逻辑只在服务端(接单打手或拼单参与者等级),快照不含等级,
// 无法在前端复现:已有行视为匹配(拉取时已通过服务端过滤),但不再插入新行。
const levelFilterActive = workerLevelIds.length > 0
const matches =
(!statuses.length || statuses.includes(String(item.status || ''))) &&
matchesWorkOrderKeyword(item, keyword) &&
(!workerId || workerId === Number(item.worker?.workerId || 0)) &&
(!targetWorkOrderId || targetWorkOrderId === Number(item.workOrderId || 0))
const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !levelFilterActive
const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert'
const filters = parseAdminWorkOrderListFilters(queryKey)
if (!filters) {
// 读不懂这个 key(旧结构 / 别的调用方):不猜、不插、不移除,交给服务端下一次拉取。
continue
}
// 三态判定:match 才允许插入顶部;mismatch 才允许移除;unknown 只原地更新已存在的行。
const verdict =
event.operation === 'remove' ? 'mismatch' : evaluateAdminWorkOrderRealtimeMatch(item, filters)
if (verdict === 'unknown') {
queryClient.setQueryData(
queryKey,
patchAdminWorkOrderListItems(current, item, { remove: false, insert: false }),
)
continue
}
const items =
isRecord(current) && isRecord(current.data) && Array.isArray(current.data.items)
? (current.data.items as unknown[])
: []
const insert =
verdict === 'match' &&
event.operation === 'upsert' &&
filters.page === 1 &&
shouldPrependAdminWorkOrder(items, item)
queryClient.setQueryData(
queryKey,
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
patchAdminWorkOrderListItems(current, item, {
remove: verdict === 'mismatch',
insert,
}),
)
}
}
@@ -597,20 +606,6 @@ function patchAdminOrderListEnvelope(
}
}
function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const item = event.data
const id = Number((item as { workOrderId?: unknown }).workOrderId || event.entityId)
const items = current.data.items as unknown[]
const nextItems =
event.operation === 'remove'
? items.filter((entry) => Number((entry as { workOrderId?: unknown })?.workOrderId) !== id)
: upsertById(items, item, 'workOrderId', insert)
return { ...current, data: { ...current.data, items: nextItems } }
}
function patchFinanceRequestListEnvelope(current: unknown, event: RealtimeEvent) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
@@ -682,17 +677,6 @@ function matchesAdminOrderDateRange(
return true
}
function matchesWorkOrderKeyword(
item: { productName?: unknown; platformOrderId?: unknown },
keyword: string,
) {
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return true
return [item.productName, item.platformOrderId]
.map((value) => String(value || '').toLowerCase())
.some((value) => value.includes(normalizedKeyword))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}