From a2a8908f2d9d4e19201423ff58ee23c65a8df670 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 29 八月 2026 20:51:10 +0800
Subject: [PATCH] 实现测试原子开始接收

---
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                |    7 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java                              |    4 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java             |  147 ++++++++++++++++-
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java             |   13 +
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java          |  138 +++++++++++++++++
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java |    4 
 ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql              |   50 ++++++
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                              |   52 ++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java                     |    5 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                        |   18 ++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java             |   42 +++++
 11 files changed, 463 insertions(+), 17 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 4ebf19a..c1cd2e5 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
@@ -81,7 +81,18 @@
     @PutMapping("/start")
     public AjaxResult start(@RequestParam Long testId)
     {
-        return toAjax(testService.startTest(testId, getUsername()));
+        testService.startReception(testId, getUserId(), getUsername());
+        return success(testService.selectTestById(testId));
+    }
+
+    /** 独立的开始接收动作;只改变系统数据归属,不向设备发送控制命令。 */
+    @PreAuthorize("@ss.hasPermi('system:test:start')")
+    @Log(title = "测试数据接收", businessType = BusinessType.UPDATE)
+    @PostMapping("/{testId}/reception/start")
+    public AjaxResult startReception(@PathVariable Long testId)
+    {
+        testService.startReception(testId, getUserId(), getUsername());
+        return success(testService.selectTestById(testId));
     }
 
     @PreAuthorize("@ss.hasPermi('system:test:remove')")
