Sfoglia il codice sorgente

feat(cockpit): 接入基础能力自检与设备边界、新鲜度真实数据

Kazerin 3 settimane fa
parent
commit
d4915d7589

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

@@ -0,0 +1,158 @@
+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.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.device.BusinessCockpitDeviceBoundaryAdapter;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot;
+import com.zksy.cockpit.foundation.BusinessCockpitFoundationAdapter;
+import com.zksy.cockpit.foundation.BusinessCockpitFreshnessAdapter;
+import com.zksy.cockpit.foundation.CockpitFoundationSnapshot;
+import com.zksy.cockpit.foundation.CockpitFreshnessSource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 BusinessCockpitFoundationAdaptersTest {
+
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
+    private MonitorPointService monitorPointService;
+    private PipeNetworkBaseService pipeNetworkBaseService;
+    private EquipmentStatusService equipmentStatusService;
+
+    @BeforeEach
+    void setUp() {
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        monitorPointService = mock(MonitorPointService.class);
+        pipeNetworkBaseService = mock(PipeNetworkBaseService.class);
+        equipmentStatusService = mock(EquipmentStatusService.class);
+    }
+
+    @Test
+    void foundationIsReadyWhenAnyBaseTableHasData() {
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device("E-1", "GAS-001")));
+        when(monitorPointService.list()).thenReturn(Collections.emptyList());
+        when(pipeNetworkBaseService.list()).thenReturn(Collections.emptyList());
+
+        CockpitFoundationSnapshot snapshot =
+                new BusinessCockpitFoundationAdapter(
+                        equipmentBaseService, monitorPointService, pipeNetworkBaseService).load();
+
+        assertEquals("READY", snapshot.getMapFrame().getState());
+        assertTrue(snapshot.getMapFrame().isRenderable());
+        assertNull(snapshot.getMapFrame().getReason());
+    }
+
+    @Test
+    void foundationIsEmptyWhenAllBaseTablesAreEmpty() {
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(monitorPointService.list()).thenReturn(Collections.emptyList());
+        when(pipeNetworkBaseService.list()).thenReturn(Collections.emptyList());
+
+        CockpitFoundationSnapshot snapshot =
+                new BusinessCockpitFoundationAdapter(
+                        equipmentBaseService, monitorPointService, pipeNetworkBaseService).load();
+
+        assertEquals("EMPTY", snapshot.getMapFrame().getState());
+        assertFalse(snapshot.getMapFrame().isRenderable());
+    }
+
+    @Test
+    void deviceBoundaryListsTypesDevicesAndFacilitiesWithHonestScope() {
+        EquipmentType gasRoot = type(3, "燃气", 0);
+        EquipmentType gasSensor = type(11, "燃气浓度计", 3);
+        when(equipmentTypeService.list()).thenReturn(Arrays.asList(gasRoot, gasSensor));
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device("E-1", "GAS-001")));
+        PipeNetworkBase facility = new PipeNetworkBase();
+        facility.setNetworkId("N-1");
+        facility.setNetworkCode("PIPE-001");
+        facility.setNetworkName("城北主管网");
+        facility.setNetworkType("主管");
+        when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(facility));
+
+        CockpitDeviceBoundarySnapshot snapshot =
+                new BusinessCockpitDeviceBoundaryAdapter(
+                        equipmentTypeService, equipmentBaseService, pipeNetworkBaseService).load();
+
+        assertEquals(2, snapshot.getTypes().size());
+        assertEquals("11", snapshot.getTypes().get(1).getCode());
+        assertEquals("GAS", snapshot.getTypes().get(1).getIndustry());
+        assertEquals("SMART_DEVICE", snapshot.getTypes().get(1).getObjectKind());
+        assertNull(snapshot.getTypes().get(1).getDrainageScope());
+
+        assertEquals(1, snapshot.getDevices().size());
+        assertEquals("GAS-001", snapshot.getDevices().get(0).getEquipmentCode());
+        assertEquals("11", snapshot.getDevices().get(0).getTypeCode());
+
+        assertEquals(1, snapshot.getFacilities().size());
+        assertEquals("PIPE-001", snapshot.getFacilities().get(0).getFacilityCode());
+        assertEquals("主管", snapshot.getFacilities().get(0).getTypeCode());
+    }
+
+    @Test
+    void freshnessUsesLedgerUpdateAndStatusUpdateTimes() {
+        EquipmentBase device = device("E-1", "GAS-001");
+        device.setUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(device));
+        EquipmentStatus status = new EquipmentStatus();
+        status.setStatusId(1);
+        status.setEquipmentId("E-1");
+        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:00:00"));
+        when(equipmentStatusService.getByDeviceCode("GAS-001")).thenReturn(status);
+
+        CockpitFreshnessSource source =
+                new BusinessCockpitFreshnessAdapter(equipmentBaseService, equipmentStatusService)
+                        .load("GAS-001");
+
+        assertEquals("GAS-001", source.getDeviceCode());
+        assertEquals("2026-09-02T09:59:50+08:00", source.getUpdatedAt());
+        assertEquals("2026-09-02T09:00:00+08:00", source.getStatusTime());
+    }
+
+    @Test
+    void freshnessOfUnknownDeviceReturnsNullForEmptySection() {
+        when(equipmentBaseService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+
+        assertNull(new BusinessCockpitFreshnessAdapter(equipmentBaseService, equipmentStatusService)
+                .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(String id, String code) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(code);
+        device.setEquipmentTypeId(11);
+        return device;
+    }
+}

+ 115 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/BusinessCockpitDeviceBoundaryAdapter.java

@@ -0,0 +1,115 @@
+package com.zksy.cockpit.device;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+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.stream.Collectors;
+
+/**
+ * 设备边界真实数据适配器(BT-5)。
+ *
+ * <p>类型字典来自 {@code equipment_type}:对外 {@code code = String(id)},行业沿类型树上溯推导,
+ * 类型对象种类统一为 {@code SMART_DEVICE}(设备类型);{@code equipment_type} 无排水口径列,
+ * 因此 {@code drainageScope} 如实为 {@code null}(服务层按非雨水/非污水统计)。
+ * 设备来自 {@code equipment_base},设施来自 {@code pipe_network_base}。
+ *
+ * <p><b>不臆造</b>:管网设施 {@code network_type} 是自由文本、与 {@code equipment_type.id} 无对应关系,
+ * 设施按自身类型编码上报,行业解析失败即为非正式范围(保留在清单,不计入正式行业统计)。
+ */
+@Component
+@Primary
+public class BusinessCockpitDeviceBoundaryAdapter implements CockpitDeviceBoundaryAdapter {
+
+    private final EquipmentTypeService equipmentTypeService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final PipeNetworkBaseService pipeNetworkBaseService;
+
+    public BusinessCockpitDeviceBoundaryAdapter(
+            EquipmentTypeService equipmentTypeService,
+            EquipmentBaseService equipmentBaseService,
+            PipeNetworkBaseService pipeNetworkBaseService) {
+        this.equipmentTypeService = equipmentTypeService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.pipeNetworkBaseService = pipeNetworkBaseService;
+    }
+
+    @Override
+    public CockpitDeviceBoundarySnapshot load() {
+        Map<Integer, EquipmentType> types = equipmentTypes();
+
+        List<CockpitDeviceBoundarySnapshot.SourceType> sourceTypes = new ArrayList<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type == null || type.getId() == null) {
+                continue;
+            }
+            sourceTypes.add(new CockpitDeviceBoundarySnapshot.SourceType(
+                    String.valueOf(type.getId()),
+                    type.getTypeName(),
+                    CockpitTypeIndustry.industryOf(type, types),
+                    null,
+                    "SMART_DEVICE"));
+        }
+
+        List<CockpitDeviceBoundarySnapshot.SourceDevice> sourceDevices = new ArrayList<>();
+        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
+            if (device == null || isBlank(device.getEquipmentCode())) {
+                continue;
+            }
+            sourceDevices.add(new CockpitDeviceBoundarySnapshot.SourceDevice(
+                    device.getEquipmentCode().trim(),
+                    device.getEquipmentName(),
+                    device.getEquipmentTypeId() == null ? null : String.valueOf(device.getEquipmentTypeId())));
+        }
+
+        List<CockpitDeviceBoundarySnapshot.SourceFacility> sourceFacilities = new ArrayList<>();
+        for (PipeNetworkBase facility : safeList(pipeNetworkBaseService.list())) {
+            if (facility == null || isBlank(facility.getNetworkCode())) {
+                continue;
+            }
+            sourceFacilities.add(new CockpitDeviceBoundarySnapshot.SourceFacility(
+                    facility.getNetworkCode().trim(),
+                    facility.getNetworkName(),
+                    trimToNull(facility.getNetworkType())));
+        }
+
+        return new CockpitDeviceBoundarySnapshot(sourceTypes, sourceFacilities, sourceDevices);
+    }
+
+    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 trimToNull(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    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();
+    }
+}

