yeg
2026-09-05 dd1af98f7e679290e5280e2aff58d7ff5237c5fb
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
@@ -5,11 +5,13 @@
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;
@@ -18,9 +20,13 @@
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;
@@ -30,12 +36,13 @@
public class TTestServiceImpl implements ITTestService
{
    private static final String WAITING = "WAITING";
    private static final String NOT_MONITORED = "NOT_MONITORED";
    private static final Set<String> TEST_STATUSES = Set.of(
            WAITING, "RUNNING", "PAUSED", "RUNNING_ABNORMAL", "COMPLETION_PENDING", "COMPLETED", "TERMINATED");
    private static final Set<String> MONITORING_STATUSES = Set.of(
            NOT_MONITORED, "WAITING_DEVICE_START", "MONITORING", "BACKFILLING", "MONITORING_ENDED", "CANCELLED");
    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;
@@ -57,8 +64,10 @@
        test.setTestName(trimToNull(test.getTestName()));
        test.setBatteryNo(trimToNull(test.getBatteryNo()));
        validateQueryStatus(test.getTestStatus(), TEST_STATUSES, "测试状态无效");
        validateQueryStatus(test.getMonitoringStatus(), MONITORING_STATUSES, "监测状态无效");
        return testMapper.selectTestList(test);
        validateQueryStatus(test.getReceptionStatus(), RECEPTION_STATUSES, "接收状态无效");
        List<TTest> tests = mutable(testMapper.selectTestList(test));
        populateBatteryNos(tests);
        return tests;
    }
    @Override
@@ -83,13 +92,12 @@
        validateBase(test, false);
        validateConfiguration(test);
        test.setTestStatus(WAITING);
        test.setMonitoringStatus(NOT_MONITORED);
        test.setReceptionStatus(NOT_STARTED);
        test.setDelFlag("0");
        test.setCreateTime(new Date());
        testMapper.insertTest(test);
        saveConfiguration(test);
        testMapper.insertTestAudit(test.getTestId(), "TEST_CREATED", test.getOperatorId(), test.getCreateBy(),
                null, JSON.toJSONString(test));
        // 最终基线移除了 t_test_audit;Controller 的 @Log 负责记录创建操作,业务表只保存配置事实。
        return test.getTestId();
    }
@@ -101,12 +109,12 @@
        TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
        if (current == null) throw new ServiceException("测试不存在");
        loadConfiguration(current);
        if (!NOT_MONITORED.equals(current.getMonitoringStatus()))
            throw new ServiceException("开始监测后不能修改测试配置");
        if (!stateMatches(current, WAITING, NOT_STARTED))
            throw new ServiceException("开始接收后不能修改测试配置");
        test.setCreatorId(current.getCreatorId());
        test.setTestStatus(current.getTestStatus());
        test.setMonitoringStatus(current.getMonitoringStatus());
        test.setReceptionStatus(current.getReceptionStatus());
        if (test.getMonitorTimeoutMinutes() == null)
            test.setMonitorTimeoutMinutes(current.getMonitorTimeoutMinutes());
        validateBase(test, true);
@@ -115,22 +123,146 @@
        int rows = testMapper.updateTest(test);
        if (rows != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
        // 监测开始前没有原始采集事实,配置关系可在同一事务内整体替换,审计记录仍只追加。
        // 开始接收前没有数据归属事实,配置关系可在同一事务内整体替换;操作审计由 Controller @Log 追加。
        testMapper.deleteTestCollectorChannelsByTestId(test.getTestId());
        testMapper.deleteTestBindingsByTestId(test.getTestId());
        testMapper.deleteTestPlanStepsByTestId(test.getTestId());
        testMapper.deleteTestFieldsByTestId(test.getTestId());
        testMapper.deleteTestBatteriesByTestId(test.getTestId());
        testMapper.deleteTestThresholdsByTestId(test.getTestId());
        saveConfiguration(test);
        testMapper.insertTestAudit(test.getTestId(), "TEST_CONFIGURATION_UPDATED", test.getOperatorId(),
                test.getUpdateBy(), JSON.toJSONString(current), JSON.toJSONString(test));
        return rows;
    }
    @Override
    public int startTest(Long testId, String updateBy)
    {
        throw new ServiceException("开始监测由后续监测动作处理");
        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 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
@@ -143,12 +275,22 @@
        {
            TTest current = testMapper.selectTestByIdForUpdate(testId);
            if (current == null) throw new ServiceException("测试不存在");
            if (!NOT_MONITORED.equals(current.getMonitoringStatus())) 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("测试配置已变更,请刷新后重试");
            testMapper.insertTestAudit(testId, "TEST_DELETED", operatorId, updateBy,
                    JSON.toJSONString(current), null);
            rows += deleted;
        }
        return rows;
@@ -162,37 +304,129 @@
        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<TTestPlanStep> planSteps = mutable(testMapper.selectTestPlanSteps(testId));
        for (TTestPlanStep step : planSteps)
        {
            try
            {
                step.setParameters(JSON.parseObject(step.getParametersJson()));
            }
            catch (JSONException exception)
            {
                throw new ServiceException("计划工步参数无法解析");
            }
        }
        List<TTestBattery> batteries = mutable(testMapper.selectTestBatteries(testId));
        List<TTestBatteryField> fields = mutable(testMapper.selectTestBatteryFields(testId));
        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<>();
        for (TTestChannelBinding binding : mutable(testMapper.selectTestChannelBindings(testId)))
        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(fields.stream()
                    .filter(field -> battery.getRelationId().equals(field.getRelationId()))
                    .toList());
            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.setPlanSteps(planSteps);
        test.setBatteries(batteries);
        test.setThresholds(mutable(testMapper.selectTestThresholds(testId)));
        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)
@@ -220,8 +454,8 @@
    private void validateConfiguration(TTest test)
    {
        // 新建测试只建立业务身份和通道关系,计划工步可在开始监测前补充。
        if (test.getPlanSteps() == null) test.setPlanSteps(new ArrayList<>());
        // 计划工步来自设备观测,不属于测试创建配置,也不写入业务库。
        test.setPlanSteps(new ArrayList<>());
        validateBatteries(test.getBatteries());
        validatePlanSteps(test.getPlanSteps());
        validateThresholds(test.getThresholds());
@@ -232,6 +466,8 @@
        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)
        {
@@ -252,10 +488,45 @@
            binding.setExpectedBarcode(trimToNull(binding.getExpectedBarcode()));
            if (binding.getExpectedBarcode() != null && binding.getExpectedBarcode().length() > 100)
                throw new ServiceException("预期条码长度不能超过100个字符");
            if (battery.getCollectorId() != null && testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
            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<TTestBatteryField> fields)
@@ -269,6 +540,22 @@
            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)
@@ -316,28 +603,22 @@
    private void saveConfiguration(TTest test)
    {
        for (TTestPlanStep step : test.getPlanSteps())
        {
            step.setTestId(test.getTestId());
            testMapper.insertTestPlanStep(step);
        }
        for (TTestBattery battery : test.getBatteries())
        {
            battery.setTestId(test.getTestId());
            testMapper.insertTestBattery(battery);
            if (battery.getFields() != null)
            {
                for (TTestBatteryField field : battery.getFields())
                {
                    field.setRelationId(battery.getRelationId());
                    testMapper.insertTestBatteryField(field);
                }
            }
            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())
        {
@@ -357,6 +638,29 @@
        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);