Эх сурвалжийг харах

fix(cockpit): retain empty monitoring points in business statistics

Kazerin 1 долоо хоног өмнө
parent
commit
ba0ce076f3

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

@@ -140,6 +140,26 @@ class BusinessCockpitMonitoringOverviewAdapterTest {
                 .findFirst().orElseThrow().getIndustry());
     }
 
+    @Test
+    void realEmptyPointsAndMissingDeviceTypesPublishDistinctAssociationFacts() {
+        stubPoints(Arrays.asList(point("P0", "EMPTY"), point("P1", "UNKNOWN")));
+        stubTypes(Collections.emptyList());
+        EquipmentBase bound = device("D", "BOUND", "99");
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(bound));
+        when(equipmentBaseService.getEquipmentByPoint("P0")).thenReturn(Collections.emptyList());
+        when(equipmentBaseService.getEquipmentByPoint("P1")).thenReturn(Arrays.asList(bound, bound));
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+        assertNull(snapshot.getMonitoringPoints().get(0).getTypeCode());
+        assertEquals(0, snapshot.getMonitoringPoints().get(0).getAssociatedDeviceCount());
+        assertNull(snapshot.getMonitoringPoints().get(1).getTypeCode());
+        assertEquals(1, snapshot.getMonitoringPoints().get(1).getAssociatedDeviceCount());
+        CockpitMonitoringOverviewService service = new CockpitMonitoringOverviewService(
+                () -> snapshot, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        var response = service.load("empty-real", null, null);
+        assertEquals(1, response.getCounts().getMonitoringPointCount());
+        assertEquals(Collections.singletonList("EMPTY"), response.getMapFilter().getPointCodes());
+    }
+
     // ---- helpers ----
 
     private static String stateOf(CockpitMonitoringOverviewSnapshot snapshot, String deviceCode) {

+ 126 - 19
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java

@@ -76,7 +76,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
                 .andExpect(jsonPath("$.data.pagination.page").value(1))
                 .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
-                .andExpect(jsonPath("$.data.pagination.total").value(8))
+                .andExpect(jsonPath("$.data.pagination.total").value(9))
                 .andExpect(jsonPath("$.data.pagination.totalPages").value(1))
                 .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
@@ -91,7 +91,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.monitoringPoints[3].pointStatus").value("WORK_ORDER"))
                 .andExpect(jsonPath("$.data.monitoringPoints[4].pointCode").value("P-WS-2"))
                 .andExpect(jsonPath("$.data.monitoringPoints[4].pointStatus").value("EMPTY"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(3)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].pointStatus").value("NORMAL"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-002"))
@@ -106,7 +106,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(1))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
@@ -115,7 +115,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
                 .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
                 .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(4))
+                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(5))
                 .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
                 .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
                 .andExpect(jsonPath("$.data.districtDistribution[1].pointCount").value(2))
@@ -127,14 +127,14 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(4))
+                .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(5))
                 .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryDistribution[2].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(4))
+                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(5))
                 .andExpect(jsonPath("$.data.deviceTypeDistribution[1].deviceType.code").value("SEWAGE_ENV"))
                 .andExpect(jsonPath("$.data.deviceTypeDistribution[2].deviceType.code").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
@@ -144,11 +144,11 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
                 .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(5)))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(8)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(9)))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
     }
@@ -157,24 +157,24 @@ class CockpitMonitoringDistributionControllerTest {
     void associatedDevicesRenderAsOnePointAndPaginationMatchesStatusTotals() throws Exception {
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(8))
+                .andExpect(jsonPath("$.data.pagination.total").value(9))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(3)));
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)));
     }
 
     @Test
     void industryFilterNarrowsDistributionAndMapFilter() throws Exception {
         mockMvc.perform(get(ENDPOINT).param("industry", "GAS"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(3))
+                .andExpect(jsonPath("$.data.pagination.total").value(4))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution", hasSize(1)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(1))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
     }
 
@@ -182,7 +182,7 @@ class CockpitMonitoringDistributionControllerTest {
     void deviceTypeFilterNarrowsDistribution() throws Exception {
         mockMvc.perform(get(ENDPOINT).param("deviceType", "GAS_SENSOR"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(3))
+                .andExpect(jsonPath("$.data.pagination.total").value(4))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value("GAS_SENSOR"))
                 .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(1)))
                 .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"));
@@ -200,7 +200,7 @@ class CockpitMonitoringDistributionControllerTest {
 
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(8))
+                .andExpect(jsonPath("$.data.pagination.total").value(9))
                 .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()));
     }
 
