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

feat(cockpit): resolve monitoring point status and detail (#21)

Kazerin 2 дней назад
Родитель
Сommit
a1d0e62488
25 измененных файлов с 1148 добавлено и 62 удалено
  1. 151 0
      docs/cockpit/monitoring-point-detail-demo.html
  2. 87 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailController.java
  3. 62 8
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  4. 261 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailControllerTest.java
  5. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidation.java
  6. 33 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidator.java
  7. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java
  8. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitInvalidCodeException.java
  9. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitMonitoringPointNotFoundException.java
  10. 7 12
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  11. 17 35
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  12. 8 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java
  13. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  14. 31 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDetail.java
  15. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDetailAdapter.java
  16. 53 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDetailResponse.java
  17. 106 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDetailService.java
  18. 45 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceCard.java
  19. 39 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java
  20. 27 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceStatistics.java
  21. 26 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatus.java
  22. 25 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusEntry.java
  23. 25 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusReason.java
  24. 110 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointStatusResolver.java
  25. 11 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/EmptyCockpitMonitoringPointDetailAdapter.java

+ 151 - 0
docs/cockpit/monitoring-point-detail-demo.html

@@ -0,0 +1,151 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Monitoring Point Detail Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#08131f; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1180px,calc(100vw - 32px)); margin:28px auto; display:grid; gap:16px; }
+    header, section { background:#0e1e2d; border:1px solid #21465f; padding:20px; }
+    h1,h2 { margin:0 0 10px; } h1 { font-size:22px; } h2 { font-size:17px; }
+    .endpoint { color:#78a2b8; font-family:Consolas,monospace; margin:0; }
+    .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
+    button { border:1px solid #2f7098; background:#123247; color:#dff2ff; padding:8px 14px; cursor:pointer; }
+    button.active { outline:2px solid #61c2ff; }
+    .summary { display:grid; grid-template-columns:repeat(6,1fr); gap:8px; margin-bottom:14px; }
+    .summary div { background:#0b1922; border:1px solid #275b74; padding:10px; text-align:center; }
+    .summary strong { display:block; font-size:20px; }
+    .summary span { color:#8fb3c7; font-size:12px; }
+    .status { display:inline-block; padding:3px 9px; border-radius:12px; background:#20415a; }
+    .alarm { background:#8b2020; } .warning { background:#8a6510; } .work-order { background:#1f5e91; }
+    .normal { background:#197343; } .offline { color:#a9b7c0; background:#38424a; } .empty { background:#48515a; }
+    table { width:100%; border-collapse:collapse; }
+    th,td { border:1px solid #275b74; padding:8px; text-align:left; }
+    th { background:#123247; } td.mono { font-family:Consolas,monospace; }
+    .reasons { margin:0; padding-left:18px; } .note { color:#8fb3c7; font-size:13px; }
+    pre { max-height:260px; overflow:auto; background:#071019; border:1px solid #275b74; padding:12px; }
+  </style>
+</head>
+<body>
+<main>
+  <header>
+    <h1>驾驶舱监测点状态与详情演示</h1>
+    <p class="endpoint">GET /api/cockpit/v1/monitoring-points/{monitoringPointCode}</p>
+  </header>
+  <section>
+    <div class="toolbar">
+      <button type="button" data-mode="alarm" class="active">报警优先</button>
+      <button type="button" data-mode="workOrder">工单处置</button>
+      <button type="button" data-mode="offline">离线</button>
+      <button type="button" data-mode="empty">无设备</button>
+      <button type="button" data-mode="notFound">404</button>
+    </div>
+    <div id="detail">
+      <div class="summary" id="statistics"></div>
+      <h2>设备卡片(后端排序)</h2>
+      <table>
+        <thead><tr><th>设备编码</th><th>设备名称</th><th>类型</th><th>状态</th><th>状态时间</th></tr></thead>
+        <tbody id="devices"></tbody>
+      </table>
+    </div>
+    <div id="error" hidden>
+      <h2 id="error-title"></h2>
+      <pre id="error-payload"></pre>
+    </div>
+  </section>
+  <section>
+    <h2>状态原因与状态字典</h2>
+    <p class="note">监测点状态由后端按 ALARM &gt; WARNING &gt; WORK_ORDER &gt; NORMAL &gt; OFFLINE &gt; EMPTY 解析。</p>
+    <ul id="reasons" class="reasons"></ul>
+    <pre id="payload"></pre>
+  </section>
+</main>
+<script>
+  var statusClass = {
+    ALARM: 'alarm', WARNING: 'warning', WORK_ORDER: 'work-order',
+    NORMAL: 'normal', OFFLINE: 'offline', EMPTY: 'empty'
+  };
+  var responses = {
+    alarm: response('MP-001', '供水干管监测点', 'ALARM', [
+      device('EQ-001', '供水压力计-001', '压力计', 'ALARM', '2026-09-02T09:59:00+08:00', ['压力超高']),
+      device('EQ-003', '燃气浓度计-003', '气体检测仪', 'ALARM', '2026-09-02T09:59:10+08:00', ['浓度超限']),
+      device('EQ-005', '污水环境监测仪-005', '环境监测仪', 'WARNING', '2026-09-02T09:59:20+08:00', ['氨氮波动']),
+      device('EQ-002', '智能井盖-002', '智能井盖', 'NORMAL', '2026-09-02T09:59:30+08:00', []),
+      device('EQ-004', '供水流量计-004', '流量计', 'OFFLINE', '2026-09-02T09:59:40+08:00', [])
+    ]),
+    workOrder: response('MP-WORK', '泵站监测点', 'WORK_ORDER', [
+      device('EQ-WORK', '泵站振动监测器', '振动监测器', 'WORK_ORDER', '2026-09-02T09:58:00+08:00', ['维修处置中'])
+    ]),
+    offline: response('MP-OFFLINE', '排口监测点', 'OFFLINE', [
+      device('EQ-OFFLINE', '污水液位计', '液位计', 'OFFLINE', '2026-09-02T09:57:00+08:00', ['连接超时'])
+    ]),
+    empty: response('MP-EMPTY', '预留监测点', 'EMPTY', []),
+    notFound: {
+      code: 404, msg: '监测点不存在',
+      data: { category: 'NOT_FOUND', message: '监测点不存在', requestId: 'point-detail-demo-404', serverTime: '2026-09-02T10:00:00+08:00' }
+    }
+  };
+  function device(code, name, type, status, time, reasons) {
+    return { deviceCode: code, deviceName: name, deviceTypeName: type, deviceStatus: status,
+      statusLabel: label(status), statusReasons: reasons, statusTime: time };
+  }
+  function label(status) {
+    return { ALARM:'报警', WARNING:'预警', WORK_ORDER:'工单处置', NORMAL:'正常', OFFLINE:'离线', EMPTY:'无设备' }[status];
+  }
+  function response(code, name, status, devices) {
+    return {
+      code: 200, msg: '操作成功',
+      data: {
+        requestId: 'point-detail-demo-' + status.toLowerCase(), apiVersion: 'v1', readOnly: true,
+        monitoringPointCode: code, monitoringPointName: name, pointStatus: status, statusLabel: label(status),
+        statusReasons: devices.filter(function (item) { return item.deviceStatus === status; })
+          .flatMap(function (item) { return item.statusReasons.map(function (reason) { return { status: status, deviceCode: item.deviceCode, reason: reason }; }); }),
+        deviceStatistics: {
+          totalDeviceCount: devices.length,
+          alarmDeviceCount: count(devices, 'ALARM'), warningDeviceCount: count(devices, 'WARNING'),
+          workOrderDeviceCount: count(devices, 'WORK_ORDER'), normalDeviceCount: count(devices, 'NORMAL'),
+          offlineDeviceCount: count(devices, 'OFFLINE')
+        },
+        devices: devices,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+        statusDictionary: ['ALARM','WARNING','WORK_ORDER','NORMAL','OFFLINE','EMPTY'].map(function (item, index) {
+          return { status: item, label: label(item), priority: index + 1 };
+        })
+      }
+    };
+  }
+  function count(devices, status) { return devices.filter(function (item) { return item.deviceStatus === status; }).length; }
+  function render(mode) {
+    document.querySelectorAll('button[data-mode]').forEach(function (button) { button.classList.toggle('active', button.dataset.mode === mode); });
+    var payload = responses[mode];
+    var success = payload.code === 200;
+    document.getElementById('detail').hidden = !success;
+    document.getElementById('error').hidden = success;
+    if (success) {
+      var data = payload.data;
+      var stats = data.deviceStatistics;
+      document.getElementById('statistics').innerHTML = [
+        ['总设备', stats.totalDeviceCount], ['报警', stats.alarmDeviceCount], ['预警', stats.warningDeviceCount],
+        ['工单', stats.workOrderDeviceCount], ['正常', stats.normalDeviceCount], ['离线', stats.offlineDeviceCount]
+      ].map(function (item) { return '<div><strong>' + item[1] + '</strong><span>' + item[0] + '</span></div>'; }).join('');
+      document.getElementById('devices').innerHTML = data.devices.map(function (item) {
+        return '<tr><td class="mono">' + item.deviceCode + '</td><td>' + item.deviceName + '</td><td>' + item.deviceTypeName +
+          '</td><td><span class="status ' + statusClass[item.deviceStatus] + '">' + item.statusLabel + '</span></td><td class="mono">' + item.statusTime + '</td></tr>';
+      }).join('');
+      document.getElementById('reasons').innerHTML = data.statusReasons.length
+        ? data.statusReasons.map(function (item) { return '<li><strong>' + item.deviceCode + '</strong>:' + item.reason + '</li>'; }).join('')
+        : '<li>无对应状态原因</li>';
+    } else {
+      document.getElementById('error-title').textContent = 'HTTP ' + payload.code + ':' + payload.msg;
+      document.getElementById('error-payload').textContent = JSON.stringify(payload, null, 2);
+    }
+    document.getElementById('payload').textContent = JSON.stringify(payload, null, 2);
+  }
+  document.querySelectorAll('button[data-mode]').forEach(function (button) {
+    button.addEventListener('click', function () { render(button.dataset.mode); });
+  });
+  render('alarm');
+</script>
+</body>
+</html>

+ 87 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringPointDetailController.java

@@ -0,0 +1,87 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidCodeException;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitMonitoringPointNotFoundException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailResponse;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriUtils;
+
+import java.nio.charset.StandardCharsets;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitMonitoringPointDetailController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitMonitoringPointDetailService detailService;
+
+    public CockpitMonitoringPointDetailController(CockpitMonitoringPointDetailService detailService) {
+        this.detailService = detailService;
+    }
+
+    @GetMapping("/api/cockpit/v1/monitoring-points/{monitoringPointCode}")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> monitoringPointDetail(
+            @PathVariable("monitoringPointCode") String monitoringPointCode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitMonitoringPointDetailResponse response = detailService.load(
+                resolveRequestId(requestId), decodePathSegment(monitoringPointCode));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST, detailService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitInvalidCodeException.class)
+    public ResponseEntity<AjaxResult> handleInvalidCode(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST, detailService.invalidCode(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitMonitoringPointNotFoundException.class)
+    public ResponseEntity<AjaxResult> handleNotFound(HttpServletRequest request) {
+        return error(HttpStatus.NOT_FOUND, detailService.notFound(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, detailService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
+        return ResponseEntity.status(status)
+                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+
+    private String decodePathSegment(String value) {
+        try {
+            return UriUtils.decode(value, StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException exception) {
+            throw new CockpitInvalidCodeException("invalid monitoring point code encoding");
+        }
+    }
+}

+ 62 - 8
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java

@@ -6,6 +6,7 @@ import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
 import com.zksy.cockpit.map.CockpitMapSnapshot;
 import com.zksy.cockpit.map.CockpitMapSnapshotAdapter;
 import com.zksy.cockpit.map.CockpitMapSnapshotService;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.test.web.servlet.MockMvc;
@@ -18,6 +19,7 @@ import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.List;
 
 import static org.hamcrest.Matchers.hasSize;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -67,7 +69,7 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(3)))
                 .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("NORMAL"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
@@ -81,6 +83,38 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(2));
     }
 
+    @Test
+    void mapSummaryResolvesPointStatusAcrossAllSixStatesFromDeviceFacts() throws Exception {
+        when(adapter.load(null)).thenReturn(new CockpitMapSnapshot(
+                SNAPSHOT_AT,
+                Arrays.asList(
+                        sourcePoint("MP-ALARM", Collections.singletonList(
+                                device("EQ-ALARM", true, false, false, true,
+                                        Collections.singletonList("浓度超限")))),
+                        sourcePoint("MP-EMPTY", Collections.emptyList()),
+                        sourcePoint("MP-NORMAL", Collections.singletonList(
+                                device("EQ-NORMAL", false, false, false, true, Collections.emptyList()))),
+                        sourcePoint("MP-OFFLINE", Collections.singletonList(
+                                device("EQ-OFFLINE", false, false, false, false, Collections.emptyList()))),
+                        sourcePoint("MP-WARNING", Collections.singletonList(
+                                device("EQ-WARNING", false, true, false, true,
+                                        Collections.singletonList("流量波动")))),
+                        sourcePoint("MP-WORK-ORDER", Collections.singletonList(
+                                device("EQ-WORK", false, false, true, true, Collections.emptyList())))),
+                Collections.emptyList()));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("ALARM"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].statusReasons[0]").value("EQ-ALARM 浓度超限"))
+                .andExpect(jsonPath("$.data.monitoringPoints[1].pointStatus").value("EMPTY"))
+                .andExpect(jsonPath("$.data.monitoringPoints[2].pointStatus").value("NORMAL"))
+                .andExpect(jsonPath("$.data.monitoringPoints[3].pointStatus").value("OFFLINE"))
+                .andExpect(jsonPath("$.data.monitoringPoints[4].pointStatus").value("WARNING"))
+                .andExpect(jsonPath("$.data.monitoringPoints[4].statusReasons[0]").value("EQ-WARNING 流量波动"))
+                .andExpect(jsonPath("$.data.monitoringPoints[5].pointStatus").value("WORK_ORDER"));
+    }
+
     @Test
     void pagesShareOneSnapshotAndDoNotDuplicateOrOmitObjects() throws Exception {
         when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
@@ -234,17 +268,14 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-002",
                                 "燃气调压站监测点",
-                                "ALARM",
-                                Collections.singletonList("EQ-ASSOC-001 报警"),
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 true,
-                                Collections.singletonList("EQ-ASSOC-001")),
+                                Collections.singletonList(device(
+                                        "EQ-ASSOC-001", true, false, false, true, Collections.emptyList()))),
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-001",
                                 "供水干管监测点",
-                                "NORMAL",
-                                Collections.emptyList(),
                                 new BigDecimal("110.141"),
                                 new BigDecimal("28.452"),
                                 true,
@@ -252,8 +283,6 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-003",
                                 "污水排口监测点",
-                                "WARNING",
-                                Collections.singletonList("EQ-FREE-003 预警"),
                                 new BigDecimal("110.143"),
                                 new BigDecimal("28.454"),
                                 false,
@@ -290,4 +319,29 @@ class CockpitMapSnapshotControllerTest {
                                 BigDecimal.ZERO,
                                 true)));
     }
+
+    private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode,
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return new CockpitMapSnapshot.SourceMonitoringPoint(
+                pointCode,
+                pointCode + "监测点",
+                new BigDecimal("110.141"),
+                new BigDecimal("28.452"),
+                true,
+                devices);
+    }
+
+    private static CockpitMonitoringPointDeviceSnapshot device(String deviceCode, boolean alarm, boolean warning,
+            boolean workOrderInProgress, boolean online, List<String> statusReasons) {
+        return new CockpitMonitoringPointDeviceSnapshot(
+                deviceCode,
+                deviceCode + "设备",
+                "智能设备",
+                online,
+                alarm,
+                warning,
+                workOrderInProgress,
+                statusReasons,
+                SNAPSHOT_AT);
+    }
 }

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

@@ -0,0 +1,261 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetail;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailAdapter;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailService;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitMonitoringPointDetailControllerTest {
+
+    private CockpitMonitoringPointDetailAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitMonitoringPointDetailAdapter.class);
+        CockpitMonitoringPointDetailService service = new CockpitMonitoringPointDetailService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        CockpitMonitoringPointDetailController controller = new CockpitMonitoringPointDetailController(service);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void detailReturnsReadOnlyContractWithResolvedStatusReasonsStatisticsAndCards() throws Exception {
+        when(adapter.load("MP-001")).thenReturn(detail("MP-001", Arrays.asList(
+                device("EQ-003", true, false, false, true, Collections.singletonList("浓度超限")),
+                device("EQ-001", true, false, false, true, Collections.singletonList("压力超高")),
+                device("EQ-005", false, true, false, true, Collections.singletonList("流量波动")),
+                device("EQ-002", false, false, false, true, Collections.emptyList()),
+                device("EQ-004", false, false, false, false, Collections.emptyList()))));
+
+        mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-001")
+                        .header("X-Request-Id", "point-detail-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("point-detail-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.monitoringPointCode").value("MP-001"))
+                .andExpect(jsonPath("$.data.monitoringPointName").value("供水干管监测点"))
+                .andExpect(jsonPath("$.data.pointStatus").value("ALARM"))
+                .andExpect(jsonPath("$.data.statusLabel").value("报警"))
+                .andExpect(jsonPath("$.data.statusReasons[0].status").value("ALARM"))
+                .andExpect(jsonPath("$.data.statusReasons[0].deviceCode").value("EQ-003"))
+                .andExpect(jsonPath("$.data.statusReasons[0].reason").value("浓度超限"))
+                .andExpect(jsonPath("$.data.statusReasons[1].deviceCode").value("EQ-001"))
+                .andExpect(jsonPath("$.data.statusReasons[1].reason").value("压力超高"))
+                .andExpect(jsonPath("$.data.deviceStatistics.totalDeviceCount").value(5))
+                .andExpect(jsonPath("$.data.deviceStatistics.alarmDeviceCount").value(2))
+                .andExpect(jsonPath("$.data.deviceStatistics.warningDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.deviceStatistics.workOrderDeviceCount").value(0))
+                .andExpect(jsonPath("$.data.deviceStatistics.normalDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.deviceStatistics.offlineDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.devices", hasSize(5)))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-001"))
+                .andExpect(jsonPath("$.data.devices[0].deviceStatus").value("ALARM"))
+                .andExpect(jsonPath("$.data.devices[0].statusLabel").value("报警"))
+                .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-003"))
+                .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-005"))
+                .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-002"))
+                .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-004"))
+                .andExpect(jsonPath("$.data.coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.coordinateValidation.renderable").value(true))
+                .andExpect(jsonPath("$.data.statusDictionary", hasSize(6)))
+                .andExpect(jsonPath("$.data.statusDictionary[0].status").value("ALARM"))
+                .andExpect(jsonPath("$.data.statusDictionary[0].label").value("报警"))
+                .andExpect(jsonPath("$.data.statusDictionary[0].priority").value(1))
+                .andExpect(jsonPath("$.data.statusDictionary[2].status").value("WORK_ORDER"))
+                .andExpect(jsonPath("$.data.statusDictionary[2].label").value("工单处置"))
+                .andExpect(jsonPath("$.data.statusDictionary[2].priority").value(3))
+                .andExpect(jsonPath("$.data.statusDictionary[5].status").value("EMPTY"))
+                .andExpect(jsonPath("$.data.statusDictionary[5].label").value("无设备"))
+                .andExpect(jsonPath("$.data.statusDictionary[5].priority").value(6));
+    }
+
+    @Test
+    void pointStatusUsesFixedPrecedenceAcrossAllSixStates() throws Exception {
+        for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
+            String pointCode = "MP-" + status.name();
+            when(adapter.load(pointCode)).thenReturn(detail(pointCode, statusFixture(status)));
+
+            mockMvc.perform(get("/api/cockpit/v1/monitoring-points/" + pointCode))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data.pointStatus").value(status.name()));
+        }
+    }
+
+    @Test
+    void cardsUseStatusPriorityThenDeviceCodeForStableOrdering() throws Exception {
+        when(adapter.load("MP-ORDER")).thenReturn(detail("MP-ORDER", Arrays.asList(
+                device("EQ-003", true, false, false, true, Collections.emptyList()),
+                device("EQ-001", true, false, false, true, Collections.emptyList()),
+                device("EQ-005", false, true, false, true, Collections.emptyList()),
+                device("EQ-006", false, false, true, true, Collections.emptyList()),
+                device("EQ-002", false, false, false, true, Collections.emptyList()),
+                device("EQ-004", false, false, false, false, Collections.emptyList()))));
+
+        mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-ORDER"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-001"))
+                .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-003"))
+                .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-005"))
+                .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-006"))
+                .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-002"))
+                .andExpect(jsonPath("$.data.devices[5].deviceCode").value("EQ-004"));
+    }
+
+    @Test
+    void missingPointReturnsTypedNotFound() throws Exception {
+        when(adapter.load("MP-404")).thenReturn(null);
+
+        mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-404")
+                        .header("X-Request-Id", "point-detail-request-404"))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.code").value(404))
+                .andExpect(jsonPath("$.data.category").value("NOT_FOUND"))
+                .andExpect(jsonPath("$.data.message").value("监测点不存在"))
+                .andExpect(jsonPath("$.data.requestId").value("point-detail-request-404"));
+    }
+
+    @Test
+    void blankCodeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/monitoring-points/%20"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("监测点编码不能为空"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void malformedPathEncodingReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/monitoring-points/%ZZ"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("监测点编码不合法"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load("MP-500")).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/monitoring-points/MP-500"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱监测点详情数据暂不可用"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/monitoring-points/MP-001"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitMonitoringPointDetailController.class.getDeclaredMethods())
+                .filter(this::hasRequestMappingAnnotation)
+                .allMatch(CockpitMonitoringPointDetailControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitMonitoringPointDetailController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(org.springframework.security.access.prepost.PreAuthorize.class)
+                        && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class)
+                        .value()
+                        .equals("isAuthenticated()")));
+    }
+
+    private boolean hasRequestMappingAnnotation(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static List<CockpitMonitoringPointDeviceSnapshot> statusFixture(CockpitMonitoringPointStatus status) {
+        switch (status) {
+            case ALARM:
+                return Collections.singletonList(device("EQ-ALARM", true, false, false, true, Collections.emptyList()));
+            case WARNING:
+                return Collections.singletonList(device("EQ-WARNING", false, true, false, true, Collections.emptyList()));
+            case WORK_ORDER:
+                return Collections.singletonList(device("EQ-WORK", false, false, true, true, Collections.emptyList()));
+            case NORMAL:
+                return Collections.singletonList(device("EQ-NORMAL", false, false, false, true, Collections.emptyList()));
+            case OFFLINE:
+                return Collections.singletonList(device("EQ-OFFLINE", false, false, false, false, Collections.emptyList()));
+            case EMPTY:
+                return Collections.emptyList();
+            default:
+                throw new AssertionError("unknown status: " + status);
+        }
+    }
+
+    private static CockpitMonitoringPointDetail detail(String pointCode,
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return new CockpitMonitoringPointDetail(
+                pointCode,
+                "供水干管监测点",
+                new BigDecimal("110.141"),
+                new BigDecimal("28.452"),
+                true,
+                devices);
+    }
+
+    private static CockpitMonitoringPointDeviceSnapshot device(String deviceCode, boolean alarm, boolean warning,
+            boolean workOrderInProgress, boolean online, List<String> statusReasons) {
+        return new CockpitMonitoringPointDeviceSnapshot(
+                deviceCode,
+                deviceCode + "设备",
+                "压力计",
+                online,
+                alarm,
+                warning,
+                workOrderInProgress,
+                statusReasons,
+                "2026-09-02T09:59:00+08:00");
+    }
+}

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitCoordinateValidation.java → pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidation.java

@@ -1,4 +1,4 @@
-package com.zksy.cockpit.map;
+package com.zksy.cockpit.foundation;
 
 public class CockpitCoordinateValidation {
     private final String state;

+ 33 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCoordinateValidator.java

@@ -0,0 +1,33 @@
+package com.zksy.cockpit.foundation;
+
+import java.math.BigDecimal;
+
+public final class CockpitCoordinateValidator {
+    private static final String WGS84 = "WGS84";
+
+    private CockpitCoordinateValidator() {
+    }
+
+    public static CockpitCoordinateValidation validate(BigDecimal longitude, BigDecimal latitude,
+            boolean coordinateVerified) {
+        if (!coordinateVerified) {
+            return new CockpitCoordinateValidation("UNVERIFIED", "UNVERIFIED_COORDINATE", false, WGS84);
+        }
+        if (longitude == null) {
+            return new CockpitCoordinateValidation("INVALID", "MISSING_LONGITUDE", false, WGS84);
+        }
+        if (latitude == null) {
+            return new CockpitCoordinateValidation("INVALID", "MISSING_LATITUDE", false, WGS84);
+        }
+        if (longitude.compareTo(new BigDecimal("-180")) < 0 || longitude.compareTo(new BigDecimal("180")) > 0) {
+            return new CockpitCoordinateValidation("INVALID", "LONGITUDE_OUT_OF_RANGE", false, WGS84);
+        }
+        if (latitude.compareTo(new BigDecimal("-90")) < 0 || latitude.compareTo(new BigDecimal("90")) > 0) {
+            return new CockpitCoordinateValidation("INVALID", "LATITUDE_OUT_OF_RANGE", false, WGS84);
+        }
+        if (longitude.signum() == 0 && latitude.signum() == 0) {
+            return new CockpitCoordinateValidation("INVALID", "ZERO_COORDINATE", false, WGS84);
+        }
+        return new CockpitCoordinateValidation("VALID", null, true, WGS84);
+    }
+}

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

@@ -4,5 +4,6 @@ public enum CockpitErrorCategory {
     UNAUTHORIZED,
     SERVER_ERROR,
     INVALID_REQUEST,
-    SNAPSHOT_EXPIRED
+    SNAPSHOT_EXPIRED,
+    NOT_FOUND
 }

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitInvalidCodeException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitInvalidCodeException extends CockpitInvalidRequestException {
+    public CockpitInvalidCodeException(String message) {
+        super(message);
+    }
+}

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitMonitoringPointNotFoundException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitMonitoringPointNotFoundException extends RuntimeException {
+    public CockpitMonitoringPointNotFoundException(String message) {
+        super(message);
+    }
+}

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

@@ -1,5 +1,7 @@
 package com.zksy.cockpit.map;
 
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+
 import java.math.BigDecimal;
 import java.util.Collections;
 import java.util.List;
@@ -35,34 +37,27 @@ public class CockpitMapSnapshot {
     public static class SourceMonitoringPoint {
         private final String pointCode;
         private final String pointName;
-        private final String pointStatus;
-        private final List<String> statusReasons;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
-        private final List<String> deviceCodes;
+        private final List<CockpitMonitoringPointDeviceSnapshot> devices;
 
-        public SourceMonitoringPoint(String pointCode, String pointName, String pointStatus,
-                List<String> statusReasons, BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
-                List<String> deviceCodes) {
+        public SourceMonitoringPoint(String pointCode, String pointName, BigDecimal longitude, BigDecimal latitude,
+                boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
             this.pointCode = pointCode;
             this.pointName = pointName;
-            this.pointStatus = pointStatus;
-            this.statusReasons = statusReasons;
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
-            this.deviceCodes = deviceCodes;
+            this.devices = devices;
         }
 
         public String getPointCode() { return pointCode; }
         public String getPointName() { return pointName; }
-        public String getPointStatus() { return pointStatus; }
-        public List<String> getStatusReasons() { return statusReasons; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public boolean isCoordinateVerified() { return coordinateVerified; }
-        public List<String> getDeviceCodes() { return deviceCodes; }
+        public List<CockpitMonitoringPointDeviceSnapshot> getDevices() { return devices; }
     }
 
     public static class SourceSmartDevice {

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

@@ -1,9 +1,14 @@
 package com.zksy.cockpit.map;
 
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -15,7 +20,6 @@ import java.time.format.DateTimeParseException;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
-import java.util.Locale;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -140,20 +144,19 @@ public class CockpitMapSnapshotService {
 
     private static CockpitMonitoringPointSummary monitoringPoint(
             CockpitMapSnapshot.SourceMonitoringPoint source) {
+        List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
+                ? Collections.emptyList()
+                : source.getDevices();
+        CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
         return new CockpitMonitoringPointSummary(
                 source.getPointCode(),
                 source.getPointName(),
-                pointStatus(source.getPointStatus()),
-                source.getStatusReasons() == null ? Collections.emptyList() : source.getStatusReasons(),
+                pointStatus,
+                CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
                 source.getLongitude(),
                 source.getLatitude(),
-                validate(source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
-    }
-
-    private static String pointStatus(String status) {
-        return status == null || status.trim().isEmpty()
-                ? "UNKNOWN"
-                : status.trim().toUpperCase(Locale.ROOT);
+                CockpitCoordinateValidator.validate(
+                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
     }
 
     private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(CockpitMapSnapshot snapshot) {
@@ -174,9 +177,10 @@ public class CockpitMapSnapshotService {
             return Collections.emptySet();
         }
         return snapshot.getMonitoringPoints().stream()
-                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDeviceCodes)
+                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
                 .filter(Objects::nonNull)
                 .flatMap(List::stream)
+                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
                 .filter(Objects::nonNull)
                 .collect(Collectors.toSet());
     }
@@ -188,36 +192,14 @@ public class CockpitMapSnapshotService {
                 source.getDeviceName(),
                 source.getLongitude(),
                 source.getLatitude(),
-                validate(source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
+                CockpitCoordinateValidator.validate(
+                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
     }
 
     private static int clamp(int value, int minimum, int maximum) {
         return Math.max(minimum, Math.min(value, maximum));
     }
 
-    private static CockpitCoordinateValidation validate(BigDecimal longitude, BigDecimal latitude,
-            boolean coordinateVerified) {
-        if (!coordinateVerified) {
-            return new CockpitCoordinateValidation("UNVERIFIED", "UNVERIFIED_COORDINATE", false, WGS84);
-        }
-        if (longitude == null) {
-            return new CockpitCoordinateValidation("INVALID", "MISSING_LONGITUDE", false, WGS84);
-        }
-        if (latitude == null) {
-            return new CockpitCoordinateValidation("INVALID", "MISSING_LATITUDE", false, WGS84);
-        }
-        if (longitude.compareTo(new BigDecimal("-180")) < 0 || longitude.compareTo(new BigDecimal("180")) > 0) {
-            return new CockpitCoordinateValidation("INVALID", "LONGITUDE_OUT_OF_RANGE", false, WGS84);
-        }
-        if (latitude.compareTo(new BigDecimal("-90")) < 0 || latitude.compareTo(new BigDecimal("90")) > 0) {
-            return new CockpitCoordinateValidation("INVALID", "LATITUDE_OUT_OF_RANGE", false, WGS84);
-        }
-        if (longitude.signum() == 0 && latitude.signum() == 0) {
-            return new CockpitCoordinateValidation("INVALID", "ZERO_COORDINATE", false, WGS84);
-        }
-        return new CockpitCoordinateValidation("VALID", null, true, WGS84);
-    }
-
     private static CockpitCoordinateValidationSummary coordinateValidation(
             List<CockpitMonitoringPointSummary> points, List<CockpitUnassociatedEquipmentSummary> equipment) {
         int renderableCount = 0;

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

@@ -1,20 +1,23 @@
 package com.zksy.cockpit.map;
 
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+
 import java.math.BigDecimal;
 import java.util.List;
 
 public class CockpitMonitoringPointSummary {
     private final String pointCode;
     private final String pointName;
-    private final String pointStatus;
+    private final CockpitMonitoringPointStatus pointStatus;
     private final List<String> statusReasons;
     private final BigDecimal longitude;
     private final BigDecimal latitude;
     private final CockpitCoordinateValidation coordinateValidation;
 
-    public CockpitMonitoringPointSummary(String pointCode, String pointName, String pointStatus,
-            List<String> statusReasons, BigDecimal longitude, BigDecimal latitude,
-            CockpitCoordinateValidation coordinateValidation) {
+    public CockpitMonitoringPointSummary(String pointCode, String pointName,
+            CockpitMonitoringPointStatus pointStatus, List<String> statusReasons, BigDecimal longitude,
+            BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
         this.pointCode = pointCode;
         this.pointName = pointName;
         this.pointStatus = pointStatus;
@@ -26,7 +29,7 @@ public class CockpitMonitoringPointSummary {
 
     public String getPointCode() { return pointCode; }
     public String getPointName() { return pointName; }
-    public String getPointStatus() { return pointStatus; }
+    public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
     public List<String> getStatusReasons() { return statusReasons; }
     public BigDecimal getLongitude() { return longitude; }
     public BigDecimal getLatitude() { return latitude; }

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

@@ -1,5 +1,7 @@
 package com.zksy.cockpit.map;
 
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+
 import java.math.BigDecimal;
 
 public class CockpitUnassociatedEquipmentSummary {

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

@@ -0,0 +1,31 @@
+package com.zksy.cockpit.point;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitMonitoringPointDetail {
+    private final String monitoringPointCode;
+    private final String monitoringPointName;
+    private final BigDecimal longitude;
+    private final BigDecimal latitude;
+    private final boolean coordinateVerified;
+    private final List<CockpitMonitoringPointDeviceSnapshot> devices;
+
+    public CockpitMonitoringPointDetail(String monitoringPointCode, String monitoringPointName,
+            BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        this.monitoringPointCode = monitoringPointCode;
+        this.monitoringPointName = monitoringPointName;
+        this.longitude = longitude;
+        this.latitude = latitude;
+        this.coordinateVerified = coordinateVerified;
+        this.devices = devices;
+    }
+
+    public String getMonitoringPointCode() { return monitoringPointCode; }
+    public String getMonitoringPointName() { return monitoringPointName; }
+    public BigDecimal getLongitude() { return longitude; }
+    public BigDecimal getLatitude() { return latitude; }
+    public boolean isCoordinateVerified() { return coordinateVerified; }
+    public List<CockpitMonitoringPointDeviceSnapshot> getDevices() { return devices; }
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.point;
+
+public interface CockpitMonitoringPointDetailAdapter {
+    CockpitMonitoringPointDetail load(String monitoringPointCode);
+}

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

@@ -0,0 +1,53 @@
+package com.zksy.cockpit.point;
+
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+
+import java.util.List;
+
+public class CockpitMonitoringPointDetailResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String monitoringPointCode;
+    private final String monitoringPointName;
+    private final CockpitMonitoringPointStatus pointStatus;
+    private final String statusLabel;
+    private final List<CockpitMonitoringPointStatusReason> statusReasons;
+    private final CockpitMonitoringPointDeviceStatistics deviceStatistics;
+    private final List<CockpitMonitoringPointDeviceCard> devices;
+    private final CockpitCoordinateValidation coordinateValidation;
+    private final List<CockpitMonitoringPointStatusEntry> statusDictionary;
+
+    public CockpitMonitoringPointDetailResponse(String requestId, String apiVersion, boolean readOnly,
+            String monitoringPointCode, String monitoringPointName, CockpitMonitoringPointStatus pointStatus,
+            List<CockpitMonitoringPointStatusReason> statusReasons,
+            CockpitMonitoringPointDeviceStatistics deviceStatistics,
+            List<CockpitMonitoringPointDeviceCard> devices, CockpitCoordinateValidation coordinateValidation,
+            List<CockpitMonitoringPointStatusEntry> statusDictionary) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.monitoringPointCode = monitoringPointCode;
+        this.monitoringPointName = monitoringPointName;
+        this.pointStatus = pointStatus;
+        this.statusLabel = pointStatus.getLabel();
+        this.statusReasons = statusReasons;
+        this.deviceStatistics = deviceStatistics;
+        this.devices = devices;
+        this.coordinateValidation = coordinateValidation;
+        this.statusDictionary = statusDictionary;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getMonitoringPointCode() { return monitoringPointCode; }
+    public String getMonitoringPointName() { return monitoringPointName; }
+    public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
+    public String getStatusLabel() { return statusLabel; }
+    public List<CockpitMonitoringPointStatusReason> getStatusReasons() { return statusReasons; }
+    public CockpitMonitoringPointDeviceStatistics getDeviceStatistics() { return deviceStatistics; }
+    public List<CockpitMonitoringPointDeviceCard> getDevices() { return devices; }
+    public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+    public List<CockpitMonitoringPointStatusEntry> getStatusDictionary() { return statusDictionary; }
+}

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

@@ -0,0 +1,106 @@
+package com.zksy.cockpit.point;
+
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitMonitoringPointNotFoundException;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class CockpitMonitoringPointDetailService {
+    private final CockpitMonitoringPointDetailAdapter adapter;
+    private final Clock clock;
+
+    public CockpitMonitoringPointDetailService(CockpitMonitoringPointDetailAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitMonitoringPointDetailResponse load(String requestId, String monitoringPointCode) {
+        if (isBlank(monitoringPointCode)) {
+            throw new CockpitInvalidRequestException("monitoring point code is required");
+        }
+        CockpitMonitoringPointDetail detail = adapter.load(monitoringPointCode.trim());
+        if (detail == null) {
+            throw new CockpitMonitoringPointNotFoundException("monitoring point not found");
+        }
+
+        List<CockpitMonitoringPointDeviceSnapshot> devices = detail.getDevices() == null
+                ? Collections.emptyList()
+                : detail.getDevices();
+        CockpitMonitoringPointStatus pointStatus =
+                CockpitMonitoringPointStatusResolver.resolve(devices);
+        CockpitCoordinateValidation coordinateValidation = CockpitCoordinateValidator.validate(
+                detail.getLongitude(), detail.getLatitude(), detail.isCoordinateVerified());
+
+        return new CockpitMonitoringPointDetailResponse(
+                requestId,
+                "v1",
+                true,
+                detail.getMonitoringPointCode(),
+                detail.getMonitoringPointName(),
+                pointStatus,
+                CockpitMonitoringPointStatusResolver.reasons(pointStatus, devices),
+                CockpitMonitoringPointStatusResolver.statistics(devices),
+                CockpitMonitoringPointStatusResolver.cards(devices),
+                coordinateValidation,
+                statusDictionary());
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                "监测点编码不能为空",
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload invalidCode(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                "监测点编码不合法",
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload notFound(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.NOT_FOUND,
+                "监测点不存在",
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱监测点详情数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private static List<CockpitMonitoringPointStatusEntry> statusDictionary() {
+        return Arrays.stream(CockpitMonitoringPointStatus.values())
+                .map(CockpitMonitoringPointStatusEntry::new)
+                .collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}

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

@@ -0,0 +1,45 @@
+package com.zksy.cockpit.point;
+
+import java.util.List;
+
+public class CockpitMonitoringPointDeviceCard {
+    private final String deviceCode;
+    private final String deviceName;
+    private final String deviceTypeName;
+    private final boolean online;
+    private final boolean alarm;
+    private final boolean warning;
+    private final boolean workOrderInProgress;
+    private final List<String> statusReasons;
+    private final String statusTime;
+    private final CockpitMonitoringPointStatus deviceStatus;
+    private final String statusLabel;
+
+    public CockpitMonitoringPointDeviceCard(String deviceCode, String deviceName, String deviceTypeName,
+            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+            List<String> statusReasons, String statusTime, CockpitMonitoringPointStatus deviceStatus) {
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.deviceTypeName = deviceTypeName;
+        this.online = online;
+        this.alarm = alarm;
+        this.warning = warning;
+        this.workOrderInProgress = workOrderInProgress;
+        this.statusReasons = statusReasons;
+        this.statusTime = statusTime;
+        this.deviceStatus = deviceStatus;
+        this.statusLabel = deviceStatus.getLabel();
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public String getDeviceTypeName() { return deviceTypeName; }
+    public boolean isOnline() { return online; }
+    public boolean isAlarm() { return alarm; }
+    public boolean isWarning() { return warning; }
+    public boolean isWorkOrderInProgress() { return workOrderInProgress; }
+    public List<String> getStatusReasons() { return statusReasons; }
+    public String getStatusTime() { return statusTime; }
+    public CockpitMonitoringPointStatus getDeviceStatus() { return deviceStatus; }
+    public String getStatusLabel() { return statusLabel; }
+}

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

@@ -0,0 +1,39 @@
+package com.zksy.cockpit.point;
+
+import java.util.List;
+
+public class CockpitMonitoringPointDeviceSnapshot {
+    private final String deviceCode;
+    private final String deviceName;
+    private final String deviceTypeName;
+    private final boolean online;
+    private final boolean alarm;
+    private final boolean warning;
+    private final boolean workOrderInProgress;
+    private final List<String> statusReasons;
+    private final String statusTime;
+
+    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
+            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+            List<String> statusReasons, String statusTime) {
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.deviceTypeName = deviceTypeName;
+        this.online = online;
+        this.alarm = alarm;
+        this.warning = warning;
+        this.workOrderInProgress = workOrderInProgress;
+        this.statusReasons = statusReasons;
+        this.statusTime = statusTime;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public String getDeviceTypeName() { return deviceTypeName; }
+    public boolean isOnline() { return online; }
+    public boolean isAlarm() { return alarm; }
+    public boolean isWarning() { return warning; }
+    public boolean isWorkOrderInProgress() { return workOrderInProgress; }
+    public List<String> getStatusReasons() { return statusReasons; }
+    public String getStatusTime() { return statusTime; }
+}

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

@@ -0,0 +1,27 @@
+package com.zksy.cockpit.point;
+
+public class CockpitMonitoringPointDeviceStatistics {
+    private final int totalDeviceCount;
+    private final int alarmDeviceCount;
+    private final int warningDeviceCount;
+    private final int workOrderDeviceCount;
+    private final int normalDeviceCount;
+    private final int offlineDeviceCount;
+
+    public CockpitMonitoringPointDeviceStatistics(int totalDeviceCount, int alarmDeviceCount, int warningDeviceCount,
+            int workOrderDeviceCount, int normalDeviceCount, int offlineDeviceCount) {
+        this.totalDeviceCount = totalDeviceCount;
+        this.alarmDeviceCount = alarmDeviceCount;
+        this.warningDeviceCount = warningDeviceCount;
+        this.workOrderDeviceCount = workOrderDeviceCount;
+        this.normalDeviceCount = normalDeviceCount;
+        this.offlineDeviceCount = offlineDeviceCount;
+    }
+
+    public int getTotalDeviceCount() { return totalDeviceCount; }
+    public int getAlarmDeviceCount() { return alarmDeviceCount; }
+    public int getWarningDeviceCount() { return warningDeviceCount; }
+    public int getWorkOrderDeviceCount() { return workOrderDeviceCount; }
+    public int getNormalDeviceCount() { return normalDeviceCount; }
+    public int getOfflineDeviceCount() { return offlineDeviceCount; }
+}

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

@@ -0,0 +1,26 @@
+package com.zksy.cockpit.point;
+
+public enum CockpitMonitoringPointStatus {
+    ALARM(1, "报警"),
+    WARNING(2, "预警"),
+    WORK_ORDER(3, "工单处置"),
+    NORMAL(4, "正常"),
+    OFFLINE(5, "离线"),
+    EMPTY(6, "无设备");
+
+    private final int priority;
+    private final String label;
+
+    CockpitMonitoringPointStatus(int priority, String label) {
+        this.priority = priority;
+        this.label = label;
+    }
+
+    public int getPriority() {
+        return priority;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+}

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

@@ -0,0 +1,25 @@
+package com.zksy.cockpit.point;
+
+public class CockpitMonitoringPointStatusEntry {
+    private final CockpitMonitoringPointStatus status;
+    private final String label;
+    private final int priority;
+
+    public CockpitMonitoringPointStatusEntry(CockpitMonitoringPointStatus status) {
+        this.status = status;
+        this.label = status.getLabel();
+        this.priority = status.getPriority();
+    }
+
+    public CockpitMonitoringPointStatus getStatus() {
+        return status;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public int getPriority() {
+        return priority;
+    }
+}

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

@@ -0,0 +1,25 @@
+package com.zksy.cockpit.point;
+
+public class CockpitMonitoringPointStatusReason {
+    private final CockpitMonitoringPointStatus status;
+    private final String deviceCode;
+    private final String reason;
+
+    public CockpitMonitoringPointStatusReason(CockpitMonitoringPointStatus status, String deviceCode, String reason) {
+        this.status = status;
+        this.deviceCode = deviceCode;
+        this.reason = reason;
+    }
+
+    public CockpitMonitoringPointStatus getStatus() {
+        return status;
+    }
+
+    public String getDeviceCode() {
+        return deviceCode;
+    }
+
+    public String getReason() {
+        return reason;
+    }
+}

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

@@ -0,0 +1,110 @@
+package com.zksy.cockpit.point;
+
+import java.util.Comparator;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public final class CockpitMonitoringPointStatusResolver {
+    private CockpitMonitoringPointStatusResolver() {
+    }
+
+    public static CockpitMonitoringPointStatus resolve(List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return safeDevices(devices).stream()
+                .map(CockpitMonitoringPointStatusResolver::deviceStatus)
+                .min(Comparator.comparingInt(CockpitMonitoringPointStatus::getPriority))
+                .orElse(CockpitMonitoringPointStatus.EMPTY);
+    }
+
+    public static List<CockpitMonitoringPointStatusReason> reasons(CockpitMonitoringPointStatus status,
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return safeDevices(devices).stream()
+                .filter(device -> deviceStatus(device) == status)
+                .flatMap(device -> reasonsForDevice(status, device).stream())
+                .collect(Collectors.toList());
+    }
+
+    public static List<String> summaryReasons(CockpitMonitoringPointStatus status,
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return reasons(status, devices).stream()
+                .map(reason -> reason.getDeviceCode() + " " + reason.getReason())
+                .collect(Collectors.toList());
+    }
+
+    public static List<CockpitMonitoringPointDeviceCard> cards(List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return orderedDevices(devices).stream()
+                .map(device -> new CockpitMonitoringPointDeviceCard(
+                        device.getDeviceCode(),
+                        device.getDeviceName(),
+                        device.getDeviceTypeName(),
+                        device.isOnline(),
+                        device.isAlarm(),
+                        device.isWarning(),
+                        device.isWorkOrderInProgress(),
+                        device.getStatusReasons(),
+                        device.getStatusTime(),
+                        deviceStatus(device)))
+                .collect(Collectors.toList());
+    }
+
+    public static CockpitMonitoringPointDeviceStatistics statistics(List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        List<CockpitMonitoringPointDeviceSnapshot> safeDevices = safeDevices(devices);
+        return new CockpitMonitoringPointDeviceStatistics(
+                safeDevices.size(),
+                count(safeDevices, CockpitMonitoringPointStatus.ALARM),
+                count(safeDevices, CockpitMonitoringPointStatus.WARNING),
+                count(safeDevices, CockpitMonitoringPointStatus.WORK_ORDER),
+                count(safeDevices, CockpitMonitoringPointStatus.NORMAL),
+                count(safeDevices, CockpitMonitoringPointStatus.OFFLINE));
+    }
+
+    public static CockpitMonitoringPointStatus deviceStatus(CockpitMonitoringPointDeviceSnapshot device) {
+        if (device.isAlarm()) {
+            return CockpitMonitoringPointStatus.ALARM;
+        }
+        if (device.isWarning()) {
+            return CockpitMonitoringPointStatus.WARNING;
+        }
+        if (device.isWorkOrderInProgress()) {
+            return CockpitMonitoringPointStatus.WORK_ORDER;
+        }
+        return device.isOnline() ? CockpitMonitoringPointStatus.NORMAL : CockpitMonitoringPointStatus.OFFLINE;
+    }
+
+    private static List<CockpitMonitoringPointDeviceSnapshot> orderedDevices(
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return safeDevices(devices).stream()
+                .sorted(Comparator
+                        .comparing(CockpitMonitoringPointStatusResolver::deviceStatus,
+                                Comparator.comparingInt(CockpitMonitoringPointStatus::getPriority))
+                        .thenComparing(CockpitMonitoringPointDeviceSnapshot::getDeviceCode,
+                                Comparator.nullsLast(String::compareTo)))
+                .collect(Collectors.toList());
+    }
+
+    private static List<CockpitMonitoringPointStatusReason> reasonsForDevice(
+            CockpitMonitoringPointStatus status, CockpitMonitoringPointDeviceSnapshot device) {
+        List<String> reasons = device.getStatusReasons() == null
+                ? Collections.emptyList()
+                : device.getStatusReasons();
+        if (reasons.isEmpty()) {
+            return Collections.singletonList(
+                    new CockpitMonitoringPointStatusReason(status, device.getDeviceCode(), status.getLabel()));
+        }
+        return reasons.stream()
+                .map(reason -> new CockpitMonitoringPointStatusReason(status, device.getDeviceCode(), reason))
+                .collect(Collectors.toList());
+    }
+
+    private static int count(List<CockpitMonitoringPointDeviceSnapshot> devices,
+            CockpitMonitoringPointStatus status) {
+        return (int) devices.stream()
+                .filter(device -> deviceStatus(device) == status)
+                .count();
+    }
+
+    private static List<CockpitMonitoringPointDeviceSnapshot> safeDevices(
+            List<CockpitMonitoringPointDeviceSnapshot> devices) {
+        return devices == null ? Collections.emptyList() : devices;
+    }
+}

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

@@ -0,0 +1,11 @@
+package com.zksy.cockpit.point;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class EmptyCockpitMonitoringPointDetailAdapter implements CockpitMonitoringPointDetailAdapter {
+    @Override
+    public CockpitMonitoringPointDetail load(String monitoringPointCode) {
+        return null;
+    }
+}