diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineConflictFallbackTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineConflictFallbackTest.java new file mode 100644 index 00000000..12a3fd50 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineConflictFallbackTest.java @@ -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 #2:FOR UPDATE 并发冲突降级路径。 + *

+ * 背景:OrderLifecycleManager.dispatch 入口加了 selectActiveByAssigneeForUpdate 行锁, + * 命中时返 {@link TransitionErrorCode#ASSIGNEE_HAS_ACTIVE_ORDER}。 + * DispatchEngine 需按工单当前状态分支处理: + *

+ */ +@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()); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineIdleCheckTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineIdleCheckTest.java new file mode 100644 index 00000000..ba2d36f9 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineIdleCheckTest.java @@ -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 #1(autoDispatchNext 空闲兜底)+ Bug #4(executeDispatch 前置检查)。 + *

+ * 产线事故:管理员 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 #4:Redis 说设备空闲,但 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); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListenerTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListenerTest.java new file mode 100644 index 00000000..246d8d72 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListenerTest.java @@ -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:状态转换审计闭环。 + *

+ * 三条路径: + *

    + *
  1. AFTER_COMMIT + 成功 → INFO + 业务 LogType(如 ORDER_DISPATCHED)
  2. + *
  3. AFTER_COMMIT + 并发冲突失败 → WARN + DISPATCH_REJECTED
  4. + *
  5. AFTER_COMMIT + 一般失败 → ERROR + TRANSITION_FAILED
  6. + *
  7. AFTER_ROLLBACK → 无论事件声明 success 与否都视为失败(事务已回滚),独立事务补写
  8. + *
+ */ +@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 captor = ArgumentCaptor.forClass(EventLogRecord.class); + verify(eventLogRecorder).recordSync(captor.capture()); + return captor.getValue(); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorEnhancedTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorEnhancedTest.java new file mode 100644 index 00000000..1604bc19 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/queue/QueueScoreCalculatorEnhancedTest.java @@ -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)。 + *

+ * 与 {@link QueueScoreCalculatorTest}(基础行为)互补,覆盖这次改动的三个关键不变量: + *

    + *
  1. G 强楼层优先:FLOOR_WEIGHT=100,10 层封顶 1000 > aging 封顶 720, + * 保证等满 4 小时的任务也不会反超近楼层任务
  2. + *
  3. B 语义对称:base 或 target 任一缺失 → floorScore=0,不再有"有 base 无 target → +600"罚分
  4. + *
  5. floor 封顶:楼层差超过 MAX_FLOOR_DIFF=10 时按 10 计算
  6. + *
+ */ +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 都 =0,aging 越大分越低 → 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 + + ",会导致等得久的远楼层任务反超近楼层"); + } +}