+ 62 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/BusinessCockpitFoundationAdapter.java

@@ -0,0 +1,62 @@
+package com.zksy.cockpit.foundation;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 驾驶舱基础能力自检真实数据适配器(BT-5)。
+ *
+ * <p>{@code mapFrame} 以三张基础底图表(设备台账、监测点位、管网设施)是否有数据判定:
+ * 任一表存在有效行即 {@code READY}/{@code renderable=true},否则 {@code EMPTY}(与空适配器同口径)。
+ * 不校验坐标、不臆造底图质量。
+ */
+@Component
+@Primary
+public class BusinessCockpitFoundationAdapter implements CockpitFoundationAdapter {
+
+    private final EquipmentBaseService equipmentBaseService;
+    private final MonitorPointService monitorPointService;
+    private final PipeNetworkBaseService pipeNetworkBaseService;
+
+    public BusinessCockpitFoundationAdapter(
+            EquipmentBaseService equipmentBaseService,
+            MonitorPointService monitorPointService,
+            PipeNetworkBaseService pipeNetworkBaseService) {
+        this.equipmentBaseService = equipmentBaseService;
+        this.monitorPointService = monitorPointService;
+        this.pipeNetworkBaseService = pipeNetworkBaseService;
+    }
+
+    @Override
+    public CockpitFoundationSnapshot load() {
+        boolean hasDevices = safeList(equipmentBaseService.list())
+                .stream().anyMatch(device -> device != null && !isBlank(device.getEquipmentCode()));
+        boolean hasPoints = safeList(monitorPointService.list())
+                .stream().anyMatch(point -> point != null && !isBlank(point.getPointCode()));
+        boolean hasFacilities = safeList(pipeNetworkBaseService.list())
+                .stream().anyMatch(facility -> facility != null && !isBlank(facility.getNetworkCode()));
+        if (hasDevices || hasPoints || hasFacilities) {
+            return new CockpitFoundationSnapshot(new CockpitMapFrame("READY", true, null));
+        }
+        return new CockpitFoundationSnapshot(CockpitMapFrame.empty());
+    }
+
+    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();
+    }
+}

