fix(ops): 队列楼层权重修复——强楼层优先 + 闭环基准兜底 + N+1 优化

问题:楼层差在分数公式中本该主导同优先级排序,但有四个缺陷导致效果不稳:

1. 有 base 无 target 时给 +600 罚分,无 base 时则全免罚——同一工单在
   保洁员忙/闲时排序不单调(B)。
2. 基准楼层只在 user 有 PROCESSING 时生效,空闲时完全无楼层信号(A)。
3. enqueue 瞬间 score 不含楼层,要等下一轮 rebuild 才补上(H)。
4. aging 上限 720 > floorDiff 上限 600,等满 4 小时可反超同优先级 10 层差
   任务,削弱"强楼层优先"语义(G)。
5. rebuild 内 for 循环对每条 WAITING 单独 selectById(order)+selectById(area),
   N+1 问题(F)。

修复:

1. QueueScoreCalculator(B + G)
   - FLOOR_WEIGHT 60 → 100:上限 1000 > aging 上限 720,4 小时老化不再反超
     同优先级的近楼层任务。
   - 删除"有 base 无 target +600"分支:任一侧缺失即 score=0,语义对称。

2. OpsOrderMapper.selectLatestCompletedAreaIdByAssignee(A 二级兜底)
   查最近 24h 内已完成工单的 area,用来推断空闲保洁员的物理位置。
   超过 24h 视为跨班次、轨迹失效。

3. OrderQueueServiceEnhanced.resolveBaselineAreaId(A 三级兜底)
   PROCESSING.area → 最近 24h COMPLETED.area → 调用方传的 fallbackAreaId。

4. OrderQueueServiceEnhanced.enqueue(H)
   事务提交后 triggerQueueRebuildAfterCommit(userId, null),新入队工单
   立即按楼层差参与排序,不依赖下一次 autoDispatchNext 触发。

5. OrderQueueServiceEnhanced.rebuildWaitingTasksByUserId(F)
   批量 selectBatchIds(orders) + selectBatchIds(areas),100 条 WAITING
   从 200 次 SELECT 降到 2 次。

权重直观对比(P2=priority×1500=3000):
             旧分数         新分数
同层刚入队    3000          3000
差5层刚入队   3000+300=3300 3000+500=3500
差5层等2小时  3000+300-360=2940 3000+500-360=3140
同层等4小时   3000+0-720=2280   3000+0-720=2280

新权重下"差5层等2小时"仍大于"同层刚入队",楼层稳定主导排序;
极端 aging(>4h)仍能让同层任务被近楼层任务压制优先执行。

测试:QueueScoreCalculatorTest(3)、OrderQueueServiceEnhancedTest(1,
已按 selectBatchIds + selectActiveListByUserId 更新 mock)、QueueSyncServiceTest
全绿。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-20 13:32:24 +08:00
parent 3e248fee8c
commit a5f916c62a
4 changed files with 86 additions and 9 deletions

View File

