test(ops): 补齐工单链路 5 个修复点的集成测试

与 4d85659…a5f916c 的 5 次修复对齐,用 Mockito 风格覆盖状态链路关键分支:
- DispatchEngineIdleCheckTest:autoDispatchNext 空闲兜底 + executeDispatch
  MySQL 活跃态降级(Bug #1/#4),ENQUEUE_ONLY 路径不触发兜底查询避免开销浪费
- DispatchEngineConflictFallbackTest:FOR UPDATE 冲突分支(Bug #2),
  PENDING → 降级入队、QUEUED → 保持排队、其他错误码 → 硬失败
- OrderTransitionAuditListenerTest:审计闭环(Bug #7),AFTER_COMMIT 成功/WARN/ERROR
  分支 + AFTER_ROLLBACK 强制视为失败 + 7 种目标状态映射
- QueueScoreCalculatorEnhancedTest:楼层权重 G+B,锁死"FLOOR×10 > AGING×240"
  不变量,验证 base/target 任一 null → score=0,移除旧 +600 罚分后语义对称

22 个新测试全部通过;模块内 115/117 测试通过,2 个 pre-existing 失败
(VspNotifyClient/AreaDeviceRelation) 依赖外部服务,与本次改动无关。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-20 14:24:07 +08:00
parent 323ddf27fb
commit 9f3ca9c6f2
4 changed files with 739 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
package com.viewsh.module.ops.core.dispatch;
import com.viewsh.module.ops.api.queue.OrderQueueService;
import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation;
import com.viewsh.module.ops.core.dispatch.model.DispatchDecision;
import com.viewsh.module.ops.core.dispatch.model.DispatchPath;
import com.viewsh.module.ops.core.dispatch.model.DispatchResult;
import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext;
import com.viewsh.module.ops.core.dispatch.strategy.AssignStrategy;
import com.viewsh.module.ops.core.dispatch.strategy.ScheduleStrategy;
import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager;
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest;
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionResult;
import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 验证 Bug #2FOR UPDATE 并发冲突降级路径。
* <p>
* 背景OrderLifecycleManager.dispatch 入口加了 selectActiveByAssigneeForUpdate 行锁,
* 命中时返 {@link TransitionErrorCode#ASSIGNEE_HAS_ACTIVE_ORDER}。
* DispatchEngine 需按工单当前状态分支处理:
* <ul>
* <li>PENDING从未入队→ 直接降级为入队,避免悬空</li>
* <li>QUEUED已在队列→ 保留排队,不做重复入队</li>
* <li>其他错误码(如 INVALID_TRANSITION→ 硬失败,不走降级</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
class DispatchEngineConflictFallbackTest {
@Mock
private OrderLifecycleManager orderLifecycleManager;
@Mock
private OrderQueueService orderQueueService;
@Mock
private OpsOrderMapper orderMapper;
@Mock
private AssignStrategy assignStrategy;
@Mock
private ScheduleStrategy scheduleStrategy;
@InjectMocks
private DispatchEngineImpl dispatchEngine;
private static final String CLEAN = "CLEAN";
private static final Long ASSIGNEE_ID = 31L;
@BeforeEach
void setUp() {
dispatchEngine.registerAssignStrategy(CLEAN, assignStrategy);
dispatchEngine.registerScheduleStrategy(CLEAN, scheduleStrategy);
}
@Test
void directDispatch_onConflict_whenOrderIsPending_shouldDowngradeToEnqueue() {
// PENDING 工单派发被拒 → 降级 executeEnqueueOnly避免工单悬空
Long orderId = 400L;
OrderDispatchContext context = baseContext(orderId);
stubHappyPathUntilDispatch(orderId);
when(orderLifecycleManager.dispatch(any(OrderTransitionRequest.class)))
.thenReturn(OrderTransitionResult.fail(orderId,
"同执行人已有活跃工单 999",
TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER));
when(orderMapper.selectById(orderId)).thenReturn(OpsOrderDO.builder()
.id(orderId)
.status(WorkOrderStatusEnum.PENDING.getStatus())
.build());
when(orderLifecycleManager.enqueue(any(OrderTransitionRequest.class)))
.thenReturn(OrderTransitionResult.success(orderId,
WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.QUEUED, 6000L));
DispatchResult result = dispatchEngine.dispatch(context);
assertTrue(result.isSuccess());
assertEquals(DispatchPath.ENQUEUE_ONLY, result.getPath());
assertEquals(6000L, result.getQueueId());
verify(orderLifecycleManager).enqueue(any(OrderTransitionRequest.class));
}
@Test
void directDispatch_onConflict_whenOrderAlreadyQueued_shouldKeepInQueue() {
// QUEUED 工单(从队列中被拉出派发)再次被拒 → 不重复入队,继续留在队列等下一轮
Long orderId = 401L;
OrderDispatchContext context = baseContext(orderId);
stubHappyPathUntilDispatch(orderId);
when(orderLifecycleManager.dispatch(any(OrderTransitionRequest.class)))
.thenReturn(OrderTransitionResult.fail(orderId,
"同执行人已有活跃工单 998",
TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER));
when(orderMapper.selectById(orderId)).thenReturn(OpsOrderDO.builder()
.id(orderId)
.status(WorkOrderStatusEnum.QUEUED.getStatus())
.build());
DispatchResult result = dispatchEngine.dispatch(context);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("已留在队列等待"),
"冲突信息应说明工单已留在队列,实际: " + result.getMessage());
// 关键断言:不能再调一次 enqueue否则队列里会出现两条记录
verify(orderLifecycleManager, never()).enqueue(any(OrderTransitionRequest.class));
}
@Test
void directDispatch_onGeneralFailure_shouldNotDowngrade() {
// 非并发冲突的失败(例如非法状态转换)不走降级路径,且不查 selectById
Long orderId = 402L;
OrderDispatchContext context = baseContext(orderId);
stubHappyPathUntilDispatch(orderId);
when(orderLifecycleManager.dispatch(any(OrderTransitionRequest.class)))
.thenReturn(OrderTransitionResult.fail(orderId,
"非法状态转换",
TransitionErrorCode.INVALID_TRANSITION));
DispatchResult result = dispatchEngine.dispatch(context);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("直接派单失败"),
"一般失败应归类为直接派单失败,实际: " + result.getMessage());
verify(orderLifecycleManager, never()).enqueue(any(OrderTransitionRequest.class));
// 非冲突错误不应触发工单状态复核
verify(orderMapper, never()).selectById(orderId);
}
// ==================== Helpers ====================
private OrderDispatchContext baseContext(Long orderId) {
return OrderDispatchContext.builder()
.orderId(orderId)
.orderCode("WO-TEST-" + orderId)
.businessType(CLEAN)
.areaId(501L)
.build();
}
/**
* 装配 dispatch 路径上到 orderLifecycleManager.dispatch() 之前的全部 stub
* 策略推荐成功 + 决策为 DIRECT_DISPATCH + 兜底查询 MySQL 为空闲。
* 留给测试自己控制 orderLifecycleManager.dispatch 的返回。
*/
private void stubHappyPathUntilDispatch(Long orderId) {
when(assignStrategy.recommend(any())).thenReturn(
AssigneeRecommendation.of(ASSIGNEE_ID, "工牌31", 80, "区域最近"));
when(scheduleStrategy.decide(any())).thenReturn(DispatchDecision.directDispatch());
when(orderQueueService.getTasksByUserId(ASSIGNEE_ID)).thenReturn(List.of());
when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, orderId)).thenReturn(List.of());
}
}

View File

@@ -0,0 +1,227 @@
package com.viewsh.module.ops.core.dispatch;
import com.viewsh.module.ops.api.queue.OrderQueueService;
import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation;
import com.viewsh.module.ops.core.dispatch.model.DispatchDecision;
import com.viewsh.module.ops.core.dispatch.model.DispatchPath;
import com.viewsh.module.ops.core.dispatch.model.DispatchResult;
import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext;
import com.viewsh.module.ops.core.dispatch.strategy.AssignStrategy;
import com.viewsh.module.ops.core.dispatch.strategy.ScheduleStrategy;
import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager;
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest;
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionResult;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 验证 Bug #1autoDispatchNext 空闲兜底)+ Bug #4executeDispatch 前置检查)。
* <p>
* 产线事故:管理员 cancel 一个僵尸 DISPATCHED 单 → handleCancelled → autoDispatchNext
* 若不校验活跃态,就会在同一设备上派发新单、旧单不死,最终 0002=CONFIRMED + 0003=DISPATCHED 并存。
*/
@ExtendWith(MockitoExtension.class)
class DispatchEngineIdleCheckTest {
@Mock
private OrderLifecycleManager orderLifecycleManager;
@Mock
private OrderQueueService orderQueueService;
@Mock
private OpsOrderMapper orderMapper;
@Mock
private AssignStrategy assignStrategy;
@Mock
private ScheduleStrategy scheduleStrategy;
@InjectMocks
private DispatchEngineImpl dispatchEngine;
private static final String CLEAN = "CLEAN";
private static final Long ASSIGNEE_ID = 31L;
@BeforeEach
void setUp() {
dispatchEngine.registerAssignStrategy(CLEAN, assignStrategy);
dispatchEngine.registerScheduleStrategy(CLEAN, scheduleStrategy);
}
@Test
void autoDispatchNext_shouldSkip_whenAssigneeStillHasActiveOrder() {
// 场景completedOrderId=100 刚被 cancel但设备 31 名下还挂着 200L CONFIRMED 单
Long completedOrderId = 100L;
when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, completedOrderId))
.thenReturn(List.of(OpsOrderDO.builder()
.id(200L)
.status(WorkOrderStatusEnum.CONFIRMED.getStatus())
.build()));
DispatchResult result = dispatchEngine.autoDispatchNext(completedOrderId, ASSIGNEE_ID);
assertTrue(result.isSuccess());
assertEquals("执行人非空闲,跳过派发", result.getMessage());
assertEquals(ASSIGNEE_ID, result.getAssigneeId());
// 不应触发后续队列重排和派发
verifyNoInteractions(orderQueueService);
verifyNoInteractions(orderLifecycleManager);
}
@Test
void autoDispatchNext_shouldSkip_whenAssigneeHasPausedOrder() {
// PAUSED 也视为"仍有任务",不能派发新单(否则 PAUSED 恢复回来就和新单冲突)
Long completedOrderId = 101L;
when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, completedOrderId))
.thenReturn(List.of(OpsOrderDO.builder()
.id(201L)
.status(WorkOrderStatusEnum.PAUSED.getStatus())
.build()));
DispatchResult result = dispatchEngine.autoDispatchNext(completedOrderId, ASSIGNEE_ID);
assertTrue(result.isSuccess());
assertEquals("执行人非空闲,跳过派发", result.getMessage());
verifyNoInteractions(orderLifecycleManager);
}
@Test
void autoDispatchNext_shouldReturnEarly_whenAssigneeIdIsNull() {
// 入参校验assigneeId 空直接返回,不查活跃态
DispatchResult result = dispatchEngine.autoDispatchNext(100L, null);
assertTrue(result.isSuccess());
assertEquals("缺少执行人,跳过派发", result.getMessage());
verifyNoInteractions(orderMapper);
verifyNoInteractions(orderQueueService);
}
@Test
void executeDispatch_shouldDowngradeDirectDispatchToEnqueue_whenMysqlShowsActive() {
// Bug #4Redis 说设备空闲,但 MySQL 仍有活跃态 → 兜底把 DIRECT_DISPATCH 降级为 ENQUEUE_ONLY
Long orderId = 300L;
OrderDispatchContext context = baseContext(orderId);
when(assignStrategy.recommend(any())).thenReturn(
AssigneeRecommendation.of(ASSIGNEE_ID, "工牌31", 80, "区域最近"));
when(scheduleStrategy.decide(any())).thenReturn(DispatchDecision.directDispatch());
when(orderQueueService.getTasksByUserId(ASSIGNEE_ID)).thenReturn(List.of());
when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, orderId))
.thenReturn(List.of(OpsOrderDO.builder()
.id(999L)
.status(WorkOrderStatusEnum.ARRIVED.getStatus())
.build()));
when(orderLifecycleManager.enqueue(any(OrderTransitionRequest.class)))
.thenReturn(successEnqueue(orderId, 5000L));
DispatchResult result = dispatchEngine.dispatch(context);
assertTrue(result.isSuccess());
assertEquals(DispatchPath.ENQUEUE_ONLY, result.getPath());
assertEquals(5000L, result.getQueueId());
verify(orderLifecycleManager, never()).dispatch(any(OrderTransitionRequest.class));
verify(orderLifecycleManager).enqueue(any(OrderTransitionRequest.class));
}
@Test
void executeDispatch_shouldDowngradePushAndEnqueue_whenMysqlShowsActive() {
// PUSH_AND_ENQUEUE 路径同样要兜底:本应"推送旧队首 + 新单入队"
// 但旧队首已活跃,推送会触发 FOR UPDATE 冲突,所以直接降级为 ENQUEUE_ONLY
Long orderId = 301L;
OrderDispatchContext context = baseContext(orderId);
when(assignStrategy.recommend(any())).thenReturn(
AssigneeRecommendation.of(ASSIGNEE_ID, "工牌31", 80, "区域最近"));
when(scheduleStrategy.decide(any())).thenReturn(DispatchDecision.pushAndEnqueue());
when(orderQueueService.getTasksByUserId(ASSIGNEE_ID)).thenReturn(List.of());
when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, orderId))
.thenReturn(List.of(OpsOrderDO.builder()
.id(998L)
.status(WorkOrderStatusEnum.DISPATCHED.getStatus())
.build()));
when(orderLifecycleManager.enqueue(any(OrderTransitionRequest.class)))
.thenReturn(successEnqueue(orderId, 5001L));
DispatchResult result = dispatchEngine.dispatch(context);
assertTrue(result.isSuccess());
assertEquals(DispatchPath.ENQUEUE_ONLY, result.getPath());
verify(orderLifecycleManager, never()).dispatch(any(OrderTransitionRequest.class));
verify(orderLifecycleManager).enqueue(any(OrderTransitionRequest.class));
}
@Test
void executeDispatch_shouldNotQueryMysql_whenPathIsEnqueueOnly() {
// ENQUEUE_ONLY 本来就不推送,无需兜底查询——避免给每一次入队都叠加一次 SQL 开销
Long orderId = 302L;
OrderDispatchContext context = baseContext(orderId);
when(assignStrategy.recommend(any())).thenReturn(
AssigneeRecommendation.of(ASSIGNEE_ID, "工牌31", 80, "区域最近"));
when(scheduleStrategy.decide(any())).thenReturn(DispatchDecision.enqueueOnly());
when(orderLifecycleManager.enqueue(any(OrderTransitionRequest.class)))
.thenReturn(successEnqueue(orderId, 5002L));
DispatchResult result = dispatchEngine.dispatch(context);
assertTrue(result.isSuccess());
assertEquals(DispatchPath.ENQUEUE_ONLY, result.getPath());
// 关键ENQUEUE_ONLY 不应触发兜底查询
verify(orderMapper, never()).selectActiveByAssignee(any(), any());
}
@Test
void executeDispatch_shouldProceedDirectDispatch_whenMysqlConfirmsIdle() {
// 反向用例MySQL 也确认空闲 → 正常走 DIRECT_DISPATCH不降级
Long orderId = 303L;
OrderDispatchContext context = baseContext(orderId);
when(assignStrategy.recommend(any())).thenReturn(
AssigneeRecommendation.of(ASSIGNEE_ID, "工牌31", 80, "区域最近"));
when(scheduleStrategy.decide(any())).thenReturn(DispatchDecision.directDispatch());
when(orderQueueService.getTasksByUserId(ASSIGNEE_ID)).thenReturn(List.of());
when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, orderId)).thenReturn(List.of());
when(orderLifecycleManager.dispatch(any(OrderTransitionRequest.class)))
.thenReturn(OrderTransitionResult.success(orderId,
WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.DISPATCHED));
DispatchResult result = dispatchEngine.dispatch(context);
assertFalse(!result.isSuccess()); // 确认成功
assertEquals(DispatchPath.DIRECT_DISPATCH, result.getPath());
verify(orderLifecycleManager).dispatch(any(OrderTransitionRequest.class));
verify(orderLifecycleManager, never()).enqueue(any(OrderTransitionRequest.class));
}
// ==================== Helpers ====================
private OrderDispatchContext baseContext(Long orderId) {
return OrderDispatchContext.builder()
.orderId(orderId)
.orderCode("WO-TEST-" + orderId)
.businessType(CLEAN)
.areaId(501L)
.build();
}
private OrderTransitionResult successEnqueue(Long orderId, Long queueId) {
return OrderTransitionResult.success(orderId,
WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.QUEUED, queueId);
}
}

