Merge branch 'master' into feat/multi-tenant
吸收 master 今日 9 个工单链路修复: - autoDispatchNext/dispatch 空闲兜底 + FOR UPDATE 并发防护 - 状态转换审计闭环(AFTER_COMMIT/AFTER_ROLLBACK) - 队列楼层权重强优先 + 三级 baseline 兜底 + N+1 优化 - 工牌 nickname 回填 - CleanOrderAutoCancelJob 超时工单自动取消
This commit is contained in:
@@ -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;
|
||||
|
||||
/**
|
||||
* 监听工单状态变更事件,同步更新设备工单关联
|
||||
* <p>
|
||||
@@ -180,40 +174,27 @@ public class BadgeDeviceStatusEventListener {
|
||||
|
||||
/**
|
||||
* 处理工单推送状态(首次设置工单关联)
|
||||
* <p>
|
||||
* 若 Redis 里检测到旧 orderId(正常业务不应出现),仅打 ERROR 告警并清理 Redis 关联。
|
||||
* 此前版本会在此处"自动取消旧工单",但那是对"数据已错乱"场景的暴力兜底:
|
||||
* <ul>
|
||||
* <li>取消使用 REQUIRES_NEW 独立事务且吞异常,失败时新单照常落地,旧单残留,形成越清越多</li>
|
||||
* <li>真正的防线应在 DispatchEngine.autoDispatchNext 入口做设备空闲校验</li>
|
||||
* </ul>
|
||||
* 现改为被动告警,暴露问题等待定位,避免误杀保洁员正在执行的任务。
|
||||
*/
|
||||
private void handleDispatched(Long deviceId, Long orderId, OpsOrderDO order) {
|
||||
// 检查并清理旧工单(防止工单切换时状态残留)
|
||||
BadgeDeviceStatusDTO deviceStatus = badgeDeviceStatusService.getBadgeStatus(deviceId);
|
||||
if (deviceStatus != null && deviceStatus.getCurrentOpsOrderId() != null) {
|
||||
Long oldOrderId = deviceStatus.getCurrentOpsOrderId();
|
||||
if (!oldOrderId.equals(orderId)) {
|
||||
log.warn("[BadgeDeviceStatusEventListener] 派发新工单时检测到旧工单残留: " +
|
||||
"deviceId={}, oldOrderId={}, newOrderId={}", deviceId, oldOrderId, orderId);
|
||||
|
||||
// 检查旧工单是否仍在进行中,如果是则先取消
|
||||
OpsOrderDO oldOrder = opsOrderMapper.selectById(oldOrderId);
|
||||
if (oldOrder != null) {
|
||||
WorkOrderStatusEnum oldStatus = WorkOrderStatusEnum.fromStatus(oldOrder.getStatus());
|
||||
if (oldStatus == WorkOrderStatusEnum.DISPATCHED
|
||||
|| oldStatus == WorkOrderStatusEnum.CONFIRMED
|
||||
|| oldStatus == WorkOrderStatusEnum.ARRIVED) {
|
||||
// 旧工单仍在进行,先取消
|
||||
// 使用 REQUIRES_NEW 独立事务,避免内层异常标记外层事务 rollback-only
|
||||
log.warn("[BadgeDeviceStatusEventListener] 取消残留的旧工单: oldOrderId={}", oldOrderId);
|
||||
try {
|
||||
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
|
||||
txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
txTemplate.executeWithoutResult(status -> {
|
||||
orderLifecycleManager.cancelOrder(oldOrderId, deviceId,
|
||||
OperatorTypeEnum.SYSTEM, "新工单派发,自动取消旧工单");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[BadgeDeviceStatusEventListener] 取消旧工单失败: oldOrderId={}", oldOrderId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
String oldStatus = oldOrder != null ? oldOrder.getStatus() : "NOT_FOUND";
|
||||
log.error("[BadgeDeviceStatusEventListener] 派发新工单时检测到旧工单残留(数据可能已错乱,需人工核查): " +
|
||||
"deviceId={}, oldOrderId={}, oldStatus={}, newOrderId={}",
|
||||
deviceId, oldOrderId, oldStatus, orderId);
|
||||
|
||||
// 确保设备状态清理(无论旧工单是否取消成功)
|
||||
// 清理 Redis 中对旧工单的关联(纯 Redis 操作,不触达状态机)
|
||||
badgeDeviceStatusService.clearCurrentOrder(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.viewsh.framework.common.pojo.CommonResult;
|
||||
import com.viewsh.framework.tenant.core.job.TenantJob;
|
||||
import com.viewsh.module.iot.api.device.IotDeviceQueryApi;
|
||||
import com.viewsh.module.iot.api.device.IotDeviceStatusQueryApi;
|
||||
import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO;
|
||||
import com.viewsh.module.iot.api.device.dto.status.DeviceStatusRespDTO;
|
||||
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
|
||||
import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO;
|
||||
@@ -18,6 +20,8 @@ import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -47,6 +51,9 @@ public class BadgeDeviceStatusSyncJob {
|
||||
@Resource
|
||||
private IotDeviceStatusQueryApi iotDeviceStatusQueryApi;
|
||||
|
||||
@Resource
|
||||
private IotDeviceQueryApi iotDeviceQueryApi;
|
||||
|
||||
@Resource
|
||||
private OpsAreaDeviceRelationMapper areaDeviceRelationMapper;
|
||||
|
||||
@@ -120,6 +127,9 @@ public class BadgeDeviceStatusSyncJob {
|
||||
OpsAreaDeviceRelationDO::getAreaId,
|
||||
(existing, replacement) -> existing));
|
||||
|
||||
// 3b. 批量查询设备 nickname(IoT 是唯一可信源),防止 Redis key 丢失后降级到 deviceCode
|
||||
Map<Long, String> deviceNicknameMap = loadDeviceNicknameMap(deviceIds);
|
||||
|
||||
// 4. 逐一对账并修正
|
||||
for (DeviceStatusRespDTO iotStatus : iotResult.getData()) {
|
||||
// 4a. 工单一致性检查(修复残留的已终态工单关联)
|
||||
@@ -135,7 +145,10 @@ public class BadgeDeviceStatusSyncJob {
|
||||
}
|
||||
|
||||
// 4b. IoT 在线/离线状态对账
|
||||
boolean corrected = syncSingleDevice(iotStatus, deviceAreaMap.get(iotStatus.getDeviceId()));
|
||||
boolean corrected = syncSingleDevice(
|
||||
iotStatus,
|
||||
deviceAreaMap.get(iotStatus.getDeviceId()),
|
||||
deviceNicknameMap.get(iotStatus.getDeviceId()));
|
||||
syncCount++;
|
||||
if (corrected) {
|
||||
correctedCount++;
|
||||
@@ -154,9 +167,10 @@ public class BadgeDeviceStatusSyncJob {
|
||||
*
|
||||
* @param iotStatus IoT 设备状态
|
||||
* @param areaId 设备所属区域ID
|
||||
* @param nickname 设备昵称(从 IoT 查到的权威值,允许 null)
|
||||
* @return 是否进行了修正
|
||||
*/
|
||||
private boolean syncSingleDevice(DeviceStatusRespDTO iotStatus, Long areaId) {
|
||||
private boolean syncSingleDevice(DeviceStatusRespDTO iotStatus, Long areaId, String nickname) {
|
||||
Long deviceId = iotStatus.getDeviceId();
|
||||
|
||||
try {
|
||||
@@ -168,8 +182,20 @@ public class BadgeDeviceStatusSyncJob {
|
||||
boolean opsOnline = opsStatus != null && opsStatus.getStatus() != null
|
||||
&& opsStatus.getStatus().isActive();
|
||||
|
||||
// 如果状态一致,无需修正
|
||||
// 如果状态一致,但 Redis 缺 nickname 而 IoT 有值,则补写一次防止派单时降级显示 deviceCode
|
||||
if (iotOnline == opsOnline) {
|
||||
if (iotOnline && nickname != null
|
||||
&& (opsStatus == null || opsStatus.getNickname() == null)) {
|
||||
badgeDeviceStatusService.updateBadgeOnlineStatus(
|
||||
deviceId,
|
||||
iotStatus.getDeviceCode(),
|
||||
nickname,
|
||||
areaId,
|
||||
BadgeDeviceStatusEnum.IDLE,
|
||||
"定时对账补写-昵称");
|
||||
log.info("[SyncJob] 补写设备昵称:deviceId={}, nickname={}", deviceId, nickname);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -178,17 +204,17 @@ public class BadgeDeviceStatusSyncJob {
|
||||
badgeDeviceStatusService.updateBadgeOnlineStatus(
|
||||
deviceId,
|
||||
iotStatus.getDeviceCode(),
|
||||
null, // nickname: 对账场景不更新昵称,保留Redis中已有值
|
||||
nickname,
|
||||
areaId,
|
||||
BadgeDeviceStatusEnum.IDLE,
|
||||
"定时对账修正-上线");
|
||||
log.info("[SyncJob] 修正设备状态:deviceId={}, IoT=ONLINE, Ops={}",
|
||||
deviceId, opsOnline ? "ACTIVE" : "OFFLINE/NULL");
|
||||
log.info("[SyncJob] 修正设备状态:deviceId={}, IoT=ONLINE, Ops={}, nickname={}",
|
||||
deviceId, opsOnline ? "ACTIVE" : "OFFLINE/NULL", nickname);
|
||||
} else {
|
||||
badgeDeviceStatusService.updateBadgeOnlineStatus(
|
||||
deviceId,
|
||||
iotStatus.getDeviceCode(),
|
||||
null, // nickname: 对账场景不更新昵称,保留Redis中已有值
|
||||
nickname,
|
||||
null,
|
||||
BadgeDeviceStatusEnum.OFFLINE,
|
||||
"定时对账修正-离线");
|
||||
@@ -204,6 +230,35 @@ public class BadgeDeviceStatusSyncJob {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量从 IoT 查询设备昵称
|
||||
* <p>
|
||||
* Redis 中 ops:badge:device:{deviceId} 的 nickname 字段可能因 TTL/重启/缓存清理而缺失,
|
||||
* 每次对账时以 IoT 为唯一可信源做回填,避免派单时降级为 deviceCode(如 "43607737587")。
|
||||
*/
|
||||
private Map<Long, String> loadDeviceNicknameMap(List<Long> deviceIds) {
|
||||
if (CollUtil.isEmpty(deviceIds)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
CommonResult<List<IotDeviceSimpleRespDTO>> result = iotDeviceQueryApi.batchGetDevices(deviceIds);
|
||||
if (!result.isSuccess() || CollUtil.isEmpty(result.getData())) {
|
||||
log.warn("[SyncJob] 查询设备昵称失败或为空: {}", result.getMsg());
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<Long, String> map = new HashMap<>(result.getData().size());
|
||||
for (IotDeviceSimpleRespDTO dto : result.getData()) {
|
||||
if (dto.getId() != null && dto.getNickname() != null) {
|
||||
map.put(dto.getId(), dto.getNickname());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
} catch (Exception e) {
|
||||
log.warn("[SyncJob] 批量查询设备昵称异常,本次对账跳过昵称回填", e);
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步结果
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.viewsh.module.ops.environment.job;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.viewsh.framework.tenant.core.job.TenantJob;
|
||||
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 com.xxl.job.core.handler.annotation.XxlJob;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 保洁工单超时自动取消 Job
|
||||
* <p>
|
||||
* 职责:
|
||||
* 扫描所有保洁类(order_type=CLEAN)非终态工单,
|
||||
* 若最近一次进展(update_time)距今超过阈值(默认 12 小时),
|
||||
* 以 SYSTEM 身份走正常取消流程将其关闭。
|
||||
* <p>
|
||||
* 设计要点:
|
||||
* 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,避免误杀用户刚触达的工单。
|
||||
* <p>
|
||||
* 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<OpsOrderDO> candidates = opsOrderMapper.selectList(new LambdaQueryWrapperX<OpsOrderDO>()
|
||||
.eq(OpsOrderDO::getOrderType, BUSINESS_TYPE_CLEAN)
|
||||
.notIn(OpsOrderDO::getStatus,
|
||||
WorkOrderStatusEnum.COMPLETED.getStatus(),
|
||||
WorkOrderStatusEnum.CANCELLED.getStatus(),
|
||||
// PAUSED 交由 resumeInterruptedOrder 经状态机恢复,不在此 Job 自动化处理
|
||||
WorkOrderStatusEnum.PAUSED.getStatus())
|
||||
.le(OpsOrderDO::getUpdateTime, threshold)
|
||||
.orderByAsc(OpsOrderDO::getUpdateTime)
|
||||
.last("LIMIT " + batchSize));
|
||||
|
||||
if (CollUtil.isEmpty(candidates)) {
|
||||
log.info("[CleanOrderAutoCancelJob] 无超时工单");
|
||||
return new CancelResult(0, 0, 0, 0, System.currentTimeMillis() - startTime);
|
||||
}
|
||||
|
||||
int succeeded = 0;
|
||||
int failed = 0;
|
||||
int skippedStale = 0;
|
||||
|
||||
for (OpsOrderDO order : candidates) {
|
||||
Long orderId = order.getId();
|
||||
try {
|
||||
// 乐观校验:候选装内存→实际 cancel 之间,用户可能已触达工单刷新 update_time。
|
||||
// 重查一次确认仍超时,避免把用户刚点过的工单一并 cancel 掉。
|
||||
OpsOrderDO fresh = opsOrderMapper.selectById(orderId);
|
||||
if (fresh == null
|
||||
|| WorkOrderStatusEnum.COMPLETED.getStatus().equals(fresh.getStatus())
|
||||
|| WorkOrderStatusEnum.CANCELLED.getStatus().equals(fresh.getStatus())
|
||||
|| WorkOrderStatusEnum.PAUSED.getStatus().equals(fresh.getStatus())
|
||||
|| fresh.getUpdateTime() == null
|
||||
|| fresh.getUpdateTime().isAfter(threshold)) {
|
||||
skippedStale++;
|
||||
log.info("[CleanOrderAutoCancelJob] 并发触达/状态已变,跳过: orderId={}, snapshotStatus={}, latestStatus={}, latestUpdateTime={}",
|
||||
orderId, order.getStatus(),
|
||||
fresh != null ? fresh.getStatus() : "NOT_FOUND",
|
||||
fresh != null ? fresh.getUpdateTime() : null);
|
||||
continue;
|
||||
}
|
||||
|
||||
orderLifecycleManager.cancelOrder(
|
||||
orderId,
|
||||
null,
|
||||
OperatorTypeEnum.SYSTEM,
|
||||
CANCEL_REASON);
|
||||
succeeded++;
|
||||
log.info("[CleanOrderAutoCancelJob] 自动取消成功: orderId={}, orderCode={}, status={}, updateTime={}",
|
||||
orderId, order.getOrderCode(), order.getStatus(), order.getUpdateTime());
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
log.warn("[CleanOrderAutoCancelJob] 自动取消失败: orderId={}, orderCode={}, status={}, error={}",
|
||||
orderId, order.getOrderCode(), order.getStatus(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("[CleanOrderAutoCancelJob] 扫描完成: 扫描 {} 单, 成功 {}, 失败 {}, 跳过 {}, 耗时 {} ms",
|
||||
candidates.size(), succeeded, failed, skippedStale, duration);
|
||||
|
||||
return new CancelResult(candidates.size(), succeeded, failed, skippedStale, duration);
|
||||
}
|
||||
|
||||
public record CancelResult(int scanned, int succeeded, int failed, int skippedStale, long durationMs) {
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,7 @@ public class BadgeDeviceAreaAssignStrategy implements AssignStrategy {
|
||||
|
||||
if (selectedDevice != null) {
|
||||
String reason = buildRecommendationReason(selectedDevice, context);
|
||||
String assigneeName = selectedDevice.getNickname() != null
|
||||
? selectedDevice.getNickname() : selectedDevice.getDeviceCode();
|
||||
String assigneeName = resolveAssigneeName(selectedDevice);
|
||||
return AssigneeRecommendation.of(
|
||||
selectedDevice.getDeviceId(),
|
||||
assigneeName,
|
||||
@@ -118,8 +117,7 @@ public class BadgeDeviceAreaAssignStrategy implements AssignStrategy {
|
||||
.map(device -> {
|
||||
int score = calculateScore(device);
|
||||
String reason = buildRecommendationReason(device, context);
|
||||
String assigneeName = device.getNickname() != null
|
||||
? device.getNickname() : device.getDeviceCode();
|
||||
String assigneeName = resolveAssigneeName(device);
|
||||
return AssigneeRecommendation.of(
|
||||
device.getDeviceId(),
|
||||
assigneeName,
|
||||
@@ -133,6 +131,25 @@ public class BadgeDeviceAreaAssignStrategy implements AssignStrategy {
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 解析执行人展示名称。
|
||||
* <p>
|
||||
* 优先用 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() : "未知工牌";
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择最佳设备
|
||||
*/
|
||||
|
||||
@@ -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 的五条不变量:
|
||||
* <ol>
|
||||
* <li>无候选 → 返回零结果,不触发取消</li>
|
||||
* <li>正常批次 → 依次 cancel,成功计数正确</li>
|
||||
* <li>单条失败不中断其余 → try/catch 隔离</li>
|
||||
* <li>候选到 cancel 间被用户触达 → 乐观锁跳过(避免误杀)</li>
|
||||
* <li>候选到 cancel 间状态变为终态/PAUSED → 跳过</li>
|
||||
* </ol>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user