@@ -142,6 +142,30 @@ public interface OpsOrderMapper extends BaseMapperX<OpsOrderDO> {
.last("FOR UPDATE"));
}
/**
* 查询执行人最近一条已完成工单的区域(用于楼层基准兜底)
* <p>
* 用途:{@code OrderQueueServiceEnhanced.resolveBaselineAreaId} 的二级兜底。
* 当执行人当前没有 PROCESSING 工单时(短暂空闲),用最近完成的那一单的
* 区域作为"物理位置推断",保证楼层差评分在空闲期仍然生效。
* <p>
* 时间窗:通过 {@code since} 过滤,超过窗口仍空闲则认为轨迹失效,
* 返回 null 让调用方降级到更外层的兜底fallbackAreaId 或无楼层模式)。
*
* @param assigneeId 执行人ID
* @param since 只考虑 updateTime 晚于此时间的工单(如 now - 24h
* @return 最近一条 COMPLETED 工单的 areaId无匹配返回 null
*/
default Long selectLatestCompletedAreaIdByAssignee(Long assigneeId, LocalDateTime since) {
OpsOrderDO order = selectOne(new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getAssigneeId, assigneeId)
.eq(OpsOrderDO::getStatus, WorkOrderStatusEnum.COMPLETED.getStatus())
.ge(since != null, OpsOrderDO::getUpdateTime, since)
.orderByDesc(OpsOrderDO::getUpdateTime)
.last("LIMIT 1"));
return order != null ? order.getAreaId() : null;
}
// ==================== 统计聚合查询 ====================
/**

View File

@@ -115,6 +115,9 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
// TODO: 触发紧急派单流程(在派单引擎中实现)
}
// 5. 事务提交后按全局楼层重排一次:新入队工单立即按楼层差参与排序,不等下一次 rebuild
triggerQueueRebuildAfterCommit(userId, null);
return queueDO.getId();
}
@@ -512,10 +515,31 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
Integer baseFloorNo = resolveFloorNo(baselineAreaId);
LocalDateTime now = LocalDateTime.now();
// 批量装载 orders + areas消除 N+1100 条 WAITING 从 200 次 SELECT 降为 2 次。
List<Long> orderIds = waitingQueues.stream()
.map(OpsOrderQueueDO::getOpsOrderId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, Long> orderIdToAreaId = orderIds.isEmpty()
? Collections.emptyMap()
: orderMapper.selectBatchIds(orderIds).stream()
.filter(o -> o.getAreaId() != null)
.collect(Collectors.toMap(OpsOrderDO::getId, OpsOrderDO::getAreaId,
(a, b) -> a));
List<Long> areaIds = orderIdToAreaId.values().stream().distinct().collect(Collectors.toList());
Map<Long, Integer> areaIdToFloorNo = areaIds.isEmpty()
? Collections.emptyMap()
: areaMapper.selectBatchIds(areaIds).stream()
.filter(a -> a.getFloorNo() != null)
.collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getFloorNo,
(a, b) -> a));
List<OrderQueueDTO> rebuiltTasks = new ArrayList<>(waitingQueues.size());
for (OpsOrderQueueDO queueDO : waitingQueues) {
OrderQueueDTO dto = convertToDTO(queueDO);
Integer targetFloorNo = resolveFloorNo(resolveOrderAreaId(queueDO.getOpsOrderId()));
Long targetAreaId = orderIdToAreaId.get(queueDO.getOpsOrderId());
Integer targetFloorNo = targetAreaId != null ? areaIdToFloorNo.get(targetAreaId) : null;
QueueScoreResult result = queueScoreCalculator.calculate(QueueScoreContext.builder()
.priority(queueDO.getPriority())
.baseFloorNo(baseFloorNo)
@@ -740,7 +764,17 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
);
}
/**
* 解析楼层基准区域(三级兜底)
* <ol>
* <li>当前 PROCESSING 工单的区域——表示“正在做的楼层”</li>
* <li>最近 24 小时内已完成工单的区域——投射保洁员最近的物理位置</li>
* <li>调用方显式传入的 {@code fallbackAreaId}(如 autoDispatchNext 传的 completedOrder.areaId</li>
* </ol>
* 都未命中则返回 null本次排序降级为无楼层模式。
*/
private Long resolveBaselineAreaId(Long userId, Long fallbackAreaId) {
// 一级:当前正在执行的工单
OpsOrderQueueDO processingQueue = orderQueueMapper.selectCurrentExecutingByUserId(userId);
if (processingQueue != null) {
Long processingAreaId = resolveOrderAreaId(processingQueue.getOpsOrderId());
@@ -748,6 +782,13 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
return processingAreaId;
}
}
// 二级:最近 24 小时内的已完成工单,推断保洁员当前物理位置
Long recentAreaId = orderMapper.selectLatestCompletedAreaIdByAssignee(
userId, LocalDateTime.now().minusHours(24));
if (recentAreaId != null) {
return recentAreaId;
}
// 三级:调用方提示的区域(可为 null
return fallbackAreaId;
}

