Просмотр исходного кода

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

Kazerin 1 неделя назад
Родитель
Сommit
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 = "industry", required = false) String industry,
             @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestParam(value = "deviceType", required = false) String deviceType,
+            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         CockpitMapSnapshotResponse response = mapSnapshotService.load(
         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));
         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(
     public ResponseEntity<AjaxResult> monitoringDistribution(
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "pointStatus", required = false) String pointStatus,
+            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
             // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
             // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
             @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
@@ -45,7 +45,7 @@ public class CockpitMonitoringDistributionController {
                         resolveRequestId(requestId),
                         resolveRequestId(requestId),
                         industry,
                         industry,
                         deviceType,
                         deviceType,
-                        pointStatus,
+                        pointStatus == null ? null : String.join(",", pointStatus),
                         district,
                         district,
                         page,
                         page,
                         pageSize,
                         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.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 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.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
@@ -131,6 +132,26 @@ class BusinessCockpitDeviceFactResolverTest {
         assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中");
         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 ----
     // ---- fixtures ----
 
 
     private static EquipmentBase device(String code) {
     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
     @Test
-    void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception {
+    void distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter() throws Exception {
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("snapshotAt", SNAPSHOT_AT)
                         .param("snapshotAt", SNAPSHOT_AT)
                         .header("X-Request-Id", "monitoring-distribution-001"))
                         .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[1].pointStatus").value("WARNING"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE"))
                 .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].status").value("ALARM"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING"))
                 .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].status").value("WORK_ORDER"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
                 .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].status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
                 .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
@@ -194,9 +194,9 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(2))
                 .andExpect(jsonPath("$.data.pagination.total").value(2))
                 .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING"))
                 .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))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
@@ -253,7 +253,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(0))
                 .andExpect(jsonPath("$.data.pagination.total").value(0))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(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.mapFilter.deviceType").value("NOT_A_TYPE"))
                 .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
@@ -279,7 +279,7 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.message").value(
                 .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);
         verifyNoInteractions(adapter);
     }
     }
@@ -351,7 +351,7 @@ class CockpitMonitoringDistributionControllerTest {
     void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
     void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
         CockpitMonitoringDistributionResponse initial = service.load(
         CockpitMonitoringDistributionResponse initial = service.load(
                 "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
                 "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));
         assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
 
 
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
@@ -360,7 +360,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:05: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));
         assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
 
 
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
@@ -369,7 +369,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
                         "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:04: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));
         assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
 
 
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
@@ -378,7 +378,7 @@ class CockpitMonitoringDistributionControllerTest {
                         "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:06: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));
         assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
     }
     }
 
 
@@ -455,7 +455,7 @@ class CockpitMonitoringDistributionControllerTest {
         assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
         assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
         assertEquals("READY", all.getFreshness().getReason());
         assertEquals("READY", all.getFreshness().getReason());
         assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
         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);
         CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
         assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
         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()),
                     type == null || type.getId() == null ? null : String.valueOf(type.getId()),
                     device.getLongitude(),
                     device.getLongitude(),
                     device.getLatitude(),
                     device.getLatitude(),
-                    false));
+                    false,
+                    deviceFactResolver.resolve(device, type == null ? null : type.getTypeName())
+                            .getPublishedDeviceStatus()));
         }
         }
 
 
         List<CockpitMapSnapshot.SourceType> sourceTypes = types.values().stream()
         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 longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         private final boolean coordinateVerified;
+        private final String deviceStatus;
 
 
         public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
         public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
                 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.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.district = district;
             this.district = district;
@@ -115,8 +121,10 @@ public class CockpitMapSnapshot {
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
             this.coordinateVerified = coordinateVerified;
+            this.deviceStatus = deviceStatus;
         }
         }
 
 
