Jelajahi Sumber

feat(cockpit): 接入设备档案与类型指标真实数据

Kazerin 3 minggu lalu
induk
melakukan
19878e6161

+ 127 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitEquipmentArchiveAdapterTest.java

@@ -0,0 +1,127 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.cockpit.equipment.BusinessCockpitEquipmentArchiveAdapter;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class BusinessCockpitEquipmentArchiveAdapterTest {
+
+    private static final String DEVICE_CODE = "GAS-001";
+
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
+    private BusinessCockpitEquipmentArchiveAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        adapter = new BusinessCockpitEquipmentArchiveAdapter(equipmentBaseService, equipmentTypeService);
+
+        EquipmentType gasRoot = type(3, "燃气", 0);
+        EquipmentType gasSensor = type(11, "燃气浓度计", 3);
+        when(equipmentTypeService.list()).thenReturn(Arrays.asList(gasRoot, gasSensor));
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device()));
+    }
+
+    @Test
+    void mapsEveryLedgerFieldAndImageWithoutInventedTimestamp() {
+        CockpitEquipmentArchiveSnapshot snapshot = adapter.load(DEVICE_CODE);
+
+        assertEquals(DEVICE_CODE, snapshot.getDeviceCode());
+        assertEquals("燃气浓度计", snapshot.getDeviceName());
+        assertEquals("MG-500", snapshot.getDeviceModel());
+        assertEquals("防爆型", snapshot.getDeviceSpec());
+        assertEquals("测试仪表厂", snapshot.getManufacturer());
+        assertEquals("2025-12-01", snapshot.getProductionDate());
+        assertEquals("11", snapshot.getDeviceType());
+        assertEquals("燃气浓度计", snapshot.getDeviceTypeName());
+        assertEquals(0, snapshot.getAssetValue().compareTo(new BigDecimal("128000")));
+        assertEquals(10, snapshot.getExpectedServiceYears());        assertEquals(new BigDecimal("110.100"), snapshot.getLongitude());
+        assertEquals(new BigDecimal("28.400"), snapshot.getLatitude());
+        assertEquals("城北燃气站", snapshot.getEquipmentLocation());
+        assertEquals("2026-01-10 09:30:00", snapshot.getInstallTime());
+        assertEquals("张维护", snapshot.getMaintainer());
+        assertEquals("13800000000", snapshot.getMaintainerPhone());
+        assertEquals("燃气公司", snapshot.getOwnershipUnit());
+        assertEquals("重点设备", snapshot.getRemark());
+        assertEquals(1, snapshot.getImages().size());
+        assertEquals("https://cockpit.example/gas-001.jpg", snapshot.getImages().get(0).getUrl());
+        assertNull(snapshot.getImages().get(0).getCapturedAt());
+    }
+
+    @Test
+    void archiveWithoutImageReturnsEmptyImageList() {
+        EquipmentBase bare = device();
+        bare.setEquipmentImage(null);
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(bare));
+
+        CockpitEquipmentArchiveSnapshot snapshot = adapter.load(DEVICE_CODE);
+
+        assertTrue(snapshot.getImages().isEmpty());
+    }
+
+    @Test
+    void unknownDeviceReturnsNullForNotFound() {
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+
+        assertNull(adapter.load("UNKNOWN"));
+    }
+
+    private static EquipmentType type(int id, String name, int parentId) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(parentId);
+        return type;
+    }
+
+    private static EquipmentBase device() {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId("E-001");
+        device.setEquipmentCode(DEVICE_CODE);
+        device.setEquipmentName("燃气浓度计");
+        device.setEquipmentModel("MG-500");
+        device.setEquipmentSpec("防爆型");
+        device.setManufacturer("测试仪表厂");
+        device.setProductionDate(Date.from(LocalDate.of(2025, 12, 1)
+                .atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant()));
+        device.setEquipmentTypeId(11);
+        device.setAssetValue(128000.0);
+        device.setUseLife(10);
+        device.setLongitude(new BigDecimal("110.100"));
+        device.setLatitude(new BigDecimal("28.400"));
+        device.setEquipmentLocation("城北燃气站");
+        device.setInstallTime(LocalDateTime.parse("2026-01-10T09:30:00"));
+        device.setMaintainer("张维护");
+        device.setMaintainerPhone("13800000000");
+        device.setOwnershipUnit("燃气公司");
+        device.setRemark("重点设备");
+        device.setEquipmentImage("https://cockpit.example/gas-001.jpg");
+        return device;
+    }
+}