@@ -352,7 +352,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse initial = service.load(
                 "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
         assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
                 "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
@@ -361,7 +361,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "2026-09-02T10:05:00+08:00"));
 
         assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(2, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
                 "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
@@ -370,7 +370,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "2026-09-02T10:04:00+08:00"));
 
         assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(2, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
                 "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
@@ -379,7 +379,114 @@ class CockpitMonitoringDistributionControllerTest {
                         "2026-09-02T10:06:00+08:00"));
 
         assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(2, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
+    }
+
+    @Test
+    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
+        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
+        for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
+            points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
+                    code, code, null, null, new BigDecimal("110.399876"), new BigDecimal("28.458287"),
+                    false, Collections.emptyList()));
+        }
+        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, SNAPSHOT_AT, Collections.emptyList(), points, Collections.emptyList());
+        when(adapter.load(null)).thenReturn(snapshot);
+        when(adapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
+
+        mockMvc.perform(get(ENDPOINT).param("pointStatus", "EMPTY").param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.total").value(6))
+                .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("GS001"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
+                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))
+                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value(nullValue()))
+                .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(6))
+                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
+                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0))
+                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
+                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
+        mockMvc.perform(get(ENDPOINT).param("page", "3").param("pageSize", "2").param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.total").value(6))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("RQ001"));
+        mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(6));
+        for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"},
+                new String[]{"deviceType", "GAS_SENSOR"}, new String[]{"district", "0"},
+                new String[]{"pointStatus", "NORMAL"})) {
+            mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
+                    .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(0));
+        }
+    }
+
+    @Test
+    void coordinatesDoNotAffectStatisticsAndAssociatedDevicesNeverBecomeStandaloneMarkers() {
+        CockpitMonitoringPointDeviceSnapshot bound = device(
+                "BOUND", "关联设备", true, true, false, false, Collections.emptyList());
+        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(),
+                Collections.singletonList(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
+                        "P", "点位", 0, "GAS_SENSOR", null, null, false, Collections.singletonList(bound))),
+                Arrays.asList(smartDevice("BOUND", "关联设备", 1, "GAS_SENSOR", "110.4", "28.4", false,
+                                true, true, false, false, Collections.emptyList()),
+                        smartDevice("FREE", "独立设备", 1, "GAS_SENSOR", "181", "28.4", true,
+                                true, false, false, false, Collections.emptyList())));
+        when(adapter.load(null)).thenReturn(snapshot);
+        CockpitMonitoringDistributionResponse all = service.load("all", null, null, null, null, 1, 100, null);
+        assertEquals(2, all.getPagination().getTotal());
+        assertEquals(1, all.getUnassociatedEquipment().size());
+        assertEquals("FREE", all.getUnassociatedEquipment().get(0).getDeviceCode());
+        assertTrue(!all.getMonitoringPoints().get(0).getCoordinateValidation().isRenderable());
+        assertTrue(!all.getUnassociatedEquipment().get(0).getCoordinateValidation().isRenderable());
+        assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
+        assertEquals("READY", all.getFreshness().getReason());
+        assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
+        assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
+
+        CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
+        assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
+        assertTrue(normal.getMapFilter().getPointCodes().isEmpty());
+        CockpitMonitoringDistributionResponse district = service.load("district", null, null, null, "1", 1, 100, null);
+        assertEquals(1, district.getPagination().getTotal(), "被筛掉的点位不能让关联设备变成独立设备");
+        assertEquals(Collections.singletonList("FREE"), district.getMapFilter().getDeviceCodes());
+    }
+
+    @Test
+    void exclusionsCountEachRejectedObjectOnceRegardlessOfAssociatedDeviceCount() {
+        CockpitMonitoringPointDeviceSnapshot d = device("D", "设备", true, false, false, false, Collections.emptyList());
+        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(), Arrays.asList(
+                        point("EMPTY", "合法空点", null, null, "110.4", "28.4"),
+                        point("UNKNOWN", "未知类型", 0, "LEGACY", "110.4", "28.4", d, d, d),
+                        point("MISMATCH", "错种类", 0, "WATER_PLANT", "110.4", "28.4", d, d),
+                        point("NONFORMAL", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", d)), Arrays.asList(
+                        smartDevice("U", "未知", 0, "LEGACY", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
+                        smartDevice("M", "错种类", 0, "WATER_PLANT", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
+                        smartDevice("N", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", true, true, false, false, false, Collections.emptyList())));
+        when(adapter.load(null)).thenReturn(snapshot);
+        CockpitMonitoringDistributionResponse response = service.load("reconcile", null, null, null, null, 1, 100, null);
+        assertEquals(1, response.getPagination().getTotal());
+        assertEquals(1, statusCount(response, CockpitMonitoringPointStatus.EMPTY));
+        assertEquals(2, response.getExcluded().getUnknownTypeCount());
+        assertEquals(2, response.getExcluded().getTypeKindMismatchCount());
+        assertEquals(2, response.getExcluded().getNonFormalIndustryCount());
+        int excluded = response.getExcluded().getUnknownTypeCount() + response.getExcluded().getTypeKindMismatchCount()
+                + response.getExcluded().getNonFormalIndustryCount() + response.getExcluded().getInvalidCoordinateCount();
+        assertEquals(7, response.getPagination().getTotal() + excluded);
     }
 
     private int statusCount(CockpitMonitoringDistributionResponse response,

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

@@ -239,6 +239,45 @@ class CockpitMonitoringOverviewControllerTest {
         }
     }
 
+    @Test
+    void sixEmptyUntypedPointsAreCountedAndEchoedWithoutFabricatingDevicesOrIndustry() throws Exception {
+        List<CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint> points = new ArrayList<>();
+        for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
+            points.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(code, null, 0));
+        }
+        when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00", Collections.emptyList(), Collections.emptyList(), points));
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(6))
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(0))
+                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
+                .andExpect(jsonPath("$.data.status.denominator").value(0))
+                .andExpect(jsonPath("$.data.onlineRate.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
+        for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"}, new String[]{"deviceType", "GAS_SENSOR"})) {
+            mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(0))
+                    .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(0)));
+        }
+    }
+
+    @Test
+    void unknownOrMixedTypeWithDevicesIsNotReinterpretedAsAnEmptyPoint() {
+        when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00", fixture().getTypes(), Collections.emptyList(), Arrays.asList(
+                        new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("EMPTY", null, 0),
+                        new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("MIXED", null, 2),
+                        new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("LEGACY", null),
+                        new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("FACILITY", "WATER_PLANT", 0))));
+        CockpitMonitoringOverviewResponse response = service.load("points", null, null);
+        assertEquals(1, response.getCounts().getMonitoringPointCount());
+        assertEquals(Collections.singletonList("EMPTY"), response.getMapFilter().getPointCodes());
+    }
+
     private static CockpitMonitoringOverviewSnapshot fixture() {
         return new CockpitMonitoringOverviewSnapshot(
                 "2026-09-02T09:59:50+08:00",

+ 4 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java

@@ -82,10 +82,12 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
             }
             String pointCode = point.getPointCode().trim();
             Set<String> deviceTypes = new LinkedHashSet<>();
+            Set<String> boundDeviceCodes = new LinkedHashSet<>();
             for (EquipmentBase bound : safeList(equipmentBaseService.getEquipmentByPoint(point.getPointId()))) {
                 if (bound == null || isBlank(bound.getEquipmentCode())) {
                     continue;
                 }
+                boundDeviceCodes.add(bound.getEquipmentCode().trim());
                 pointCodeByDevice.putIfAbsent(bound.getEquipmentCode().trim(), pointCode);
                 if (bound.getEquipmentTypeId() == null) {
                     continue;
@@ -97,7 +99,8 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
             }
             sourcePoints.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
                     pointCode,
-                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null));
+                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
+                    boundDeviceCodes.size()));
         }
 
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();

