From c6202c6b72613e714a7b32717259b2f8ad532596 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 02 九月 2026 20:38:57 +0800
Subject: [PATCH] 按设备时间归属采集器工步观测

---
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java                     |    2 
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java |   23 +++++++
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                                      |    2 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java             |  105 +++++++++++++++++++++++++++++++++++
 4 files changed, 130 insertions(+), 2 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 657be65..86bfaf7 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
@@ -165,7 +165,7 @@
         return success(consoleDataService.selectOverrides(testId, dataType));
     }
 
-    /** 只查询已归属并持久化到当前测试的检测柜 record。 */
+    /** 查询当前测试成功接收的检测柜与采集器实时观测。 */
     @PreAuthorize("@ss.hasPermi('system:test:query')")
     @GetMapping("/{testId}/data/records")
     public AjaxResult persistedRecords(@PathVariable Long testId)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
index 58b19c1..3755377 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
@@ -3,6 +3,7 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.when;
+import java.time.Instant;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -65,6 +66,28 @@
         assertTrue(service.cycles(8L).get(0).containsKey("cycleNo"));
     }
 
+    @Test
+    void assignsCollectorObservationToCabinetStepByDeviceTime()
+    {
+        when(tests.selectTestById(9L)).thenReturn(new TTest());
+        Map<String, Object> cabinet = row("sourceType", "CABINET", "batteryNo", "B-01", "cycleNo", 1,
+                "stepNo", 2, "stepName", "恒流充电", "deviceTime", Instant.parse("2026-09-02T00:00:00Z"),
+                "adapterId", 1L, "devtype", 22, "deviceId", 22L, "subdevid", 0, "channelId", 8L, "sequenceId", 1L);
+        Map<String, Object> collector = row("sourceType", "COLLECTOR", "batteryNo", "B-01", "metricCode", "temperature",
+                "deviceTime", Instant.parse("2026-09-02T00:00:01Z"), "collectorId", 3L, "channelCode", "S-01", "sequenceId", 2L);
+        when(mapper.selectPersistedRecords(9L)).thenReturn(List.of(cabinet));
+        when(mapper.selectPersistedCollectorRecords(9L)).thenReturn(List.of(collector));
+        when(mapper.selectDataOverrides(9L, "REALTIME")).thenReturn(List.of());
+
+        List<Map<String, Object>> result = new TestDataQueryServiceImpl(mapper, tests).records(9L);
+
+        assertEquals(2, result.size());
+        Map<String, Object> assigned = result.stream().filter(item -> "COLLECTOR".equals(item.get("sourceType"))).findFirst().orElseThrow();
+        assertEquals("1", assigned.get("cycleNo"));
+        assertEquals("2", assigned.get("stepNo"));
+        assertEquals("恒流充电", assigned.get("stepName"));
+    }
+
     private static TTestDataOverride override(String identity, String values, boolean deleted)
     {
         TTestDataOverride result = new TTestDataOverride();
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
index b8fcfd2..8f4b5dc 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
@@ -1,6 +1,13 @@
 package com.ruoyi.system.service.impl;
 
 import java.util.List;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.HashMap;
@@ -33,6 +40,7 @@
         List<Map<String, Object>> collector = mapper.selectPersistedCollectorRecords(testId);
         if (cabinet != null) source.addAll(cabinet);
         if (collector != null) source.addAll(collector);
+        attachCollectorStepContext(source);
         return applyEffectiveData(testId, "REALTIME", source);
     }
 
@@ -116,6 +124,103 @@
         return "cycle:" + channelIdentity + ":" + cycleNo;
     }
 
