From 5a18d863778ffabcc45da185f7f77e7462289bb9 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 27 八月 2026 20:45:31 +0800
Subject: [PATCH] feat(test): implement test configuration and channel binding

---
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestConfigurationOptionsVo.java      |   24 +
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                |    6 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java             |   12 
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java          |  275 +++++++---
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestBatteryOptionVo.java             |   19 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestUserOptionVo.java                |   16 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningThreshold.java              |   32 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestTypeOptionVo.java                |   13 
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                              |  296 ++++++++++--
 ruoyi-admin/src/main/resources/db/migration/V10__test_configuration_collector.sql          |    6 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                        |   40 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestChannelOptionVo.java             |   37 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java                              |   77 ++-
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java                |   60 ++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java             |  321 +++++++++---
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestBattery.java                       |   56 -
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java |   24 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestPlanStep.java                      |   53 ++
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java                     |   15 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestCollectorOptionVo.java           |   16 
 20 files changed, 1,077 insertions(+), 321 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
index 555b1f2..91dd05f 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
@@ -30,6 +30,13 @@
     @Autowired
     private ITTestService testService;
 
+    @PreAuthorize("@ss.hasAnyPermi('system:test:add,system:test:edit')")
+    @GetMapping("/options")
+    public AjaxResult options()
+    {
+        return success(testService.selectConfigurationOptions());
+    }
+
     @PreAuthorize("@ss.hasPermi('system:test:list')")
     @GetMapping("/list")
     public TableDataInfo list(TTest test)
@@ -51,8 +58,10 @@
     @PostMapping
     public AjaxResult add(@Validated @RequestBody TTest test)
     {
+        test.setOperatorId(getUserId());
+        test.setCreatorId(getUserId());
         test.setCreateBy(getUsername());
-        return toAjax(testService.insertTest(test));
+        return success(testService.createTest(test));
     }
 
     @PreAuthorize("@ss.hasPermi('system:test:edit')")
@@ -60,6 +69,7 @@
     @PutMapping
     public AjaxResult edit(@Validated @RequestBody TTest test)
     {
+        test.setOperatorId(getUserId());
         test.setUpdateBy(getUsername());
         return toAjax(testService.updateTest(test));
     }
diff --git a/ruoyi-admin/src/main/resources/db/migration/V10__test_configuration_collector.sql b/ruoyi-admin/src/main/resources/db/migration/V10__test_configuration_collector.sql
new file mode 100644
index 0000000..169e8d2
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V10__test_configuration_collector.sql
@@ -0,0 +1,6 @@
+-- 采集器是测试电池号的可选辅助设备;为空不影响仅使用检测柜通道的测试配置。
+ALTER TABLE t_test_battery
+    ADD COLUMN collector_id BIGINT NULL COMMENT '可选辅助采集器' AFTER battery_no,
+    ADD KEY idx_test_battery_collector (collector_id),
+    ADD CONSTRAINT fk_test_battery_collector
+        FOREIGN KEY (collector_id) REFERENCES t_collector (collector_id);
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
index 4d7ff68..f9236f0 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -83,6 +83,12 @@
         assertFalse(v9.contains("DROP FOREIGN KEY fk_channel_adapter"));
         assertFalse(v9.contains("DROP FOREIGN KEY fk_cabinet_adapter"));
         assertFalse(v9.matches("(?s).*t_test_battery\\s+\\w+\\s+WHERE\\s+\\w+\\.cabinet_id.*"));
+
+        String v10 = resource("db/migration/V10__test_configuration_collector.sql");
+        assertTrue(v10.contains("ALTER TABLE t_test_battery"));
+        assertTrue(v10.contains("collector_id"));
+        assertTrue(v10.contains("fk_test_battery_collector"));
+        assertFalse(v10.matches("(?s).*\\bINSERT\\s+INTO\\b.*"));
     }
 
     private static String sha256(String path)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
index b96b26b..71aa715 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
@@ -4,7 +4,7 @@
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -12,6 +12,7 @@
 
 import java.math.BigDecimal;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
@@ -20,11 +21,12 @@
 import com.ruoyi.common.exception.ServiceException;
 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.TTestPlanStep;
 import com.ruoyi.system.domain.TTestWarningThreshold;
 import com.ruoyi.system.mapper.TTestMapper;
 