+ 91 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/BusinessCockpitFreshnessAdapter.java

@@ -0,0 +1,91 @@
+package com.zksy.cockpit.foundation;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 设备新鲜度真实数据适配器(BT-5)。
+ *
+ * <p>{@code updatedAt} 取设备台账 {@code equipment_base.update_time}(缺失回退 {@code create_time});
+ * {@code statusTime} 取设备状态 {@code equipment_status.status_update_time}。
+ * 设备不存在返回 {@code null}(服务层按 EMPTY/NO_SOURCE 处理)。
+ * 不臆造采样时间:没有监测帧参与计算。
+ */
+@Component
+@Primary
+public class BusinessCockpitFreshnessAdapter implements CockpitFreshnessAdapter {
+
+    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 EquipmentStatusService equipmentStatusService;
+
+    public BusinessCockpitFreshnessAdapter(
+            EquipmentBaseService equipmentBaseService,
+            EquipmentStatusService equipmentStatusService) {
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentStatusService = equipmentStatusService;
+    }
+
+    @Override
+    public CockpitFreshnessSource load(String deviceCode) {
+        if (isBlank(deviceCode)) {
+            return null;
+        }
+        String code = deviceCode.trim();
+        EquipmentBase device = findDevice(code);
+        if (device == null) {
+            return null;
+        }
+        String updatedAt = offsetTime(firstNonNull(device.getUpdateTime(), device.getCreateTime()));
+        String statusTime = null;
+        EquipmentStatus status = null;
+        try {
+            status = equipmentStatusService.getByDeviceCode(code);
+        } catch (Exception ignored) {
+            // 状态不可得按缺失处理
+        }
+        if (status != null) {
+            statusTime = offsetTime(firstNonNull(status.getStatusUpdateTime(), status.getCreateTime()));
+        }
+        return new CockpitFreshnessSource(code, updatedAt, statusTime);
+    }
+
+    private EquipmentBase findDevice(String code) {
+        List<EquipmentBase> devices = safeList(equipmentBaseService.list(
+                new LambdaQueryWrapper<EquipmentBase>()
+                        .eq(EquipmentBase::getEquipmentCode, code)
+                        .last("limit 1")));
+        return devices.isEmpty() ? null : devices.get(0);
+    }
+
+    private static LocalDateTime firstNonNull(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 <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();
+    }
+}