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 需按工单当前状态分支处理:
+ *
+ * - PENDING(从未入队)→ 直接降级为入队,避免悬空
+ * - QUEUED(已在队列)→ 保留排队,不做重复入队
+ * - 其他错误码(如 INVALID_TRANSITION)→ 硬失败,不走降级
+ *
+ */
+@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:状态转换审计闭环。
+ *
+ * 三条路径:
+ *
+ * - AFTER_COMMIT + 成功 → INFO + 业务 LogType(如 ORDER_DISPATCHED)
+ * - AFTER_COMMIT + 并发冲突失败 → WARN + DISPATCH_REJECTED
+ * - AFTER_COMMIT + 一般失败 → ERROR + TRANSITION_FAILED
+ * - AFTER_ROLLBACK → 无论事件声明 success 与否都视为失败(事务已回滚),独立事务补写
+ *
+ */
+@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}(基础行为)互补,覆盖这次改动的三个关键不变量:
+ *
+ * - G 强楼层优先:FLOOR_WEIGHT=100,10 层封顶 1000 > aging 封顶 720,
+ * 保证等满 4 小时的任务也不会反超近楼层任务
+ * - B 语义对称:base 或 target 任一缺失 → floorScore=0,不再有"有 base 无 target → +600"罚分
+ * - floor 封顶:楼层差超过 MAX_FLOOR_DIFF=10 时按 10 计算
+ *
+ */
+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
+ + ",会导致等得久的远楼层任务反超近楼层");
+ }
+}