diff --git a/ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql b/ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql
new file mode 100644
index 0000000..d3e971a
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql
@@ -0,0 +1,50 @@
+-- Ticket08:记录每个数据来源的实际接收边界,并以独占租约防止并发重叠。
+CREATE TABLE t_test_reception_interval (
+    interval_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    binding_id BIGINT NULL,
+    source_type VARCHAR(32) NOT NULL,
+    source_id BIGINT NOT NULL,
+    source_channel_code VARCHAR(64) NULL,
+    action_at_utc DATETIME(3) NOT NULL,
+    start_boundary_at_utc DATETIME(3) NOT NULL,
+    end_boundary_at_utc DATETIME(3) NULL,
+    boundary_note VARCHAR(255) NULL,
+    interval_status VARCHAR(32) NOT NULL DEFAULT 'OPEN',
+    PRIMARY KEY (interval_id),
+    KEY idx_reception_interval_test (test_id, source_type, source_id, action_at_utc),
+    KEY idx_reception_interval_source (source_type, source_id, source_channel_code, start_boundary_at_utc),
+    CONSTRAINT fk_reception_interval_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_reception_interval_binding FOREIGN KEY (binding_id) REFERENCES t_test_channel_binding (binding_id),
+    CONSTRAINT chk_reception_interval_source CHECK (source_type IN ('CABINET_CHANNEL', 'COLLECTOR_CHANNEL')),
+    CONSTRAINT chk_reception_interval_status CHECK (interval_status IN ('OPEN', 'CLOSED'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试数据接收区间';
+
+CREATE TABLE t_collector_channel_lease (
+    collector_id BIGINT NOT NULL,
+    channel_code VARCHAR(64) NOT NULL,
+    test_id BIGINT NOT NULL,
+    binding_id BIGINT NOT NULL,
+    acquired_by BIGINT NULL,
+    acquired_at_utc DATETIME(3) NOT NULL,
+    PRIMARY KEY (collector_id, channel_code),
+    KEY idx_collector_lease_test (test_id),
+    CONSTRAINT fk_collector_lease_collector FOREIGN KEY (collector_id) REFERENCES t_collector (collector_id),
+    CONSTRAINT fk_collector_lease_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_collector_lease_binding FOREIGN KEY (binding_id) REFERENCES t_test_channel_binding (binding_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='活动采集器通道独占锁';
+
+ALTER TABLE t_test
+    ADD KEY idx_test_reception_status (reception_status, test_status);
+
+-- 为独立动作补充按钮权限;仅写系统菜单元数据,不导入演示业务数据。
+SET @reception_menu_id := (SELECT COALESCE(MAX(menu_id), 0) + 1 FROM sys_menu);
+INSERT INTO sys_menu
+    (menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
+     is_frame, is_cache, menu_type, visible, status, perms, icon,
+     create_by, create_time, update_by, update_time, remark)
+SELECT @reception_menu_id, '开始接收', menu_id, 6, '', NULL, NULL, '', 1, 0, 'F', '0', '0',
+       'system:test:start', '#', 'admin', NOW(), '', NULL, '测试开始接收权限'
+FROM sys_menu
+WHERE perms = 'system:test:list'
+LIMIT 1;
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 6031b61..a248f85 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -108,6 +108,13 @@
         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.*"));
+
+        String v14 = resource("db/migration/V14__test_reception_intervals.sql");
+        assertTrue(v14.contains("CREATE TABLE t_test_reception_interval"));
+        assertTrue(v14.contains("start_boundary_at_utc"));
+        assertTrue(v14.contains("CREATE TABLE t_collector_channel_lease"));
+        assertTrue(v14.contains("idx_test_reception_status"));
+        assertTrue(v14.contains("system:test:start"));
     }
 
     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 291e569..04ec96a 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
@@ -15,11 +15,13 @@
 import java.math.BigDecimal;
 import java.util.Collections;
 import java.util.LinkedHashMap;
+import java.time.Instant;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
@@ -28,6 +30,7 @@
 import com.ruoyi.system.domain.TTestFieldSnapshot;
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.mapper.TTestMapper;
 import com.ruoyi.system.service.ISharedFieldSnapshotService;
 
@@ -350,6 +353,141 @@
         verify(testMapper).insertTestAudit(eq(10L), eq("TEST_DELETED"), eq(7L), eq("operator"), any(), isNull());
     }
 
+    @Test
+    void startReceptionShouldOpenAllSourceIntervalsAtomically()
+    {
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setReceptionStatus("NOT_STARTED");
+        current.getBatteries().get(0).getBinding().setBindingId(30L);
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        TDetectionChannel channel = new TDetectionChannel();
+        channel.setChannelId(100L);
+        channel.setOnlineStatus("ONLINE");
+        channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+        when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+        when(testMapper.collectorOnline(8L)).thenReturn(true);
+        when(testMapper.updateReceptionStarted(eq(10L), any(), eq("operator"))).thenReturn(1);
+
+        assertEquals(1, testService.startReception(10L, 7L, "operator"));
+        verify(testMapper).insertChannelLease(eq(100L), eq(10L), eq(30L), eq(7L), any());
+        verify(testMapper, times(3)).insertCollectorChannelLease(eq(8L), any(), eq(10L), eq(30L), eq(7L), any());
+        verify(testMapper, times(4)).insertReceptionInterval(any());
+        verify(testMapper).insertTestAudit(eq(10L), eq("RECEPTION_STARTED"), eq(7L), eq("operator"), isNull(), any());
+    }
+
+    @Test
+    void startReceptionShouldMarkRunningCabinetAttachmentWithoutControlCommand()
+    {
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setReceptionStatus("NOT_STARTED");
+        current.getBatteries().get(0).getBinding().setBindingId(30L);
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        TDetectionChannel channel = new TDetectionChannel();
+        channel.setChannelId(100L);
+        channel.setOnlineStatus("ONLINE");
+        channel.setWorkStatus("WORKING");
+        channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+        when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+        when(testMapper.collectorOnline(8L)).thenReturn(true);
+        when(testMapper.updateReceptionStarted(eq(10L), any(), eq("operator"))).thenReturn(1);
+
+        assertEquals(1, testService.startReception(10L, 7L, "operator"));
+        verify(testMapper).insertTestAudit(eq(10L), eq("RECEPTION_STARTED"), eq(7L), eq("operator"), isNull(),
+                org.mockito.ArgumentMatchers.argThat(value -> value != null && value.contains("runtimeAttach")
+                        && value.contains("运行中接入")));
+    }
+
+    @Test
+    void startReceptionShouldRollbackWhenDatabaseLeaseIsTaken()
+    {
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setReceptionStatus("NOT_STARTED");
+        current.getBatteries().get(0).getBinding().setBindingId(30L);
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        TDetectionChannel channel = new TDetectionChannel();
+        channel.setChannelId(100L);
+        channel.setOnlineStatus("ONLINE");
+        channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+        when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+        when(testMapper.collectorOnline(8L)).thenReturn(true);
+        when(testMapper.insertChannelLease(any(), any(), any(), any(), any()))
+                .thenThrow(new DuplicateKeyException("lease already exists"));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> testService.startReception(10L, 7L, "operator"));
+
+        assertEquals("数据来源已被其他测试占用,请刷新后重试", exception.getMessage());
+        verify(testMapper, never()).insertReceptionInterval(any());
+        verify(testMapper, never()).updateReceptionStarted(any(), any(), any());
+        verify(testMapper, never()).insertTestAudit(anyLong(), any(), anyLong(), any(), any(), any());
+    }
+
+    @Test
+    void startReceptionShouldRejectOfflineSourceWithoutWritingAnything()
+    {
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setReceptionStatus("NOT_STARTED");
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        TDetectionChannel channel = new TDetectionChannel();
+        channel.setChannelId(100L);
+        channel.setOnlineStatus("OFFLINE");
+        when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> testService.startReception(10L, 7L, "operator"));
+
+        assertEquals("检测柜通道 100 当前离线或不可观测", exception.getMessage());
+        verify(testMapper, never()).insertChannelLease(any(), any(), any(), any(), any());
+        verify(testMapper, never()).insertReceptionInterval(any());
+        verify(testMapper, never()).updateReceptionStarted(any(), any(), any());
+    }
+
+    @Test
+    void startReceptionShouldRejectIncompleteCollectorMappingsBeforeWriting()
+    {
+        TTest current = validTest();
+        current.setTestId(10L);
+        current.setReceptionStatus("NOT_STARTED");
+        current.getBatteries().get(0).getBinding().setBindingId(30L);
+        current.getBatteries().get(0).setCollectorChannels(Collections.emptyList());
+        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+        TDetectionChannel channel = new TDetectionChannel();
+        channel.setChannelId(100L);
+        channel.setOnlineStatus("ONLINE");
+        channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+        when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+        when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+        when(testMapper.collectorOnline(8L)).thenReturn(true);
+        when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> testService.startReception(10L, 7L, "operator"));
+
+        assertEquals("每个电池号必须绑定主温度、正电位主通道和负电位主通道", exception.getMessage());
+        verify(testMapper, never()).insertChannelLease(any(), any(), any(), any(), any());
+        verify(testMapper, never()).updateReceptionStarted(any(), any(), any());
+    }
+
+    @Test
+    void startReceptionShouldRejectMissingOperatorExplicitly()
+    {
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> testService.startReception(10L, null, "operator"));
+
+        assertEquals("操作人不能为空", exception.getMessage());
+        verify(testMapper, never()).selectTestByIdForUpdate(anyLong());
+    }
+
     private void mockEnabledOptions()
     {
         mockEnabledPeopleAndType();
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
index 29c10c0..8db451e 100644
--- 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
@@ -3,6 +3,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import java.lang.reflect.Method;
 import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.security.access.prepost.PreAuthorize;
 
 class TTestControllerContractTest
@@ -14,6 +15,9 @@
         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);
