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:
@@ -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 并发冲突降级路径。
|
||||
* <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());
|
||||
}
|
||||
}
|
||||
@@ -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 前置检查)。
|
||||
* <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 #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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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=100,10 层封顶 1000 > 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 都 =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
|
||||
+ ",会导致等得久的远楼层任务反超近楼层");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user