-/** 测试管理 Service 核心业务单元测试。 */
+/** 测试配置服务的业务契约测试。 */
 @ExtendWith(MockitoExtension.class)
 class TTestServiceImplTest
 {
@@ -35,10 +37,10 @@
     private TTestServiceImpl testService;
 
     @Test
-    void insertShouldBackfillRelationIdBeforeSavingFields()
+    void createShouldReturnIdentifierAndInitializeIndependentStatuses()
     {
-        TTest test = validTest(null, "0");
-        mockEnabledResources();
+        TTest test = validTest();
+        mockEnabledOptions();
         when(testMapper.insertTest(any(TTest.class))).thenAnswer(invocation -> {
             invocation.<TTest>getArgument(0).setTestId(10L);
             return 1;
@@ -48,118 +50,217 @@
             return 1;
         }).when(testMapper).insertTestBattery(any(TTestBattery.class));
 
-        assertEquals(1, testService.insertTest(test));
-
-        verify(testMapper).insertTestBatteryField(any(TTestBatteryField.class));
-        assertEquals(20L, test.getBatteries().get(0).getFields().get(0).getRelationId());
+        assertEquals(10L, testService.createTest(test));
+        assertEquals("WAITING", test.getTestStatus());
+        assertEquals("NOT_MONITORED", test.getMonitoringStatus());
+        assertEquals(7L, test.getCreatorId());
+        verify(testMapper).insertTestPlanStep(any(TTestPlanStep.class));
+        verify(testMapper).insertTestChannelBinding(any(TTestChannelBinding.class));
+        verify(testMapper).insertTestThreshold(any(TTestWarningThreshold.class));
+        verify(testMapper).insertTestAudit(anyLong(), any(), anyLong(), any(), isNull(), any());
     }
 
     @Test
-    void insertShouldRejectOccupiedBattery()
+    void createShouldAllowMissingCollector()
     {
-        TTest test = validTest(null, "0");
-        mockEnabledResources();
-        when(testMapper.countTestingByBatteryNo("BAT-001", null)).thenReturn(1);
+        TTest test = validTest();
+        test.getBatteries().get(0).setCollectorId(null);
+        mockEnabledPeopleAndType();
+        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
+        when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
+        when(testMapper.insertTest(any(TTest.class))).thenAnswer(invocation -> {
+            invocation.<TTest>getArgument(0).setTestId(10L);
+            return 1;
+        });
+        doAnswer(invocation -> {
+            invocation.<TTestBattery>getArgument(0).setRelationId(20L);
+            return 1;
+        }).when(testMapper).insertTestBattery(any(TTestBattery.class));
 
-        ServiceException exception = assertThrows(ServiceException.class, () -> testService.insertTest(test));
+        assertEquals(10L, testService.createTest(test));
 
-        assertEquals("电池号或检测柜测试通道已被检测中的任务占用", exception.getMessage());
+        verify(testMapper, never()).countCollectorEnabled(anyLong());
+    }
+
+    @Test
+    void createShouldRejectBatteryOutsideSelectedGroup()
+    {
+        TTest test = validTest();
+        mockEnabledPeopleAndType();
+        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(0);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("电池号不属于所选启用电池组", exception.getMessage());
         verify(testMapper, never()).insertTest(any(TTest.class));
     }
 
     @Test
-    void updateShouldRejectNonWaitingStatus()
+    void createShouldRejectDisabledCreator()
     {
-        TTest request = validTest(10L, "0");
-        TTest current = validTest(10L, "1");
-        when(testMapper.countUserEnabled(1L)).thenReturn(1);
-        when(testMapper.countTestTypeEnabled(1L)).thenReturn(1);
-        when(testMapper.selectTestById(10L)).thenReturn(current);
-        when(testMapper.selectTestBatteries(10L)).thenReturn(current.getBatteries());
-        when(testMapper.selectTestBatteryFields(10L)).thenReturn(current.getBatteries().get(0).getFields());
-        when(testMapper.selectTestThresholds(10L)).thenReturn(current.getThresholds());
+        TTest test = validTest();
+        when(testMapper.countUserEnabled(7L)).thenReturn(0);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("创建人不存在或已停用", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
+    }
+
+    @Test
+    void createShouldRejectDisabledPrincipal()
+    {
+        TTest test = validTest();
+        when(testMapper.countUserEnabled(7L)).thenReturn(1);
+        when(testMapper.countUserEnabled(9L)).thenReturn(0);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("测试负责人不存在或已停用", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
+    }
+
+    @Test
+    void createShouldRejectDuplicatePrimaryChannelBinding()
+    {
+        TTest test = validTest();
+        TTestBattery duplicate = validBattery("BAT-002", 2L, 100L);
+        test.setBatteries(java.util.List.of(test.getBatteries().get(0), duplicate));
+        mockEnabledPeopleAndType();
+        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
+        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-002")).thenReturn(1);
+        when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("同一检测柜通道不可重复绑定", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
+    }
+
+    @Test
+    void createShouldRejectBatteriesFromDifferentGroups()
+    {
+        TTest test = validTest();
+        TTestBattery otherGroup = validBattery("BAT-002", 2L, 101L);
+        otherGroup.setGroupId(2L);
+        test.setBatteries(java.util.List.of(test.getBatteries().get(0), otherGroup));
+        mockEnabledPeopleAndType();
+        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
+        when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("同一测试只能选择一个电池组", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
+    }
+
+    @Test
+    void updateShouldRejectConfigurationAfterMonitoringStarted()
+    {
+        TTest request = validTest();
+        request.setTestId(10L);
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setMonitoringStatus("MONITORING");
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
 
         ServiceException exception = assertThrows(ServiceException.class, () -> testService.updateTest(request));
 
-        assertEquals("仅待检测状态允许编辑草稿", exception.getMessage());
+        assertEquals("开始监测后不能修改测试配置", exception.getMessage());
         verify(testMapper, never()).updateTest(any(TTest.class));
     }
 
     @Test
-    void startShouldTransitionWaitingToTesting()
+    void updateShouldReplaceConfigurationBeforeMonitoringAndAppendAudit()
     {
-        TTest current = validTest(10L, "0");
-        when(testMapper.selectTestById(10L)).thenReturn(current);
-        when(testMapper.selectTestBatteries(10L)).thenReturn(current.getBatteries());
-        when(testMapper.selectTestBatteryFields(10L)).thenReturn(current.getBatteries().get(0).getFields());
-        when(testMapper.selectTestThresholds(10L)).thenReturn(current.getThresholds());
+        TTest request = validTest();
+        request.setTestId(10L);
+        request.setMonitorTimeoutMinutes(45);
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setCreatorId(7L);
+        current.setMonitoringStatus("NOT_MONITORED");
+        current.setMonitorTimeoutMinutes(30);
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        mockEnabledOptions();
+        when(testMapper.updateTest(request)).thenReturn(1);
+        doAnswer(invocation -> {
+            invocation.<TTestBattery>getArgument(0).setRelationId(21L);
+            return 1;
+        }).when(testMapper).insertTestBattery(any(TTestBattery.class));
+
+        assertEquals(1, testService.updateTest(request));
+        assertEquals(45, request.getMonitorTimeoutMinutes());
+        verify(testMapper).deleteTestBindingsByTestId(10L);
+        verify(testMapper).deleteTestPlanStepsByTestId(10L);
+        verify(testMapper).insertTestAudit(anyLong(), any(), anyLong(), any(), any(), any());
+    }
+
+    private void mockEnabledOptions()
+    {
+        mockEnabledPeopleAndType();
         when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
-        when(testMapper.countCabinetEnabled(1L)).thenReturn(1);
-        when(testMapper.countCollectorEnabled(1L, 1L)).thenReturn(1);
-        when(testMapper.updateTestStatus(10L, "1", "admin")).thenReturn(1);
-
-        assertEquals(1, testService.startTest(10L, "admin"));
-        verify(testMapper).updateTestStatus(10L, "1", "admin");
+        when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
     }
 
-    @Test
-    void deleteShouldRejectTestingTask()
+    private void mockEnabledPeopleAndType()
     {
-        TTest current = validTest(10L, "1");
-        when(testMapper.selectTestById(10L)).thenReturn(current);
-        when(testMapper.selectTestBatteries(10L)).thenReturn(current.getBatteries());
-        when(testMapper.selectTestBatteryFields(10L)).thenReturn(current.getBatteries().get(0).getFields());
-        when(testMapper.selectTestThresholds(10L)).thenReturn(current.getThresholds());
-
-        ServiceException exception = assertThrows(ServiceException.class,
-                () -> testService.deleteTestByIds(new Long[] {10L}, "admin"));
-
-        assertEquals("检测中或异常测试不能删除", exception.getMessage());
-        verify(testMapper, never()).deleteTestById(anyLong(), anyString());
+        when(testMapper.countUserEnabled(7L)).thenReturn(1);
+        when(testMapper.countUserEnabled(9L)).thenReturn(1);
+        when(testMapper.countTestTypeEnabled(3L)).thenReturn(1);
     }
 
-    private void mockEnabledResources()
+    private TTest validTest()
     {
-        when(testMapper.countUserEnabled(1L)).thenReturn(1);
-        when(testMapper.countTestTypeEnabled(1L)).thenReturn(1);
-        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
-        when(testMapper.countCabinetEnabled(1L)).thenReturn(1);
-        when(testMapper.countCollectorEnabled(1L, 1L)).thenReturn(1);
-    }
-
-    private TTest validTest(Long testId, String status)
-    {
-        TTestBatteryField field = new TTestBatteryField();
-        field.setRelationId(20L);
-        field.setFieldName("容量");
-        field.setFieldValue("100");
-
-        TTestBattery battery = new TTestBattery();
-        battery.setRelationId(20L);
-        battery.setGroupId(1L);
-        battery.setBatteryNo("BAT-001");
-        battery.setCabinetId(1L);
-        battery.setTestChannelNo(0);
-        battery.setCollectorId(1L);
-        battery.setTemperatureChannelNo(1);
-        battery.setPositiveChannelNo(2);
-        battery.setNegativeChannelNo(3);
-        battery.setFields(Collections.singletonList(field));
+        TTestPlanStep step = new TTestPlanStep();
+        step.setCycleNo(1);
+        step.setStepNo(1);
+        step.setStepType("CONSTANT_CURRENT_CHARGE");
+        step.setStepName("恒流充电");
+        step.setSortOrder(1);
+        LinkedHashMap<String, Object> parameters = new LinkedHashMap<>();
+        parameters.put("currentA", new BigDecimal("1.5"));
+        step.setParameters(parameters);
 
         TTestWarningThreshold threshold = new TTestWarningThreshold();
-        threshold.setThresholdType("VOLTAGE:2");
-        threshold.setMinValue(BigDecimal.ZERO);
-        threshold.setMaxValue(BigDecimal.TEN);
+        threshold.setMetricCode("VOLTAGE");
+        threshold.setMinValue(new BigDecimal("2.5"));
+        threshold.setMaxValue(new BigDecimal("4.2"));
+        threshold.setUnit("V");
+        threshold.setSeverity("WARNING");
 
         TTest test = new TTest();
-        test.setTestId(testId);
-        test.setTestName("测试任务");
-        test.setPrincipalId(1L);
-        test.setTestTypeId(1L);
-        test.setTestPurpose("验证容量");
-        test.setStatus(status);
-        test.setBatteries(Collections.singletonList(battery));
+        test.setTestName("低温循环测试");
+        test.setCreatorId(7L);
+        test.setOperatorId(7L);
+        test.setCreateBy("creator");
+        test.setUpdateBy("creator");
+        test.setPrincipalId(9L);
+        test.setTestTypeId(3L);
+        test.setTestPurpose("验证低温循环性能");
+        test.setMonitorTimeoutMinutes(30);
+        test.setBatteries(Collections.singletonList(validBattery("BAT-001", 1L, 100L)));
+        test.setPlanSteps(Collections.singletonList(step));
         test.setThresholds(Collections.singletonList(threshold));
         return test;
     }
+
+    private TTestBattery validBattery(String batteryNo, Long relationId, Long channelId)
+    {
+        TTestChannelBinding binding = new TTestChannelBinding();
+        binding.setChannelId(channelId);
+        binding.setExpectedBarcode("CODE-" + batteryNo);
+
+        TTestBattery battery = new TTestBattery();
+        battery.setRelationId(relationId);
+        battery.setGroupId(1L);
+        battery.setBatteryNo(batteryNo);
+        battery.setCollectorId(8L);
+        battery.setBinding(binding);
+        battery.setFields(Collections.emptyList());
+        return battery;
+    }
 }
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
new file mode 100644
index 0000000..29c10c0
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
@@ -0,0 +1,24 @@
+package com.ruoyi.web.controller.system;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+class TTestControllerContractTest
+{
+    @Test
+    void configurationEndpointsKeepServerSidePermissions() throws Exception
+    {
+        assertPermission("options", "@ss.hasAnyPermi('system:test:add,system:test:edit')");
+        assertPermission("getInfo", "@ss.hasPermi('system:test:query')", Long.class);
+        assertPermission("add", "@ss.hasPermi('system:test:add')", com.ruoyi.system.domain.TTest.class);
+        assertPermission("edit", "@ss.hasPermi('system:test:edit')", com.ruoyi.system.domain.TTest.class);
+    }
+
+    private void assertPermission(String methodName, String permission, Class<?>... parameterTypes) throws Exception
+    {
+        Method method = TTestController.class.getMethod(methodName, parameterTypes);
+        assertEquals(permission, method.getAnnotation(PreAuthorize.class).value());
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
index 50d9870..0e27006 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
@@ -2,18 +2,16 @@
 
 import java.util.Date;
 import java.util.List;
+import com.alibaba.fastjson2.annotation.JSONField;
 import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Size;
 import com.ruoyi.common.core.domain.BaseEntity;
 
-/**
- * 测试任务对象 t_test。
- *
- * @author ruoyi
- */
+/** 测试配置及其独立业务状态。 */
 public class TTest extends BaseEntity
 {
     private static final long serialVersionUID = 1L;
@@ -22,6 +20,10 @@
     @NotBlank(message = "测试名称不能为空")
     @Size(max = 100, message = "测试名称长度不能超过100个字符")
     private String testName;
+    private Long creatorId;
+    private String creatorName;
+    @JSONField(serialize = false)
+    private transient Long operatorId;
     @NotNull(message = "测试负责人不能为空")
     private Long principalId;
     private String principalName;
@@ -30,7 +32,11 @@
     private String testTypeName;
     @NotBlank(message = "测试目的不能为空")
     private String testPurpose;
-    private String status;
+    private String testStatus;
+    private String monitoringStatus;
+    private Integer monitorTimeoutMinutes;
+    private Date startedAtUtc;
+    private Date endedAtUtc;
     private String delFlag;
     private String batteryNo;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -38,42 +44,63 @@
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date endCreateTime;
     private Integer batteryCount;
-    @NotEmpty(message = "测试电池关系不能为空")
+    @Valid
+    @NotEmpty(message = "测试至少绑定一个电池号")
     private List<TTestBattery> batteries;
-    @NotEmpty(message = "预警阈值不能为空")
+    @Valid
+    @NotEmpty(message = "计划工步不能为空")
+    private List<TTestPlanStep> planSteps;
+    @Valid
+    @NotEmpty(message = "阈值配置不能为空")
     private List<TTestWarningThreshold> thresholds;
     private List<TTestResult> resultSummary;
 
     public Long getTestId() { return testId; }
-    public void setTestId(Long testId) { this.testId = testId; }
+    public void setTestId(Long value) { testId = value; }
     public String getTestName() { return testName; }
-    public void setTestName(String testName) { this.testName = testName; }
+    public void setTestName(String value) { testName = value; }
+    public Long getCreatorId() { return creatorId; }
+    public void setCreatorId(Long value) { creatorId = value; }
+    public String getCreatorName() { return creatorName; }
+    public void setCreatorName(String value) { creatorName = value; }
+    public Long getOperatorId() { return operatorId; }
+    public void setOperatorId(Long value) { operatorId = value; }
     public Long getPrincipalId() { return principalId; }
-    public void setPrincipalId(Long principalId) { this.principalId = principalId; }
+    public void setPrincipalId(Long value) { principalId = value; }
     public String getPrincipalName() { return principalName; }
-    public void setPrincipalName(String principalName) { this.principalName = principalName; }
+    public void setPrincipalName(String value) { principalName = value; }
     public Long getTestTypeId() { return testTypeId; }
-    public void setTestTypeId(Long testTypeId) { this.testTypeId = testTypeId; }
+    public void setTestTypeId(Long value) { testTypeId = value; }
     public String getTestTypeName() { return testTypeName; }
-    public void setTestTypeName(String testTypeName) { this.testTypeName = testTypeName; }
+    public void setTestTypeName(String value) { testTypeName = value; }
     public String getTestPurpose() { return testPurpose; }
-    public void setTestPurpose(String testPurpose) { this.testPurpose = testPurpose; }
-    public String getStatus() { return status; }
-    public void setStatus(String status) { this.status = status; }
+    public void setTestPurpose(String value) { testPurpose = value; }
+    public String getTestStatus() { return testStatus; }
+    public void setTestStatus(String value) { testStatus = value; }
+    public String getMonitoringStatus() { return monitoringStatus; }
+    public void setMonitoringStatus(String value) { monitoringStatus = value; }
+    public Integer getMonitorTimeoutMinutes() { return monitorTimeoutMinutes; }
+    public void setMonitorTimeoutMinutes(Integer value) { monitorTimeoutMinutes = value; }
+    public Date getStartedAtUtc() { return startedAtUtc; }
+    public void setStartedAtUtc(Date value) { startedAtUtc = value; }
+    public Date getEndedAtUtc() { return endedAtUtc; }
+    public void setEndedAtUtc(Date value) { endedAtUtc = value; }
     public String getDelFlag() { return delFlag; }
-    public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
+    public void setDelFlag(String value) { delFlag = value; }
     public String getBatteryNo() { return batteryNo; }
-    public void setBatteryNo(String batteryNo) { this.batteryNo = batteryNo; }
+    public void setBatteryNo(String value) { batteryNo = value; }
     public Date getBeginCreateTime() { return beginCreateTime; }
-    public void setBeginCreateTime(Date beginCreateTime) { this.beginCreateTime = beginCreateTime; }
+    public void setBeginCreateTime(Date value) { beginCreateTime = value; }
     public Date getEndCreateTime() { return endCreateTime; }
-    public void setEndCreateTime(Date endCreateTime) { this.endCreateTime = endCreateTime; }
+    public void setEndCreateTime(Date value) { endCreateTime = value; }
     public Integer getBatteryCount() { return batteryCount; }
-    public void setBatteryCount(Integer batteryCount) { this.batteryCount = batteryCount; }
+    public void setBatteryCount(Integer value) { batteryCount = value; }
     public List<TTestBattery> getBatteries() { return batteries; }
-    public void setBatteries(List<TTestBattery> batteries) { this.batteries = batteries; }
+    public void setBatteries(List<TTestBattery> value) { batteries = value; }
+    public List<TTestPlanStep> getPlanSteps() { return planSteps; }
+    public void setPlanSteps(List<TTestPlanStep> value) { planSteps = value; }
     public List<TTestWarningThreshold> getThresholds() { return thresholds; }
-    public void setThresholds(List<TTestWarningThreshold> thresholds) { this.thresholds = thresholds; }
+    public void setThresholds(List<TTestWarningThreshold> value) { thresholds = value; }
     public List<TTestResult> getResultSummary() { return resultSummary; }
-    public void setResultSummary(List<TTestResult> resultSummary) { this.resultSummary = resultSummary; }
+    public void setResultSummary(List<TTestResult> value) { resultSummary = value; }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestBattery.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestBattery.java
index fd3c2ab..02ed1a0 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestBattery.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestBattery.java
@@ -2,12 +2,11 @@
 
 import java.io.Serializable;
 import java.util.List;
-import jakarta.validation.constraints.Min;
+import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
 
-/** 测试与电池号关系对象 t_test_battery。 */
+/** 测试内的电池号、可选采集器与主要检测柜通道配置。 */
 public class TTestBattery implements Serializable
 {
     private static final long serialVersionUID = 1L;
@@ -16,50 +15,35 @@
     private Long testId;
     @NotNull(message = "电池组不能为空")
     private Long groupId;
+    private String groupName;
     @NotBlank(message = "电池号不能为空")
     private String batteryNo;
-    @NotNull(message = "检测柜不能为空")
-    private Long cabinetId;
-    @NotNull(message = "测试通道不能为空")
-    @Min(value = 0, message = "测试通道不能小于0")
-    private Integer testChannelNo;
-    @NotNull(message = "采集器不能为空")
     private Long collectorId;
-    @NotNull(message = "温度通道不能为空")
-    @Min(value = 0, message = "温度通道不能小于0")
-    private Integer temperatureChannelNo;
-    @NotNull(message = "正电位通道不能为空")
-    @Min(value = 0, message = "正电位通道不能小于0")
-    private Integer positiveChannelNo;
-    @NotNull(message = "负电位通道不能为空")
-    @Min(value = 0, message = "负电位通道不能小于0")
-    private Integer negativeChannelNo;
+    private String collectorName;
     private String delFlag;
-    @NotEmpty(message = "扩展属性不能为空")
+    @Valid
+    @NotNull(message = "主要检测柜通道不能为空")
+    private TTestChannelBinding binding;
     private List<TTestBatteryField> fields;
 
     public Long getRelationId() { return relationId; }
-    public void setRelationId(Long relationId) { this.relationId = relationId; }
+    public void setRelationId(Long value) { relationId = value; }
     public Long getTestId() { return testId; }
-    public void setTestId(Long testId) { this.testId = testId; }
+    public void setTestId(Long value) { testId = value; }
     public Long getGroupId() { return groupId; }
-    public void setGroupId(Long groupId) { this.groupId = groupId; }
+    public void setGroupId(Long value) { groupId = value; }
+    public String getGroupName() { return groupName; }
+    public void setGroupName(String value) { groupName = value; }
     public String getBatteryNo() { return batteryNo; }
-    public void setBatteryNo(String batteryNo) { this.batteryNo = batteryNo; }
-    public Long getCabinetId() { return cabinetId; }
-    public void setCabinetId(Long cabinetId) { this.cabinetId = cabinetId; }
-    public Integer getTestChannelNo() { return testChannelNo; }
-    public void setTestChannelNo(Integer testChannelNo) { this.testChannelNo = testChannelNo; }
+    public void setBatteryNo(String value) { batteryNo = value; }
     public Long getCollectorId() { return collectorId; }
-    public void setCollectorId(Long collectorId) { this.collectorId = collectorId; }
-    public Integer getTemperatureChannelNo() { return temperatureChannelNo; }
-    public void setTemperatureChannelNo(Integer temperatureChannelNo) { this.temperatureChannelNo = temperatureChannelNo; }
-    public Integer getPositiveChannelNo() { return positiveChannelNo; }
-    public void setPositiveChannelNo(Integer positiveChannelNo) { this.positiveChannelNo = positiveChannelNo; }
-    public Integer getNegativeChannelNo() { return negativeChannelNo; }
-    public void setNegativeChannelNo(Integer negativeChannelNo) { this.negativeChannelNo = negativeChannelNo; }
+    public void setCollectorId(Long value) { collectorId = value; }
+    public String getCollectorName() { return collectorName; }
+    public void setCollectorName(String value) { collectorName = value; }
     public String getDelFlag() { return delFlag; }
-    public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
+    public void setDelFlag(String value) { delFlag = value; }
+    public TTestChannelBinding getBinding() { return binding; }
+    public void setBinding(TTestChannelBinding value) { binding = value; }
     public List<TTestBatteryField> getFields() { return fields; }
-    public void setFields(List<TTestBatteryField> fields) { this.fields = fields; }
+    public void setFields(List<TTestBatteryField> value) { fields = value; }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
new file mode 100644
index 0000000..ed47411
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
@@ -0,0 +1,60 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+/** 测试电池号与主要检测柜通道的绑定。 */
+public class TTestChannelBinding implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    private Long bindingId;
+    private Long testId;
+    private Long batteryRelationId;
+    @NotNull(message = "检测柜通道不能为空")
+    private Long channelId;
+    @Size(max = 100, message = "预期条码长度不能超过100个字符")
+    private String expectedBarcode;
+    private String claimStatus;
+    private Long cabinetId;
+    private String cabinetCode;
+    private String cabinetName;
+    private String adapterCode;
+    private Integer devtype;
+    private Integer devid;
+    private Integer subdevid;
+    private Integer chlid;
+    private String displayNo;
+
+    public Long getBindingId() { return bindingId; }
+    public void setBindingId(Long value) { bindingId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public Long getBatteryRelationId() { return batteryRelationId; }
+    public void setBatteryRelationId(Long value) { batteryRelationId = value; }
+    public Long getChannelId() { return channelId; }
+    public void setChannelId(Long value) { channelId = value; }
+    public String getExpectedBarcode() { return expectedBarcode; }
+    public void setExpectedBarcode(String value) { expectedBarcode = value; }
+    public String getClaimStatus() { return claimStatus; }
+    public void setClaimStatus(String value) { claimStatus = value; }
+    public Long getCabinetId() { return cabinetId; }
+    public void setCabinetId(Long value) { cabinetId = value; }
+    public String getCabinetCode() { return cabinetCode; }
+    public void setCabinetCode(String value) { cabinetCode = value; }
+    public String getCabinetName() { return cabinetName; }
+    public void setCabinetName(String value) { cabinetName = value; }
+    public String getAdapterCode() { return adapterCode; }
+    public void setAdapterCode(String value) { adapterCode = value; }
+    public Integer getDevtype() { return devtype; }
+    public void setDevtype(Integer value) { devtype = value; }
+    public Integer getDevid() { return devid; }
+    public void setDevid(Integer value) { devid = value; }
+    public Integer getSubdevid() { return subdevid; }
+    public void setSubdevid(Integer value) { subdevid = value; }
+    public Integer getChlid() { return chlid; }
+    public void setChlid(Integer value) { chlid = value; }
+    public String getDisplayNo() { return displayNo; }
+    public void setDisplayNo(String value) { displayNo = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestPlanStep.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestPlanStep.java
new file mode 100644
index 0000000..cf336d2
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestPlanStep.java
@@ -0,0 +1,53 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.util.Map;
+import com.alibaba.fastjson2.annotation.JSONField;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/** 创建测试时配置的计划工步。 */
+public class TTestPlanStep implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    private Long planStepId;
+    private Long testId;
+    @NotNull(message = "循环号不能为空")
+    @Min(value = 1, message = "循环号必须大于0")
+    private Integer cycleNo;
+    @NotNull(message = "工步号不能为空")
+    @Min(value = 1, message = "工步号必须大于0")
+    private Integer stepNo;
+    @NotBlank(message = "工步类型不能为空")
+    private String stepType;
+    @NotBlank(message = "工步名称不能为空")
+    private String stepName;
+    @NotNull(message = "工步参数不能为空")
+    private Map<String, Object> parameters;
+    @JSONField(serialize = false)
+    private String parametersJson;
+    @NotNull(message = "工步顺序不能为空")
+    @Min(value = 1, message = "工步顺序必须大于0")
+    private Integer sortOrder;
+
+    public Long getPlanStepId() { return planStepId; }
+    public void setPlanStepId(Long value) { planStepId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public Integer getCycleNo() { return cycleNo; }
+    public void setCycleNo(Integer value) { cycleNo = value; }
+    public Integer getStepNo() { return stepNo; }
+    public void setStepNo(Integer value) { stepNo = value; }
+    public String getStepType() { return stepType; }
+    public void setStepType(String value) { stepType = value; }
+    public String getStepName() { return stepName; }
+    public void setStepName(String value) { stepName = value; }
+    public Map<String, Object> getParameters() { return parameters; }
+    public void setParameters(Map<String, Object> value) { parameters = value; }
+    public String getParametersJson() { return parametersJson; }
+    public void setParametersJson(String value) { parametersJson = value; }
+    public Integer getSortOrder() { return sortOrder; }
+    public void setSortOrder(Integer value) { sortOrder = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningThreshold.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningThreshold.java
index 46ff296..a312bb7 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningThreshold.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningThreshold.java
@@ -3,35 +3,39 @@
 import java.io.Serializable;
 import java.math.BigDecimal;
 import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Size;
 
-/** 测试预警阈值对象 t_test_warning_threshold。 */
+/** 使用 SI 业务单位保存的测试告警阈值。 */
 public class TTestWarningThreshold implements Serializable
 {
     private static final long serialVersionUID = 1L;
 
     private Long thresholdId;
     private Long testId;
-    @NotBlank(message = "阈值类型不能为空")
-    @Size(max = 100, message = "阈值类型长度不能超过100个字符")
-    private String thresholdType;
-    @NotNull(message = "阈值最小值不能为空")
+    @NotBlank(message = "指标编码不能为空")
+    @Size(max = 100, message = "指标编码长度不能超过100个字符")
+    private String metricCode;
     private BigDecimal minValue;
-    @NotNull(message = "阈值最大值不能为空")
     private BigDecimal maxValue;
+    @NotBlank(message = "阈值单位不能为空")
+    private String unit;
+    private String severity;
     private String delFlag;
 
     public Long getThresholdId() { return thresholdId; }
-    public void setThresholdId(Long thresholdId) { this.thresholdId = thresholdId; }
+    public void setThresholdId(Long value) { thresholdId = value; }
     public Long getTestId() { return testId; }
-    public void setTestId(Long testId) { this.testId = testId; }
-    public String getThresholdType() { return thresholdType; }
-    public void setThresholdType(String thresholdType) { this.thresholdType = thresholdType; }
+    public void setTestId(Long value) { testId = value; }
+    public String getMetricCode() { return metricCode; }
+    public void setMetricCode(String value) { metricCode = value; }
     public BigDecimal getMinValue() { return minValue; }
-    public void setMinValue(BigDecimal minValue) { this.minValue = minValue; }
+    public void setMinValue(BigDecimal value) { minValue = value; }
     public BigDecimal getMaxValue() { return maxValue; }
-    public void setMaxValue(BigDecimal maxValue) { this.maxValue = maxValue; }
+    public void setMaxValue(BigDecimal value) { maxValue = value; }
+    public String getUnit() { return unit; }
+    public void setUnit(String value) { unit = value; }
+    public String getSeverity() { return severity; }
+    public void setSeverity(String value) { severity = value; }
     public String getDelFlag() { return delFlag; }
-    public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
+    public void setDelFlag(String value) { delFlag = value; }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestBatteryOptionVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestBatteryOptionVo.java
new file mode 100644
index 0000000..6b2a7d9
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestBatteryOptionVo.java
@@ -0,0 +1,19 @@
+package com.ruoyi.system.domain.vo;
+
+/** 启用电池组内可选的电池号。 */
+public class TestBatteryOptionVo
+{
+    private Long relationId;
+    private Long groupId;
+    private String groupName;
+    private String batteryNo;
+
+    public Long getRelationId() { return relationId; }
+    public void setRelationId(Long value) { relationId = value; }
+    public Long getGroupId() { return groupId; }
+    public void setGroupId(Long value) { groupId = value; }
+    public String getGroupName() { return groupName; }
+    public void setGroupName(String value) { groupName = value; }
+    public String getBatteryNo() { return batteryNo; }
+    public void setBatteryNo(String value) { batteryNo = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestChannelOptionVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestChannelOptionVo.java
new file mode 100644
index 0000000..11e1e00
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestChannelOptionVo.java
@@ -0,0 +1,37 @@
+package com.ruoyi.system.domain.vo;
+
+/** 已正式登记检测柜的稳定通道选项。 */
+public class TestChannelOptionVo
+{
+    private Long channelId;
+    private Long cabinetId;
+    private String cabinetCode;
+    private String cabinetName;
+    private String adapterCode;
+    private Integer devtype;
+    private Integer devid;
+    private Integer subdevid;
+    private Integer chlid;
+    private String displayNo;
+
+    public Long getChannelId() { return channelId; }
+    public void setChannelId(Long value) { channelId = value; }
+    public Long getCabinetId() { return cabinetId; }
+    public void setCabinetId(Long value) { cabinetId = value; }
+    public String getCabinetCode() { return cabinetCode; }
+    public void setCabinetCode(String value) { cabinetCode = value; }
+    public String getCabinetName() { return cabinetName; }
+    public void setCabinetName(String value) { cabinetName = value; }
+    public String getAdapterCode() { return adapterCode; }
+    public void setAdapterCode(String value) { adapterCode = value; }
+    public Integer getDevtype() { return devtype; }
+    public void setDevtype(Integer value) { devtype = value; }
+    public Integer getDevid() { return devid; }
+    public void setDevid(Integer value) { devid = value; }
+    public Integer getSubdevid() { return subdevid; }
+    public void setSubdevid(Integer value) { subdevid = value; }
+    public Integer getChlid() { return chlid; }
+    public void setChlid(Integer value) { chlid = value; }
+    public String getDisplayNo() { return displayNo; }
+    public void setDisplayNo(String value) { displayNo = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestCollectorOptionVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestCollectorOptionVo.java
new file mode 100644
index 0000000..a702b91
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestCollectorOptionVo.java
@@ -0,0 +1,16 @@
+package com.ruoyi.system.domain.vo;
+
+/** 可选的启用采集器。 */
+public class TestCollectorOptionVo
+{
+    private Long collectorId;
+    private String collectorCode;
+    private String collectorName;
+
+    public Long getCollectorId() { return collectorId; }
+    public void setCollectorId(Long value) { collectorId = value; }
+    public String getCollectorCode() { return collectorCode; }
+    public void setCollectorCode(String value) { collectorCode = value; }
+    public String getCollectorName() { return collectorName; }
+    public void setCollectorName(String value) { collectorName = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestConfigurationOptionsVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestConfigurationOptionsVo.java
new file mode 100644
index 0000000..96667ac
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestConfigurationOptionsVo.java
@@ -0,0 +1,24 @@
+package com.ruoyi.system.domain.vo;
+
+import java.util.List;
+
+/** 创建或编辑测试时由后端提供的有效选项。 */
+public class TestConfigurationOptionsVo
+{
+    private List<TestUserOptionVo> users;
+    private List<TestTypeOptionVo> testTypes;
+    private List<TestBatteryOptionVo> batteries;
+    private List<TestChannelOptionVo> channels;
+    private List<TestCollectorOptionVo> collectors;
+
+    public List<TestUserOptionVo> getUsers() { return users; }
+    public void setUsers(List<TestUserOptionVo> value) { users = value; }
+    public List<TestTypeOptionVo> getTestTypes() { return testTypes; }
+    public void setTestTypes(List<TestTypeOptionVo> value) { testTypes = value; }
+    public List<TestBatteryOptionVo> getBatteries() { return batteries; }
+    public void setBatteries(List<TestBatteryOptionVo> value) { batteries = value; }
+    public List<TestChannelOptionVo> getChannels() { return channels; }
+    public void setChannels(List<TestChannelOptionVo> value) { channels = value; }
+    public List<TestCollectorOptionVo> getCollectors() { return collectors; }
+    public void setCollectors(List<TestCollectorOptionVo> value) { collectors = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestTypeOptionVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestTypeOptionVo.java
new file mode 100644
index 0000000..a7dcd0d
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestTypeOptionVo.java
@@ -0,0 +1,13 @@
+package com.ruoyi.system.domain.vo;
+
+/** 启用的测试类型选项。 */
+public class TestTypeOptionVo
+{
+    private Long testTypeId;
+    private String testTypeName;
+
+    public Long getTestTypeId() { return testTypeId; }
+    public void setTestTypeId(Long value) { testTypeId = value; }
+    public String getTestTypeName() { return testTypeName; }
+    public void setTestTypeName(String value) { testTypeName = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestUserOptionVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestUserOptionVo.java
new file mode 100644
index 0000000..d011dc8
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestUserOptionVo.java
@@ -0,0 +1,16 @@
+package com.ruoyi.system.domain.vo;
+
+/** 可担任测试负责人的启用用户选项。 */
+public class TestUserOptionVo
+{
+    private Long userId;
+    private String userName;
+    private String nickName;
+
+    public Long getUserId() { return userId; }
+    public void setUserId(Long value) { userId = value; }
+    public String getUserName() { return userName; }
+    public void setUserName(String value) { userName = value; }
+    public String getNickName() { return nickName; }
+    public void setNickName(String value) { nickName = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
index 8a1225a..f5ba595 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -5,37 +5,53 @@
 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.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
+import com.ruoyi.system.domain.vo.TestChannelOptionVo;
+import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
+import com.ruoyi.system.domain.vo.TestTypeOptionVo;
+import com.ruoyi.system.domain.vo.TestUserOptionVo;
 
-/** 测试管理数据访问接口。 */
+/** 测试配置及其只追加审计的数据访问接口。 */
 public interface TTestMapper
 {
     TTest selectTestById(Long testId);
+    TTest selectTestByIdForUpdate(Long testId);
     List<TTest> selectTestList(TTest test);
     TTest selectTestByName(String testName);
     int insertTest(TTest test);
     int updateTest(TTest test);
-    int updateTestStatus(@Param("testId") Long testId, @Param("status") String status, @Param("updateBy") String updateBy);
     int deleteTestById(@Param("testId") Long testId, @Param("updateBy") String updateBy);
-    int deleteTestBatteriesByTestId(@Param("testId") Long testId, @Param("updateBy") String updateBy);
-    int deleteTestFieldsByTestId(@Param("testId") Long testId, @Param("updateBy") String updateBy);
-    int deleteTestThresholdsByTestId(@Param("testId") Long testId, @Param("updateBy") String updateBy);
+    int deleteTestBindingsByTestId(Long testId);
+    int deleteTestPlanStepsByTestId(Long testId);
+    int deleteTestBatteriesByTestId(Long testId);
+    int deleteTestFieldsByTestId(Long testId);
+    int deleteTestThresholdsByTestId(Long testId);
+    int insertTestPlanStep(TTestPlanStep step);
     int insertTestBattery(TTestBattery battery);
     int insertTestBatteryField(TTestBatteryField field);
+    int insertTestChannelBinding(TTestChannelBinding binding);
     int insertTestThreshold(TTestWarningThreshold threshold);
+    List<TTestPlanStep> selectTestPlanSteps(Long testId);
     List<TTestBattery> selectTestBatteries(Long testId);
     List<TTestBatteryField> selectTestBatteryFields(Long testId);
+    List<TTestChannelBinding> selectTestChannelBindings(Long testId);
     List<TTestWarningThreshold> selectTestThresholds(Long testId);
     List<TTestResult> selectTestResults(TTestResult result);
-    int countTestingByBatteryNo(@Param("batteryNo") String batteryNo, @Param("testId") Long testId);
-    int countTestingByCabinetChannel(@Param("cabinetId") Long cabinetId, @Param("channelNo") Integer channelNo, @Param("testId") Long testId);
-    int countTestBatteryRelation(@Param("testId") Long testId, @Param("relationId") Long relationId);
-    int countTestBatteryFields(@Param("testId") Long testId, @Param("relationId") Long relationId, @Param("fieldName") String fieldName);
-    int countTestThreshold(@Param("testId") Long testId, @Param("thresholdType") String thresholdType, @Param("thresholdId") Long thresholdId);
     int countTestTypeEnabled(Long testTypeId);
     int countUserEnabled(Long userId);
     int countBatteryInEnabledGroup(@Param("groupId") Long groupId, @Param("batteryNo") String batteryNo);
-    int countCabinetEnabled(Long cabinetId);
-    int countCollectorEnabled(@Param("collectorId") Long collectorId, @Param("cabinetId") Long cabinetId);
+    int countRegisteredChannel(Long channelId);
+    int countCollectorEnabled(Long collectorId);
+    List<TestUserOptionVo> selectEnabledUserOptions();
+    List<TestTypeOptionVo> selectEnabledTestTypeOptions();
+    List<TestBatteryOptionVo> selectEnabledBatteryOptions();
+    List<TestChannelOptionVo> selectRegisteredChannelOptions();
+    List<TestCollectorOptionVo> selectEnabledCollectorOptions();
+    int insertTestAudit(@Param("testId") Long testId, @Param("eventType") String eventType,
+            @Param("actorId") Long actorId, @Param("actorName") String actorName,
+            @Param("previousValue") String previousValue, @Param("newValue") String newValue);
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java
index f0fe0b0..ff54c93 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java
@@ -3,14 +3,25 @@
 import java.util.List;
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestResult;
+import com.ruoyi.system.domain.vo.TestConfigurationOptionsVo;
 
-/** 测试管理服务接口。 */
+/** 测试配置业务接口。 */
 public interface ITTestService
 {
+    /** 返回测试配置、正式测试状态和独立监测状态。 */
     TTest selectTestById(Long testId);
+
     List<TTest> selectTestList(TTest test);
-    int insertTest(TTest test);
+
+    /** 返回当前启用且已正式登记的测试配置选项。 */
+    TestConfigurationOptionsVo selectConfigurationOptions();
+
+    /** 创建未监测的待测试记录并返回测试标识。 */
+    Long createTest(TTest test);
+
+    /** 仅在尚未开始监测时替换测试配置。 */
     int updateTest(TTest test);
+
     int startTest(Long testId, String updateBy);
     int deleteTestByIds(Long[] testIds, String updateBy);
     List<TTestResult> selectTestResults(TTestResult result);
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..de2f8b9 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,10 +1,14 @@
 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.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.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -13,22 +17,25 @@
 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.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+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_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 Set<String> THRESHOLD_UNITS = Set.of("A", "V", "Ah", "Wh", "℃");
 
     @Autowired
     private TTestMapper testMapper;
@@ -39,14 +46,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,49 +56,81 @@
         if (test == null) test = new TTest();
         test.setTestName(trimToNull(test.getTestName()));
         test.setBatteryNo(trimToNull(test.getBatteryNo()));
-        validateStatus(test.getStatus());
+        validateQueryStatus(test.getTestStatus(), TEST_STATUSES, "测试状态无效");
+        validateQueryStatus(test.getMonitoringStatus(), MONITORING_STATUSES, "监测状态无效");
         return testMapper.selectTestList(test);
     }
 
     @Override
-    @Transactional(rollbackFor = Exception.class)
-    public int insertTest(TTest test)
+    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 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.setMonitoringStatus(NOT_MONITORED);
         test.setDelFlag("0");
         test.setCreateTime(new Date());
         testMapper.insertTest(test);
-        saveRelations(test);
-        return 1;
+        saveConfiguration(test);
+        testMapper.insertTestAudit(test.getTestId(), "TEST_CREATED", test.getOperatorId(), test.getCreateBy(),
+                null, JSON.toJSONString(test));
+        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 (!NOT_MONITORED.equals(current.getMonitoringStatus()))
+            throw new ServiceException("开始监测后不能修改测试配置");
+
+        test.setCreatorId(current.getCreatorId());
+        test.setTestStatus(current.getTestStatus());
+        test.setMonitoringStatus(current.getMonitoringStatus());
+        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("测试配置已变更,请刷新后重试");
+
+        // 监测开始前没有原始采集事实,配置关系可在同一事务内整体替换,审计记录仍只追加。
+        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
-    @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
@@ -106,14 +138,14 @@
     public int deleteTestByIds(Long[] testIds, 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 (!NOT_MONITORED.equals(current.getMonitoringStatus())) throw new ServiceException("开始监测后不能删除测试");
+            rows += testMapper.deleteTestById(testId, updateBy);
         }
-        int rows = 0;
-        for (Long testId : testIds) rows += testMapper.deleteTestById(testId, updateBy);
         return rows;
     }
 
@@ -125,9 +157,41 @@
         return testMapper.selectTestResults(result);
     }
 
+    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));
+        Map<Long, TTestChannelBinding> bindings = new HashMap<>();
+        for (TTestChannelBinding binding : mutable(testMapper.selectTestChannelBindings(testId)))
+            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());
+        }
+        test.setPlanSteps(planSteps);
+        test.setBatteries(batteries);
+        test.setThresholds(mutable(testMapper.selectTestThresholds(testId)));
+    }
+
     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,69 +199,138 @@
         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("至少配置一个预警阈值");
+        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<>();
+        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 && testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
+                throw new ServiceException("采集器不存在或未启用");
+            validateBatteryFields(battery.getFields());
         }
     }
 
-    private void saveRelations(TTest test)
+    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 validatePlanSteps(List<TTestPlanStep> planSteps)
+    {
+        if (planSteps == null || planSteps.isEmpty()) throw new ServiceException("计划工步不能为空");
+        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 (TTestPlanStep step : test.getPlanSteps())
+        {
+            step.setTestId(test.getTestId());
+            testMapper.insertTestPlanStep(step);
+        }
         for (TTestBattery battery : test.getBatteries())
         {
             battery.setTestId(test.getTestId());
             testMapper.insertTestBattery(battery);
-            for (TTestBatteryField field : battery.getFields())
+            if (battery.getFields() != null)
             {
-                field.setRelationId(battery.getRelationId());
-                testMapper.insertTestBatteryField(field);
+                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 (TTestWarningThreshold threshold : test.getThresholds())
         {
@@ -206,14 +339,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 +349,9 @@
         String trimmed = StringUtils.trim(value);
         return StringUtils.isEmpty(trimmed) ? null : trimmed;
     }
+
+    private <T> List<T> mutable(List<T> values)
+    {
+        return values == null ? new ArrayList<>() : new ArrayList<>(values);
+    }
 }
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 958534d..60ea046 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -1,17 +1,23 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE mapper
-PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.ruoyi.system.mapper.TTestMapper">
+
     <resultMap id="TTestResultMap" type="TTest">
         <id property="testId" column="test_id" />
         <result property="testName" column="test_name" />
+        <result property="creatorId" column="creator_id" />
+        <result property="creatorName" column="creator_name" />
         <result property="principalId" column="principal_id" />
         <result property="principalName" column="principal_name" />
         <result property="testTypeId" column="test_type_id" />
         <result property="testTypeName" column="test_type_name" />
         <result property="testPurpose" column="test_purpose" />
-        <result property="status" column="status" />
+        <result property="testStatus" column="test_status" />
+        <result property="monitoringStatus" column="monitoring_status" />
+        <result property="monitorTimeoutMinutes" column="monitor_timeout_minutes" />
+        <result property="startedAtUtc" column="started_at_utc" />
+        <result property="endedAtUtc" column="ended_at_utc" />
         <result property="delFlag" column="del_flag" />
         <result property="createBy" column="create_by" />
         <result property="createTime" column="create_time" />
@@ -20,19 +26,18 @@
         <result property="remark" column="remark" />
         <result property="batteryCount" column="battery_count" />
     </resultMap>
+
     <resultMap id="TTestBatteryResultMap" type="TTestBattery">
         <id property="relationId" column="relation_id" />
         <result property="testId" column="test_id" />
         <result property="groupId" column="group_id" />
+        <result property="groupName" column="group_name" />
         <result property="batteryNo" column="battery_no" />
-        <result property="cabinetId" column="cabinet_id" />
-        <result property="testChannelNo" column="test_channel_no" />
         <result property="collectorId" column="collector_id" />
-        <result property="temperatureChannelNo" column="temperature_channel_no" />
-        <result property="positiveChannelNo" column="positive_channel_no" />
-        <result property="negativeChannelNo" column="negative_channel_no" />
+        <result property="collectorName" column="collector_name" />
         <result property="delFlag" column="del_flag" />
     </resultMap>
+
     <resultMap id="TTestBatteryFieldResultMap" type="TTestBatteryField">
         <id property="fieldId" column="field_id" />
         <result property="relationId" column="relation_id" />
@@ -40,14 +45,47 @@
         <result property="fieldValue" column="field_value" />
         <result property="delFlag" column="del_flag" />
     </resultMap>
+
+    <resultMap id="TTestBindingResultMap" type="TTestChannelBinding">
+        <id property="bindingId" column="binding_id" />
+        <result property="testId" column="test_id" />
+        <result property="batteryRelationId" column="battery_relation_id" />
+        <result property="channelId" column="channel_id" />
+        <result property="expectedBarcode" column="expected_barcode" />
+        <result property="claimStatus" column="claim_status" />
+        <result property="cabinetId" column="cabinet_id" />
+        <result property="cabinetCode" column="cabinet_code" />
+        <result property="cabinetName" column="cabinet_name" />
+        <result property="adapterCode" column="adapter_code" />
+        <result property="devtype" column="devtype" />
+        <result property="devid" column="devid" />
+        <result property="subdevid" column="subdevid" />
+        <result property="chlid" column="chlid" />
+        <result property="displayNo" column="display_no" />
+    </resultMap>
+
+    <resultMap id="TTestPlanStepResultMap" type="TTestPlanStep">
+        <id property="planStepId" column="plan_step_id" />
+        <result property="testId" column="test_id" />
+        <result property="cycleNo" column="cycle_no" />
+        <result property="stepNo" column="step_no" />
+        <result property="stepType" column="step_type" />
+        <result property="stepName" column="step_name" />
+        <result property="parametersJson" column="parameters_json" />
+        <result property="sortOrder" column="sort_order" />
+    </resultMap>
+
     <resultMap id="TTestThresholdResultMap" type="TTestWarningThreshold">
         <id property="thresholdId" column="threshold_id" />
         <result property="testId" column="test_id" />
-        <result property="thresholdType" column="threshold_type" />
+        <result property="metricCode" column="metric_code" />
         <result property="minValue" column="min_value" />
         <result property="maxValue" column="max_value" />
+        <result property="unit" column="unit" />
+        <result property="severity" column="severity" />
         <result property="delFlag" column="del_flag" />
     </resultMap>
+
     <resultMap id="TTestResultResultMap" type="TTestResult">
         <id property="resultId" column="result_id" />
         <result property="relationId" column="relation_id" />
@@ -58,58 +96,216 @@
         <result property="exceptionInfo" column="exception_info" />
         <result property="delFlag" column="del_flag" />
     </resultMap>
-    <select id="selectTestList" parameterType="TTest" resultMap="TTestResultMap">
-        select t.test_id, t.test_name, t.principal_id, u.user_name principal_name,
-               t.test_type_id, ty.test_type_name, t.test_purpose, t.status, t.del_flag,
+
+    <sql id="selectTestBase">
+        select t.test_id, t.test_name, t.creator_id, creator.nick_name creator_name,
+               t.principal_id, principal.nick_name principal_name,
+               t.test_type_id, ty.test_type_name, t.test_purpose,
+               t.test_status, t.monitoring_status, t.monitor_timeout_minutes,
+               t.started_at_utc, t.ended_at_utc, t.del_flag,
                t.create_by, t.create_time, t.update_by, t.update_time, t.remark,
-               (select count(1) from t_test_battery b where b.test_id = t.test_id and b.del_flag = '0') battery_count
+               (select count(1) from t_test_battery b where b.test_id = t.test_id and b.del_flag = 0) battery_count
         from t_test t
-        left join sys_user u on u.user_id = t.principal_id and u.del_flag = '0'
-        left join t_test_type ty on ty.test_type_id = t.test_type_id and ty.del_flag = '0'
+        left join sys_user creator on creator.user_id = t.creator_id and creator.del_flag = '0'
+        left join sys_user principal on principal.user_id = t.principal_id and principal.del_flag = '0'
+        left join t_test_type ty on ty.test_type_id = t.test_type_id and ty.del_flag = 0
+    </sql>
+
+    <select id="selectTestList" parameterType="TTest" resultMap="TTestResultMap">
+        <include refid="selectTestBase" />
         <where>
-            t.del_flag = '0'
+            t.del_flag = 0
             <if test="testId != null">and t.test_id = #{testId}</if>
             <if test="testName != null and testName != ''">and t.test_name like concat('%', #{testName}, '%')</if>
             <if test="testTypeId != null">and t.test_type_id = #{testTypeId}</if>
-            <if test="batteryNo != null and batteryNo != ''">and exists (select 1 from t_test_battery fb where fb.test_id = t.test_id and fb.battery_no = #{batteryNo} and fb.del_flag = '0')</if>
-            <if test="status != null and status != ''">and t.status = #{status}</if>
+            <if test="batteryNo != null and batteryNo != ''">and exists (select 1 from t_test_battery fb where fb.test_id = t.test_id and fb.battery_no = #{batteryNo} and fb.del_flag = 0)</if>
+            <if test="testStatus != null and testStatus != ''">and t.test_status = #{testStatus}</if>
+            <if test="monitoringStatus != null and monitoringStatus != ''">and t.monitoring_status = #{monitoringStatus}</if>
             <if test="beginCreateTime != null">and t.create_time &gt;= #{beginCreateTime}</if>
             <if test="endCreateTime != null">and t.create_time &lt;= #{endCreateTime}</if>
         </where>
         order by t.create_time desc, t.test_id desc
     </select>
+
     <select id="selectTestById" parameterType="Long" resultMap="TTestResultMap">
-        select t.test_id, t.test_name, t.principal_id, u.user_name principal_name,
-               t.test_type_id, ty.test_type_name, t.test_purpose, t.status, t.del_flag,
-               t.create_by, t.create_time, t.update_by, t.update_time, t.remark,
-               (select count(1) from t_test_battery b where b.test_id = t.test_id and b.del_flag = '0') battery_count
-        from t_test t left join sys_user u on u.user_id = t.principal_id and u.del_flag = '0'
-        left join t_test_type ty on ty.test_type_id = t.test_type_id and ty.del_flag = '0'
-        where t.test_id = #{testId} and t.del_flag = '0'
+        <include refid="selectTestBase" />
+        where t.test_id = #{testId} and t.del_flag = 0
     </select>
-    <select id="selectTestByName" parameterType="String" resultMap="TTestResultMap">select * from t_test where test_name = #{testName} and del_flag = '0' limit 1</select>
-    <insert id="insertTest" parameterType="TTest" useGeneratedKeys="true" keyProperty="testId">insert into t_test(test_name, principal_id, test_type_id, test_purpose, status, del_flag, create_by, create_time, remark) values(#{testName}, #{principalId}, #{testTypeId}, #{testPurpose}, #{status}, #{delFlag}, #{createBy}, #{createTime}, #{remark})</insert>
-    <update id="updateTest" parameterType="TTest">update t_test set test_name=#{testName}, principal_id=#{principalId}, test_type_id=#{testTypeId}, test_purpose=#{testPurpose}, remark=#{remark}, update_by=#{updateBy}, update_time=#{updateTime} where test_id=#{testId} and del_flag='0'</update>
-    <update id="updateTestStatus">update t_test set status=#{status}, update_by=#{updateBy}, update_time=sysdate() where test_id=#{testId} and status='0' and del_flag='0'</update>
-    <update id="deleteTestById">update t_test set del_flag=cast(test_id as char), update_by=#{updateBy}, update_time=sysdate() where test_id=#{testId} and del_flag='0'</update>
-    <update id="deleteTestBatteriesByTestId">update t_test_battery set del_flag=cast(relation_id as char) where test_id=#{testId} and del_flag='0'</update>
-    <update id="deleteTestFieldsByTestId">update t_test_battery_field f join t_test_battery b on b.relation_id=f.relation_id set f.del_flag=cast(f.field_id as char) where b.test_id=#{testId} and f.del_flag='0'</update>
-    <update id="deleteTestThresholdsByTestId">update t_test_warning_threshold set del_flag=cast(threshold_id as char) where test_id=#{testId} and del_flag='0'</update>
-    <insert id="insertTestBattery" parameterType="TTestBattery" useGeneratedKeys="true" keyProperty="relationId">insert into t_test_battery(test_id, group_id, battery_no, cabinet_id, test_channel_no, collector_id, temperature_channel_no, positive_channel_no, negative_channel_no, del_flag) values(#{testId}, #{groupId}, #{batteryNo}, #{cabinetId}, #{testChannelNo}, #{collectorId}, #{temperatureChannelNo}, #{positiveChannelNo}, #{negativeChannelNo}, '0')</insert>
-    <insert id="insertTestBatteryField" parameterType="TTestBatteryField" useGeneratedKeys="true" keyProperty="fieldId">insert into t_test_battery_field(relation_id, field_name, field_value, del_flag) values(#{relationId}, #{fieldName}, #{fieldValue}, '0')</insert>
-    <insert id="insertTestThreshold" parameterType="TTestWarningThreshold" useGeneratedKeys="true" keyProperty="thresholdId">insert into t_test_warning_threshold(test_id, threshold_type, min_value, max_value, del_flag) values(#{testId}, #{thresholdType}, #{minValue}, #{maxValue}, '0')</insert>
-    <select id="selectTestBatteries" parameterType="Long" resultMap="TTestBatteryResultMap">select * from t_test_battery where test_id=#{testId} and del_flag='0' order by relation_id</select>
-    <select id="selectTestBatteryFields" parameterType="Long" resultMap="TTestBatteryFieldResultMap">select f.* from t_test_battery_field f join t_test_battery b on b.relation_id=f.relation_id where b.test_id=#{testId} and f.del_flag='0' order by f.field_id</select>
-    <select id="selectTestThresholds" parameterType="Long" resultMap="TTestThresholdResultMap">select * from t_test_warning_threshold where test_id=#{testId} and del_flag='0' order by threshold_id</select>
-    <select id="selectTestResults" parameterType="TTestResult" resultMap="TTestResultResultMap">select r.*, b.test_id from t_test_result r join t_test_battery b on b.relation_id=r.relation_id where b.test_id=#{testId} and r.del_flag='0' <if test="relationId != null">and r.relation_id=#{relationId}</if><if test="channelNo != null">and r.channel_no=#{channelNo}</if><if test="beginResultTime != null">and r.result_time &gt;= #{beginResultTime}</if><if test="endResultTime != null">and r.result_time &lt;= #{endResultTime}</if> order by r.result_time asc, r.result_id asc</select>
-    <select id="countTestingByBatteryNo" resultType="int">select count(1) from t_test_battery b join t_test t on t.test_id=b.test_id where b.battery_no=#{batteryNo} and b.del_flag='0' and t.status='1' and t.del_flag='0' <if test="testId != null">and t.test_id != #{testId}</if></select>
-    <select id="countTestingByCabinetChannel" resultType="int">select count(1) from t_test_battery b join t_test t on t.test_id=b.test_id where b.cabinet_id=#{cabinetId} and b.test_channel_no=#{channelNo} and b.del_flag='0' and t.status='1' and t.del_flag='0' <if test="testId != null">and t.test_id != #{testId}</if></select>
-    <select id="countTestBatteryRelation" resultType="int">select count(1) from t_test_battery where test_id=#{testId} and relation_id=#{relationId} and del_flag='0'</select>
-    <select id="countTestBatteryFields" resultType="int">select count(1) from t_test_battery_field f join t_test_battery b on b.relation_id=f.relation_id where b.test_id=#{testId} and f.relation_id=#{relationId} and f.field_name=#{fieldName} and f.del_flag='0'</select>
-    <select id="countTestThreshold" resultType="int">select count(1) from t_test_warning_threshold where test_id=#{testId} and threshold_type=#{thresholdType} and del_flag='0' <if test="thresholdId != null">and threshold_id != #{thresholdId}</if></select>
-    <select id="countTestTypeEnabled" resultType="int">select count(1) from t_test_type where test_type_id=#{testTypeId} and status='0' and del_flag='0'</select>
-    <select id="countUserEnabled" resultType="int">select count(1) from sys_user where user_id=#{userId} and status='0' and del_flag='0'</select>
-    <select id="countBatteryInEnabledGroup" resultType="int">select count(1) from t_battery_group_battery b inner join t_battery_group g on g.group_id=b.group_id and g.status='0' and g.del_flag='0' where b.group_id=#{groupId} and b.battery_no=#{batteryNo} and b.del_flag='0'</select>
-    <select id="countCabinetEnabled" resultType="int">select count(1) from t_detection_cabinet where cabinet_id=#{cabinetId} and registration_status='REGISTERED' and status='0' and del_flag='0'</select>
-    <select id="countCollectorEnabled" resultType="int">select count(1) from t_collector where collector_id=#{collectorId} and cabinet_id=#{cabinetId} and status='0' and del_flag='0'</select>
+
+    <select id="selectTestByIdForUpdate" parameterType="Long" resultMap="TTestResultMap">
+        <include refid="selectTestBase" />
+        where t.test_id = #{testId} and t.del_flag = 0
+        for update
+    </select>
+
+    <select id="selectTestByName" parameterType="String" resultMap="TTestResultMap">
+        <include refid="selectTestBase" />
+        where t.test_name = #{testName} and t.del_flag = 0 limit 1
+    </select>
+
+    <insert id="insertTest" parameterType="TTest" useGeneratedKeys="true" keyProperty="testId">
+        insert into t_test
+        (test_name, creator_id, principal_id, test_type_id, test_purpose, test_status, monitoring_status,
+         monitor_timeout_minutes, del_flag, create_by, create_time, remark)
+        values
+        (#{testName}, #{creatorId}, #{principalId}, #{testTypeId}, #{testPurpose}, #{testStatus}, #{monitoringStatus},
+         #{monitorTimeoutMinutes}, 0, #{createBy}, #{createTime}, #{remark})
+    </insert>
+
+    <update id="updateTest" parameterType="TTest">
+        update t_test
+        set test_name = #{testName}, principal_id = #{principalId}, test_type_id = #{testTypeId},
+            test_purpose = #{testPurpose}, monitor_timeout_minutes = #{monitorTimeoutMinutes},
+            remark = #{remark}, update_by = #{updateBy}, update_time = #{updateTime}
+        where test_id = #{testId} and monitoring_status = 'NOT_MONITORED' and del_flag = 0
+    </update>
+
+    <update id="deleteTestById">
+        update t_test set del_flag = test_id, update_by = #{updateBy}, update_time = sysdate()
+        where test_id = #{testId} and monitoring_status = 'NOT_MONITORED' and del_flag = 0
+    </update>
+
+    <delete id="deleteTestBindingsByTestId">delete from t_test_channel_binding where test_id = #{testId}</delete>
+    <delete id="deleteTestPlanStepsByTestId">delete from t_test_plan_step where test_id = #{testId}</delete>
+    <update id="deleteTestFieldsByTestId">
+        update t_test_battery_field f join t_test_battery b on b.relation_id = f.relation_id
+        set f.del_flag = f.field_id where b.test_id = #{testId} and f.del_flag = 0
+    </update>
+    <update id="deleteTestBatteriesByTestId">
+        update t_test_battery set del_flag = relation_id where test_id = #{testId} and del_flag = 0
+    </update>
+    <update id="deleteTestThresholdsByTestId">
+        update t_test_warning_threshold set del_flag = threshold_id where test_id = #{testId} and del_flag = 0
+    </update>
+
+    <insert id="insertTestPlanStep" parameterType="TTestPlanStep" useGeneratedKeys="true" keyProperty="planStepId">
+        insert into t_test_plan_step(test_id, cycle_no, step_no, step_type, step_name, parameters_json, sort_order)
+        values(#{testId}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName}, cast(#{parametersJson} as json), #{sortOrder})
+    </insert>
+    <insert id="insertTestBattery" parameterType="TTestBattery" useGeneratedKeys="true" keyProperty="relationId">
+        insert into t_test_battery(test_id, group_id, battery_no, collector_id, del_flag)
+        values(#{testId}, #{groupId}, #{batteryNo}, #{collectorId}, 0)
+    </insert>
+    <insert id="insertTestBatteryField" parameterType="TTestBatteryField" useGeneratedKeys="true" keyProperty="fieldId">
+        insert into t_test_battery_field(relation_id, field_name, field_value, del_flag)
+        values(#{relationId}, #{fieldName}, #{fieldValue}, 0)
+    </insert>
+    <insert id="insertTestChannelBinding" parameterType="TTestChannelBinding" useGeneratedKeys="true" keyProperty="bindingId">
+        insert into t_test_channel_binding(test_id, battery_relation_id, channel_id, expected_barcode, claim_status)
+        values(#{testId}, #{batteryRelationId}, #{channelId}, #{expectedBarcode}, #{claimStatus})
+    </insert>
+    <insert id="insertTestThreshold" parameterType="TTestWarningThreshold" useGeneratedKeys="true" keyProperty="thresholdId">
+        insert into t_test_warning_threshold(test_id, metric_code, min_value, max_value, unit, severity, del_flag)
+        values(#{testId}, #{metricCode}, #{minValue}, #{maxValue}, #{unit}, #{severity}, 0)
+    </insert>
+
+    <select id="selectTestPlanSteps" parameterType="Long" resultMap="TTestPlanStepResultMap">
+        select plan_step_id, test_id, cycle_no, step_no, step_type, step_name,
+               cast(parameters_json as char) parameters_json, sort_order
+        from t_test_plan_step where test_id = #{testId} order by sort_order, plan_step_id
+    </select>
+    <select id="selectTestBatteries" parameterType="Long" resultMap="TTestBatteryResultMap">
+        select battery.relation_id, battery.test_id, battery.group_id, battery_group.group_name,
+               battery.battery_no, battery.collector_id, collector.collector_name, battery.del_flag
+        from t_test_battery battery
+        join t_battery_group battery_group on battery_group.group_id = battery.group_id
+        left join t_collector collector on collector.collector_id = battery.collector_id
+        where battery.test_id = #{testId} and battery.del_flag = 0
+        order by battery.relation_id
+    </select>
+    <select id="selectTestBatteryFields" parameterType="Long" resultMap="TTestBatteryFieldResultMap">
+        select field.* from t_test_battery_field field
+        join t_test_battery battery on battery.relation_id = field.relation_id
+        where battery.test_id = #{testId} and field.del_flag = 0 order by field.field_id
+    </select>
+    <select id="selectTestChannelBindings" parameterType="Long" resultMap="TTestBindingResultMap">
+        select binding.binding_id, binding.test_id, binding.battery_relation_id, binding.channel_id,
+               binding.expected_barcode, binding.claim_status, channel_fact.cabinet_id,
+               cabinet.cabinet_code, cabinet.cabinet_name,
+               coalesce(channel_fact.adapter_code, cabinet.adapter_code) adapter_code,
+               channel_fact.devtype, channel_fact.devid, channel_fact.subdevid, channel_fact.chlid, channel_fact.display_no
+        from t_test_channel_binding binding
+        join t_detection_channel channel_fact on channel_fact.channel_id = binding.channel_id
+        join t_detection_cabinet cabinet on cabinet.cabinet_id = channel_fact.cabinet_id
+        where binding.test_id = #{testId} order by binding.binding_id
+    </select>
+    <select id="selectTestThresholds" parameterType="Long" resultMap="TTestThresholdResultMap">
+        select threshold_id, test_id, metric_code, min_value, max_value, unit, severity, del_flag
+        from t_test_warning_threshold where test_id = #{testId} and del_flag = 0 order by threshold_id
+    </select>
+    <select id="selectTestResults" parameterType="TTestResult" resultMap="TTestResultResultMap">
+        select result.*, battery.test_id from t_test_result result
+        join t_test_battery battery on battery.relation_id = result.relation_id
+        where battery.test_id = #{testId} and result.del_flag = 0
+        <if test="relationId != null">and result.relation_id = #{relationId}</if>
+        <if test="channelNo != null">and result.channel_no = #{channelNo}</if>
+        <if test="beginResultTime != null">and result.result_time &gt;= #{beginResultTime}</if>
+        <if test="endResultTime != null">and result.result_time &lt;= #{endResultTime}</if>
+        order by result.result_time, result.result_id
+    </select>
+
+    <select id="countTestTypeEnabled" resultType="int">
+        select count(1) from t_test_type where test_type_id = #{testTypeId} and status = '0' and del_flag = 0
+    </select>
+    <select id="countUserEnabled" resultType="int">
+        select count(1) from sys_user where user_id = #{userId} and status = '0' and del_flag = '0'
+    </select>
+    <select id="countBatteryInEnabledGroup" resultType="int">
+        select count(1) from t_battery_group_battery battery
+        join t_battery_group battery_group on battery_group.group_id = battery.group_id
+        where battery.group_id = #{groupId} and battery.battery_no = #{batteryNo}
+          and battery.del_flag = 0 and battery_group.status = '0' and battery_group.del_flag = 0
+    </select>
+    <select id="countRegisteredChannel" resultType="int">
+        select count(1) from t_detection_channel channel_fact
+        join t_detection_cabinet cabinet on cabinet.cabinet_id = channel_fact.cabinet_id
+        where channel_fact.channel_id = #{channelId} and channel_fact.status = '0' and channel_fact.del_flag = 0
+          and cabinet.registration_status = 'REGISTERED' and cabinet.status = '0' and cabinet.del_flag = 0
+    </select>
+    <select id="countCollectorEnabled" resultType="int">
+        select count(1) from t_collector where collector_id = #{collectorId} and status = '0' and del_flag = 0
+    </select>
+
+    <select id="selectEnabledUserOptions" resultType="com.ruoyi.system.domain.vo.TestUserOptionVo">
+        select user_id userId, user_name userName, nick_name nickName
+        from sys_user where status = '0' and del_flag = '0' order by nick_name, user_id
+    </select>
+    <select id="selectEnabledTestTypeOptions" resultType="com.ruoyi.system.domain.vo.TestTypeOptionVo">
+        select test_type_id testTypeId, test_type_name testTypeName
+        from t_test_type where status = '0' and del_flag = 0 order by test_type_name, test_type_id
+    </select>
+    <select id="selectEnabledBatteryOptions" resultType="com.ruoyi.system.domain.vo.TestBatteryOptionVo">
+        select battery.relation_id relationId, battery.group_id groupId,
+               battery_group.group_name groupName, battery.battery_no batteryNo
+        from t_battery_group_battery battery
+        join t_battery_group battery_group on battery_group.group_id = battery.group_id
+        where battery.del_flag = 0 and battery_group.status = '0' and battery_group.del_flag = 0
+        order by battery_group.group_name, battery.sort_order, battery.relation_id
+    </select>
+    <select id="selectRegisteredChannelOptions" resultType="com.ruoyi.system.domain.vo.TestChannelOptionVo">
+        select channel_fact.channel_id channelId, channel_fact.cabinet_id cabinetId,
+               cabinet.cabinet_code cabinetCode, cabinet.cabinet_name cabinetName,
+               coalesce(channel_fact.adapter_code, cabinet.adapter_code) adapterCode,
+               channel_fact.devtype devtype, channel_fact.devid devid, channel_fact.subdevid subdevid,
+               channel_fact.chlid chlid, channel_fact.display_no displayNo
+        from t_detection_channel channel_fact
+        join t_detection_cabinet cabinet on cabinet.cabinet_id = channel_fact.cabinet_id
+        where channel_fact.status = '0' and channel_fact.del_flag = 0
+          and cabinet.registration_status = 'REGISTERED' and cabinet.status = '0' and cabinet.del_flag = 0
+        order by cabinet.cabinet_name, channel_fact.chlid, channel_fact.channel_id
+    </select>
+    <select id="selectEnabledCollectorOptions" resultType="com.ruoyi.system.domain.vo.TestCollectorOptionVo">
+        select collector_id collectorId, collector_code collectorCode, collector_name collectorName
+        from t_collector where status = '0' and del_flag = 0 order by collector_name, collector_id
+    </select>
+
+    <insert id="insertTestAudit">
+        insert into t_test_audit
+        (test_id, event_type, actor_id, actor_name, occurred_at_utc, previous_value, new_value, correlation_id)
+        values
+        (#{testId}, #{eventType}, #{actorId}, #{actorName}, utc_timestamp(3),
+         <choose><when test="previousValue != null">cast(#{previousValue} as json)</when><otherwise>null</otherwise></choose>,
+         <choose><when test="newValue != null">cast(#{newValue} as json)</when><otherwise>null</otherwise></choose>,
+         uuid())
+    </insert>
 </mapper>

--
Gitblit v1.7.1