View File

@@ -9,7 +9,11 @@ import java.time.LocalDateTime;
public class QueueScoreCalculator {
static final int PRIORITY_WEIGHT = 1500;
static final int FLOOR_WEIGHT = 60;
/**
* 楼层差权重。10 层封顶 × 100 = 1000大于 aging 上限 720实现"强楼层优先"
* 等满 4 小时aging 上限)的任务也不会反超更近楼层的同优先级任务。
*/
static final int FLOOR_WEIGHT = 100;
static final int AGING_WEIGHT = 3;
static final int MAX_FLOOR_DIFF = 10;
static final int MAX_AGING_MINUTES = 240;
@@ -22,11 +26,11 @@ public class QueueScoreCalculator {
Integer targetFloorNo = context.getTargetFloorNo();
Integer floorDiff = null;
int floorDiffScore = 0;
// 语义对称:只要 baseFloor 或 targetFloor 任一缺失,就视为"信息不足"不参与楼层排序score=0
// 旧逻辑会在"有 base 无 target"时打 +600 罚分,导致同一工单在保洁员忙碌/空闲时排序不单调。
if (baseFloorNo != null && targetFloorNo != null) {
floorDiff = Math.abs(targetFloorNo - baseFloorNo);
floorDiffScore = Math.min(floorDiff, MAX_FLOOR_DIFF) * FLOOR_WEIGHT;
} else if (baseFloorNo != null) {
floorDiffScore = MAX_FLOOR_DIFF * FLOOR_WEIGHT;
}
long waitMinutes = 0;

View File

@@ -77,13 +77,21 @@ class OrderQueueServiceEnhancedTest {
when(orderQueueMapper.selectListByUserIdAndStatus(userId, OrderQueueStatusEnum.WAITING.getStatus()))
.thenReturn(List.of(olderFarTask, newerNearTask));
when(orderQueueMapper.selectCurrentExecutingByUserId(userId)).thenReturn(currentTask);
when(orderQueueMapper.selectListByUserId(userId)).thenReturn(List.of(olderFarTask, newerNearTask, currentTask));
// syncUserQueueToRedis 走 selectActiveListByUserIdBug#6只返回活跃态
when(orderQueueMapper.selectActiveListByUserId(userId))
.thenReturn(List.of(olderFarTask, newerNearTask, currentTask));
// resolveOrderAreaId 仍单条 selectByIdPROCESSING 工单的 area
when(orderMapper.selectById(900L)).thenReturn(OpsOrderDO.builder().id(900L).areaId(501L).build());
when(orderMapper.selectById(101L)).thenReturn(OpsOrderDO.builder().id(101L).areaId(503L).build());
when(orderMapper.selectById(102L)).thenReturn(OpsOrderDO.builder().id(102L).areaId(502L).build());
// WAITING 工单批量加载
when(orderMapper.selectBatchIds(org.mockito.ArgumentMatchers.anyCollection()))
.thenReturn(List.of(
OpsOrderDO.builder().id(101L).areaId(503L).build(),
OpsOrderDO.builder().id(102L).areaId(502L).build()));
when(areaMapper.selectById(501L)).thenReturn(OpsBusAreaDO.builder().id(501L).floorNo(5).build());
when(areaMapper.selectById(502L)).thenReturn(OpsBusAreaDO.builder().id(502L).floorNo(6).build());
when(areaMapper.selectById(503L)).thenReturn(OpsBusAreaDO.builder().id(503L).floorNo(8).build());
when(areaMapper.selectBatchIds(org.mockito.ArgumentMatchers.anyCollection()))
.thenReturn(List.of(
OpsBusAreaDO.builder().id(503L).floorNo(8).build(),
OpsBusAreaDO.builder().id(502L).floorNo(6).build()));
when(orderQueueMapper.updateById(any(OpsOrderQueueDO.class))).thenReturn(1);
List<OrderQueueDTO> rebuiltTasks = orderQueueService.rebuildWaitingTasksByUserId(userId, null);