add:新增同一个工单同一人可以多次拼单:抢单大厅优化

This commit is contained in:
2026-10-24 13:44:34 +08:00
parent f9aeece7ab
commit 25756901cc
10 changed files with 245 additions and 46 deletions
@@ -16,6 +16,7 @@ import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/
import { useIsMobile } from '@/utils/use-is-mobile'
import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders'
import {
compareWorkerHallOrderRows,
evaluateWorkerKeywordVerdict,
patchWorkerWorkOrderInfiniteEnvelope,
patchWorkerWorkOrderListEnvelope,
@@ -438,6 +439,11 @@ function applyWorkerHallCachePatch(
// 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额;
// 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。
['myShare', 'myShares'],
// 大厅展示顺序是「置顶优先,再按工单号倒序」(与列表 SQL 的
// `pinned_at DESC NULLS LAST, id DESC` 同口径)。广播补丁只做就地覆盖
// 或顶部插入,不重排缓存,这里传入排序比较器,保证新插入的行
// 和快照里带 pinnedAt 的行按大厅口径归位,不会跑到置顶行前面。
compareWorkerHallOrderRows,
),
)
}
@@ -116,6 +116,39 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: {
}
}
/**
* 大厅行的实时排序:与后端列表 SQL(pinnedFirst 时 `pinned_at DESC NULLS LAST, id DESC`)
* 保持同一口径。大厅广播是「就地覆盖 + 第 1 页顶部插入」的缓存补丁,快照来自事件
* 而不是重新查询,行会带着事件发生时的快字段停在缓存里;排序只影响展示位置。
*
* 这里必须带置顶维度:广播快照的 pinnedAt 参与不了 SQL 排序,若只按 workOrderId 排,
* 每次广播变更(拼单进度、材料更新等)都会把置顶行挤到未置顶行后面 ——
* 表现为「进大厅过一会儿,排名靠后的订单跑到前面去」。
*/
export function compareWorkerHallOrderRows(left: unknown, right: unknown): number {
const leftPinnedAtMs = resolveHallPinnedAtMs(left)
const rightPinnedAtMs = resolveHallPinnedAtMs(right)
if (leftPinnedAtMs !== rightPinnedAtMs) {
// 有一方置顶即置顶行在前;双方都置顶时按置顶时间倒序(同列表 SQL 的 NULLS LAST)。
if (leftPinnedAtMs !== null && rightPinnedAtMs !== null) {
return rightPinnedAtMs - leftPinnedAtMs
}
return leftPinnedAtMs !== null ? -1 : 1
}
// 双方置顶状态一致:按工单号倒序(与列表 SQL 的 `id DESC` 一致)。
return (
toNumber((right as { workOrderId?: unknown })?.workOrderId) -
toNumber((left as { workOrderId?: unknown })?.workOrderId)
)
}
/** 置顶行的时间戳;未置顶返回 null(对应 SQL 的 NULLS LAST)。 */
function resolveHallPinnedAtMs(item: unknown): number | null {
if (!isRecord(item)) return null
const parsed = Date.parse(String(item.pinnedAt || ''))
return Number.isFinite(parsed) ? parsed : null
}
/** 按工单号原地增删行,并同步分页总数(结构同后端 `{ data: { items, pagination } }`)。 */
export function patchWorkerWorkOrderListEnvelope(
current: unknown,
@@ -128,6 +161,15 @@ export function patchWorkerWorkOrderListEnvelope(
* 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。
*/
preserveFields: readonly string[] = [],
/**
* 插行后的行内排序比较器。
*
* 插入的新行(大厅广播的 upsert)落在第 1 页顶部,如果大厅缓存里已有置顶行,
* 只按「插到最前面」处理会把新行插到置顶行前面;传入比较器可以按大厅的
* 排序口径(置顶优先,然后工单号倒序)重新排一次。「我的订单」等按服务端
* 顺序追加的列表不传该参数,保持原行为。
*/
sortCompare?: (left: unknown, right: unknown) => number,
): unknown {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
@@ -161,6 +203,7 @@ export function patchWorkerWorkOrderListEnvelope(
return current
}
}
if (sortCompare) nextItems = [...nextItems].sort(sortCompare)
const delta = nextItems.length - items.length
const pagination = isRecord(current.data.pagination) ? current.data.pagination : null
@@ -188,12 +231,19 @@ export function patchWorkerWorkOrderInfiniteEnvelope(
event: WorkerListEvent,
insert = false,
preserveFields: readonly string[] = [],
sortCompare?: (left: unknown, right: unknown) => number,
): unknown {
if (!isRecord(current) || !Array.isArray(current.pages)) return current
return {
...current,
pages: current.pages.map((page: unknown, index: number) =>
patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0, preserveFields),
patchWorkerWorkOrderListEnvelope(
page,
event,
insert && index === 0,
preserveFields,
sortCompare,
),
),
}
}
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
compareWorkerHallOrderRows,
evaluateWorkerKeywordVerdict,
matchesWorkerWorkOrderKeyword,
patchWorkerWorkOrderInfiniteEnvelope,
@@ -326,3 +327,50 @@ test('大厅广播覆盖行时必须保留本打手的 myShare/myShares,并更
assert.equal(insertedRow?.myShare ?? null, null)
assert.equal(insertedRow?.myShares ?? null, null)
})
/**
* 线上坑:进大厅后过一会儿「排名靠后的订单跑到前面去」。
*
* 根因是大厅广播补丁只做「就地覆盖 + 顶部插入」、不重排缓存:
* 1) 列表 SQL 按置顶时间排序时,广播快照没带 pinnedAt,排序口径直接丢失;
* 2) 广播 upsert 插到第 1 页顶部,会把已置顶的行挤到后面。
* 修复 = 后端快照带 pinnedAt + 前端按「置顶优先 → 工单号倒序」重排插行后的列表。
*/
test('大厅排序比较器:置顶行恒在未置顶行前,同置顶按置顶时间倒序', () => {
const pinnedNewer = buildRow({ workOrderId: 1, pinnedAt: '2026-10-24T10:00:00Z' })
const pinnedOlder = buildRow({ workOrderId: 2, pinnedAt: '2026-10-24T09:00:00Z' })
const unpinned = buildRow({ workOrderId: 3, pinnedAt: null })
assert.equal(compareWorkerHallOrderRows(pinnedNewer, pinnedOlder) < 0, true)
assert.equal(compareWorkerHallOrderRows(pinnedOlder, pinnedNewer) > 0, true)
assert.equal(compareWorkerHallOrderRows(pinnedNewer, unpinned) < 0, true)
assert.equal(compareWorkerHallOrderRows(unpinned, pinnedNewer) > 0, true)
// 双方都未置顶:按工单号倒序(与列表 SQL 的 id DESC 一致)
const newer = buildRow({ workOrderId: 10, pinnedAt: null })
const older = buildRow({ workOrderId: 9, pinnedAt: null })
assert.equal(compareWorkerHallOrderRows(newer, older) < 0, true)
})
test('广播 upsert 插入新行后必须按大厅口径重排,不能把置顶行挤下去', () => {
const pinnedRow = buildRow({
workOrderId: 100,
status: 'open',
pinnedAt: '2026-10-24T09:00:00Z',
})
const envelope = { pages: [buildEnvelope([pinnedRow])], pageParams: [1] }
const patched = patchWorkerWorkOrderInfiniteEnvelope(
envelope,
{
operation: 'upsert',
data: buildRow({ workOrderId: 200, status: 'open', pinnedAt: null }),
},
true,
['myShare', 'myShares'],
compareWorkerHallOrderRows,
) as { pages: unknown[] }
assert.deepEqual(
itemsOf(patched.pages[0]).map((item) => item.workOrderId),
[100, 200],
'新单排在置顶单后面,而不是插到最前面',
)
})