From dd1af98f7e679290e5280e2aff58d7ff5237c5fb Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 11:17:11 +0800
Subject: [PATCH] 实现测试数据物理删除修订

---
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java |  634 +++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 539 insertions(+), 95 deletions(-)

diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
index 33cfd05..a6e59f1 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
@@ -1,11 +1,17 @@
 package com.ruoyi.system.service.impl;
 
-import java.math.BigDecimal;
+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;
@@ -13,22 +19,30 @@
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.domain.TTestBatteryField;
+import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCollectorChannel;
+import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TTestLogicStepMapping;
+import com.ruoyi.system.domain.TTestConsoleDetail;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.vo.TestConfigurationOptionsVo;
 import com.ruoyi.system.mapper.TTestMapper;
 import com.ruoyi.system.service.ITTestService;
 
-/**
- * 测试管理事务服务。
- * 负责测试资源校验、状态机和聚合关系写入,避免检测过程中修改资源配置。
- */
+/** 测试配置服务,负责状态边界、关系校验、事务写入和只追加审计。 */
 @Service
 public class TTestServiceImpl implements ITTestService
 {
-    private static final String WAITING = "0";
-    private static final String TESTING = "1";
-    private static final String ABNORMAL = "2";
-    private static final String FINISHED = "3";
+    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;
@@ -39,14 +53,7 @@
         if (testId == null) throw new ServiceException("测试ID不能为空");
         TTest test = testMapper.selectTestById(testId);
         if (test == null) throw new ServiceException("测试不存在");
-        List<TTestBattery> batteries = testMapper.selectTestBatteries(testId);
-        List<TTestBatteryField> fields = testMapper.selectTestBatteryFields(testId);
-        for (TTestBattery battery : batteries)
-        {
-            battery.setFields(fields.stream().filter(field -> battery.getRelationId().equals(field.getRelationId())).toList());
-        }
-        test.setBatteries(batteries);
-        test.setThresholds(testMapper.selectTestThresholds(testId));
+        loadConfiguration(test);
         return test;
     }
 
@@ -56,64 +63,236 @@
         if (test == null) test = new TTest();
         test.setTestName(trimToNull(test.getTestName()));
         test.setBatteryNo(trimToNull(test.getBatteryNo()));