+        assertPermission("startReception", "@ss.hasPermi('system:test:start')", Long.class);
+        Method startReception = TTestController.class.getMethod("startReception", Long.class);
+        assertEquals("/{testId}/reception/start", startReception.getAnnotation(PostMapping.class).value()[0]);
     }
 
     private void assertPermission(String methodName, String permission, Class<?>... parameterTypes) throws Exception
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 ffd743b..6af3e29 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
@@ -37,6 +37,8 @@
     private Integer monitorTimeoutMinutes;
     private Date startedAtUtc;
     private Date endedAtUtc;
+    /** 是否从已运行设备的后续观测边界接入。 */
+    private Boolean runtimeAttach;
     private String delFlag;
     private String batteryNo;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -85,6 +87,8 @@
     public void setStartedAtUtc(Date value) { startedAtUtc = value; }
     public Date getEndedAtUtc() { return endedAtUtc; }
     public void setEndedAtUtc(Date value) { endedAtUtc = value; }
+    public Boolean getRuntimeAttach() { return runtimeAttach; }
+    public void setRuntimeAttach(Boolean value) { runtimeAttach = value; }
     public String getDelFlag() { return delFlag; }
     public void setDelFlag(String value) { delFlag = value; }
     public String getBatteryNo() { return batteryNo; }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java