View File

@@ -0,0 +1,211 @@
package com.viewsh.module.ops.core.lifecycle.audit;
import com.viewsh.module.ops.core.event.OrderTransitionAttemptedEvent;
import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
import com.viewsh.module.ops.enums.OperatorTypeEnum;
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain;
import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel;
import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule;
import com.viewsh.module.ops.infrastructure.log.enumeration.LogType;
import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord;
import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
/**
* 验证 Bug #7状态转换审计闭环。
* <p>
* 三条路径:
* <ol>
* <li>AFTER_COMMIT + 成功 → INFO + 业务 LogType如 ORDER_DISPATCHED</li>
* <li>AFTER_COMMIT + 并发冲突失败 → WARN + DISPATCH_REJECTED</li>
* <li>AFTER_COMMIT + 一般失败 → ERROR + TRANSITION_FAILED</li>
* <li>AFTER_ROLLBACK → 无论事件声明 success 与否都视为失败(事务已回滚),独立事务补写</li>
* </ol>
*/
@ExtendWith(MockitoExtension.class)
class OrderTransitionAuditListenerTest {
@Mock
private EventLogRecorder eventLogRecorder;
@InjectMocks
private OrderTransitionAuditListener listener;
@Test
void onAfterCommit_success_shouldRecordInfoWithBusinessEventType() {
OrderTransitionAttemptedEvent event = successEvent(100L, WorkOrderStatusEnum.QUEUED,
WorkOrderStatusEnum.DISPATCHED);
listener.onAfterCommit(event);
EventLogRecord rec = captureRecord();
assertEquals(EventLevel.INFO, rec.getLevel());
assertEquals(EventDomain.DISPATCH, rec.getDomain());
assertEquals(LogType.ORDER_DISPATCHED.getCode(), rec.getEventType());
assertEquals(LogModule.CLEAN, rec.getModule());
assertEquals(100L, rec.getTargetId());
assertEquals("order", rec.getTargetType());
assertEquals(Boolean.TRUE, rec.getPayload().get("success"));
assertEquals(Boolean.FALSE, rec.getPayload().get("rolledBack"));
assertEquals(WorkOrderStatusEnum.QUEUED.getStatus(), rec.getPayload().get("fromStatus"));
assertEquals(WorkOrderStatusEnum.DISPATCHED.getStatus(), rec.getPayload().get("targetStatus"));
}
@Test
void onAfterCommit_success_shouldMapAllBusinessStatusesToLogType() {
// 验证关键目标状态都能映射到对应 LogType避免回归导致全映射到 SYSTEM_EVENT
assertLogTypeForTarget(WorkOrderStatusEnum.QUEUED, LogType.ORDER_QUEUED);
assertLogTypeForTarget(WorkOrderStatusEnum.DISPATCHED, LogType.ORDER_DISPATCHED);
assertLogTypeForTarget(WorkOrderStatusEnum.CONFIRMED, LogType.ORDER_CONFIRM);
assertLogTypeForTarget(WorkOrderStatusEnum.ARRIVED, LogType.ORDER_ARRIVED);
assertLogTypeForTarget(WorkOrderStatusEnum.PAUSED, LogType.ORDER_PAUSED);
assertLogTypeForTarget(WorkOrderStatusEnum.COMPLETED, LogType.ORDER_COMPLETED);
assertLogTypeForTarget(WorkOrderStatusEnum.CANCELLED, LogType.ORDER_CANCELLED);
}
@Test
void onAfterCommit_forUpdateRejected_shouldRecordWarnWithDispatchRejected() {
OrderTransitionAttemptedEvent event = OrderTransitionAttemptedEvent.builder()
.orderId(200L)
.orderType("CLEAN")
.fromStatus(WorkOrderStatusEnum.PENDING)
.targetStatus(WorkOrderStatusEnum.DISPATCHED)
.assigneeId(31L)
.operatorType(OperatorTypeEnum.SYSTEM)
.success(false)
.errorCode(TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER)
.errorMessage("同执行人已有活跃工单 999")
.attemptedAt(LocalDateTime.now())
.build();
listener.onAfterCommit(event);
EventLogRecord rec = captureRecord();
// 并发冲突只是业务层拒绝,不是系统异常,所以是 WARN 而不是 ERROR
assertEquals(EventLevel.WARN, rec.getLevel());
assertEquals(LogType.DISPATCH_REJECTED.getCode(), rec.getEventType());
assertEquals("ASSIGNEE_HAS_ACTIVE_ORDER", rec.getPayload().get("errorCode"));
assertEquals("同执行人已有活跃工单 999", rec.getPayload().get("errorMessage"));
assertTrue(rec.getMessage().contains("状态转换失败"),
"消息应标明转换失败,实际: " + rec.getMessage());
}
@Test
void onAfterCommit_generalFailure_shouldRecordErrorWithTransitionFailed() {
OrderTransitionAttemptedEvent event = OrderTransitionAttemptedEvent.builder()
.orderId(300L)
.orderType("SECURITY")
.fromStatus(WorkOrderStatusEnum.PENDING)
.targetStatus(WorkOrderStatusEnum.DISPATCHED)
.success(false)
.errorCode(TransitionErrorCode.INVALID_TRANSITION)
.errorMessage("PENDING → ARRIVED 非法")
.causeSummary("IllegalStateException: PENDING → ARRIVED 非法")
.attemptedAt(LocalDateTime.now())
.build();
listener.onAfterCommit(event);
EventLogRecord rec = captureRecord();
assertEquals(EventLevel.ERROR, rec.getLevel());
assertEquals(LogType.TRANSITION_FAILED.getCode(), rec.getEventType());
assertEquals(LogModule.SECURITY, rec.getModule());
assertEquals("INVALID_TRANSITION", rec.getPayload().get("errorCode"));
assertEquals("IllegalStateException: PENDING → ARRIVED 非法",
rec.getPayload().get("cause"));
}
@Test
void writeRollbackAudit_evenIfEventClaimsSuccess_shouldForceFailure() {
// 即便发布时声明 success=true事务 rollback 就是没真正落库,必须按失败记录
OrderTransitionAttemptedEvent event = OrderTransitionAttemptedEvent.builder()
.orderId(400L)
.orderType("CLEAN")
.fromStatus(WorkOrderStatusEnum.QUEUED)
.targetStatus(WorkOrderStatusEnum.DISPATCHED)
.success(true) // 发布时乐观声明
.attemptedAt(LocalDateTime.now())
.build();
listener.writeRollbackAudit(event);
EventLogRecord rec = captureRecord();
assertEquals(EventLevel.ERROR, rec.getLevel());
assertEquals(LogType.TRANSITION_FAILED.getCode(), rec.getEventType());
assertEquals(Boolean.TRUE, rec.getPayload().get("rolledBack"));
assertEquals(Boolean.FALSE, rec.getPayload().get("success"));
assertTrue(rec.getMessage().contains("状态转换回滚"),
"回滚消息应明确标注,实际: " + rec.getMessage());
}
@Test
void writeRollbackAudit_withForUpdateRejected_stillMapsToDispatchRejected() {
// 回滚 + 冲突错误码 → 依然归类为 DISPATCH_REJECTED而不是 TRANSITION_FAILED
OrderTransitionAttemptedEvent event = OrderTransitionAttemptedEvent.builder()
.orderId(500L)
.orderType("CLEAN")
.fromStatus(WorkOrderStatusEnum.PENDING)
.targetStatus(WorkOrderStatusEnum.DISPATCHED)
.success(false)
.errorCode(TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER)
.attemptedAt(LocalDateTime.now())
.build();
listener.writeRollbackAudit(event);
EventLogRecord rec = captureRecord();
// 冲突型错误即便回滚也应是 WARN + DISPATCH_REJECTED方便运维过滤
assertEquals(EventLevel.WARN, rec.getLevel());
assertEquals(LogType.DISPATCH_REJECTED.getCode(), rec.getEventType());
assertFalse((Boolean) rec.getPayload().get("success"));
}
// ==================== Helpers ====================
private OrderTransitionAttemptedEvent successEvent(Long orderId,
WorkOrderStatusEnum from,
WorkOrderStatusEnum to) {
return OrderTransitionAttemptedEvent.builder()
.orderId(orderId)
.orderType("CLEAN")
.orderCode("WO-" + orderId)
.fromStatus(from)
.targetStatus(to)
.assigneeId(31L)
.operatorType(OperatorTypeEnum.SYSTEM)
.operatorId(31L)
.reason("test")
.success(true)
.attemptedAt(LocalDateTime.now())
.build();
}
private void assertLogTypeForTarget(WorkOrderStatusEnum target, LogType expected) {
org.mockito.Mockito.reset(eventLogRecorder);
listener.onAfterCommit(successEvent(1000L + target.ordinal(),
WorkOrderStatusEnum.PENDING, target));
EventLogRecord rec = captureRecord();
assertEquals(expected.getCode(), rec.getEventType(),
"target=" + target + " 应映射到 " + expected);
}
private EventLogRecord captureRecord() {
ArgumentCaptor<EventLogRecord> captor = ArgumentCaptor.forClass(EventLogRecord.class);
verify(eventLogRecorder).recordSync(captor.capture());
return captor.getValue();
}
}