-        validateStatus(test.getStatus());
-        return testMapper.selectTestList(test);
+        validateQueryStatus(test.getTestStatus(), TEST_STATUSES, "测试状态无效");
+        validateQueryStatus(test.getReceptionStatus(), RECEPTION_STATUSES, "接收状态无效");
+        List<TTest> tests = mutable(testMapper.selectTestList(test));
+        populateBatteryNos(tests);
+        return tests;
+    }
+
+    @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;
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public int insertTest(TTest test)
+    public Long createTest(TTest test)
     {
+        if (test == null) throw new ServiceException("测试信息不能为空");
+        test.setCreatorId(test.getOperatorId());
+        if (test.getMonitorTimeoutMinutes() == null) test.setMonitorTimeoutMinutes(30);
         validateBase(test, false);
-        validateRelations(test);
-        test.setStatus(WAITING);
+        validateConfiguration(test);
+        test.setTestStatus(WAITING);
+        test.setReceptionStatus(NOT_STARTED);
         test.setDelFlag("0");
         test.setCreateTime(new Date());
         testMapper.insertTest(test);
-        saveRelations(test);
-        return 1;
+        saveConfiguration(test);
+        // 最终基线移除了 t_test_audit;Controller 的 @Log 负责记录创建操作,业务表只保存配置事实。
+        return test.getTestId();
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public int updateTest(TTest test)
     {
+        if (test == null || test.getTestId() == null) throw new ServiceException("测试ID不能为空");
+        TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
+        if (current == null) throw new ServiceException("测试不存在");
+        loadConfiguration(current);
+        if (!stateMatches(current, WAITING, NOT_STARTED))
+            throw new ServiceException("开始接收后不能修改测试配置");
+
+        test.setCreatorId(current.getCreatorId());
+        test.setTestStatus(current.getTestStatus());
+        test.setReceptionStatus(current.getReceptionStatus());
+        if (test.getMonitorTimeoutMinutes() == null)
+            test.setMonitorTimeoutMinutes(current.getMonitorTimeoutMinutes());
         validateBase(test, true);
-        TTest current = selectTestById(test.getTestId());
-        if (!WAITING.equals(current.getStatus())) throw new ServiceException("仅待检测状态允许编辑草稿");
-        validateRelations(test);
+        validateConfiguration(test);
         test.setUpdateTime(new Date());
         int rows = testMapper.updateTest(test);
-        testMapper.deleteTestFieldsByTestId(test.getTestId(), test.getUpdateBy());
-        testMapper.deleteTestBatteriesByTestId(test.getTestId(), test.getUpdateBy());
-        testMapper.deleteTestThresholdsByTestId(test.getTestId(), test.getUpdateBy());
-        saveRelations(test);
+        if (rows != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
+
+        // 开始接收前没有数据归属事实,配置关系可在同一事务内整体替换;操作审计由 Controller @Log 追加。
+        testMapper.deleteTestCollectorChannelsByTestId(test.getTestId());
+        testMapper.deleteTestBindingsByTestId(test.getTestId());
+        testMapper.deleteTestBatteriesByTestId(test.getTestId());
+        testMapper.deleteTestThresholdsByTestId(test.getTestId());
+        saveConfiguration(test);
         return rows;
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public int startTest(Long testId, String updateBy)
     {
-        TTest current = selectTestById(testId);
-        if (!WAITING.equals(current.getStatus())) throw new ServiceException("只有待检测状态可以启动");
-        validateRelations(current);
-        return testMapper.updateTestStatus(testId, TESTING, updateBy);
+        throw new 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("操作人不能为空");
+        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("测试未配置电池号");
+
+        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);
+
+        }
+
+        try
+        {
+            if (testMapper.updateReceptionStarted(testId, actionAt, runtimeAttach, updateBy) != 1)
+                throw new ServiceException("测试状态已变更,请刷新后重试");
+            testMapper.insertTestAudit(testId, "RECEPTION_STARTED", operatorId, updateBy, null,
+                    JSON.toJSONString(Map.of("receptionStatus", "RECEIVING", "runtimeAttach", runtimeAttach)));
+            return 1;
+        }
+        catch (DuplicateKeyException exception)
+        {
+            throw new ServiceException("数据来源已被其他测试占用,请刷新后重试");
+        }
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public int deleteTestByIds(Long[] testIds, String updateBy)
+    public int pauseReception(Long testId, Long operatorId, String updateBy)
+    {
+        if (testId == null) throw new ServiceException("测试ID不能为空");
+        if (operatorId == null) throw new ServiceException("操作人不能为空");
+        TTest current = testMapper.selectTestByIdForUpdate(testId);
+        if (current == null) throw new ServiceException("测试不存在");
+        if (!stateMatches(current, "RUNNING", "RECEIVING"))
+            throw new ServiceException("当前测试不是接收中状态");
+
+        Date actionAt = new Date();
+        if (testMapper.updateReceptionPaused(testId, updateBy, actionAt) != 1)
+            throw new ServiceException("测试状态已变更,请刷新后重试");
+        // 暂停期间停止请求数据;继续接收后仅追加新的成功接收事实。
+        testMapper.insertTestAudit(testId, "RECEPTION_PAUSED", operatorId, updateBy, null,
+                JSON.toJSONString(Map.of("receptionStatus", "PAUSED", "polling", "STOPPED")));
+        return 1;
+    }
+
+    @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("操作人不能为空");
+        TTest current = testMapper.selectTestByIdForUpdate(testId);
+        if (current == null) throw new ServiceException("测试不存在");
+        if (!stateMatches(current, "PAUSED", "PAUSED"))
+            throw new ServiceException("当前测试不是暂停接收状态");
+        // 加锁查询只返回测试主表,继续接收前重新加载绑定配置以校验来源仍可用。
+        loadConfiguration(current);
+        Date actionAt = new Date();
+        if (testMapper.updateReceptionResumed(testId, updateBy, actionAt) != 1)
+            throw new ServiceException("测试状态已变更,请刷新后重试");
+        testMapper.insertTestAudit(testId, "RECEPTION_RESUMED", operatorId, updateBy, null,
+                JSON.toJSONString(Map.of("receptionStatus", "RECEIVING", "polling", "RESUMED")));
+        return 1;
+    }
+
+    @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("操作人不能为空");
+        TTest current = testMapper.selectTestByIdForUpdate(testId);
+        if (current == null) throw new ServiceException("测试不存在");
+        if (!(stateMatches(current, "RUNNING", "RECEIVING")
+                || stateMatches(current, "PAUSED", "PAUSED")))
+            throw new ServiceException("当前测试不能停止");
+        Date actionAt = new Date();
+        if (testMapper.updateReceptionStopped(testId, updateBy, actionAt) != 1)
+            throw new ServiceException("测试状态已变更,请刷新后重试");
+        testMapper.insertTestAudit(testId, "TEST_STOPPED", operatorId, updateBy, null,
+                JSON.toJSONString(Map.of("testStatus", "COMPLETED", "receptionStatus", "ENDED",
+                        "polling", "STOPPED", "release", "IMMEDIATE")));
+        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());
+    }
+
+    @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)
         {
-            TTest test = selectTestById(testId);
-            if (TESTING.equals(test.getStatus()) || ABNORMAL.equals(test.getStatus()))
-                throw new ServiceException("检测中或异常测试不能删除");
+            TTest current = testMapper.selectTestByIdForUpdate(testId);
+            if (current == null) throw new ServiceException("测试不存在");
+            if (!stateMatches(current, WAITING, NOT_STARTED)) throw new ServiceException("开始接收后不能删除测试");
+            loadConfiguration(current);
+            // 测试整体删除是无痕例外:先清理全部测试绑定和阈值,再删除主记录,不写审计。
+            testMapper.deleteTestRealtimeRecordsByTestId(testId);
+            testMapper.deleteTestRealtimeSnapshotsByTestId(testId);
+            testMapper.deleteTestStepRecordsByTestId(testId);
+            testMapper.deleteTestCycleRecordsByTestId(testId);
+            testMapper.deleteTestDataRevisionActionsByTestId(testId);
+            testMapper.deleteTestDataOverridesByTestId(testId);
+            testMapper.deleteTestConsoleImportRecordsByTestId(testId);
+            testMapper.deleteTestCollectorChannelsByTestId(testId);
+            testMapper.deleteTestBindingsByTestId(testId);
+            testMapper.deleteTestThresholdRowsByTestId(testId);
+            testMapper.deleteTestBatteriesByTestId(testId);
+            int deleted = testMapper.deleteTestById(testId, updateBy);
+            if (deleted != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
+            rows += deleted;
         }
-        int rows = 0;
-        for (Long testId : testIds) rows += testMapper.deleteTestById(testId, updateBy);
         return rows;
     }
 
