--- a/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java +++ b/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java @@ -37,9 +37,11 @@ @RequestParam(value = "industry", required = false) String industry, @RequestParam(value = "district", required = false) String district, @RequestParam(value = "deviceType", required = false) String deviceType, + @RequestParam(value = "pointStatus", required = false) String[] pointStatus, @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) { CockpitMapSnapshotResponse response = mapSnapshotService.load( - resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType); + resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType, + pointStatus == null ? null : String.join(",", pointStatus)); return ResponseEntity.ok(AjaxResult.success(response)); } --- a/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java +++ b/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java @@ -33,7 +33,7 @@ public ResponseEntity monitoringDistribution( @RequestParam(value = "industry", required = false) String industry, @RequestParam(value = "deviceType", required = false) String deviceType, - @RequestParam(value = "pointStatus", required = false) String pointStatus, + @RequestParam(value = "pointStatus", required = false) String[] pointStatus, // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉 @RequestParam(value = "district", required = false) String district, @RequestParam(value = "page", defaultValue = "1") Integer page, @@ -45,7 +45,7 @@ resolveRequestId(requestId), industry, deviceType, - pointStatus, + pointStatus == null ? null : String.join(",", pointStatus), district, page, pageSize, --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -131,6 +132,26 @@ assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中"); } + @Test + void publishedOfflineAndMissingStatusAreDistinctAndDoNotDependOnTimestamp() { + EquipmentStatus offline = status(1, 0, 0); + offline.setStatusUpdateTime(null); + when(equipmentStatusService.getByDeviceCode("DEV-PUBLISHED")).thenReturn(offline); + assertEquals("OFFLINE", resolver.resolve(device("DEV-PUBLISHED"), null).getPublishedDeviceStatus()); + assertNull(resolver.resolve(device("DEV-MISSING"), null).getPublishedDeviceStatus()); + } + + @Test + void cancelledStatusSevenNeverPublishesWorkOrderState() { + when(equipmentStatusService.getByDeviceCode("DEV-CANCELLED")).thenReturn(status(1, 0, 1)); + when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(7))); + CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-CANCELLED"), null); + assertFalse(fact.isWorkOrderInProgress()); + assertEquals("NORMAL", fact.getPublishedDeviceStatus()); + assertEquals(CockpitMonitoringPointStatus.NORMAL, + CockpitMonitoringPointStatusResolver.resolve(Collections.singletonList(fact))); + } + // ---- fixtures ---- private static EquipmentBase device(String code) { --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java @@ -63,7 +63,7 @@ } @Test - void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception { + void distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter() throws Exception { mockMvc.perform(get(ENDPOINT) .param("snapshotAt", SNAPSHOT_AT) .header("X-Request-Id", "monitoring-distribution-001")) @@ -98,17 +98,17 @@ .andExpect(jsonPath("$.data.unassociatedEquipment[1].pointStatus").value("WARNING")) .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003")) .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE")) - .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(6))) + .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(8))) .andExpect(jsonPath("$.data.pointStatusDistribution[0].status").value("ALARM")) .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1)) .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING")) - .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2)) + .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1)) .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER")) .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1)) .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL")) .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2)) .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE")) - .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2)) + .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1)) .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY")) .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1)) .andExpect(jsonPath("$.data.districtDistribution", hasSize(3))) @@ -194,9 +194,9 @@ .andExpect(status().isOk()) .andExpect(jsonPath("$.data.pagination.total").value(2)) .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING")) - .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0)) - .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2)) - .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(0)); + .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1)) + .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1)) + .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1)); mockMvc.perform(get(ENDPOINT)) .andExpect(status().isOk()) @@ -253,7 +253,7 @@ .andExpect(status().isOk()) .andExpect(jsonPath("$.data.pagination.total").value(0)) .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0))) - .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0)) + .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1)) .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE")) .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0)) .andExpect(jsonPath("$.data.freshness.status").value("EMPTY")) @@ -279,7 +279,7 @@ .andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST")) .andExpect(jsonPath("$.data.message").value( - "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY")); + "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED")); verifyNoInteractions(adapter); } @@ -351,7 +351,7 @@ void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() { CockpitMonitoringDistributionResponse initial = service.load( "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT); - assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE)); + assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE)); assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL)); CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta( @@ -360,7 +360,7 @@ "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00", "2026-09-02T10:05:00+08:00")); - assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE)); + assertEquals(0, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE)); assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL)); CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta( @@ -369,7 +369,7 @@ "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00", "2026-09-02T10:04:00+08:00")); - assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE)); + assertEquals(0, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE)); assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL)); CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta( @@ -378,7 +378,7 @@ "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00", "2026-09-02T10:06:00+08:00")); - assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE)); + assertEquals(0, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE)); assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL)); } @@ -455,7 +455,7 @@ assertEquals(0, all.getExcluded().getInvalidCoordinateCount()); assertEquals("READY", all.getFreshness().getReason()); assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM)); - assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL)); + assertEquals(0, statusCount(all, CockpitMonitoringPointStatus.NORMAL)); CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null); assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes()); --- /dev/null +++ b/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 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 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 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 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 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); + } +} --- /dev/null +++ b/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 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()); + } +} --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java @@ -257,7 +257,9 @@ type == null || type.getId() == null ? null : String.valueOf(type.getId()), device.getLongitude(), device.getLatitude(), - false)); + false, + deviceFactResolver.resolve(device, type == null ? null : type.getTypeName()) + .getPublishedDeviceStatus())); } List sourceTypes = types.values().stream() --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java @@ -105,9 +105,15 @@ private final BigDecimal longitude; private final BigDecimal latitude; private final boolean coordinateVerified; + private final String deviceStatus; public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode, BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) { + this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified, null); + } + + public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode, + BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified, String deviceStatus) { this.deviceCode = deviceCode; this.deviceName = deviceName; this.district = district; @@ -115,8 +121,10 @@ this.longitude = longitude; this.latitude = latitude; this.coordinateVerified = coordinateVerified; + this.deviceStatus = deviceStatus; } + public String getDeviceStatus() { return deviceStatus; } public String getDeviceCode() { return deviceCode; } public String getDeviceName() { return deviceName; } public Integer getDistrict() { return district; } --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java @@ -1,6 +1,7 @@ package com.zksy.cockpit.map; import java.util.List; +import com.zksy.cockpit.point.CockpitPointStatusQuery; public class CockpitMapSnapshotResponse { private final String requestId; @@ -16,13 +17,14 @@ private final List unassociatedEquipment; private final CockpitCoordinateValidationSummary coordinateValidation; private final MapFilter mapFilter; + private final List pointStatusDistribution; public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize, String snapshotAt, int total, String batchUpdatedAt, boolean complete, List monitoringPoints, List unassociatedEquipment, CockpitCoordinateValidationSummary coordinateValidation, - MapFilter mapFilter) { + MapFilter mapFilter, List pointStatusDistribution) { this.requestId = requestId; this.apiVersion = apiVersion; this.readOnly = readOnly; @@ -36,6 +38,7 @@ this.unassociatedEquipment = unassociatedEquipment; this.coordinateValidation = coordinateValidation; this.mapFilter = mapFilter; + this.pointStatusDistribution = pointStatusDistribution; } public String getRequestId() { return requestId; } @@ -50,6 +53,7 @@ public List getMonitoringPoints() { return monitoringPoints; } public List getUnassociatedEquipment() { return unassociatedEquipment; } public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; } + public List getPointStatusDistribution() { return pointStatusDistribution; } public MapFilter getMapFilter() { return mapFilter; } /** @@ -66,10 +70,11 @@ private final String districtName; private final String districtFilter; private final String deviceType; + private final String pointStatus; private final List pointCodes; private final List deviceCodes; - public MapFilter(String industry, Integer district, String districtFilter, String deviceType, + public MapFilter(String industry, Integer district, String districtFilter, String deviceType, String pointStatus, List pointCodes, List deviceCodes) { this.industry = industry; this.district = district; @@ -77,6 +82,7 @@ this.districtName = district == null ? null : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district); this.deviceType = deviceType; + this.pointStatus = pointStatus; this.pointCodes = pointCodes; this.deviceCodes = deviceCodes; } @@ -88,6 +94,7 @@ public String getDistrictName() { return districtName; } /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */ public String getDistrictFilter() { return districtFilter; } + public String getPointStatus() { return pointStatus; } public String getDeviceType() { return deviceType; } public List getPointCodes() { return pointCodes; } public List getDeviceCodes() { return deviceCodes; } --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java @@ -11,6 +11,7 @@ import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot; import com.zksy.cockpit.point.CockpitMonitoringPointStatus; import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver; +import com.zksy.cockpit.point.CockpitPointStatusQuery; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -55,6 +56,12 @@ */ public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt, String industry, String district, String deviceType) { + return load(requestId, page, pageSize, snapshotAt, industry, district, deviceType, null); + } + + public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt, + String industry, String district, String deviceType, String pointStatus) { + CockpitPointStatusQuery statusQuery = CockpitPointStatusQuery.parse(pointStatus); int safePage = page == null ? 1 : page; int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize; validatePagination(safePage, safePageSize, snapshotAt); @@ -86,6 +93,15 @@ snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType); List equipment = unassociatedEquipment( snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType); + CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution(); + monitoringPoints(snapshot, types, null, CockpitDistrict.parseQuery(null), null) + .forEach(point -> global.addPoint(point.getPointStatus())); + unassociatedEquipment(snapshot, types, null, CockpitDistrict.parseQuery(null), null) + .forEach(device -> global.addUnassociatedDevice()); + points = points.stream().filter(point -> statusQuery.matchesPoint(point.getPointStatus())) + .collect(Collectors.toList()); + equipment = equipment.stream().filter(device -> statusQuery.matchesDevice(device.getDeviceStatus())) + .collect(Collectors.toList()); int total = points.size() + equipment.size(); int fromIndex = Math.min((safePage - 1) * safePageSize, total); int toIndex = Math.min(fromIndex + safePageSize, total); @@ -109,7 +125,8 @@ pagePoints, pageEquipment, coordinateValidation(points, equipment), - mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment)); + mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, statusQuery.echo(), points, equipment), + global.rows()); } public CockpitErrorPayload invalidRequest(String requestId, String message) { @@ -237,6 +254,7 @@ } return snapshot.getMonitoringPoints().stream() .filter(Objects::nonNull) + .filter(source -> !isBlank(source.getPointCode())) .filter(source -> matchesFilters( industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types)) .map(source -> monitoringPoint(source, types)) @@ -250,7 +268,8 @@ Map types) { List devices = source.getDevices() == null ? Collections.emptyList() - : source.getDevices(); + : source.getDevices().stream().filter(Objects::nonNull) + .filter(device -> !isBlank(device.getDeviceCode())).collect(Collectors.toList()); CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices); List deviceCodes = devices.stream() .filter(Objects::nonNull) @@ -287,8 +306,8 @@ Set associatedDeviceCodes = associatedDeviceCodes(snapshot); return snapshot.getSmartDevices().stream() .filter(Objects::nonNull) - .filter(device -> device.getDeviceCode() == null - || !associatedDeviceCodes.contains(device.getDeviceCode())) + .filter(device -> !isBlank(device.getDeviceCode())) + .filter(device -> !associatedDeviceCodes.contains(device.getDeviceCode().trim())) .filter(source -> matchesFilters( industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types)) .map(source -> unassociatedEquipment(source, types)) @@ -302,11 +321,15 @@ return Collections.emptySet(); } return snapshot.getMonitoringPoints().stream() + .filter(Objects::nonNull) .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices) .filter(Objects::nonNull) .flatMap(List::stream) + .filter(Objects::nonNull) .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode) .filter(Objects::nonNull) + .map(String::trim) + .filter(code -> !code.isEmpty()) .collect(Collectors.toSet()); } @@ -324,11 +347,12 @@ source.getLongitude(), source.getLatitude(), CockpitCoordinateValidator.validate( - source.getLongitude(), source.getLatitude(), source.isCoordinateVerified())); + source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()), + CockpitPointStatusQuery.deviceBucket(source.getDeviceStatus())); } private static CockpitMapSnapshotResponse.MapFilter mapFilter( - String industry, CockpitDistrict.Query district, String deviceType, + String industry, CockpitDistrict.Query district, String deviceType, String pointStatus, List points, List equipment) { Set pointCodes = new LinkedHashSet<>(); @@ -354,7 +378,7 @@ industry, district.active() ? district.code() : null, !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"), - deviceType, sorted(pointCodes), sorted(deviceCodes)); + deviceType, pointStatus, sorted(pointCodes), sorted(deviceCodes)); } private static List sorted(Set values) { --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java @@ -15,10 +15,12 @@ private final BigDecimal longitude; private final BigDecimal latitude; private final CockpitCoordinateValidation coordinateValidation; + private final String deviceStatus; public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName, Integer district, String industry, String typeCode, String typeName, - BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) { + BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, + String deviceStatus) { this.deviceCode = deviceCode; this.deviceName = deviceName; this.district = CockpitDistrict.codeOf(district); @@ -28,8 +30,10 @@ this.longitude = longitude; this.latitude = latitude; this.coordinateValidation = coordinateValidation; + this.deviceStatus = deviceStatus; } + public String getDeviceStatus() { return deviceStatus; } public String getDeviceCode() { return deviceCode; } public String getDeviceName() { return deviceName; } /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */ --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java @@ -137,7 +137,8 @@ fact.isWarning(), fact.isWorkOrderInProgress(), fact.getStatusReasons(), - fact.getStatusTime())); + fact.getStatusTime(), + fact.isStatusPublished())); } List sourceTypes = types.values().stream() --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java @@ -3,6 +3,7 @@ import com.zksy.cockpit.foundation.CockpitCoordinateValidation; import com.zksy.cockpit.foundation.CockpitDistrict; import com.zksy.cockpit.point.CockpitMonitoringPointStatus; +import com.zksy.cockpit.point.CockpitPointStatusQuery; import java.math.BigDecimal; import java.util.List; @@ -19,7 +20,7 @@ private final List districtDistribution; private final List industryDistribution; private final List deviceTypeDistribution; - private final List pointStatusDistribution; + private final List pointStatusDistribution; private final MapFilter mapFilter; private final Pagination pagination; private final List monitoringPoints; @@ -38,7 +39,7 @@ List districtDistribution, List industryDistribution, List deviceTypeDistribution, - List pointStatusDistribution, + List pointStatusDistribution, MapFilter mapFilter, Pagination pagination, List monitoringPoints, @@ -74,7 +75,7 @@ public List getDistrictDistribution() { return districtDistribution; } public List getIndustryDistribution() { return industryDistribution; } public List getDeviceTypeDistribution() { return deviceTypeDistribution; } - public List getPointStatusDistribution() { return pointStatusDistribution; } + public List getPointStatusDistribution() { return pointStatusDistribution; } public MapFilter getMapFilter() { return mapFilter; } public Pagination getPagination() { return pagination; } public List getMonitoringPoints() { return monitoringPoints; } @@ -220,19 +221,6 @@ public String getName() { return name; } } - public static class PointStatusCount { - private final CockpitMonitoringPointStatus status; - private final int count; - - public PointStatusCount(CockpitMonitoringPointStatus status, int count) { - this.status = status; - this.count = count; - } - - public CockpitMonitoringPointStatus getStatus() { return status; } - public int getCount() { return count; } - } - /** * 生效范围回显。片区维度自执行票 12 起为 {@code district}: * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter} @@ -368,6 +356,9 @@ } public String getDeviceCode() { return deviceCode; } + public String getDeviceStatus() { + return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name()); + } public String getDeviceName() { return deviceName; } public Integer getDistrict() { return district; } public String getDistrictName() { return districtName; } --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java @@ -11,6 +11,7 @@ import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot; import com.zksy.cockpit.point.CockpitMonitoringPointStatus; import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver; +import com.zksy.cockpit.point.CockpitPointStatusQuery; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -32,9 +33,8 @@ /** * Read-only aggregation for 运行监测 / 监测分布. * - *

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. + *

业务对象参与计数、台账与筛选,坐标校验仅决定能否上图。 + * 八桶分布取全局未筛全集(六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备)。 */ @Service public class CockpitMonitoringDistributionService { @@ -74,7 +74,7 @@ Integer pageSize, String snapshotAt) { String selectedIndustry = resolveIndustry(industry); - CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus); + CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus); CockpitDistrict.Query selectedDistrict = resolveDistrict(district); int safePage = page == null ? 1 : page; int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize; @@ -102,7 +102,7 @@ String snapshotAt, CockpitMonitoringDistributionDeviceStatusDelta delta) { String selectedIndustry = resolveIndustry(industry); - CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus); + CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus); CockpitDistrict.Query selectedDistrict = resolveDistrict(district); int safePage = page == null ? 1 : page; int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize; @@ -231,7 +231,7 @@ String requestId, String selectedIndustry, String deviceType, - CockpitMonitoringPointStatus selectedStatus, + CockpitPointStatusQuery selectedStatus, CockpitDistrict.Query selectedDistrict, int page, int pageSize) { @@ -241,22 +241,14 @@ int unknownTypeCount = 0; int typeKindMismatchCount = 0; int nonFormalIndustryCount = 0; - // Compatibility field: coordinates no longer exclude business objects. int invalidCoordinateCount = 0; List pointMarkers = new ArrayList<>(); List deviceMarkers = new ArrayList<>(); Set pointCodes = new LinkedHashSet<>(); Set deviceCodes = new LinkedHashSet<>(); - // Association is a source fact, independent of all display filters. - Set 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 associatedCodes = new LinkedHashSet<>(); + CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution(); if (snapshot.getMonitoringPoints() != null) { for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) { @@ -264,54 +256,39 @@ continue; } CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode()); - String mismatch = classify(type); - List 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 effective = new ArrayList<>(); - for (CockpitMonitoringPointDeviceSnapshot device : devices) { - if (device == null || isBlank(device.getDeviceCode())) { - continue; - } + for (CockpitMonitoringPointDeviceSnapshot device : safeDevices(point.getDevices())) { + if (device == null || isBlank(device.getDeviceCode())) continue; + associatedCodes.add(device.getDeviceCode().trim()); effective.add(effectiveDevice(device)); } CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective); - if (selectedStatus != null && selectedStatus != status) { + global.addPoint(status); + String mismatch = classify(type); + boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && effective.isEmpty(); + if (mismatch != null && !emptyPointWithUnknownType) { + if (UNKNOWN_TYPE.equals(mismatch)) unknownTypeCount++; + else if (TYPE_KIND_MISMATCH.equals(mismatch)) typeKindMismatchCount++; + else nonFormalIndustryCount++; continue; } + String markerIndustry = industryOf(type); + String markerTypeCode = point.getTypeCode(); + Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict()); + if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict, + markerIndustry, point.getTypeCode(), markerDistrict, type) + || !selectedStatus.matchesPoint(status)) continue; + CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate( + point.getLongitude(), point.getLatitude(), point.isCoordinateVerified()); + deviceCodes.addAll(codes(effective)); pointCodes.add(point.getPointCode().trim()); - deviceCodes.addAll(codes(effective)); pointMarkers.add(new Marker( point.getPointCode().trim(), point.getPointName(), markerDistrict, markerIndustry, markerTypeCode, - type == null ? "未知" : type.getName(), + type == null ? null : type.getName(), status, CockpitMonitoringPointStatusResolver.summaryReasons(status, effective), point.getLongitude(), @@ -324,39 +301,30 @@ if (snapshot.getSmartDevices() != null) { for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) { - if (device == null || isBlank(device.getDeviceCode()) - || associatedDeviceCodes.contains(device.getDeviceCode().trim())) { + if (device == null || isBlank(device.getDeviceCode())) { continue; } + if (associatedCodes.contains(device.getDeviceCode().trim())) continue; + global.addUnassociatedDevice(); CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode()); String mismatch = classify(type); if (mismatch != null) { - if (UNKNOWN_TYPE.equals(mismatch)) { - unknownTypeCount++; - } else if (TYPE_KIND_MISMATCH.equals(mismatch)) { - typeKindMismatchCount++; - } else { - nonFormalIndustryCount++; - } + 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())) { - continue; - } + String markerIndustry = industryOf(type); + String markerTypeCode = device.getTypeCode(); + Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict()); + if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict, + markerIndustry, device.getTypeCode(), markerDistrict, type)) continue; CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate( device.getLongitude(), device.getLatitude(), device.isCoordinateVerified()); - // 设备自身片区(equipment_base.district)即权威来源 - Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict()); - if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) { - continue; - } CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot( device.getDeviceCode().trim(), device.getDeviceName(), - type.getName(), + type == null ? null : type.getName(), device.getLongitude(), device.getLatitude(), validation, @@ -365,23 +333,24 @@ device.isWarning(), device.isWorkOrderInProgress(), device.getStatusReasons(), - device.getStatusTime()); + device.getStatusTime(), device.isStatusPublished()); CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact); - CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.deviceStatus(applied); - if (selectedStatus != null && selectedStatus != status) { - continue; - } + String publishedStatus = publishedStatus(applied); + CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus) + ? null : CockpitMonitoringPointStatus.valueOf(publishedStatus); + if (!selectedStatus.matchesDevice(publishedStatus)) continue; deviceCodes.add(applied.getDeviceCode().trim()); deviceMarkers.add(new Marker( applied.getDeviceCode().trim(), applied.getDeviceName(), markerDistrict, - type.getIndustry(), - type.getCode(), - type.getName(), + markerIndustry, + markerTypeCode, + type == null ? null : type.getName(), status, - CockpitMonitoringPointStatusResolver.summaryReasons( - status, Collections.singletonList(applied)), + status == null ? applied.getStatusReasons() + : CockpitMonitoringPointStatusResolver.summaryReasons( + status, Collections.singletonList(applied)), device.getLongitude(), device.getLatitude(), validation, @@ -397,18 +366,15 @@ Map byDistrict = new LinkedHashMap<>(); Map byIndustry = new LinkedHashMap<>(); Map byType = new LinkedHashMap<>(); - int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length]; for (Marker marker : pointMarkers) { accumulate(byDistrict, marker.district, marker); accumulate(byIndustry, marker.industry, marker); accumulate(byType, marker.typeCode, marker); - statusCounts[marker.status.ordinal()]++; } for (Marker marker : deviceMarkers) { accumulate(byDistrict, marker.district, marker); accumulate(byIndustry, marker.industry, marker); accumulate(byType, marker.typeCode, marker); - statusCounts[marker.status.ordinal()]++; } List districtRows = @@ -417,12 +383,7 @@ industryRows(byIndustry); List typeRows = typeRows(byType); - List statusRows = - new ArrayList<>(); - for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) { - statusRows.add(new CockpitMonitoringDistributionResponse.PointStatusCount( - status, statusCounts[status.ordinal()])); - } + List statusRows = global.rows(); int total = pointMarkers.size() + deviceMarkers.size(); int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize; @@ -468,7 +429,7 @@ new CockpitMonitoringDistributionResponse.MapFilter( selectedIndustry, isBlank(deviceType) ? null : deviceType.trim(), - selectedStatus == null ? null : selectedStatus.name(), + selectedStatus.echo(), // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot) selectedDistrict.code(), !selectedDistrict.active() ? "NONE" @@ -502,7 +463,10 @@ source.isWarning(), source.isWorkOrderInProgress(), source.getStatusReasons(), - source.getStatusTime()); + source.getStatusTime(), + runtime == null || runtime.serverUpdatedAt == null + ? source.isStatusPublished() + : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState)); } /** @@ -637,18 +601,20 @@ return query; } - private static CockpitMonitoringPointStatus resolvePointStatus(String pointStatus) { - if (isBlank(pointStatus)) { - return null; - } - String resolved = pointStatus.trim(); - for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) { - if (status.name().equals(resolved)) { - return status; - } - } - throw new CockpitInvalidRequestException( - "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY"); + private String industryOf(CockpitMonitoringDistributionSnapshot.SourceType type) { + return classify(type) == null ? type.getIndustry() : null; + } + + private static boolean matchesFilters(String industry, String deviceType, CockpitDistrict.Query district, + String actualIndustry, String typeCode, Integer actualDistrict, + CockpitMonitoringDistributionSnapshot.SourceType type) { + return (industry == null || industry.equals(actualIndustry)) + && (isBlank(deviceType) || (type != null && deviceType.trim().equals(typeCode))) + && CockpitDistrict.matches(district, actualDistrict); + } + + private static String publishedStatus(CockpitMonitoringPointDeviceSnapshot device) { + return CockpitPointStatusQuery.deviceBucket(device.getPublishedDeviceStatus()); } private static void validatePagination(int page, int pageSize, String snapshotAt) { @@ -822,7 +788,7 @@ } else { deviceCount++; } - statusCounts[marker.status.ordinal()]++; + if (marker.status != null) statusCounts[marker.status.ordinal()]++; if (typeName == null) { typeName = marker.typeName; } --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java @@ -173,6 +173,7 @@ private final boolean workOrderInProgress; private final List statusReasons; private final String statusTime; + private final boolean statusPublished; public SourceSmartDevice( String deviceCode, @@ -188,6 +189,14 @@ boolean workOrderInProgress, List statusReasons, String statusTime) { + this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified, + online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true); + } + + public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode, + BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified, + boolean online, boolean alarm, boolean warning, boolean workOrderInProgress, + List statusReasons, String statusTime, boolean statusPublished) { this.deviceCode = deviceCode; this.deviceName = deviceName; this.district = district; @@ -201,6 +210,7 @@ this.workOrderInProgress = workOrderInProgress; this.statusReasons = statusReasons; this.statusTime = statusTime; + this.statusPublished = statusPublished; } public String getDeviceCode() { @@ -252,6 +262,8 @@ return statusReasons; } + public boolean isStatusPublished() { return statusPublished; } + public String getStatusTime() { return statusTime; } --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java +++ b/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.QueryWrapper; import com.zksy.base.domain.EquipmentBase; import com.zksy.base.domain.EquipmentStatus; +import com.zksy.cockpit.foundation.CockpitCoordinateValidator; import com.zksy.base.service.EquipmentStatusService; import com.zksy.base.warning.domain.EarlyWarning; import com.zksy.base.warning.service.EarlyWarningService; @@ -77,7 +78,7 @@ deviceTypeName, device.getLongitude(), device.getLatitude(), - false, + CockpitCoordinateValidator.validate(device.getLongitude(), device.getLatitude(), false), online, alarm, hasOpenWarning(deviceCode), @@ -86,7 +87,8 @@ status == null || status.getStatusUpdateTime() == null ? null : TIME_FORMATTER.format(status.getStatusUpdateTime() - .atZone(BUSINESS_ZONE).toOffsetDateTime())); + .atZone(BUSINESS_ZONE).toOffsetDateTime()), + status != null); } private boolean hasOpenWarning(String deviceCode) { --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java @@ -19,6 +19,7 @@ private final boolean workOrderInProgress; private final List statusReasons; private final String statusTime; + private final boolean statusPublished; public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName, boolean online, boolean alarm, boolean warning, boolean workOrderInProgress, @@ -40,6 +41,14 @@ BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean online, boolean alarm, boolean warning, boolean workOrderInProgress, List 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 statusReasons, String statusTime, boolean statusPublished) { this.deviceCode = deviceCode; this.deviceName = deviceName; this.deviceTypeName = deviceTypeName; @@ -52,6 +61,7 @@ this.workOrderInProgress = workOrderInProgress; this.statusReasons = statusReasons; this.statusTime = statusTime; + this.statusPublished = statusPublished; } public String getDeviceCode() { return deviceCode; } @@ -66,4 +76,9 @@ public boolean isWorkOrderInProgress() { return workOrderInProgress; } public List getStatusReasons() { return statusReasons; } public String getStatusTime() { return statusTime; } + public boolean isStatusPublished() { return statusPublished; } + /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */ + public String getPublishedDeviceStatus() { + return statusPublished ? CockpitMonitoringPointStatusResolver.deviceStatus(this).name() : null; + } } --- /dev/null +++ b/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 BUCKETS = Collections.unmodifiableList(Arrays.asList( + "ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE", "EMPTY", "UNKNOWN", "UNASSOCIATED")); + private static final List DEVICE_STATUSES = Arrays.asList( + "ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE"); + private final List selected; + + private CockpitPointStatusQuery(List selected) { + this.selected = selected; + } + + public static CockpitPointStatusQuery parse(String raw) { + Set 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 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 rows() { + List rows = new ArrayList<>(); + for (int index = 0; index < BUCKETS.size(); index++) { + rows.add(new Count(BUCKETS.get(index), counts[index])); + } + return rows; + } + } +}