View File

@@ -0,0 +1,131 @@
package com.viewsh.module.ops.service.queue;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 楼层权重修复的补充测试commit a5f916c
* <p>
* 与 {@link QueueScoreCalculatorTest}(基础行为)互补,覆盖这次改动的三个关键不变量:
* <ol>
* <li><b>G 强楼层优先</b>FLOOR_WEIGHT=10010 层封顶 1000 &gt; aging 封顶 720
* 保证等满 4 小时的任务也不会反超近楼层任务</li>
* <li><b>B 语义对称</b>base 或 target 任一缺失 → floorScore=0不再有"有 base 无 target → +600"罚分</li>
* <li><b>floor 封顶</b>:楼层差超过 MAX_FLOOR_DIFF=10 时按 10 计算</li>
* </ol>
*/
class QueueScoreCalculatorEnhancedTest {
private final QueueScoreCalculator calculator = new QueueScoreCalculator();
@Test
void strongFloorPriority_farLongWaitedTaskShouldNotOvertakeNearJustInTask() {
// G: 同 P1 优先级下,"远楼层+等满 4 小时" 不应反超 "近楼层+刚入队"。
// 近刚入队: priority=1500, floor=0, aging=0 → 1500
// 远等满: priority=1500, floor=10*100=1000, aging=720 → 1780
// near.score (1500) < far.score (1780) → near 先派发,符合"强楼层优先"
LocalDateTime now = LocalDateTime.of(2026, 4, 20, 12, 0);
QueueScoreResult nearJustIn = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(3).targetFloorNo(3)
.enqueueTime(now).now(now)
.build());
QueueScoreResult farLongWaited = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(3).targetFloorNo(13) // diff=10封顶
.enqueueTime(now.minusHours(4)) // aging 封顶 240 min
.now(now)
.build());
assertTrue(nearJustIn.getTotalScore() < farLongWaited.getTotalScore(),
"near=" + nearJustIn.getTotalScore() + " far=" + farLongWaited.getTotalScore()
+ ":远楼层即便等满也不应反超近楼层");
}
@Test
void symmetricNullHandling_baseFloorMissing_shouldGiveZeroFloorScore() {
// B: baseFloor=null执行人位置未知不应被动扣分
LocalDateTime now = LocalDateTime.of(2026, 4, 20, 12, 0);
QueueScoreResult result = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(null).targetFloorNo(5)
.enqueueTime(now).now(now)
.build());
// priorityScore=1500, floorScore=0, aging=0 → 1500
assertEquals(1500.0, result.getTotalScore(), 0.001);
assertNull(result.getFloorDiff(), "floorDiff 应为 null表示楼层信息不完整");
}
@Test
void symmetricNullHandling_targetFloorMissing_shouldGiveZeroFloorScore() {
// B: targetFloor=null工单区域未登记楼层同样应得 floorScore=0——与 baseFloor 缺失等价
LocalDateTime now = LocalDateTime.of(2026, 4, 20, 12, 0);
QueueScoreResult result = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(5).targetFloorNo(null)
.enqueueTime(now).now(now)
.build());
assertEquals(1500.0, result.getTotalScore(), 0.001);
assertNull(result.getFloorDiff());
}
@Test
void symmetricNullHandling_bothTasksWithPartialFloor_shouldSortByAgingOnly() {
// 关键回归:旧逻辑会给"有 base 无 target"+600 罚分,导致同一工单在不同 base 场景排序不单调。
// 现在两个任务楼层信息同等"不完整"应仅靠 aging 排序,"等得久"的排前。
LocalDateTime now = LocalDateTime.of(2026, 4, 20, 12, 0);
QueueScoreResult newerNoTarget = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(5).targetFloorNo(null)
.enqueueTime(now.minusMinutes(5))
.now(now)
.build());
QueueScoreResult olderNoTarget = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(5).targetFloorNo(null)
.enqueueTime(now.minusMinutes(80))
.now(now)
.build());
// 两者 floorScore 都 =0aging 越大分越低 → older 先派
assertTrue(olderNoTarget.getTotalScore() < newerNoTarget.getTotalScore());
}
@Test
void floorCappedAtMaxFloorDiff_evenWhenActualDiffMuchLarger() {
// 楼层差 25 应按 10 封顶floorScore = 10 × 100 = 1000
LocalDateTime now = LocalDateTime.of(2026, 4, 20, 12, 0);
QueueScoreResult result = calculator.calculate(QueueScoreContext.builder()
.priority(1)
.baseFloorNo(0).targetFloorNo(25)
.enqueueTime(now).now(now)
.build());
assertEquals(25, result.getFloorDiff(), "floorDiff 透传原始差值,便于诊断");
// priorityScore=1500 + floorScore(capped)=1000 - aging=0 = 2500
assertEquals(2500.0, result.getTotalScore(), 0.001,
"超过 10 层差应按 10 层封顶计算分数");
}
@Test
void floorWeightShouldDominateAgingCap() {
// 锁死这次改动的核心不变量FLOOR_WEIGHT * MAX_FLOOR_DIFF > AGING_WEIGHT * MAX_AGING_MINUTES
// 即 100 * 10 = 1000 > 3 * 240 = 720
int floorMax = QueueScoreCalculator.FLOOR_WEIGHT * QueueScoreCalculator.MAX_FLOOR_DIFF;
int agingMax = QueueScoreCalculator.AGING_WEIGHT * QueueScoreCalculator.MAX_AGING_MINUTES;
assertTrue(floorMax > agingMax,
"权重失衡floor 封顶 " + floorMax + " 不再压倒 aging 封顶 " + agingMax
+ ",会导致等得久的远楼层任务反超近楼层");
}
}