t28-backend.patch 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringOverviewAdapterTest.java
  2. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringOverviewAdapterTest.java
  3. @@ -140,6 +140,26 @@
  4. .findFirst().orElseThrow().getIndustry());
  5. }
  6. + @Test
  7. + void realEmptyPointsAndMissingDeviceTypesPublishDistinctAssociationFacts() {
  8. + stubPoints(Arrays.asList(point("P0", "EMPTY"), point("P1", "UNKNOWN")));
  9. + stubTypes(Collections.emptyList());
  10. + EquipmentBase bound = device("D", "BOUND", "99");
  11. + when(equipmentBaseService.list()).thenReturn(Collections.singletonList(bound));
  12. + when(equipmentBaseService.getEquipmentByPoint("P0")).thenReturn(Collections.emptyList());
  13. + when(equipmentBaseService.getEquipmentByPoint("P1")).thenReturn(Arrays.asList(bound, bound));
  14. + CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
  15. + assertNull(snapshot.getMonitoringPoints().get(0).getTypeCode());
  16. + assertEquals(0, snapshot.getMonitoringPoints().get(0).getAssociatedDeviceCount());
  17. + assertNull(snapshot.getMonitoringPoints().get(1).getTypeCode());
  18. + assertEquals(1, snapshot.getMonitoringPoints().get(1).getAssociatedDeviceCount());
  19. + CockpitMonitoringOverviewService service = new CockpitMonitoringOverviewService(
  20. + () -> snapshot, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
  21. + var response = service.load("empty-real", null, null);
  22. + assertEquals(1, response.getCounts().getMonitoringPointCount());
  23. + assertEquals(Collections.singletonList("EMPTY"), response.getMapFilter().getPointCodes());
  24. + }
  25. +
  26. // ---- helpers ----
  27. private static String stateOf(CockpitMonitoringOverviewSnapshot snapshot, String deviceCode) {
  28. --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  29. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  30. @@ -76,7 +76,7 @@
  31. .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
  32. .andExpect(jsonPath("$.data.pagination.page").value(1))
  33. .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
  34. - .andExpect(jsonPath("$.data.pagination.total").value(8))
  35. + .andExpect(jsonPath("$.data.pagination.total").value(9))
  36. .andExpect(jsonPath("$.data.pagination.totalPages").value(1))
  37. .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
  38. .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
  39. @@ -91,7 +91,7 @@
  40. .andExpect(jsonPath("$.data.monitoringPoints[3].pointStatus").value("WORK_ORDER"))
  41. .andExpect(jsonPath("$.data.monitoringPoints[4].pointCode").value("P-WS-2"))
  42. .andExpect(jsonPath("$.data.monitoringPoints[4].pointStatus").value("EMPTY"))
  43. - .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(3)))
  44. + .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
  45. .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
  46. .andExpect(jsonPath("$.data.unassociatedEquipment[0].pointStatus").value("NORMAL"))
  47. .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-002"))
  48. @@ -106,7 +106,7 @@
  49. .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
  50. .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
  51. .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
  52. - .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(1))
  53. + .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
  54. .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
  55. .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2))
  56. .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
  57. @@ -115,7 +115,7 @@
  58. .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
  59. .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
  60. .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
  61. - .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(4))
  62. + .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(5))
  63. .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
  64. .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
  65. .andExpect(jsonPath("$.data.districtDistribution[1].pointCount").value(2))
  66. @@ -127,14 +127,14 @@
  67. .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
  68. .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
  69. .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
  70. - .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(4))
  71. + .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(5))
  72. .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
  73. .andExpect(jsonPath("$.data.industryDistribution[1].deviceCount").value(2))
  74. .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
  75. .andExpect(jsonPath("$.data.industryDistribution[2].deviceCount").value(2))
  76. .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(3)))
  77. .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"))
  78. - .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(4))
  79. + .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(5))
  80. .andExpect(jsonPath("$.data.deviceTypeDistribution[1].deviceType.code").value("SEWAGE_ENV"))
  81. .andExpect(jsonPath("$.data.deviceTypeDistribution[2].deviceType.code").value("WATER_SUPPLY_SENSOR"))
  82. .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
  83. @@ -144,11 +144,11 @@
  84. .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
  85. .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
  86. .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(5)))
  87. - .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(8)))
  88. + .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(9)))
  89. .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
  90. .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
  91. .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
  92. - .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(1))
  93. + .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
  94. .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
  95. .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
  96. }
  97. @@ -157,24 +157,24 @@
  98. void associatedDevicesRenderAsOnePointAndPaginationMatchesStatusTotals() throws Exception {
  99. mockMvc.perform(get(ENDPOINT))
  100. .andExpect(status().isOk())
  101. - .andExpect(jsonPath("$.data.pagination.total").value(8))
  102. + .andExpect(jsonPath("$.data.pagination.total").value(9))
  103. .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
  104. .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
  105. - .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(3)));
  106. + .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)));
  107. }
  108. @Test
  109. void industryFilterNarrowsDistributionAndMapFilter() throws Exception {
  110. mockMvc.perform(get(ENDPOINT).param("industry", "GAS"))
  111. .andExpect(status().isOk())
  112. - .andExpect(jsonPath("$.data.pagination.total").value(3))
  113. + .andExpect(jsonPath("$.data.pagination.total").value(4))
  114. .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
  115. - .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
  116. + .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
  117. .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
  118. .andExpect(jsonPath("$.data.industryDistribution", hasSize(1)))
  119. .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
  120. .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
  121. - .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(1))
  122. + .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
  123. .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
  124. }
  125. @@ -182,7 +182,7 @@
  126. void deviceTypeFilterNarrowsDistribution() throws Exception {
  127. mockMvc.perform(get(ENDPOINT).param("deviceType", "GAS_SENSOR"))
  128. .andExpect(status().isOk())
  129. - .andExpect(jsonPath("$.data.pagination.total").value(3))
  130. + .andExpect(jsonPath("$.data.pagination.total").value(4))
  131. .andExpect(jsonPath("$.data.mapFilter.deviceType").value("GAS_SENSOR"))
  132. .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(1)))
  133. .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"));
  134. @@ -200,7 +200,7 @@
  135. mockMvc.perform(get(ENDPOINT))
  136. .andExpect(status().isOk())
  137. - .andExpect(jsonPath("$.data.pagination.total").value(8))
  138. + .andExpect(jsonPath("$.data.pagination.total").value(9))
  139. .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()));
  140. }
  141. @@ -352,7 +352,7 @@
  142. CockpitMonitoringDistributionResponse initial = service.load(
  143. "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
  144. assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
  145. - assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
  146. + assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
  147. CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
  148. "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
  149. @@ -361,7 +361,7 @@
  150. "2026-09-02T10:05:00+08:00"));
  151. assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
  152. - assertEquals(2, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
  153. + assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
  154. CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
  155. "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
  156. @@ -370,7 +370,7 @@
  157. "2026-09-02T10:04:00+08:00"));
  158. assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
  159. - assertEquals(2, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
  160. + assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
  161. CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
  162. "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
  163. @@ -379,7 +379,114 @@
  164. "2026-09-02T10:06:00+08:00"));
  165. assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
  166. - assertEquals(2, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
  167. + assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
  168. + }
  169. +
  170. + @Test
  171. + void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
  172. + List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
  173. + for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
  174. + points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
  175. + code, code, null, null, new BigDecimal("110.399876"), new BigDecimal("28.458287"),
  176. + false, Collections.emptyList()));
  177. + }
  178. + CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
  179. + SNAPSHOT_AT, SNAPSHOT_AT, Collections.emptyList(), points, Collections.emptyList());
  180. + when(adapter.load(null)).thenReturn(snapshot);
  181. + when(adapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
  182. +
  183. + mockMvc.perform(get(ENDPOINT).param("pointStatus", "EMPTY").param("pageSize", "2"))
  184. + .andExpect(status().isOk())
  185. + .andExpect(jsonPath("$.data.pagination.total").value(6))
  186. + .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
  187. + .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
  188. + .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
  189. + .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("GS001"))
  190. + .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
  191. + .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
  192. + .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
  193. + .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
  194. + .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
  195. + .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
  196. + .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))
  197. + .andExpect(jsonPath("$.data.industryDistribution[0].industry").value(nullValue()))
  198. + .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(6))
  199. + .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value(nullValue()))
  200. + .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
  201. + .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
  202. + .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0))
  203. + .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
  204. + .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
  205. + .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
  206. + mockMvc.perform(get(ENDPOINT).param("page", "3").param("pageSize", "2").param("snapshotAt", SNAPSHOT_AT))
  207. + .andExpect(status().isOk())
  208. + .andExpect(jsonPath("$.data.pagination.total").value(6))
  209. + .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
  210. + .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("RQ001"));
  211. + mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
  212. + .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(6));
  213. + for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"},
  214. + new String[]{"deviceType", "GAS_SENSOR"}, new String[]{"district", "0"},
  215. + new String[]{"pointStatus", "NORMAL"})) {
  216. + mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
  217. + .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(0));
  218. + }
  219. + }
  220. +
  221. + @Test
  222. + void coordinatesDoNotAffectStatisticsAndAssociatedDevicesNeverBecomeStandaloneMarkers() {
  223. + CockpitMonitoringPointDeviceSnapshot bound = device(
  224. + "BOUND", "关联设备", true, true, false, false, Collections.emptyList());
  225. + CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
  226. + SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(),
  227. + Collections.singletonList(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
  228. + "P", "点位", 0, "GAS_SENSOR", null, null, false, Collections.singletonList(bound))),
  229. + Arrays.asList(smartDevice("BOUND", "关联设备", 1, "GAS_SENSOR", "110.4", "28.4", false,
  230. + true, true, false, false, Collections.emptyList()),
  231. + smartDevice("FREE", "独立设备", 1, "GAS_SENSOR", "181", "28.4", true,
  232. + true, false, false, false, Collections.emptyList())));
  233. + when(adapter.load(null)).thenReturn(snapshot);
  234. + CockpitMonitoringDistributionResponse all = service.load("all", null, null, null, null, 1, 100, null);
  235. + assertEquals(2, all.getPagination().getTotal());
  236. + assertEquals(1, all.getUnassociatedEquipment().size());
  237. + assertEquals("FREE", all.getUnassociatedEquipment().get(0).getDeviceCode());
  238. + assertTrue(!all.getMonitoringPoints().get(0).getCoordinateValidation().isRenderable());
  239. + assertTrue(!all.getUnassociatedEquipment().get(0).getCoordinateValidation().isRenderable());
  240. + assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
  241. + assertEquals("READY", all.getFreshness().getReason());
  242. + assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
  243. + assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
  244. +
  245. + CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
  246. + assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
  247. + assertTrue(normal.getMapFilter().getPointCodes().isEmpty());
  248. + CockpitMonitoringDistributionResponse district = service.load("district", null, null, null, "1", 1, 100, null);
  249. + assertEquals(1, district.getPagination().getTotal(), "被筛掉的点位不能让关联设备变成独立设备");
  250. + assertEquals(Collections.singletonList("FREE"), district.getMapFilter().getDeviceCodes());
  251. + }
  252. +
  253. + @Test
  254. + void exclusionsCountEachRejectedObjectOnceRegardlessOfAssociatedDeviceCount() {
  255. + CockpitMonitoringPointDeviceSnapshot d = device("D", "设备", true, false, false, false, Collections.emptyList());
  256. + CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
  257. + SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(), Arrays.asList(
  258. + point("EMPTY", "合法空点", null, null, "110.4", "28.4"),
  259. + point("UNKNOWN", "未知类型", 0, "LEGACY", "110.4", "28.4", d, d, d),
  260. + point("MISMATCH", "错种类", 0, "WATER_PLANT", "110.4", "28.4", d, d),
  261. + point("NONFORMAL", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", d)), Arrays.asList(
  262. + smartDevice("U", "未知", 0, "LEGACY", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
  263. + smartDevice("M", "错种类", 0, "WATER_PLANT", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
  264. + smartDevice("N", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", true, true, false, false, false, Collections.emptyList())));
  265. + when(adapter.load(null)).thenReturn(snapshot);
  266. + CockpitMonitoringDistributionResponse response = service.load("reconcile", null, null, null, null, 1, 100, null);
  267. + assertEquals(1, response.getPagination().getTotal());
  268. + assertEquals(1, statusCount(response, CockpitMonitoringPointStatus.EMPTY));
  269. + assertEquals(2, response.getExcluded().getUnknownTypeCount());
  270. + assertEquals(2, response.getExcluded().getTypeKindMismatchCount());
  271. + assertEquals(2, response.getExcluded().getNonFormalIndustryCount());
  272. + int excluded = response.getExcluded().getUnknownTypeCount() + response.getExcluded().getTypeKindMismatchCount()
  273. + + response.getExcluded().getNonFormalIndustryCount() + response.getExcluded().getInvalidCoordinateCount();
  274. + assertEquals(7, response.getPagination().getTotal() + excluded);
  275. }
  276. private int statusCount(CockpitMonitoringDistributionResponse response,
  277. --- a/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java
  278. +++ b/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java
  279. @@ -239,6 +239,45 @@
  280. }
  281. }
  282. + @Test
  283. + void sixEmptyUntypedPointsAreCountedAndEchoedWithoutFabricatingDevicesOrIndustry() throws Exception {
  284. + List<CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint> points = new ArrayList<>();
  285. + for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
  286. + points.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(code, null, 0));
  287. + }
  288. + when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
  289. + "2026-09-02T09:59:50+08:00", Collections.emptyList(), Collections.emptyList(), points));
  290. + mockMvc.perform(get(ENDPOINT))
  291. + .andExpect(status().isOk())
  292. + .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(6))
  293. + .andExpect(jsonPath("$.data.counts.equipmentCount").value(0))
  294. + .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
  295. + .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
  296. + .andExpect(jsonPath("$.data.status.denominator").value(0))
  297. + .andExpect(jsonPath("$.data.onlineRate.state").value("EMPTY"))
  298. + .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
  299. + .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
  300. + for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"}, new String[]{"deviceType", "GAS_SENSOR"})) {
  301. + mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
  302. + .andExpect(status().isOk())
  303. + .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(0))
  304. + .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(0)));
  305. + }
  306. + }
  307. +
  308. + @Test
  309. + void unknownOrMixedTypeWithDevicesIsNotReinterpretedAsAnEmptyPoint() {
  310. + when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
  311. + "2026-09-02T09:59:50+08:00", fixture().getTypes(), Collections.emptyList(), Arrays.asList(
  312. + new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("EMPTY", null, 0),
  313. + new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("MIXED", null, 2),
  314. + new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("LEGACY", null),
  315. + new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint("FACILITY", "WATER_PLANT", 0))));
  316. + CockpitMonitoringOverviewResponse response = service.load("points", null, null);
  317. + assertEquals(1, response.getCounts().getMonitoringPointCount());
  318. + assertEquals(Collections.singletonList("EMPTY"), response.getMapFilter().getPointCodes());
  319. + }
  320. +
  321. private static CockpitMonitoringOverviewSnapshot fixture() {
  322. return new CockpitMonitoringOverviewSnapshot(
  323. "2026-09-02T09:59:50+08:00",
  324. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java
  325. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java
  326. @@ -82,10 +82,12 @@
  327. }
  328. String pointCode = point.getPointCode().trim();
  329. Set<String> deviceTypes = new LinkedHashSet<>();
  330. + Set<String> boundDeviceCodes = new LinkedHashSet<>();
  331. for (EquipmentBase bound : safeList(equipmentBaseService.getEquipmentByPoint(point.getPointId()))) {
  332. if (bound == null || isBlank(bound.getEquipmentCode())) {
  333. continue;
  334. }
  335. + boundDeviceCodes.add(bound.getEquipmentCode().trim());
  336. pointCodeByDevice.putIfAbsent(bound.getEquipmentCode().trim(), pointCode);
  337. if (bound.getEquipmentTypeId() == null) {
  338. continue;
  339. @@ -97,7 +99,8 @@
  340. }
  341. sourcePoints.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
  342. pointCode,
  343. - deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null));
  344. + deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
  345. + boundDeviceCodes.size()));
  346. }
  347. List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();
  348. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  349. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  350. @@ -32,10 +32,9 @@
  351. /**
  352. * Read-only aggregation for 运行监测 / 监测分布.
  353. *
  354. - * <p>All distribution statistics use the map rendering scope: monitoring points with
  355. - * valid verified coordinates render once regardless of how many devices they carry;
  356. - * unassociated smart devices render as their own marker only when coordinates are
  357. - * valid and verified. Non-renderable objects are counted in excluded.invalidCoordinateCount.
  358. + * <p>Statistics and pagination use selected business objects, including empty points
  359. + * and objects without renderable coordinates. coordinateValidation controls map
  360. + * presentation only; excluded counts rejected objects, not their associated devices.
  361. */
  362. @Service
  363. public class CockpitMonitoringDistributionService {
  364. @@ -242,12 +241,22 @@
  365. int unknownTypeCount = 0;
  366. int typeKindMismatchCount = 0;
  367. int nonFormalIndustryCount = 0;
  368. + // Compatibility field: coordinates no longer exclude business objects.
  369. int invalidCoordinateCount = 0;
  370. List<Marker> pointMarkers = new ArrayList<>();
  371. List<Marker> deviceMarkers = new ArrayList<>();
  372. Set<String> pointCodes = new LinkedHashSet<>();
  373. Set<String> deviceCodes = new LinkedHashSet<>();
  374. + // Association is a source fact, independent of all display filters.
  375. + Set<String> associatedDeviceCodes = new LinkedHashSet<>();
  376. + if (snapshot.getMonitoringPoints() != null) {
  377. + for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
  378. + if (point != null && !isBlank(point.getPointCode())) {
  379. + associatedDeviceCodes.addAll(codes(safeDevices(point.getDevices())));
  380. + }
  381. + }
  382. + }
  383. if (snapshot.getMonitoringPoints() != null) {
  384. for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
  385. @@ -257,29 +266,27 @@
  386. CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode());
  387. String mismatch = classify(type);
  388. List<CockpitMonitoringPointDeviceSnapshot> devices = safeDevices(point.getDevices());
  389. - if (mismatch != null) {
  390. - int amount = devices.size();
  391. + boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && devices.isEmpty();
  392. + if (mismatch != null && !emptyPointWithUnknownType) {
  393. if (UNKNOWN_TYPE.equals(mismatch)) {
  394. - unknownTypeCount += amount;
  395. + unknownTypeCount++;
  396. } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
  397. - typeKindMismatchCount += amount;
  398. + typeKindMismatchCount++;
  399. } else {
  400. - nonFormalIndustryCount += amount;
  401. + nonFormalIndustryCount++;
  402. }
  403. continue;
  404. }
  405. - if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
  406. - continue;
  407. - }
  408. - if (!isBlank(deviceType) && !deviceType.trim().equals(type.getCode())) {
  409. + String markerIndustry = type == null ? null : type.getIndustry();
  410. + String markerTypeCode = type == null ? null : type.getCode();
  411. + if (selectedIndustry != null && !selectedIndustry.equals(markerIndustry)) {
  412. + continue;
  413. + }
  414. + if (!isBlank(deviceType) && !deviceType.trim().equals(markerTypeCode)) {
  415. continue;
  416. }
  417. CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
  418. point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
  419. - if (!validation.isRenderable()) {
  420. - invalidCoordinateCount++;
  421. - continue;
  422. - }
  423. // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
  424. Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
  425. if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
  426. @@ -290,22 +297,21 @@
  427. if (device == null || isBlank(device.getDeviceCode())) {
  428. continue;
  429. }
  430. - CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(device);
  431. - effective.add(applied);
  432. - deviceCodes.add(applied.getDeviceCode().trim());
  433. + effective.add(effectiveDevice(device));
  434. }
  435. CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
  436. if (selectedStatus != null && selectedStatus != status) {
  437. continue;
  438. }
  439. pointCodes.add(point.getPointCode().trim());
  440. + deviceCodes.addAll(codes(effective));
  441. pointMarkers.add(new Marker(
  442. point.getPointCode().trim(),
  443. point.getPointName(),
  444. markerDistrict,
  445. - type.getIndustry(),
  446. - type.getCode(),
  447. - type.getName(),
  448. + markerIndustry,
  449. + markerTypeCode,
  450. + type == null ? "未知" : type.getName(),
  451. status,
  452. CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
  453. point.getLongitude(),
  454. @@ -318,7 +324,8 @@
  455. if (snapshot.getSmartDevices() != null) {
  456. for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
  457. - if (device == null || isBlank(device.getDeviceCode())) {
  458. + if (device == null || isBlank(device.getDeviceCode())
  459. + || associatedDeviceCodes.contains(device.getDeviceCode().trim())) {
  460. continue;
  461. }
  462. CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
  463. @@ -341,10 +348,6 @@
  464. }
  465. CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
  466. device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
  467. - if (!validation.isRenderable()) {
  468. - invalidCoordinateCount++;
  469. - continue;
  470. - }
  471. // 设备自身片区(equipment_base.district)即权威来源
  472. Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
  473. if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
  474. @@ -441,7 +444,7 @@
  475. pageDevices.add(marker.toUnassociatedEquipmentMarker());
  476. }
  477. - boolean hasResolved = !deviceCodes.isEmpty();
  478. + boolean hasResolved = total > 0;
  479. String updatedAt = isBlank(snapshot.getUpdatedAt()) ? businessTime() : snapshot.getUpdatedAt();
  480. return new CockpitMonitoringDistributionResponse(
  481. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java
  482. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java
  483. @@ -135,7 +135,7 @@
  484. int typeKindMismatchCount = 0;
  485. int nonFormalIndustryCount = 0;
  486. Map<String, TypeAccumulator> typeAccumulators = new LinkedHashMap<>();
  487. - Set<String> selectedPointCodes = new LinkedHashSet<>();
  488. + Set<String> selectedPointCodes = selectMonitoringPointCodes(snapshot, selectedIndustry, deviceType, types);
  489. Set<String> selectedDeviceCodes = new LinkedHashSet<>();
  490. List<CockpitMonitoringOverviewSnapshot.SourceDevice> selectedDevices = new ArrayList<>();
  491. @@ -178,12 +178,9 @@
  492. selectedDevices.add(snapshotDevice(snapshot, runtime.deviceCode));
  493. selectedDeviceCodes.add(runtime.deviceCode);
  494. - if (!isBlank(runtime.pointCode)) {
  495. - selectedPointCodes.add(runtime.pointCode.trim());
  496. - }
  497. - }
  498. -
  499. - int monitoringPointCount = countMonitoringPoints(snapshot, selectedIndustry, deviceType, types);
  500. + }
  501. +
  502. + int monitoringPointCount = selectedPointCodes.size();
  503. int denominator = online + offline + stale;
  504. BigDecimal rate = denominator == 0
  505. ? BigDecimal.ZERO.setScale(4)
  506. @@ -191,10 +188,11 @@
  507. .divide(BigDecimal.valueOf(denominator), 4, RoundingMode.HALF_UP);
  508. String rateState = denominator == 0 ? "EMPTY" : "AVAILABLE";
  509. String rateReason = denominator == 0 ? "NO_RESOLVED_DEVICES" : null;
  510. - String freshnessStatus = denominator == 0
  511. + boolean hasObjects = monitoringPointCount > 0 || !selectedDeviceCodes.isEmpty();
  512. + String freshnessStatus = !hasObjects
  513. ? CockpitFreshnessStatus.EMPTY.name()
  514. : stale == 0 ? CockpitFreshnessStatus.CURRENT.name() : CockpitFreshnessStatus.STALE.name();
  515. - String freshnessReason = denominator == 0
  516. + String freshnessReason = !hasObjects
  517. ? "NO_RESOLVED_DEVICES"
  518. : stale == 0 ? "READY" : "STALE_SAMPLES";
  519. @@ -263,33 +261,35 @@
  520. return null;
  521. }
  522. - private static int countMonitoringPoints(
  523. + private static Set<String> selectMonitoringPointCodes(
  524. CockpitMonitoringOverviewSnapshot snapshot,
  525. String selectedIndustry,
  526. String selectedDeviceType,
  527. Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types) {
  528. + Set<String> unique = new LinkedHashSet<>();
  529. if (snapshot == null || snapshot.getMonitoringPoints() == null) {
  530. - return 0;
  531. - }
  532. - Set<String> unique = new LinkedHashSet<>();
  533. + return unique;
  534. + }
  535. for (CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
  536. if (point == null || isBlank(point.getPointCode())) {
  537. continue;
  538. }
  539. CockpitMonitoringOverviewSnapshot.SourceType type = isBlank(point.getTypeCode())
  540. - ? null : types.get(point.getTypeCode());
  541. - if (type == null || !SMART_DEVICE.equals(type.getObjectKind())) {
  542. - continue;
  543. - }
  544. - if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
  545. - continue;
  546. - }
  547. - if (!isBlank(selectedDeviceType) && !selectedDeviceType.trim().equals(point.getTypeCode())) {
  548. + ? null : types.get(point.getTypeCode().trim());
  549. + boolean emptyPointWithUnknownType = type == null && point.getAssociatedDeviceCount() == 0;
  550. + if (!emptyPointWithUnknownType && (type == null || !SMART_DEVICE.equals(type.getObjectKind())
  551. + || !INDUSTRIES.contains(type.getIndustry()))) {
  552. + continue;
  553. + }
  554. + if (selectedIndustry != null && (type == null || !selectedIndustry.equals(type.getIndustry()))) {
  555. + continue;
  556. + }
  557. + if (!isBlank(selectedDeviceType) && (type == null || !selectedDeviceType.trim().equals(type.getCode()))) {
  558. continue;
  559. }
  560. unique.add(point.getPointCode().trim());
  561. }
  562. - return unique.size();
  563. + return unique;
  564. }
  565. private static Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types(
  566. --- a/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java
  567. +++ b/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java
  568. @@ -73,13 +73,21 @@
  569. public static class SourceMonitoringPoint {
  570. private final String pointCode;
  571. private final String typeCode;
  572. + /** -1 means an older adapter did not publish the association fact. */
  573. + private final int associatedDeviceCount;
  574. public SourceMonitoringPoint(String pointCode, String typeCode) {
  575. + this(pointCode, typeCode, -1);
  576. + }
  577. +
  578. + public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount) {
  579. this.pointCode = pointCode;
  580. this.typeCode = typeCode;
  581. + this.associatedDeviceCount = associatedDeviceCount;
  582. }
  583. public String getPointCode() { return pointCode; }
  584. public String getTypeCode() { return typeCode; }
  585. + public int getAssociatedDeviceCount() { return associatedDeviceCount; }
  586. }
  587. }