From a5f916c62aec7a3f5061e445e5d4a22868158150 Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 20 Apr 2026 13:32:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=E9=98=9F=E5=88=97=E6=A5=BC?= =?UTF-8?q?=E5=B1=82=E6=9D=83=E9=87=8D=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=BC=BA=E6=A5=BC=E5=B1=82=E4=BC=98=E5=85=88=20+=20=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E5=9F=BA=E5=87=86=E5=85=9C=E5=BA=95=20+=20N+1=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:楼层差在分数公式中本该主导同优先级排序,但有四个缺陷导致效果不稳: 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) --- .../dal/mysql/workorder/OpsOrderMapper.java | 24 +++++++++++ .../queue/OrderQueueServiceEnhanced.java | 43 ++++++++++++++++++- .../service/queue/QueueScoreCalculator.java | 10 +++-- .../queue/OrderQueueServiceEnhancedTest.java | 18 +++++--- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/workorder/OpsOrderMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/workorder/OpsOrderMapper.java index e19a7262..997f4fac 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/workorder/OpsOrderMapper.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/workorder/OpsOrderMapper.java @@ -142,6 +142,30 @@ public interface OpsOrderMapper extends BaseMapperX { .last("FOR UPDATE")); } + /** + * 查询执行人最近一条已完成工单的区域(用于楼层基准兜底) + *

+ * 用途:{@code OrderQueueServiceEnhanced.resolveBaselineAreaId} 的二级兜底。 + * 当执行人当前没有 PROCESSING 工单时(短暂空闲),用最近完成的那一单的 + * 区域作为"物理位置推断",保证楼层差评分在空闲期仍然生效。 + *

+ * 时间窗:通过 {@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() + .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; + } + // ==================== 统计聚合查询 ==================== /** diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java index ef723a69..2401de09 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java @@ -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+1:100 条 WAITING 从 200 次 SELECT 降为 2 次。 + List orderIds = waitingQueues.stream() + .map(OpsOrderQueueDO::getOpsOrderId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map 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 areaIds = orderIdToAreaId.values().stream().distinct().collect(Collectors.toList()); + Map 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 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 { ); } + /** + * 解析楼层基准区域(三级兜底) + *

    + *
  1. 当前 PROCESSING 工单的区域——表示“正在做的楼层”
  2. + *
  3. 最近 24 小时内已完成工单的区域——投射保洁员最近的物理位置
  4. + *
  5. 调用方显式传入的 {@code fallbackAreaId}(如 autoDispatchNext 传的 completedOrder.areaId)
  6. + *
+ * 都未命中则返回 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; } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java index 5e22b1f3..bd161f51 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java @@ -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; diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java index 19f5885a..bdf2a12c 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhancedTest.java @@ -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 走 selectActiveListByUserId(Bug#6),只返回活跃态 + when(orderQueueMapper.selectActiveListByUserId(userId)) + .thenReturn(List.of(olderFarTask, newerNearTask, currentTask)); + // resolveOrderAreaId 仍单条 selectById(PROCESSING 工单的 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 rebuiltTasks = orderQueueService.rebuildWaitingTasksByUserId(userId, null);