+ 205 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitTypeMetricsAdapterTest.java

@@ -0,0 +1,205 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.gas.domain.GasMonitorData;
+import com.zksy.base.gas.service.GasMonitorDataService;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.water.domain.WaterMonitorData;
+import com.zksy.base.water.service.WaterMonitorDataService;
+import com.zksy.cockpit.type.BusinessCockpitTypeMetricsAdapter;
+import com.zksy.cockpit.type.CockpitMetricSnapshot;
+import com.zksy.cockpit.type.CockpitTypeMetricsSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class BusinessCockpitTypeMetricsAdapterTest {
+
+    private static final String GAS_CODE = "GAS-001";
+    private static final String WATER_CODE = "WATER-001";
+
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
+    private GasMonitorDataService gasMonitorDataService;
+    private WaterMonitorDataService waterMonitorDataService;
+    private BusinessCockpitTypeMetricsAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        gasMonitorDataService = mock(GasMonitorDataService.class);
+        waterMonitorDataService = mock(WaterMonitorDataService.class);
+        adapter = new BusinessCockpitTypeMetricsAdapter(
+                equipmentBaseService, equipmentTypeService, gasMonitorDataService, waterMonitorDataService);
+
+        EquipmentType gasRoot = type(3, "燃气", 0);
+        EquipmentType gasSensor = type(11, "燃气浓度计", 3);
+        EquipmentType waterRoot = type(1, "供水", 0);
+        EquipmentType waterMeter = type(5, "供水流量计", 1);
+        when(equipmentTypeService.list()).thenReturn(
+                Arrays.asList(gasRoot, gasSensor, waterRoot, waterMeter));
+    }
+
+    @Test
+    void gasCurrentReadsMonitorFrameWithAlarmStatusFromFrameBits() {
+        EquipmentBase device = device("E-001", GAS_CODE, 11);
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device));
+        when(gasMonitorDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(gasFrame(new BigDecimal("18.5"), 2)));
+
+        CockpitTypeMetricsSnapshot snapshot = adapter.loadCurrent(GAS_CODE);
+
+        assertEquals(GAS_CODE, snapshot.getDeviceCode());
+        assertEquals("11", snapshot.getDeviceType());
+        assertEquals("燃气浓度计", snapshot.getDeviceTypeName());
+        assertEquals(3, snapshot.getMetrics().size());
+        CockpitMetricSnapshot concentration = snapshot.getMetrics().get(0);
+        assertEquals("GAS_CONCENTRATION", concentration.getMetricCode());
+        assertEquals("气体浓度", concentration.getMetricName());
+        assertEquals(new BigDecimal("18.5"), concentration.getValue());
+        assertEquals("%LEL", concentration.getUnit());
+        assertEquals("ALARM", concentration.getStatus());
+        assertEquals("2026-09-02T09:00:00+08:00", concentration.getSampledAt());
+    }
+
+    @Test
+    void gasCurrentWithoutAnyFrameReturnsEmptyMetricsButKeepsDevice() {
+        EquipmentBase device = device("E-001", GAS_CODE, 11);
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device));
+        when(gasMonitorDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+
+        CockpitTypeMetricsSnapshot snapshot = adapter.loadCurrent(GAS_CODE);
+
+        assertEquals(GAS_CODE, snapshot.getDeviceCode());
+        assertTrue(snapshot.getMetrics().isEmpty());
+    }
+
+    @Test
+    void gasHistoryPaginatesPointsWithStableTotal() {
+        EquipmentBase device = device("E-001", GAS_CODE, 11);
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device));
+        Page<GasMonitorData> page = new Page<>();
+        page.setRecords(Arrays.asList(
+                gasFrame(new BigDecimal("16.0"), 0),
+                gasFrame(new BigDecimal("18.5"), 0)));
+        page.setTotal(2);
+        when(gasMonitorDataService.page(any(Page.class), any(LambdaQueryWrapper.class)))
+                .thenReturn(page);
+
+        CockpitTypeMetricsSnapshot snapshot = adapter.loadHistory(
+                GAS_CODE,
+                OffsetDateTime.parse("2026-09-02T08:00:00+08:00"),
+                OffsetDateTime.parse("2026-09-02T12:00:00+08:00"),
+                1,
+                100);
+
+        assertEquals(Integer.valueOf(2), snapshot.getTotal());
+        CockpitMetricSnapshot concentration = snapshot.getMetrics().get(0);
+        assertEquals(2, concentration.getPoints().size());
+        assertEquals("NORMAL", concentration.getPoints().get(0).getStatus());
+        assertEquals("2026-09-02T09:00:00+08:00", concentration.getPoints().get(0).getSampledAt());
+    }
+
+    @Test
+    void waterCurrentReadsStringFieldsAndTreatsMissingStatusAsUnknown() {
+        EquipmentBase device = device("E-002", WATER_CODE, 5);
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device));
+        WaterMonitorData row = new WaterMonitorData();
+        row.setId("W-1");
+        row.setDeviceCode(WATER_CODE);
+        row.setWaterLevel("3.2");
+        row.setPowerVoltage("12.6");
+        row.setSignalQuality("85");
+        row.setCreateTime(LocalDateTime.parse("2026-09-02T10:00:00"));
+        when(waterMonitorDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(row));
+
+        CockpitTypeMetricsSnapshot snapshot = adapter.loadCurrent(WATER_CODE);
+
+        assertEquals(WATER_CODE, snapshot.getDeviceCode());
+        assertEquals(3, snapshot.getMetrics().size());
+        CockpitMetricSnapshot level = snapshot.getMetrics().get(0);
+        assertEquals("WATER_LEVEL", level.getMetricCode());
+        assertEquals(new BigDecimal("3.2"), level.getValue());
+        assertEquals("UNKNOWN", level.getStatus());
+        assertEquals("2026-09-02T10:00:00+08:00", level.getSampledAt());
+        assertEquals(new BigDecimal("12.6"), snapshot.getMetrics().get(1).getValue());
+    }
+
+    @Test
+    void unparseableWaterFieldStaysNullWithoutFabricatingValue() {
+        EquipmentBase device = device("E-002", WATER_CODE, 5);
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device));
+        WaterMonitorData row = new WaterMonitorData();
+        row.setId("W-2");
+        row.setDeviceCode(WATER_CODE);
+        row.setWaterLevel("---");
+        row.setCreateTime(LocalDateTime.parse("2026-09-02T10:00:00"));
+        when(waterMonitorDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(row));
+
+        CockpitMetricSnapshot level = adapter.loadCurrent(WATER_CODE).getMetrics().get(0);
+
+        assertNull(level.getValue());
+    }
+
+    @Test
+    void unknownDeviceReturnsNullForNotFound() {
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+
+        assertNull(adapter.loadCurrent("UNKNOWN"));
+    }
+
+    private static EquipmentType type(int id, String name, int parentId) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(parentId);
+        return type;
+    }
+
+    private static EquipmentBase device(String id, String code, int typeId) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(code);
+        device.setEquipmentTypeId(typeId);
+        return device;
+    }
+
+    private static GasMonitorData gasFrame(BigDecimal concentration, Integer alarmInfo) {
+        GasMonitorData frame = new GasMonitorData();
+        frame.setId("F-" + concentration.toPlainString());
+        frame.setMacAddress(GAS_CODE);
+        frame.setGasConcentration(concentration);
+        frame.setBattery(new BigDecimal("80.5"));
+        frame.setSignalStrength(-67.0f);
+        frame.setAlarmInfo(alarmInfo);
+        frame.setReportTime(LocalDateTime.parse("2026-09-02T09:00:00"));
+        return frame;
+    }
+}