@@ -125,9 +304,133 @@
         return testMapper.selectTestResults(result);
     }
 
+    @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("测试不存在");
+        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++;
+        }
+        String oldJson = current.getLogicStepMappingsJson();
+        String newJson = JSON.toJSONString(normalized);
+        int rows = testMapper.updateLogicStepMappings(testId, newJson, updateBy, new Date());
+        if (rows != 1) throw new ServiceException("工步设置已变更,请刷新后重试");
+        testMapper.insertTestAudit(testId, "LOGIC_STEP_MAPPING_UPDATED", operatorId, updateBy, oldJson, newJson);
+        return rows;
+    }
+
+    @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("测试详情不能为空");
+        TTest current = testMapper.selectTestByIdForUpdate(testId);
+        if (current == null) throw new ServiceException("测试不存在");
+        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);
+        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<TTestBatteryField> currentFields = testMapper.selectCurrentBatteryFields(testId);
+        Map<Long, List<TTestBatteryField>> fieldsByBattery = new HashMap<>();
+        for (TTestBatteryField 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));
+        // 测试不保存字段快照;字段值始终随电池组当前配置读取。
+        test.setFieldSnapshots(new ArrayList<>());
+        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)
     {
-        if (test == null) throw new ServiceException("测试信息不能为空");
         test.setTestName(trimToNull(test.getTestName()));
         test.setTestPurpose(trimToNull(test.getTestPurpose()));
         test.setRemark(trimToNull(test.getRemark()));
@@ -135,68 +438,186 @@
         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.getPrincipalId() == null || testMapper.countUserEnabled(test.getPrincipalId()) == 0) throw new ServiceException("负责人不存在或已停用");