+    /** 按检测柜实际工步的设备时间边界给采集器样本补充筛选上下文,不配对采样点。 */
+    private void attachCollectorStepContext(List<Map<String, Object>> rows)
+    {
+        List<StepWindow> windows = new ArrayList<>();
+        Map<String, StepWindowBuilder> builders = new LinkedHashMap<>();
+        for (Map<String, Object> row : rows)
+        {
+            if ("COLLECTOR".equals(String.valueOf(row.get("sourceType")))) continue;
+            Instant time = toInstant(row.get("deviceTime"));
+            if (time == null || row.get("stepNo") == null) continue;
+            String key = String.join("|", text(row, "batteryNo"), text(row, "cycleNo"), text(row, "stepNo"));
+            StepWindowBuilder builder = builders.computeIfAbsent(key, ignored -> new StepWindowBuilder(row, time));
+            builder.start = builder.start.isAfter(time) ? time : builder.start;
+            builder.end = builder.end == null || builder.end.isBefore(time) ? time : builder.end;
+        }
+        for (StepWindowBuilder builder : builders.values()) windows.add(builder.build());
+        windows.sort(Comparator.comparing(StepWindow::start));
+        for (int index = 0; index < windows.size(); index++)
+        {
+            StepWindow current = windows.get(index);
+            Instant nextStart = windows.stream()
+                    .filter(window -> window.batteryNo().equals(current.batteryNo()) && window.start().isAfter(current.start()))
+                    .map(StepWindow::start).min(Comparator.naturalOrder()).orElse(null);
+            windows.set(index, current.withNextStart(nextStart));
+        }
+        for (Map<String, Object> row : rows)
+        {
+            if (!"COLLECTOR".equals(String.valueOf(row.get("sourceType")))) continue;
+            Instant time = toInstant(row.get("deviceTime"));
+            if (time == null) continue;
+            StepWindow match = windows.stream()
+                    .filter(window -> text(row, "batteryNo").equals(window.batteryNo()))
+                    .filter(window -> !time.isBefore(window.start())
+                            && (window.nextStart() == null || time.isBefore(window.nextStart())))
+                    .findFirst().orElse(null);
+            if (match == null) continue;
+            row.put("cycleNo", match.cycleNo());
+            row.put("stepNo", match.stepNo());
+            row.put("stepName", match.stepName());
+            row.put("actualStepLabel", match.stepName());
+        }
+    }
+
+    private String text(Map<String, Object> row, String key)
+    {
+        Object value = row.get(key);
+        return value == null ? "" : String.valueOf(value);
+    }
+
+    private Instant toInstant(Object value)
+    {
+        if (value instanceof Instant instant) return instant;
+        if (value instanceof Date date) return date.toInstant();
+        if (value == null) return null;
+        String text = String.valueOf(value);
+        try { return Instant.parse(text); }
+        catch (RuntimeException ignored) { }
+        try { return OffsetDateTime.parse(text).toInstant(); }
+        catch (RuntimeException ignored) { }
+        try { return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC); }
+        catch (RuntimeException ignored) { return null; }
+    }
+
+    private static final class StepWindowBuilder
+    {
+        private final String batteryNo;
+        private final String cycleNo;
+        private final String stepNo;
+        private final String stepName;
+        private Instant start;
+        private Instant end;
+
+        private StepWindowBuilder(Map<String, Object> row, Instant time)
+        {
+            batteryNo = row.get("batteryNo") == null ? "" : String.valueOf(row.get("batteryNo"));
+            cycleNo = row.get("cycleNo") == null ? "" : String.valueOf(row.get("cycleNo"));
+            stepNo = row.get("stepNo") == null ? "" : String.valueOf(row.get("stepNo"));
+            stepName = row.get("stepName") == null ? "-" : String.valueOf(row.get("stepName"));
+            start = time;
+            end = time;
+        }
+
+        private StepWindow build()
+        {
+            return new StepWindow(batteryNo, cycleNo, stepNo, stepName, start, end, null);
+        }
+    }
+
+    private record StepWindow(String batteryNo, String cycleNo, String stepNo, String stepName,
+            Instant start, Instant end, Instant nextStart)
+    {
+        private StepWindow withNextStart(Instant value)
+        {
+            return new StepWindow(batteryNo, cycleNo, stepNo, stepName, start, end, value);
+        }
+    }
+
     private String identityPart(Map<String, Object> row, String key)
     {
         Object value = row == null ? null : row.get(key);
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index e70b84e..56b7312 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -321,7 +321,7 @@
     <select id="selectPersistedRecords" resultType="map">
         select sample.sample_id id,
                concat('RD-', channel.devid, '-', channel.chlid, '-', lpad(sample.seq_id, 6, '0')) recordNo,
-               battery.battery_no batteryNo, sample.cycle_no cycleNo, sample.step_no stepNo,
+               'CABINET' sourceType, battery.battery_no batteryNo, sample.cycle_no cycleNo, sample.step_no stepNo,
                coalesce(sample.step_name, sample.step_type) stepName, sample.step_type stepType, sample.seq_id sequenceId,
                sample.device_time_utc recordedAt, sample.device_time_utc absoluteTime,
                sample.device_time_utc deviceTime, sample.received_at_utc serverReceivedAt,

--
Gitblit v1.7.1