point-status-eight-buckets.patch 71 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305
  1. --- a/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  2. +++ b/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  3. @@ -37,9 +37,11 @@
  4. @RequestParam(value = "industry", required = false) String industry,
  5. @RequestParam(value = "district", required = false) String district,
  6. @RequestParam(value = "deviceType", required = false) String deviceType,
  7. + @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
  8. @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
  9. CockpitMapSnapshotResponse response = mapSnapshotService.load(
  10. - resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType);
  11. + resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType,
  12. + pointStatus == null ? null : String.join(",", pointStatus));
  13. return ResponseEntity.ok(AjaxResult.success(response));
  14. }
  15. --- a/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java
  16. +++ b/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java
  17. @@ -33,7 +33,7 @@
  18. public ResponseEntity<AjaxResult> monitoringDistribution(
  19. @RequestParam(value = "industry", required = false) String industry,
  20. @RequestParam(value = "deviceType", required = false) String deviceType,
  21. - @RequestParam(value = "pointStatus", required = false) String pointStatus,
  22. + @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
  23. // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
  24. @RequestParam(value = "district", required = false) String district,
  25. @RequestParam(value = "page", defaultValue = "1") Integer page,
  26. @@ -45,7 +45,7 @@
  27. resolveRequestId(requestId),
  28. industry,
  29. deviceType,
  30. - pointStatus,
  31. + pointStatus == null ? null : String.join(",", pointStatus),
  32. district,
  33. page,
  34. pageSize,
  35. --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  36. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  37. @@ -24,6 +24,7 @@
  38. import static org.junit.jupiter.api.Assertions.assertEquals;
  39. import static org.junit.jupiter.api.Assertions.assertFalse;
  40. +import static org.junit.jupiter.api.Assertions.assertNull;
  41. import static org.junit.jupiter.api.Assertions.assertTrue;
  42. import static org.mockito.ArgumentMatchers.any;
  43. import static org.mockito.Mockito.mock;
  44. @@ -131,6 +132,26 @@
  45. assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中");
  46. }
  47. + @Test
  48. + void publishedOfflineAndMissingStatusAreDistinctAndDoNotDependOnTimestamp() {
  49. + EquipmentStatus offline = status(1, 0, 0);
  50. + offline.setStatusUpdateTime(null);
  51. + when(equipmentStatusService.getByDeviceCode("DEV-PUBLISHED")).thenReturn(offline);
  52. + assertEquals("OFFLINE", resolver.resolve(device("DEV-PUBLISHED"), null).getPublishedDeviceStatus());
  53. + assertNull(resolver.resolve(device("DEV-MISSING"), null).getPublishedDeviceStatus());
  54. + }
  55. +
  56. + @Test
  57. + void cancelledStatusSevenNeverPublishesWorkOrderState() {
  58. + when(equipmentStatusService.getByDeviceCode("DEV-CANCELLED")).thenReturn(status(1, 0, 1));
  59. + when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(7)));
  60. + CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-CANCELLED"), null);
  61. + assertFalse(fact.isWorkOrderInProgress());
  62. + assertEquals("NORMAL", fact.getPublishedDeviceStatus());
  63. + assertEquals(CockpitMonitoringPointStatus.NORMAL,
  64. + CockpitMonitoringPointStatusResolver.resolve(Collections.singletonList(fact)));
  65. + }
  66. +
  67. // ---- fixtures ----
  68. private static EquipmentBase device(String code) {
  69. --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  70. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  71. @@ -63,7 +63,7 @@
  72. }
  73. @Test
  74. - void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception {
  75. + void distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter() throws Exception {
  76. mockMvc.perform(get(ENDPOINT)
  77. .param("snapshotAt", SNAPSHOT_AT)
  78. .header("X-Request-Id", "monitoring-distribution-001"))
  79. @@ -98,17 +98,17 @@
  80. .andExpect(jsonPath("$.data.unassociatedEquipment[1].pointStatus").value("WARNING"))
  81. .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
  82. .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE"))
  83. - .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(6)))
  84. + .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(8)))
  85. .andExpect(jsonPath("$.data.pointStatusDistribution[0].status").value("ALARM"))
  86. .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
  87. .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING"))
  88. - .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2))
  89. + .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
  90. .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
  91. .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
  92. .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
  93. .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
  94. .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
  95. - .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2))
  96. + .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1))
  97. .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
  98. .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
  99. .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
  100. @@ -194,9 +194,9 @@
  101. .andExpect(status().isOk())
  102. .andExpect(jsonPath("$.data.pagination.total").value(2))
  103. .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING"))
  104. - .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
  105. - .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2))
  106. - .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(0));
  107. + .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
  108. + .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
  109. + .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
  110. mockMvc.perform(get(ENDPOINT))
  111. .andExpect(status().isOk())
  112. @@ -253,7 +253,7 @@
  113. .andExpect(status().isOk())
  114. .andExpect(jsonPath("$.data.pagination.total").value(0))
  115. .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
  116. - .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
  117. + .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
  118. .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE"))
  119. .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
  120. .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
  121. @@ -279,7 +279,7 @@
  122. .andExpect(jsonPath("$.code").value(400))
  123. .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
  124. .andExpect(jsonPath("$.data.message").value(
  125. - "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY"));
  126. + "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED"));
  127. verifyNoInteractions(adapter);
  128. }
  129. @@ -351,7 +351,7 @@
  130. void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
  131. CockpitMonitoringDistributionResponse initial = service.load(
  132. "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
  133. - assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
  134. + assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
  135. assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
  136. CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
  137. @@ -360,7 +360,7 @@
  138. "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
  139. "2026-09-02T10:05:00+08:00"));
  140. - assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
  141. + assertEquals(0, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
  142. assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
  143. CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
  144. @@ -369,7 +369,7 @@
  145. "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
  146. "2026-09-02T10:04:00+08:00"));
  147. - assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
  148. + assertEquals(0, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
  149. assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
  150. CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
  151. @@ -378,7 +378,7 @@
  152. "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
  153. "2026-09-02T10:06:00+08:00"));
  154. - assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
  155. + assertEquals(0, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
  156. assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
  157. }
  158. @@ -455,7 +455,7 @@
  159. assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
  160. assertEquals("READY", all.getFreshness().getReason());
  161. assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
  162. - assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
  163. + assertEquals(0, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
  164. CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
  165. assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
  166. --- /dev/null
  167. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java
  168. @@ -0,0 +1,191 @@
  169. +package com.zksy.web.controller.cockpit;
  170. +
  171. +import com.fasterxml.jackson.databind.JsonNode;
  172. +import com.fasterxml.jackson.databind.ObjectMapper;
  173. +import com.zksy.cockpit.map.*;
  174. +import com.zksy.cockpit.monitoring.*;
  175. +import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
  176. +import org.junit.jupiter.api.BeforeEach;
  177. +import org.junit.jupiter.api.Test;
  178. +import org.springframework.test.web.servlet.MockMvc;
  179. +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
  180. +import org.springframework.test.web.servlet.setup.MockMvcBuilders;
  181. +
  182. +import java.math.BigDecimal;
  183. +import java.time.Clock;
  184. +import java.time.Instant;
  185. +import java.time.ZoneOffset;
  186. +import java.util.Arrays;
  187. +import java.util.Collections;
  188. +import java.util.List;
  189. +
  190. +import static org.junit.jupiter.api.Assertions.assertEquals;
  191. +import static org.mockito.Mockito.*;
  192. +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  193. +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  194. +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  195. +
  196. +/** 同一事实集穿过两个真实控制器/服务,锁定 HTTP 绑定、回显、分页前计数和匹配语义。 */
  197. +class CockpitPointStatusContractTest {
  198. + private static final String TOKEN = "2026-09-02T10:00:00+08:00";
  199. + private static final String MAP = "/api/cockpit/v1/map-snapshot";
  200. + private static final String DISTRIBUTION = "/api/cockpit/v1/monitoring/distribution";
  201. + private final ObjectMapper json = new ObjectMapper();
  202. + private MockMvc mvc;
  203. + private CockpitMapSnapshotAdapter mapAdapter;
  204. + private CockpitMonitoringDistributionAdapter distributionAdapter;
  205. +
  206. + @BeforeEach
  207. + void setUp() {
  208. + Clock clock = Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
  209. + mapAdapter = mock(CockpitMapSnapshotAdapter.class);
  210. + distributionAdapter = mock(CockpitMonitoringDistributionAdapter.class);
  211. + when(mapAdapter.load(any())).thenReturn(mapFixture());
  212. + when(distributionAdapter.load(any())).thenReturn(distributionFixture());
  213. + mvc = MockMvcBuilders.standaloneSetup(
  214. + new CockpitMapSnapshotController(new CockpitMapSnapshotService(mapAdapter, clock)),
  215. + new CockpitMonitoringDistributionController(
  216. + new CockpitMonitoringDistributionService(distributionAdapter, clock))).build();
  217. + }
  218. +
  219. + @Test
  220. + void csvAndRepeatedParametersAreOrDeduplicatedAndEchoedIdentically() throws Exception {
  221. + for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
  222. + JsonNode result = read(endpoint, "WARNING,ALARM", "ALARM");
  223. + assertEquals("ALARM,WARNING", result.path("mapFilter").path("pointStatus").asText());
  224. + assertEquals(3, total(result));
  225. + assertEquals(2, result.path("monitoringPoints").size());
  226. + assertEquals(1, result.path("unassociatedEquipment").size());
  227. + assertEquals("D-A", result.path("unassociatedEquipment").get(0).path("deviceCode").asText());
  228. + assertEquals(3, result.path("mapFilter").path("deviceCodes").size());
  229. + }
  230. + assertEquals(read(MAP, "ALARM,WARNING").path("mapFilter"),
  231. + read(DISTRIBUTION, "WARNING", "ALARM").path("mapFilter"));
  232. + }
  233. +
  234. + @Test
  235. + void globalEightBucketsDoNotChangeWithAnyFilterOrPageAndNeverDoubleCountDevices() throws Exception {
  236. + JsonNode baseline = read(MAP);
  237. + JsonNode counts = baseline.path("pointStatusDistribution");
  238. + assertEquals(8, counts.size());
  239. + assertEquals(1, count(counts, "ALARM"));
  240. + assertEquals(1, count(counts, "WARNING"));
  241. + assertEquals(1, count(counts, "NORMAL"));
  242. + assertEquals(1, count(counts, "EMPTY"));
  243. + assertEquals(0, count(counts, "UNKNOWN"));
  244. + assertEquals(4, count(counts, "UNASSOCIATED"));
  245. + int sum = 0;
  246. + for (JsonNode row : counts) sum += row.path("count").asInt();
  247. + assertEquals(8, sum);
  248. + for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
  249. + assertEquals(8, total(read(endpoint)));
  250. + assertEquals(counts, read(endpoint, "ALARM").path("pointStatusDistribution"));
  251. + JsonNode filtered = response(get(endpoint).param("pointStatus", "ALARM")
  252. + .param("industry", "GAS").param("district", "0").param("deviceType", "GAS_SENSOR")
  253. + .param("pageSize", "1").param("page", "2").param("snapshotAt", TOKEN));
  254. + assertEquals(2, total(filtered));
  255. + assertEquals(counts, filtered.path("pointStatusDistribution"));
  256. + JsonNode empty = response(get(endpoint).param("deviceType", "NOT_A_TYPE"));
  257. + assertEquals(0, total(empty));
  258. + assertEquals(counts, empty.path("pointStatusDistribution"));
  259. + }
  260. + }
  261. +
  262. + @Test
  263. + void emptyUnknownAndUnassociatedUseTheirOwnObjectSemantics() throws Exception {
  264. + for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
  265. + JsonNode empty = read(endpoint, "EMPTY");
  266. + assertEquals(1, total(empty));
  267. + assertEquals("P-E", empty.path("monitoringPoints").get(0).path("pointCode").asText());
  268. + assertEquals(0, empty.path("unassociatedEquipment").size());
  269. + JsonNode unknown = read(endpoint, "UNKNOWN");
  270. + assertEquals(1, total(unknown));
  271. + assertEquals("D-U", unknown.path("unassociatedEquipment").get(0).path("deviceCode").asText());
  272. + assertEquals("UNKNOWN", unknown.path("unassociatedEquipment").get(0).path("deviceStatus").asText());
  273. + assertEquals(0, unknown.path("monitoringPoints").size());
  274. + assertEquals(4, total(read(endpoint, "UNASSOCIATED")));
  275. + JsonNode onlyAlarmDevices = read(endpoint, "UNASSOCIATED", "ALARM");
  276. + assertEquals(1, total(onlyAlarmDevices));
  277. + assertEquals(0, onlyAlarmDevices.path("monitoringPoints").size());
  278. + assertEquals(1, total(read(endpoint, "UNASSOCIATED,UNKNOWN")));
  279. + // 明确离线不能冒充 UNKNOWN;EMPTY 不适用于设备自身。
  280. + assertEquals("D-O", read(endpoint, "UNASSOCIATED,OFFLINE")
  281. + .path("unassociatedEquipment").get(0).path("deviceCode").asText());
  282. + assertEquals(0, total(read(endpoint, "UNASSOCIATED,EMPTY")));
  283. + }
  284. + }
  285. +
  286. + @Test
  287. + void invalidValuesIncludingOneBadRepeatedParameterAreRejectedBeforeReading() throws Exception {
  288. + for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
  289. + for (String value : Arrays.asList("BOGUS", "ALARM,BOGUS", "ALARM,", "EMPTY,,WARNING")) {
  290. + mvc.perform(get(endpoint).param("pointStatus", value))
  291. + .andExpect(status().isBadRequest())
  292. + .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
  293. + }
  294. + mvc.perform(get(endpoint).param("pointStatus", "ALARM", "BOGUS"))
  295. + .andExpect(status().isBadRequest())
  296. + .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
  297. + }
  298. + verifyNoInteractions(mapAdapter, distributionAdapter);
  299. + }
  300. +
  301. + private JsonNode read(String endpoint, String... statuses) throws Exception {
  302. + MockHttpServletRequestBuilder request = get(endpoint);
  303. + if (statuses.length > 0) request.param("pointStatus", statuses);
  304. + return response(request);
  305. + }
  306. +
  307. + private JsonNode response(MockHttpServletRequestBuilder request) throws Exception {
  308. + String content = mvc.perform(request).andExpect(status().isOk())
  309. + .andReturn().getResponse().getContentAsString();
  310. + return json.readTree(content).path("data");
  311. + }
  312. +
  313. + private static int total(JsonNode data) {
  314. + return data.has("pagination") ? data.path("pagination").path("total").asInt() : data.path("total").asInt();
  315. + }
  316. +
  317. + private static int count(JsonNode rows, String status) {
  318. + for (JsonNode row : rows) if (status.equals(row.path("status").asText())) return row.path("count").asInt();
  319. + throw new AssertionError("Missing bucket: " + status);
  320. + }
  321. +
  322. + private static List<CockpitMonitoringPointDeviceSnapshot> devices(String code) {
  323. + if ("P-E".equals(code)) return Collections.emptyList();
  324. + return Collections.singletonList(new CockpitMonitoringPointDeviceSnapshot(
  325. + "D-" + code, code, "传感器", true, "P-A".equals(code), "P-W".equals(code), false,
  326. + Collections.emptyList(), null)); // 缺状态时间不代表未发布。
  327. + }
  328. +
  329. + private static CockpitMapSnapshot mapFixture() {
  330. + List<CockpitMapSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
  331. + for (String code : Arrays.asList("P-A", "P-W", "P-E", "P-N")) {
  332. + points.add(new CockpitMapSnapshot.SourceMonitoringPoint(code, code, 0,
  333. + "P-E".equals(code) ? null : "GAS_SENSOR", null, null, false, devices(code)));
  334. + }
  335. + List<CockpitMapSnapshot.SourceSmartDevice> devices = new java.util.ArrayList<>();
  336. + for (String code : Arrays.asList("D-A", "D-N", "D-O", "D-U", "D-P-A")) {
  337. + devices.add(new CockpitMapSnapshot.SourceSmartDevice(code, code, 0, "GAS_SENSOR", null, null, false,
  338. + "D-A".equals(code) ? "ALARM" : "D-O".equals(code) ? "OFFLINE" : "D-U".equals(code) ? null : "NORMAL"));
  339. + }
  340. + return new CockpitMapSnapshot(TOKEN, Collections.singletonList(
  341. + new CockpitMapSnapshot.SourceType("GAS_SENSOR", "传感器", "GAS", "SMART_DEVICE")), points, devices);
  342. + }
  343. +
  344. + private static CockpitMonitoringDistributionSnapshot distributionFixture() {
  345. + List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
  346. + for (String code : Arrays.asList("P-A", "P-W", "P-E", "P-N")) {
  347. + points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(code, code, 0,
  348. + "P-E".equals(code) ? null : "GAS_SENSOR", null, null, false, devices(code)));
  349. + }
  350. + List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> devices = new java.util.ArrayList<>();
  351. + for (String code : Arrays.asList("D-A", "D-N", "D-O", "D-U", "D-P-A")) {
  352. + devices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(code, code, 0, "GAS_SENSOR",
  353. + null, null, false, !"D-O".equals(code), "D-A".equals(code), false, false,
  354. + Collections.emptyList(), null, !"D-U".equals(code)));
  355. + }
  356. + return new CockpitMonitoringDistributionSnapshot(TOKEN, TOKEN, Collections.singletonList(
  357. + new CockpitMonitoringDistributionSnapshot.SourceType("GAS_SENSOR", "传感器", "GAS", "SMART_DEVICE")), points, devices);
  358. + }
  359. +}
  360. --- /dev/null
  361. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java
  362. @@ -0,0 +1,46 @@
  363. +package com.zksy.web.controller.cockpit;
  364. +
  365. +import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
  366. +import com.zksy.cockpit.map.CockpitMapSnapshot;
  367. +import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
  368. +import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
  369. +import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
  370. +import com.zksy.framework.config.FastJson2JsonRedisSerializer;
  371. +import org.junit.jupiter.api.Test;
  372. +import java.nio.charset.StandardCharsets;
  373. +import java.util.Arrays;
  374. +import java.util.Collections;
  375. +import static org.junit.jupiter.api.Assertions.*;
  376. +
  377. +class CockpitPointStatusSnapshotSerializationTest {
  378. + private final FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
  379. +
  380. + @Test
  381. + void publishedAndMissingDeviceStatesSurviveTheRealRedisSerializer() {
  382. + CockpitMonitoringPointDeviceSnapshot unpublished = new CockpitMonitoringPointDeviceSnapshot(
  383. + "BOUND", "设备", null, null, null, CockpitCoordinateValidator.validate(null, null, false),
  384. + false, false, false, false, Collections.emptyList(), null, false);
  385. + CockpitMapSnapshot snapshot = new CockpitMapSnapshot("2026-09-02T10:00:00+08:00", Collections.emptyList(),
  386. + Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
  387. + "P", "点位", null, null, null, null, false, Collections.singletonList(unpublished))),
  388. + Arrays.asList(new CockpitMapSnapshot.SourceSmartDevice("ALARM", "报警", null, null, null, null, false, "ALARM"),
  389. + new CockpitMapSnapshot.SourceSmartDevice("UNKNOWN", "未发布", null, null, null, null, false, null)));
  390. + CockpitMapSnapshot restored = (CockpitMapSnapshot) serializer.deserialize(serializer.serialize(snapshot));
  391. + assertEquals("ALARM", restored.getSmartDevices().get(0).getDeviceStatus());
  392. + assertNull(restored.getSmartDevices().get(1).getDeviceStatus());
  393. + assertFalse(restored.getMonitoringPoints().get(0).getDevices().get(0).isStatusPublished());
  394. + // 新设备发布标记不改变既有点位六态聚合。
  395. + assertEquals(CockpitMonitoringPointStatus.OFFLINE,
  396. + CockpitMonitoringPointStatusResolver.resolve(restored.getMonitoringPoints().get(0).getDevices()));
  397. + }
  398. +
  399. + @Test
  400. + void legacySnapshotWithoutNewFieldsDoesNotInventDeviceStatus() {
  401. + String payload = "{\"@type\":\"com.zksy.cockpit.map.CockpitMapSnapshot\","
  402. + + "\"snapshotAt\":\"2026-09-02T10:00:00+08:00\",\"types\":[],\"monitoringPoints\":[],"
  403. + + "\"smartDevices\":[{\"deviceCode\":\"LEGACY\",\"deviceName\":\"旧设备\",\"coordinateVerified\":false}]}";
  404. + CockpitMapSnapshot restored = (CockpitMapSnapshot) serializer.deserialize(payload.getBytes(StandardCharsets.UTF_8));
  405. + assertEquals("LEGACY", restored.getSmartDevices().get(0).getDeviceCode());
  406. + assertNull(restored.getSmartDevices().get(0).getDeviceStatus());
  407. + }
  408. +}
  409. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  410. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  411. @@ -257,7 +257,9 @@
  412. type == null || type.getId() == null ? null : String.valueOf(type.getId()),
  413. device.getLongitude(),
  414. device.getLatitude(),
  415. - false));
  416. + false,
  417. + deviceFactResolver.resolve(device, type == null ? null : type.getTypeName())
  418. + .getPublishedDeviceStatus()));
  419. }
  420. List<CockpitMapSnapshot.SourceType> sourceTypes = types.values().stream()
  421. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  422. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  423. @@ -105,9 +105,15 @@
  424. private final BigDecimal longitude;
  425. private final BigDecimal latitude;
  426. private final boolean coordinateVerified;
  427. + private final String deviceStatus;
  428. public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
  429. BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
  430. + this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified, null);
  431. + }
  432. +
  433. + public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
  434. + BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified, String deviceStatus) {
  435. this.deviceCode = deviceCode;
  436. this.deviceName = deviceName;
  437. this.district = district;
  438. @@ -115,8 +121,10 @@
  439. this.longitude = longitude;
  440. this.latitude = latitude;
  441. this.coordinateVerified = coordinateVerified;
  442. + this.deviceStatus = deviceStatus;
  443. }
  444. + public String getDeviceStatus() { return deviceStatus; }
  445. public String getDeviceCode() { return deviceCode; }
  446. public String getDeviceName() { return deviceName; }
  447. public Integer getDistrict() { return district; }
  448. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  449. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  450. @@ -1,6 +1,7 @@
  451. package com.zksy.cockpit.map;
  452. import java.util.List;
  453. +import com.zksy.cockpit.point.CockpitPointStatusQuery;
  454. public class CockpitMapSnapshotResponse {
  455. private final String requestId;
  456. @@ -16,13 +17,14 @@
  457. private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
  458. private final CockpitCoordinateValidationSummary coordinateValidation;
  459. private final MapFilter mapFilter;
  460. + private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
  461. public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
  462. String snapshotAt, int total, String batchUpdatedAt, boolean complete,
  463. List<CockpitMonitoringPointSummary> monitoringPoints,
  464. List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
  465. CockpitCoordinateValidationSummary coordinateValidation,
  466. - MapFilter mapFilter) {
  467. + MapFilter mapFilter, List<CockpitPointStatusQuery.Count> pointStatusDistribution) {
  468. this.requestId = requestId;
  469. this.apiVersion = apiVersion;
  470. this.readOnly = readOnly;
  471. @@ -36,6 +38,7 @@
  472. this.unassociatedEquipment = unassociatedEquipment;
  473. this.coordinateValidation = coordinateValidation;
  474. this.mapFilter = mapFilter;
  475. + this.pointStatusDistribution = pointStatusDistribution;
  476. }
  477. public String getRequestId() { return requestId; }
  478. @@ -50,6 +53,7 @@
  479. public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
  480. public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
  481. public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
  482. + public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
  483. public MapFilter getMapFilter() { return mapFilter; }
  484. /**
  485. @@ -66,10 +70,11 @@
  486. private final String districtName;
  487. private final String districtFilter;
  488. private final String deviceType;
  489. + private final String pointStatus;
  490. private final List<String> pointCodes;
  491. private final List<String> deviceCodes;
  492. - public MapFilter(String industry, Integer district, String districtFilter, String deviceType,
  493. + public MapFilter(String industry, Integer district, String districtFilter, String deviceType, String pointStatus,
  494. List<String> pointCodes, List<String> deviceCodes) {
  495. this.industry = industry;
  496. this.district = district;
  497. @@ -77,6 +82,7 @@
  498. this.districtName = district == null ? null
  499. : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
  500. this.deviceType = deviceType;
  501. + this.pointStatus = pointStatus;
  502. this.pointCodes = pointCodes;
  503. this.deviceCodes = deviceCodes;
  504. }
  505. @@ -88,6 +94,7 @@
  506. public String getDistrictName() { return districtName; }
  507. /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
  508. public String getDistrictFilter() { return districtFilter; }
  509. + public String getPointStatus() { return pointStatus; }
  510. public String getDeviceType() { return deviceType; }
  511. public List<String> getPointCodes() { return pointCodes; }
  512. public List<String> getDeviceCodes() { return deviceCodes; }
  513. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  514. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  515. @@ -11,6 +11,7 @@
  516. import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
  517. import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
  518. import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
  519. +import com.zksy.cockpit.point.CockpitPointStatusQuery;
  520. import org.springframework.stereotype.Service;
  521. import java.math.BigDecimal;
  522. @@ -55,6 +56,12 @@
  523. */
  524. public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
  525. String industry, String district, String deviceType) {
  526. + return load(requestId, page, pageSize, snapshotAt, industry, district, deviceType, null);
  527. + }
  528. +
  529. + public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
  530. + String industry, String district, String deviceType, String pointStatus) {
  531. + CockpitPointStatusQuery statusQuery = CockpitPointStatusQuery.parse(pointStatus);
  532. int safePage = page == null ? 1 : page;
  533. int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
  534. validatePagination(safePage, safePageSize, snapshotAt);
  535. @@ -86,6 +93,15 @@
  536. snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
  537. List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
  538. snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
  539. + CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();
  540. + monitoringPoints(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
  541. + .forEach(point -> global.addPoint(point.getPointStatus()));
  542. + unassociatedEquipment(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
  543. + .forEach(device -> global.addUnassociatedDevice());
  544. + points = points.stream().filter(point -> statusQuery.matchesPoint(point.getPointStatus()))
  545. + .collect(Collectors.toList());
  546. + equipment = equipment.stream().filter(device -> statusQuery.matchesDevice(device.getDeviceStatus()))
  547. + .collect(Collectors.toList());
  548. int total = points.size() + equipment.size();
  549. int fromIndex = Math.min((safePage - 1) * safePageSize, total);
  550. int toIndex = Math.min(fromIndex + safePageSize, total);
  551. @@ -109,7 +125,8 @@
  552. pagePoints,
  553. pageEquipment,
  554. coordinateValidation(points, equipment),
  555. - mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment));
  556. + mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, statusQuery.echo(), points, equipment),
  557. + global.rows());
  558. }
  559. public CockpitErrorPayload invalidRequest(String requestId, String message) {
  560. @@ -237,6 +254,7 @@
  561. }
  562. return snapshot.getMonitoringPoints().stream()
  563. .filter(Objects::nonNull)
  564. + .filter(source -> !isBlank(source.getPointCode()))
  565. .filter(source -> matchesFilters(
  566. industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
  567. .map(source -> monitoringPoint(source, types))
  568. @@ -250,7 +268,8 @@
  569. Map<String, CockpitMapSnapshot.SourceType> types) {
  570. List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
  571. ? Collections.emptyList()
  572. - : source.getDevices();
  573. + : source.getDevices().stream().filter(Objects::nonNull)
  574. + .filter(device -> !isBlank(device.getDeviceCode())).collect(Collectors.toList());
  575. CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
  576. List<String> deviceCodes = devices.stream()
  577. .filter(Objects::nonNull)
  578. @@ -287,8 +306,8 @@
  579. Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
  580. return snapshot.getSmartDevices().stream()
  581. .filter(Objects::nonNull)
  582. - .filter(device -> device.getDeviceCode() == null
  583. - || !associatedDeviceCodes.contains(device.getDeviceCode()))
  584. + .filter(device -> !isBlank(device.getDeviceCode()))
  585. + .filter(device -> !associatedDeviceCodes.contains(device.getDeviceCode().trim()))
  586. .filter(source -> matchesFilters(
  587. industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
  588. .map(source -> unassociatedEquipment(source, types))
  589. @@ -302,11 +321,15 @@
  590. return Collections.emptySet();
  591. }
  592. return snapshot.getMonitoringPoints().stream()
  593. + .filter(Objects::nonNull)
  594. .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
  595. .filter(Objects::nonNull)
  596. .flatMap(List::stream)
  597. + .filter(Objects::nonNull)
  598. .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
  599. .filter(Objects::nonNull)
  600. + .map(String::trim)
  601. + .filter(code -> !code.isEmpty())
  602. .collect(Collectors.toSet());
  603. }
  604. @@ -324,11 +347,12 @@
  605. source.getLongitude(),
  606. source.getLatitude(),
  607. CockpitCoordinateValidator.validate(
  608. - source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
  609. + source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()),
  610. + CockpitPointStatusQuery.deviceBucket(source.getDeviceStatus()));
  611. }
  612. private static CockpitMapSnapshotResponse.MapFilter mapFilter(
  613. - String industry, CockpitDistrict.Query district, String deviceType,
  614. + String industry, CockpitDistrict.Query district, String deviceType, String pointStatus,
  615. List<CockpitMonitoringPointSummary> points,
  616. List<CockpitUnassociatedEquipmentSummary> equipment) {
  617. Set<String> pointCodes = new LinkedHashSet<>();
  618. @@ -354,7 +378,7 @@
  619. industry,
  620. district.active() ? district.code() : null,
  621. !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
  622. - deviceType, sorted(pointCodes), sorted(deviceCodes));
  623. + deviceType, pointStatus, sorted(pointCodes), sorted(deviceCodes));
  624. }
  625. private static List<String> sorted(Set<String> values) {
  626. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  627. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  628. @@ -15,10 +15,12 @@
  629. private final BigDecimal longitude;
  630. private final BigDecimal latitude;
  631. private final CockpitCoordinateValidation coordinateValidation;
  632. + private final String deviceStatus;
  633. public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
  634. Integer district, String industry, String typeCode, String typeName,
  635. - BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
  636. + BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
  637. + String deviceStatus) {
  638. this.deviceCode = deviceCode;
  639. this.deviceName = deviceName;
  640. this.district = CockpitDistrict.codeOf(district);
  641. @@ -28,8 +30,10 @@
  642. this.longitude = longitude;
  643. this.latitude = latitude;
  644. this.coordinateValidation = coordinateValidation;
  645. + this.deviceStatus = deviceStatus;
  646. }
  647. + public String getDeviceStatus() { return deviceStatus; }
  648. public String getDeviceCode() { return deviceCode; }
  649. public String getDeviceName() { return deviceName; }
  650. /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
  651. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  652. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  653. @@ -137,7 +137,8 @@
  654. fact.isWarning(),
  655. fact.isWorkOrderInProgress(),
  656. fact.getStatusReasons(),
  657. - fact.getStatusTime()));
  658. + fact.getStatusTime(),
  659. + fact.isStatusPublished()));
  660. }
  661. List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
  662. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  663. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  664. @@ -3,6 +3,7 @@
  665. import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
  666. import com.zksy.cockpit.foundation.CockpitDistrict;
  667. import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
  668. +import com.zksy.cockpit.point.CockpitPointStatusQuery;
  669. import java.math.BigDecimal;
  670. import java.util.List;
  671. @@ -19,7 +20,7 @@
  672. private final List<DistrictDistribution> districtDistribution;
  673. private final List<IndustryDistribution> industryDistribution;
  674. private final List<DeviceTypeDistribution> deviceTypeDistribution;
  675. - private final List<PointStatusCount> pointStatusDistribution;
  676. + private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
  677. private final MapFilter mapFilter;
  678. private final Pagination pagination;
  679. private final List<MonitoringPointMarker> monitoringPoints;
  680. @@ -38,7 +39,7 @@
  681. List<DistrictDistribution> districtDistribution,
  682. List<IndustryDistribution> industryDistribution,
  683. List<DeviceTypeDistribution> deviceTypeDistribution,
  684. - List<PointStatusCount> pointStatusDistribution,
  685. + List<CockpitPointStatusQuery.Count> pointStatusDistribution,
  686. MapFilter mapFilter,
  687. Pagination pagination,
  688. List<MonitoringPointMarker> monitoringPoints,
  689. @@ -74,7 +75,7 @@
  690. public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
  691. public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
  692. public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
  693. - public List<PointStatusCount> getPointStatusDistribution() { return pointStatusDistribution; }
  694. + public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
  695. public MapFilter getMapFilter() { return mapFilter; }
  696. public Pagination getPagination() { return pagination; }
  697. public List<MonitoringPointMarker> getMonitoringPoints() { return monitoringPoints; }
  698. @@ -220,19 +221,6 @@
  699. public String getName() { return name; }
  700. }
  701. - public static class PointStatusCount {
  702. - private final CockpitMonitoringPointStatus status;
  703. - private final int count;
  704. -
  705. - public PointStatusCount(CockpitMonitoringPointStatus status, int count) {
  706. - this.status = status;
  707. - this.count = count;
  708. - }
  709. -
  710. - public CockpitMonitoringPointStatus getStatus() { return status; }
  711. - public int getCount() { return count; }
  712. - }
  713. -
  714. /**
  715. * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
  716. * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
  717. @@ -368,6 +356,9 @@
  718. }
  719. public String getDeviceCode() { return deviceCode; }
  720. + public String getDeviceStatus() {
  721. + return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
  722. + }
  723. public String getDeviceName() { return deviceName; }
  724. public Integer getDistrict() { return district; }
  725. public String getDistrictName() { return districtName; }
  726. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  727. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  728. @@ -11,6 +11,7 @@
  729. import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
  730. import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
  731. import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
  732. +import com.zksy.cockpit.point.CockpitPointStatusQuery;
  733. import org.springframework.stereotype.Service;
  734. import java.math.BigDecimal;
  735. @@ -32,9 +33,8 @@
  736. /**
  737. * Read-only aggregation for 运行监测 / 监测分布.
  738. *
  739. - * <p>Statistics and pagination use selected business objects, including empty points
  740. - * and objects without renderable coordinates. coordinateValidation controls map
  741. - * presentation only; excluded counts rejected objects, not their associated devices.
  742. + * <p>业务对象参与计数、台账与筛选,坐标校验仅决定能否上图。
  743. + * 八桶分布取全局未筛全集(六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备)。
  744. */
  745. @Service
  746. public class CockpitMonitoringDistributionService {
  747. @@ -74,7 +74,7 @@
  748. Integer pageSize,
  749. String snapshotAt) {
  750. String selectedIndustry = resolveIndustry(industry);
  751. - CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
  752. + CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);
  753. CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
  754. int safePage = page == null ? 1 : page;
  755. int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
  756. @@ -102,7 +102,7 @@
  757. String snapshotAt,
  758. CockpitMonitoringDistributionDeviceStatusDelta delta) {
  759. String selectedIndustry = resolveIndustry(industry);
  760. - CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
  761. + CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);
  762. CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
  763. int safePage = page == null ? 1 : page;
  764. int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
  765. @@ -231,7 +231,7 @@
  766. String requestId,
  767. String selectedIndustry,
  768. String deviceType,
  769. - CockpitMonitoringPointStatus selectedStatus,
  770. + CockpitPointStatusQuery selectedStatus,
  771. CockpitDistrict.Query selectedDistrict,
  772. int page,
  773. int pageSize) {
  774. @@ -241,22 +241,14 @@
  775. int unknownTypeCount = 0;
  776. int typeKindMismatchCount = 0;
  777. int nonFormalIndustryCount = 0;
  778. - // Compatibility field: coordinates no longer exclude business objects.
  779. int invalidCoordinateCount = 0;
  780. List<Marker> pointMarkers = new ArrayList<>();
  781. List<Marker> deviceMarkers = new ArrayList<>();
  782. Set<String> pointCodes = new LinkedHashSet<>();
  783. Set<String> deviceCodes = new LinkedHashSet<>();
  784. - // Association is a source fact, independent of all display filters.
  785. - Set<String> associatedDeviceCodes = new LinkedHashSet<>();
  786. - if (snapshot.getMonitoringPoints() != null) {
  787. - for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
  788. - if (point != null && !isBlank(point.getPointCode())) {
  789. - associatedDeviceCodes.addAll(codes(safeDevices(point.getDevices())));
  790. - }
  791. - }
  792. - }
  793. + Set<String> associatedCodes = new LinkedHashSet<>();
  794. + CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();
  795. if (snapshot.getMonitoringPoints() != null) {
  796. for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
  797. @@ -264,54 +256,39 @@
  798. continue;
  799. }
  800. CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode());
  801. - String mismatch = classify(type);
  802. - List<CockpitMonitoringPointDeviceSnapshot> devices = safeDevices(point.getDevices());
  803. - boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && devices.isEmpty();
  804. - if (mismatch != null && !emptyPointWithUnknownType) {
  805. - if (UNKNOWN_TYPE.equals(mismatch)) {
  806. - unknownTypeCount++;
  807. - } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
  808. - typeKindMismatchCount++;
  809. - } else {
  810. - nonFormalIndustryCount++;
  811. - }
  812. - continue;
  813. - }
  814. - String markerIndustry = type == null ? null : type.getIndustry();
  815. - String markerTypeCode = type == null ? null : type.getCode();
  816. - if (selectedIndustry != null && !selectedIndustry.equals(markerIndustry)) {
  817. - continue;
  818. - }
  819. - if (!isBlank(deviceType) && !deviceType.trim().equals(markerTypeCode)) {
  820. - continue;
  821. - }
  822. - CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
  823. - point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
  824. - // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
  825. - Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
  826. - if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
  827. - continue;
  828. - }
  829. List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
  830. - for (CockpitMonitoringPointDeviceSnapshot device : devices) {
  831. - if (device == null || isBlank(device.getDeviceCode())) {
  832. - continue;
  833. - }
  834. + for (CockpitMonitoringPointDeviceSnapshot device : safeDevices(point.getDevices())) {
  835. + if (device == null || isBlank(device.getDeviceCode())) continue;
  836. + associatedCodes.add(device.getDeviceCode().trim());
  837. effective.add(effectiveDevice(device));
  838. }
  839. CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
  840. - if (selectedStatus != null && selectedStatus != status) {
  841. + global.addPoint(status);
  842. + String mismatch = classify(type);
  843. + boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && effective.isEmpty();
  844. + if (mismatch != null && !emptyPointWithUnknownType) {
  845. + if (UNKNOWN_TYPE.equals(mismatch)) unknownTypeCount++;
  846. + else if (TYPE_KIND_MISMATCH.equals(mismatch)) typeKindMismatchCount++;
  847. + else nonFormalIndustryCount++;
  848. continue;
  849. }
  850. + String markerIndustry = industryOf(type);
  851. + String markerTypeCode = point.getTypeCode();
  852. + Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
  853. + if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
  854. + markerIndustry, point.getTypeCode(), markerDistrict, type)
  855. + || !selectedStatus.matchesPoint(status)) continue;
  856. + CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
  857. + point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
  858. + deviceCodes.addAll(codes(effective));
  859. pointCodes.add(point.getPointCode().trim());
  860. - deviceCodes.addAll(codes(effective));
  861. pointMarkers.add(new Marker(
  862. point.getPointCode().trim(),
  863. point.getPointName(),
  864. markerDistrict,
  865. markerIndustry,
  866. markerTypeCode,
  867. - type == null ? "未知" : type.getName(),
  868. + type == null ? null : type.getName(),
  869. status,
  870. CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
  871. point.getLongitude(),
  872. @@ -324,39 +301,30 @@
  873. if (snapshot.getSmartDevices() != null) {
  874. for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
  875. - if (device == null || isBlank(device.getDeviceCode())
  876. - || associatedDeviceCodes.contains(device.getDeviceCode().trim())) {
  877. + if (device == null || isBlank(device.getDeviceCode())) {
  878. continue;
  879. }
  880. + if (associatedCodes.contains(device.getDeviceCode().trim())) continue;
  881. + global.addUnassociatedDevice();
  882. CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
  883. String mismatch = classify(type);
  884. if (mismatch != null) {
  885. - if (UNKNOWN_TYPE.equals(mismatch)) {
  886. - unknownTypeCount++;
  887. - } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
  888. - typeKindMismatchCount++;
  889. - } else {
  890. - nonFormalIndustryCount++;
  891. - }
  892. + if (UNKNOWN_TYPE.equals(mismatch)) unknownTypeCount++;
  893. + else if (TYPE_KIND_MISMATCH.equals(mismatch)) typeKindMismatchCount++;
  894. + else nonFormalIndustryCount++;
  895. continue;
  896. }
  897. - if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
  898. - continue;
  899. - }
  900. - if (!isBlank(deviceType) && !deviceType.trim().equals(type.getCode())) {
  901. - continue;
  902. - }
  903. + String markerIndustry = industryOf(type);
  904. + String markerTypeCode = device.getTypeCode();
  905. + Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
  906. + if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
  907. + markerIndustry, device.getTypeCode(), markerDistrict, type)) continue;
  908. CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
  909. device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
  910. - // 设备自身片区(equipment_base.district)即权威来源
  911. - Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
  912. - if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
  913. - continue;
  914. - }
  915. CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
  916. device.getDeviceCode().trim(),
  917. device.getDeviceName(),
  918. - type.getName(),
  919. + type == null ? null : type.getName(),
  920. device.getLongitude(),
  921. device.getLatitude(),
  922. validation,
  923. @@ -365,23 +333,24 @@
  924. device.isWarning(),
  925. device.isWorkOrderInProgress(),
  926. device.getStatusReasons(),
  927. - device.getStatusTime());
  928. + device.getStatusTime(), device.isStatusPublished());
  929. CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
  930. - CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.deviceStatus(applied);
  931. - if (selectedStatus != null && selectedStatus != status) {
  932. - continue;
  933. - }
  934. + String publishedStatus = publishedStatus(applied);
  935. + CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus)
  936. + ? null : CockpitMonitoringPointStatus.valueOf(publishedStatus);
  937. + if (!selectedStatus.matchesDevice(publishedStatus)) continue;
  938. deviceCodes.add(applied.getDeviceCode().trim());
  939. deviceMarkers.add(new Marker(
  940. applied.getDeviceCode().trim(),
  941. applied.getDeviceName(),
  942. markerDistrict,
  943. - type.getIndustry(),
  944. - type.getCode(),
  945. - type.getName(),
  946. + markerIndustry,
  947. + markerTypeCode,
  948. + type == null ? null : type.getName(),
  949. status,
  950. - CockpitMonitoringPointStatusResolver.summaryReasons(
  951. - status, Collections.singletonList(applied)),
  952. + status == null ? applied.getStatusReasons()
  953. + : CockpitMonitoringPointStatusResolver.summaryReasons(
  954. + status, Collections.singletonList(applied)),
  955. device.getLongitude(),
  956. device.getLatitude(),
  957. validation,
  958. @@ -397,18 +366,15 @@
  959. Map<Integer, Accumulator> byDistrict = new LinkedHashMap<>();
  960. Map<String, Accumulator> byIndustry = new LinkedHashMap<>();
  961. Map<String, Accumulator> byType = new LinkedHashMap<>();
  962. - int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
  963. for (Marker marker : pointMarkers) {
  964. accumulate(byDistrict, marker.district, marker);
  965. accumulate(byIndustry, marker.industry, marker);
  966. accumulate(byType, marker.typeCode, marker);
  967. - statusCounts[marker.status.ordinal()]++;
  968. }
  969. for (Marker marker : deviceMarkers) {
  970. accumulate(byDistrict, marker.district, marker);
  971. accumulate(byIndustry, marker.industry, marker);
  972. accumulate(byType, marker.typeCode, marker);
  973. - statusCounts[marker.status.ordinal()]++;
  974. }
  975. List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
  976. @@ -417,12 +383,7 @@
  977. industryRows(byIndustry);
  978. List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
  979. typeRows(byType);
  980. - List<CockpitMonitoringDistributionResponse.PointStatusCount> statusRows =
  981. - new ArrayList<>();
  982. - for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
  983. - statusRows.add(new CockpitMonitoringDistributionResponse.PointStatusCount(
  984. - status, statusCounts[status.ordinal()]));
  985. - }
  986. + List<CockpitPointStatusQuery.Count> statusRows = global.rows();
  987. int total = pointMarkers.size() + deviceMarkers.size();
  988. int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
  989. @@ -468,7 +429,7 @@
  990. new CockpitMonitoringDistributionResponse.MapFilter(
  991. selectedIndustry,
  992. isBlank(deviceType) ? null : deviceType.trim(),
  993. - selectedStatus == null ? null : selectedStatus.name(),
  994. + selectedStatus.echo(),
  995. // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot)
  996. selectedDistrict.code(),
  997. !selectedDistrict.active() ? "NONE"
  998. @@ -502,7 +463,10 @@
  999. source.isWarning(),
  1000. source.isWorkOrderInProgress(),
  1001. source.getStatusReasons(),
  1002. - source.getStatusTime());
  1003. + source.getStatusTime(),
  1004. + runtime == null || runtime.serverUpdatedAt == null
  1005. + ? source.isStatusPublished()
  1006. + : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState));
  1007. }
  1008. /**
  1009. @@ -637,18 +601,20 @@
  1010. return query;
  1011. }
  1012. - private static CockpitMonitoringPointStatus resolvePointStatus(String pointStatus) {
  1013. - if (isBlank(pointStatus)) {
  1014. - return null;
  1015. - }
  1016. - String resolved = pointStatus.trim();
  1017. - for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
  1018. - if (status.name().equals(resolved)) {
  1019. - return status;
  1020. - }
  1021. - }
  1022. - throw new CockpitInvalidRequestException(
  1023. - "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY");
  1024. + private String industryOf(CockpitMonitoringDistributionSnapshot.SourceType type) {
  1025. + return classify(type) == null ? type.getIndustry() : null;
  1026. + }
  1027. +
  1028. + private static boolean matchesFilters(String industry, String deviceType, CockpitDistrict.Query district,
  1029. + String actualIndustry, String typeCode, Integer actualDistrict,
  1030. + CockpitMonitoringDistributionSnapshot.SourceType type) {
  1031. + return (industry == null || industry.equals(actualIndustry))
  1032. + && (isBlank(deviceType) || (type != null && deviceType.trim().equals(typeCode)))
  1033. + && CockpitDistrict.matches(district, actualDistrict);
  1034. + }
  1035. +
  1036. + private static String publishedStatus(CockpitMonitoringPointDeviceSnapshot device) {
  1037. + return CockpitPointStatusQuery.deviceBucket(device.getPublishedDeviceStatus());
  1038. }
  1039. private static void validatePagination(int page, int pageSize, String snapshotAt) {
  1040. @@ -822,7 +788,7 @@
  1041. } else {
  1042. deviceCount++;
  1043. }
  1044. - statusCounts[marker.status.ordinal()]++;
  1045. + if (marker.status != null) statusCounts[marker.status.ordinal()]++;
  1046. if (typeName == null) {
  1047. typeName = marker.typeName;
  1048. }
  1049. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  1050. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  1051. @@ -173,6 +173,7 @@
  1052. private final boolean workOrderInProgress;
  1053. private final List<String> statusReasons;
  1054. private final String statusTime;
  1055. + private final boolean statusPublished;
  1056. public SourceSmartDevice(
  1057. String deviceCode,
  1058. @@ -188,6 +189,14 @@
  1059. boolean workOrderInProgress,
  1060. List<String> statusReasons,
  1061. String statusTime) {
  1062. + this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified,
  1063. + online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true);
  1064. + }
  1065. +
  1066. + public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
  1067. + BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
  1068. + boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
  1069. + List<String> statusReasons, String statusTime, boolean statusPublished) {
  1070. this.deviceCode = deviceCode;
  1071. this.deviceName = deviceName;
  1072. this.district = district;
  1073. @@ -201,6 +210,7 @@
  1074. this.workOrderInProgress = workOrderInProgress;
  1075. this.statusReasons = statusReasons;
  1076. this.statusTime = statusTime;
  1077. + this.statusPublished = statusPublished;
  1078. }
  1079. public String getDeviceCode() {
  1080. @@ -252,6 +262,8 @@
  1081. return statusReasons;
  1082. }
  1083. + public boolean isStatusPublished() { return statusPublished; }
  1084. +
  1085. public String getStatusTime() {
  1086. return statusTime;
  1087. }
  1088. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java
  1089. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java
  1090. @@ -4,6 +4,7 @@
  1091. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  1092. import com.zksy.base.domain.EquipmentBase;
  1093. import com.zksy.base.domain.EquipmentStatus;
  1094. +import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
  1095. import com.zksy.base.service.EquipmentStatusService;
  1096. import com.zksy.base.warning.domain.EarlyWarning;
  1097. import com.zksy.base.warning.service.EarlyWarningService;
  1098. @@ -77,7 +78,7 @@
  1099. deviceTypeName,
  1100. device.getLongitude(),
  1101. device.getLatitude(),
  1102. - false,
  1103. + CockpitCoordinateValidator.validate(device.getLongitude(), device.getLatitude(), false),
  1104. online,
  1105. alarm,
  1106. hasOpenWarning(deviceCode),
  1107. @@ -86,7 +87,8 @@
  1108. status == null || status.getStatusUpdateTime() == null
  1109. ? null
  1110. : TIME_FORMATTER.format(status.getStatusUpdateTime()
  1111. - .atZone(BUSINESS_ZONE).toOffsetDateTime()));
  1112. + .atZone(BUSINESS_ZONE).toOffsetDateTime()),
  1113. + status != null);
  1114. }
  1115. private boolean hasOpenWarning(String deviceCode) {
  1116. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java
  1117. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java
  1118. @@ -19,6 +19,7 @@
  1119. private final boolean workOrderInProgress;
  1120. private final List<String> statusReasons;
  1121. private final String statusTime;
  1122. + private final boolean statusPublished;
  1123. public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
  1124. boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
  1125. @@ -40,6 +41,14 @@
  1126. BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
  1127. boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
  1128. List<String> statusReasons, String statusTime) {
  1129. + this(deviceCode, deviceName, deviceTypeName, longitude, latitude, coordinateValidation,
  1130. + online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true);
  1131. + }
  1132. +
  1133. + public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
  1134. + BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
  1135. + boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
  1136. + List<String> statusReasons, String statusTime, boolean statusPublished) {
  1137. this.deviceCode = deviceCode;
  1138. this.deviceName = deviceName;
  1139. this.deviceTypeName = deviceTypeName;
  1140. @@ -52,6 +61,7 @@
  1141. this.workOrderInProgress = workOrderInProgress;
  1142. this.statusReasons = statusReasons;
  1143. this.statusTime = statusTime;
  1144. + this.statusPublished = statusPublished;
  1145. }
  1146. public String getDeviceCode() { return deviceCode; }
  1147. @@ -66,4 +76,9 @@
  1148. public boolean isWorkOrderInProgress() { return workOrderInProgress; }
  1149. public List<String> getStatusReasons() { return statusReasons; }
  1150. public String getStatusTime() { return statusTime; }
  1151. + public boolean isStatusPublished() { return statusPublished; }
  1152. + /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */
  1153. + public String getPublishedDeviceStatus() {
  1154. + return statusPublished ? CockpitMonitoringPointStatusResolver.deviceStatus(this).name() : null;
  1155. + }
  1156. }
  1157. --- /dev/null
  1158. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitPointStatusQuery.java
  1159. @@ -0,0 +1,96 @@
  1160. +package com.zksy.cockpit.point;
  1161. +
  1162. +import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
  1163. +import java.util.ArrayList;
  1164. +import java.util.Arrays;
  1165. +import java.util.Collections;
  1166. +import java.util.LinkedHashSet;
  1167. +import java.util.List;
  1168. +import java.util.Set;
  1169. +
  1170. +/** 八桶查询与全局分布。六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备,互不重复。 */
  1171. +public final class CockpitPointStatusQuery {
  1172. + public static final List<String> BUCKETS = Collections.unmodifiableList(Arrays.asList(
  1173. + "ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE", "EMPTY", "UNKNOWN", "UNASSOCIATED"));
  1174. + private static final List<String> DEVICE_STATUSES = Arrays.asList(
  1175. + "ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE");
  1176. + private final List<String> selected;
  1177. +
  1178. + private CockpitPointStatusQuery(List<String> selected) {
  1179. + this.selected = selected;
  1180. + }
  1181. +
  1182. + public static CockpitPointStatusQuery parse(String raw) {
  1183. + Set<String> values = new LinkedHashSet<>();
  1184. + if (raw != null && !raw.trim().isEmpty()) {
  1185. + for (String token : raw.split(",", -1)) {
  1186. + String value = token.trim();
  1187. + if (!BUCKETS.contains(value)) {
  1188. + throw new CockpitInvalidRequestException(
  1189. + "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED");
  1190. + }
  1191. + values.add(value);
  1192. + }
  1193. + }
  1194. + // 同一集合不因入参顺序不同而有不同回显。
  1195. + List<String> selected = new ArrayList<>(BUCKETS);
  1196. + selected.retainAll(values);
  1197. + return new CockpitPointStatusQuery(Collections.unmodifiableList(selected));
  1198. + }
  1199. +
  1200. + public String echo() {
  1201. + return selected.isEmpty() ? null : String.join(",", selected);
  1202. + }
  1203. +
  1204. + public boolean matchesPoint(CockpitMonitoringPointStatus status) {
  1205. + return !selected.contains("UNASSOCIATED")
  1206. + && (selected.isEmpty() || selected.contains(pointBucket(status)));
  1207. + }
  1208. +
  1209. + public boolean matchesDevice(String status) {
  1210. + return selected.isEmpty()
  1211. + || (selected.size() == 1 && selected.contains("UNASSOCIATED"))
  1212. + || selected.contains(deviceBucket(status));
  1213. + }
  1214. +
  1215. + public static String pointBucket(CockpitMonitoringPointStatus status) {
  1216. + return status == null ? "UNKNOWN" : status.name();
  1217. + }
  1218. +
  1219. + public static String deviceBucket(String status) {
  1220. + return status != null && DEVICE_STATUSES.contains(status) ? status : "UNKNOWN";
  1221. + }
  1222. +
  1223. + public static class Count {
  1224. + private final String status;
  1225. + private final int count;
  1226. +
  1227. + public Count(String status, int count) {
  1228. + this.status = status;
  1229. + this.count = count;
  1230. + }
  1231. +
  1232. + public String getStatus() { return status; }
  1233. + public int getCount() { return count; }
  1234. + }
  1235. +
  1236. + public static class Distribution {
  1237. + private final int[] counts = new int[BUCKETS.size()];
  1238. +
  1239. + public void addPoint(CockpitMonitoringPointStatus status) {
  1240. + counts[BUCKETS.indexOf(pointBucket(status))]++;
  1241. + }
  1242. +
  1243. + public void addUnassociatedDevice() {
  1244. + counts[BUCKETS.indexOf("UNASSOCIATED")]++;
  1245. + }
  1246. +
  1247. + public List<Count> rows() {
  1248. + List<Count> rows = new ArrayList<>();
  1249. + for (int index = 0; index < BUCKETS.size(); index++) {
  1250. + rows.add(new Count(BUCKETS.get(index), counts[index]));
  1251. + }
  1252. + return rows;
  1253. + }
  1254. + }
  1255. +}