From 41cfe20aeb9835315c54b3d6873d4e07ff238cfa Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 29 八月 2026 18:15:46 +0800
Subject: [PATCH] 实现测试必填设备绑定

---
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java         |    9 
 ruoyi-admin/src/test/java/com/ruoyi/config/TestDeviceBindingMapperContractTest.java |   37 +++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java   |  155 +++++++++++++-
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                       |   66 +++++
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                 |    9 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java                       |    9 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java      |  144 +++++++++++++-
 ruoyi-admin/src/main/resources/db/migration/V13__test_required_device_bindings.sql  |   35 +++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestBattery.java                |    6 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorChannel.java       |   36 +++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestFieldSnapshot.java          |   29 ++
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java              |   12 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/TestCollectorOptionVo.java    |    3 
 13 files changed, 501 insertions(+), 49 deletions(-)

diff --git a/ruoyi-admin/src/main/resources/db/migration/V13__test_required_device_bindings.sql b/ruoyi-admin/src/main/resources/db/migration/V13__test_required_device_bindings.sql
new file mode 100644
index 0000000..a5f2707
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V13__test_required_device_bindings.sql
@@ -0,0 +1,35 @@
+-- 旧监测列保留给既有数据;新版业务从独立接收状态开始演进。
+ALTER TABLE t_test
+    ADD COLUMN reception_status VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED' COMMENT '数据接收状态' AFTER monitoring_status,
+    ADD CONSTRAINT chk_reception_status
+        CHECK (reception_status IN ('NOT_STARTED', 'RECEIVING', 'PAUSED', 'ENDED'));
+
+CREATE TABLE t_test_collector_channel (
+    mapping_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    battery_relation_id BIGINT NOT NULL,
+    collector_id BIGINT NOT NULL,
+    measurement_code VARCHAR(100) NOT NULL,
+    measurement_name VARCHAR(100) NOT NULL,
+    channel_code VARCHAR(32) NOT NULL,
+    PRIMARY KEY (mapping_id),
+    UNIQUE KEY uk_test_collector_measurement (battery_relation_id, measurement_code),
+    UNIQUE KEY uk_test_collector_channel (test_id, collector_id, channel_code),
+    KEY idx_test_collector_mapping (test_id, battery_relation_id),
+    CONSTRAINT fk_test_collector_mapping_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_test_collector_mapping_battery FOREIGN KEY (battery_relation_id) REFERENCES t_test_battery (relation_id),
+    CONSTRAINT fk_test_collector_mapping_collector FOREIGN KEY (collector_id) REFERENCES t_collector (collector_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试采集器通道映射';
+
+CREATE TABLE t_test_field_snapshot (
+    snapshot_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    field_definition_id BIGINT NOT NULL,
+    field_name VARCHAR(100) NOT NULL,
+    field_type VARCHAR(20) NOT NULL,
+    field_value VARCHAR(500) NULL,
+    PRIMARY KEY (snapshot_id),
+    UNIQUE KEY uk_test_field_snapshot (test_id, field_definition_id),
+    KEY idx_test_field_snapshot_test (test_id),
+    CONSTRAINT fk_test_field_snapshot_test FOREIGN KEY (test_id) REFERENCES t_test (test_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试共享业务字段快照';
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 c7d2b28..6031b61 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -99,6 +99,15 @@
         assertTrue(v12.contains("chk_battery_group_field_type"));
         assertTrue(v12.contains("'text', 'number', 'time', 'remark'"));
         assertFalse(v12.matches("(?s).*\\bINSERT\\s+INTO\\b.*"));
+
+        String v13 = resource("db/migration/V13__test_required_device_bindings.sql");
+        assertTrue(v13.contains("reception_status"));
+        assertTrue(v13.contains("DEFAULT 'NOT_STARTED'"));
+        assertTrue(v13.contains("CREATE TABLE t_test_collector_channel"));
+        assertTrue(v13.contains("uk_test_collector_channel"));
+        assertTrue(v13.contains("CREATE TABLE t_test_field_snapshot"));
+        assertFalse(v13.matches("(?s).*\\bINSERT\\s+INTO\\b.*"));
+        assertFalse(v13.matches("(?s).*\\bUPDATE\\s+t_test\\b.*"));
     }
 
     private static String sha256(String path)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/TestDeviceBindingMapperContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/TestDeviceBindingMapperContractTest.java
new file mode 100644
index 0000000..e145b03
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/TestDeviceBindingMapperContractTest.java
@@ -0,0 +1,37 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+
+/** 测试双设备绑定、字段快照和接收状态的 Mapper 契约。 */
+class TestDeviceBindingMapperContractTest
+{
+    @Test
+    void persistsAndReadsCollectorMappingsAndFieldSnapshots()
+    {
+        String mapper = resource("mapper/system/TTestMapper.xml");
+
+        assertTrue(mapper.contains("id=\"insertTestCollectorChannel\""));
+        assertTrue(mapper.contains("id=\"selectTestCollectorChannels\""));
+        assertTrue(mapper.contains("id=\"insertTestFieldSnapshot\""));
+        assertTrue(mapper.contains("id=\"selectTestFieldSnapshots\""));
+        assertTrue(mapper.contains("reception_status = 'NOT_STARTED'"));
+        assertTrue(mapper.contains("channel_count channelCount"));
+    }
+
+    private static String resource(String path)
+    {
+        try (InputStream stream = TestDeviceBindingMapperContractTest.class.getClassLoader().getResourceAsStream(path))
+        {
+            if (stream == null) throw new AssertionError("缺少资源 " + path);
+            return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        catch (Exception exception)
+        {
+            throw new AssertionError("无法读取资源 " + path, exception);
+        }
+    }
+}
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 75541e4..5793f3e 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
@@ -10,6 +10,7 @@
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.times;
 
 import java.math.BigDecimal;
 import java.util.Collections;
@@ -21,11 +22,16 @@
 import org.mockito.junit.jupiter.MockitoExtension;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.TTest;
+import com.ruoyi.system.domain.TBatteryGroup;
+import com.ruoyi.system.domain.TBatteryGroupRelationField;
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCollectorChannel;
+import com.ruoyi.system.domain.TTestFieldSnapshot;
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestWarningThreshold;
 import com.ruoyi.system.mapper.TTestMapper;
+import com.ruoyi.system.service.ITBatteryGroupService;
 
 /** 测试配置服务的业务契约测试。 */
 @ExtendWith(MockitoExtension.class)
@@ -33,6 +39,9 @@
 {
     @Mock
     private TTestMapper testMapper;
+
+    @Mock
+    private ITBatteryGroupService batteryGroupService;
 
     @InjectMocks
     private TTestServiceImpl testService;
@@ -42,6 +51,7 @@
     {
         TTest test = validTest();
         mockEnabledOptions();
+        mockGroupSnapshot();
         when(testMapper.insertTest(any(TTest.class))).thenAnswer(invocation -> {
             invocation.<TTest>getArgument(0).setTestId(10L);
             return 1;
@@ -53,34 +63,42 @@
 
         assertEquals(10L, testService.createTest(test));
         assertEquals("WAITING", test.getTestStatus());
-        assertEquals("NOT_MONITORED", test.getMonitoringStatus());
+        assertEquals("NOT_STARTED", test.getReceptionStatus());
         assertEquals(7L, test.getCreatorId());
         verify(testMapper).insertTestPlanStep(any(TTestPlanStep.class));
         verify(testMapper).insertTestChannelBinding(any(TTestChannelBinding.class));
+        verify(testMapper, times(3)).insertTestCollectorChannel(any(TTestCollectorChannel.class));
+        verify(testMapper).insertTestFieldSnapshot(any());
         verify(testMapper).insertTestThreshold(any(TTestWarningThreshold.class));
         verify(testMapper).insertTestAudit(anyLong(), any(), anyLong(), any(), isNull(), any());
     }
 
     @Test
-    void createShouldAllowMissingCollector()
+    void createShouldRejectMissingCollector()
     {
         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));
 
-        assertEquals(10L, testService.createTest(test));
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
 
-        verify(testMapper, never()).countCollectorEnabled(anyLong());
+        assertEquals("采集器不能为空", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
+    }
+
+    @Test
+    void createShouldRejectMissingRequiredCollectorChannel()
+    {
+        TTest test = validTest();
+        test.getBatteries().get(0).getCollectorChannels().remove(0);
+        mockEnabledOptions();
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("每个电池号必须绑定主温度、正电位主通道和负电位主通道", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
     }
 
     @Test
@@ -89,6 +107,7 @@
         TTest test = validTest();
         test.setPlanSteps(Collections.emptyList());
         mockEnabledOptions();
+        mockGroupSnapshot();
         when(testMapper.insertTest(any(TTest.class))).thenAnswer(invocation -> {
             invocation.<TTest>getArgument(0).setTestId(10L);
             return 1;
@@ -152,10 +171,31 @@
         when(testMapper.countBatteryInEnabledGroup(1L, "BAT-002")).thenReturn(1);
         when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
         when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
 
         ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
 
         assertEquals("同一检测柜通道不可重复绑定", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
+    }
+
+    @Test
+    void createShouldRejectDuplicateCollectorChannelMapping()
+    {
+        TTest test = validTest();
+        TTestBattery second = validBattery("BAT-002", 2L, 101L);
+        test.setBatteries(java.util.List.of(test.getBatteries().get(0), second));
+        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.countRegisteredChannel(101L)).thenReturn(1);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("同一采集器通道不可重复映射", exception.getMessage());
         verify(testMapper, never()).insertTest(any(TTest.class));
     }
 
@@ -170,6 +210,7 @@
         when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
         when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
         when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
 
         ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
 
@@ -178,19 +219,36 @@
     }
 
     @Test
-    void updateShouldRejectConfigurationAfterMonitoringStarted()
+    void updateShouldRejectConfigurationAfterReceptionStarted()
     {
         TTest request = validTest();
         request.setTestId(10L);
         TTest current = validTest();
         current.setTestId(10L);
-        current.setMonitoringStatus("MONITORING");
+        current.setReceptionStatus("RECEIVING");
         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 createShouldRejectCollectorChannelOutsideRegisteredRange()
+    {
+        TTest test = validTest();
+        test.getBatteries().get(0).getCollectorChannels().get(2).setChannelCode("S-05");
+        mockEnabledPeopleAndType();
+        when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
+        when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+
+        ServiceException exception = assertThrows(ServiceException.class, () -> testService.createTest(test));
+
+        assertEquals("采集器通道不存在或超出登记范围", exception.getMessage());
+        verify(testMapper, never()).insertTest(any(TTest.class));
     }
 
     @Test
@@ -202,10 +260,11 @@
         TTest current = validTest();
         current.setTestId(10L);
         current.setCreatorId(7L);
-        current.setMonitoringStatus("NOT_MONITORED");
+        current.setReceptionStatus("NOT_STARTED");
         current.setMonitorTimeoutMinutes(30);
         when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
         mockEnabledOptions();
+        mockGroupSnapshot();
         when(testMapper.updateTest(request)).thenReturn(1);
         doAnswer(invocation -> {
             invocation.<TTestBattery>getArgument(0).setRelationId(21L);
@@ -220,11 +279,46 @@
     }
 
     @Test
+    void updateSameGroupShouldPreserveOriginalFieldSnapshot()
+    {
+        TTest request = validTest();
+        request.setTestId(10L);
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setCreatorId(7L);
+        current.setReceptionStatus("NOT_STARTED");
+        TTestFieldSnapshot originalSnapshot = new TTestFieldSnapshot();
+        originalSnapshot.setFieldDefinitionId(11L);
+        originalSnapshot.setFieldName("额定容量");
+        originalSnapshot.setFieldType("number");
+        originalSnapshot.setFieldValue("72");
+
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        when(testMapper.selectTestBatteries(10L)).thenReturn(current.getBatteries());
+        when(testMapper.selectTestFieldSnapshots(10L)).thenReturn(java.util.List.of(originalSnapshot));
+        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));
+
+        verify(testMapper).insertTestFieldSnapshot(org.mockito.ArgumentMatchers.argThat(snapshot ->
+                Long.valueOf(11L).equals(snapshot.getFieldDefinitionId())
+                        && "额定容量".equals(snapshot.getFieldName())
+                        && "number".equals(snapshot.getFieldType())
+                        && "72".equals(snapshot.getFieldValue())));
+        verify(batteryGroupService, never()).selectBatteryGroupById(anyLong());
+    }
+
+    @Test
     void deleteBeforeMonitoringShouldAppendAudit()
     {
         TTest current = validTest();
         current.setTestId(10L);
-        current.setMonitoringStatus("NOT_MONITORED");
+        current.setReceptionStatus("NOT_STARTED");
         when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
         when(testMapper.deleteTestById(10L, "operator")).thenReturn(1);
 
@@ -239,6 +333,7 @@
         when(testMapper.countBatteryInEnabledGroup(1L, "BAT-001")).thenReturn(1);
         when(testMapper.countRegisteredChannel(100L)).thenReturn(1);
         when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
     }
 
     private void mockEnabledPeopleAndType()
@@ -246,6 +341,19 @@
         when(testMapper.countUserEnabled(7L)).thenReturn(1);
         when(testMapper.countUserEnabled(9L)).thenReturn(1);
         when(testMapper.countTestTypeEnabled(3L)).thenReturn(1);
+    }
+
+    private void mockGroupSnapshot()
+    {
+        TBatteryGroupRelationField field = new TBatteryGroupRelationField();
+        field.setBatteryGroupFieldId(11L);
+        field.setFieldName("额定容量");
+        field.setFieldType("number");
+        field.setFieldValue("72");
+        TBatteryGroup group = new TBatteryGroup();
+        group.setGroupId(1L);
+        group.setTemplateFields(java.util.List.of(field));
+        when(batteryGroupService.selectBatteryGroupById(1L)).thenReturn(group);
     }
 
     private TTest validTest()
@@ -295,7 +403,20 @@
         battery.setBatteryNo(batteryNo);
         battery.setCollectorId(8L);
         battery.setBinding(binding);
+        battery.setCollectorChannels(new java.util.ArrayList<>(java.util.List.of(
+                collectorChannel("PRIMARY_TEMPERATURE", "主温度", "S-01"),
+                collectorChannel("PRIMARY_POSITIVE_POTENTIAL", "正电位主通道", "S-02"),
+                collectorChannel("PRIMARY_NEGATIVE_POTENTIAL", "负电位主通道", "S-03"))));
         battery.setFields(Collections.emptyList());
         return battery;
     }
+
+    private TTestCollectorChannel collectorChannel(String measurementCode, String measurementName, String channelCode)
+    {
+        TTestCollectorChannel mapping = new TTestCollectorChannel();
+        mapping.setMeasurementCode(measurementCode);
+        mapping.setMeasurementName(measurementName);
+        mapping.setChannelCode(channelCode);
+        return mapping;
+    }
 }
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 5e769dd..ffd743b 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
@@ -33,7 +33,7 @@
     @NotBlank(message = "测试目的不能为空")
     private String testPurpose;
     private String testStatus;
-    private String monitoringStatus;
+    private String receptionStatus;
     private Integer monitorTimeoutMinutes;
     private Date startedAtUtc;
     private Date endedAtUtc;
@@ -52,6 +52,7 @@
     @Valid
     @NotEmpty(message = "阈值配置不能为空")
     private List<TTestWarningThreshold> thresholds;
+    private List<TTestFieldSnapshot> fieldSnapshots;
     private List<TTestResult> resultSummary;
 
     public Long getTestId() { return testId; }
@@ -76,8 +77,8 @@
     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 String getReceptionStatus() { return receptionStatus; }
+    public void setReceptionStatus(String value) { receptionStatus = value; }
     public Integer getMonitorTimeoutMinutes() { return monitorTimeoutMinutes; }
     public void setMonitorTimeoutMinutes(Integer value) { monitorTimeoutMinutes = value; }
     public Date getStartedAtUtc() { return startedAtUtc; }
@@ -100,6 +101,8 @@
     public void setPlanSteps(List<TTestPlanStep> value) { planSteps = value; }
     public List<TTestWarningThreshold> getThresholds() { return thresholds; }
     public void setThresholds(List<TTestWarningThreshold> value) { thresholds = value; }
+    public List<TTestFieldSnapshot> getFieldSnapshots() { return fieldSnapshots; }
+    public void setFieldSnapshots(List<TTestFieldSnapshot> value) { fieldSnapshots = value; }
     public List<TTestResult> getResultSummary() { return 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 02ed1a0..ce1ac3c 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
@@ -6,7 +6,7 @@
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 
-/** 测试内的电池号、可选采集器与主要检测柜通道配置。 */
+/** 测试内的电池号、必填采集器与设备通道配置。 */
 public class TTestBattery implements Serializable
 {
     private static final long serialVersionUID = 1L;
@@ -24,6 +24,8 @@
     @Valid
     @NotNull(message = "主要检测柜通道不能为空")
     private TTestChannelBinding binding;
+    @Valid
+    private List<TTestCollectorChannel> collectorChannels;
     private List<TTestBatteryField> fields;
 
     public Long getRelationId() { return relationId; }
@@ -44,6 +46,8 @@
     public void setDelFlag(String value) { delFlag = value; }
     public TTestChannelBinding getBinding() { return binding; }
     public void setBinding(TTestChannelBinding value) { binding = value; }
+    public List<TTestCollectorChannel> getCollectorChannels() { return collectorChannels; }
+    public void setCollectorChannels(List<TTestCollectorChannel> value) { collectorChannels = value; }
     public List<TTestBatteryField> getFields() { return fields; }
     public void setFields(List<TTestBatteryField> value) { fields = value; }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorChannel.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorChannel.java
new file mode 100644
index 0000000..f5af607
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorChannel.java
@@ -0,0 +1,36 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import jakarta.validation.constraints.NotBlank;
+
+/** 测试电池号与采集器演示通道的测量映射。 */
+public class TTestCollectorChannel implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    private Long mappingId;
+    private Long testId;
+    private Long batteryRelationId;
+    private Long collectorId;
+    @NotBlank(message = "采集器测量项编码不能为空")
+    private String measurementCode;
+    @NotBlank(message = "采集器测量项名称不能为空")
+    private String measurementName;
+    @NotBlank(message = "采集器通道不能为空")
+    private String channelCode;
+
+    public Long getMappingId() { return mappingId; }
+    public void setMappingId(Long value) { mappingId = 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 getCollectorId() { return collectorId; }
+    public void setCollectorId(Long value) { collectorId = value; }
+    public String getMeasurementCode() { return measurementCode; }
+    public void setMeasurementCode(String value) { measurementCode = value; }
+    public String getMeasurementName() { return measurementName; }
+    public void setMeasurementName(String value) { measurementName = value; }
+    public String getChannelCode() { return channelCode; }
+    public void setChannelCode(String value) { channelCode = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestFieldSnapshot.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestFieldSnapshot.java
new file mode 100644
index 0000000..4b2ebf0
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestFieldSnapshot.java
@@ -0,0 +1,29 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+
+/** 测试创建或显式更换电池组时冻结的共享业务字段。 */
+public class TTestFieldSnapshot implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    private Long snapshotId;
+    private Long testId;
+    private Long fieldDefinitionId;
+    private String fieldName;
+    private String fieldType;
+    private String fieldValue;
+
+    public Long getSnapshotId() { return snapshotId; }
+    public void setSnapshotId(Long value) { snapshotId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public Long getFieldDefinitionId() { return fieldDefinitionId; }
+    public void setFieldDefinitionId(Long value) { fieldDefinitionId = value; }
+    public String getFieldName() { return fieldName; }
+    public void setFieldName(String value) { fieldName = value; }
+    public String getFieldType() { return fieldType; }
+    public void setFieldType(String value) { fieldType = value; }
+    public String getFieldValue() { return fieldValue; }
+    public void setFieldValue(String value) { fieldValue = 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
index a702b91..e014150 100644
--- 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
@@ -6,6 +6,7 @@
     private Long collectorId;
     private String collectorCode;
     private String collectorName;
+    private Integer channelCount;
 
     public Long getCollectorId() { return collectorId; }
     public void setCollectorId(Long value) { collectorId = value; }
@@ -13,4 +14,6 @@
     public void setCollectorCode(String value) { collectorCode = value; }
     public String getCollectorName() { return collectorName; }
     public void setCollectorName(String value) { collectorName = value; }
+    public Integer getChannelCount() { return channelCount; }
+    public void setChannelCount(Integer value) { channelCount = 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 f5ba595..420424c 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
@@ -6,6 +6,8 @@
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.domain.TTestBatteryField;
 import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCollectorChannel;
+import com.ruoyi.system.domain.TTestFieldSnapshot;
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
@@ -26,6 +28,8 @@
     int updateTest(TTest test);
     int deleteTestById(@Param("testId") Long testId, @Param("updateBy") String updateBy);
     int deleteTestBindingsByTestId(Long testId);
+    int deleteTestCollectorChannelsByTestId(Long testId);
+    int deleteTestFieldSnapshotsByTestId(Long testId);
     int deleteTestPlanStepsByTestId(Long testId);
     int deleteTestBatteriesByTestId(Long testId);
     int deleteTestFieldsByTestId(Long testId);
@@ -34,11 +38,15 @@
     int insertTestBattery(TTestBattery battery);
     int insertTestBatteryField(TTestBatteryField field);
     int insertTestChannelBinding(TTestChannelBinding binding);
+    int insertTestCollectorChannel(TTestCollectorChannel mapping);
+    int insertTestFieldSnapshot(TTestFieldSnapshot snapshot);
     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<TTestCollectorChannel> selectTestCollectorChannels(Long testId);
+    List<TTestFieldSnapshot> selectTestFieldSnapshots(Long testId);
     List<TTestWarningThreshold> selectTestThresholds(Long testId);
     List<TTestResult> selectTestResults(TTestResult result);
     int countTestTypeEnabled(Long testTypeId);
@@ -46,6 +54,7 @@
     int countBatteryInEnabledGroup(@Param("groupId") Long groupId, @Param("batteryNo") String batteryNo);
     int countRegisteredChannel(Long channelId);
     int countCollectorEnabled(Long collectorId);
+    Integer selectCollectorChannelCount(Long collectorId);
     List<TestUserOptionVo> selectEnabledUserOptions();
     List<TestTypeOptionVo> selectEnabledTestTypeOptions();
     List<TestBatteryOptionVo> selectEnabledBatteryOptions();
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 c44340d..b20e0cd 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
@@ -8,25 +8,25 @@
 /** 测试配置业务接口。 */
 public interface ITTestService
 {
-    /** 返回测试配置、正式测试状态和独立监测状态。 */
+    /** 返回测试配置、正式测试状态和独立接收状态。 */
     TTest selectTestById(Long testId);
 
-    /** 按正式测试状态、监测状态及配置条件查询测试。 */
+    /** 按正式测试状态、接收状态及配置条件查询测试。 */
     List<TTest> selectTestList(TTest test);
 
     /** 返回当前启用且已正式登记的测试配置选项。 */
     TestConfigurationOptionsVo selectConfigurationOptions();
 
-    /** 创建未监测的待测试记录并返回测试标识。 */
+    /** 创建未开始接收的待测试记录并返回测试标识。 */
     Long createTest(TTest test);
 
-    /** 仅在尚未开始监测时替换测试配置。 */
+    /** 仅在尚未开始接收时替换测试配置。 */
     int updateTest(TTest test);
 
-    /** 拒绝旧通用启动入口,监测启动由后续原子动作实现。 */
+    /** 拒绝旧通用启动入口,开始接收由后续原子动作实现。 */
     int startTest(Long testId, String updateBy);
 
-    /** 仅删除未开始监测的测试并追加审计记录。 */
+    /** 仅删除未开始接收的测试并追加审计记录。 */
     int deleteTestByIds(Long[] testIds, Long operatorId, String updateBy);
 
     /** 查询指定测试已持久化的结果记录。 */
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 05d2381..7287679 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
@@ -15,30 +15,39 @@
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.domain.TTest;
+import com.ruoyi.system.domain.TBatteryGroup;
+import com.ruoyi.system.domain.TBatteryGroupRelationField;
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.domain.TTestBatteryField;
 import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCollectorChannel;
+import com.ruoyi.system.domain.TTestFieldSnapshot;
 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;
+import com.ruoyi.system.service.ITBatteryGroupService;
 
 /** 测试配置服务,负责状态边界、关系校验、事务写入和只追加审计。 */
 @Service
 public class TTestServiceImpl implements ITTestService
 {
     private static final String WAITING = "WAITING";
-    private static final String NOT_MONITORED = "NOT_MONITORED";
+    private static final String NOT_STARTED = "NOT_STARTED";
     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> RECEPTION_STATUSES = Set.of(NOT_STARTED, "RECEIVING", "PAUSED", "ENDED");
     private static final Set<String> THRESHOLD_UNITS = Set.of("A", "V", "Ah", "Wh", "℃");
+    private static final Set<String> REQUIRED_COLLECTOR_MEASUREMENTS = Set.of(
+            "PRIMARY_TEMPERATURE", "PRIMARY_POSITIVE_POTENTIAL", "PRIMARY_NEGATIVE_POTENTIAL");
 
     @Autowired
     private TTestMapper testMapper;
+
+    @Autowired
+    private ITBatteryGroupService batteryGroupService;
 
     @Override
     public TTest selectTestById(Long testId)
@@ -57,7 +66,7 @@
         test.setTestName(trimToNull(test.getTestName()));
         test.setBatteryNo(trimToNull(test.getBatteryNo()));
         validateQueryStatus(test.getTestStatus(), TEST_STATUSES, "测试状态无效");
-        validateQueryStatus(test.getMonitoringStatus(), MONITORING_STATUSES, "监测状态无效");
+        validateQueryStatus(test.getReceptionStatus(), RECEPTION_STATUSES, "接收状态无效");
         return testMapper.selectTestList(test);
     }
 
@@ -83,7 +92,8 @@
         validateBase(test, false);
         validateConfiguration(test);
         test.setTestStatus(WAITING);
-        test.setMonitoringStatus(NOT_MONITORED);
+        test.setReceptionStatus(NOT_STARTED);
+        test.setFieldSnapshots(snapshotGroupFields(test.getBatteries().get(0).getGroupId()));
         test.setDelFlag("0");
         test.setCreateTime(new Date());
         testMapper.insertTest(test);
@@ -101,26 +111,29 @@
         TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
         if (current == null) throw new ServiceException("测试不存在");
         loadConfiguration(current);
-        if (!NOT_MONITORED.equals(current.getMonitoringStatus()))
-            throw new ServiceException("开始监测后不能修改测试配置");
+        if (!NOT_STARTED.equals(current.getReceptionStatus()))
+            throw new ServiceException("开始接收后不能修改测试配置");
 
         test.setCreatorId(current.getCreatorId());
         test.setTestStatus(current.getTestStatus());
-        test.setMonitoringStatus(current.getMonitoringStatus());
+        test.setReceptionStatus(current.getReceptionStatus());
         if (test.getMonitorTimeoutMinutes() == null)
             test.setMonitorTimeoutMinutes(current.getMonitorTimeoutMinutes());
         validateBase(test, true);
         validateConfiguration(test);
+        test.setFieldSnapshots(resolveUpdatedFieldSnapshots(test, current));
         test.setUpdateTime(new Date());
         int rows = testMapper.updateTest(test);
         if (rows != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
 
-        // 监测开始前没有原始采集事实,配置关系可在同一事务内整体替换,审计记录仍只追加。
+        // 开始接收前没有数据归属事实,配置关系可在同一事务内整体替换,审计记录仍只追加。
+        testMapper.deleteTestCollectorChannelsByTestId(test.getTestId());
         testMapper.deleteTestBindingsByTestId(test.getTestId());
         testMapper.deleteTestPlanStepsByTestId(test.getTestId());
         testMapper.deleteTestFieldsByTestId(test.getTestId());
         testMapper.deleteTestBatteriesByTestId(test.getTestId());
         testMapper.deleteTestThresholdsByTestId(test.getTestId());
+        testMapper.deleteTestFieldSnapshotsByTestId(test.getTestId());
         saveConfiguration(test);
         testMapper.insertTestAudit(test.getTestId(), "TEST_CONFIGURATION_UPDATED", test.getOperatorId(),
                 test.getUpdateBy(), JSON.toJSONString(current), JSON.toJSONString(test));
@@ -130,7 +143,7 @@
     @Override
     public int startTest(Long testId, String updateBy)
     {
-        throw new ServiceException("开始监测由后续监测动作处理");
+        throw new ServiceException("开始接收由后续原子动作处理");
     }
 
     @Override
@@ -143,7 +156,7 @@
         {
             TTest current = testMapper.selectTestByIdForUpdate(testId);
             if (current == null) throw new ServiceException("测试不存在");
-            if (!NOT_MONITORED.equals(current.getMonitoringStatus())) throw new ServiceException("开始监测后不能删除测试");
+            if (!NOT_STARTED.equals(current.getReceptionStatus())) throw new ServiceException("开始接收后不能删除测试");
             loadConfiguration(current);
             int deleted = testMapper.deleteTestById(testId, updateBy);
             if (deleted != 1) throw new ServiceException("测试配置已变更,请刷新后重试");
@@ -180,6 +193,7 @@
 
         List<TTestBattery> batteries = mutable(testMapper.selectTestBatteries(testId));
         List<TTestBatteryField> fields = mutable(testMapper.selectTestBatteryFields(testId));
+        List<TTestCollectorChannel> collectorChannels = mutable(testMapper.selectTestCollectorChannels(testId));
         Map<Long, TTestChannelBinding> bindings = new HashMap<>();
         for (TTestChannelBinding binding : mutable(testMapper.selectTestChannelBindings(testId)))
             bindings.put(binding.getBatteryRelationId(), binding);
@@ -189,10 +203,14 @@
             battery.setFields(fields.stream()
                     .filter(field -> battery.getRelationId().equals(field.getRelationId()))
                     .toList());
+            battery.setCollectorChannels(collectorChannels.stream()
+                    .filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
+                    .toList());
         }
         test.setPlanSteps(planSteps);
         test.setBatteries(batteries);
         test.setThresholds(mutable(testMapper.selectTestThresholds(testId)));
+        test.setFieldSnapshots(mutable(testMapper.selectTestFieldSnapshots(testId)));
     }
 
     private void validateBase(TTest test, boolean update)
@@ -220,7 +238,7 @@
 
     private void validateConfiguration(TTest test)
     {
-        // 新建测试只建立业务身份和通道关系,计划工步可在开始监测前补充。
+        // 新建测试只建立业务身份和通道关系,计划工步可在开始接收前补充。
         if (test.getPlanSteps() == null) test.setPlanSteps(new ArrayList<>());
         validateBatteries(test.getBatteries());
         validatePlanSteps(test.getPlanSteps());
@@ -232,6 +250,8 @@
         if (batteries == null || batteries.isEmpty()) throw new ServiceException("测试至少绑定一个电池号");
         Set<String> batteryNos = new HashSet<>();
         Set<Long> channelIds = new HashSet<>();
+        Set<String> collectorChannelIdentities = new HashSet<>();
+        Map<Long, Integer> collectorChannelCounts = new HashMap<>();
         Long groupId = null;
         for (TTestBattery battery : batteries)
         {
@@ -252,10 +272,45 @@
             binding.setExpectedBarcode(trimToNull(binding.getExpectedBarcode()));
             if (binding.getExpectedBarcode() != null && binding.getExpectedBarcode().length() > 100)
                 throw new ServiceException("预期条码长度不能超过100个字符");
-            if (battery.getCollectorId() != null && testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
+            if (battery.getCollectorId() == null) throw new ServiceException("采集器不能为空");
+            if (testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
                 throw new ServiceException("采集器不存在或未启用");
+            Integer channelCount = collectorChannelCounts.computeIfAbsent(battery.getCollectorId(),
+                    testMapper::selectCollectorChannelCount);
+            if (channelCount == null || channelCount < 1) throw new ServiceException("采集器通道配置无效");
+            validateCollectorChannels(battery.getCollectorId(), channelCount, battery.getCollectorChannels(),
+                    collectorChannelIdentities);
             validateBatteryFields(battery.getFields());
         }
+    }
+
+    private void validateCollectorChannels(Long collectorId, int channelCount, List<TTestCollectorChannel> mappings,
+            Set<String> collectorChannelIdentities)
+    {
+        if (mappings == null || mappings.isEmpty())
+            throw new ServiceException("每个电池号必须绑定主温度、正电位主通道和负电位主通道");
+        Set<String> measurementCodes = new HashSet<>();
+        for (TTestCollectorChannel mapping : mappings)
+        {
+            if (mapping == null) throw new ServiceException("采集器通道映射不完整");
+            mapping.setMeasurementCode(trimToNull(mapping.getMeasurementCode()));
+            mapping.setMeasurementName(trimToNull(mapping.getMeasurementName()));
+            mapping.setChannelCode(normalizeCollectorChannel(mapping.getChannelCode()));
+            if (mapping.getMeasurementCode() == null || mapping.getMeasurementName() == null
+                    || mapping.getChannelCode() == null)
+                throw new ServiceException("采集器通道映射不完整");
+            if (mapping.getMeasurementCode().length() > 100 || mapping.getMeasurementName().length() > 100)
+                throw new ServiceException("采集器测量项长度不能超过100个字符");
+            int channelNumber = Integer.parseInt(mapping.getChannelCode().substring(2));
+            if (channelNumber < 1 || channelNumber > channelCount)
+                throw new ServiceException("采集器通道不存在或超出登记范围");
+            if (!measurementCodes.add(mapping.getMeasurementCode()))
+                throw new ServiceException("同一电池号的采集器测量项不可重复");
+            if (!collectorChannelIdentities.add(collectorId + ":" + mapping.getChannelCode()))
+                throw new ServiceException("同一采集器通道不可重复映射");
+        }
+        if (!measurementCodes.containsAll(REQUIRED_COLLECTOR_MEASUREMENTS))
+            throw new ServiceException("每个电池号必须绑定主温度、正电位主通道和负电位主通道");
     }
 
     private void validateBatteryFields(List<TTestBatteryField> fields)
@@ -269,6 +324,41 @@
             if (field.getFieldName() == null || !fieldNames.add(field.getFieldName()))
                 throw new ServiceException("扩展属性名称不能为空且不可重复");
         }
+    }
+
+    private List<TTestFieldSnapshot> resolveUpdatedFieldSnapshots(TTest test, TTest current)
+    {
+        Long nextGroupId = test.getBatteries().get(0).getGroupId();
+        Long currentGroupId = current.getBatteries().isEmpty() ? null : current.getBatteries().get(0).getGroupId();
+        if (nextGroupId.equals(currentGroupId))
+            return mutable(current.getFieldSnapshots()).stream().map(this::copySnapshot).toList();
+        return snapshotGroupFields(nextGroupId);
+    }
+
+    private List<TTestFieldSnapshot> snapshotGroupFields(Long groupId)
+    {
+        TBatteryGroup group = batteryGroupService.selectBatteryGroupById(groupId);
+        List<TTestFieldSnapshot> snapshots = new ArrayList<>();
+        for (TBatteryGroupRelationField field : mutable(group.getTemplateFields()))
+        {
+            TTestFieldSnapshot snapshot = new TTestFieldSnapshot();
+            snapshot.setFieldDefinitionId(field.getBatteryGroupFieldId());
+            snapshot.setFieldName(field.getFieldName());
+            snapshot.setFieldType(field.getFieldType());
+            snapshot.setFieldValue(field.getFieldValue());
+            snapshots.add(snapshot);
+        }
+        return snapshots;
+    }
+
+    private TTestFieldSnapshot copySnapshot(TTestFieldSnapshot source)
+    {
+        TTestFieldSnapshot copy = new TTestFieldSnapshot();
+        copy.setFieldDefinitionId(source.getFieldDefinitionId());
+        copy.setFieldName(source.getFieldName());
+        copy.setFieldType(source.getFieldType());
+        copy.setFieldValue(source.getFieldValue());
+        return copy;
     }
 
     private void validatePlanSteps(List<TTestPlanStep> planSteps)
@@ -338,6 +428,18 @@
             binding.setBatteryRelationId(battery.getRelationId());
             binding.setClaimStatus("PENDING");
             testMapper.insertTestChannelBinding(binding);
+            for (TTestCollectorChannel mapping : battery.getCollectorChannels())
+            {
+                mapping.setTestId(test.getTestId());
+                mapping.setBatteryRelationId(battery.getRelationId());
+                mapping.setCollectorId(battery.getCollectorId());
+                testMapper.insertTestCollectorChannel(mapping);
+            }
+        }
+        for (TTestFieldSnapshot snapshot : mutable(test.getFieldSnapshots()))
+        {
+            snapshot.setTestId(test.getTestId());
+            testMapper.insertTestFieldSnapshot(snapshot);
         }
         for (TTestWarningThreshold threshold : test.getThresholds())
         {
@@ -357,6 +459,22 @@
         return StringUtils.isEmpty(trimmed) ? null : trimmed;
     }
 
+    private String normalizeCollectorChannel(String value)
+    {
+        String normalized = trimToNull(value);
+        if (normalized == null || !normalized.toUpperCase().matches("S-\\d+"))
+            throw new ServiceException("采集器通道必须使用S-01...S-N演示编码");
+        try
+        {
+            int channelNumber = Integer.parseInt(normalized.substring(2));
+            return String.format("S-%02d", channelNumber);
+        }
+        catch (NumberFormatException exception)
+        {
+            throw new ServiceException("采集器通道必须使用S-01...S-N演示编码");
+        }
+    }
+
     private <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 60ea046..1ff6619 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -14,7 +14,7 @@
         <result property="testTypeName" column="test_type_name" />
         <result property="testPurpose" column="test_purpose" />
         <result property="testStatus" column="test_status" />
-        <result property="monitoringStatus" column="monitoring_status" />
+        <result property="receptionStatus" column="reception_status" />
         <result property="monitorTimeoutMinutes" column="monitor_timeout_minutes" />
         <result property="startedAtUtc" column="started_at_utc" />
         <result property="endedAtUtc" column="ended_at_utc" />
@@ -25,6 +25,7 @@
         <result property="updateTime" column="update_time" />
         <result property="remark" column="remark" />
         <result property="batteryCount" column="battery_count" />
+        <result property="batteryNo" column="battery_no" />
     </resultMap>
 
     <resultMap id="TTestBatteryResultMap" type="TTestBattery">
@@ -64,6 +65,25 @@
         <result property="displayNo" column="display_no" />
     </resultMap>
 
+    <resultMap id="TTestCollectorChannelResultMap" type="TTestCollectorChannel">
+        <id property="mappingId" column="mapping_id" />
+        <result property="testId" column="test_id" />
+        <result property="batteryRelationId" column="battery_relation_id" />
+        <result property="collectorId" column="collector_id" />
+        <result property="measurementCode" column="measurement_code" />
+        <result property="measurementName" column="measurement_name" />
+        <result property="channelCode" column="channel_code" />
+    </resultMap>
+
+    <resultMap id="TTestFieldSnapshotResultMap" type="TTestFieldSnapshot">
+        <id property="snapshotId" column="snapshot_id" />
+        <result property="testId" column="test_id" />
+        <result property="fieldDefinitionId" column="field_definition_id" />
+        <result property="fieldName" column="field_name" />
+        <result property="fieldType" column="field_type" />
+        <result property="fieldValue" column="field_value" />
+    </resultMap>
+
     <resultMap id="TTestPlanStepResultMap" type="TTestPlanStep">
         <id property="planStepId" column="plan_step_id" />
         <result property="testId" column="test_id" />
@@ -101,10 +121,12 @@
         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.test_status, t.reception_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,
+               (select group_concat(b.battery_no order by b.relation_id separator '、')
+                  from t_test_battery b where b.test_id = t.test_id and b.del_flag = 0) battery_no
         from t_test t
         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'
@@ -120,7 +142,7 @@
             <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="testStatus != null and testStatus != ''">and t.test_status = #{testStatus}</if>
-            <if test="monitoringStatus != null and monitoringStatus != ''">and t.monitoring_status = #{monitoringStatus}</if>
+            <if test="receptionStatus != null and receptionStatus != ''">and t.reception_status = #{receptionStatus}</if>
             <if test="beginCreateTime != null">and t.create_time &gt;= #{beginCreateTime}</if>
             <if test="endCreateTime != null">and t.create_time &lt;= #{endCreateTime}</if>
         </where>
@@ -145,10 +167,10 @@
 
     <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,
+        (test_name, creator_id, principal_id, test_type_id, test_purpose, test_status, reception_status,
          monitor_timeout_minutes, del_flag, create_by, create_time, remark)
         values
-        (#{testName}, #{creatorId}, #{principalId}, #{testTypeId}, #{testPurpose}, #{testStatus}, #{monitoringStatus},
+        (#{testName}, #{creatorId}, #{principalId}, #{testTypeId}, #{testPurpose}, #{testStatus}, #{receptionStatus},
          #{monitorTimeoutMinutes}, 0, #{createBy}, #{createTime}, #{remark})
     </insert>
 
@@ -157,15 +179,17 @@
         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
+        where test_id = #{testId} and reception_status = 'NOT_STARTED' 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
+        where test_id = #{testId} and reception_status = 'NOT_STARTED' and del_flag = 0
     </update>
 
     <delete id="deleteTestBindingsByTestId">delete from t_test_channel_binding where test_id = #{testId}</delete>
+    <delete id="deleteTestCollectorChannelsByTestId">delete from t_test_collector_channel where test_id = #{testId}</delete>
+    <delete id="deleteTestFieldSnapshotsByTestId">delete from t_test_field_snapshot 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
@@ -193,6 +217,16 @@
     <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="insertTestCollectorChannel" parameterType="TTestCollectorChannel" useGeneratedKeys="true" keyProperty="mappingId">
+        insert into t_test_collector_channel
+        (test_id, battery_relation_id, collector_id, measurement_code, measurement_name, channel_code)
+        values(#{testId}, #{batteryRelationId}, #{collectorId}, #{measurementCode}, #{measurementName}, #{channelCode})
+    </insert>
+    <insert id="insertTestFieldSnapshot" parameterType="TTestFieldSnapshot" useGeneratedKeys="true" keyProperty="snapshotId">
+        insert into t_test_field_snapshot
+        (test_id, field_definition_id, field_name, field_type, field_value)
+        values(#{testId}, #{fieldDefinitionId}, #{fieldName}, #{fieldType}, #{fieldValue})
     </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)
@@ -228,6 +262,16 @@
         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="selectTestCollectorChannels" parameterType="Long" resultMap="TTestCollectorChannelResultMap">
+        select mapping_id, test_id, battery_relation_id, collector_id,
+               measurement_code, measurement_name, channel_code
+        from t_test_collector_channel where test_id = #{testId}
+        order by battery_relation_id, mapping_id
+    </select>
+    <select id="selectTestFieldSnapshots" parameterType="Long" resultMap="TTestFieldSnapshotResultMap">
+        select snapshot_id, test_id, field_definition_id, field_name, field_type, field_value
+        from t_test_field_snapshot where test_id = #{testId} order by snapshot_id
     </select>
     <select id="selectTestThresholds" parameterType="Long" resultMap="TTestThresholdResultMap">
         select threshold_id, test_id, metric_code, min_value, max_value, unit, severity, del_flag
@@ -265,6 +309,9 @@
     <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="selectCollectorChannelCount" resultType="int">
+        select channel_count 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
@@ -295,7 +342,8 @@
         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
+        select collector_id collectorId, collector_code collectorCode, collector_name collectorName,
+               channel_count channelCount
         from t_collector where status = '0' and del_flag = 0 order by collector_name, collector_id
     </select>
 

--
Gitblit v1.7.1