+        public String getDeviceStatus() { return deviceStatus; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
         public String getDeviceName() { return deviceName; }
         public Integer getDistrict() { return district; }
         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;
 package com.zksy.cockpit.map;
 
 
 import java.util.List;
 import java.util.List;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 
 
 public class CockpitMapSnapshotResponse {
 public class CockpitMapSnapshotResponse {
     private final String requestId;
     private final String requestId;
@@ -16,13 +17,14 @@ public class CockpitMapSnapshotResponse {
     private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
     private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
     private final CockpitCoordinateValidationSummary coordinateValidation;
     private final CockpitCoordinateValidationSummary coordinateValidation;
     private final MapFilter mapFilter;
     private final MapFilter mapFilter;
+    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
 
 
     public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
     public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
             String snapshotAt, int total, String batchUpdatedAt, boolean complete,
             String snapshotAt, int total, String batchUpdatedAt, boolean complete,
             List<CockpitMonitoringPointSummary> monitoringPoints,
             List<CockpitMonitoringPointSummary> monitoringPoints,
             List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
             List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
             CockpitCoordinateValidationSummary coordinateValidation,
             CockpitCoordinateValidationSummary coordinateValidation,
-            MapFilter mapFilter) {
+            MapFilter mapFilter, List<CockpitPointStatusQuery.Count> pointStatusDistribution) {
         this.requestId = requestId;
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
         this.readOnly = readOnly;
@@ -36,6 +38,7 @@ public class CockpitMapSnapshotResponse {
         this.unassociatedEquipment = unassociatedEquipment;
         this.unassociatedEquipment = unassociatedEquipment;
         this.coordinateValidation = coordinateValidation;
         this.coordinateValidation = coordinateValidation;
         this.mapFilter = mapFilter;
         this.mapFilter = mapFilter;
+        this.pointStatusDistribution = pointStatusDistribution;
     }
     }
 
 
     public String getRequestId() { return requestId; }
     public String getRequestId() { return requestId; }
@@ -50,6 +53,7 @@ public class CockpitMapSnapshotResponse {
     public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
     public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
     public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
     public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
     public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
     public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
+    public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
     public MapFilter getMapFilter() { return mapFilter; }
     public MapFilter getMapFilter() { return mapFilter; }
 
 
     /**
     /**
@@ -66,10 +70,11 @@ public class CockpitMapSnapshotResponse {
         private final String districtName;
         private final String districtName;
         private final String districtFilter;
         private final String districtFilter;
         private final String deviceType;
         private final String deviceType;
+        private final String pointStatus;
         private final List<String> pointCodes;
         private final List<String> pointCodes;
         private final List<String> deviceCodes;
         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) {
                 List<String> pointCodes, List<String> deviceCodes) {
             this.industry = industry;
             this.industry = industry;
             this.district = district;
             this.district = district;
@@ -77,6 +82,7 @@ public class CockpitMapSnapshotResponse {
             this.districtName = district == null ? null
             this.districtName = district == null ? null
                     : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
                     : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
             this.deviceType = deviceType;
             this.deviceType = deviceType;
+            this.pointStatus = pointStatus;
             this.pointCodes = pointCodes;
             this.pointCodes = pointCodes;
             this.deviceCodes = deviceCodes;
             this.deviceCodes = deviceCodes;
         }
         }
@@ -88,6 +94,7 @@ public class CockpitMapSnapshotResponse {
         public String getDistrictName() { return districtName; }
         public String getDistrictName() { return districtName; }
         /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
         /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
         public String getDistrictFilter() { return districtFilter; }
         public String getDistrictFilter() { return districtFilter; }
+        public String getPointStatus() { return pointStatus; }
         public String getDeviceType() { return deviceType; }
         public String getDeviceType() { return deviceType; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }
         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.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -55,6 +56,12 @@ public class CockpitMapSnapshotService {
      */
      */
     public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
     public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
             String industry, String district, String deviceType) {
             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 safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
         validatePagination(safePage, safePageSize, snapshotAt);
@@ -86,6 +93,15 @@ public class CockpitMapSnapshotService {
                 snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
                 snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
         List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
         List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
                 snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
                 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 total = points.size() + equipment.size();
         int fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
@@ -109,7 +125,8 @@ public class CockpitMapSnapshotService {
                 pagePoints,
                 pagePoints,
                 pageEquipment,
                 pageEquipment,
                 coordinateValidation(points, equipment),
                 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) {
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -237,6 +254,7 @@ public class CockpitMapSnapshotService {
         }
         }
         return snapshot.getMonitoringPoints().stream()
         return snapshot.getMonitoringPoints().stream()
                 .filter(Objects::nonNull)
                 .filter(Objects::nonNull)
+                .filter(source -> !isBlank(source.getPointCode()))
                 .filter(source -> matchesFilters(
                 .filter(source -> matchesFilters(
                         industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                         industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> monitoringPoint(source, types))
                 .map(source -> monitoringPoint(source, types))
@@ -250,7 +268,8 @@ public class CockpitMapSnapshotService {
             Map<String, CockpitMapSnapshot.SourceType> types) {
             Map<String, CockpitMapSnapshot.SourceType> types) {
         List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
         List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
                 ? Collections.emptyList()
                 ? Collections.emptyList()
-                : source.getDevices();
+                : source.getDevices().stream().filter(Objects::nonNull)
+                        .filter(device -> !isBlank(device.getDeviceCode())).collect(Collectors.toList());
         CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
         CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
         List<String> deviceCodes = devices.stream()
         List<String> deviceCodes = devices.stream()
                 .filter(Objects::nonNull)
                 .filter(Objects::nonNull)
@@ -287,8 +306,8 @@ public class CockpitMapSnapshotService {
         Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
         Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
         return snapshot.getSmartDevices().stream()
         return snapshot.getSmartDevices().stream()
                 .filter(Objects::nonNull)
                 .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(
                 .filter(source -> matchesFilters(
                         industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                         industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> unassociatedEquipment(source, types))
                 .map(source -> unassociatedEquipment(source, types))
@@ -302,11 +321,15 @@ public class CockpitMapSnapshotService {
             return Collections.emptySet();
             return Collections.emptySet();
         }
         }
         return snapshot.getMonitoringPoints().stream()
         return snapshot.getMonitoringPoints().stream()
+                .filter(Objects::nonNull)
                 .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
                 .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
                 .filter(Objects::nonNull)
                 .filter(Objects::nonNull)
                 .flatMap(List::stream)
                 .flatMap(List::stream)
+                .filter(Objects::nonNull)
                 .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
                 .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
                 .filter(Objects::nonNull)
                 .filter(Objects::nonNull)
+                .map(String::trim)
+                .filter(code -> !code.isEmpty())
                 .collect(Collectors.toSet());
                 .collect(Collectors.toSet());
     }
     }
 
 
@@ -324,11 +347,12 @@ public class CockpitMapSnapshotService {
                 source.getLongitude(),
                 source.getLongitude(),
                 source.getLatitude(),
                 source.getLatitude(),
                 CockpitCoordinateValidator.validate(
                 CockpitCoordinateValidator.validate(
-                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
+                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()),
+                CockpitPointStatusQuery.deviceBucket(source.getDeviceStatus()));
     }
     }
 
 
     private static CockpitMapSnapshotResponse.MapFilter mapFilter(
     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<CockpitMonitoringPointSummary> points,
             List<CockpitUnassociatedEquipmentSummary> equipment) {
             List<CockpitUnassociatedEquipmentSummary> equipment) {
         Set<String> pointCodes = new LinkedHashSet<>();
         Set<String> pointCodes = new LinkedHashSet<>();
@@ -354,7 +378,7 @@ public class CockpitMapSnapshotService {
                 industry,
                 industry,
                 district.active() ? district.code() : null,
                 district.active() ? district.code() : null,
                 !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
                 !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) {
     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 longitude;
     private final BigDecimal latitude;
     private final BigDecimal latitude;
     private final CockpitCoordinateValidation coordinateValidation;
     private final CockpitCoordinateValidation coordinateValidation;
+    private final String deviceStatus;
 
 
     public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
     public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
             Integer district, String industry, String typeCode, String typeName,
             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.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.deviceName = deviceName;
         this.district = CockpitDistrict.codeOf(district);
         this.district = CockpitDistrict.codeOf(district);
@@ -28,8 +30,10 @@ public class CockpitUnassociatedEquipmentSummary {
         this.longitude = longitude;
         this.longitude = longitude;
         this.latitude = latitude;
         this.latitude = latitude;
         this.coordinateValidation = coordinateValidation;
         this.coordinateValidation = coordinateValidation;
+        this.deviceStatus = deviceStatus;
     }
     }
 
 
+    public String getDeviceStatus() { return deviceStatus; }
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceName() { return deviceName; }
     public String getDeviceName() { return deviceName; }
     /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
     /** 片区编码(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.isWarning(),
                     fact.isWorkOrderInProgress(),
                     fact.isWorkOrderInProgress(),
                     fact.getStatusReasons(),
                     fact.getStatusReasons(),
-                    fact.getStatusTime()));
+                    fact.getStatusTime(),
+                    fact.isStatusPublished()));
         }
         }
 
 
         List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
         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.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.List;
@@ -19,7 +20,7 @@ public class CockpitMonitoringDistributionResponse {
     private final List<DistrictDistribution> districtDistribution;
     private final List<DistrictDistribution> districtDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<DeviceTypeDistribution> deviceTypeDistribution;
     private final List<DeviceTypeDistribution> deviceTypeDistribution;
-    private final List<PointStatusCount> pointStatusDistribution;
+    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
     private final MapFilter mapFilter;
     private final MapFilter mapFilter;
     private final Pagination pagination;
     private final Pagination pagination;
     private final List<MonitoringPointMarker> monitoringPoints;
     private final List<MonitoringPointMarker> monitoringPoints;
@@ -38,7 +39,7 @@ public class CockpitMonitoringDistributionResponse {
             List<DistrictDistribution> districtDistribution,
             List<DistrictDistribution> districtDistribution,
             List<IndustryDistribution> industryDistribution,
             List<IndustryDistribution> industryDistribution,
             List<DeviceTypeDistribution> deviceTypeDistribution,
             List<DeviceTypeDistribution> deviceTypeDistribution,
-            List<PointStatusCount> pointStatusDistribution,
+            List<CockpitPointStatusQuery.Count> pointStatusDistribution,
             MapFilter mapFilter,
             MapFilter mapFilter,
             Pagination pagination,
             Pagination pagination,
             List<MonitoringPointMarker> monitoringPoints,
             List<MonitoringPointMarker> monitoringPoints,
@@ -74,7 +75,7 @@ public class CockpitMonitoringDistributionResponse {
     public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
     public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
     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 MapFilter getMapFilter() { return mapFilter; }
     public Pagination getPagination() { return pagination; }
     public Pagination getPagination() { return pagination; }
     public List<MonitoringPointMarker> getMonitoringPoints() { return monitoringPoints; }
     public List<MonitoringPointMarker> getMonitoringPoints() { return monitoringPoints; }
@@ -220,19 +221,6 @@ public class CockpitMonitoringDistributionResponse {
         public String getName() { return name; }
         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}:
      * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
      * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
      * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
@@ -368,6 +356,9 @@ public class CockpitMonitoringDistributionResponse {
         }
         }
 
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
+        public String getDeviceStatus() {
+            return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
+        }
         public String getDeviceName() { return deviceName; }
         public String getDeviceName() { return deviceName; }
         public Integer getDistrict() { return district; }
         public Integer getDistrict() { return district; }
         public String getDistrictName() { return districtName; }
         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.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
+import com.zksy.cockpit.point.CockpitPointStatusQuery;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -32,9 +33,8 @@ import java.util.Set;
 /**
 /**
  * Read-only aggregation for 运行监测 / 监测分布.
  * 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
 @Service
 public class CockpitMonitoringDistributionService {
 public class CockpitMonitoringDistributionService {
@@ -74,7 +74,7 @@ public class CockpitMonitoringDistributionService {
             Integer pageSize,
             Integer pageSize,
             String snapshotAt) {
             String snapshotAt) {
         String selectedIndustry = resolveIndustry(industry);
         String selectedIndustry = resolveIndustry(industry);
-        CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);
         CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
@@ -102,7 +102,7 @@ public class CockpitMonitoringDistributionService {
             String snapshotAt,
             String snapshotAt,
             CockpitMonitoringDistributionDeviceStatusDelta delta) {
             CockpitMonitoringDistributionDeviceStatusDelta delta) {
         String selectedIndustry = resolveIndustry(industry);
         String selectedIndustry = resolveIndustry(industry);
-        CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);
         CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
@@ -231,7 +231,7 @@ public class CockpitMonitoringDistributionService {
             String requestId,
             String requestId,
             String selectedIndustry,
             String selectedIndustry,
             String deviceType,
             String deviceType,
-            CockpitMonitoringPointStatus selectedStatus,
+            CockpitPointStatusQuery selectedStatus,
             CockpitDistrict.Query selectedDistrict,
             CockpitDistrict.Query selectedDistrict,
             int page,
             int page,
             int pageSize) {
             int pageSize) {
@@ -241,22 +241,14 @@ public class CockpitMonitoringDistributionService {
         int unknownTypeCount = 0;
         int unknownTypeCount = 0;
         int typeKindMismatchCount = 0;
         int typeKindMismatchCount = 0;
         int nonFormalIndustryCount = 0;
         int nonFormalIndustryCount = 0;
-        // Compatibility field: coordinates no longer exclude business objects.
         int invalidCoordinateCount = 0;
         int invalidCoordinateCount = 0;
 
 
         List<Marker> pointMarkers = new ArrayList<>();
         List<Marker> pointMarkers = new ArrayList<>();
         List<Marker> deviceMarkers = new ArrayList<>();
         List<Marker> deviceMarkers = new ArrayList<>();
         Set<String> pointCodes = new LinkedHashSet<>();
         Set<String> pointCodes = new LinkedHashSet<>();
         Set<String> deviceCodes = 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) {
         if (snapshot.getMonitoringPoints() != null) {
             for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
             for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
@@ -264,54 +256,39 @@ public class CockpitMonitoringDistributionService {
                     continue;
                     continue;
                 }
                 }
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode());
                 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<>();
                 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));
                     effective.add(effectiveDevice(device));
                 }
                 }
                 CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
                 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;
                     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));
                 deviceCodes.addAll(codes(effective));
+                pointCodes.add(point.getPointCode().trim());
                 pointMarkers.add(new Marker(
                 pointMarkers.add(new Marker(
                         point.getPointCode().trim(),
                         point.getPointCode().trim(),
                         point.getPointName(),
                         point.getPointName(),
                         markerDistrict,
                         markerDistrict,
                         markerIndustry,
                         markerIndustry,
                         markerTypeCode,
                         markerTypeCode,
-                        type == null ? "未知" : type.getName(),
+                        type == null ? null : type.getName(),
                         status,
                         status,
                         CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
                         CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
                         point.getLongitude(),
                         point.getLongitude(),
@@ -324,39 +301,30 @@ public class CockpitMonitoringDistributionService {
 
 
         if (snapshot.getSmartDevices() != null) {
         if (snapshot.getSmartDevices() != null) {
             for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
             for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
-                if (device == null || isBlank(device.getDeviceCode())
-                        || associatedDeviceCodes.contains(device.getDeviceCode().trim())) {
+                if (device == null || isBlank(device.getDeviceCode())) {
                     continue;
                     continue;
                 }
                 }
+                if (associatedCodes.contains(device.getDeviceCode().trim())) continue;
+                global.addUnassociatedDevice();
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
                 CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
                 String mismatch = classify(type);
                 String mismatch = classify(type);
                 if (mismatch != null) {
                 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;
                     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(
                 CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
                         device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
                         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(
                 CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
                         device.getDeviceCode().trim(),
                         device.getDeviceCode().trim(),
                         device.getDeviceName(),
                         device.getDeviceName(),
-                        type.getName(),
+                        type == null ? null : type.getName(),
                         device.getLongitude(),
                         device.getLongitude(),
                          device.getLatitude(),
                          device.getLatitude(),
                          validation,
                          validation,
@@ -365,23 +333,24 @@ public class CockpitMonitoringDistributionService {
                         device.isWarning(),
                         device.isWarning(),
                         device.isWorkOrderInProgress(),
                         device.isWorkOrderInProgress(),
                         device.getStatusReasons(),
                         device.getStatusReasons(),
-                        device.getStatusTime());
+                        device.getStatusTime(), device.isStatusPublished());
                 CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
                 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());
                 deviceCodes.add(applied.getDeviceCode().trim());
                 deviceMarkers.add(new Marker(
                 deviceMarkers.add(new Marker(
                         applied.getDeviceCode().trim(),
                         applied.getDeviceCode().trim(),
                         applied.getDeviceName(),
                         applied.getDeviceName(),
                         markerDistrict,
                         markerDistrict,
-                        type.getIndustry(),
-                        type.getCode(),
-                        type.getName(),
+                        markerIndustry,
+                        markerTypeCode,
+                        type == null ? null : type.getName(),
                         status,
                         status,
-                        CockpitMonitoringPointStatusResolver.summaryReasons(
-                                status, Collections.singletonList(applied)),
+                        status == null ? applied.getStatusReasons()
+                                : CockpitMonitoringPointStatusResolver.summaryReasons(
+                                        status, Collections.singletonList(applied)),
                         device.getLongitude(),
                         device.getLongitude(),
                         device.getLatitude(),
                         device.getLatitude(),
                         validation,
                         validation,
@@ -397,18 +366,15 @@ public class CockpitMonitoringDistributionService {
         Map<Integer, Accumulator> byDistrict = new LinkedHashMap<>();
         Map<Integer, Accumulator> byDistrict = new LinkedHashMap<>();
         Map<String, Accumulator> byIndustry = new LinkedHashMap<>();
         Map<String, Accumulator> byIndustry = new LinkedHashMap<>();
         Map<String, Accumulator> byType = new LinkedHashMap<>();
         Map<String, Accumulator> byType = new LinkedHashMap<>();
-        int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
         for (Marker marker : pointMarkers) {
         for (Marker marker : pointMarkers) {
             accumulate(byDistrict, marker.district, marker);
             accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
             accumulate(byType, marker.typeCode, marker);
-            statusCounts[marker.status.ordinal()]++;
         }
         }
         for (Marker marker : deviceMarkers) {
         for (Marker marker : deviceMarkers) {
             accumulate(byDistrict, marker.district, marker);
             accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
             accumulate(byType, marker.typeCode, marker);
-            statusCounts[marker.status.ordinal()]++;
         }
         }
 
 
         List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
         List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
@@ -417,12 +383,7 @@ public class CockpitMonitoringDistributionService {
                 industryRows(byIndustry);
                 industryRows(byIndustry);
         List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
         List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
                 typeRows(byType);
                 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 total = pointMarkers.size() + deviceMarkers.size();
         int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
         int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
@@ -468,7 +429,7 @@ public class CockpitMonitoringDistributionService {
                 new CockpitMonitoringDistributionResponse.MapFilter(
                 new CockpitMonitoringDistributionResponse.MapFilter(
                         selectedIndustry,
                         selectedIndustry,
                         isBlank(deviceType) ? null : deviceType.trim(),
                         isBlank(deviceType) ? null : deviceType.trim(),
-                        selectedStatus == null ? null : selectedStatus.name(),
+                        selectedStatus.echo(),
                         // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot)
                         // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot)
                         selectedDistrict.code(),
                         selectedDistrict.code(),
                         !selectedDistrict.active() ? "NONE"
                         !selectedDistrict.active() ? "NONE"
@@ -502,7 +463,10 @@ public class CockpitMonitoringDistributionService {
                 source.isWarning(),
                 source.isWarning(),
                 source.isWorkOrderInProgress(),
                 source.isWorkOrderInProgress(),
                 source.getStatusReasons(),
                 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;
         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) {
     private static void validatePagination(int page, int pageSize, String snapshotAt) {
@@ -822,7 +788,7 @@ public class CockpitMonitoringDistributionService {
             } else {
             } else {
                 deviceCount++;
                 deviceCount++;
             }
             }
-            statusCounts[marker.status.ordinal()]++;
+            if (marker.status != null) statusCounts[marker.status.ordinal()]++;
             if (typeName == null) {
             if (typeName == null) {
                 typeName = marker.typeName;
                 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 boolean workOrderInProgress;
         private final List<String> statusReasons;
         private final List<String> statusReasons;
         private final String statusTime;
         private final String statusTime;
+        private final boolean statusPublished;
 
 
         public SourceSmartDevice(
         public SourceSmartDevice(
                 String deviceCode,
                 String deviceCode,
@@ -188,6 +189,14 @@ public class CockpitMonitoringDistributionSnapshot {
                 boolean workOrderInProgress,
                 boolean workOrderInProgress,
                 List<String> statusReasons,
                 List<String> statusReasons,
                 String statusTime) {
                 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.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.district = district;
             this.district = district;
@@ -201,6 +210,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.workOrderInProgress = workOrderInProgress;
             this.workOrderInProgress = workOrderInProgress;
             this.statusReasons = statusReasons;
             this.statusReasons = statusReasons;
             this.statusTime = statusTime;
             this.statusTime = statusTime;
+            this.statusPublished = statusPublished;
         }
         }
 
 
         public String getDeviceCode() {
         public String getDeviceCode() {
@@ -252,6 +262,8 @@ public class CockpitMonitoringDistributionSnapshot {
             return statusReasons;
             return statusReasons;
         }
         }
 
 
+        public boolean isStatusPublished() { return statusPublished; }
+
         public String getStatusTime() {
         public String getStatusTime() {
             return statusTime;
             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.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
@@ -77,7 +78,7 @@ public class BusinessCockpitDeviceFactResolver {
                 deviceTypeName,
                 deviceTypeName,
                  device.getLongitude(),
                  device.getLongitude(),
                  device.getLatitude(),
                  device.getLatitude(),
-                 false,
+                 CockpitCoordinateValidator.validate(device.getLongitude(), device.getLatitude(), false),
                 online,
                 online,
                 alarm,
                 alarm,
                 hasOpenWarning(deviceCode),
                 hasOpenWarning(deviceCode),
@@ -86,7 +87,8 @@ public class BusinessCockpitDeviceFactResolver {
                 status == null || status.getStatusUpdateTime() == null
                 status == null || status.getStatusUpdateTime() == null
                         ? null
                         ? null
                         : TIME_FORMATTER.format(status.getStatusUpdateTime()
                         : TIME_FORMATTER.format(status.getStatusUpdateTime()
-                                .atZone(BUSINESS_ZONE).toOffsetDateTime()));
+                                .atZone(BUSINESS_ZONE).toOffsetDateTime()),
+                status != null);
     }
     }
 
 
     private boolean hasOpenWarning(String deviceCode) {
     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 boolean workOrderInProgress;
     private final List<String> statusReasons;
     private final List<String> statusReasons;
     private final String statusTime;
     private final String statusTime;
+    private final boolean statusPublished;
 
 
     public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
     public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
@@ -40,6 +41,14 @@ public class CockpitMonitoringPointDeviceSnapshot {
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             List<String> statusReasons, String statusTime) {
             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.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.deviceName = deviceName;
         this.deviceTypeName = deviceTypeName;
         this.deviceTypeName = deviceTypeName;
@@ -52,6 +61,7 @@ public class CockpitMonitoringPointDeviceSnapshot {
         this.workOrderInProgress = workOrderInProgress;
         this.workOrderInProgress = workOrderInProgress;
         this.statusReasons = statusReasons;
         this.statusReasons = statusReasons;
         this.statusTime = statusTime;
         this.statusTime = statusTime;
+        this.statusPublished = statusPublished;
     }
     }
 
 
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceCode() { return deviceCode; }
@@ -66,4 +76,9 @@ public class CockpitMonitoringPointDeviceSnapshot {
     public boolean isWorkOrderInProgress() { return workOrderInProgress; }
     public boolean isWorkOrderInProgress() { return workOrderInProgress; }
     public List<String> getStatusReasons() { return statusReasons; }
     public List<String> getStatusReasons() { return statusReasons; }
     public String getStatusTime() { return statusTime; }
     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;
+        }
+    }
+}