+ 13 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEquipmentArchiveControllerTest.java

@@ -111,6 +111,19 @@ class CockpitEquipmentArchiveControllerTest {
                 .andExpect(jsonPath("$.data.latestImage.capturedAt").value("2026-08-30T10:20:00+08:00"));
     }
 
+    @Test
+    void archiveFallsBackToSingleImageWithoutCapturedAt() throws Exception {
+        when(adapter.load("TC-PS-YS-202609020015")).thenReturn(archive(
+                "TC-PS-YS-202609020015",
+                Collections.singletonList(new CockpitEquipmentImage(
+                        "https://cockpit.example/equipment.jpg", null))));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020015/archive"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.latestImage.url").value("https://cockpit.example/equipment.jpg"))
+                .andExpect(jsonPath("$.data.latestImage.capturedAt").doesNotExist());
+    }
+
     @Test
     void missingDeviceReturnsTypedNotFound() throws Exception {
         when(adapter.load("TC-PS-YS-404")).thenReturn(null);

+ 132 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/BusinessCockpitEquipmentArchiveAdapter.java

@@ -0,0 +1,132 @@
+package com.zksy.cockpit.equipment;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 设备档案真实数据适配器(BT-5)。
+ *
+ * <p>字段全部来自 {@code equipment_base} 台账;{@code deviceType} 采用对外口径
+ * {@code String(equipment_type.id)},{@code deviceTypeName} 取类型名。
+ *
+ * <p><b>不臆造</b>:现网 {@code equipment_base.equipment_image} 只有单个图片 URL、无拍摄时间列,
+ * 因此图片 {@code capturedAt} 保持 {@code null}(服务层在无时间戳时将该图视为当前图片);
+ * 坐标与台账一致,不补充校核来源。
+ */
+@Component
+@Primary
+public class BusinessCockpitEquipmentArchiveAdapter implements CockpitEquipmentArchiveAdapter {
+
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
+
+    public BusinessCockpitEquipmentArchiveAdapter(
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService) {
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
+    }
+
+    @Override
+    public CockpitEquipmentArchiveSnapshot load(String deviceCode) {
+        EquipmentBase device = findDevice(deviceCode);
+        if (device == null) {
+            return null;
+        }
+        Map<Integer, EquipmentType> types = equipmentTypes();
+        EquipmentType type = device.getEquipmentTypeId() == null ? null : types.get(device.getEquipmentTypeId());
+        return new CockpitEquipmentArchiveSnapshot(
+                device.getEquipmentCode().trim(),
+                device.getEquipmentName(),
+                device.getEquipmentModel(),
+                device.getEquipmentSpec(),
+                device.getManufacturer(),
+                dateText(device.getProductionDate()),
+                typeCodeOf(type),
+                type == null ? null : type.getTypeName(),
+                toBigDecimal(device.getAssetValue()),
+                device.getUseLife(),
+                device.getLongitude(),
+                device.getLatitude(),
+                device.getEquipmentLocation(),
+                dateTimeText(device.getInstallTime()),
+                device.getMaintainer(),
+                device.getMaintainerPhone(),
+                device.getOwnershipUnit(),
+                device.getRemark(),
+                images(device.getEquipmentImage()));
+    }
+
+    private EquipmentBase findDevice(String deviceCode) {
+        if (isBlank(deviceCode)) {
+            return null;
+        }
+        List<EquipmentBase> devices = safeList(equipmentBaseService.list(
+                new LambdaQueryWrapper<EquipmentBase>()
+                        .eq(EquipmentBase::getEquipmentCode, deviceCode.trim())
+                        .last("limit 1")));
+        return devices.isEmpty() ? null : devices.get(0);
+    }
+
+    private static List<CockpitEquipmentImage> images(String equipmentImage) {
+        if (isBlank(equipmentImage)) {
+            return Collections.emptyList();
+        }
+        return Collections.singletonList(new CockpitEquipmentImage(equipmentImage.trim(), null));
+    }
+
+    private Map<Integer, EquipmentType> equipmentTypes() {
+        Map<Integer, EquipmentType> result = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static String typeCodeOf(EquipmentType type) {
+        return type == null || type.getId() == null ? null : String.valueOf(type.getId());
+    }
+
+    private static String dateText(java.util.Date value) {
+        return value == null ? null : DATE_FORMATTER.format(
+                value.toInstant().atZone(BUSINESS_ZONE).toLocalDate());
+    }
+
+    private static String dateTimeText(LocalDateTime value) {
+        return value == null ? null : DATETIME_FORMATTER.format(value);
+    }
+
+    private static BigDecimal toBigDecimal(Double value) {
+        return value == null ? null : BigDecimal.valueOf(value);
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

+ 15 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentArchiveService.java

@@ -15,6 +15,7 @@ import java.time.format.DateTimeParseException;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Collectors;
 
 @Service
 public class CockpitEquipmentArchiveService {
@@ -64,14 +65,21 @@ public class CockpitEquipmentArchiveService {
             return null;
         }
 
-        try {
-            Optional<CockpitEquipmentImage> latest = images.stream()
-                    .filter(image -> image != null && image.getCapturedAt() != null)
-                    .max(Comparator.comparing(image -> OffsetDateTime.parse(image.getCapturedAt())));
-            return latest.orElse(null);
-        } catch (DateTimeParseException exception) {
-            throw new CockpitReadException("invalid equipment image timestamp: " + exception.getMessage());
+        List<CockpitEquipmentImage> timestamped = images.stream()
+                .filter(image -> image != null && image.getCapturedAt() != null)
+                .collect(Collectors.toList());
+        if (!timestamped.isEmpty()) {
+            try {
+                Optional<CockpitEquipmentImage> latest = timestamped.stream()
+                        .max(Comparator.comparing(image -> OffsetDateTime.parse(image.getCapturedAt())));
+                return latest.orElse(null);
+            } catch (DateTimeParseException exception) {
+                throw new CockpitReadException("invalid equipment image timestamp: " + exception.getMessage());
+            }
         }
+
+        // equipment_base.equipment_image 无拍摄时间列,单 URL 即当前图片,不臆造时间戳。
+        return images.get(0);
     }
 
     private CockpitErrorPayload error(String requestId, CockpitErrorCategory category, String message) {

+ 332 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/type/BusinessCockpitTypeMetricsAdapter.java

@@ -0,0 +1,332 @@
+package com.zksy.cockpit.type;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.gas.domain.GasMonitorData;
+import com.zksy.base.gas.service.GasMonitorDataService;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.water.domain.WaterMonitorData;
+import com.zksy.base.water.service.WaterMonitorDataService;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 类型指标真实数据适配器(BT-5)。
+ *
+ * <p>指标采样来源按设备行业分流:
+ * <ul>
+ *   <li>燃气({@code GAS}):{@code gas_monitor_data},现网惯例以
+ *       {@code mac_address} 存设备编码(见 {@code GasDashboardServiceImpl}),
+ *       指标为气体浓度/%LEL、电池电量/%、信号强度/dBm;报警位取自帧内 {@code alarm_info};</li>
+ *   <li>供水/排水({@code WATER_SUPPLY}、{@code DRAINAGE}):{@code water_monitor_data},
+ *       {@code device_code} 关联设备编码,指标为水位、电源电压、信号质量;
+ *       水位/电压为字符串字段,可解析成数值才上报,解析失败按缺失处理;</li>
+ *   <li>其他行业或类型不可推导:不臆造数据源,返回空指标(服务层按 EMPTY 处理)。</li>
+ * </ul>
+ *
+ * <p><b>不臆造</b>:水位帧无报警位字段 → 状态为 {@code UNKNOWN};气体帧有报警位 → {@code ALARM}/{@code NORMAL};
+ * 无报告时间回退入库时间;不补造趋势与阈值判定。
+ */
+@Component
+@Primary
+public class BusinessCockpitTypeMetricsAdapter implements CockpitTypeMetricsAdapter {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
+    private final GasMonitorDataService gasMonitorDataService;
+    private final WaterMonitorDataService waterMonitorDataService;
+
+    public BusinessCockpitTypeMetricsAdapter(
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService,
+            GasMonitorDataService gasMonitorDataService,
+            WaterMonitorDataService waterMonitorDataService) {
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
+        this.gasMonitorDataService = gasMonitorDataService;
+        this.waterMonitorDataService = waterMonitorDataService;
+    }
+
+    @Override
+    public CockpitTypeMetricsSnapshot loadCurrent(String deviceCode) {
+        EquipmentBase device = findDevice(deviceCode);
+        if (device == null) {
+            return null;
+        }
+        String code = device.getEquipmentCode().trim();
+        Map<Integer, EquipmentType> types = equipmentTypes();
+        String typeCode = typeCodeOf(device, types);
+        String typeName = typeNameOf(device, types);
+        String industry = industryOf(device, types);
+        if ("GAS".equals(industry)) {
+            return gasCurrent(device, code, typeCode, typeName);
+        }
+        if ("WATER_SUPPLY".equals(industry) || "DRAINAGE".equals(industry)) {
+            return waterCurrent(device, code, typeCode, typeName);
+        }
+        return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, Collections.emptyList());
+    }
+
+    @Override
+    public CockpitTypeMetricsSnapshot loadHistory(
+            String deviceCode,
+            OffsetDateTime startTime,
+            OffsetDateTime endTime,
+            int page,
+            int pageSize) {
+        EquipmentBase device = findDevice(deviceCode);
+        if (device == null) {
+            return null;
+        }
+        String code = device.getEquipmentCode().trim();
+        Map<Integer, EquipmentType> types = equipmentTypes();
+        String typeCode = typeCodeOf(device, types);
+        String typeName = typeNameOf(device, types);
+        String industry = industryOf(device, types);
+        if ("GAS".equals(industry)) {
+            return gasHistory(device, code, typeCode, typeName, startTime, endTime, page, pageSize);
+        }
+        if ("WATER_SUPPLY".equals(industry) || "DRAINAGE".equals(industry)) {
+            return waterHistory(device, code, typeCode, typeName, startTime, endTime, page, pageSize);
+        }
+        return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, Collections.emptyList(), 0);
+    }
+
+    // ---------------- 燃气 ----------------
+
+    private CockpitTypeMetricsSnapshot gasCurrent(
+            EquipmentBase device, String code, String typeCode, String typeName) {
+        GasMonitorData latest = latestGas(code);
+        if (latest == null) {
+            return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, Collections.emptyList());
+        }
+        String sampledAt = offsetTime(sampledAt(latest.getReportTime(), latest.getCreateTime()));
+        String status = gasStatus(latest);
+        List<CockpitMetricSnapshot> metrics = new ArrayList<>();
+        addMetric(metrics, "GAS_CONCENTRATION", "气体浓度", latest.getGasConcentration(), "%LEL", status, sampledAt);
+        addMetric(metrics, "GAS_BATTERY", "电池电量", latest.getBattery(), "%", status, sampledAt);
+        addMetric(metrics, "GAS_SIGNAL_STRENGTH", "信号强度",
+                latest.getSignalStrength() == null
+                        ? null : BigDecimal.valueOf(latest.getSignalStrength().doubleValue()),
+                "dBm", status, sampledAt);
+        return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, metrics);
+    }
+
+    private CockpitTypeMetricsSnapshot gasHistory(
+            EquipmentBase device, String code, String typeCode, String typeName,
+            OffsetDateTime startTime, OffsetDateTime endTime, int page, int pageSize) {
+        IPage<GasMonitorData> result = gasMonitorDataService.page(
+                new Page<>(page, pageSize),
+                new LambdaQueryWrapper<GasMonitorData>()
+                        .eq(GasMonitorData::getMacAddress, code)
+                        .between(GasMonitorData::getCreateTime, startTime.toLocalDateTime(), endTime.toLocalDateTime())
+                        .orderByAsc(GasMonitorData::getCreateTime));
+        List<GasMonitorData> rows = result == null || result.getRecords() == null
+                ? Collections.emptyList() : result.getRecords();
+        int total = result == null ? 0 : (int) result.getTotal();
+        if (rows.isEmpty()) {
+            return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, Collections.emptyList(), total);
+        }
+        List<CockpitMetricSnapshot> metrics = new ArrayList<>();
+        metrics.add(buildGasPoints("GAS_CONCENTRATION", "气体浓度", "%LEL", rows, GasMonitorData::getGasConcentration));
+        metrics.add(buildGasPoints("GAS_BATTERY", "电池电量", "%", rows, GasMonitorData::getBattery));
+        metrics.add(buildGasPoints("GAS_SIGNAL_STRENGTH", "信号强度", "dBm", rows, data -> data.getSignalStrength() == null
+                ? null : BigDecimal.valueOf(data.getSignalStrength().doubleValue())));
+        return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, metrics, total);
+    }
+
+    private GasMonitorData latestGas(String code) {
+        List<GasMonitorData> rows = safeList(gasMonitorDataService.list(
+                new LambdaQueryWrapper<GasMonitorData>()
+                        .eq(GasMonitorData::getMacAddress, code)
+                        .orderByDesc(GasMonitorData::getCreateTime)
+                        .last("limit 1")));
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    private CockpitMetricSnapshot buildGasPoints(
+            String metricCode, String metricName, String unit,
+            List<GasMonitorData> rows, Function<GasMonitorData, BigDecimal> extractor) {
+        List<CockpitMetricSnapshot.PointSnapshot> points = new ArrayList<>();
+        BigDecimal latestValue = null;
+        String latestAt = null;
+        String latestStatus = "UNKNOWN";
+        for (GasMonitorData row : rows) {
+            BigDecimal value = extractor.apply(row);
+            String status = gasStatus(row);
+            String at = offsetTime(sampledAt(row.getReportTime(), row.getCreateTime()));
+            points.add(new CockpitMetricSnapshot.PointSnapshot(value, status, at));
+            latestValue = value;
+            latestAt = at;
+            latestStatus = status;
+        }
+        return new CockpitMetricSnapshot(metricCode, metricName, latestValue, unit, latestStatus, latestAt, points);
+    }
+
+    private String gasStatus(GasMonitorData data) {
+        if (data == null || data.getAlarmInfo() == null) {
+            return "UNKNOWN";
+        }
+        boolean alarm = data.parseAlarmBits().values().stream().anyMatch(Boolean::booleanValue);
+        return alarm ? "ALARM" : "NORMAL";
+    }
+
+    // ---------------- 供水/排水 ----------------
+
+    private CockpitTypeMetricsSnapshot waterCurrent(
+            EquipmentBase device, String code, String typeCode, String typeName) {
+        WaterMonitorData latest = latestWater(code);
+        if (latest == null) {
+            return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, Collections.emptyList());
+        }
+        String sampledAt = offsetTime(sampledAt(latest.getTimestampSince(), latest.getCreateTime()));
+        List<CockpitMetricSnapshot> metrics = new ArrayList<>();
+        addMetric(metrics, "WATER_LEVEL", "水位", decimal(latest.getWaterLevel()), null, "UNKNOWN", sampledAt);
+        addMetric(metrics, "WATER_POWER_VOLTAGE", "电源电压", decimal(latest.getPowerVoltage()), "V", "UNKNOWN", sampledAt);
+        addMetric(metrics, "WATER_SIGNAL_QUALITY", "信号质量", decimal(latest.getSignalQuality()), null, "UNKNOWN", sampledAt);
+        return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, metrics);
+    }
+
+    private CockpitTypeMetricsSnapshot waterHistory(
+            EquipmentBase device, String code, String typeCode, String typeName,
+            OffsetDateTime startTime, OffsetDateTime endTime, int page, int pageSize) {
+        IPage<WaterMonitorData> result = waterMonitorDataService.page(
+                new Page<>(page, pageSize),
+                new LambdaQueryWrapper<WaterMonitorData>()
+                        .eq(WaterMonitorData::getDeviceCode, code)
+                        .between(WaterMonitorData::getCreateTime, startTime.toLocalDateTime(), endTime.toLocalDateTime())
+                        .orderByAsc(WaterMonitorData::getCreateTime));
+        List<WaterMonitorData> rows = result == null || result.getRecords() == null
+                ? Collections.emptyList() : result.getRecords();
+        int total = result == null ? 0 : (int) result.getTotal();
+        if (rows.isEmpty()) {
+            return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, Collections.emptyList(), total);
+        }
+        List<CockpitMetricSnapshot> metrics = new ArrayList<>();
+        metrics.add(buildWaterPoints("WATER_LEVEL", "水位", null, rows, WaterMonitorData::getWaterLevel));
+        metrics.add(buildWaterPoints("WATER_POWER_VOLTAGE", "电源电压", "V", rows, WaterMonitorData::getPowerVoltage));
+        metrics.add(buildWaterPoints("WATER_SIGNAL_QUALITY", "信号质量", null, rows, WaterMonitorData::getSignalQuality));
+        return new CockpitTypeMetricsSnapshot(code, typeCode, typeName, metrics, total);
+    }
+
+    private WaterMonitorData latestWater(String code) {
+        List<WaterMonitorData> rows = safeList(waterMonitorDataService.list(
+                new LambdaQueryWrapper<WaterMonitorData>()
+                        .eq(WaterMonitorData::getDeviceCode, code)
+                        .orderByDesc(WaterMonitorData::getCreateTime)
+                        .last("limit 1")));
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    private CockpitMetricSnapshot buildWaterPoints(
+            String metricCode, String metricName, String unit,
+            List<WaterMonitorData> rows, Function<WaterMonitorData, String> extractor) {
+        List<CockpitMetricSnapshot.PointSnapshot> points = new ArrayList<>();
+        BigDecimal latestValue = null;
+        String latestAt = null;
+        for (WaterMonitorData row : rows) {
+            BigDecimal value = decimal(extractor.apply(row));
+            String at = offsetTime(sampledAt(row.getTimestampSince(), row.getCreateTime()));
+            points.add(new CockpitMetricSnapshot.PointSnapshot(value, "UNKNOWN", at));
+            latestValue = value;
+            latestAt = at;
+        }
+        return new CockpitMetricSnapshot(metricCode, metricName, latestValue, unit, "UNKNOWN", latestAt, points);
+    }
+
+    // ---------------- 公共 ----------------
+
+    private EquipmentBase findDevice(String deviceCode) {
+        if (isBlank(deviceCode)) {
+            return null;
+        }
+        List<EquipmentBase> devices = safeList(equipmentBaseService.list(
+                new LambdaQueryWrapper<EquipmentBase>()
+                        .eq(EquipmentBase::getEquipmentCode, deviceCode.trim())
+                        .last("limit 1")));
+        return devices.isEmpty() ? null : devices.get(0);
+    }
+
+    private Map<Integer, EquipmentType> equipmentTypes() {
+        Map<Integer, EquipmentType> result = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static String typeCodeOf(EquipmentBase device, Map<Integer, EquipmentType> types) {
+        EquipmentType type = device == null || device.getEquipmentTypeId() == null
+                ? null : types.get(device.getEquipmentTypeId());
+        return type == null || type.getId() == null ? null : String.valueOf(type.getId());
+    }
+
+    private static String typeNameOf(EquipmentBase device, Map<Integer, EquipmentType> types) {
+        EquipmentType type = device == null || device.getEquipmentTypeId() == null
+                ? null : types.get(device.getEquipmentTypeId());
+        return type == null ? null : type.getTypeName();
+    }
+
+    private static String industryOf(EquipmentBase device, Map<Integer, EquipmentType> types) {
+        EquipmentType type = device == null || device.getEquipmentTypeId() == null
+                ? null : types.get(device.getEquipmentTypeId());
+        return CockpitTypeIndustry.industryOf(type, types);
+    }
+
+    private static LocalDateTime sampledAt(LocalDateTime primary, LocalDateTime fallback) {
+        return primary != null ? primary : fallback;
+    }
+
+    private static String offsetTime(LocalDateTime value) {
+        return value == null ? null : TIME_FORMATTER.format(value.atZone(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private static void addMetric(
+            List<CockpitMetricSnapshot> metrics, String code, String name,
+            BigDecimal value, String unit, String status, String sampledAt) {
+        metrics.add(new CockpitMetricSnapshot(code, name, value, unit, status, sampledAt, null));
+    }
+
+    private static BigDecimal decimal(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(value.trim());
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}