diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeDeviceStatusEventListener.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeDeviceStatusEventListener.java index a9cc44c5..cd0b7857 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeDeviceStatusEventListener.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeDeviceStatusEventListener.java @@ -15,9 +15,6 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionDefinition; -import org.springframework.transaction.support.TransactionTemplate; /** * 工牌设备状态事件监听器 @@ -87,9 +84,6 @@ public class BadgeDeviceStatusEventListener { @Resource private OrderLifecycleManager orderLifecycleManager; - @Resource - private PlatformTransactionManager transactionManager; - /** * 监听工单状态变更事件,同步更新设备工单关联 *
@@ -180,40 +174,27 @@ public class BadgeDeviceStatusEventListener { /** * 处理工单推送状态(首次设置工单关联) + *
+ * 若 Redis 里检测到旧 orderId(正常业务不应出现),仅打 ERROR 告警并清理 Redis 关联。 + * 此前版本会在此处"自动取消旧工单",但那是对"数据已错乱"场景的暴力兜底: + *
+ * Redis 中 ops:badge:device:{deviceId} 的 nickname 字段可能因 TTL/重启/缓存清理而缺失,
+ * 每次对账时以 IoT 为唯一可信源做回填,避免派单时降级为 deviceCode(如 "43607737587")。
+ */
+ private Map
+ * 职责:
+ * 扫描所有保洁类(order_type=CLEAN)非终态工单,
+ * 若最近一次进展(update_time)距今超过阈值(默认 12 小时),
+ * 以 SYSTEM 身份走正常取消流程将其关闭。
+ *
+ * 设计要点:
+ * 1. 时间基准使用 update_time 而非 create_time——任何状态转换/字段更新都会刷新 update_time,
+ * 这样"按最新进展计算超时"才准确:刚被重派的 DISPATCHED 单不会因 create_time 老而被误杀。
+ * 2. 状态白名单 = PENDING / QUEUED / DISPATCHED / CONFIRMED / ARRIVED(不含 PAUSED)。
+ * PAUSED 是 P0 打断的产物,应由 resumeInterruptedOrder 经状态机走 PAUSED → DISPATCHED
+ * 恢复。若此 Job 把 PAUSED 单直接 CANCELLED,P0 完成后的 resume 会在状态机检查
+ * "PAUSED → DISPATCHED" 时因源状态已变为 CANCELLED 而抛 IllegalStateException,
+ * 进而破坏 P0 恢复链路。PAUSED 若真的卡死(P0 也卡),交由人工审核,不自动化。
+ * 3. 取消调用 {@link OrderLifecycleManager#cancelOrder} 走完整责任链:
+ * StateTransitionHandler → QueueSyncHandler → EventPublishHandler
+ * → CleanOrderEventListener.onOrderStateChanged(CANCELLED) 会统一处理
+ * TTS 停播、设备工单关联回收、审计日志。
+ * 4. 单单独立事务 + try/catch 隔离,单条失败不影响批次其余工单。
+ * 5. 单次扫描限 batchSize 条,防止异常堆积时一次性取消过多触发事件风暴;
+ * 未处理完的工单留给下一轮 cron。
+ * 6. cancel 前再做一次乐观校验:重查 update_time 是否仍 <= threshold。
+ * 候选装内存到实际 cancel 之间如果有用户触达(确认/到岗),update_time 会被刷新;
+ * 此时放弃 cancel,避免误杀用户刚触达的工单。
+ *
+ * XXL-Job 配置建议:
+ * - JobHandler: cleanOrderAutoCancelJob
+ * - Cron: 0 17 * * * ? (每小时 :17 触发,避开整点尖峰)
+ *
+ * @author lzh
+ */
+@Slf4j
+@Component
+public class CleanOrderAutoCancelJob {
+
+ private static final String BUSINESS_TYPE_CLEAN = "CLEAN";
+ private static final String CANCEL_REASON = "超过12小时未处理,系统自动完结";
+
+ @Resource
+ private OpsOrderMapper opsOrderMapper;
+
+ @Resource
+ private OrderLifecycleManager orderLifecycleManager;
+
+ /** 超时时长(小时),update_time 距今超过此值视为卡死 */
+ @Value("${viewsh.ops.clean.auto-cancel.timeout-hours:12}")
+ private int timeoutHours;
+
+ /** 单次最大扫描/取消工单数,防止事件风暴 */
+ @Value("${viewsh.ops.clean.auto-cancel.batch-size:200}")
+ private int batchSize;
+
+ @XxlJob("cleanOrderAutoCancelJob")
+ @TenantJob
+ public String execute() {
+ try {
+ CancelResult result = scanAndCancel();
+ return StrUtil.format(
+ "保洁工单超时自动取消完成: 扫描 {} 单, 成功 {}, 失败 {}, 跳过 {}, 耗时 {} ms",
+ result.scanned, result.succeeded, result.failed, result.skippedStale, result.durationMs);
+ } catch (Exception e) {
+ log.error("[CleanOrderAutoCancelJob] 执行失败", e);
+ return StrUtil.format("保洁工单超时自动取消失败: {}", e.getMessage());
+ }
+ }
+
+ public CancelResult scanAndCancel() {
+ long startTime = System.currentTimeMillis();
+ LocalDateTime threshold = LocalDateTime.now().minusHours(timeoutHours);
+
+ log.info("[CleanOrderAutoCancelJob] 开始扫描: timeoutHours={}, threshold={}, batchSize={}",
+ timeoutHours, threshold, batchSize);
+
+ List
+ * 优先用 nickname;nickname 缺失时(例如 Redis 状态缓存被清理、IoT 侧未维护昵称),
+ * 返回 "工牌-尾号" 这样的可读降级文案,避免把 deviceCode/IMEI 这类长数字串直接当作人员名字暴露给调用方。
+ */
+ private String resolveAssigneeName(BadgeDeviceStatusDTO device) {
+ String nickname = device.getNickname();
+ if (nickname != null && !nickname.isBlank()) {
+ return nickname;
+ }
+ String code = device.getDeviceCode();
+ if (code != null && !code.isBlank()) {
+ int len = code.length();
+ return "工牌-" + (len > 4 ? code.substring(len - 4) : code);
+ }
+ return device.getDeviceId() != null ? "工牌-" + device.getDeviceId() : "未知工牌";
+ }
+
/**
* 选择最佳设备
*/
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/job/CleanOrderAutoCancelJobTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/job/CleanOrderAutoCancelJobTest.java
new file mode 100644
index 00000000..a18dbf22
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/job/CleanOrderAutoCancelJobTest.java
@@ -0,0 +1,198 @@
+package com.viewsh.module.ops.environment.job;
+
+import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager;
+import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
+import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
+import com.viewsh.module.ops.enums.OperatorTypeEnum;
+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 org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 验证 CleanOrderAutoCancelJob 的五条不变量:
+ *
+ * 与 {@link OrderStateChangedEvent} 的区别:
+ *
+ * 注意:这是发布瞬间的判断;如果后续 handler 抛异常导致整个事务 rollback,
+ * 监听器在 {@code AFTER_ROLLBACK} 阶段应强制将其视为失败。
+ */
+ private boolean success;
+
+ /** 失败错误码(success=false 时有值) */
+ private TransitionErrorCode errorCode;
+
+ /** 失败原因(简要消息,success=false 时有值) */
+ private String errorMessage;
+
+ /** 异常摘要(success=false 且存在异常时有值,只保留 class + message,不带堆栈) */
+ private String causeSummary;
+
+ /** 事件时间 */
+ private LocalDateTime attemptedAt;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java
index 3864f49f..4770afa7 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java
@@ -1,5 +1,6 @@
package com.viewsh.module.ops.core.lifecycle;
+import com.viewsh.module.ops.core.event.OrderTransitionAttemptedEvent;
import com.viewsh.module.ops.core.lifecycle.handler.EventPublishHandler;
import com.viewsh.module.ops.core.lifecycle.handler.QueueSyncHandler;
import com.viewsh.module.ops.core.lifecycle.handler.StateTransitionHandler;
@@ -7,6 +8,10 @@ import com.viewsh.module.ops.core.lifecycle.handler.TransitionHandler;
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.TransitionContext;
+import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.time.LocalDateTime;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import com.viewsh.module.ops.enums.OperatorTypeEnum;
@@ -62,6 +67,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
@Resource
private EventLogRecorder eventLogRecorder;
+ @Resource
+ private ApplicationEventPublisher applicationEventPublisher;
+
/**
* 责任链处理器
*/
@@ -101,10 +109,15 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
// 4. 检查结果
if (context.hasError()) {
log.error("状态转换失败: orderId={}, error={}", order.getId(), context.getErrorMessage());
+ publishAttempt(order, oldStatus, request, false,
+ TransitionErrorCode.INVALID_TRANSITION,
+ context.getErrorMessage(),
+ summarizeThrowable(context.getCause()));
return OrderTransitionResult.fail(order.getId(), context.getErrorMessage());
}
log.info("状态转换成功: orderId={}, {} -> {}", order.getId(), oldStatus, request.getTargetStatus());
+ publishAttempt(order, oldStatus, request, true, null, null, null);
return OrderTransitionResult.builder()
.success(true)
.orderId(order.getId())
@@ -142,6 +155,35 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
// 设置目标状态
request.setTargetStatus(WorkOrderStatusEnum.DISPATCHED);
+ // 业务不变量:同一执行人在任一时刻最多只能有 1 条活跃工单
+ // (DISPATCHED/CONFIRMED/ARRIVED)。PAUSED 不纳入——P0 打断恢复走的就是
+ // PAUSED→DISPATCHED,此处放行。对命中行加 FOR UPDATE,配合 @Transactional
+ // 串行化并发派发;命中则本次派发被拒,由调用方决定降级策略
+ // (DispatchEngineImpl.executeDirectDispatch 会降级为入队)。
+ if (request.getAssigneeId() != null) {
+ java.util.List
+ * 闭环设计:
+ *
+ * 字段归位:
+ *
+ * fallbackExecution=true:在无事务上下文时也执行(如测试、跨线程补写场景)。
+ */
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+ public void onAfterCommit(OrderTransitionAttemptedEvent event) {
+ try {
+ eventLogRecorder.recordSync(toRecord(event, /*rolledBack=*/false));
+ } catch (Exception e) {
+ log.error("[TransitionAudit] AFTER_COMMIT 写 bus_log 失败: orderId={}, success={}, errorCode={}",
+ event.getOrderId(), event.isSuccess(), event.getErrorCode(), e);
+ }
+ }
+
+ /**
+ * 主事务已回滚:无论事件里声称 success 与否,这次"尝试"都**实际未落库**。
+ * 必须开独立事务写 bus_log,否则日志也会因同事务回滚而丢失。
+ */
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
+ public void onAfterRollback(OrderTransitionAttemptedEvent event) {
+ writeRollbackAudit(event);
+ }
+
+ /**
+ * 写入"事务已回滚"的审计记录。
+ *
+ * 不加 @Transactional:AFTER_ROLLBACK 阶段主事务已彻底结束,当前线程无活跃事务;
+ * 且本方法由 onAfterRollback 自调用,Spring 代理不会拦截,加注解也是死注解。
+ * 实际行为:eventLogRecorder.recordSync 的 insert 在 auto-commit 模式下单条提交,
+ * 失败只丢这一行审计、不影响主业务(主业务早已回滚并报错给调用方)。
+ */
+ public void writeRollbackAudit(OrderTransitionAttemptedEvent event) {
+ try {
+ eventLogRecorder.recordSync(toRecord(event, /*rolledBack=*/true));
+ } catch (Exception e) {
+ log.error("[TransitionAudit] AFTER_ROLLBACK 写 bus_log 失败: orderId={}, targetStatus={}",
+ event.getOrderId(), event.getTargetStatus(), e);
+ }
+ }
+
+ // ==================== 私有映射方法 ====================
+
+ private EventLogRecord toRecord(OrderTransitionAttemptedEvent event, boolean rolledBack) {
+ // rolledBack=true 时强制视为失败:即便发布时声明 success=true,
+ // 事务 rollback 说明写入未真正生效。
+ boolean success = event.isSuccess() && !rolledBack;
+
+ EventLevel level = success ? EventLevel.INFO
+ : (event.getErrorCode() == TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER
+ ? EventLevel.WARN : EventLevel.ERROR);
+
+ String eventTypeCode = resolveEventTypeCode(event, success, rolledBack);
+
+ Map
+ * 调用方可据此区分需降级的失败(如 ASSIGNEE_HAS_ACTIVE_ORDER)与硬失败,
+ * 未显式设置时默认为 {@link TransitionErrorCode#OTHER}。
+ */
+ private TransitionErrorCode errorCode;
+
/**
* 成功结果
*/
@@ -81,6 +89,7 @@ public class OrderTransitionResult {
return OrderTransitionResult.builder()
.success(false)
.message(message)
+ .errorCode(TransitionErrorCode.OTHER)
.build();
}
@@ -92,6 +101,19 @@ public class OrderTransitionResult {
.success(false)
.orderId(orderId)
.message(message)
+ .errorCode(TransitionErrorCode.OTHER)
+ .build();
+ }
+
+ /**
+ * 失败结果(带工单ID 和错误码)
+ */
+ public static OrderTransitionResult fail(Long orderId, String message, TransitionErrorCode errorCode) {
+ return OrderTransitionResult.builder()
+ .success(false)
+ .orderId(orderId)
+ .message(message)
+ .errorCode(errorCode)
.build();
}
}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/model/TransitionErrorCode.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/model/TransitionErrorCode.java
new file mode 100644
index 00000000..0ae7fac1
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/model/TransitionErrorCode.java
@@ -0,0 +1,35 @@
+package com.viewsh.module.ops.core.lifecycle.model;
+
+/**
+ * 状态转换失败的错误码
+ *
+ * 用于调用方区分可恢复/需降级的失败场景(如并发冲突)与真正的硬失败(状态机非法转换等),
+ * 避免把"可降级"的结果误当成硬错误直接向用户暴露。
+ *
+ * @author lzh
+ */
+public enum TransitionErrorCode {
+
+ /**
+ * 执行人已有活跃工单(DISPATCHED/CONFIRMED/ARRIVED),不应再派发。
+ *
+ * 发生在 OrderLifecycleManager.dispatch 入口的 FOR UPDATE 兜底检查命中时。
+ * 调用方应将工单降级到 QUEUED(入队等待下一轮动态派发),避免 PENDING 状态悬空。
+ */
+ ASSIGNEE_HAS_ACTIVE_ORDER,
+
+ /**
+ * 状态机不允许此转换(非法的状态流转)
+ */
+ INVALID_TRANSITION,
+
+ /**
+ * 工单不存在
+ */
+ ORDER_NOT_FOUND,
+
+ /**
+ * 其他失败(无特定归类)
+ */
+ OTHER;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/queue/OpsOrderQueueMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/queue/OpsOrderQueueMapper.java
index f6ed6f62..a25c207d 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/queue/OpsOrderQueueMapper.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/queue/OpsOrderQueueMapper.java
@@ -54,7 +54,7 @@ public interface OpsOrderQueueMapper extends BaseMapperX
+ * 同步到 Redis、计算队列长度、查询当前任务等场景应走此方法,避免
+ * 将历史 REMOVED 记录同步到 Redis 造成 ZSet / Hash 膨胀。
+ */
+ default List
+ * 用于 autoDispatchNext 等调度入口的空闲校验:若该执行人仍挂着活跃工单,
+ * 则不应再派发新任务,避免"越清越多"的级联派发。
+ *
+ * @param assigneeId 执行人ID(工牌设备ID)
+ * @param excludeOrderId 需要排除的工单ID(通常是刚完成/取消触发本次调度的工单),可传 null
+ * @return 活跃工单列表,按创建时间升序
+ */
+ default List
+ * 与 {@link #selectActiveByAssignee} 的区别:
+ *
+ * 用途:{@code OrderQueueServiceEnhanced.resolveBaselineAreaId} 的二级兜底。
+ * 当执行人当前没有 PROCESSING 工单时(短暂空闲),用最近完成的那一单的
+ * 区域作为"物理位置推断",保证楼层差评分在空闲期仍然生效。
+ *
+ * 时间窗:通过 {@code since} 过滤,超过窗口仍空闲则认为轨迹失效,
+ * 返回 null 让调用方降级到更外层的兜底(fallbackAreaId 或无楼层模式)。
+ *
+ * @param assigneeId 执行人ID
+ * @param since 只考虑 updateTime 晚于此时间的工单(如 now - 24h)
+ * @return 最近一条 COMPLETED 工单的 areaId;无匹配返回 null
+ */
+ default Long selectLatestCompletedAreaIdByAssignee(Long assigneeId, LocalDateTime since) {
+ OpsOrderDO order = selectOne(new LambdaQueryWrapperX
+ * 需要确保日志真正落库的场景使用(如 AFTER_COMMIT 审计、事务回滚场景补写)。
+ * 调用方负责事务边界:本方法内部不开启事务,MyBatis 的 insert 会按当前线程的
+ * 事务上下文执行;若无活跃事务则自动单条提交。
+ *
+ * @param record 日志记录
+ */
+ void recordSync(EventLogRecord record);
+
// ==================== 便捷方法:按级别记录 ====================
/**
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java
index ea12055b..92bab9f0 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java
@@ -58,6 +58,7 @@ public class EventLogRecorderImpl implements EventLogRecorder {
*
* 用于需要确认日志写入成功的场景(如测试、关键业务)
*/
+ @Override
public void recordSync(EventLogRecord record) {
doRecord(record);
}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java
index aeb3fc2b..216a37a2 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/OrderQueueServiceEnhanced.java
@@ -115,6 +115,9 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
// TODO: 触发紧急派单流程(在派单引擎中实现)
}
+ // 5. 事务提交后按全局楼层重排一次:新入队工单立即按楼层差参与排序,不等下一次 rebuild
+ triggerQueueRebuildAfterCommit(userId, null);
+
return queueDO.getId();
}
@@ -467,7 +470,8 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
}
// 2. Redis 未命中,从 MySQL 获取并同步到 Redis
- List
+ * 事务边界说明:本方法在 afterCommit 阶段(即外层事务已提交)自调用
+ * {@link #rebuildWaitingTasksByUserId(Long, Long)},此时:
+ *
+ * 背景:OrderLifecycleManager.dispatch 入口加了 selectActiveByAssigneeForUpdate 行锁,
+ * 命中时返 {@link TransitionErrorCode#ASSIGNEE_HAS_ACTIVE_ORDER}。
+ * DispatchEngine 需按工单当前状态分支处理:
+ *
+ * 产线事故:管理员 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);
+ }
+
+ @Test
+ void autoDispatchNext_whenDispatchingFromQueue_shouldGoThroughDispatchNotTransition() {
+ // 锁死 P1 修复:从队列派发必须走 dispatch(),以继承 Bug #2 的 FOR UPDATE 串行化防线。
+ // 如果未来有人改回 transition(),本测试会红:autoDispatchNext 绕过 FOR UPDATE 的漏洞就回来了。
+ Long completedOrderId = 700L;
+ Long waitingOrderId = 701L;
+ Long queueId = 800L;
+
+ when(orderMapper.selectActiveByAssignee(ASSIGNEE_ID, completedOrderId)).thenReturn(List.of());
+ when(orderMapper.selectById(completedOrderId)).thenReturn(OpsOrderDO.builder()
+ .id(completedOrderId).areaId(501L).build());
+ OrderQueueDTO waitingDTO = new OrderQueueDTO();
+ waitingDTO.setId(queueId);
+ waitingDTO.setOpsOrderId(waitingOrderId);
+ waitingDTO.setQueueScore(1000.0);
+ waitingDTO.setFloorDiff(1);
+ waitingDTO.setWaitMinutes(2L);
+ when(orderQueueService.rebuildWaitingTasksByUserId(ASSIGNEE_ID, 501L))
+ .thenReturn(List.of(waitingDTO));
+ when(orderMapper.selectById(waitingOrderId)).thenReturn(OpsOrderDO.builder()
+ .id(waitingOrderId)
+ .status(WorkOrderStatusEnum.QUEUED.getStatus())
+ .build());
+ when(orderLifecycleManager.dispatch(any(OrderTransitionRequest.class)))
+ .thenReturn(OrderTransitionResult.success(waitingOrderId,
+ WorkOrderStatusEnum.QUEUED, WorkOrderStatusEnum.DISPATCHED));
+
+ DispatchResult result = dispatchEngine.autoDispatchNext(completedOrderId, ASSIGNEE_ID);
+
+ assertTrue(result.isSuccess());
+ assertEquals("已按队列总分派发下一单", result.getMessage());
+ // 关键断言:必须调 dispatch()(带 FOR UPDATE)而不是 transition()(裸责任链)
+ verify(orderLifecycleManager).dispatch(any(OrderTransitionRequest.class));
+ verify(orderLifecycleManager, never()).transition(any(OrderTransitionRequest.class));
+ }
+}
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:状态转换审计闭环。
+ *
+ * 三条路径:
+ *
+ * 与 {@link QueueScoreCalculatorTest}(基础行为)互补,覆盖这次改动的三个关键不变量:
+ * > result = iotDeviceQueryApi.batchGetDevices(deviceIds);
+ if (!result.isSuccess() || CollUtil.isEmpty(result.getData())) {
+ log.warn("[SyncJob] 查询设备昵称失败或为空: {}", result.getMsg());
+ return Collections.emptyMap();
+ }
+ Map
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+class CleanOrderAutoCancelJobTest {
+
+ @Mock
+ private OpsOrderMapper opsOrderMapper;
+ @Mock
+ private OrderLifecycleManager orderLifecycleManager;
+
+ @InjectMocks
+ private CleanOrderAutoCancelJob job;
+
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(job, "timeoutHours", 12);
+ ReflectionTestUtils.setField(job, "batchSize", 200);
+ }
+
+ @Test
+ void scanAndCancel_whenNoCandidates_shouldReturnZeroCounts() {
+ when(opsOrderMapper.selectList(any(com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX.class)))
+ .thenReturn(Collections.emptyList());
+
+ CleanOrderAutoCancelJob.CancelResult result = job.scanAndCancel();
+
+ assertEquals(0, result.scanned());
+ assertEquals(0, result.succeeded());
+ assertEquals(0, result.failed());
+ assertEquals(0, result.skippedStale());
+ verify(orderLifecycleManager, never()).cancelOrder(anyLong(), any(), any(), any());
+ }
+
+ @Test
+ void scanAndCancel_whenAllCandidatesStillStale_shouldCancelAll() {
+ LocalDateTime staleTime = LocalDateTime.now().minusHours(13);
+ OpsOrderDO a = stale(101L, "WO-101", WorkOrderStatusEnum.DISPATCHED, staleTime);
+ OpsOrderDO b = stale(102L, "WO-102", WorkOrderStatusEnum.CONFIRMED, staleTime);
+ OpsOrderDO c = stale(103L, "WO-103", WorkOrderStatusEnum.ARRIVED, staleTime);
+
+ when(opsOrderMapper.selectList(any(com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX.class)))
+ .thenReturn(List.of(a, b, c));
+ // Fresh fetch confirms all three are still stale
+ when(opsOrderMapper.selectById(101L)).thenReturn(a);
+ when(opsOrderMapper.selectById(102L)).thenReturn(b);
+ when(opsOrderMapper.selectById(103L)).thenReturn(c);
+
+ CleanOrderAutoCancelJob.CancelResult result = job.scanAndCancel();
+
+ assertEquals(3, result.scanned());
+ assertEquals(3, result.succeeded());
+ assertEquals(0, result.failed());
+ assertEquals(0, result.skippedStale());
+ verify(orderLifecycleManager, times(3))
+ .cancelOrder(anyLong(), eq(null), eq(OperatorTypeEnum.SYSTEM), any());
+ }
+
+ @Test
+ void scanAndCancel_whenOneCancelThrows_shouldNotAbortBatch() {
+ LocalDateTime staleTime = LocalDateTime.now().minusHours(13);
+ OpsOrderDO a = stale(201L, "WO-201", WorkOrderStatusEnum.DISPATCHED, staleTime);
+ OpsOrderDO b = stale(202L, "WO-202", WorkOrderStatusEnum.CONFIRMED, staleTime);
+ OpsOrderDO c = stale(203L, "WO-203", WorkOrderStatusEnum.ARRIVED, staleTime);
+
+ when(opsOrderMapper.selectList(any(com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX.class)))
+ .thenReturn(List.of(a, b, c));
+ when(opsOrderMapper.selectById(201L)).thenReturn(a);
+ when(opsOrderMapper.selectById(202L)).thenReturn(b);
+ when(opsOrderMapper.selectById(203L)).thenReturn(c);
+ // 第二条取消抛异常,不应影响第一、第三条。
+ // 不能用 doThrow(...).when(mock).cancelOrder(eq(202L), ...)——strict stubs 会把"201L 调用和 202L 存根不匹配"判成错配。
+ // 改用 doAnswer 按 orderId 路由,覆盖所有 cancel 调用。
+ doAnswer(invocation -> {
+ Long orderId = invocation.getArgument(0);
+ if (orderId != null && orderId == 202L) {
+ throw new IllegalStateException("状态机非法转换");
+ }
+ return null;
+ }).when(orderLifecycleManager).cancelOrder(anyLong(), any(), any(), any());
+
+ CleanOrderAutoCancelJob.CancelResult result = job.scanAndCancel();
+
+ assertEquals(3, result.scanned());
+ assertEquals(2, result.succeeded());
+ assertEquals(1, result.failed());
+ assertEquals(0, result.skippedStale());
+ verify(orderLifecycleManager).cancelOrder(eq(201L), any(), any(), any());
+ verify(orderLifecycleManager).cancelOrder(eq(202L), any(), any(), any());
+ verify(orderLifecycleManager).cancelOrder(eq(203L), any(), any(), any());
+ }
+
+ @Test
+ void scanAndCancel_whenOrderTouchedBeforeCancel_shouldSkipAsStale() {
+ // 候选装内存时 update_time=13h ago,实际 cancel 前用户刚刚点确认,update_time 刷为"1 分钟前"。
+ // 乐观校验应跳过,避免误杀已被触达的工单。
+ LocalDateTime snapshotUpdate = LocalDateTime.now().minusHours(13);
+ LocalDateTime freshUpdate = LocalDateTime.now().minusMinutes(1);
+
+ OpsOrderDO snapshot = stale(301L, "WO-301", WorkOrderStatusEnum.DISPATCHED, snapshotUpdate);
+ OpsOrderDO fresh = stale(301L, "WO-301", WorkOrderStatusEnum.CONFIRMED, freshUpdate);
+
+ when(opsOrderMapper.selectList(any(com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX.class)))
+ .thenReturn(List.of(snapshot));
+ when(opsOrderMapper.selectById(301L)).thenReturn(fresh);
+
+ CleanOrderAutoCancelJob.CancelResult result = job.scanAndCancel();
+
+ assertEquals(1, result.scanned());
+ assertEquals(0, result.succeeded());
+ assertEquals(1, result.skippedStale());
+ verify(orderLifecycleManager, never()).cancelOrder(anyLong(), any(), any(), any());
+ }
+
+ @Test
+ void scanAndCancel_whenOrderBecameTerminal_shouldSkip() {
+ // 候选装内存时还是 ARRIVED,实际 cancel 前已被其他路径 forceComplete 为 COMPLETED
+ LocalDateTime staleTime = LocalDateTime.now().minusHours(13);
+ OpsOrderDO snapshot = stale(401L, "WO-401", WorkOrderStatusEnum.ARRIVED, staleTime);
+ OpsOrderDO fresh = stale(401L, "WO-401", WorkOrderStatusEnum.COMPLETED, staleTime);
+
+ when(opsOrderMapper.selectList(any(com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX.class)))
+ .thenReturn(List.of(snapshot));
+ when(opsOrderMapper.selectById(401L)).thenReturn(fresh);
+
+ CleanOrderAutoCancelJob.CancelResult result = job.scanAndCancel();
+
+ assertEquals(1, result.skippedStale());
+ verify(orderLifecycleManager, never()).cancelOrder(anyLong(), any(), any(), any());
+ }
+
+ @Test
+ void scanAndCancel_whenOrderBecamePaused_shouldSkip() {
+ // 快照是 DISPATCHED,刚被 P0 打断成 PAUSED——此 Job 应放行给 resumeInterruptedOrder
+ LocalDateTime staleTime = LocalDateTime.now().minusHours(13);
+ OpsOrderDO snapshot = stale(501L, "WO-501", WorkOrderStatusEnum.DISPATCHED, staleTime);
+ OpsOrderDO fresh = stale(501L, "WO-501", WorkOrderStatusEnum.PAUSED,
+ LocalDateTime.now().minusHours(14)); // update_time 刚刷新,但仍<=threshold;状态变 PAUSED 就该跳过
+
+ when(opsOrderMapper.selectList(any(com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX.class)))
+ .thenReturn(List.of(snapshot));
+ when(opsOrderMapper.selectById(501L)).thenReturn(fresh);
+
+ CleanOrderAutoCancelJob.CancelResult result = job.scanAndCancel();
+
+ assertEquals(1, result.skippedStale());
+ verify(orderLifecycleManager, never()).cancelOrder(anyLong(), any(), any(), any());
+ }
+
+ // ==================== Helpers ====================
+
+ private OpsOrderDO stale(Long id, String code, WorkOrderStatusEnum status, LocalDateTime updateTime) {
+ OpsOrderDO order = OpsOrderDO.builder()
+ .id(id)
+ .orderCode(code)
+ .status(status.getStatus())
+ .orderType("CLEAN")
+ .build();
+ order.setUpdateTime(updateTime);
+ return order;
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java
index 48ab3741..b65f4582 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java
@@ -9,6 +9,7 @@ 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.OperatorTypeEnum;
@@ -178,6 +179,22 @@ public class DispatchEngineImpl implements DispatchEngine {
public DispatchResult autoDispatchNext(Long completedOrderId, Long assigneeId) {
log.info("任务完成后自动派发下一单: completedOrderId={}, assigneeId={}", completedOrderId, assigneeId);
+ if (assigneeId == null) {
+ log.warn("autoDispatchNext 缺少执行人,跳过派发: completedOrderId={}", completedOrderId);
+ return DispatchResult.success("缺少执行人,跳过派发", null);
+ }
+
+ // 空闲校验:若执行人仍挂着其他活跃工单(DISPATCHED/CONFIRMED/ARRIVED/PAUSED),
+ // 说明设备尚未真正空闲,不应再派发新任务——否则会触发"同一设备并行多单"的状态错乱,
+ // 典型场景是管理员手动取消一个僵尸 DISPATCHED 单时,handleCancelled 会调到这里。
+ List
+ *
+ * 事务边界:
+ *
+ *
+ *
+ * @author lzh
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderTransitionAttemptedEvent {
+
+ /** 工单ID */
+ private Long orderId;
+
+ /** 工单类型(CLEAN / SECURITY / REPAIR / SERVICE) */
+ private String orderType;
+
+ /** 工单编号(冗余,便于日志检索) */
+ private String orderCode;
+
+ /** 原状态(查询时的当前状态) */
+ private WorkOrderStatusEnum fromStatus;
+
+ /** 目标状态 */
+ private WorkOrderStatusEnum targetStatus;
+
+ /** 执行人ID */
+ private Long assigneeId;
+
+ /** 操作人类型 */
+ private OperatorTypeEnum operatorType;
+
+ /** 操作人ID */
+ private Long operatorId;
+
+ /** 原因/备注 */
+ private String reason;
+
+ /**
+ * 发布时的"声明结果"。
+ *
+ *
+ *
+ *
+ *
+ * @author lzh
+ */
+@Slf4j
+@Component
+public class OrderTransitionAuditListener {
+
+ @Resource
+ private EventLogRecorder eventLogRecorder;
+
+ /**
+ * 主事务已提交:照事件声明写一条审计日志。
+ *
+ *
+ * 必须在事务中调用,否则锁无意义。
+ *
+ * @param assigneeId 执行人ID
+ * @param excludeOrderId 排除的工单ID(通常是本次正在派发的工单本身)
+ * @return 命中的活跃工单列表(通常空列表表示可派发)
+ */
+ default List
+ *
+ * 都未命中则返回 null,本次排序降级为无楼层模式。
+ */
private Long resolveBaselineAreaId(Long userId, Long fallbackAreaId) {
+ // 一级:当前正在执行的工单
OpsOrderQueueDO processingQueue = orderQueueMapper.selectCurrentExecutingByUserId(userId);
if (processingQueue != null) {
Long processingAreaId = resolveOrderAreaId(processingQueue.getOpsOrderId());
@@ -747,6 +782,13 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
return processingAreaId;
}
}
+ // 二级:最近 24 小时内的已完成工单,推断保洁员当前物理位置
+ Long recentAreaId = orderMapper.selectLatestCompletedAreaIdByAssignee(
+ userId, LocalDateTime.now().minusHours(24));
+ if (recentAreaId != null) {
+ return recentAreaId;
+ }
+ // 三级:调用方提示的区域(可为 null)
return fallbackAreaId;
}
@@ -764,7 +806,8 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
}
private void syncUserQueueToRedis(Long userId, List
+ *
+ * 后果:rebuild 中途抛异常时 MySQL 可能半更新、Redis 可能部分写入,
+ * 不强一致但最终一致——下一次 enqueue 会再触发一次完整 rebuild 自愈。
+ * 对“队列排序”这类可重放数据可以接受;若未来改为影响 MySQL 外表的写入,
+ * 需要把 rebuild 抽到独立 bean,用代理调用走新事务。
+ */
private void triggerQueueRebuildAfterCommit(Long userId, Long fallbackAreaId) {
Runnable rebuildAction = () -> {
try {
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java
index 5e22b1f3..bd161f51 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/queue/QueueScoreCalculator.java
@@ -9,7 +9,11 @@ import java.time.LocalDateTime;
public class QueueScoreCalculator {
static final int PRIORITY_WEIGHT = 1500;
- static final int FLOOR_WEIGHT = 60;
+ /**
+ * 楼层差权重。10 层封顶 × 100 = 1000,大于 aging 上限 720,实现"强楼层优先":
+ * 等满 4 小时(aging 上限)的任务也不会反超更近楼层的同优先级任务。
+ */
+ static final int FLOOR_WEIGHT = 100;
static final int AGING_WEIGHT = 3;
static final int MAX_FLOOR_DIFF = 10;
static final int MAX_AGING_MINUTES = 240;
@@ -22,11 +26,11 @@ public class QueueScoreCalculator {
Integer targetFloorNo = context.getTargetFloorNo();
Integer floorDiff = null;
int floorDiffScore = 0;
+ // 语义对称:只要 baseFloor 或 targetFloor 任一缺失,就视为"信息不足",不参与楼层排序(score=0)。
+ // 旧逻辑会在"有 base 无 target"时打 +600 罚分,导致同一工单在保洁员忙碌/空闲时排序不单调。
if (baseFloorNo != null && targetFloorNo != null) {
floorDiff = Math.abs(targetFloorNo - baseFloorNo);
floorDiffScore = Math.min(floorDiff, MAX_FLOOR_DIFF) * FLOOR_WEIGHT;
- } else if (baseFloorNo != null) {
- floorDiffScore = MAX_FLOOR_DIFF * FLOOR_WEIGHT;
}
long waitMinutes = 0;
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/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 并发冲突降级路径。
+ *
+ *
+ */
+@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..4c04ac10
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/core/dispatch/DispatchEngineIdleCheckTest.java
@@ -0,0 +1,264 @@
+package com.viewsh.module.ops.core.dispatch;
+
+import com.viewsh.module.ops.api.queue.OrderQueueDTO;
+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 前置检查)。
+ *
+ *
+ */
+@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
+ *
+ */
+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
+ + ",会导致等得久的远楼层任务反超近楼层");
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application.yaml b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application.yaml
index 3b48f1ea..b72e80c4 100644
--- a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application.yaml
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application.yaml
@@ -146,6 +146,12 @@ viewsh:
connect-timeout: 5000
read-timeout: 10000
max-retry: 2
+ clean:
+ auto-cancel:
+ # 保洁工单 update_time 距今超过此小时数视为卡死,由 CleanOrderAutoCancelJob 自动取消
+ timeout-hours: 12
+ # 单次扫描/取消上限,防止事件风暴;超出的工单留给下一轮 cron
+ batch-size: 200
# API 签名配置:外部系统调用开放接口时使用(如安保工单的告警系统)
signature:
apps: