瀏覽代碼

merge: integrate pointStatus eight-bucket contract with empty-point statistics

Kazerin 1 周之前
父節點
當前提交
60a0e9a775
共有 18 個文件被更改,包括 541 次插入 和 153 次删除
  1. 3 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  2. 2 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java
  3. 21 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  4. 14 14
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  5. 191 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java
  6. 46 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java
  7. 3 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  8. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  9. 9 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  10. 31 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  11. 5 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  12. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  13. 7 16
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  14. 72 106
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  15. 12 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  16. 4 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java
  17. 15 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java
  18. 96 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitPointStatusQuery.java

+ 3 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java

@@ -37,9 +37,11 @@ public class CockpitMapSnapshotController {
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "deviceType", required = false) String deviceType,
+            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         CockpitMapSnapshotResponse response = mapSnapshotService.load(
-                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType);
+                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType,
+                pointStatus == null ? null : String.join(",", pointStatus));
         return ResponseEntity.ok(AjaxResult.success(response));
     }
 

+ 2 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java

@@ -33,7 +33,7 @@ public class CockpitMonitoringDistributionController {
     public ResponseEntity<AjaxResult> monitoringDistribution(
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "pointStatus", required = false) String pointStatus,
+            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
             // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
             @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
@@ -45,7 +45,7 @@ public class CockpitMonitoringDistributionController {
                         resolveRequestId(requestId),
                         industry,
                         deviceType,
-                        pointStatus,
+                        pointStatus == null ? null : String.join(",", pointStatus),
                         district,
                         page,
                         pageSize,

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

@@ -24,6 +24,7 @@ import java.util.List;
 
 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;
@@ -131,6 +132,26 @@ class BusinessCockpitDeviceFactResolverTest {
         assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中");
     }
 
+    @Test
+    void publishedOfflineAndMissingStatusAreDistinctAndDoNotDependOnTimestamp() {
+        EquipmentStatus offline = status(1, 0, 0);
+        offline.setStatusUpdateTime(null);
+        when(equipmentStatusService.getByDeviceCode("DEV-PUBLISHED")).thenReturn(offline);
+        assertEquals("OFFLINE", resolver.resolve(device("DEV-PUBLISHED"), null).getPublishedDeviceStatus());
+        assertNull(resolver.resolve(device("DEV-MISSING"), null).getPublishedDeviceStatus());
+    }
+
+    @Test
+    void cancelledStatusSevenNeverPublishesWorkOrderState() {
+        when(equipmentStatusService.getByDeviceCode("DEV-CANCELLED")).thenReturn(status(1, 0, 1));
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(7)));
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-CANCELLED"), null);
+        assertFalse(fact.isWorkOrderInProgress());
+        assertEquals("NORMAL", fact.getPublishedDeviceStatus());
+        assertEquals(CockpitMonitoringPointStatus.NORMAL,
+                CockpitMonitoringPointStatusResolver.resolve(Collections.singletonList(fact)));
+    }
+
     // ---- fixtures ----
 
     private static EquipmentBase device(String code) {

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

@@ -63,7 +63,7 @@ class CockpitMonitoringDistributionControllerTest {
     }
 
     @Test
-    void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception {
+    void distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("snapshotAt", SNAPSHOT_AT)
                         .header("X-Request-Id", "monitoring-distribution-001"))
@@ -98,17 +98,17 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.unassociatedEquipment[1].pointStatus").value("WARNING"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(6)))
+                .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(8)))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].status").value("ALARM"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
                 .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(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
                 .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
@@ -194,9 +194,9 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(2))
                 .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(0));
+                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
 
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
@@ -253,7 +253,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
+                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE"))
                 .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
@@ -279,7 +279,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.message").value(
-                        "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY"));
+                        "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED"));
 
         verifyNoInteractions(adapter);
     }
@@ -351,7 +351,7 @@ class CockpitMonitoringDistributionControllerTest {
     void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
         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.OFFLINE));
         assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
@@ -360,7 +360,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:05:00+08:00"));
 
-        assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
+        assertEquals(0, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
         assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
@@ -369,7 +369,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:04:00+08:00"));
 
-        assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
+        assertEquals(0, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
         assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
@@ -378,7 +378,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:06:00+08:00"));
 
-        assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
+        assertEquals(0, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
         assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
     }
 
@@ -455,7 +455,7 @@ class CockpitMonitoringDistributionControllerTest {
         assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
         assertEquals("READY", all.getFreshness().getReason());
         assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
-        assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
+        assertEquals(0, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
 
         CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
         assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());

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

@@ -0,0 +1,191 @@
+package com.zksy.web.controller.cockpit;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zksy.cockpit.map.*;
+import com.zksy.cockpit.monitoring.*;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/** 同一事实集穿过两个真实控制器/服务,锁定 HTTP 绑定、回显、分页前计数和匹配语义。 */
+class CockpitPointStatusContractTest {
+    private static final String TOKEN = "2026-09-02T10:00:00+08:00";
+    private static final String MAP = "/api/cockpit/v1/map-snapshot";
+    private static final String DISTRIBUTION = "/api/cockpit/v1/monitoring/distribution";
+    private final ObjectMapper json = new ObjectMapper();
+    private MockMvc mvc;
+    private CockpitMapSnapshotAdapter mapAdapter;
+    private CockpitMonitoringDistributionAdapter distributionAdapter;
+
+    @BeforeEach
+    void setUp() {
+        Clock clock = Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
+        mapAdapter = mock(CockpitMapSnapshotAdapter.class);
+        distributionAdapter = mock(CockpitMonitoringDistributionAdapter.class);
+        when(mapAdapter.load(any())).thenReturn(mapFixture());
+        when(distributionAdapter.load(any())).thenReturn(distributionFixture());
+        mvc = MockMvcBuilders.standaloneSetup(
+                new CockpitMapSnapshotController(new CockpitMapSnapshotService(mapAdapter, clock)),
+                new CockpitMonitoringDistributionController(
+                        new CockpitMonitoringDistributionService(distributionAdapter, clock))).build();
+    }
+
+    @Test
+    void csvAndRepeatedParametersAreOrDeduplicatedAndEchoedIdentically() throws Exception {
+        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
+            JsonNode result = read(endpoint, "WARNING,ALARM", "ALARM");
+            assertEquals("ALARM,WARNING", result.path("mapFilter").path("pointStatus").asText());
+            assertEquals(3, total(result));
+            assertEquals(2, result.path("monitoringPoints").size());
+            assertEquals(1, result.path("unassociatedEquipment").size());
+            assertEquals("D-A", result.path("unassociatedEquipment").get(0).path("deviceCode").asText());
+            assertEquals(3, result.path("mapFilter").path("deviceCodes").size());
+        }
+        assertEquals(read(MAP, "ALARM,WARNING").path("mapFilter"),
+                read(DISTRIBUTION, "WARNING", "ALARM").path("mapFilter"));
+    }
+
+    @Test
+    void globalEightBucketsDoNotChangeWithAnyFilterOrPageAndNeverDoubleCountDevices() throws Exception {
+        JsonNode baseline = read(MAP);
+        JsonNode counts = baseline.path("pointStatusDistribution");
+        assertEquals(8, counts.size());
+        assertEquals(1, count(counts, "ALARM"));
+        assertEquals(1, count(counts, "WARNING"));
+        assertEquals(1, count(counts, "NORMAL"));
+        assertEquals(1, count(counts, "EMPTY"));
+        assertEquals(0, count(counts, "UNKNOWN"));
+        assertEquals(4, count(counts, "UNASSOCIATED"));
+        int sum = 0;
+        for (JsonNode row : counts) sum += row.path("count").asInt();
+        assertEquals(8, sum);
+        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
+            assertEquals(8, total(read(endpoint)));
+            assertEquals(counts, read(endpoint, "ALARM").path("pointStatusDistribution"));
+            JsonNode filtered = response(get(endpoint).param("pointStatus", "ALARM")
+                    .param("industry", "GAS").param("district", "0").param("deviceType", "GAS_SENSOR")
+                    .param("pageSize", "1").param("page", "2").param("snapshotAt", TOKEN));
+            assertEquals(2, total(filtered));
+            assertEquals(counts, filtered.path("pointStatusDistribution"));
+            JsonNode empty = response(get(endpoint).param("deviceType", "NOT_A_TYPE"));
+            assertEquals(0, total(empty));
+            assertEquals(counts, empty.path("pointStatusDistribution"));
+        }
+    }
+
+    @Test
+    void emptyUnknownAndUnassociatedUseTheirOwnObjectSemantics() throws Exception {
+        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
+            JsonNode empty = read(endpoint, "EMPTY");
+            assertEquals(1, total(empty));
+            assertEquals("P-E", empty.path("monitoringPoints").get(0).path("pointCode").asText());
+            assertEquals(0, empty.path("unassociatedEquipment").size());
+            JsonNode unknown = read(endpoint, "UNKNOWN");
+            assertEquals(1, total(unknown));
+            assertEquals("D-U", unknown.path("unassociatedEquipment").get(0).path("deviceCode").asText());
+            assertEquals("UNKNOWN", unknown.path("unassociatedEquipment").get(0).path("deviceStatus").asText());
+            assertEquals(0, unknown.path("monitoringPoints").size());
+            assertEquals(4, total(read(endpoint, "UNASSOCIATED")));
+            JsonNode onlyAlarmDevices = read(endpoint, "UNASSOCIATED", "ALARM");
+            assertEquals(1, total(onlyAlarmDevices));
+            assertEquals(0, onlyAlarmDevices.path("monitoringPoints").size());
+            assertEquals(1, total(read(endpoint, "UNASSOCIATED,UNKNOWN")));
+            // 明确离线不能冒充 UNKNOWN;EMPTY 不适用于设备自身。
+            assertEquals("D-O", read(endpoint, "UNASSOCIATED,OFFLINE")
+                    .path("unassociatedEquipment").get(0).path("deviceCode").asText());
+            assertEquals(0, total(read(endpoint, "UNASSOCIATED,EMPTY")));
+        }
+    }
+
+    @Test
+    void invalidValuesIncludingOneBadRepeatedParameterAreRejectedBeforeReading() throws Exception {
+        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
+            for (String value : Arrays.asList("BOGUS", "ALARM,BOGUS", "ALARM,", "EMPTY,,WARNING")) {
+                mvc.perform(get(endpoint).param("pointStatus", value))
+                        .andExpect(status().isBadRequest())
+                        .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
+            }
+            mvc.perform(get(endpoint).param("pointStatus", "ALARM", "BOGUS"))
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
+        }
+        verifyNoInteractions(mapAdapter, distributionAdapter);
+    }
+
+    private JsonNode read(String endpoint, String... statuses) throws Exception {
+        MockHttpServletRequestBuilder request = get(endpoint);
+        if (statuses.length > 0) request.param("pointStatus", statuses);
+        return response(request);
+    }
+
+    private JsonNode response(MockHttpServletRequestBuilder request) throws Exception {
+        String content = mvc.perform(request).andExpect(status().isOk())
+                .andReturn().getResponse().getContentAsString();
+        return json.readTree(content).path("data");
+    }
+
+    private static int total(JsonNode data) {
+        return data.has("pagination") ? data.path("pagination").path("total").asInt() : data.path("total").asInt();
+    }
+
+    private static int count(JsonNode rows, String status) {
+        for (JsonNode row : rows) if (status.equals(row.path("status").asText())) return row.path("count").asInt();
+        throw new AssertionError("Missing bucket: " + status);
+    }
+
+    private static List<CockpitMonitoringPointDeviceSnapshot> devices(String code) {
+        if ("P-E".equals(code)) return Collections.emptyList();
+        return Collections.singletonList(new CockpitMonitoringPointDeviceSnapshot(
+                "D-" + code, code, "传感器", true, "P-A".equals(code), "P-W".equals(code), false,
+                Collections.emptyList(), null)); // 缺状态时间不代表未发布。
+    }
+
+    private static CockpitMapSnapshot mapFixture() {
+        List<CockpitMapSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
+        for (String code : Arrays.asList("P-A", "P-W", "P-E", "P-N")) {
+            points.add(new CockpitMapSnapshot.SourceMonitoringPoint(code, code, 0,
+                    "P-E".equals(code) ? null : "GAS_SENSOR", null, null, false, devices(code)));
+        }
+        List<CockpitMapSnapshot.SourceSmartDevice> devices = new java.util.ArrayList<>();
+        for (String code : Arrays.asList("D-A", "D-N", "D-O", "D-U", "D-P-A")) {
+            devices.add(new CockpitMapSnapshot.SourceSmartDevice(code, code, 0, "GAS_SENSOR", null, null, false,
+                    "D-A".equals(code) ? "ALARM" : "D-O".equals(code) ? "OFFLINE" : "D-U".equals(code) ? null : "NORMAL"));
+        }
+        return new CockpitMapSnapshot(TOKEN, Collections.singletonList(
+                new CockpitMapSnapshot.SourceType("GAS_SENSOR", "传感器", "GAS", "SMART_DEVICE")), points, devices);
+    }
+
+    private static CockpitMonitoringDistributionSnapshot distributionFixture() {
+        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
+        for (String code : Arrays.asList("P-A", "P-W", "P-E", "P-N")) {
+            points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(code, code, 0,
+                    "P-E".equals(code) ? null : "GAS_SENSOR", null, null, false, devices(code)));
+        }
+        List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> devices = new java.util.ArrayList<>();
+        for (String code : Arrays.asList("D-A", "D-N", "D-O", "D-U", "D-P-A")) {
+            devices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(code, code, 0, "GAS_SENSOR",
+                    null, null, false, !"D-O".equals(code), "D-A".equals(code), false, false,
+                    Collections.emptyList(), null, !"D-U".equals(code)));
+        }
+        return new CockpitMonitoringDistributionSnapshot(TOKEN, TOKEN, Collections.singletonList(
+                new CockpitMonitoringDistributionSnapshot.SourceType("GAS_SENSOR", "传感器", "GAS", "SMART_DEVICE")), points, devices);
+    }
+}

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

@@ -0,0 +1,46 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.map.CockpitMapSnapshot;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
+import com.zksy.framework.config.FastJson2JsonRedisSerializer;
+import org.junit.jupiter.api.Test;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import static org.junit.jupiter.api.Assertions.*;
+
+class CockpitPointStatusSnapshotSerializationTest {
+    private final FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
+
+    @Test
+    void publishedAndMissingDeviceStatesSurviveTheRealRedisSerializer() {
+        CockpitMonitoringPointDeviceSnapshot unpublished = new CockpitMonitoringPointDeviceSnapshot(
+                "BOUND", "设备", null, null, null, CockpitCoordinateValidator.validate(null, null, false),
+                false, false, false, false, Collections.emptyList(), null, false);
+        CockpitMapSnapshot snapshot = new CockpitMapSnapshot("2026-09-02T10:00:00+08:00", Collections.emptyList(),
+                Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
+                        "P", "点位", null, null, null, null, false, Collections.singletonList(unpublished))),
+                Arrays.asList(new CockpitMapSnapshot.SourceSmartDevice("ALARM", "报警", null, null, null, null, false, "ALARM"),
+                        new CockpitMapSnapshot.SourceSmartDevice("UNKNOWN", "未发布", null, null, null, null, false, null)));
+        CockpitMapSnapshot restored = (CockpitMapSnapshot) serializer.deserialize(serializer.serialize(snapshot));
+        assertEquals("ALARM", restored.getSmartDevices().get(0).getDeviceStatus());
+        assertNull(restored.getSmartDevices().get(1).getDeviceStatus());
+        assertFalse(restored.getMonitoringPoints().get(0).getDevices().get(0).isStatusPublished());
+        // 新设备发布标记不改变既有点位六态聚合。
+        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
+                CockpitMonitoringPointStatusResolver.resolve(restored.getMonitoringPoints().get(0).getDevices()));
+    }
+
+    @Test
+    void legacySnapshotWithoutNewFieldsDoesNotInventDeviceStatus() {
+        String payload = "{\"@type\":\"com.zksy.cockpit.map.CockpitMapSnapshot\","
+                + "\"snapshotAt\":\"2026-09-02T10:00:00+08:00\",\"types\":[],\"monitoringPoints\":[],"
+                + "\"smartDevices\":[{\"deviceCode\":\"LEGACY\",\"deviceName\":\"旧设备\",\"coordinateVerified\":false}]}";
+        CockpitMapSnapshot restored = (CockpitMapSnapshot) serializer.deserialize(payload.getBytes(StandardCharsets.UTF_8));
+        assertEquals("LEGACY", restored.getSmartDevices().get(0).getDeviceCode());
+        assertNull(restored.getSmartDevices().get(0).getDeviceStatus());
+    }
+}

+ 3 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java

@@ -257,7 +257,9 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
                     type == null || type.getId() == null ? null : String.valueOf(type.getId()),
                     device.getLongitude(),
                     device.getLatitude(),
-                    false));
+                    false,
+                    deviceFactResolver.resolve(device, type == null ? null : type.getTypeName())
+                            .getPublishedDeviceStatus()));
         }
 
         List<CockpitMapSnapshot.SourceType> sourceTypes = types.values().stream()

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

@@ -105,9 +105,15 @@ public class CockpitMapSnapshot {
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
+        private final String deviceStatus;
 
         public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
+            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified, null);
+        }
+
+        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
+                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified, String deviceStatus) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.district = district;
@@ -115,8 +121,10 @@ public class CockpitMapSnapshot {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
+            this.deviceStatus = deviceStatus;
         }
 
+        public String getDeviceStatus() { return deviceStatus; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
         public Integer getDistrict() { return district; }

+ 9 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.map;
 
 import java.util.List;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 
 public class CockpitMapSnapshotResponse {
     private final String requestId;
@@ -16,13 +17,14 @@ public class CockpitMapSnapshotResponse {
     private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
     private final CockpitCoordinateValidationSummary coordinateValidation;
     private final MapFilter mapFilter;
+    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
 
     public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
             String snapshotAt, int total, String batchUpdatedAt, boolean complete,
             List<CockpitMonitoringPointSummary> monitoringPoints,
             List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
             CockpitCoordinateValidationSummary coordinateValidation,
-            MapFilter mapFilter) {
+            MapFilter mapFilter, List<CockpitPointStatusQuery.Count> pointStatusDistribution) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -36,6 +38,7 @@ public class CockpitMapSnapshotResponse {
         this.unassociatedEquipment = unassociatedEquipment;
         this.coordinateValidation = coordinateValidation;
         this.mapFilter = mapFilter;
+        this.pointStatusDistribution = pointStatusDistribution;
     }
 
     public String getRequestId() { return requestId; }
@@ -50,6 +53,7 @@ public class CockpitMapSnapshotResponse {
     public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
     public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
     public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
+    public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
     public MapFilter getMapFilter() { return mapFilter; }
 
     /**
@@ -66,10 +70,11 @@ public class CockpitMapSnapshotResponse {
         private final String districtName;
         private final String districtFilter;
         private final String deviceType;
+        private final String pointStatus;
         private final List<String> pointCodes;
         private final List<String> deviceCodes;
 
-        public MapFilter(String industry, Integer district, String districtFilter, String deviceType,
+        public MapFilter(String industry, Integer district, String districtFilter, String deviceType, String pointStatus,
                 List<String> pointCodes, List<String> deviceCodes) {
             this.industry = industry;
             this.district = district;
@@ -77,6 +82,7 @@ public class CockpitMapSnapshotResponse {
             this.districtName = district == null ? null
                     : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
             this.deviceType = deviceType;
+            this.pointStatus = pointStatus;
             this.pointCodes = pointCodes;
             this.deviceCodes = deviceCodes;
         }
@@ -88,6 +94,7 @@ public class CockpitMapSnapshotResponse {
         public String getDistrictName() { return districtName; }
         /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
         public String getDistrictFilter() { return districtFilter; }
+        public String getPointStatus() { return pointStatus; }
         public String getDeviceType() { return deviceType; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }

+ 31 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java

@@ -11,6 +11,7 @@ import com.zksy.cockpit.foundation.CockpitSnapshotTime;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -55,6 +56,12 @@ public class CockpitMapSnapshotService {
      */
     public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
             String industry, String district, String deviceType) {
+        return load(requestId, page, pageSize, snapshotAt, industry, district, deviceType, null);
+    }
+
+    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
+            String industry, String district, String deviceType, String pointStatus) {
+        CockpitPointStatusQuery statusQuery = CockpitPointStatusQuery.parse(pointStatus);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
@@ -86,6 +93,15 @@ public class CockpitMapSnapshotService {
                 snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
         List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
                 snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
+        CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();
+        monitoringPoints(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
+                .forEach(point -> global.addPoint(point.getPointStatus()));
+        unassociatedEquipment(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
+                .forEach(device -> global.addUnassociatedDevice());
+        points = points.stream().filter(point -> statusQuery.matchesPoint(point.getPointStatus()))
+                .collect(Collectors.toList());
+        equipment = equipment.stream().filter(device -> statusQuery.matchesDevice(device.getDeviceStatus()))
+                .collect(Collectors.toList());
         int total = points.size() + equipment.size();
         int fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
@@ -109,7 +125,8 @@ public class CockpitMapSnapshotService {
                 pagePoints,
                 pageEquipment,
                 coordinateValidation(points, equipment),
-                mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment));
+                mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, statusQuery.echo(), points, equipment),
+                global.rows());
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -237,6 +254,7 @@ public class CockpitMapSnapshotService {
         }
         return snapshot.getMonitoringPoints().stream()
                 .filter(Objects::nonNull)
+                .filter(source -> !isBlank(source.getPointCode()))
                 .filter(source -> matchesFilters(
                         industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> monitoringPoint(source, types))
@@ -250,7 +268,8 @@ public class CockpitMapSnapshotService {
             Map<String, CockpitMapSnapshot.SourceType> types) {
         List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
                 ? Collections.emptyList()
-                : source.getDevices();
+                : source.getDevices().stream().filter(Objects::nonNull)
+                        .filter(device -> !isBlank(device.getDeviceCode())).collect(Collectors.toList());
         CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
         List<String> deviceCodes = devices.stream()
                 .filter(Objects::nonNull)
@@ -287,8 +306,8 @@ public class CockpitMapSnapshotService {
         Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
         return snapshot.getSmartDevices().stream()
                 .filter(Objects::nonNull)
-                .filter(device -> device.getDeviceCode() == null
-                        || !associatedDeviceCodes.contains(device.getDeviceCode()))
+                .filter(device -> !isBlank(device.getDeviceCode()))
+                .filter(device -> !associatedDeviceCodes.contains(device.getDeviceCode().trim()))
                 .filter(source -> matchesFilters(
                         industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> unassociatedEquipment(source, types))
@@ -302,11 +321,15 @@ public class CockpitMapSnapshotService {
             return Collections.emptySet();
         }
         return snapshot.getMonitoringPoints().stream()
+                .filter(Objects::nonNull)
                 .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
                 .filter(Objects::nonNull)
                 .flatMap(List::stream)
+                .filter(Objects::nonNull)
                 .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
                 .filter(Objects::nonNull)
+                .map(String::trim)
+                .filter(code -> !code.isEmpty())
                 .collect(Collectors.toSet());
     }
 
@@ -324,11 +347,12 @@ public class CockpitMapSnapshotService {
                 source.getLongitude(),
                 source.getLatitude(),
                 CockpitCoordinateValidator.validate(
-                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
+                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()),
+                CockpitPointStatusQuery.deviceBucket(source.getDeviceStatus()));
     }
 
     private static CockpitMapSnapshotResponse.MapFilter mapFilter(
-            String industry, CockpitDistrict.Query district, String deviceType,
+            String industry, CockpitDistrict.Query district, String deviceType, String pointStatus,
             List<CockpitMonitoringPointSummary> points,
             List<CockpitUnassociatedEquipmentSummary> equipment) {
         Set<String> pointCodes = new LinkedHashSet<>();
@@ -354,7 +378,7 @@ public class CockpitMapSnapshotService {
                 industry,
                 district.active() ? district.code() : null,
                 !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
-                deviceType, sorted(pointCodes), sorted(deviceCodes));
+                deviceType, pointStatus, sorted(pointCodes), sorted(deviceCodes));
     }
 
     private static List<String> sorted(Set<String> values) {

+ 5 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java

@@ -15,10 +15,12 @@ public class CockpitUnassociatedEquipmentSummary {
     private final BigDecimal longitude;
     private final BigDecimal latitude;
     private final CockpitCoordinateValidation coordinateValidation;
+    private final String deviceStatus;
 
     public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
             Integer district, String industry, String typeCode, String typeName,
-            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
+            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
+            String deviceStatus) {
         this.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.district = CockpitDistrict.codeOf(district);
@@ -28,8 +30,10 @@ public class CockpitUnassociatedEquipmentSummary {
         this.longitude = longitude;
         this.latitude = latitude;
         this.coordinateValidation = coordinateValidation;
+        this.deviceStatus = deviceStatus;
     }
 
+    public String getDeviceStatus() { return deviceStatus; }
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceName() { return deviceName; }
     /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */

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

@@ -137,7 +137,8 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     fact.isWarning(),
                     fact.isWorkOrderInProgress(),
                     fact.getStatusReasons(),
-                    fact.getStatusTime()));
+                    fact.getStatusTime(),
+                    fact.isStatusPublished()));
         }
 
         List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()

+ 7 - 16
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

@@ -3,6 +3,7 @@ package com.zksy.cockpit.monitoring;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -19,7 +20,7 @@ public class CockpitMonitoringDistributionResponse {
     private final List<DistrictDistribution> districtDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<DeviceTypeDistribution> deviceTypeDistribution;
-    private final List<PointStatusCount> pointStatusDistribution;
+    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
     private final MapFilter mapFilter;
     private final Pagination pagination;
     private final List<MonitoringPointMarker> monitoringPoints;
@@ -38,7 +39,7 @@ public class CockpitMonitoringDistributionResponse {
             List<DistrictDistribution> districtDistribution,
             List<IndustryDistribution> industryDistribution,
             List<DeviceTypeDistribution> deviceTypeDistribution,
-            List<PointStatusCount> pointStatusDistribution,
+            List<CockpitPointStatusQuery.Count> pointStatusDistribution,
             MapFilter mapFilter,
             Pagination pagination,
             List<MonitoringPointMarker> monitoringPoints,
@@ -74,7 +75,7 @@ public class CockpitMonitoringDistributionResponse {
     public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
     public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
-    public List<PointStatusCount> getPointStatusDistribution() { return pointStatusDistribution; }
+    public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Pagination getPagination() { return pagination; }
     public List<MonitoringPointMarker> getMonitoringPoints() { return monitoringPoints; }
@@ -220,19 +221,6 @@ public class CockpitMonitoringDistributionResponse {
         public String getName() { return name; }
     }
 
-    public static class PointStatusCount {
-        private final CockpitMonitoringPointStatus status;
-        private final int count;
-
-        public PointStatusCount(CockpitMonitoringPointStatus status, int count) {
-            this.status = status;
-            this.count = count;
-        }
-
-        public CockpitMonitoringPointStatus getStatus() { return status; }
-        public int getCount() { return count; }
-    }
-
     /**
      * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
      * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
@@ -368,6 +356,9 @@ public class CockpitMonitoringDistributionResponse {
         }
 
         public String getDeviceCode() { return deviceCode; }
+        public String getDeviceStatus() {
+            return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
+        }
         public String getDeviceName() { return deviceName; }
         public Integer getDistrict() { return district; }
         public String getDistrictName() { return districtName; }

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

@@ -11,6 +11,7 @@ import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -32,9 +33,8 @@ import java.util.Set;
 /**
  * Read-only aggregation for 运行监测 / 监测分布.
  *
- * <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.
+ * <p>业务对象参与计数、台账与筛选,坐标校验仅决定能否上图。
+ * 八桶分布取全局未筛全集(六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备)。
  */
 @Service
 public class CockpitMonitoringDistributionService {
@@ -74,7 +74,7 @@ public class CockpitMonitoringDistributionService {
             Integer pageSize,
             String snapshotAt) {
         String selectedIndustry = resolveIndustry(industry);
-        CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);
         CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
@@ -102,7 +102,7 @@ public class CockpitMonitoringDistributionService {
             String snapshotAt,
             CockpitMonitoringDistributionDeviceStatusDelta delta) {
         String selectedIndustry = resolveIndustry(industry);
-        CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);
         CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
@@ -231,7 +231,7 @@ public class CockpitMonitoringDistributionService {
             String requestId,
             String selectedIndustry,
             String deviceType,
-            CockpitMonitoringPointStatus selectedStatus,
+            CockpitPointStatusQuery selectedStatus,
             CockpitDistrict.Query selectedDistrict,
             int page,
             int pageSize) {
@@ -241,22 +241,14 @@ 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())));
-                }
-            }
-        }
+        Set<String> associatedCodes = new LinkedHashSet<>();
+        CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();
 
         if (snapshot.getMonitoringPoints() != null) {
             for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
@@ -264,54 +256,39 @@ public class CockpitMonitoringDistributionService {
                     continue;
                 }
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode());
-                String mismatch = classify(type);
-                List<CockpitMonitoringPointDeviceSnapshot> devices = safeDevices(point.getDevices());
-                boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && devices.isEmpty();
-                if (mismatch != null && !emptyPointWithUnknownType) {
-                    if (UNKNOWN_TYPE.equals(mismatch)) {
-                        unknownTypeCount++;
-                    } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
-                        typeKindMismatchCount++;
-                    } else {
-                        nonFormalIndustryCount++;
-                    }
-                    continue;
-                }
-                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(markerTypeCode)) {
-                    continue;
-                }
-                CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
-                        point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
-                // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
-                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
-                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
-                    continue;
-                }
                 List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
-                for (CockpitMonitoringPointDeviceSnapshot device : devices) {
-                    if (device == null || isBlank(device.getDeviceCode())) {
-                        continue;
-                    }
+                for (CockpitMonitoringPointDeviceSnapshot device : safeDevices(point.getDevices())) {
+                    if (device == null || isBlank(device.getDeviceCode())) continue;
+                    associatedCodes.add(device.getDeviceCode().trim());
                     effective.add(effectiveDevice(device));
                 }
                 CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
-                if (selectedStatus != null && selectedStatus != status) {
+                global.addPoint(status);
+                String mismatch = classify(type);
+                boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && effective.isEmpty();
+                if (mismatch != null && !emptyPointWithUnknownType) {
+                    if (UNKNOWN_TYPE.equals(mismatch)) unknownTypeCount++;
+                    else if (TYPE_KIND_MISMATCH.equals(mismatch)) typeKindMismatchCount++;
+                    else nonFormalIndustryCount++;
                     continue;
                 }
-                pointCodes.add(point.getPointCode().trim());
+                String markerIndustry = industryOf(type);
+                String markerTypeCode = point.getTypeCode();
+                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
+                if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
+                        markerIndustry, point.getTypeCode(), markerDistrict, type)
+                        || !selectedStatus.matchesPoint(status)) continue;
+                CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
+                        point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
                 deviceCodes.addAll(codes(effective));
+                pointCodes.add(point.getPointCode().trim());
                 pointMarkers.add(new Marker(
                         point.getPointCode().trim(),
                         point.getPointName(),
                         markerDistrict,
                         markerIndustry,
                         markerTypeCode,
-                        type == null ? "未知" : type.getName(),
+                        type == null ? null : type.getName(),
                         status,
                         CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
                         point.getLongitude(),
@@ -324,39 +301,30 @@ public class CockpitMonitoringDistributionService {
 
         if (snapshot.getSmartDevices() != null) {
             for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
-                if (device == null || isBlank(device.getDeviceCode())
-                        || associatedDeviceCodes.contains(device.getDeviceCode().trim())) {
+                if (device == null || isBlank(device.getDeviceCode())) {
                     continue;
                 }
+                if (associatedCodes.contains(device.getDeviceCode().trim())) continue;
+                global.addUnassociatedDevice();
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
                 String mismatch = classify(type);
                 if (mismatch != null) {
-                    if (UNKNOWN_TYPE.equals(mismatch)) {
-                        unknownTypeCount++;
-                    } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
-                        typeKindMismatchCount++;
-                    } else {
-                        nonFormalIndustryCount++;
-                    }
-                    continue;
-                }
-                if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
-                    continue;
-                }
-                if (!isBlank(deviceType) && !deviceType.trim().equals(type.getCode())) {
+                    if (UNKNOWN_TYPE.equals(mismatch)) unknownTypeCount++;
+                    else if (TYPE_KIND_MISMATCH.equals(mismatch)) typeKindMismatchCount++;
+                    else nonFormalIndustryCount++;
                     continue;
                 }
+                String markerIndustry = industryOf(type);
+                String markerTypeCode = device.getTypeCode();
+                Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
+                if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
+                        markerIndustry, device.getTypeCode(), markerDistrict, type)) continue;
                 CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
                         device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
-                // 设备自身片区(equipment_base.district)即权威来源
-                Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
-                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
-                    continue;
-                }
                 CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
                         device.getDeviceCode().trim(),
                         device.getDeviceName(),
-                        type.getName(),
+                        type == null ? null : type.getName(),
                         device.getLongitude(),
                          device.getLatitude(),
                          validation,
@@ -365,23 +333,24 @@ public class CockpitMonitoringDistributionService {
                         device.isWarning(),
                         device.isWorkOrderInProgress(),
                         device.getStatusReasons(),
-                        device.getStatusTime());
+                        device.getStatusTime(), device.isStatusPublished());
                 CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