-        if (test.getTestTypeId() == null || testMapper.countTestTypeEnabled(test.getTestTypeId()) == 0) throw new ServiceException("测试类型不存在或未启用");
+        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("测试名称已存在");
+        if (same != null && (!update || !test.getTestId().equals(same.getTestId())))
+            throw new ServiceException("测试名称已存在");
     }
 
-    private void validateRelations(TTest test)
+    private void validateConfiguration(TTest test)
     {
-        if (test.getBatteries() == null || test.getBatteries().isEmpty()) throw new ServiceException("至少配置一个测试电池");
-        if (test.getThresholds() == null || test.getThresholds().isEmpty()) throw new ServiceException("至少配置一个预警阈值");
+        // 计划工步来自设备观测,不属于测试创建配置,也不写入业务库。
+        test.setPlanSteps(new ArrayList<>());
+        validateBatteries(test.getBatteries());
+        validatePlanSteps(test.getPlanSteps());
+        validateThresholds(test.getThresholds());
+    }
+
+    private void validateBatteries(List<TTestBattery> batteries)
+    {
+        if (batteries == null || batteries.isEmpty()) throw new ServiceException("测试至少绑定一个电池号");
         Set<String> batteryNos = new HashSet<>();
-        Set<String> resources = new HashSet<>();
-        for (TTestBattery battery : test.getBatteries())
+        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 || StringUtils.isEmpty(trimToNull(battery.getBatteryNo()))
-                    || battery.getCabinetId() == null || battery.getCollectorId() == null) throw new ServiceException("测试电池关系资料不完整");
+            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()));
-            validateChannel(battery.getTestChannelNo(), "测试通道");
-            validateChannel(battery.getTemperatureChannelNo(), "温度通道");
-            validateChannel(battery.getPositiveChannelNo(), "正电位通道");
-            validateChannel(battery.getNegativeChannelNo(), "负电位通道");
-            if (battery.getTemperatureChannelNo().equals(battery.getPositiveChannelNo())
-                    || battery.getTemperatureChannelNo().equals(battery.getNegativeChannelNo())
-                    || battery.getPositiveChannelNo().equals(battery.getNegativeChannelNo())) throw new ServiceException("温度及正负电位通道不能重复");
-            if (!batteryNos.add(battery.getBatteryNo())) throw new ServiceException("测试电池号不能重复");
-            String resource = battery.getCabinetId() + ":" + battery.getTestChannelNo();
-            if (!resources.add(resource)) throw new ServiceException("检测柜测试通道不能重复");
+            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("电池号不属于指定的启用电池组");
-            if (testMapper.countCabinetEnabled(battery.getCabinetId()) == 0)
-                throw new ServiceException("检测柜不存在或未启用");
-            if (testMapper.countCollectorEnabled(battery.getCollectorId(), battery.getCabinetId()) == 0)
-                throw new ServiceException("采集器不存在、未启用或不属于指定检测柜");
-            if (testMapper.countTestingByBatteryNo(battery.getBatteryNo(), test.getTestId()) > 0
-                    || testMapper.countTestingByCabinetChannel(battery.getCabinetId(), battery.getTestChannelNo(), test.getTestId()) > 0)
-                throw new ServiceException("电池号或检测柜测试通道已被检测中的任务占用");
-            if (battery.getFields() == null || battery.getFields().isEmpty()) throw new ServiceException("每个测试电池必须配置扩展属性");
-            Set<String> fieldNames = new HashSet<>();
-            for (TTestBatteryField field : battery.getFields())
-                if (field == null || StringUtils.isEmpty(trimToNull(field.getFieldName())) || !fieldNames.add(field.getFieldName().trim())) throw new ServiceException("扩展属性名称不能为空且不能重复");
-        }
-        Set<String> thresholdTypes = new HashSet<>();
-        for (TTestWarningThreshold threshold : test.getThresholds())
-        {
-            if (threshold == null || StringUtils.isEmpty(trimToNull(threshold.getThresholdType())) || threshold.getMinValue() == null || threshold.getMaxValue() == null) throw new ServiceException("阈值配置不完整");
-            threshold.setThresholdType(threshold.getThresholdType().trim());
-            if (!thresholdTypes.add(threshold.getThresholdType())) throw new ServiceException("阈值类型不能重复");
-            if (threshold.getMinValue().compareTo(threshold.getMaxValue()) > 0) throw new ServiceException("阈值最小值不能大于最大值");
-            if (!threshold.getThresholdType().contains(":")) throw new ServiceException("阈值类型必须包含通道号");
+                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 saveRelations(TTest test)
+    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<TTestBatteryField> fields)
+    {
+        if (fields == null) return;
+        Set<String> fieldNames = new HashSet<>();
+        for (TTestBatteryField 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 validatePlanSteps(List<TTestPlanStep> planSteps)
+    {
+        if (planSteps.isEmpty()) return;
+        Set<String> identities = new HashSet<>();
+        Set<Integer> orders = new HashSet<>();
+        for (TTestPlanStep step : planSteps)
+        {
+            if (step == null || step.getCycleNo() == null || step.getCycleNo() < 1
+                    || step.getStepNo() == null || step.getStepNo() < 1
+                    || step.getSortOrder() == null || step.getSortOrder() < 1)
+                throw new ServiceException("计划工步序号必须为正整数");
+            step.setStepType(trimToNull(step.getStepType()));
+            step.setStepName(trimToNull(step.getStepName()));
+            if (step.getStepType() == null || step.getStepName() == null || step.getParameters() == null)
+                throw new ServiceException("计划工步配置不完整");
+            if (!identities.add(step.getCycleNo() + ":" + step.getStepNo()) || !orders.add(step.getSortOrder()))
+                throw new ServiceException("计划工步序号不可重复");
+            step.setParametersJson(JSON.toJSONString(step.getParameters()));
+        }
+    }
+
+    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);
-            for (TTestBatteryField field : battery.getFields())
+            TTestChannelBinding binding = battery.getBinding();
+            binding.setTestId(test.getTestId());
+            binding.setBatteryRelationId(battery.getRelationId());
+            binding.setClaimStatus("PENDING");
+            testMapper.insertTestChannelBinding(binding);
+            for (TTestCollectorChannel mapping : battery.getCollectorChannels())
             {
-                field.setRelationId(battery.getRelationId());
-                testMapper.insertTestBatteryField(field);
+                mapping.setTestId(test.getTestId());
+                mapping.setBatteryRelationId(battery.getRelationId());
+                mapping.setCollectorId(battery.getCollectorId());
+                testMapper.insertTestCollectorChannel(mapping);
             }
         }
         for (TTestWarningThreshold threshold : test.getThresholds())
@@ -206,14 +627,9 @@
         }
     }
 
-    private void validateChannel(Integer channel, String name)
+    private void validateQueryStatus(String status, Set<String> allowed, String message)
     {
-        if (channel == null || channel < 0) throw new ServiceException(name + "必须为非负整数");
-    }
-
-    private void validateStatus(String status)
-    {
-        if (StringUtils.isNotEmpty(status) && !WAITING.equals(status) && !TESTING.equals(status) && !ABNORMAL.equals(status) && !FINISHED.equals(status)) throw new ServiceException("测试状态无效");
+        if (StringUtils.isNotEmpty(status) && !allowed.contains(status)) throw new ServiceException(message);
     }
 
     private String trimToNull(String value)
@@ -221,4 +637,32 @@
         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);
+    }
 }

--
Gitblit v1.7.1