+ 30 - 27
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java

@@ -32,10 +32,9 @@ import java.util.Set;
 /**
  * Read-only aggregation for 运行监测 / 监测分布.
  *
- * <p>All distribution statistics use the map rendering scope: monitoring points with
- * valid verified coordinates render once regardless of how many devices they carry;
- * unassociated smart devices render as their own marker only when coordinates are
- * valid and verified. Non-renderable objects are counted in excluded.invalidCoordinateCount.
+ * <p>Statistics and pagination use selected business objects, including empty points
+ * and objects without renderable coordinates. coordinateValidation controls map
+ * presentation only; excluded counts rejected objects, not their associated devices.
  */
 @Service
 public class CockpitMonitoringDistributionService {
@@ -242,12 +241,22 @@ public class CockpitMonitoringDistributionService {
         int unknownTypeCount = 0;
         int typeKindMismatchCount = 0;
         int nonFormalIndustryCount = 0;
+        // Compatibility field: coordinates no longer exclude business objects.
         int invalidCoordinateCount = 0;
 
         List<Marker> pointMarkers = new ArrayList<>();
         List<Marker> deviceMarkers = new ArrayList<>();
         Set<String> pointCodes = new LinkedHashSet<>();
         Set<String> deviceCodes = new LinkedHashSet<>();
+        // Association is a source fact, independent of all display filters.
+        Set<String> associatedDeviceCodes = new LinkedHashSet<>();
+        if (snapshot.getMonitoringPoints() != null) {
+            for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
+                if (point != null && !isBlank(point.getPointCode())) {
+                    associatedDeviceCodes.addAll(codes(safeDevices(point.getDevices())));
+                }
+            }
+        }
 
         if (snapshot.getMonitoringPoints() != null) {
             for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
@@ -257,29 +266,27 @@ public class CockpitMonitoringDistributionService {
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode());
                 String mismatch = classify(type);
                 List<CockpitMonitoringPointDeviceSnapshot> devices = safeDevices(point.getDevices());
-                if (mismatch != null) {
-                    int amount = devices.size();
+                boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && devices.isEmpty();
+                if (mismatch != null && !emptyPointWithUnknownType) {
                     if (UNKNOWN_TYPE.equals(mismatch)) {
-                        unknownTypeCount += amount;
+                        unknownTypeCount++;
                     } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
-                        typeKindMismatchCount += amount;
+                        typeKindMismatchCount++;
                     } else {
-                        nonFormalIndustryCount += amount;
+                        nonFormalIndustryCount++;
                     }
                     continue;
                 }
-                if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
+                String markerIndustry = type == null ? null : type.getIndustry();
+                String markerTypeCode = type == null ? null : type.getCode();
+                if (selectedIndustry != null && !selectedIndustry.equals(markerIndustry)) {
                     continue;
                 }
-                if (!isBlank(deviceType) && !deviceType.trim().equals(type.getCode())) {
+                if (!isBlank(deviceType) && !deviceType.trim().equals(markerTypeCode)) {
                     continue;
                 }
                 CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
                         point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
-                if (!validation.isRenderable()) {
-                    invalidCoordinateCount++;
-                    continue;
-                }
                 // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
                 Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
                 if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
@@ -290,22 +297,21 @@ public class CockpitMonitoringDistributionService {
                     if (device == null || isBlank(device.getDeviceCode())) {
                         continue;
                     }
-                    CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(device);
-                    effective.add(applied);
-                    deviceCodes.add(applied.getDeviceCode().trim());
+                    effective.add(effectiveDevice(device));
                 }
                 CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
                 if (selectedStatus != null && selectedStatus != status) {
                     continue;
                 }
                 pointCodes.add(point.getPointCode().trim());
+                deviceCodes.addAll(codes(effective));
                 pointMarkers.add(new Marker(
                         point.getPointCode().trim(),
                         point.getPointName(),
                         markerDistrict,
-                        type.getIndustry(),
-                        type.getCode(),
-                        type.getName(),
+                        markerIndustry,
+                        markerTypeCode,
+                        type == null ? "未知" : type.getName(),
                         status,
                         CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
                         point.getLongitude(),
@@ -318,7 +324,8 @@ public class CockpitMonitoringDistributionService {
 
         if (snapshot.getSmartDevices() != null) {
             for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
-                if (device == null || isBlank(device.getDeviceCode())) {
+                if (device == null || isBlank(device.getDeviceCode())
+                        || associatedDeviceCodes.contains(device.getDeviceCode().trim())) {
                     continue;
                 }
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
@@ -341,10 +348,6 @@ public class CockpitMonitoringDistributionService {
                 }
                 CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
                         device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
-                if (!validation.isRenderable()) {
-                    invalidCoordinateCount++;
-                    continue;
-                }
                 // 设备自身片区(equipment_base.district)即权威来源
                 Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
                 if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
@@ -441,7 +444,7 @@ public class CockpitMonitoringDistributionService {
             pageDevices.add(marker.toUnassociatedEquipmentMarker());
         }
 
-        boolean hasResolved = !deviceCodes.isEmpty();
+        boolean hasResolved = total > 0;
         String updatedAt = isBlank(snapshot.getUpdatedAt()) ? businessTime() : snapshot.getUpdatedAt();
 
         return new CockpitMonitoringDistributionResponse(

+ 15 - 15
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java

@@ -135,7 +135,7 @@ public class CockpitMonitoringOverviewService {
         int typeKindMismatchCount = 0;
         int nonFormalIndustryCount = 0;
         Map<String, TypeAccumulator> typeAccumulators = new LinkedHashMap<>();
-        Set<String> selectedPointCodes = new LinkedHashSet<>();
+        Set<String> selectedPointCodes = selectMonitoringPointCodes(snapshot, selectedIndustry, deviceType, types);
         Set<String> selectedDeviceCodes = new LinkedHashSet<>();
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> selectedDevices = new ArrayList<>();
 
@@ -178,12 +178,9 @@ public class CockpitMonitoringOverviewService {
 
             selectedDevices.add(snapshotDevice(snapshot, runtime.deviceCode));
             selectedDeviceCodes.add(runtime.deviceCode);
-            if (!isBlank(runtime.pointCode)) {
-                selectedPointCodes.add(runtime.pointCode.trim());
-            }
         }
 
-        int monitoringPointCount = countMonitoringPoints(snapshot, selectedIndustry, deviceType, types);
+        int monitoringPointCount = selectedPointCodes.size();
         int denominator = online + offline + stale;
         BigDecimal rate = denominator == 0
                 ? BigDecimal.ZERO.setScale(4)
@@ -191,10 +188,11 @@ public class CockpitMonitoringOverviewService {
                         .divide(BigDecimal.valueOf(denominator), 4, RoundingMode.HALF_UP);
         String rateState = denominator == 0 ? "EMPTY" : "AVAILABLE";
         String rateReason = denominator == 0 ? "NO_RESOLVED_DEVICES" : null;
-        String freshnessStatus = denominator == 0
+        boolean hasObjects = monitoringPointCount > 0 || !selectedDeviceCodes.isEmpty();
+        String freshnessStatus = !hasObjects
                 ? CockpitFreshnessStatus.EMPTY.name()
                 : stale == 0 ? CockpitFreshnessStatus.CURRENT.name() : CockpitFreshnessStatus.STALE.name();
-        String freshnessReason = denominator == 0
+        String freshnessReason = !hasObjects
                 ? "NO_RESOLVED_DEVICES"
                 : stale == 0 ? "READY" : "STALE_SAMPLES";
 
@@ -263,33 +261,35 @@ public class CockpitMonitoringOverviewService {
         return null;
     }
 
-    private static int countMonitoringPoints(
+    private static Set<String> selectMonitoringPointCodes(
             CockpitMonitoringOverviewSnapshot snapshot,
             String selectedIndustry,
             String selectedDeviceType,
             Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types) {
+        Set<String> unique = new LinkedHashSet<>();
         if (snapshot == null || snapshot.getMonitoringPoints() == null) {
-            return 0;
+            return unique;
         }
-        Set<String> unique = new LinkedHashSet<>();
         for (CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
             if (point == null || isBlank(point.getPointCode())) {
                 continue;
             }
             CockpitMonitoringOverviewSnapshot.SourceType type = isBlank(point.getTypeCode())
-                    ? null : types.get(point.getTypeCode());
-            if (type == null || !SMART_DEVICE.equals(type.getObjectKind())) {
+                    ? null : types.get(point.getTypeCode().trim());
+            boolean emptyPointWithUnknownType = type == null && point.getAssociatedDeviceCount() == 0;
+            if (!emptyPointWithUnknownType && (type == null || !SMART_DEVICE.equals(type.getObjectKind())
+                    || !INDUSTRIES.contains(type.getIndustry()))) {
                 continue;
             }
-            if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
+            if (selectedIndustry != null && (type == null || !selectedIndustry.equals(type.getIndustry()))) {
                 continue;
             }
-            if (!isBlank(selectedDeviceType) && !selectedDeviceType.trim().equals(point.getTypeCode())) {
+            if (!isBlank(selectedDeviceType) && (type == null || !selectedDeviceType.trim().equals(type.getCode()))) {
                 continue;
             }
             unique.add(point.getPointCode().trim());
         }
-        return unique.size();
+        return unique;
     }
 
     private static Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types(

+ 8 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java

@@ -73,13 +73,21 @@ public class CockpitMonitoringOverviewSnapshot {
     public static class SourceMonitoringPoint {
         private final String pointCode;
         private final String typeCode;
+        /** -1 means an older adapter did not publish the association fact. */
+        private final int associatedDeviceCount;
 
         public SourceMonitoringPoint(String pointCode, String typeCode) {
+            this(pointCode, typeCode, -1);
+        }
+
+        public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount) {
             this.pointCode = pointCode;
             this.typeCode = typeCode;
+            this.associatedDeviceCount = associatedDeviceCount;
         }
 
         public String getPointCode() { return pointCode; }
         public String getTypeCode() { return typeCode; }
+        public int getAssociatedDeviceCount() { return associatedDeviceCount; }
     }
 }