-                CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.deviceStatus(applied);
-                if (selectedStatus != null && selectedStatus != status) {
-                    continue;
-                }
+                String publishedStatus = publishedStatus(applied);
+                CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus)
+                        ? null : CockpitMonitoringPointStatus.valueOf(publishedStatus);
+                if (!selectedStatus.matchesDevice(publishedStatus)) continue;
                 deviceCodes.add(applied.getDeviceCode().trim());
                 deviceMarkers.add(new Marker(
                         applied.getDeviceCode().trim(),
                         applied.getDeviceName(),
                         markerDistrict,
-                        type.getIndustry(),
-                        type.getCode(),
-                        type.getName(),
+                        markerIndustry,
+                        markerTypeCode,
+                        type == null ? null : type.getName(),
                         status,
-                        CockpitMonitoringPointStatusResolver.summaryReasons(
-                                status, Collections.singletonList(applied)),
+                        status == null ? applied.getStatusReasons()
+                                : CockpitMonitoringPointStatusResolver.summaryReasons(
+                                        status, Collections.singletonList(applied)),
                         device.getLongitude(),
                         device.getLatitude(),
                         validation,
@@ -397,18 +366,15 @@ public class CockpitMonitoringDistributionService {
         Map<Integer, Accumulator> byDistrict = new LinkedHashMap<>();
         Map<String, Accumulator> byIndustry = new LinkedHashMap<>();
         Map<String, Accumulator> byType = new LinkedHashMap<>();
-        int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
         for (Marker marker : pointMarkers) {
             accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
-            statusCounts[marker.status.ordinal()]++;
         }
         for (Marker marker : deviceMarkers) {
             accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
-            statusCounts[marker.status.ordinal()]++;
         }
 
         List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
@@ -417,12 +383,7 @@ public class CockpitMonitoringDistributionService {
                 industryRows(byIndustry);
         List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
                 typeRows(byType);
-        List<CockpitMonitoringDistributionResponse.PointStatusCount> statusRows =
-                new ArrayList<>();
-        for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
-            statusRows.add(new CockpitMonitoringDistributionResponse.PointStatusCount(
-                    status, statusCounts[status.ordinal()]));
-        }
+        List<CockpitPointStatusQuery.Count> statusRows = global.rows();
 
         int total = pointMarkers.size() + deviceMarkers.size();
         int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
@@ -468,7 +429,7 @@ public class CockpitMonitoringDistributionService {
                 new CockpitMonitoringDistributionResponse.MapFilter(
                         selectedIndustry,
                         isBlank(deviceType) ? null : deviceType.trim(),
-                        selectedStatus == null ? null : selectedStatus.name(),
+                        selectedStatus.echo(),
                         // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot)
                         selectedDistrict.code(),
                         !selectedDistrict.active() ? "NONE"
@@ -502,7 +463,10 @@ public class CockpitMonitoringDistributionService {
                 source.isWarning(),
                 source.isWorkOrderInProgress(),
                 source.getStatusReasons(),
-                source.getStatusTime());
+                source.getStatusTime(),
+                runtime == null || runtime.serverUpdatedAt == null
+                        ? source.isStatusPublished()
+                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState));
     }
 
     /**
@@ -637,18 +601,20 @@ public class CockpitMonitoringDistributionService {
         return query;
     }
 
-    private static CockpitMonitoringPointStatus resolvePointStatus(String pointStatus) {
-        if (isBlank(pointStatus)) {
-            return null;
-        }
-        String resolved = pointStatus.trim();
-        for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
-            if (status.name().equals(resolved)) {
-                return status;
-            }
-        }
-        throw new CockpitInvalidRequestException(
-                "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY");
+    private String industryOf(CockpitMonitoringDistributionSnapshot.SourceType type) {
+        return classify(type) == null ? type.getIndustry() : null;
+    }
+
+    private static boolean matchesFilters(String industry, String deviceType, CockpitDistrict.Query district,
+            String actualIndustry, String typeCode, Integer actualDistrict,
+            CockpitMonitoringDistributionSnapshot.SourceType type) {
+        return (industry == null || industry.equals(actualIndustry))
+                && (isBlank(deviceType) || (type != null && deviceType.trim().equals(typeCode)))
+                && CockpitDistrict.matches(district, actualDistrict);
+    }
+
+    private static String publishedStatus(CockpitMonitoringPointDeviceSnapshot device) {
+        return CockpitPointStatusQuery.deviceBucket(device.getPublishedDeviceStatus());
     }
 
     private static void validatePagination(int page, int pageSize, String snapshotAt) {
@@ -822,7 +788,7 @@ public class CockpitMonitoringDistributionService {
             } else {
                 deviceCount++;
             }
-            statusCounts[marker.status.ordinal()]++;
+            if (marker.status != null) statusCounts[marker.status.ordinal()]++;
             if (typeName == null) {
                 typeName = marker.typeName;
             }

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

@@ -173,6 +173,7 @@ public class CockpitMonitoringDistributionSnapshot {
         private final boolean workOrderInProgress;
         private final List<String> statusReasons;
         private final String statusTime;
+        private final boolean statusPublished;
 
         public SourceSmartDevice(
                 String deviceCode,
@@ -188,6 +189,14 @@ public class CockpitMonitoringDistributionSnapshot {
                 boolean workOrderInProgress,
                 List<String> statusReasons,
                 String statusTime) {
+            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified,
+                    online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true);
+        }
+
+        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
+                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
+                boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+                List<String> statusReasons, String statusTime, boolean statusPublished) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.district = district;
@@ -201,6 +210,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.workOrderInProgress = workOrderInProgress;
             this.statusReasons = statusReasons;
             this.statusTime = statusTime;
+            this.statusPublished = statusPublished;
         }
 
         public String getDeviceCode() {
@@ -252,6 +262,8 @@ public class CockpitMonitoringDistributionSnapshot {
             return statusReasons;
         }
 
+        public boolean isStatusPublished() { return statusPublished; }
+
         public String getStatusTime() {
             return statusTime;
         }

+ 4 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
@@ -77,7 +78,7 @@ public class BusinessCockpitDeviceFactResolver {
                 deviceTypeName,
                  device.getLongitude(),
                  device.getLatitude(),
-                 false,
+                 CockpitCoordinateValidator.validate(device.getLongitude(), device.getLatitude(), false),
                 online,
                 alarm,
                 hasOpenWarning(deviceCode),
@@ -86,7 +87,8 @@ public class BusinessCockpitDeviceFactResolver {
                 status == null || status.getStatusUpdateTime() == null
                         ? null
                         : TIME_FORMATTER.format(status.getStatusUpdateTime()
-                                .atZone(BUSINESS_ZONE).toOffsetDateTime()));
+                                .atZone(BUSINESS_ZONE).toOffsetDateTime()),
+                status != null);
     }
 
     private boolean hasOpenWarning(String deviceCode) {

+ 15 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java

@@ -19,6 +19,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
     private final boolean workOrderInProgress;
     private final List<String> statusReasons;
     private final String statusTime;
+    private final boolean statusPublished;
 
     public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
@@ -40,6 +41,14 @@ public class CockpitMonitoringPointDeviceSnapshot {
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             List<String> statusReasons, String statusTime) {
+        this(deviceCode, deviceName, deviceTypeName, longitude, latitude, coordinateValidation,
+                online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true);
+    }
+
+    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
+            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
+            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+            List<String> statusReasons, String statusTime, boolean statusPublished) {
         this.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.deviceTypeName = deviceTypeName;
@@ -52,6 +61,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
         this.workOrderInProgress = workOrderInProgress;
         this.statusReasons = statusReasons;
         this.statusTime = statusTime;
+        this.statusPublished = statusPublished;
     }
 
     public String getDeviceCode() { return deviceCode; }
@@ -66,4 +76,9 @@ public class CockpitMonitoringPointDeviceSnapshot {
     public boolean isWorkOrderInProgress() { return workOrderInProgress; }
     public List<String> getStatusReasons() { return statusReasons; }
     public String getStatusTime() { return statusTime; }
+    public boolean isStatusPublished() { return statusPublished; }
+    /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */
+    public String getPublishedDeviceStatus() {
+        return statusPublished ? CockpitMonitoringPointStatusResolver.deviceStatus(this).name() : null;
+    }
 }

+ 96 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitPointStatusQuery.java

@@ -0,0 +1,96 @@
+package com.zksy.cockpit.point;
+
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/** 八桶查询与全局分布。六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备,互不重复。 */
+public final class CockpitPointStatusQuery {
+    public static final List<String> BUCKETS = Collections.unmodifiableList(Arrays.asList(
+            "ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE", "EMPTY", "UNKNOWN", "UNASSOCIATED"));
+    private static final List<String> DEVICE_STATUSES = Arrays.asList(
+            "ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE");
+    private final List<String> selected;
+
+    private CockpitPointStatusQuery(List<String> selected) {
+        this.selected = selected;
+    }
+
+    public static CockpitPointStatusQuery parse(String raw) {
+        Set<String> values = new LinkedHashSet<>();
+        if (raw != null && !raw.trim().isEmpty()) {
+            for (String token : raw.split(",", -1)) {
+                String value = token.trim();
+                if (!BUCKETS.contains(value)) {
+                    throw new CockpitInvalidRequestException(
+                            "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED");
+                }
+                values.add(value);
+            }
+        }
+        // 同一集合不因入参顺序不同而有不同回显。
+        List<String> selected = new ArrayList<>(BUCKETS);
+        selected.retainAll(values);
+        return new CockpitPointStatusQuery(Collections.unmodifiableList(selected));
+    }
+
+    public String echo() {
+        return selected.isEmpty() ? null : String.join(",", selected);
+    }
+
+    public boolean matchesPoint(CockpitMonitoringPointStatus status) {
+        return !selected.contains("UNASSOCIATED")
+                && (selected.isEmpty() || selected.contains(pointBucket(status)));
+    }
+
+    public boolean matchesDevice(String status) {
+        return selected.isEmpty()
+                || (selected.size() == 1 && selected.contains("UNASSOCIATED"))
+                || selected.contains(deviceBucket(status));
+    }
+
+    public static String pointBucket(CockpitMonitoringPointStatus status) {
+        return status == null ? "UNKNOWN" : status.name();
+    }
+
+    public static String deviceBucket(String status) {
+        return status != null && DEVICE_STATUSES.contains(status) ? status : "UNKNOWN";
+    }
+
+    public static class Count {
+        private final String status;
+        private final int count;
+
+        public Count(String status, int count) {
+            this.status = status;
+            this.count = count;
+        }
+
+        public String getStatus() { return status; }
+        public int getCount() { return count; }
+    }
+
+    public static class Distribution {
+        private final int[] counts = new int[BUCKETS.size()];
+
+        public void addPoint(CockpitMonitoringPointStatus status) {
+            counts[BUCKETS.indexOf(pointBucket(status))]++;
+        }
+
+        public void addUnassociatedDevice() {
+            counts[BUCKETS.indexOf("UNASSOCIATED")]++;
+        }
+
+        public List<Count> rows() {
+            List<Count> rows = new ArrayList<>();
+            for (int index = 0; index < BUCKETS.size(); index++) {
+                rows.add(new Count(BUCKETS.get(index), counts[index]));
+            }
+            return rows;
+        }
+    }
+}