package com.battery.business.service.impl;
|
|
import java.util.ArrayList;
|
import java.util.Date;
|
import java.util.HashMap;
|
import java.util.HashSet;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.Set;
|
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSONException;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import com.ruoyi.common.exception.ServiceException;
|
import com.ruoyi.common.utils.StringUtils;
|
import com.battery.business.domain.TTest;
|
import com.battery.business.domain.TTestBattery;
|
import com.battery.business.domain.CurrentBatteryField;
|
import com.battery.business.domain.TTestChannelBinding;
|
import com.battery.business.domain.TTestCollectorChannel;
|
import com.battery.business.domain.TTestWarningThreshold;
|
import com.battery.business.domain.TTestLogicStepMapping;
|
import com.battery.business.domain.TTestConsoleDetail;
|
import com.battery.business.domain.TDetectionChannel;
|
import com.battery.business.domain.vo.TestConfigurationOptionsVo;
|
import com.battery.business.mapper.TTestMapper;
|
import com.battery.business.service.ITTestService;
|
|
/** 测试配置服务,负责状态边界、关系校验、事务写入和只追加审计。 */
|
@Service
|
public class TTestServiceImpl implements ITTestService
|
{
|
private static final String WAITING = "WAITING";
|
private static final String NOT_STARTED = "NOT_STARTED";
|
/** 新流程只允许四种测试状态;历史状态仅在旧数据展示层做兼容映射。 */
|
private static final Set<String> TEST_STATUSES = Set.of(WAITING, "RUNNING", "PAUSED", "COMPLETED");
|
private static final Set<String> RECEPTION_STATUSES = Set.of(NOT_STARTED, "RECEIVING", "PAUSED", "ENDED");
|
private static final Set<String> THRESHOLD_UNITS = Set.of("A", "V", "Ah", "Wh", "℃");
|
private static final Set<String> REQUIRED_COLLECTOR_MEASUREMENTS = Set.of(
|
"PRIMARY_TEMPERATURE", "PRIMARY_POSITIVE_POTENTIAL", "PRIMARY_NEGATIVE_POTENTIAL");
|
|
@Autowired
|
private TTestMapper testMapper;
|
|
/**
|
* 按测试主键读取测试配置,同时装配电池号、阈值、通道绑定等配置关系。
|
*
|
* @param testId 测试主键
|
* @return 测试配置及其业务状态
|
* @throws ServiceException 测试ID为空或测试不存在
|
*/
|
@Override
|
public TTest selectTestById(Long testId)
|
{
|
if (testId == null) throw new ServiceException("测试ID不能为空");
|
TTest test = testMapper.selectTestById(testId);
|
if (test == null) throw new ServiceException("测试不存在");
|
loadConfiguration(test);
|
return test;
|
}
|
|
/**
|
* 按正式测试状态、接收状态及配置条件查询测试列表。
|
*
|
* <p>查询条件中的测试状态与接收状态都须是现行取值,历史兼容状态不参与筛选。</p>
|
*
|
* @param test 查询条件;为空时按无条件查询处理
|
* @return 测试列表,含各测试绑定的电池号
|
* @throws ServiceException 传入的测试状态或接收状态不是现行取值
|
*/
|
@Override
|
public List<TTest> selectTestList(TTest test)
|
{
|
if (test == null) test = new TTest();
|
test.setTestName(trimToNull(test.getTestName()));
|
test.setBatteryNo(trimToNull(test.getBatteryNo()));
|
validateQueryStatus(test.getTestStatus(), TEST_STATUSES, "测试状态无效");
|
validateQueryStatus(test.getReceptionStatus(), RECEPTION_STATUSES, "接收状态无效");
|
List<TTest> tests = mutable(testMapper.selectTestList(test));
|
populateBatteryNos(tests);
|
return tests;
|
}
|
|
/**
|
* 返回创建或编辑测试时可选的来源:启用用户、启用测试类型、启用电池组的电池号、已正式登记的通道与采集器。
|
*
|
* @return 测试配置选项,五类选项都可能为空列表
|
*/
|
@Override
|
public TestConfigurationOptionsVo selectConfigurationOptions()
|
{
|
TestConfigurationOptionsVo options = new TestConfigurationOptionsVo();
|
options.setUsers(testMapper.selectEnabledUserOptions());
|
options.setTestTypes(testMapper.selectEnabledTestTypeOptions());
|
options.setBatteries(testMapper.selectEnabledBatteryOptions());
|
options.setChannels(testMapper.selectRegisteredChannelOptions());
|
options.setCollectors(testMapper.selectEnabledCollectorOptions());
|
return options;
|
}
|
|
/**
|
* 创建一条待测试记录,在同一事务内写入测试配置和电池号、阈值、通道绑定关系。
|
*
|
* <p>前置条件:调用方已从登录会话取得操作人,测试配置通过基本信息与配置关系校验。
|
* 创建人取当前操作人,监测超时缺省 30 分钟,初始状态固定为“待测试 + 未开始接收”。</p>
|
*
|
* @param test 测试配置,含电池号、阈值、检测柜通道绑定与采集器映射
|
* @return 新建测试主键
|
* @throws ServiceException 测试信息为空、操作人缺失或配置校验不通过
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public Long createTest(TTest test)
|
{
|
if (test == null) throw new ServiceException("测试信息不能为空");
|
// STEP 1: 规范化系统赋值字段,创建人取当前操作人,监测超时缺省 30 分钟。
|
test.setCreatorId(test.getOperatorId());
|
if (test.getMonitorTimeoutMinutes() == null) test.setMonitorTimeoutMinutes(30);
|
// STEP 2: 校验基本信息与电池号、阈值、通道绑定等配置关系。
|
validateBase(test, false);
|
validateConfiguration(test);
|
// STEP 3: 初始化为“待测试 + 未开始接收”,测试状态只在开始接收时流转。
|
test.setTestStatus(WAITING);
|
test.setReceptionStatus(NOT_STARTED);
|
test.setDelFlag("0");
|
test.setCreateTime(new Date());
|
testMapper.insertTest(test);
|
// STEP 4: 在同一事务内写入电池号、阈值、通道绑定与采集器映射。
|
saveConfiguration(test);
|
// 最终基线移除了 t_test_audit;Controller 的 @Log 负责记录创建操作,业务表只保存配置事实。
|
return test.getTestId();
|
}
|
|
/**
|
* 仅在开始接收之前整体替换测试配置,测试状态与接收状态保持不变。
|
*
|
* <p>前置条件:测试处于“待测试 + 未开始接收”。开始接收后设备数据已按配置归属,
|
* 配置不再允许修改;替换范围包括电池号、阈值和通道绑定,在同一事务内完成。</p>
|
*
|
* @param test 新的测试配置,须带测试主键
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 测试ID为空、测试不存在、已开始接收或配置校验不通过
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int updateTest(TTest test)
|
{
|
if (test == null || test.getTestId() == null) throw new ServiceException("测试ID不能为空");
|
// STEP 1: 取测试行锁并确认仍处于“待测试 + 未开始接收”。
|
TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
|
if (current == null) throw new ServiceException("测试不存在");
|
loadConfiguration(current);
|
if (!stateMatches(current, WAITING, NOT_STARTED))
|
throw new ServiceException("开始接收后不能修改测试配置");
|
|
// STEP 2: 保留创建人与两个状态字段,只接受配置类字段的修改。
|
test.setCreatorId(current.getCreatorId());
|
test.setTestStatus(current.getTestStatus());
|
test.setReceptionStatus(current.getReceptionStatus());
|
if (test.getMonitorTimeoutMinutes() == null)
|
test.setMonitorTimeoutMinutes(current.getMonitorTimeoutMinutes());
|
validateBase(test, true);
|
validateConfiguration(test);
|
test.setUpdateTime(new Date());
|
int rows = testMapper.updateTest(test);
|
if (rows != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
|
|
// STEP 3: 开始接收前没有数据归属事实,配置关系可在同一事务内整体替换;操作审计由 Controller @Log 追加。
|
testMapper.deleteTestCollectorChannelsByTestId(test.getTestId());
|
testMapper.deleteTestBindingsByTestId(test.getTestId());
|
testMapper.deleteTestBatteriesByTestId(test.getTestId());
|
testMapper.deleteTestThresholdsByTestId(test.getTestId());
|
saveConfiguration(test);
|
return rows;
|
}
|
|
/**
|
* 开始接收:校验全部绑定来源后建立接收边界,边界之后的成功观测才归属本测试。
|
*
|
* <p>前置条件:测试处于“待测试 + 未开始接收”,每个电池号都绑定启用的检测柜通道与采集器,
|
* 且同一检测柜通道在本测试内不重复。来源离线不阻断状态流转,允许从已运行设备中途接入;
|
* 本动作只改变系统数据请求和测试状态,不向设备发送控制命令。</p>
|
*
|
* @param testId 测试主键
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号,写入测试行的 update_by
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 入参为空、测试不存在、状态不匹配、来源校验失败或数据来源已被其他测试占用
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int startReception(Long testId, Long operatorId, String updateBy)
|
{
|
if (testId == null) throw new ServiceException("测试ID不能为空");
|
if (operatorId == null) throw new ServiceException("操作人不能为空");
|
// STEP 1: 取测试行锁,确认状态为“待测试 + 未开始接收”并加载绑定配置。
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
if (!stateMatches(current, WAITING, NOT_STARTED))
|
throw new ServiceException("当前测试不是未开始接收状态");
|
loadConfiguration(current);
|
if (current.getBatteries().isEmpty()) throw new ServiceException("测试未配置电池号");
|
|
// STEP 2: 逐电池号校验检测柜通道唯一、通道启用、采集器存在且映射完整。
|
Date actionAt = new Date();
|
Set<Long> channelIds = new HashSet<>();
|
Set<String> collectorChannels = new HashSet<>();
|
boolean runtimeAttach = false;
|
for (TTestBattery battery : current.getBatteries())
|
{
|
TTestChannelBinding binding = battery.getBinding();
|
if (binding == null || binding.getChannelId() == null)
|
throw new ServiceException("电池号 " + battery.getBatteryNo() + " 未绑定检测柜通道");
|
if (!channelIds.add(binding.getChannelId()))
|
throw new ServiceException("检测柜通道重复绑定,无法开始接收");
|
TDetectionChannel channel = testMapper.selectReceptionChannelForUpdate(binding.getChannelId());
|
if (channel == null || !isEnabled(channel.getStatus()))
|
throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 不存在或未启用");
|
// 只有设备事实为运行中时才标记“运行中接入”;设备暂停属于独立设备状态。
|
runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus())
|
|| "RUNNING".equalsIgnoreCase(channel.getWorkStatus());
|
if (battery.getCollectorId() == null || testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
|
throw new ServiceException("电池号 " + battery.getBatteryNo() + " 的采集器不存在或未启用");
|
Integer collectorChannelCount = testMapper.selectCollectorChannelCount(battery.getCollectorId());
|
validateCollectorChannels(battery.getCollectorId(), collectorChannelCount == null ? 0 : collectorChannelCount,
|
battery.getCollectorChannels(), collectorChannels);
|
|
}
|
|
// STEP 3: 在测试行锁内推进为“检测中 + 接收中”,并把此刻写为接收边界。
|
try
|
{
|
if (testMapper.updateReceptionStarted(testId, actionAt, runtimeAttach, updateBy) != 1)
|
throw new ServiceException("测试状态已变更,请刷新后重试");
|
return 1;
|
}
|
catch (DuplicateKeyException exception)
|
{
|
// 检测柜通道已被其他接收中的测试占用,唯一索引保证不会同时归属两个测试。
|
throw new ServiceException("数据来源已被其他测试占用,请刷新后重试");
|
}
|
}
|
|
/**
|
* 暂停接收:停止为本次测试请求实时数据,保留已持久化的历史事实。
|
*
|
* <p>前置条件:测试处于“检测中 + 接收中”。设备不接收暂停命令,
|
* 暂停期间停止请求数据且不补采,暂停空档不视为数据缺口。</p>
|
*
|
* @param testId 测试主键
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号,写入测试行的 update_by
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 入参为空、测试不存在或当前不是接收中状态
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int pauseReception(Long testId, Long operatorId, String updateBy)
|
{
|
if (testId == null) throw new ServiceException("测试ID不能为空");
|
if (operatorId == null) throw new ServiceException("操作人不能为空");
|
// STEP 1: 取测试行锁,确认状态为“检测中 + 接收中”。
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
if (!stateMatches(current, "RUNNING", "RECEIVING"))
|
throw new ServiceException("当前测试不是接收中状态");
|
|
// STEP 2: 推进为“暂停 + 暂停接收”;不记录接收边界,恢复时再重置边界。
|
Date actionAt = new Date();
|
if (testMapper.updateReceptionPaused(testId, updateBy, actionAt) != 1)
|
throw new ServiceException("测试状态已变更,请刷新后重试");
|
// 暂停期间停止请求数据;继续接收后仅追加新的成功接收事实。
|
return 1;
|
}
|
|
/**
|
* 继续接收:为全部绑定来源恢复实时数据请求,只追加新的成功接收事实。
|
*
|
* <p>前置条件:测试处于“暂停 + 暂停接收”,且绑定来源仍可用。
|
* 继续接收会刷新接收边界,暂停空档不补采;设备已经结束时不得继续接收。</p>
|
*
|
* @param testId 测试主键
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号,写入测试行的 update_by
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 入参为空、测试不存在或当前不是暂停接收状态
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int resumeReception(Long testId, Long operatorId, String updateBy)
|
{
|
if (testId == null) throw new ServiceException("测试ID不能为空");
|
if (operatorId == null) throw new ServiceException("操作人不能为空");
|
// STEP 1: 取测试行锁,确认状态为“暂停 + 暂停接收”。
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
if (!stateMatches(current, "PAUSED", "PAUSED"))
|
throw new ServiceException("当前测试不是暂停接收状态");
|
// STEP 2: 加锁查询只返回测试主表,继续接收前重新加载绑定配置以校验来源仍可用。
|
loadConfiguration(current);
|
// STEP 3: 推进为“检测中 + 接收中”,并把此刻刷新为新的接收边界。
|
Date actionAt = new Date();
|
if (testMapper.updateReceptionResumed(testId, updateBy, actionAt) != 1)
|
throw new ServiceException("测试状态已变更,请刷新后重试");
|
return 1;
|
}
|
|
/**
|
* 停止测试:停止数据请求并进入“检测完成 + 已结束接收”的不可恢复终态。
|
*
|
* <p>前置条件:测试处于“检测中 + 接收中”或“暂停 + 暂停接收”。数据缺失、序号跳跃和
|
* 采样间隔异常不阻止完成;不向检测柜或采集器发送停止命令,设备仍按现场事实独立运行。</p>
|
*
|
* @param testId 测试主键
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号,写入测试行的 update_by
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 入参为空、测试不存在或当前状态不允许停止
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int stopTest(Long testId, Long operatorId, String updateBy)
|
{
|
if (testId == null) throw new ServiceException("测试ID不能为空");
|
if (operatorId == null) throw new ServiceException("操作人不能为空");
|
// STEP 1: 取测试行锁,确认处于可停止的两种状态之一。
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
if (!(stateMatches(current, "RUNNING", "RECEIVING")
|
|| stateMatches(current, "PAUSED", "PAUSED")))
|
throw new ServiceException("当前测试不能停止");
|
// STEP 2: 推进为“检测完成 + 已结束接收”并记录结束时间,不执行完整性门禁。
|
Date actionAt = new Date();
|
if (testMapper.updateReceptionStopped(testId, updateBy, actionAt) != 1)
|
throw new ServiceException("测试状态已变更,请刷新后重试");
|
return 1;
|
}
|
|
private boolean isEnabled(String status)
|
{
|
return status == null || "0".equals(status);
|
}
|
|
/** 生命周期动作必须同时匹配测试状态和接收状态,避免并发请求后两个状态字段互相矛盾。 */
|
private boolean stateMatches(TTest test, String expectedTestStatus, String expectedReceptionStatus)
|
{
|
return expectedReceptionStatus.equals(test.getReceptionStatus())
|
&& expectedTestStatus.equals(test.getTestStatus());
|
}
|
|
/**
|
* 在任意生命周期整体删除测试及其全部子数据,删除后不可恢复。
|
*
|
* <p>前置条件:调用方已确认操作人身份。逐条测试加测试行锁后清理绑定、阈值、三类数据、
|
* 逻辑工步、预警异常和修订记录,最后删除测试主记录;按 ADR-0047 不写操作日志、修订记录或墓碑,
|
* 也不删除电池组、设备登记和独立保存的报表。</p>
|
*
|
* @param testIds 待删除的测试主键数组
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号
|
* @return 实际删除的测试数量
|
* @throws ServiceException 未选择测试或测试不存在
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int deleteTestByIds(Long[] testIds, Long operatorId, String updateBy)
|
{
|
if (testIds == null || testIds.length == 0) throw new ServiceException("请选择要删除的测试");
|
int rows = 0;
|
for (Long testId : testIds)
|
{
|
// STEP 1: 加测试行锁,与接收事务互斥;删除提交后不能再向该测试写入数据。
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
// STEP 2: 在同一事务内清理全部测试子数据,整体删除不留审计或墓碑。
|
testMapper.deleteTestRealtimeRecordsByTestId(testId);
|
testMapper.deleteTestStepRecordsByTestId(testId);
|
testMapper.deleteTestCycleRecordsByTestId(testId);
|
testMapper.deleteTestDataRevisionActionsByTestId(testId);
|
testMapper.deleteTestWarningExceptionsByTestId(testId);
|
testMapper.deleteTestLogicStepMappingsByTestId(testId);
|
testMapper.deleteTestCollectorChannelsByTestId(testId);
|
testMapper.deleteTestBindingsByTestId(testId);
|
testMapper.deleteTestThresholdRowsByTestId(testId);
|
testMapper.deleteTestBatteriesByTestId(testId);
|
// STEP 3: 最后删除测试主记录,行数不为 1 说明配置已被并发改动。
|
int deleted = testMapper.deleteTestById(testId, updateBy);
|
if (deleted != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
|
rows += deleted;
|
}
|
return rows;
|
}
|
|
/**
|
* 保存当前测试的逻辑工步合并规则,整体替换原有映射。
|
*
|
* <p>前置条件:映射须按循环内连续位置升序、互不重叠、编号从 1 起,且每项都有名称。
|
* 逻辑工步只用于统计图表和报表等派生视图,不改写原始采样记录。</p>
|
*
|
* @param testId 测试主键
|
* @param mappings 逻辑工步映射列表,为空表示清空
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 测试或操作人为空、测试不存在或映射不满足连续升序且互不重叠
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int saveLogicStepMappings(Long testId, List<TTestLogicStepMapping> mappings, Long operatorId, String updateBy)
|
{
|
if (testId == null || operatorId == null) throw new ServiceException("测试和操作人不能为空");
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
// STEP 1: 校验映射为连续的循环内位置区间,编号从 1 起且互不重叠。
|
List<TTestLogicStepMapping> normalized = mappings == null ? List.of() : mappings;
|
int previousEnd = 0;
|
int expectedNo = 1;
|
for (TTestLogicStepMapping mapping : normalized)
|
{
|
if (mapping == null || mapping.getLogicalNo() == null || mapping.getLogicalNo() != expectedNo
|
|| mapping.getStartStepNo() == null || mapping.getEndStepNo() == null
|
|| mapping.getStartStepNo() < 1 || mapping.getEndStepNo() < mapping.getStartStepNo()
|
|| mapping.getStartStepNo() <= previousEnd || StringUtils.isEmpty(trimToNull(mapping.getLogicalName())))
|
throw new ServiceException("逻辑工步必须按循环内连续位置升序且互不重叠");
|
mapping.setLogicalName(trimToNull(mapping.getLogicalName()));
|
previousEnd = mapping.getEndStepNo();
|
expectedNo++;
|
}
|
// STEP 2: 整体替换映射 JSON,作用于该测试的全部循环。
|
String newJson = JSON.toJSONString(normalized);
|
int rows = testMapper.updateLogicStepMappings(testId, newJson, updateBy, new Date());
|
if (rows != 1) throw new ServiceException("工步设置已变更,请刷新后重试");
|
return rows;
|
}
|
|
/**
|
* 保存测试详情当前最新版,允许测试处于任意状态。
|
*
|
* <p>前置条件:实验名称不超过 200 个字符,字段名称与字段值通过校验。
|
* 详情只保留当前最新版,直接读取电池组当前字段值,不创建测试字段快照,也不产生修订记录。</p>
|
*
|
* @param testId 测试主键
|
* @param detail 测试补充详情,含实验名称、字段名称与人工填写值
|
* @param operatorId 操作人用户 ID
|
* @param updateBy 更新人账号
|
* @return 更新行数,成功固定为 1
|
* @throws ServiceException 测试或操作人为空、测试不存在、实验名称或字段校验不通过
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int saveConsoleDetail(Long testId, TTestConsoleDetail detail, Long operatorId, String updateBy)
|
{
|
if (testId == null || operatorId == null || detail == null) throw new ServiceException("测试详情不能为空");
|
// STEP 1: 加测试行锁并确认测试存在。
|
TTest current = testMapper.selectTestByIdForUpdate(testId);
|
if (current == null) throw new ServiceException("测试不存在");
|
// STEP 2: 规范化并校验实验名称、字段名称与字段值。
|
String experimentName = trimToNull(detail.getExperimentName());
|
if (experimentName != null && experimentName.length() > 200) throw new ServiceException("实验名称长度不能超过200个字符");
|
detail.setExperimentName(experimentName);
|
validateConsoleFields(detail.getBatteryFields());
|
validateConsoleFields(detail.getSpecialFields());
|
validateConsoleValues(testId, detail);
|
// STEP 3: 覆盖保存最新版详情,不追加修订记录。
|
int rows = testMapper.updateConsoleDetail(testId, JSON.toJSONString(detail), updateBy, new Date());
|
if (rows != 1) throw new ServiceException("测试详情保存失败,请刷新后重试");
|
return rows;
|
}
|
|
private void validateConsoleFields(List<String> fields)
|
{
|
if (fields == null) return;
|
Set<String> names = new HashSet<>();
|
for (String field : fields)
|
{
|
String normalized = trimToNull(field);
|
if (normalized == null || normalized.length() > 100 || !names.add(normalized))
|
throw new ServiceException("测试详情字段不能为空、长度不能超过100且不可重复");
|
}
|
}
|
|
private void validateConsoleValues(Long testId, TTestConsoleDetail detail)
|
{
|
Set<String> allowedFields = new HashSet<>();
|
if (detail.getBatteryFields() != null) allowedFields.addAll(detail.getBatteryFields());
|
if (detail.getSpecialFields() != null) allowedFields.addAll(detail.getSpecialFields());
|
Set<String> batteryNos = new HashSet<>();
|
List<TTestBattery> batteries = testMapper.selectTestBatteries(testId);
|
if (batteries != null) for (TTestBattery battery : batteries) batteryNos.add(battery.getBatteryNo());
|
if (detail.getFieldValues() == null) return;
|
for (Map.Entry<String, Map<String, String>> entry : detail.getFieldValues().entrySet())
|
{
|
if (!allowedFields.contains(entry.getKey()) || entry.getValue() == null)
|
throw new ServiceException("测试详情字段值结构无效");
|
for (Map.Entry<String, String> value : entry.getValue().entrySet())
|
{
|
if (!batteryNos.contains(value.getKey()) || (value.getValue() != null && value.getValue().length() > 500))
|
throw new ServiceException("测试详情电池号或字段值无效");
|
}
|
}
|
}
|
|
private void loadConfiguration(TTest test)
|
{
|
Long testId = test.getTestId();
|
List<TTestBattery> loadedBatteries = testMapper.selectTestBatteries(testId);
|
List<TTestBattery> batteries = loadedBatteries == null || (loadedBatteries.isEmpty() && test.getBatteries() != null
|
&& !test.getBatteries().isEmpty()) ? mutable(test.getBatteries()) : mutable(loadedBatteries);
|
// 字段属于电池组当前配置;每次查询测试都重新读取,避免产生测试字段快照。
|
List<CurrentBatteryField> currentFields = testMapper.selectCurrentBatteryFields(testId);
|
Map<Long, List<CurrentBatteryField>> fieldsByBattery = new HashMap<>();
|
for (CurrentBatteryField field : mutable(currentFields))
|
fieldsByBattery.computeIfAbsent(field.getRelationId(), ignored -> new ArrayList<>()).add(field);
|
List<TTestCollectorChannel> loadedCollectorChannels = testMapper.selectTestCollectorChannels(testId);
|
List<TTestCollectorChannel> collectorChannels = loadedCollectorChannels == null ? List.of() : mutable(loadedCollectorChannels);
|
Map<Long, TTestChannelBinding> bindings = new HashMap<>();
|
List<TTestChannelBinding> loadedBindings = testMapper.selectTestChannelBindings(testId);
|
if (loadedBindings == null || (loadedBindings.isEmpty() && batteries.stream().anyMatch(item -> item.getBinding() != null)))
|
for (TTestBattery battery : batteries)
|
if (battery.getBinding() != null) bindings.put(battery.getRelationId(), battery.getBinding());
|
for (TTestChannelBinding binding : mutable(loadedBindings))
|
bindings.put(binding.getBatteryRelationId(), binding);
|
for (TTestBattery battery : batteries)
|
{
|
battery.setBinding(bindings.get(battery.getRelationId()));
|
battery.setFields(fieldsByBattery.getOrDefault(battery.getRelationId(), List.of()));
|
if (loadedCollectorChannels != null && (!loadedCollectorChannels.isEmpty() || battery.getCollectorChannels() == null))
|
battery.setCollectorChannels(collectorChannels.stream()
|
.filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
|
.toList());
|
}
|
test.setBatteries(batteries);
|
test.setBatteryNo(joinBatteryNos(batteries));
|
List<TTestWarningThreshold> loadedThresholds = testMapper.selectTestThresholds(testId);
|
test.setThresholds(loadedThresholds == null ? mutable(test.getThresholds()) : mutable(loadedThresholds));
|
// 测试不保存字段快照;字段值始终随电池组当前配置读取。
|
String mappingJson = test.getLogicStepMappingsJson();
|
try { test.setLogicStepMappings(mappingJson == null || mappingJson.isBlank()
|
? new ArrayList<>() : JSON.parseArray(mappingJson, TTestLogicStepMapping.class)); }
|
catch (JSONException exception) { throw new ServiceException("逻辑工步设置无法解析"); }
|
String detailJson = test.getConsoleDetailJson();
|
try { test.setConsoleDetail(detailJson == null || detailJson.isBlank()
|
? null : JSON.parseObject(detailJson, TTestConsoleDetail.class)); }
|
catch (JSONException exception) { throw new ServiceException("测试详情无法解析"); }
|
}
|
|
private void validateBase(TTest test, boolean update)
|
{
|
test.setTestName(trimToNull(test.getTestName()));
|
test.setTestPurpose(trimToNull(test.getTestPurpose()));
|
test.setRemark(trimToNull(test.getRemark()));
|
if (StringUtils.isEmpty(test.getTestName())) throw new ServiceException("测试名称不能为空");
|
if (test.getTestName().length() > 100) throw new ServiceException("测试名称长度不能超过100个字符");
|
if (StringUtils.isEmpty(test.getTestPurpose())) throw new ServiceException("测试目的不能为空");
|
if (test.getRemark() != null && test.getRemark().length() > 500) throw new ServiceException("备注长度不能超过500个字符");
|
if (test.getMonitorTimeoutMinutes() == null || test.getMonitorTimeoutMinutes() < 1
|
|| test.getMonitorTimeoutMinutes() > 1440)
|
throw new ServiceException("监测超时分钟数必须在1到1440之间");
|
if (test.getCreatorId() == null || testMapper.countUserEnabled(test.getCreatorId()) == 0)
|
throw new ServiceException("创建人不存在或已停用");
|
if (test.getPrincipalId() == null || testMapper.countUserEnabled(test.getPrincipalId()) == 0)
|
throw new ServiceException("测试负责人不存在或已停用");
|
if (test.getTestTypeId() == null || testMapper.countTestTypeEnabled(test.getTestTypeId()) == 0)
|
throw new ServiceException("测试类型不存在或未启用");
|
TTest same = testMapper.selectTestByName(test.getTestName());
|
if (same != null && (!update || !test.getTestId().equals(same.getTestId())))
|
throw new ServiceException("测试名称已存在");
|
}
|
|
private void validateConfiguration(TTest test)
|
{
|
// 计划工步来自设备观测,不属于测试创建配置,也不写入业务库。
|
validateBatteries(test.getBatteries());
|
validateThresholds(test.getThresholds());
|
}
|
|
private void validateBatteries(List<TTestBattery> batteries)
|
{
|
if (batteries == null || batteries.isEmpty()) throw new ServiceException("测试至少绑定一个电池号");
|
Set<String> batteryNos = new HashSet<>();
|
Set<Long> channelIds = new HashSet<>();
|
Set<String> collectorChannelIdentities = new HashSet<>();
|
Map<Long, Integer> collectorChannelCounts = new HashMap<>();
|
Long groupId = null;
|
for (TTestBattery battery : batteries)
|
{
|
if (battery == null || battery.getGroupId() == null) throw new ServiceException("电池组不能为空");
|
if (groupId == null) groupId = battery.getGroupId();
|
else if (!groupId.equals(battery.getGroupId())) throw new ServiceException("同一测试只能选择一个电池组");
|
battery.setBatteryNo(trimToNull(battery.getBatteryNo()));
|
if (battery.getBatteryNo() == null) throw new ServiceException("电池号不能为空");
|
if (!batteryNos.add(battery.getBatteryNo())) throw new ServiceException("测试内电池号不可重复");
|
if (testMapper.countBatteryInEnabledGroup(battery.getGroupId(), battery.getBatteryNo()) == 0)
|
throw new ServiceException("电池号不属于所选启用电池组");
|
|
TTestChannelBinding binding = battery.getBinding();
|
if (binding == null || binding.getChannelId() == null) throw new ServiceException("主要检测柜通道不能为空");
|
if (!channelIds.add(binding.getChannelId())) throw new ServiceException("同一检测柜通道不可重复绑定");
|
if (testMapper.countRegisteredChannel(binding.getChannelId()) == 0)
|
throw new ServiceException("检测柜通道不存在、未登记或未启用");
|
binding.setExpectedBarcode(trimToNull(binding.getExpectedBarcode()));
|
if (binding.getExpectedBarcode() != null && binding.getExpectedBarcode().length() > 100)
|
throw new ServiceException("预期条码长度不能超过100个字符");
|
if (battery.getCollectorId() == null) throw new ServiceException("采集器不能为空");
|
if (testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
|
throw new ServiceException("采集器不存在或未启用");
|
Integer channelCount = collectorChannelCounts.computeIfAbsent(battery.getCollectorId(),
|
testMapper::selectCollectorChannelCount);
|
if (channelCount == null || channelCount < 1) throw new ServiceException("采集器通道配置无效");
|
validateCollectorChannels(battery.getCollectorId(), channelCount, battery.getCollectorChannels(),
|
collectorChannelIdentities);
|
validateBatteryFields(battery.getFields());
|
}
|
}
|
|
private void validateCollectorChannels(Long collectorId, int channelCount, List<TTestCollectorChannel> mappings,
|
Set<String> collectorChannelIdentities)
|
{
|
if (mappings == null || mappings.isEmpty())
|
throw new ServiceException("每个电池号必须绑定主温度、正电位主通道和负电位主通道");
|
Set<String> measurementCodes = new HashSet<>();
|
for (TTestCollectorChannel mapping : mappings)
|
{
|
if (mapping == null) throw new ServiceException("采集器通道映射不完整");
|
mapping.setMeasurementCode(normalizeMeasurementCode(mapping.getMeasurementCode()));
|
mapping.setMeasurementName(trimToNull(mapping.getMeasurementName()));
|
mapping.setChannelCode(normalizeCollectorChannel(mapping.getChannelCode()));
|
if (mapping.getMeasurementCode() == null || mapping.getMeasurementName() == null
|
|| mapping.getChannelCode() == null)
|
throw new ServiceException("采集器通道映射不完整");
|
if (mapping.getMeasurementCode().length() > 100 || mapping.getMeasurementName().length() > 100)
|
throw new ServiceException("采集器测量项长度不能超过100个字符");
|
int channelNumber = Integer.parseInt(mapping.getChannelCode().substring(2));
|
if (channelNumber < 1 || channelNumber > channelCount)
|
throw new ServiceException("采集器通道不存在或超出登记范围");
|
if (!measurementCodes.add(mapping.getMeasurementCode()))
|
throw new ServiceException("同一电池号的采集器测量项不可重复");
|
if (!collectorChannelIdentities.add(collectorId + ":" + mapping.getChannelCode()))
|
throw new ServiceException("同一采集器通道不可重复映射");
|
}
|
if (!measurementCodes.containsAll(REQUIRED_COLLECTOR_MEASUREMENTS))
|
throw new ServiceException("每个电池号必须绑定主温度、正电位主通道和负电位主通道");
|
}
|
|
private void validateBatteryFields(List<CurrentBatteryField> fields)
|
{
|
if (fields == null) return;
|
Set<String> fieldNames = new HashSet<>();
|
for (CurrentBatteryField field : fields)
|
{
|
if (field == null) throw new ServiceException("扩展属性不能为空");
|
field.setFieldName(trimToNull(field.getFieldName()));
|
if (field.getFieldName() == null || !fieldNames.add(field.getFieldName()))
|
throw new ServiceException("扩展属性名称不能为空且不可重复");
|
}
|
}
|
|
private void populateBatteryNos(List<TTest> tests)
|
{
|
if (tests.isEmpty()) return;
|
Map<Long, List<TTestBattery>> batteriesByTest = new HashMap<>();
|
List<Long> testIds = tests.stream().map(TTest::getTestId).toList();
|
for (TTestBattery battery : mutable(testMapper.selectBatteryNosByTestIds(testIds)))
|
batteriesByTest.computeIfAbsent(battery.getTestId(), ignored -> new ArrayList<>()).add(battery);
|
for (TTest item : tests)
|
item.setBatteryNo(joinBatteryNos(batteriesByTest.get(item.getTestId())));
|
}
|
|
private String joinBatteryNos(List<TTestBattery> batteries)
|
{
|
return mutable(batteries).stream().map(TTestBattery::getBatteryNo).collect(java.util.stream.Collectors.joining("、"));
|
}
|
|
|
|
private void validateThresholds(List<TTestWarningThreshold> thresholds)
|
{
|
if (thresholds == null || thresholds.isEmpty()) throw new ServiceException("阈值配置不能为空");
|
Set<String> metricCodes = new HashSet<>();
|
for (TTestWarningThreshold threshold : thresholds)
|
{
|
if (threshold == null) throw new ServiceException("阈值配置不完整");
|
threshold.setMetricCode(trimToNull(threshold.getMetricCode()));
|
threshold.setUnit(trimToNull(threshold.getUnit()));
|
threshold.setSeverity(trimToNull(threshold.getSeverity()));
|
if (threshold.getMetricCode() == null || !metricCodes.add(threshold.getMetricCode()))
|
throw new ServiceException("阈值指标编码不能为空且不可重复");
|
if (threshold.getMinValue() == null && threshold.getMaxValue() == null)
|
throw new ServiceException("阈值最小值和最大值至少填写一项");
|
if (threshold.getMinValue() != null && threshold.getMaxValue() != null
|
&& threshold.getMinValue().compareTo(threshold.getMaxValue()) > 0)
|
throw new ServiceException("阈值最小值不能大于最大值");
|
if (!THRESHOLD_UNITS.contains(threshold.getUnit())) throw new ServiceException("阈值单位无效");
|
if (threshold.getSeverity() == null) threshold.setSeverity("WARNING");
|
}
|
}
|
|
private void saveConfiguration(TTest test)
|
{
|
for (TTestBattery battery : test.getBatteries())
|
{
|
battery.setTestId(test.getTestId());
|
testMapper.insertTestBattery(battery);
|
TTestChannelBinding binding = battery.getBinding();
|
binding.setTestId(test.getTestId());
|
binding.setBatteryRelationId(battery.getRelationId());
|
binding.setClaimStatus("PENDING");
|
testMapper.insertTestChannelBinding(binding);
|
for (TTestCollectorChannel mapping : battery.getCollectorChannels())
|
{
|
mapping.setTestId(test.getTestId());
|
mapping.setBatteryRelationId(battery.getRelationId());
|
mapping.setCollectorId(battery.getCollectorId());
|
testMapper.insertTestCollectorChannel(mapping);
|
}
|
}
|
for (TTestWarningThreshold threshold : test.getThresholds())
|
{
|
threshold.setTestId(test.getTestId());
|
testMapper.insertTestThreshold(threshold);
|
}
|
}
|
|
private void validateQueryStatus(String status, Set<String> allowed, String message)
|
{
|
if (StringUtils.isNotEmpty(status) && !allowed.contains(status)) throw new ServiceException(message);
|
}
|
|
private String trimToNull(String value)
|
{
|
String trimmed = StringUtils.trim(value);
|
return StringUtils.isEmpty(trimmed) ? null : trimmed;
|
}
|
|
private String normalizeCollectorChannel(String value)
|
{
|
String normalized = trimToNull(value);
|
if (normalized == null || !normalized.toUpperCase().matches("S-\\d+"))
|
throw new ServiceException("采集器通道必须使用S-01...S-N演示编码");
|
try
|
{
|
int channelNumber = Integer.parseInt(normalized.substring(2));
|
return String.format("S-%02d", channelNumber);
|
}
|
catch (NumberFormatException exception)
|
{
|
throw new ServiceException("采集器通道必须使用S-01...S-N演示编码");
|
}
|
}
|
|
/** 测量编码与数据库不区分大小写排序保持一致,避免校验通过后插入冲突。 */
|
private String normalizeMeasurementCode(String value)
|
{
|
String normalized = trimToNull(value);
|
return normalized == null ? null : normalized.toUpperCase(Locale.ROOT);
|
}
|
|
private <T> List<T> mutable(List<T> values)
|
{
|
return values == null ? new ArrayList<>() : new ArrayList<>(values);
|
}
|
}
|