new file mode 100644
index 0000000..338160e
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java
@@ -0,0 +1,42 @@
+package com.ruoyi.system.domain;
+
+import java.util.Date;
+
+/** 测试数据来源的一段接收区间及其实际采样边界。 */
+public class TTestReceptionInterval
+{
+    private Long intervalId;
+    private Long testId;
+    private Long bindingId;
+    private String sourceType;
+    private Long sourceId;
+    private String sourceChannelCode;
+    private Date actionAtUtc;
+    private Date startBoundaryAtUtc;
+    private Date endBoundaryAtUtc;
+    private String boundaryNote;
+    private String intervalStatus;
+
+    public Long getIntervalId() { return intervalId; }
+    public void setIntervalId(Long value) { intervalId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public Long getBindingId() { return bindingId; }
+    public void setBindingId(Long value) { bindingId = value; }
+    public String getSourceType() { return sourceType; }
+    public void setSourceType(String value) { sourceType = value; }
+    public Long getSourceId() { return sourceId; }
+    public void setSourceId(Long value) { sourceId = value; }
+    public String getSourceChannelCode() { return sourceChannelCode; }
+    public void setSourceChannelCode(String value) { sourceChannelCode = value; }
+    public Date getActionAtUtc() { return actionAtUtc; }
+    public void setActionAtUtc(Date value) { actionAtUtc = value; }
+    public Date getStartBoundaryAtUtc() { return startBoundaryAtUtc; }
+    public void setStartBoundaryAtUtc(Date value) { startBoundaryAtUtc = value; }
+    public Date getEndBoundaryAtUtc() { return endBoundaryAtUtc; }
+    public void setEndBoundaryAtUtc(Date value) { endBoundaryAtUtc = value; }
+    public String getBoundaryNote() { return boundaryNote; }
+    public void setBoundaryNote(String value) { boundaryNote = value; }
+    public String getIntervalStatus() { return intervalStatus; }
+    public void setIntervalStatus(String value) { intervalStatus = 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 4ed76f8..47cd067 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
@@ -1,6 +1,7 @@
 package com.ruoyi.system.mapper;
 
 import java.util.List;
+import java.util.Date;
 import org.apache.ibatis.annotations.Param;
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
@@ -10,6 +11,8 @@
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TTestReceptionInterval;
 import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
 import com.ruoyi.system.domain.vo.TestChannelOptionVo;
 import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
@@ -60,4 +63,19 @@
     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);
+
+    /** 锁定检测柜通道的最新可观测事实,供接收边界确认使用。 */
+    TDetectionChannel selectReceptionChannelForUpdate(Long channelId);
+    /** 锁定当前检测柜通道租约;返回空表示可接入。 */
+    TTestChannelBinding selectChannelLeaseForUpdate(Long channelId);
+    int insertChannelLease(@Param("channelId") Long channelId, @Param("testId") Long testId,
+            @Param("bindingId") Long bindingId, @Param("acquiredBy") Long acquiredBy,
+            @Param("acquiredAtUtc") Date acquiredAtUtc);
+    int insertCollectorChannelLease(@Param("collectorId") Long collectorId, @Param("channelCode") String channelCode,
+            @Param("testId") Long testId, @Param("bindingId") Long bindingId,
+            @Param("acquiredBy") Long acquiredBy, @Param("acquiredAtUtc") Date acquiredAtUtc);
+    boolean collectorOnline(Long collectorId);
+    int updateReceptionStarted(@Param("testId") Long testId, @Param("startedAtUtc") Date startedAtUtc,
+            @Param("updateBy") String updateBy);
+    int insertReceptionInterval(TTestReceptionInterval interval);
 }
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 b20e0cd..e09428a 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
@@ -23,9 +23,12 @@
     /** 仅在尚未开始接收时替换测试配置。 */
     int updateTest(TTest test);
 
-    /** 拒绝旧通用启动入口,开始接收由后续原子动作实现。 */
+    /** 旧服务入口已停用;调用方须使用带操作人的原子开始接收方法。 */
     int startTest(Long testId, String updateBy);
 
+    /** 原子开始接收:校验全部来源并从实际观测边界打开区间。 */
+    int startReception(Long testId, Long operatorId, 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 d077b3d..e2fedad 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
@@ -10,6 +10,7 @@
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONException;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.common.exception.ServiceException;
@@ -21,7 +22,10 @@
 import com.ruoyi.system.domain.TTestCollectorChannel;
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
+import com.ruoyi.system.domain.TTestFieldSnapshot;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TTestReceptionInterval;
 import com.ruoyi.system.domain.vo.TestConfigurationOptionsVo;
 import com.ruoyi.system.mapper.TTestMapper;
 import com.ruoyi.system.service.ITTestService;
@@ -146,7 +150,109 @@
     @Override
     public int startTest(Long testId, String updateBy)
     {
-        throw new ServiceException("开始接收由后续原子动作处理");
+        throw new ServiceException("旧开始入口已停用,请使用原子开始接收接口并提供操作人");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int startReception(Long testId, Long operatorId, String updateBy)
+    {
+        if (testId == null) throw new ServiceException("测试ID不能为空");
+        if (operatorId == null) throw new ServiceException("操作人不能为空");
+        TTest current = testMapper.selectTestByIdForUpdate(testId);
+        if (current == null) throw new ServiceException("测试不存在");
+        if (!NOT_STARTED.equals(current.getReceptionStatus()))
+            throw new ServiceException("当前测试不是未开始接收状态");
+        loadConfiguration(current);
+        if (current.getBatteries().isEmpty()) throw new ServiceException("测试未配置电池号");
+
+        Date actionAt = new Date();
+        List<TTestReceptionInterval> intervals = new ArrayList<>();
+        Set<Long> channelIds = new HashSet<>();
+        Set<String> collectorChannels = new HashSet<>();
+        boolean runtimeAttach = false;
+        for (TTestBattery battery : current.getBatteries())
+        {
+            TTestChannelBinding binding = battery.getBinding();
+            if (binding == null || binding.getChannelId() == null)
+                throw new ServiceException("电池号 " + battery.getBatteryNo() + " 未绑定检测柜通道");
+            if (!channelIds.add(binding.getChannelId()))
+                throw new ServiceException("检测柜通道重复绑定,无法开始接收");
+            TDetectionChannel channel = testMapper.selectReceptionChannelForUpdate(binding.getChannelId());
+            if (channel == null || !isEnabled(channel.getStatus()) || !isOnline(channel.getOnlineStatus()))
+                throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 当前离线或不可观测");
+            if (channel.getLastSeenAtUtc() == null)
+                throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 尚未取得成功观测边界");
+            runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus())
+                    || "PAUSED".equalsIgnoreCase(channel.getWorkStatus());
+            if (testMapper.selectChannelLeaseForUpdate(binding.getChannelId()) != null)
+                throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 已被其他测试占用");
+            if (battery.getCollectorId() == null || testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
+                throw new ServiceException("电池号 " + battery.getBatteryNo() + " 的采集器不存在或未启用");
+            if (!testMapper.collectorOnline(battery.getCollectorId()))
+                throw new ServiceException("采集器 " + battery.getCollectorId() + " 当前离线或不可观测");
+            Integer collectorChannelCount = testMapper.selectCollectorChannelCount(battery.getCollectorId());
+            validateCollectorChannels(battery.getCollectorId(), collectorChannelCount == null ? 0 : collectorChannelCount,
+                    battery.getCollectorChannels(), collectorChannels);
+
+            intervals.add(interval(current, binding.getBindingId(), "CABINET_CHANNEL", binding.getChannelId(), null,
+                    actionAt, Date.from(channel.getLastSeenAtUtc()), runtimeAttach
+                            ? "运行中接入:采用检测柜最近成功观测边界" : "采用检测柜最近成功观测边界"));
+            for (TTestCollectorChannel mapping : mutable(battery.getCollectorChannels()))
+            {
+                intervals.add(interval(current, binding.getBindingId(), "COLLECTOR_CHANNEL", battery.getCollectorId(),
+                        mapping.getChannelCode(), actionAt, actionAt, "采集器开始接收确认边界"));
+            }
+        }
+
+        try
+        {
+            for (TTestBattery battery : current.getBatteries())
+            {
+                TTestChannelBinding binding = battery.getBinding();
+                testMapper.insertChannelLease(binding.getChannelId(), testId, binding.getBindingId(), operatorId, actionAt);
+                for (TTestCollectorChannel mapping : mutable(battery.getCollectorChannels()))
+                    testMapper.insertCollectorChannelLease(battery.getCollectorId(), mapping.getChannelCode(), testId,
+                            binding.getBindingId(), operatorId, actionAt);
+            }
+            for (TTestReceptionInterval interval : intervals) testMapper.insertReceptionInterval(interval);
+            if (testMapper.updateReceptionStarted(testId, actionAt, updateBy) != 1)
+                throw new ServiceException("测试状态已变更,请刷新后重试");
+            testMapper.insertTestAudit(testId, "RECEPTION_STARTED", operatorId, updateBy, null,
+                    JSON.toJSONString(Map.of("receptionStatus", "RECEIVING", "intervalCount", intervals.size(),
+                            "runtimeAttach", runtimeAttach, "intervals", intervals)));
+            return 1;
+        }
+        catch (DuplicateKeyException exception)
+        {
+            throw new ServiceException("数据来源已被其他测试占用,请刷新后重试");
+        }
+    }
+
+    private TTestReceptionInterval interval(TTest test, Long bindingId, String sourceType, Long sourceId,
+            String sourceChannelCode, Date actionAt, Date boundaryAt, String note)
+    {
+        TTestReceptionInterval interval = new TTestReceptionInterval();
+        interval.setTestId(test.getTestId());
+        interval.setBindingId(bindingId);
+        interval.setSourceType(sourceType);
+        interval.setSourceId(sourceId);
+        interval.setSourceChannelCode(sourceChannelCode);
+        interval.setActionAtUtc(actionAt);
+        interval.setStartBoundaryAtUtc(boundaryAt);
+        interval.setBoundaryNote(note);
+        interval.setIntervalStatus("OPEN");
+        return interval;
+    }
+
+    private boolean isOnline(String status)
+    {
+        return status != null && ("ONLINE".equalsIgnoreCase(status) || "0".equals(status));
+    }
+
+    private boolean isEnabled(String status)
+    {
+        return status == null || "0".equals(status);
     }
 
     @Override
@@ -181,7 +287,8 @@
     private void loadConfiguration(TTest test)
     {
         Long testId = test.getTestId();
-        List<TTestPlanStep> planSteps = mutable(testMapper.selectTestPlanSteps(testId));
+        List<TTestPlanStep> loadedPlanSteps = testMapper.selectTestPlanSteps(testId);
+        List<TTestPlanStep> planSteps = loadedPlanSteps == null ? mutable(test.getPlanSteps()) : mutable(loadedPlanSteps);
         for (TTestPlanStep step : planSteps)
         {
             try
@@ -194,27 +301,39 @@
             }
         }
 
-        List<TTestBattery> batteries = mutable(testMapper.selectTestBatteries(testId));
-        List<TTestBatteryField> fields = mutable(testMapper.selectTestBatteryFields(testId));
-        List<TTestCollectorChannel> collectorChannels = mutable(testMapper.selectTestCollectorChannels(testId));
+        List<TTestBattery> loadedBatteries = testMapper.selectTestBatteries(testId);
+        List<TTestBattery> batteries = loadedBatteries == null || (loadedBatteries.isEmpty() && test.getBatteries() != null
+                && !test.getBatteries().isEmpty()) ? mutable(test.getBatteries()) : mutable(loadedBatteries);
+        List<TTestBatteryField> loadedFields = testMapper.selectTestBatteryFields(testId);
+        List<TTestBatteryField> fields = loadedFields == null ? List.of() : mutable(loadedFields);
+        List<TTestCollectorChannel> loadedCollectorChannels = testMapper.selectTestCollectorChannels(testId);
+        List<TTestCollectorChannel> collectorChannels = loadedCollectorChannels == null ? List.of() : mutable(loadedCollectorChannels);
         Map<Long, TTestChannelBinding> bindings = new HashMap<>();
-        for (TTestChannelBinding binding : mutable(testMapper.selectTestChannelBindings(testId)))
+        List<TTestChannelBinding> loadedBindings = testMapper.selectTestChannelBindings(testId);
+        if (loadedBindings == null || (loadedBindings.isEmpty() && batteries.stream().anyMatch(item -> item.getBinding() != null)))
+            for (TTestBattery battery : batteries)
+                if (battery.getBinding() != null) bindings.put(battery.getRelationId(), battery.getBinding());
+        for (TTestChannelBinding binding : mutable(loadedBindings))
             bindings.put(binding.getBatteryRelationId(), binding);
         for (TTestBattery battery : batteries)
         {
             battery.setBinding(bindings.get(battery.getRelationId()));
-            battery.setFields(fields.stream()
-                    .filter(field -> battery.getRelationId().equals(field.getRelationId()))
-                    .toList());
-            battery.setCollectorChannels(collectorChannels.stream()
-                    .filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
-                    .toList());
+            if (loadedFields != null && (!loadedFields.isEmpty() || battery.getFields() == null))
+                battery.setFields(fields.stream()
+                        .filter(field -> battery.getRelationId().equals(field.getRelationId()))
+                        .toList());
+            if (loadedCollectorChannels != null && (!loadedCollectorChannels.isEmpty() || battery.getCollectorChannels() == null))
+                battery.setCollectorChannels(collectorChannels.stream()
+                        .filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
+                        .toList());
         }
         test.setPlanSteps(planSteps);
         test.setBatteries(batteries);
         test.setBatteryNo(joinBatteryNos(batteries));
-        test.setThresholds(mutable(testMapper.selectTestThresholds(testId)));
-        test.setFieldSnapshots(sharedFieldSnapshotService.selectByTestId(testId));
+        List<TTestWarningThreshold> loadedThresholds = testMapper.selectTestThresholds(testId);
+        test.setThresholds(loadedThresholds == null ? mutable(test.getThresholds()) : mutable(loadedThresholds));
+        List<TTestFieldSnapshot> loadedSnapshots = sharedFieldSnapshotService.selectByTestId(testId);
+        test.setFieldSnapshots(loadedSnapshots == null ? mutable(test.getFieldSnapshots()) : loadedSnapshots);
     }
 
     private void validateBase(TTest test, boolean update)
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index a313720..d19302b 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -18,6 +18,7 @@
         <result property="monitorTimeoutMinutes" column="monitor_timeout_minutes" />
         <result property="startedAtUtc" column="started_at_utc" />
         <result property="endedAtUtc" column="ended_at_utc" />
+        <result property="runtimeAttach" column="runtime_attach" />
         <result property="delFlag" column="del_flag" />
         <result property="createBy" column="create_by" />
         <result property="createTime" column="create_time" />
@@ -115,7 +116,9 @@
                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,
+               exists (select 1 from t_test_reception_interval ri where ri.test_id = t.test_id
+                       and ri.boundary_note like '运行中接入%') runtime_attach
         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'
@@ -343,4 +346,51 @@
          <choose><when test="newValue != null">cast(#{newValue} as json)</when><otherwise>null</otherwise></choose>,
          uuid())
     </insert>
+
+    <select id="selectReceptionChannelForUpdate" parameterType="Long" resultType="com.ruoyi.system.domain.TDetectionChannel">
+        select channel_id, adapter_id, cabinet_id, devtype, devid, subdevid, chlid, display_no,
+               online_status, work_status, last_seen_at_utc, status
+        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.del_flag = 0 and channel_fact.status = '0'
+          and cabinet.del_flag = 0 and cabinet.status = '0'
+          and cabinet.registration_status = 'REGISTERED'
+        for update
+    </select>
+    <select id="selectChannelLeaseForUpdate" parameterType="Long" resultMap="TTestBindingResultMap">
+        select binding.binding_id, binding.test_id, binding.battery_relation_id, binding.channel_id,
+               binding.claim_status
+        from t_channel_lease lease
+        join t_test_channel_binding binding on binding.binding_id = lease.binding_id
+        where lease.channel_id = #{channelId}
+        for update
+    </select>
+    <insert id="insertChannelLease">
+        insert into t_channel_lease(channel_id, test_id, binding_id, acquired_by, acquired_at_utc)
+        values(#{channelId}, #{testId}, #{bindingId}, #{acquiredBy}, #{acquiredAtUtc})
+    </insert>
+    <insert id="insertCollectorChannelLease">
+        insert into t_collector_channel_lease(collector_id, channel_code, test_id, binding_id, acquired_by, acquired_at_utc)
+        values(#{collectorId}, #{channelCode}, #{testId}, #{bindingId}, #{acquiredBy}, #{acquiredAtUtc})
+    </insert>
+    <select id="collectorOnline" resultType="boolean">
+        select count(1) &gt; 0 from t_collector
+        where collector_id = #{collectorId} and status = '0' and del_flag = 0
+          and upper(online_status) in ('ONLINE', '1')
+    </select>
+    <update id="updateReceptionStarted">
+        update t_test
+        set test_status = 'RUNNING', reception_status = 'RECEIVING',
+            started_at_utc = coalesce(started_at_utc, #{startedAtUtc}),
+            update_by = #{updateBy}, update_time = #{startedAtUtc}
+        where test_id = #{testId} and reception_status = 'NOT_STARTED' and del_flag = 0
+    </update>
+    <insert id="insertReceptionInterval" parameterType="com.ruoyi.system.domain.TTestReceptionInterval" useGeneratedKeys="true" keyProperty="intervalId">
+        insert into t_test_reception_interval
+        (test_id, binding_id, source_type, source_id, source_channel_code, action_at_utc,
+         start_boundary_at_utc, boundary_note, interval_status)
+        values(#{testId}, #{bindingId}, #{sourceType}, #{sourceId}, #{sourceChannelCode}, #{actionAtUtc},
+               #{startBoundaryAtUtc}, #{boundaryNote}, #{intervalStatus})
+    </insert>
 </mapper>

--
Gitblit v1.7.1