Bläddra i källkod

feat(cockpit): 总体概览四模块只读聚合端点

新增 /overview/perception、/overview/pipe-network、/overview/warning-study、
/overview/alarm-disposal 四个只读端点,为「总体概览」四模块大屏提供可复算取数:

- perception:设备总数/在线/离线/在线率、四大类设备(含监测内容派生)、片区分布
- pipe-network:管道数、管线里程、三类占比(供水/排水/燃气)
- warning-study:今日新增/较昨日、未解除与分级、30 日类型排行(limit 0 = 全部)
- alarm-disposal:已转/未转工单、处置率、平均响应与处置时长、分级、近 7 日双序列折线

口径要点:
- 报警基础集合按维护者裁定用 audit_status=1(不是 alarm_status=1)
- 报警等级字典用 alarm_level;预警等级才用 alert_level
- 类型排行不再硬截前 3(RANKING_TOP_SIZE=0)—— 原先「共 N 起」与各行之和对不上
- 折线每日两个读数:当天新增 + 截至当天未处理累计;累计从入库最早一条起算
- 缺段/空态如实表达(EMPTY/DEGRADED),不用 0 冒充

@MapperScan 增加 com.zksy.cockpit.**.mapper 扫描。

验证:cockpit 全量 582/582;新增模块单测 28 条全绿。
Kazerin 1 vecka sedan
förälder
incheckning
0fe48df745
20 ändrade filer med 3041 tillägg och 1 borttagningar
  1. 76 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesController.java
  2. 158 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesControllerTest.java
  3. 614 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesServiceTest.java
  4. 1 1
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/ApplicationConfig.java
  5. 333 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewModulesAdapter.java
  6. 182 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewAlarmDisposalResponse.java
  7. 109 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewAlarmDisposalSnapshot.java
  8. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesAdapter.java
  9. 602 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesService.java
  10. 151 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPerceptionResponse.java
  11. 70 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPerceptionSnapshot.java
  12. 91 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkResponse.java
  13. 36 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkSnapshot.java
  14. 59 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewReadEnvelope.java
  15. 115 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningStudyResponse.java
  16. 84 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningStudySnapshot.java
  17. 44 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWindows.java
  18. 34 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewModulesAdapter.java
  19. 67 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/mapper/CockpitOverviewModulesMapper.java
  20. 197 0
      pipe-network-service/zksy-system/src/main/resources/mapper/cockpit/CockpitOverviewModulesMapper.xml

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

@@ -0,0 +1,76 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.overview.CockpitOverviewModulesService;
+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.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 总体概览四模块只读端点(规格 §1,2026-10-22 四模块契约)。
+ *
+ * <p>免鉴权:{@code SecurityConfig} 已放行 {@code GET /api/cockpit/v1/**}。
+ * 四个端点各自独立成败,一个模块失败只影响它自己的响应。
+ */
+@RestController
+public class CockpitOverviewModulesController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitOverviewModulesService modulesService;
+
+    public CockpitOverviewModulesController(CockpitOverviewModulesService modulesService) {
+        this.modulesService = modulesService;
+    }
+
+    @GetMapping("/api/cockpit/v1/overview/perception")
+    public ResponseEntity<AjaxResult> perception(
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                modulesService.loadPerception(resolveRequestId(requestId))));
+    }
+
+    @GetMapping("/api/cockpit/v1/overview/pipe-network")
+    public ResponseEntity<AjaxResult> pipeNetwork(
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                modulesService.loadPipeNetwork(resolveRequestId(requestId))));
+    }
+
+    @GetMapping("/api/cockpit/v1/overview/warning-study")
+    public ResponseEntity<AjaxResult> warningStudy(
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                modulesService.loadWarningStudy(resolveRequestId(requestId))));
+    }
+
+    @GetMapping("/api/cockpit/v1/overview/alarm-disposal")
+    public ResponseEntity<AjaxResult> alarmDisposal(
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                modulesService.loadAlarmDisposal(resolveRequestId(requestId))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, modulesService.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();
+    }
+}

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

@@ -0,0 +1,158 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.overview.CockpitOverviewAlarmDisposalSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewModulesAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewModulesService;
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewPipeNetworkSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewWarningStudySnapshot;
+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 org.springframework.web.bind.annotation.GetMapping;
+
+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 static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+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;
+
+/** 四个新端点的路径、信封、只读约束与失败形状(规格 §0/§1)。 */
+class CockpitOverviewModulesControllerTest {
+
+    private CockpitOverviewModulesAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitOverviewModulesAdapter.class);
+        CockpitOverviewModulesService service = new CockpitOverviewModulesService(
+                adapter, Clock.fixed(Instant.parse("2026-10-22T08:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitOverviewModulesController(service))
+                .build();
+    }
+
+    @Test
+    void perceptionEndpointUsesCockpitEnvelope() throws Exception {
+        when(adapter.loadPerception()).thenReturn(new CockpitOverviewPerceptionSnapshot(
+                null,
+                Arrays.asList(new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(1, 0, true, true, 1)),
+                Collections.emptyList()));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/perception")
+                        .header("X-Request-Id", "perception-http-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("perception-http-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.totals.deviceCount").value(1))
+                .andExpect(jsonPath("$.data.totals.noStatusCount").value(0))
+                .andExpect(jsonPath("$.data.totals.onlineRate.value").value("1.0000"))
+                .andExpect(jsonPath("$.data.byType.length()").value(4))
+                .andExpect(jsonPath("$.data.byType[0].code").value("1"))
+                .andExpect(jsonPath("$.data.byType[0].name").value("供水设备"))
+                .andExpect(jsonPath("$.data.byType[0].monitoringContent").value("压力 / 流量 / 漏失"))
+                .andExpect(jsonPath("$.data.byType[0].monitoringContentSource").value("FALLBACK"))
+                .andExpect(jsonPath("$.data.byDistrict.length()").value(3))
+                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0));
+    }
+
+    @Test
+    void pipeNetworkEndpointSerialisesDecimalsAsStrings() throws Exception {
+        when(adapter.loadPipeNetwork()).thenReturn(new CockpitOverviewPipeNetworkSnapshot(
+                null,
+                Arrays.asList(new CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket(
+                        "WATER_PIPE", 1, new BigDecimal("19104.68")))));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/pipe-network"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totals.pipeCount").value(1))
+                .andExpect(jsonPath("$.data.totals.lengthKm").value("19.10"))
+                .andExpect(jsonPath("$.data.byPipeType[0].ratio").value("1.0000"))
+                .andExpect(jsonPath("$.data.state").value("AVAILABLE"));
+    }
+
+    @Test
+    void warningStudyEndpointReturnsFixedStatusKeys() throws Exception {
+        when(adapter.loadWarningStudy(any())).thenReturn(new CockpitOverviewWarningStudySnapshot(
+                null, 0, 0, Collections.emptyList(), 0, 30,
+                Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-study"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.unresolved.statuses.PENDING").value(0))
+                .andExpect(jsonPath("$.data.unresolved.statuses.RECEIVED").value(0))
+                .andExpect(jsonPath("$.data.unresolved.statuses.PROCESSING").value(0))
+                .andExpect(jsonPath("$.data.unresolved.statuses.HANDLED").value(0))
+                .andExpect(jsonPath("$.data.byLevel.length()").value(4))
+                .andExpect(jsonPath("$.data.typeRanking.rangeDays").value(30));
+    }
+
+    @Test
+    void alarmDisposalEndpointReturnsSevenTrendPoints() throws Exception {
+        when(adapter.loadAlarmDisposal(any())).thenReturn(new CockpitOverviewAlarmDisposalSnapshot(
+                null, 0, 0, 0, 0, 0,
+                Collections.emptyList(), Collections.emptyList(),
+                Collections.emptyMap(), Collections.emptyMap()));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/alarm-disposal"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.sevenDayTrend.length()").value(7))
+                .andExpect(jsonPath("$.data.sevenDayTrend[6].date").value("2026-10-22"))
+                .andExpect(jsonPath("$.data.disposal.rate.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.disposal.rate.basis").value("CLOSED_WORK_ORDERS_OVER_ALL_ALARMS"))
+                .andExpect(jsonPath("$.data.disposal.avgResponseDuration.unit").value("HOURS"))
+                .andExpect(jsonPath("$.data.byLevel[0].name").value("一级报警"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/overview/perception"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.loadPerception()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/perception")
+                        .header("X-Request-Id", "perception-http-500"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱总体概览只读数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("perception-http-500"))
+                .andExpect(jsonPath("$.data.serverTime").value("2026-10-22T16:00:00+08:00"));
+    }
+
+    @Test
+    void controllerOnlyExposesGetMappings() {
+        assertTrue(Arrays.stream(CockpitOverviewModulesController.class.getDeclaredMethods())
+                .filter(method -> method.isAnnotationPresent(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))
+                .allMatch(method -> method.isAnnotationPresent(GetMapping.class)));
+    }
+}

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

@@ -0,0 +1,614 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.overview.CockpitOverviewAlarmDisposalResponse;
+import com.zksy.cockpit.overview.CockpitOverviewAlarmDisposalSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewModulesAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewModulesService;
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionResponse;
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewPipeNetworkResponse;
+import com.zksy.cockpit.overview.CockpitOverviewPipeNetworkSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewWarningStudyResponse;
+import com.zksy.cockpit.overview.CockpitOverviewWarningStudySnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewWindows;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 规格 §8 边界:分母 0、无状态设备、未分区/未分类、今日/昨日边界、未解除状态集合、
+ * Top3 并列、无日志样本、7 日含 0 点。
+ *
+ * <p>业务时区 {@code Asia/Shanghai},时钟固定在 2026-10-22T08:00Z = 2026-10-22 16:00 北京时间。
+ */
+class CockpitOverviewModulesServiceTest {
+
+    private CockpitOverviewModulesAdapter adapter;
+    private CockpitOverviewModulesService service;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitOverviewModulesAdapter.class);
+        Clock clock = Clock.fixed(Instant.parse("2026-10-22T08:00:00Z"), ZoneOffset.UTC);
+        service = new CockpitOverviewModulesService(adapter, clock);
+    }
+
+    // ------------------------------------------------------------ 感知设备
+
+    @Test
+    void perceptionCountsAllBucketsAndSplitsFixedCards() {
+        when(adapter.loadPerception()).thenReturn(perception(Arrays.asList(
+                bucket(1, 0, true, false, 2),
+                bucket(1, 1, true, false, 1),
+                bucket(2, 0, true, true, 2),
+                bucket(2, 0, true, false, 2),
+                bucket(2, 1, true, true, 1),
+                bucket(4, 1, true, false, 1),
+                bucket(null, 2, true, false, 1))));
+
+        CockpitOverviewPerceptionResponse response = service.loadPerception("perception-001");
+
+        assertEquals("perception-001", response.getRequestId());
+        assertEquals("v1", response.getApiVersion());
+        assertEquals("Asia/Shanghai", response.getTimezone());
+        assertEquals(10, response.getTotals().getDeviceCount());
+        assertEquals(3, response.getTotals().getOnlineCount());
+        assertEquals(7, response.getTotals().getOfflineCount());
+        assertEquals(0, response.getTotals().getNoStatusCount());
+        assertEquals("0.3000", response.getTotals().getOnlineRate().getValue().toPlainString());
+        assertEquals("AVAILABLE", response.getTotals().getOnlineRate().getState());
+        assertNull(response.getTotals().getOnlineRate().getReason());
+        assertEquals(1, response.getExcluded().getUnknownTypeCount());
+        assertEquals(0, response.getExcluded().getUnassignedDistrictCount());
+
+        List<CockpitOverviewPerceptionResponse.TypeItem> byType = response.getByType();
+        assertEquals(4, byType.size());
+        assertEquals(Arrays.asList("1", "2", "3", "4"), Arrays.asList(
+                byType.get(0).getCode(), byType.get(1).getCode(), byType.get(2).getCode(), byType.get(3).getCode()));
+        assertEquals("供水设备", byType.get(0).getName());
+        assertEquals(3, byType.get(0).getDeviceCount());
+        assertEquals(0, byType.get(0).getOnlineCount());
+        assertEquals(3, byType.get(0).getOfflineCount());
+        assertEquals(5, byType.get(1).getDeviceCount());
+        assertEquals(3, byType.get(1).getOnlineCount());
+        assertEquals(2, byType.get(1).getOfflineCount());
+        assertEquals(0, byType.get(2).getDeviceCount());
+        assertEquals(1, byType.get(3).getDeviceCount());
+
+        List<CockpitOverviewPerceptionResponse.DistrictItem> byDistrict = response.getByDistrict();
+        assertEquals(3, byDistrict.size());
+        assertEquals(Arrays.asList(0, 1, 2), Arrays.asList(
+                byDistrict.get(0).getDistrict(), byDistrict.get(1).getDistrict(), byDistrict.get(2).getDistrict()));
+        assertEquals(Arrays.asList("城南", "城北", "太常"), Arrays.asList(
+                byDistrict.get(0).getName(), byDistrict.get(1).getName(), byDistrict.get(2).getName()));
+        assertEquals(6, byDistrict.get(0).getDeviceCount());
+        assertEquals(3, byDistrict.get(1).getDeviceCount());
+        assertEquals(1, byDistrict.get(2).getDeviceCount());
+    }
+
+    @Test
+    void perceptionWithoutDevicesReturnsEmptyRateInsteadOfZero() {
+        when(adapter.loadPerception()).thenReturn(perception(Collections.emptyList(), Collections.emptyList()));
+
+        CockpitOverviewPerceptionResponse response = service.loadPerception("perception-002");
+
+        assertEquals(0, response.getTotals().getDeviceCount());
+        assertEquals("0.0000", response.getTotals().getOnlineRate().getValue().toPlainString());
+        assertEquals("EMPTY", response.getTotals().getOnlineRate().getState());
+        assertEquals("NO_DEVICES", response.getTotals().getOnlineRate().getReason());
+        assertEquals(0, response.getTotals().getOnlineRate().getDenominator());
+        assertEquals("EMPTY", response.getFreshness().getStatus());
+        assertEquals("NO_DEVICES", response.getFreshness().getReason());
+        assertEquals(4, response.getByType().size());
+        assertEquals(3, response.getByDistrict().size());
+    }
+
+    @Test
+    void perceptionCountsMissingStatusAsOfflineAndExplainsReason() {
+        when(adapter.loadPerception()).thenReturn(perception(
+                Collections.singletonList(bucket(1, 0, false, false, 2))));
+
+        CockpitOverviewPerceptionResponse response = service.loadPerception("perception-003");
+
+        assertEquals(2, response.getTotals().getOfflineCount());
+        assertEquals(2, response.getTotals().getNoStatusCount());
+        assertEquals("0.0000", response.getTotals().getOnlineRate().getValue().toPlainString());
+        assertEquals("AVAILABLE", response.getTotals().getOnlineRate().getState());
+        assertEquals("NO_STATUS_COUNTED_OFFLINE", response.getTotals().getOnlineRate().getReason());
+    }
+
+    @Test
+    void perceptionDerivesMonitoringContentAndFallsBackToConstant() {
+        when(adapter.loadPerception()).thenReturn(perception(
+                Arrays.asList(bucket(1, 0, true, true, 1), bucket(3, 0, true, true, 1)),
+                Arrays.asList(
+                        content(1, "WARN-PRESSURE", "供水压力监测"),
+                        content(1, "WARN-LEAK", "漏失监测"),
+                        content(1, "WARN-LEAK", "漏失监测"),
+                        content(2, "WARN-COD", "COD监测"))));
+
+        CockpitOverviewPerceptionResponse response = service.loadPerception("perception-004");
+
+        CockpitOverviewPerceptionResponse.TypeItem water = response.getByType().get(0);
+        assertEquals("供水压力监测 / 漏失监测", water.getMonitoringContent());
+        assertEquals("DERIVED", water.getMonitoringContentSource());
+        CockpitOverviewPerceptionResponse.TypeItem gas = response.getByType().get(2);
+        assertEquals("可燃气体 / 压力", gas.getMonitoringContent());
+        assertEquals("FALLBACK", gas.getMonitoringContentSource());
+        assertEquals("井盖状态 / 液位", response.getByType().get(3).getMonitoringContent());
+    }
+
+    @Test
+    void perceptionTreatsUnknownTypeAndOutOfDomainDistrictAsExcluded() {
+        when(adapter.loadPerception()).thenReturn(perception(
+                Collections.singletonList(bucket(null, 7, true, true, 3))));
+
+        CockpitOverviewPerceptionResponse response = service.loadPerception("perception-005");
+
+        assertEquals(3, response.getTotals().getDeviceCount());
+        assertEquals(3, response.getTotals().getOnlineCount());
+        assertEquals(3, response.getExcluded().getUnknownTypeCount());
+        assertEquals(3, response.getExcluded().getUnassignedDistrictCount());
+        assertEquals(0, response.getByDistrict().get(0).getDeviceCount());
+    }
+
+    // ------------------------------------------------------------ 基础设施
+
+    @Test
+    void pipeNetworkAggregatesFixedThreeTypesInOrder() {
+        when(adapter.loadPipeNetwork()).thenReturn(pipes(
+                pipe("WATER_PIPE", 2, "19104.68"),
+                pipe("DRAINAGE_PIPE", 1, "52260.98"),
+                pipe("GAS_PIPE", 1, "7180.38"),
+                pipe("PIPE_UNCLASSIFIED", 1, "1000")));
+
+        CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-001");
+
+        assertEquals(5, response.getTotals().getPipeCount());
+        assertEquals("79.55", response.getTotals().getLengthKm().toPlainString());
+        List<CockpitOverviewPipeNetworkResponse.PipeTypeItem> byPipeType = response.getByPipeType();
+        assertEquals(3, byPipeType.size());
+        assertEquals("WATER_PIPE", byPipeType.get(0).getCode());
+        assertEquals("供水管道", byPipeType.get(0).getName());
+        assertEquals("DRAINAGE_PIPE", byPipeType.get(1).getCode());
+        assertEquals("排水管道", byPipeType.get(1).getName());
+        assertEquals("GAS_PIPE", byPipeType.get(2).getCode());
+        assertEquals("燃气管道", byPipeType.get(2).getName());
+        assertEquals(2, byPipeType.get(0).getCount());
+        assertEquals("19.10", byPipeType.get(0).getLengthKm().toPlainString());
+        assertEquals("0.5000", byPipeType.get(0).getRatio().toPlainString());
+        assertEquals("0.2500", byPipeType.get(1).getRatio().toPlainString());
+        assertEquals("0.2500", byPipeType.get(2).getRatio().toPlainString());
+        assertEquals(1, response.getExcluded().getUnclassifiedCount());
+        assertEquals("AVAILABLE", response.getState());
+        assertNull(response.getReason());
+    }
+
+    @Test
+    void pipeNetworkRoundsLikeTheRealSample() {
+        when(adapter.loadPipeNetwork()).thenReturn(pipes(
+                pipe("WATER_PIPE", 2489, "19104.68"),
+                pipe("DRAINAGE_PIPE", 3574, "52260.98"),
+                pipe("GAS_PIPE", 699, "7180.38")));
+
+        CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-002");
+
+        assertEquals(6762, response.getTotals().getPipeCount());
+        assertEquals("78.55", response.getTotals().getLengthKm().toPlainString());
+        assertEquals("19.10", response.getByPipeType().get(0).getLengthKm().toPlainString());
+        assertEquals("52.26", response.getByPipeType().get(1).getLengthKm().toPlainString());
+        assertEquals("7.18", response.getByPipeType().get(2).getLengthKm().toPlainString());
+        assertEquals("0.3681", response.getByPipeType().get(0).getRatio().toPlainString());
+        // 实测口径:3574 / 6762 = 0.52853 → 0.5285(规格样例的 0.5286 是手写近似)。
+        assertEquals("0.5285", response.getByPipeType().get(1).getRatio().toPlainString());
+        assertEquals("0.1034", response.getByPipeType().get(2).getRatio().toPlainString());
+    }
+
+    @Test
+    void pipeNetworkWithoutPipesReturnsEmptyState() {
+        when(adapter.loadPipeNetwork()).thenReturn(pipes());
+
+        CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-003");
+
+        assertEquals(0, response.getTotals().getPipeCount());
+        assertEquals("0.00", response.getTotals().getLengthKm().toPlainString());
+        assertEquals("EMPTY", response.getState());
+        assertEquals("NO_PIPES", response.getReason());
+        assertEquals("EMPTY", response.getFreshness().getStatus());
+        assertEquals("0", response.getByPipeType().get(0).getRatio().toPlainString());
+    }
+
+    @Test
+    void pipeNetworkWithOnlyUnclassifiedRowsKeepsTotalsButEmptyRatio() {
+        when(adapter.loadPipeNetwork()).thenReturn(pipes(pipe("PIPE_UNCLASSIFIED", 1, "500")));
+
+        CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-004");
+
+        assertEquals(1, response.getTotals().getPipeCount());
+        assertEquals("0.50", response.getTotals().getLengthKm().toPlainString());
+        assertEquals(1, response.getExcluded().getUnclassifiedCount());
+        assertEquals(0, response.getByPipeType().get(0).getCount());
+        assertEquals("0", response.getByPipeType().get(0).getRatio().toPlainString());
+        assertEquals("EMPTY", response.getState());
+        assertEquals("NO_CLASSIFIED_PIPES", response.getReason());
+    }
+
+    // ------------------------------------------------------------ 预警研判
+
+    @Test
+    void warningStudyPassesBusinessWindowsToTheAdapter() {
+        when(adapter.loadWarningStudy(any())).thenReturn(warnings());
+
+        service.loadWarningStudy("warning-001");
+
+        ArgumentCaptor<CockpitOverviewWindows> captor = ArgumentCaptor.forClass(CockpitOverviewWindows.class);
+        verify(adapter).loadWarningStudy(captor.capture());
+        CockpitOverviewWindows windows = captor.getValue();
+        assertEquals(LocalDateTime.parse("2026-10-21T00:00:00"), windows.yesterdayStart());
+        assertEquals(LocalDateTime.parse("2026-10-22T00:00:00"), windows.todayStart());
+        assertEquals(LocalDateTime.parse("2026-10-23T00:00:00"), windows.tomorrowStart());
+        assertEquals(LocalDateTime.parse("2026-09-23T00:00:00"), windows.rankingStart());
+        assertEquals(LocalDateTime.parse("2026-10-16T00:00:00"), windows.trendStart());
+        assertEquals(LocalDate.parse("2026-10-22"), windows.today());
+    }
+
+    @Test
+    void warningUnresolvedOnlyCountsFourStatusesAndLevelsAreCrossed() {
+        when(adapter.loadWarningStudy(any())).thenReturn(warnings(1, 0, 42,
+                Arrays.asList(
+                        statusLevel("PENDING", 1, 5),
+                        statusLevel("PENDING", 2, 4),
+                        statusLevel("PROCESSING", 1, 3),
+                        statusLevel("HANDLED", 1, 1),
+                        statusLevel("PROCESSING", null, 1),
+                        statusLevel("PROCESSING", 4, 1),
+                        // 下面两行是 SQL 之外的脏数据:DRAFT/CLOSED 不得进未解除与分级。
+                        statusLevel("DRAFT", 1, 9),
+                        statusLevel("CLOSED", 4, 22)),
+                Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
+
+        CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-002");
+
+        assertEquals(1, response.getToday().getNewCount());
+        assertEquals(0, response.getToday().getYesterdayCount());
+        assertEquals(1, response.getToday().getDelta());
+        assertEquals(15, response.getUnresolved().getCount());
+        Map<String, Integer> statuses = response.getUnresolved().getStatuses();
+        assertEquals(Arrays.asList("PENDING", "RECEIVED", "PROCESSING", "HANDLED"),
+                Arrays.asList(statuses.keySet().toArray(new String[0])));
+        assertEquals(9, statuses.get("PENDING"));
+        assertEquals(0, statuses.get("RECEIVED"));
+        assertEquals(5, statuses.get("PROCESSING"));
+        assertEquals(1, statuses.get("HANDLED"));
+        List<CockpitOverviewWarningStudyResponse.LevelItem> byLevel = response.getByLevel();
+        assertEquals(4, byLevel.size());
+        assertEquals(9, byLevel.get(0).getCount());
+        assertEquals(4, byLevel.get(1).getCount());
+        assertEquals(0, byLevel.get(2).getCount());
+        assertEquals(1, byLevel.get(3).getCount());
+    }
+
+    @Test
+    void warningByLevelPrefersDictionaryLabelAndFallsBackToFixedFormat() {
+        Map<Integer, String> levelNames = new LinkedHashMap<>();
+        levelNames.put(1, "一级预警");
+        when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 0,
+                Collections.singletonList(statusLevel("PENDING", 2, 1)),
+                Collections.emptyList(), Collections.emptyMap(), levelNames));
+
+        CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-003");
+
+        assertEquals("一级预警", response.getByLevel().get(0).getName());
+        assertEquals("二级预警", response.getByLevel().get(1).getName());
+        assertEquals("三级预警", response.getByLevel().get(2).getName());
+        assertEquals("四级预警", response.getByLevel().get(3).getName());
+        assertEquals(1, response.getByLevel().get(1).getCount());
+    }
+
+    @Test
+    void warningTypeRankingTieBreaksByCodeAndDegradesUnknownName() {
+        when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 10,
+                Collections.emptyList(),
+                Arrays.asList(typeCount("WARN-A", 3), typeCount("WARN-B", 2), typeCount("WARN-C", 2),
+                        typeCount("WARN-D", 2), typeCount("", 1)),
+                names("WARN-A", "压力预警"), Collections.emptyMap()));
+
+        CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-004");
+
+        CockpitOverviewWarningStudyResponse.TypeRanking ranking = response.getTypeRanking();
+        assertEquals(10, ranking.getTotal());
+        // 维护者 2026-10-26:不再只给前三 —— 页面「共27起」而三行只凑出 23 起,读者对不上数。
+        // 四个有 code 的类型全部给出;空 code 的那条仍不参与排行(但已计入 total)。
+        assertEquals(4, ranking.getTop().size());
+        assertEquals("WARN-A", ranking.getTop().get(0).getCode());
+        assertEquals("压力预警", ranking.getTop().get(0).getName());
+        assertEquals(3, ranking.getTop().get(0).getCount());
+        // 并列按 code 升序:WARN-B / WARN-C / WARN-D 都是 2 起。
+        assertEquals("WARN-B", ranking.getTop().get(1).getCode());
+        assertEquals("WARN-C", ranking.getTop().get(2).getCode());
+        assertEquals("WARN-D", ranking.getTop().get(3).getCode());
+        // 关联不到 warning_type 时如实降级为 code。
+        assertEquals("WARN-B", ranking.getTop().get(1).getName());
+    }
+
+    @Test
+    void warningTypeNameMatchesAcrossDashAndUnderscore() {
+        when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 3,
+                Collections.emptyList(),
+                Collections.singletonList(typeCount("WARN_INSTANT_FLOW", 3)),
+                names("WARN-INSTANT-FLOW", "排水瞬时流量监测"), Collections.emptyMap()));
+
+        CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-005");
+
+        assertEquals("WARN_INSTANT_FLOW", response.getTypeRanking().getTop().get(0).getCode());
+        assertEquals("排水瞬时流量监测", response.getTypeRanking().getTop().get(0).getName());
+    }
+
+    @Test
+    void warningWithoutRowsIsEmptyNotFailed() {
+        when(adapter.loadWarningStudy(any())).thenReturn(warnings());
+
+        CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-006");
+
+        assertEquals(0, response.getToday().getNewCount());
+        assertEquals(0, response.getUnresolved().getCount());
+        assertEquals(0, response.getTypeRanking().getTotal());
+        assertEquals(0, response.getTypeRanking().getTop().size());
+        assertEquals(4, response.getByLevel().size());
+        assertEquals(30, response.getTypeRanking().getRangeDays());
+        assertEquals("EMPTY", response.getFreshness().getStatus());
+        assertEquals("NO_WARNINGS", response.getFreshness().getReason());
+    }
+
+    // ------------------------------------------------------------ 报警处置
+
+    @Test
+    void alarmDisposalComputesRateFromTheAuditStatusOneBaseSet() {
+        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(3, 1, 1, 1, 1,
+                Arrays.asList(levelCount(1, 1), levelCount(4, 2)),
+                Collections.emptyList(), Collections.emptyMap(),
+                levels(1, "一级报警", 4, "四级报警")));
+
+        CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-001");
+
+        assertEquals(1, response.getToday().getNewCount());
+        assertEquals(1, response.getToday().getYesterdayCount());
+        assertEquals(0, response.getToday().getDelta());
+        assertEquals(2, response.getWorkOrderConversion().getConvertedCount());
+        assertEquals(1, response.getWorkOrderConversion().getUnconvertedCount());
+        assertEquals(3, response.getWorkOrderConversion().getTotalCount());
+        assertEquals(1, response.getDisposal().getRate().getNumerator());
+        assertEquals(3, response.getDisposal().getRate().getDenominator());
+        assertEquals("0.3333", response.getDisposal().getRate().getValue().toPlainString());
+        assertEquals("AVAILABLE", response.getDisposal().getRate().getState());
+        assertEquals("CLOSED_WORK_ORDERS_OVER_ALL_ALARMS", response.getDisposal().getRate().getBasis());
+        assertEquals("一级报警", response.getByLevel().get(0).getName());
+        assertEquals(1, response.getByLevel().get(0).getCount());
+        assertEquals("四级报警", response.getByLevel().get(3).getName());
+        assertEquals(2, response.getByLevel().get(3).getCount());
+    }
+
+    @Test
+    void alarmDisposalWithoutBaseSetReturnsEmptyRateAndEmptyDurations() {
+        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms());
+
+        CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-002");
+
+        assertEquals(0, response.getWorkOrderConversion().getTotalCount());
+        assertEquals("EMPTY", response.getDisposal().getRate().getState());
+        assertEquals("NO_ALARMS", response.getDisposal().getRate().getReason());
+        assertEquals("0", response.getDisposal().getRate().getValue().toPlainString());
+        assertEquals("EMPTY", response.getDisposal().getAvgResponseDuration().getState());
+        assertEquals("NO_RESPONSE_LOGS", response.getDisposal().getAvgResponseDuration().getReason());
+        assertEquals(0, response.getDisposal().getAvgResponseDuration().getSampleCount());
+        assertEquals("NO_DISPOSAL_LOGS", response.getDisposal().getAvgDisposalDuration().getReason());
+        assertEquals("EMPTY", response.getFreshness().getStatus());
+        assertEquals(7, response.getSevenDayTrend().size());
+    }
+
+    @Test
+    void alarmDisposalSevenDayTrendKeepsZeroPoints() {
+        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(4, 1, 1, 4, 0,
+                Collections.emptyList(),
+                Arrays.asList(dayCount("2026-10-16", 1, 5), dayCount("2026-10-21", 1, 3),
+                        dayCount("2026-10-22", 1, 3), dayCount("2026-10-15", 9, 9)),
+                Collections.emptyMap(), Collections.emptyMap()));
+
+        CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-003");
+
+        List<CockpitOverviewAlarmDisposalResponse.TrendPoint> trend = response.getSevenDayTrend();
+        assertEquals(7, trend.size());
+        assertEquals("2026-10-16", trend.get(0).getDate());
+        assertEquals(1, trend.get(0).getCount());
+        assertEquals("2026-10-18", trend.get(2).getDate());
+        assertEquals(0, trend.get(2).getCount());
+        assertEquals("2026-10-21", trend.get(5).getDate());
+        assertEquals(1, trend.get(5).getCount());
+        assertEquals("2026-10-22", trend.get(6).getDate());
+        assertEquals(1, trend.get(6).getCount());
+        // 同一天还带"截至当天未处理累计"(维护者 2026-10-23:折线改为两条线对照)。
+        // 缺失的日期补 0 —— 那是"当天没有数据",不是"积压为零"(真积压由有数据的那几天给出)。
+        assertEquals(5, trend.get(0).getUnhandledTotal());
+        assertEquals(0, trend.get(2).getUnhandledTotal());
+        assertEquals(3, trend.get(5).getUnhandledTotal());
+        assertEquals(3, trend.get(6).getUnhandledTotal());
+    }
+
+    @Test
+    void alarmDisposalAveragesDurationsAndKeepsInvalidSampleCount() {
+        Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> samples = new LinkedHashMap<>();
+        samples.put(2, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(109924L, 1, 2));
+        samples.put(5, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(789067L, 1, 0));
+        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(2, 0, 0, 1, 1,
+                Collections.emptyList(), Collections.emptyList(), samples, Collections.emptyMap()));
+
+        CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-004");
+
+        CockpitOverviewAlarmDisposalResponse.AverageDuration responseDuration =
+                response.getDisposal().getAvgResponseDuration();
+        assertEquals("30.5", responseDuration.getValue().toPlainString());
+        assertEquals("HOURS", responseDuration.getUnit());
+        assertEquals(1, responseDuration.getSampleCount());
+        assertEquals(2, responseDuration.getInvalidSampleCount());
+        assertEquals("AVAILABLE", responseDuration.getState());
+        CockpitOverviewAlarmDisposalResponse.AverageDuration disposal =
+                response.getDisposal().getAvgDisposalDuration();
+        assertEquals("219.2", disposal.getValue().toPlainString());
+        assertEquals(1, disposal.getSampleCount());
+        assertEquals(0, disposal.getInvalidSampleCount());
+    }
+
+    @Test
+    void alarmLevelCardsFallBackToFixedAlarmLabels() {
+        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(1, 0, 0, 1, 0,
+                Collections.singletonList(levelCount(1, 1)),
+                Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
+
+        CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-005");
+
+        assertEquals("一级报警", response.getByLevel().get(0).getName());
+        assertEquals("二级报警", response.getByLevel().get(1).getName());
+        assertEquals("三级报警", response.getByLevel().get(2).getName());
+        assertEquals("四级报警", response.getByLevel().get(3).getName());
+    }
+
+    @Test
+    void adapterFailureIsReadAsTypedReadException() {
+        when(adapter.loadPerception()).thenThrow(new IllegalStateException("upstream down"));
+
+        assertThrows(CockpitReadException.class, () -> service.loadPerception("perception-500"));
+        assertTrue(true);
+    }
+
+    // ------------------------------------------------------------ 夹具
+
+    private static CockpitOverviewPerceptionSnapshot perception(
+            List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets) {
+        return perception(buckets, Collections.emptyList());
+    }
+
+    private static CockpitOverviewPerceptionSnapshot perception(
+            List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets,
+            List<CockpitOverviewPerceptionSnapshot.SourceMonitoringContent> contents) {
+        return new CockpitOverviewPerceptionSnapshot(
+                LocalDateTime.parse("2026-10-22T15:00:00"), buckets, contents);
+    }
+
+    private static CockpitOverviewPerceptionSnapshot.SourceDeviceBucket bucket(
+            Integer rootTypeId, Integer district, boolean hasStatus, boolean online, int deviceCount) {
+        return new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(
+                rootTypeId, district, hasStatus, online, deviceCount);
+    }
+
+    private static CockpitOverviewPerceptionSnapshot.SourceMonitoringContent content(
+            Integer rootTypeId, String code, String name) {
+        return new CockpitOverviewPerceptionSnapshot.SourceMonitoringContent(rootTypeId, code, name);
+    }
+
+    private static CockpitOverviewPipeNetworkSnapshot pipes(
+            CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket... buckets) {
+        return new CockpitOverviewPipeNetworkSnapshot(
+                LocalDateTime.parse("2026-10-22T15:00:00"), Arrays.asList(buckets));
+    }
+
+    private static CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket pipe(
+            String typeCode, int pipeCount, String lengthMeters) {
+        return new CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket(
+                typeCode, pipeCount, new BigDecimal(lengthMeters));
+    }
+
+    private static CockpitOverviewWarningStudySnapshot warnings() {
+        return warnings(0, 0, 0, Collections.emptyList(), Collections.emptyList(),
+                Collections.emptyMap(), Collections.emptyMap());
+    }
+
+    private static CockpitOverviewWarningStudySnapshot warnings(
+            int todayNew,
+            int yesterdayNew,
+            int rankingTotal,
+            List<CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount> statusLevelCounts,
+            List<CockpitOverviewWarningStudySnapshot.SourceTypeCount> typeCounts,
+            Map<String, String> typeNames,
+            Map<Integer, String> levelNames) {
+        return new CockpitOverviewWarningStudySnapshot(
+                LocalDateTime.parse("2026-10-22T15:00:00"),
+                todayNew, yesterdayNew, statusLevelCounts, rankingTotal, 30,
+                typeCounts, typeNames, levelNames);
+    }
+
+    private static CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount statusLevel(
+            String status, Integer level, int count) {
+        return new CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount(status, level, count);
+    }
+
+    private static CockpitOverviewWarningStudySnapshot.SourceTypeCount typeCount(String code, int count) {
+        return new CockpitOverviewWarningStudySnapshot.SourceTypeCount(code, count);
+    }
+
+    private static Map<String, String> names(String code, String name) {
+        Map<String, String> names = new LinkedHashMap<>();
+        names.put(code, name);
+        return names;
+    }
+
+    private static CockpitOverviewAlarmDisposalSnapshot alarms() {
+        return alarms(0, 0, 0, 0, 0, Collections.emptyList(), Collections.emptyList(),
+                Collections.emptyMap(), Collections.emptyMap());
+    }
+
+    private static CockpitOverviewAlarmDisposalSnapshot alarms(
+            int totalCount,
+            int todayNew,
+            int yesterdayNew,
+            int unconvertedCount,
+            int closedCount,
+            List<CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount> levelCounts,
+            List<CockpitOverviewAlarmDisposalSnapshot.SourceDayCount> dailyCounts,
+            Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> durationSamples,
+            Map<Integer, String> levelNames) {
+        return new CockpitOverviewAlarmDisposalSnapshot(
+                LocalDateTime.parse("2026-10-22T15:00:00"),
+                totalCount, todayNew, yesterdayNew, unconvertedCount, closedCount,
+                levelCounts, dailyCounts, durationSamples, levelNames);
+    }
+
+    private static CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount levelCount(
+            Integer level, int count) {
+        return new CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount(level, count);
+    }
+
+    /** 按日两个读数:当天新增 + 截至当天未处理的累计(后端新契约)。 */
+    private static CockpitOverviewAlarmDisposalSnapshot.SourceDayCount dayCount(
+            String day, int count, int unhandledTotal) {
+        return new CockpitOverviewAlarmDisposalSnapshot.SourceDayCount(day, count, unhandledTotal);
+    }
+
+    private static Map<Integer, String> levels(int firstLevel, String firstLabel, int secondLevel, String secondLabel) {
+        Map<Integer, String> levels = new LinkedHashMap<>();
+        levels.put(firstLevel, firstLabel);
+        levels.put(secondLevel, secondLabel);
+        return levels;
+    }
+}

+ 1 - 1
pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/ApplicationConfig.java

@@ -17,7 +17,7 @@ import java.util.TimeZone;
 // 表示通过aop框架暴露该代理对象,AopContext能够访问
 @EnableAspectJAutoProxy(exposeProxy = true)
 // 指定要扫描的Mapper类的包的路径
-@MapperScan({"com.zksy.base.**.mapper", "com.zksy.system.**.mapper", "com.zksy.quartz.**.mapper", "com.zksy.generator.**.mapper", "com.zksy.drainage.**.mapper", "com.zksy.WaterSupply.**.mapper", "com.zksy.dispatch.**.mapper", "com.zksy.ops.**.mapper"})
+@MapperScan({"com.zksy.base.**.mapper", "com.zksy.system.**.mapper", "com.zksy.quartz.**.mapper", "com.zksy.generator.**.mapper", "com.zksy.drainage.**.mapper", "com.zksy.WaterSupply.**.mapper", "com.zksy.dispatch.**.mapper", "com.zksy.ops.**.mapper", "com.zksy.cockpit.**.mapper"})
 public class ApplicationConfig
 {
     /**

+ 333 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewModulesAdapter.java

@@ -0,0 +1,333 @@
+package com.zksy.cockpit.overview;
+
+import com.zksy.base.warning.domain.WarningType;
+import com.zksy.base.warning.mapper.WarningTypeMapper;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
+import com.zksy.cockpit.overview.mapper.CockpitOverviewModulesMapper;
+import com.zksy.common.core.domain.entity.SysDictData;
+import com.zksy.system.mapper.SysDictDataMapper;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 总体概览四模块的真实数据适配器(规格 §2–§5,调研结论 §11.1)。
+ *
+ * <p>聚合全部由 {@link CockpitOverviewModulesMapper} 下推 SQL:单次扫描 + {@code GROUP BY},
+ * 不做全表 {@code list()} 内存聚合,也不逐设备回查状态。本类只做「取行 + 归一」:
+ * 管网类型走 {@link CockpitPipeNetworkType} 单一权威映射、等级名称取 {@code sys_dict_data}、
+ * 预警类型名称取 {@code warning_type}。
+ */
+@Component
+@Primary
+public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewModulesAdapter {
+    private static final String ALERT_LEVEL_DICT = "alert_level";
+    private static final String ALARM_LEVEL_DICT = "alarm_level";
+    private static final int OPER_TYPE_RECEIVE = 2;
+    private static final int OPER_TYPE_ACCEPT = 5;
+
+    private final CockpitOverviewModulesMapper mapper;
+    private final WarningTypeMapper warningTypeMapper;
+    private final SysDictDataMapper sysDictDataMapper;
+
+    public BusinessCockpitOverviewModulesAdapter(
+            CockpitOverviewModulesMapper mapper,
+            WarningTypeMapper warningTypeMapper,
+            SysDictDataMapper sysDictDataMapper) {
+        this.mapper = mapper;
+        this.warningTypeMapper = warningTypeMapper;
+        this.sysDictDataMapper = sysDictDataMapper;
+    }
+
+    @Override
+    public CockpitOverviewPerceptionSnapshot loadPerception() {
+        List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets = new ArrayList<>();
+        LocalDateTime updatedAt = null;
+        for (Map<String, Object> row : safe(mapper.selectDeviceBuckets())) {
+            updatedAt = latest(updatedAt, localDateTime(row, "source_updated_at"));
+            buckets.add(new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(
+                    integer(row, "root_type_id"),
+                    integer(row, "district"),
+                    intValue(row, "has_status") == 1,
+                    intValue(row, "is_online") == 1,
+                    intValue(row, "device_count")));
+        }
+        List<CockpitOverviewPerceptionSnapshot.SourceMonitoringContent> contents = new ArrayList<>();
+        for (Map<String, Object> row : safe(mapper.selectMonitoringContents())) {
+            Integer rootTypeId = integer(row, "root_type_id");
+            String warningName = trimToNull(string(row, "warning_name"));
+            if (rootTypeId == null || warningName == null) {
+                continue;
+            }
+            contents.add(new CockpitOverviewPerceptionSnapshot.SourceMonitoringContent(
+                    rootTypeId, trimToNull(string(row, "warning_code")), warningName));
+        }
+        return new CockpitOverviewPerceptionSnapshot(updatedAt, buckets, contents);
+    }
+
+    @Override
+    public CockpitOverviewPipeNetworkSnapshot loadPipeNetwork() {
+        List<CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket> buckets = new ArrayList<>();
+        LocalDateTime updatedAt = null;
+        for (Map<String, Object> row : safe(mapper.selectPipeBuckets())) {
+            updatedAt = latest(updatedAt, localDateTime(row, "source_updated_at"));
+            buckets.add(new CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket(
+                    CockpitPipeNetworkType.typeCodeOf(string(row, "network_type")),
+                    intValue(row, "pipe_count"),
+                    bigDecimal(row, "length_meters")));
+        }
+        return new CockpitOverviewPipeNetworkSnapshot(updatedAt, buckets);
+    }
+
+    @Override
+    public CockpitOverviewWarningStudySnapshot loadWarningStudy(CockpitOverviewWindows windows) {
+        Map<String, Object> counts = mapper.selectWarningCounts(
+                timestamp(windows == null ? null : windows.yesterdayStart()),
+                timestamp(windows == null ? null : windows.todayStart()),
+                timestamp(windows == null ? null : windows.tomorrowStart()));
+        Map<String, Object> range = mapper.selectWarningCountInWindow(
+                timestamp(windows == null ? null : windows.rankingStart()),
+                timestamp(windows == null ? null : windows.tomorrowStart()));
+
+        List<CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount> statusLevelCounts = new ArrayList<>();
+        for (Map<String, Object> row : safe(mapper.selectUnresolvedStatusLevelCounts())) {
+            statusLevelCounts.add(new CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount(
+                    trimToNull(string(row, "status")),
+                    levelOf(string(row, "warning_level")),
+                    intValue(row, "type_count")));
+        }
+        List<CockpitOverviewWarningStudySnapshot.SourceTypeCount> typeCounts = new ArrayList<>();
+        for (Map<String, Object> row : safe(mapper.selectWarningTypeCountsInWindow(
+                timestamp(windows == null ? null : windows.rankingStart()),
+                timestamp(windows == null ? null : windows.tomorrowStart())))) {
+            typeCounts.add(new CockpitOverviewWarningStudySnapshot.SourceTypeCount(
+                    trimToNull(string(row, "type_code")), intValue(row, "type_count")));
+        }
+        int rangeDays = 0;
+        if (windows != null) {
+            rangeDays = (int) (windows.tomorrowStart().toLocalDate().toEpochDay()
+                    - windows.rankingStart().toLocalDate().toEpochDay());
+        }
+        return new CockpitOverviewWarningStudySnapshot(
+                localDateTime(counts, "source_updated_at"),
+                intValue(counts, "today_new"),
+                intValue(counts, "yesterday_new"),
+                statusLevelCounts,
+                intValue(range, "warning_count"),
+                rangeDays,
+                typeCounts,
+                warningTypeNames(),
+                levelNames(ALERT_LEVEL_DICT));
+    }
+
+    @Override
+    public CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(CockpitOverviewWindows windows) {
+        Map<String, Object> totals = mapper.selectAlarmTotals(
+                timestamp(windows == null ? null : windows.yesterdayStart()),
+                timestamp(windows == null ? null : windows.todayStart()),
+                timestamp(windows == null ? null : windows.tomorrowStart()));
+        Map<String, Object> closed = mapper.selectAlarmClosedCount();
+
+        List<CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount> levelCounts = new ArrayList<>();
+        for (Map<String, Object> row : safe(mapper.selectAlarmLevelCounts())) {
+            levelCounts.add(new CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount(
+                    integer(row, "alarm_level"), intValue(row, "level_count")));
+        }
+        List<CockpitOverviewAlarmDisposalSnapshot.SourceDayCount> dailyCounts = new ArrayList<>();
+        for (Map<String, Object> row : safe(mapper.selectAlarmDailyCounts(
+                timestamp(windows == null ? null : windows.trendStart()),
+                timestamp(windows == null ? null : windows.tomorrowStart())))) {
+            String day = trimToNull(string(row, "day"));
+            if (day != null) {
+                dailyCounts.add(new CockpitOverviewAlarmDisposalSnapshot.SourceDayCount(
+                        day, intValue(row, "daily_count"), intValue(row, "unhandled_total")));
+            }
+        }
+        Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> durationSamples =
+                new LinkedHashMap<>();
+        for (Map<String, Object> row : safe(mapper.selectAlarmDurationSamples())) {
+            Integer operType = integer(row, "oper_type");
+            if (operType == null) {
+                continue;
+            }
+            durationSamples.put(operType, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(
+                    longValue(row, "total_seconds"),
+                    intValue(row, "valid_samples"),
+                    intValue(row, "invalid_samples")));
+        }
+        return new CockpitOverviewAlarmDisposalSnapshot(
+                localDateTime(totals, "source_updated_at"),
+                intValue(totals, "alarm_count"),
+                intValue(totals, "today_new"),
+                intValue(totals, "yesterday_new"),
+                intValue(totals, "unconverted_count"),
+                intValue(closed, "closed_count"),
+                levelCounts,
+                dailyCounts,
+                durationSamples,
+                levelNames(ALARM_LEVEL_DICT));
+    }
+
+    /** 等级名称取字典(预警用 {@code alert_level}、报警用 {@code alarm_level});缺项由服务层兜底。 */
+    private Map<Integer, String> levelNames(String dictType) {
+        Map<Integer, String> names = new LinkedHashMap<>();
+        for (SysDictData data : safe(sysDictDataMapper.selectDictDataByType(dictType))) {
+            if (data == null) {
+                continue;
+            }
+            Integer value = levelOf(data.getDictValue());
+            if (value != null) {
+                names.put(value, data.getDictLabel());
+            }
+        }
+        return names;
+    }
+
+    private Map<String, String> warningTypeNames() {
+        Map<String, String> names = new LinkedHashMap<>();
+        for (WarningType type : safe(warningTypeMapper.selectList(null))) {
+            String code = type == null ? null : trimToNull(type.getWarningCode());
+            if (code != null) {
+                names.put(code, trimToNull(type.getWarningName()));
+            }
+        }
+        return names;
+    }
+
+    /** {@code warning_level} 是字符串,仅 1–4 计算编码有效(与既有预警适配器同源)。 */
+    private static Integer levelOf(String level) {
+        String normalized = trimToNull(level);
+        if (normalized == null) {
+            return null;
+        }
+        try {
+            int resolved = Integer.parseInt(normalized);
+            return resolved >= 1 && resolved <= 4 ? resolved : null;
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    // ------------------------------------------------------------ 行取值工具
+
+    private static Object value(Map<String, Object> row, String key) {
+        if (row == null) {
+            return null;
+        }
+        if (row.containsKey(key)) {
+            return row.get(key);
+        }
+        for (Map.Entry<String, Object> entry : row.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(key)) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private static String string(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        return value == null ? null : value.toString();
+    }
+
+    private static int intValue(Map<String, Object> row, String key) {
+        return (int) longValue(row, key);
+    }
+
+    private static long longValue(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        if (value == null) {
+            return 0L;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).longValue();
+        }
+        try {
+            return Long.parseLong(value.toString().trim());
+        } catch (NumberFormatException ignored) {
+            return 0L;
+        }
+    }
+
+    private static Integer integer(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        try {
+            return Integer.valueOf(value.toString().trim());
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    private static BigDecimal bigDecimal(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        if (value == null) {
+            return BigDecimal.ZERO;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return new BigDecimal(value.toString());
+        }
+        try {
+            return new BigDecimal(value.toString().trim());
+        } catch (NumberFormatException ignored) {
+            return BigDecimal.ZERO;
+        }
+    }
+
+    private static LocalDateTime localDateTime(Map<String, Object> row, String key) {
+        Object value = value(row, key);
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Timestamp) {
+            return ((Timestamp) value).toLocalDateTime();
+        }
+        if (value instanceof LocalDateTime) {
+            return (LocalDateTime) value;
+        }
+        if (value instanceof Date) {
+            return new Timestamp(((Date) value).getTime()).toLocalDateTime();
+        }
+        return null;
+    }
+
+    private static Date timestamp(LocalDateTime value) {
+        return value == null ? null : Timestamp.valueOf(value);
+    }
+
+    private static LocalDateTime latest(LocalDateTime current, LocalDateTime candidate) {
+        if (candidate == null) {
+            return current;
+        }
+        return current == null || candidate.isAfter(current) ? candidate : current;
+    }
+
+    private static String trimToNull(String value) {
+        return value == null || value.trim().isEmpty() ? null : value.trim();
+    }
+
+    private static <T> List<T> safe(List<T> values) {
+        return values == null
+                ? Collections.emptyList()
+                : values.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+}

+ 182 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewAlarmDisposalResponse.java

@@ -0,0 +1,182 @@
+package com.zksy.cockpit.overview;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 报警处置 {@code GET /overview/alarm-disposal} 的响应体(规格 §5)。 */
+public class CockpitOverviewAlarmDisposalResponse extends CockpitOverviewReadEnvelope {
+    private final Today today;
+    private final WorkOrderConversion workOrderConversion;
+    private final Disposal disposal;
+    private final List<LevelItem> byLevel;
+    private final List<TrendPoint> sevenDayTrend;
+
+    public CockpitOverviewAlarmDisposalResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String timezone,
+            String updatedAt,
+            Freshness freshness,
+            Today today,
+            WorkOrderConversion workOrderConversion,
+            Disposal disposal,
+            List<LevelItem> byLevel,
+            List<TrendPoint> sevenDayTrend) {
+        super(requestId, apiVersion, readOnly, timezone, updatedAt, freshness);
+        this.today = today;
+        this.workOrderConversion = workOrderConversion;
+        this.disposal = disposal;
+        this.byLevel = byLevel;
+        this.sevenDayTrend = sevenDayTrend;
+    }
+
+    public Today getToday() { return today; }
+    public WorkOrderConversion getWorkOrderConversion() { return workOrderConversion; }
+    public Disposal getDisposal() { return disposal; }
+    public List<LevelItem> getByLevel() { return byLevel; }
+    public List<TrendPoint> getSevenDayTrend() { return sevenDayTrend; }
+
+    public static class Today {
+        private final int newCount;
+        private final int yesterdayCount;
+        private final int delta;
+
+        public Today(int newCount, int yesterdayCount, int delta) {
+            this.newCount = newCount;
+            this.yesterdayCount = yesterdayCount;
+            this.delta = delta;
+        }
+
+        public int getNewCount() { return newCount; }
+        public int getYesterdayCount() { return yesterdayCount; }
+        public int getDelta() { return delta; }
+    }
+
+    /** 已转 + 未转 = 基础集合总数。 */
+    public static class WorkOrderConversion {
+        private final int convertedCount;
+        private final int unconvertedCount;
+        private final int totalCount;
+
+        public WorkOrderConversion(int convertedCount, int unconvertedCount, int totalCount) {
+            this.convertedCount = convertedCount;
+            this.unconvertedCount = unconvertedCount;
+            this.totalCount = totalCount;
+        }
+
+        public int getConvertedCount() { return convertedCount; }
+        public int getUnconvertedCount() { return unconvertedCount; }
+        public int getTotalCount() { return totalCount; }
+    }
+
+    public static class Disposal {
+        private final Rate rate;
+        private final AverageDuration avgResponseDuration;
+        private final AverageDuration avgDisposalDuration;
+
+        public Disposal(Rate rate, AverageDuration avgResponseDuration, AverageDuration avgDisposalDuration) {
+            this.rate = rate;
+            this.avgResponseDuration = avgResponseDuration;
+            this.avgDisposalDuration = avgDisposalDuration;
+        }
+
+        public Rate getRate() { return rate; }
+        public AverageDuration getAvgResponseDuration() { return avgResponseDuration; }
+        public AverageDuration getAvgDisposalDuration() { return avgDisposalDuration; }
+    }
+
+    /** 处置率:分母 0 → {@code state EMPTY}、{@code value "0"},前端不得显示 0%。 */
+    public static class Rate {
+        private final int numerator;
+        private final int denominator;
+        private final BigDecimal value;
+        private final String state;
+        private final String reason;
+        private final String basis;
+
+        public Rate(
+                int numerator, int denominator, BigDecimal value, String state, String reason, String basis) {
+            this.numerator = numerator;
+            this.denominator = denominator;
+            this.value = value;
+            this.state = state;
+            this.reason = reason;
+            this.basis = basis;
+        }
+
+        public int getNumerator() { return numerator; }
+        public int getDenominator() { return denominator; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+        public String getBasis() { return basis; }
+    }
+
+    /** 平均时长:{@code sampleCount} 为有效样本数,{@code invalidSampleCount} 为负时长剔除数。 */
+    public static class AverageDuration {
+        private final BigDecimal value;
+        private final String unit;
+        private final int sampleCount;
+        private final int invalidSampleCount;
+        private final String state;
+        private final String reason;
+
+        public AverageDuration(
+                BigDecimal value, String unit, int sampleCount, int invalidSampleCount, String state, String reason) {
+            this.value = value;
+            this.unit = unit;
+            this.sampleCount = sampleCount;
+            this.invalidSampleCount = invalidSampleCount;
+            this.state = state;
+            this.reason = reason;
+        }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public String getUnit() { return unit; }
+        public int getSampleCount() { return sampleCount; }
+        public int getInvalidSampleCount() { return invalidSampleCount; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+    }
+
+    public static class LevelItem {
+        private final int level;
+        private final String name;
+        private final int count;
+
+        public LevelItem(int level, String name, int count) {
+            this.level = level;
+            this.name = name;
+            this.count = count;
+        }
+
+        public int getLevel() { return level; }
+        public String getName() { return name; }
+        public int getCount() { return count; }
+    }
+
+    /**
+     * 折线上的一天,两个读数(维护者 2026-10-23):
+     * {@code count} = 当天新增;{@code unhandledTotal} = 截至当天仍未处理的累计条数。
+     */
+    public static class TrendPoint {
+        private final String date;
+        private final int count;
+        private final int unhandledTotal;
+
+        public TrendPoint(String date, int count, int unhandledTotal) {
+            this.date = date;
+            this.count = count;
+            this.unhandledTotal = unhandledTotal;
+        }
+
+        public String getDate() { return date; }
+        public int getCount() { return count; }
+        public int getUnhandledTotal() { return unhandledTotal; }
+    }
+}

+ 109 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewAlarmDisposalSnapshot.java

@@ -0,0 +1,109 @@
+package com.zksy.cockpit.overview;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 报警处置的源事实(已按 SQL 聚合,基础集合 {@code audit_status = 1}):
+ * 计数、转单、结案、等级分组、按日趋势与响应/处置时长样本。
+ */
+public class CockpitOverviewAlarmDisposalSnapshot {
+    private final LocalDateTime updatedAt;
+    private final int totalCount;
+    private final int todayNew;
+    private final int yesterdayNew;
+    private final int unconvertedCount;
+    private final int closedCount;
+    private final List<SourceLevelCount> levelCounts;
+    private final List<SourceDayCount> dailyCounts;
+    private final Map<Integer, SourceDurationSample> durationSamples;
+    private final Map<Integer, String> levelNames;
+
+    public CockpitOverviewAlarmDisposalSnapshot(
+            LocalDateTime updatedAt,
+            int totalCount,
+            int todayNew,
+            int yesterdayNew,
+            int unconvertedCount,
+            int closedCount,
+            List<SourceLevelCount> levelCounts,
+            List<SourceDayCount> dailyCounts,
+            Map<Integer, SourceDurationSample> durationSamples,
+            Map<Integer, String> levelNames) {
+        this.updatedAt = updatedAt;
+        this.totalCount = totalCount;
+        this.todayNew = todayNew;
+        this.yesterdayNew = yesterdayNew;
+        this.unconvertedCount = unconvertedCount;
+        this.closedCount = closedCount;
+        this.levelCounts = levelCounts;
+        this.dailyCounts = dailyCounts;
+        this.durationSamples = durationSamples;
+        this.levelNames = levelNames;
+    }
+
+    public LocalDateTime getUpdatedAt() { return updatedAt; }
+    public int getTotalCount() { return totalCount; }
+    public int getTodayNew() { return todayNew; }
+    public int getYesterdayNew() { return yesterdayNew; }
+    public int getUnconvertedCount() { return unconvertedCount; }
+    public int getClosedCount() { return closedCount; }
+    public List<SourceLevelCount> getLevelCounts() { return levelCounts; }
+    public List<SourceDayCount> getDailyCounts() { return dailyCounts; }
+    /** {@code oper_type → 时长样本}(2 = 响应、5 = 处置)。 */
+    public Map<Integer, SourceDurationSample> getDurationSamples() { return durationSamples; }
+    /** 字典 {@code alarm_level}:{@code dict_value → dict_label}(一级报警…四级报警)。 */
+    public Map<Integer, String> getLevelNames() { return levelNames; }
+
+    public static class SourceLevelCount {
+        private final Integer level;
+        private final int count;
+
+        public SourceLevelCount(Integer level, int count) {
+            this.level = level;
+            this.count = count;
+        }
+
+        public Integer getLevel() { return level; }
+        public int getCount() { return count; }
+    }
+
+    /**
+     * 按日两个读数(维护者 2026-10-23):
+     * {@code count} = 当天新增;{@code unhandledTotal} = 截至当天仍未处理的累计条数。
+     * 两条线放同一张折线图做对照 —— 新增归零而积压不为零,读起来就是「最近没新报警、积压还在」。
+     */
+    public static class SourceDayCount {
+        private final String day;
+        private final int count;
+        private final int unhandledTotal;
+
+        public SourceDayCount(String day, int count, int unhandledTotal) {
+            this.day = day;
+            this.count = count;
+            this.unhandledTotal = unhandledTotal;
+        }
+
+        public String getDay() { return day; }
+        public int getCount() { return count; }
+        public int getUnhandledTotal() { return unhandledTotal; }
+    }
+
+    /** 时长样本:{@code totalSeconds} 只累计有效样本,负时长单独计 {@code invalidSamples}。 */
+    public static class SourceDurationSample {
+        private final long totalSeconds;
+        private final int validSamples;
+        private final int invalidSamples;
+
+        public SourceDurationSample(long totalSeconds, int validSamples, int invalidSamples) {
+            this.totalSeconds = totalSeconds;
+            this.validSamples = validSamples;
+            this.invalidSamples = invalidSamples;
+        }
+
+        public long getTotalSeconds() { return totalSeconds; }
+        public int getValidSamples() { return validSamples; }
+        public int getInvalidSamples() { return invalidSamples; }
+    }
+}

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesAdapter.java

@@ -0,0 +1,18 @@
+package com.zksy.cockpit.overview;
+
+/**
+ * 总体概览四模块的真实数据入口(规格 §2–§5)。
+ *
+ * <p>四个 {@code load*} 各自独立取数:一个模块的上游失败不影响其它模块(分区独立成败)。
+ * 时间窗由服务层按 {@code Asia/Shanghai} 算好后传入({@link CockpitOverviewWindows}),
+ * 适配器只负责把 SQL 聚合结果归一成源事实,口径判定留在 {@link CockpitOverviewModulesService}。
+ */
+public interface CockpitOverviewModulesAdapter {
+    CockpitOverviewPerceptionSnapshot loadPerception();
+
+    CockpitOverviewPipeNetworkSnapshot loadPipeNetwork();
+
+    CockpitOverviewWarningStudySnapshot loadWarningStudy(CockpitOverviewWindows windows);
+
+    CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(CockpitOverviewWindows windows);
+}

+ 602 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesService.java

@@ -0,0 +1,602 @@
+package com.zksy.cockpit.overview;
+
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * 总体概览四模块的只读聚合口径(规格 2026-10-22 四模块契约 §2–§5)。
+ *
+ * <p>四端点共用同一份时区/信封/空态规则:{@code Asia/Shanghai}、今日 {@code [今天 00:00, 明天 00:00)}、
+ * 分母或样本为 0 时 {@code state=EMPTY} 且不把 0 当作读数。口径判定集中在本层,适配器只给聚合事实行,
+ * 便于按规格 §8 逐条单测边界。
+ */
+@Service
+public class CockpitOverviewModulesService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final String API_VERSION = "v1";
+    private static final String TIMEZONE = "Asia/Shanghai";
+    private static final String AVAILABLE = "AVAILABLE";
+    private static final String EMPTY = "EMPTY";
+    private static final String DERIVED = "DERIVED";
+    private static final String FALLBACK = "FALLBACK";
+    private static final BigDecimal METERS_PER_KILOMETER = BigDecimal.valueOf(1000);
+    // 排行不再截断(维护者 2026-10-26:近 30 日共 27 起,页面只列出 14+6+3=23 起,读者对不上数)。
+    // 保留常量为 0 表达「全部」,比删掉更明确 —— 调用点读到它就知道这是刻意口径。
+    private static final int RANKING_TOP_SIZE = 0;
+    private static final int TREND_DAYS = 7;
+    private static final int WORK_ORDER_CLOSED = 5;
+    private static final int OPER_TYPE_RECEIVE = 2;
+    private static final int OPER_TYPE_ACCEPT = 5;
+    private static final String RATE_BASIS = "CLOSED_WORK_ORDERS_OVER_ALL_ALARMS";
+    private static final String CONTENT_SEPARATOR = " / ";
+    private static final String[] LEVEL_PREFIXES = {"一", "二", "三", "四"};
+    private static final List<Integer> DISTRICT_CODES =
+            Collections.unmodifiableList(Arrays.asList(0, 1, 2));
+    private static final List<String> UNRESOLVED_STATUSES =
+            Collections.unmodifiableList(Arrays.asList("PENDING", "RECEIVED", "PROCESSING", "HANDLED"));
+
+    /**
+     * 四类监测设备的固定卡片(规格 §2):根类型 code 1–4、库内 {@code equipment_type} 根类型名,
+     * 以及派生失败时的规格常量兜底(缺口 G6)。
+     */
+    private static final List<DeviceCategory> DEVICE_CATEGORIES = Collections.unmodifiableList(Arrays.asList(
+            new DeviceCategory(1, "1", "供水设备", "压力 / 流量 / 漏失"),
+            new DeviceCategory(2, "2", "排水设备", "液位 / 流量 / 内涝"),
+            new DeviceCategory(3, "3", "燃气设备", "可燃气体 / 压力"),
+            new DeviceCategory(4, "4", "窨井设备", "井盖状态 / 液位")));
+
+    /** 三类管道的固定卡片与顺序(规格 §3):供水、排水、燃气。 */
+    private static final List<PipeCategory> PIPE_CATEGORIES = Collections.unmodifiableList(Arrays.asList(
+            new PipeCategory(CockpitPipeNetworkType.WATER_PIPE, "供水管道"),
+            new PipeCategory(CockpitPipeNetworkType.DRAINAGE_PIPE, "排水管道"),
+            new PipeCategory(CockpitPipeNetworkType.GAS_PIPE, "燃气管道")));
+
+    private final CockpitOverviewModulesAdapter adapter;
+    private final Clock clock;
+
+    public CockpitOverviewModulesService(CockpitOverviewModulesAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public synchronized CockpitOverviewPerceptionResponse loadPerception(String requestId) {
+        return read(() -> perception(requestId));
+    }
+
+    public synchronized CockpitOverviewPipeNetworkResponse loadPipeNetwork(String requestId) {
+        return read(() -> pipeNetwork(requestId));
+    }
+
+    public synchronized CockpitOverviewWarningStudyResponse loadWarningStudy(String requestId) {
+        return read(() -> warningStudy(requestId));
+    }
+
+    public synchronized CockpitOverviewAlarmDisposalResponse loadAlarmDisposal(String requestId) {
+        return read(() -> alarmDisposal(requestId));
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱总体概览只读数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    // ---------------------------------------------------------------- 感知设备
+
+    private CockpitOverviewPerceptionResponse perception(String requestId) {
+        CockpitOverviewPerceptionSnapshot snapshot = adapter.loadPerception();
+        List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets =
+                safe(snapshot == null ? null : snapshot.getBuckets());
+
+        int deviceCount = 0;
+        int online = 0;
+        int offline = 0;
+        int noStatus = 0;
+        int unknownType = 0;
+        int unassignedDistrict = 0;
+        int[][] byType = new int[DEVICE_CATEGORIES.size()][3];
+        int[] byDistrict = new int[DISTRICT_CODES.size()];
+        for (CockpitOverviewPerceptionSnapshot.SourceDeviceBucket bucket : buckets) {
+            int count = bucket.getDeviceCount();
+            deviceCount += count;
+            if (bucket.isOnline()) {
+                online += count;
+            } else {
+                offline += count;
+            }
+            // G7:无状态记录的设备计离线并单独计数(noStatusCount),不冒充在线。
+            if (!bucket.hasStatus()) {
+                noStatus += count;
+            }
+            int categoryIndex = categoryIndex(bucket.getRootTypeId());
+            if (categoryIndex < 0) {
+                unknownType += count;
+            } else {
+                byType[categoryIndex][0] += count;
+                if (bucket.isOnline()) {
+                    byType[categoryIndex][1] += count;
+                } else {
+                    byType[categoryIndex][2] += count;
+                }
+            }
+            Integer district = CockpitDistrict.codeOf(bucket.getDistrict());
+            if (district == null) {
+                unassignedDistrict += count;
+            } else {
+                byDistrict[DISTRICT_CODES.indexOf(district)] += count;
+            }
+        }
+
+        BigDecimal rate = deviceCount == 0
+                ? BigDecimal.ZERO.setScale(4)
+                : BigDecimal.valueOf(online).divide(BigDecimal.valueOf(deviceCount), 4, RoundingMode.HALF_UP);
+        CockpitOverviewPerceptionResponse.OnlineRate onlineRate =
+                new CockpitOverviewPerceptionResponse.OnlineRate(
+                        rate,
+                        online,
+                        deviceCount,
+                        deviceCount == 0 ? EMPTY : AVAILABLE,
+                        deviceCount == 0 ? "NO_DEVICES"
+                                : noStatus > 0 ? "NO_STATUS_COUNTED_OFFLINE" : null);
+
+        Map<Integer, Set<String>> derivedContents = derivedContents(
+                snapshot == null ? null : snapshot.getMonitoringContents());
+        List<CockpitOverviewPerceptionResponse.TypeItem> byTypeItems = new ArrayList<>();
+        for (int index = 0; index < DEVICE_CATEGORIES.size(); index++) {
+            DeviceCategory category = DEVICE_CATEGORIES.get(index);
+            Set<String> names = derivedContents.get(category.rootTypeId);
+            boolean derived = names != null && !names.isEmpty();
+            byTypeItems.add(new CockpitOverviewPerceptionResponse.TypeItem(
+                    category.code,
+                    category.name,
+                    byType[index][0],
+                    byType[index][1],
+                    byType[index][2],
+                    derived ? String.join(CONTENT_SEPARATOR, names) : category.fallbackContent,
+                    derived ? DERIVED : FALLBACK));
+        }
+        List<CockpitOverviewPerceptionResponse.DistrictItem> districts = new ArrayList<>();
+        for (Integer code : DISTRICT_CODES) {
+            districts.add(new CockpitOverviewPerceptionResponse.DistrictItem(
+                    code, CockpitDistrict.nameOf(code), byDistrict[DISTRICT_CODES.indexOf(code)]));
+        }
+
+        String serverTime = businessTime();
+        String updatedAt = updatedAt(snapshot == null ? null : snapshot.getUpdatedAt(), serverTime);
+        return new CockpitOverviewPerceptionResponse(
+                requestId,
+                API_VERSION,
+                true,
+                TIMEZONE,
+                updatedAt,
+                freshness(deviceCount == 0 ? "NO_DEVICES" : null, updatedAt, serverTime),
+                new CockpitOverviewPerceptionResponse.Totals(
+                        deviceCount, online, offline, noStatus, onlineRate),
+                byTypeItems,
+                districts,
+                new CockpitOverviewPerceptionResponse.Excluded(unknownType, unassignedDistrict));
+    }
+
+    // ---------------------------------------------------------------- 基础设施
+
+    private CockpitOverviewPipeNetworkResponse pipeNetwork(String requestId) {
+        CockpitOverviewPipeNetworkSnapshot snapshot = adapter.loadPipeNetwork();
+        List<CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket> buckets =
+                safe(snapshot == null ? null : snapshot.getBuckets());
+
+        int pipeCount = 0;
+        BigDecimal totalLengthMeters = BigDecimal.ZERO;
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, BigDecimal> lengths = new LinkedHashMap<>();
+        int unclassified = 0;
+        for (CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket bucket : buckets) {
+            pipeCount += bucket.getPipeCount();
+            if (bucket.getLengthMeters() != null) {
+                totalLengthMeters = totalLengthMeters.add(bucket.getLengthMeters());
+            }
+            if (!isFormalPipeType(bucket.getTypeCode())) {
+                // G9:无法映射的 network_type 不进分类占比,但计入总数与 excluded。
+                unclassified += bucket.getPipeCount();
+                continue;
+            }
+            counts.merge(bucket.getTypeCode(), bucket.getPipeCount(), Integer::sum);
+            lengths.merge(bucket.getTypeCode(), nullToZero(bucket.getLengthMeters()), BigDecimal::add);
+        }
+        int classified = counts.values().stream().mapToInt(Integer::intValue).sum();
+
+        List<CockpitOverviewPipeNetworkResponse.PipeTypeItem> byPipeType = new ArrayList<>();
+        for (PipeCategory category : PIPE_CATEGORIES) {
+            int count = counts.getOrDefault(category.code, 0);
+            BigDecimal lengthKm = nullToZero(lengths.get(category.code))
+                    .divide(METERS_PER_KILOMETER, 2, RoundingMode.HALF_UP);
+            BigDecimal ratio = classified == 0
+                    ? BigDecimal.ZERO
+                    : BigDecimal.valueOf(count).divide(BigDecimal.valueOf(classified), 4, RoundingMode.HALF_UP);
+            byPipeType.add(new CockpitOverviewPipeNetworkResponse.PipeTypeItem(
+                    category.code, category.name, count, lengthKm, ratio));
+        }
+
+        String serverTime = businessTime();
+        String updatedAt = updatedAt(snapshot == null ? null : snapshot.getUpdatedAt(), serverTime);
+        String state = pipeCount == 0 ? EMPTY : classified == 0 ? EMPTY : AVAILABLE;
+        String reason = pipeCount == 0 ? "NO_PIPES" : classified == 0 ? "NO_CLASSIFIED_PIPES" : null;
+        return new CockpitOverviewPipeNetworkResponse(
+                requestId,
+                API_VERSION,
+                true,
+                TIMEZONE,
+                updatedAt,
+                freshness(pipeCount == 0 ? "NO_PIPES" : null, updatedAt, serverTime),
+                new CockpitOverviewPipeNetworkResponse.Totals(
+                        pipeCount, totalLengthMeters.divide(METERS_PER_KILOMETER, 2, RoundingMode.HALF_UP)),
+                byPipeType,
+                new CockpitOverviewPipeNetworkResponse.Excluded(unclassified),
+                state,
+                reason);
+    }
+
+    // ------------------------------------------------------------ 预警研判
+
+    private CockpitOverviewWarningStudyResponse warningStudy(String requestId) {
+        CockpitOverviewWindows windows = CockpitOverviewWindows.of(clock);
+        CockpitOverviewWarningStudySnapshot snapshot = adapter.loadWarningStudy(windows);
+        List<CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount> statusLevelCounts =
+                safe(snapshot == null ? null : snapshot.getStatusLevelCounts());
+        Map<String, String> typeNames =
+                snapshot == null ? Collections.emptyMap() : nullToEmpty(snapshot.getTypeNames());
+        Map<Integer, String> levelNames =
+                snapshot == null ? Collections.emptyMap() : nullToEmpty(snapshot.getLevelNames());
+
+        int unresolved = 0;
+        Map<String, Integer> statusCounts = new LinkedHashMap<>();
+        for (String status : UNRESOLVED_STATUSES) {
+            statusCounts.put(status, 0);
+        }
+        int[] levelCounts = new int[4];
+        for (CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount row : statusLevelCounts) {
+            String status = normalizeCode(row.getStatus());
+            if (status == null || !statusCounts.containsKey(status)) {
+                // 规格 §4:分级只对未解除集合统计,DRAFT/CLOSED 不得漏进 byLevel。
+                continue;
+            }
+            statusCounts.merge(status, row.getCount(), Integer::sum);
+            unresolved += row.getCount();
+            Integer level = row.getLevel();
+            if (level != null && level >= 1 && level <= 4) {
+                levelCounts[level - 1] += row.getCount();
+            }
+        }
+
+        List<CockpitOverviewWarningStudyResponse.LevelItem> byLevel = new ArrayList<>();
+        for (int level = 1; level <= 4; level++) {
+            byLevel.add(new CockpitOverviewWarningStudyResponse.LevelItem(
+                    level, levelName(levelNames.get(level), level, "预警"), levelCounts[level - 1]));
+        }
+
+        Map<String, String> normalizedNames = normalizedTypeNames(typeNames);
+        List<CockpitOverviewWarningStudyResponse.TopType> top = new ArrayList<>();
+        for (CockpitOverviewWarningStudySnapshot.SourceTypeCount row : rankedTypes(
+                snapshot == null ? null : snapshot.getTypeCounts())) {
+            String code = row.getTypeCode();
+            top.add(new CockpitOverviewWarningStudyResponse.TopType(
+                    code, typeName(code, typeNames, normalizedNames), row.getCount()));
+        }
+
+        int rankingTotal = snapshot == null ? 0 : snapshot.getRankingTotal();
+        int todayNew = snapshot == null ? 0 : snapshot.getTodayNew();
+        int yesterdayNew = snapshot == null ? 0 : snapshot.getYesterdayNew();
+        String serverTime = businessTime();
+        String updatedAt = updatedAt(snapshot == null ? null : snapshot.getUpdatedAt(), serverTime);
+        boolean noWarnings = unansweredWarnings(todayNew, yesterdayNew, unresolved, rankingTotal);
+        return new CockpitOverviewWarningStudyResponse(
+                requestId,
+                API_VERSION,
+                true,
+                TIMEZONE,
+                updatedAt,
+                freshness(noWarnings ? "NO_WARNINGS" : null, updatedAt, serverTime),
+                new CockpitOverviewWarningStudyResponse.Today(todayNew, yesterdayNew, todayNew - yesterdayNew),
+                new CockpitOverviewWarningStudyResponse.Unresolved(unresolved, statusCounts),
+                byLevel,
+                new CockpitOverviewWarningStudyResponse.TypeRanking(
+                        snapshot == null ? 30 : snapshot.getRangeDays(), rankingTotal, top));
+    }
+
+    // ------------------------------------------------------------ 报警处置
+
+    private CockpitOverviewAlarmDisposalResponse alarmDisposal(String requestId) {
+        CockpitOverviewWindows windows = CockpitOverviewWindows.of(clock);
+        CockpitOverviewAlarmDisposalSnapshot snapshot = adapter.loadAlarmDisposal(windows);
+        int total = snapshot == null ? 0 : snapshot.getTotalCount();
+        int todayNew = snapshot == null ? 0 : snapshot.getTodayNew();
+        int yesterdayNew = snapshot == null ? 0 : snapshot.getYesterdayNew();
+        int unconverted = snapshot == null ? 0 : snapshot.getUnconvertedCount();
+        int converted = Math.max(total - unconverted, 0);
+        int closedOrders = snapshot == null ? 0 : snapshot.getClosedCount();
+
+        CockpitOverviewAlarmDisposalResponse.Rate rate = new CockpitOverviewAlarmDisposalResponse.Rate(
+                closedOrders,
+                total,
+                total == 0
+                        ? BigDecimal.ZERO
+                        : BigDecimal.valueOf(closedOrders).divide(BigDecimal.valueOf(total), 4, RoundingMode.HALF_UP),
+                total == 0 ? EMPTY : AVAILABLE,
+                total == 0 ? "NO_ALARMS" : null,
+                RATE_BASIS);
+
+        int[] levelCounts = new int[4];
+        for (CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount row : safe(
+                snapshot == null ? null : snapshot.getLevelCounts())) {
+            Integer level = row.getLevel();
+            if (level != null && level >= 1 && level <= 4) {
+                levelCounts[level - 1] += row.getCount();
+            }
+        }
+        Map<Integer, String> levelNames =
+                snapshot == null ? Collections.emptyMap() : nullToEmpty(snapshot.getLevelNames());
+        List<CockpitOverviewAlarmDisposalResponse.LevelItem> byLevel = new ArrayList<>();
+        for (int level = 1; level <= 4; level++) {
+            byLevel.add(new CockpitOverviewAlarmDisposalResponse.LevelItem(
+                    level, levelName(levelNames.get(level), level, "报警"), levelCounts[level - 1]));
+        }
+
+        // 按日两个读数:新增(count)与截至当天总未处理(unhandledTotal)。
+        // 两者合成一个 map 再按日期铺满 7 天 —— 缺的那天补 0 是「当天没有新增」,
+        // 而不是「没取到数据」(缺数据由 freshness 说,不在这里用 0 冒充)。
+        Map<String, Integer> dailyCounts = new LinkedHashMap<>();
+        Map<String, Integer> unhandledTotals = new LinkedHashMap<>();
+        for (CockpitOverviewAlarmDisposalSnapshot.SourceDayCount row : safe(
+                snapshot == null ? null : snapshot.getDailyCounts())) {
+            if (row.getDay() != null) {
+                String day = row.getDay().trim();
+                dailyCounts.merge(day, row.getCount(), Integer::sum);
+                unhandledTotals.merge(day, row.getUnhandledTotal(), Integer::sum);
+            }
+        }
+        List<CockpitOverviewAlarmDisposalResponse.TrendPoint> trend = new ArrayList<>();
+        LocalDate today = windows.today();
+        for (int offset = TREND_DAYS - 1; offset >= 0; offset--) {
+            String date = DATE_FORMATTER.format(today.minusDays(offset));
+            trend.add(new CockpitOverviewAlarmDisposalResponse.TrendPoint(
+                    date, dailyCounts.getOrDefault(date, 0), unhandledTotals.getOrDefault(date, 0)));
+        }
+
+        Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> samples =
+                snapshot == null ? Collections.emptyMap() : nullToEmpty(snapshot.getDurationSamples());
+        String serverTime = businessTime();
+        String updatedAt = updatedAt(snapshot == null ? null : snapshot.getUpdatedAt(), serverTime);
+        return new CockpitOverviewAlarmDisposalResponse(
+                requestId,
+                API_VERSION,
+                true,
+                TIMEZONE,
+                updatedAt,
+                freshness(total == 0 ? "NO_ALARMS" : null, updatedAt, serverTime),
+                new CockpitOverviewAlarmDisposalResponse.Today(todayNew, yesterdayNew, todayNew - yesterdayNew),
+                new CockpitOverviewAlarmDisposalResponse.WorkOrderConversion(converted, unconverted, total),
+                new CockpitOverviewAlarmDisposalResponse.Disposal(
+                        rate,
+                        averageDuration(samples.get(OPER_TYPE_RECEIVE), "NO_RESPONSE_LOGS"),
+                        averageDuration(samples.get(OPER_TYPE_ACCEPT), "NO_DISPOSAL_LOGS")),
+                byLevel,
+                trend);
+    }
+
+    // ---------------------------------------------------------------- 内部工具
+
+    private <T> T read(Supplier<T> loader) {
+        try {
+            return loader.get();
+        } catch (CockpitReadException exception) {
+            throw exception;
+        } catch (RuntimeException exception) {
+            throw new CockpitReadException("驾驶舱总体概览只读数据暂不可用: " + exception.getMessage());
+        }
+    }
+
+    private CockpitOverviewReadEnvelope.Freshness freshness(
+            String emptyReason, String updatedAt, String serverTime) {
+        return new CockpitOverviewReadEnvelope.Freshness(
+                emptyReason == null ? "CURRENT" : EMPTY, emptyReason, updatedAt, serverTime);
+    }
+
+    private static String updatedAt(LocalDateTime source, String serverTime) {
+        String formatted = offsetTime(source);
+        return formatted == null ? serverTime : formatted;
+    }
+
+    /** 监测内容派生源按根类型去重;同一根类型下同名指标只保留一次。 */
+    private static Map<Integer, Set<String>> derivedContents(
+            List<CockpitOverviewPerceptionSnapshot.SourceMonitoringContent> contents) {
+        Map<Integer, Set<String>> result = new LinkedHashMap<>();
+        for (CockpitOverviewPerceptionSnapshot.SourceMonitoringContent content : safe(contents)) {
+            if (content.getRootTypeId() == null || content.getWarningName() == null) {
+                continue;
+            }
+            result.computeIfAbsent(content.getRootTypeId(), key -> new LinkedHashSet<>())
+                    .add(content.getWarningName().trim());
+        }
+        return result;
+    }
+
+    /** 根类型 id → 固定卡片下标;不在 1–4 内(含悬空类型)回 -1。 */
+    private static int categoryIndex(Integer rootTypeId) {
+        for (int index = 0; index < DEVICE_CATEGORIES.size(); index++) {
+            if (Integer.valueOf(DEVICE_CATEGORIES.get(index).rootTypeId).equals(rootTypeId)) {
+                return index;
+            }
+        }
+        return -1;
+    }
+
+    private static boolean isFormalPipeType(String typeCode) {
+        for (PipeCategory category : PIPE_CATEGORIES) {
+            if (category.code.equals(typeCode)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 等级展示名:字典有标签用标签(预警 {@code alert_level}/报警 {@code alarm_level}),缺项按「N级<后缀>」兜底。 */
+    private static String levelName(String dictLabel, int level, String suffix) {
+        if (dictLabel != null && !dictLabel.trim().isEmpty()) {
+            return dictLabel.trim();
+        }
+        return LEVEL_PREFIXES[level - 1] + "级" + suffix;
+    }
+
+    /**
+     * 类型排行:计数降序,并列按 code 升序,**全部返回**(维护者 2026-10-26 改)。
+     *
+     * 原来 `limit(3)` 只给前三:页面写「近30日 · 共27起」而三行相加只有 23 起,读者对不上数。
+     * 现在把库里所有类型都给出(空 code 仍不参与排行,但计入 {@code total}),
+     * 展示几条由前端决定 —— 口径与计算留在后端,展示取舍留在前端。
+     */
+    private static List<CockpitOverviewWarningStudySnapshot.SourceTypeCount> rankedTypes(
+            List<CockpitOverviewWarningStudySnapshot.SourceTypeCount> rows) {
+        Stream<CockpitOverviewWarningStudySnapshot.SourceTypeCount> ranked = safe(rows).stream()
+                .filter(row -> row.getTypeCode() != null && !row.getTypeCode().trim().isEmpty())
+                .sorted(Comparator.comparingInt(CockpitOverviewWarningStudySnapshot.SourceTypeCount::getCount)
+                        .reversed()
+                        .thenComparing(CockpitOverviewWarningStudySnapshot.SourceTypeCount::getTypeCode));
+        if (RANKING_TOP_SIZE > 0) {
+            ranked = ranked.limit(RANKING_TOP_SIZE);
+        }
+        return ranked.collect(Collectors.toList());
+    }
+
+    /**
+     * 类型名称归一索引(规格 §4):库内 {@code warning_type.warning_code} 与
+     * {@code early_warning.warning_type} 存在 {@code -}/{@code _} 混用,join 前统一为大写 {@code _}。
+     */
+    private static Map<String, String> normalizedTypeNames(Map<String, String> typeNames) {
+        Map<String, String> result = new LinkedHashMap<>();
+        for (Map.Entry<String, String> entry : typeNames.entrySet()) {
+            String key = normalizeCode(entry.getKey());
+            if (key != null && entry.getValue() != null && !entry.getValue().trim().isEmpty()) {
+                result.putIfAbsent(key, entry.getValue().trim());
+            }
+        }
+        return result;
+    }
+
+    private static String typeName(
+            String code, Map<String, String> typeNames, Map<String, String> normalizedNames) {
+        String name = code == null ? null : typeNames.get(code);
+        if (name == null || name.trim().isEmpty()) {
+            name = normalizedNames.get(normalizeCode(code));
+        }
+        return name == null || name.trim().isEmpty() ? code : name.trim();
+    }
+
+    /** 编码归一:去空白、大写、{@code -} → {@code _}(仅用于匹配,不改展示值)。 */
+    private static String normalizeCode(String code) {
+        if (code == null) {
+            return null;
+        }
+        String trimmed = code.trim();
+        return trimmed.isEmpty() ? null : trimmed.toUpperCase(Locale.ROOT).replace('-', '_');
+    }
+
+    /** 平均时长:有效样本为 0 时 {@code state EMPTY},不返回 0 冒充(规格 §5)。 */
+    private static CockpitOverviewAlarmDisposalResponse.AverageDuration averageDuration(
+            CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample sample, String emptyReason) {
+        int validSamples = sample == null ? 0 : sample.getValidSamples();
+        int invalidSamples = sample == null ? 0 : sample.getInvalidSamples();
+        long totalSeconds = sample == null ? 0L : sample.getTotalSeconds();
+        BigDecimal value = validSamples == 0
+                ? BigDecimal.ZERO
+                : BigDecimal.valueOf(totalSeconds)
+                        .divide(BigDecimal.valueOf(3600L * validSamples), 1, RoundingMode.HALF_UP);
+        return new CockpitOverviewAlarmDisposalResponse.AverageDuration(
+                value,
+                "HOURS",
+                validSamples,
+                invalidSamples,
+                validSamples == 0 ? EMPTY : AVAILABLE,
+                validSamples == 0 ? emptyReason : null);
+    }
+
+    private static boolean unansweredWarnings(
+            int todayNew, int yesterdayNew, int unresolved, int rankingTotal) {
+        return todayNew == 0 && yesterdayNew == 0 && unresolved == 0 && rankingTotal == 0;
+    }
+
+    private static String offsetTime(LocalDateTime value) {
+        return value == null ? null : TIME_FORMATTER.format(value.atZone(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private static BigDecimal nullToZero(BigDecimal value) {
+        return value == null ? BigDecimal.ZERO : value;
+    }
+
+    private static <T> List<T> safe(List<T> values) {
+        return values == null
+                ? Collections.emptyList()
+                : values.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static <K, V> Map<K, V> nullToEmpty(Map<K, V> values) {
+        return values == null ? Collections.emptyMap() : values;
+    }
+
+    private String businessTime() {
+        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
+    }
+
+    /** 一个固定设备类别的展示常量。 */
+    private static final class DeviceCategory {
+        private final int rootTypeId;
+        private final String code;
+        private final String name;
+        private final String fallbackContent;
+
+        private DeviceCategory(int rootTypeId, String code, String name, String fallbackContent) {
+            this.rootTypeId = rootTypeId;
+            this.code = code;
+            this.name = name;
+            this.fallbackContent = fallbackContent;
+        }
+    }
+
+    private static final class PipeCategory {
+        private final String code;
+        private final String name;
+
+        private PipeCategory(String code, String name) {
+            this.code = code;
+            this.name = name;
+        }
+    }
+}

+ 151 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPerceptionResponse.java

@@ -0,0 +1,151 @@
+package com.zksy.cockpit.overview;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 感知设备 {@code GET /overview/perception} 的响应体(规格 §2)。 */
+public class CockpitOverviewPerceptionResponse extends CockpitOverviewReadEnvelope {
+    private final Totals totals;
+    private final List<TypeItem> byType;
+    private final List<DistrictItem> byDistrict;
+    private final Excluded excluded;
+
+    public CockpitOverviewPerceptionResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String timezone,
+            String updatedAt,
+            Freshness freshness,
+            Totals totals,
+            List<TypeItem> byType,
+            List<DistrictItem> byDistrict,
+            Excluded excluded) {
+        super(requestId, apiVersion, readOnly, timezone, updatedAt, freshness);
+        this.totals = totals;
+        this.byType = byType;
+        this.byDistrict = byDistrict;
+        this.excluded = excluded;
+    }
+
+    public Totals getTotals() { return totals; }
+    public List<TypeItem> getByType() { return byType; }
+    public List<DistrictItem> getByDistrict() { return byDistrict; }
+    public Excluded getExcluded() { return excluded; }
+
+    /** {@code noStatusCount}:无状态记录、按规格计入离线的设备数(G7)。 */
+    public static class Totals {
+        private final int deviceCount;
+        private final int onlineCount;
+        private final int offlineCount;
+        private final int noStatusCount;
+        private final OnlineRate onlineRate;
+
+        public Totals(
+                int deviceCount, int onlineCount, int offlineCount, int noStatusCount, OnlineRate onlineRate) {
+            this.deviceCount = deviceCount;
+            this.onlineCount = onlineCount;
+            this.offlineCount = offlineCount;
+            this.noStatusCount = noStatusCount;
+            this.onlineRate = onlineRate;
+        }
+
+        public int getDeviceCount() { return deviceCount; }
+        public int getOnlineCount() { return onlineCount; }
+        public int getOfflineCount() { return offlineCount; }
+        public int getNoStatusCount() { return noStatusCount; }
+        public OnlineRate getOnlineRate() { return onlineRate; }
+    }
+
+    /** 在线率:分母 0 → {@code state EMPTY},不返回 0 冒充。 */
+    public static class OnlineRate {
+        private final BigDecimal value;
+        private final int numerator;
+        private final int denominator;
+        private final String state;
+        private final String reason;
+
+        public OnlineRate(BigDecimal value, int numerator, int denominator, String state, String reason) {
+            this.value = value;
+            this.numerator = numerator;
+            this.denominator = denominator;
+            this.state = state;
+            this.reason = reason;
+        }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public int getNumerator() { return numerator; }
+        public int getDenominator() { return denominator; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+    }
+
+    /** {@code monitoringContentSource}:{@code DERIVED} = 库内派生,{@code FALLBACK} = 规格常量兜底。 */
+    public static class TypeItem {
+        private final String code;
+        private final String name;
+        private final int deviceCount;
+        private final int onlineCount;
+        private final int offlineCount;
+        private final String monitoringContent;
+        private final String monitoringContentSource;
+
+        public TypeItem(
+                String code,
+                String name,
+                int deviceCount,
+                int onlineCount,
+                int offlineCount,
+                String monitoringContent,
+                String monitoringContentSource) {
+            this.code = code;
+            this.name = name;
+            this.deviceCount = deviceCount;
+            this.onlineCount = onlineCount;
+            this.offlineCount = offlineCount;
+            this.monitoringContent = monitoringContent;
+            this.monitoringContentSource = monitoringContentSource;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public int getDeviceCount() { return deviceCount; }
+        public int getOnlineCount() { return onlineCount; }
+        public int getOfflineCount() { return offlineCount; }
+        public String getMonitoringContent() { return monitoringContent; }
+        public String getMonitoringContentSource() { return monitoringContentSource; }
+    }
+
+    public static class DistrictItem {
+        private final int district;
+        private final String name;
+        private final int deviceCount;
+
+        public DistrictItem(int district, String name, int deviceCount) {
+            this.district = district;
+            this.name = name;
+            this.deviceCount = deviceCount;
+        }
+
+        public int getDistrict() { return district; }
+        public String getName() { return name; }
+        public int getDeviceCount() { return deviceCount; }
+    }
+
+    /** 不静默丢弃:未归属根类型、未分片区的设备显式计数。 */
+    public static class Excluded {
+        private final int unknownTypeCount;
+        private final int unassignedDistrictCount;
+
+        public Excluded(int unknownTypeCount, int unassignedDistrictCount) {
+            this.unknownTypeCount = unknownTypeCount;
+            this.unassignedDistrictCount = unassignedDistrictCount;
+        }
+
+        public int getUnknownTypeCount() { return unknownTypeCount; }
+        public int getUnassignedDistrictCount() { return unassignedDistrictCount; }
+    }
+}

+ 70 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPerceptionSnapshot.java

@@ -0,0 +1,70 @@
+package com.zksy.cockpit.overview;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 感知设备的源事实(已按 SQL 聚合):设备计数桶 + 监测内容派生源。
+ *
+ * <p>计数桶把「类型 × 片区 × 有无状态 × 是否在线」一次 GROUP BY 出,避免逐设备回查状态
+ * (既有适配器 {@code getByDeviceCode} 是 N+1,且一条设备多状态行会抛异常)。
+ */
+public class CockpitOverviewPerceptionSnapshot {
+    private final LocalDateTime updatedAt;
+    private final List<SourceDeviceBucket> buckets;
+    private final List<SourceMonitoringContent> monitoringContents;
+
+    public CockpitOverviewPerceptionSnapshot(
+            LocalDateTime updatedAt,
+            List<SourceDeviceBucket> buckets,
+            List<SourceMonitoringContent> monitoringContents) {
+        this.updatedAt = updatedAt;
+        this.buckets = buckets;
+        this.monitoringContents = monitoringContents;
+    }
+
+    public LocalDateTime getUpdatedAt() { return updatedAt; }
+    public List<SourceDeviceBucket> getBuckets() { return buckets; }
+    public List<SourceMonitoringContent> getMonitoringContents() { return monitoringContents; }
+
+    /** 一个设备计数桶:{@code rootTypeId} 为 {@code null} 表示类型悬空(只进 totals)。 */
+    public static class SourceDeviceBucket {
+        private final Integer rootTypeId;
+        private final Integer district;
+        private final boolean hasStatus;
+        private final boolean online;
+        private final int deviceCount;
+
+        public SourceDeviceBucket(
+                Integer rootTypeId, Integer district, boolean hasStatus, boolean online, int deviceCount) {
+            this.rootTypeId = rootTypeId;
+            this.district = district;
+            this.hasStatus = hasStatus;
+            this.online = online;
+            this.deviceCount = deviceCount;
+        }
+
+        public Integer getRootTypeId() { return rootTypeId; }
+        public Integer getDistrict() { return district; }
+        public boolean hasStatus() { return hasStatus; }
+        public boolean isOnline() { return online; }
+        public int getDeviceCount() { return deviceCount; }
+    }
+
+    /** 监测内容派生源:{@code equipment_warning_type → warning_type.warning_name}。 */
+    public static class SourceMonitoringContent {
+        private final Integer rootTypeId;
+        private final String warningCode;
+        private final String warningName;
+
+        public SourceMonitoringContent(Integer rootTypeId, String warningCode, String warningName) {
+            this.rootTypeId = rootTypeId;
+            this.warningCode = warningCode;
+            this.warningName = warningName;
+        }
+
+        public Integer getRootTypeId() { return rootTypeId; }
+        public String getWarningCode() { return warningCode; }
+        public String getWarningName() { return warningName; }
+    }
+}

+ 91 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkResponse.java

@@ -0,0 +1,91 @@
+package com.zksy.cockpit.overview;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 基础设施 {@code GET /overview/pipe-network} 的响应体(规格 §3)。 */
+public class CockpitOverviewPipeNetworkResponse extends CockpitOverviewReadEnvelope {
+    private final Totals totals;
+    private final List<PipeTypeItem> byPipeType;
+    private final Excluded excluded;
+    private final String state;
+    private final String reason;
+
+    public CockpitOverviewPipeNetworkResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String timezone,
+            String updatedAt,
+            Freshness freshness,
+            Totals totals,
+            List<PipeTypeItem> byPipeType,
+            Excluded excluded,
+            String state,
+            String reason) {
+        super(requestId, apiVersion, readOnly, timezone, updatedAt, freshness);
+        this.totals = totals;
+        this.byPipeType = byPipeType;
+        this.excluded = excluded;
+        this.state = state;
+        this.reason = reason;
+    }
+
+    public Totals getTotals() { return totals; }
+    public List<PipeTypeItem> getByPipeType() { return byPipeType; }
+    public Excluded getExcluded() { return excluded; }
+    /** 分类占比的整体态:三类计数和为 0 时 {@code EMPTY}(规格 §3)。 */
+    public String getState() { return state; }
+    public String getReason() { return reason; }
+
+    public static class Totals {
+        private final int pipeCount;
+        private final BigDecimal lengthKm;
+
+        public Totals(int pipeCount, BigDecimal lengthKm) {
+            this.pipeCount = pipeCount;
+            this.lengthKm = lengthKm;
+        }
+
+        public int getPipeCount() { return pipeCount; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getLengthKm() { return lengthKm; }
+    }
+
+    public static class PipeTypeItem {
+        private final String code;
+        private final String name;
+        private final int count;
+        private final BigDecimal lengthKm;
+        private final BigDecimal ratio;
+
+        public PipeTypeItem(String code, String name, int count, BigDecimal lengthKm, BigDecimal ratio) {
+            this.code = code;
+            this.name = name;
+            this.count = count;
+            this.lengthKm = lengthKm;
+            this.ratio = ratio;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public int getCount() { return count; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getLengthKm() { return lengthKm; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getRatio() { return ratio; }
+    }
+
+    /** {@code network_type} 无法映射的行不进分类占比,但计入总数与排除数。 */
+    public static class Excluded {
+        private final int unclassifiedCount;
+
+        public Excluded(int unclassifiedCount) {
+            this.unclassifiedCount = unclassifiedCount;
+        }
+
+        public int getUnclassifiedCount() { return unclassifiedCount; }
+    }
+}

+ 36 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkSnapshot.java

@@ -0,0 +1,36 @@
+package com.zksy.cockpit.overview;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 基础设施的源事实(已按 SQL 聚合):{@code pipe_network_base.network_type} → 条数与总长度(米)。 */
+public class CockpitOverviewPipeNetworkSnapshot {
+    private final LocalDateTime updatedAt;
+    private final List<SourcePipeBucket> buckets;
+
+    public CockpitOverviewPipeNetworkSnapshot(LocalDateTime updatedAt, List<SourcePipeBucket> buckets) {
+        this.updatedAt = updatedAt;
+        this.buckets = buckets;
+    }
+
+    public LocalDateTime getUpdatedAt() { return updatedAt; }
+    public List<SourcePipeBucket> getBuckets() { return buckets; }
+
+    /** 一个管网计数桶:{@code typeCode} 已按 {@code CockpitPipeNetworkType} 归一(含未分类哨兵)。 */
+    public static class SourcePipeBucket {
+        private final String typeCode;
+        private final int pipeCount;
+        private final BigDecimal lengthMeters;
+
+        public SourcePipeBucket(String typeCode, int pipeCount, BigDecimal lengthMeters) {
+            this.typeCode = typeCode;
+            this.pipeCount = pipeCount;
+            this.lengthMeters = lengthMeters;
+        }
+
+        public String getTypeCode() { return typeCode; }
+        public int getPipeCount() { return pipeCount; }
+        public BigDecimal getLengthMeters() { return lengthMeters; }
+    }
+}

+ 59 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewReadEnvelope.java

@@ -0,0 +1,59 @@
+package com.zksy.cockpit.overview;
+
+/**
+ * 总体概览四模块只读端点的公共信封(规格 §0)。
+ *
+ * <p>沿用既有驾驶舱端点形状:{@code requestId / apiVersion / readOnly / timezone / updatedAt} +
+ * {@code freshness{status, reason, updatedAt, serverUpdatedAt}}。四个模块各自继承本类追加自己的读数,
+ * 不重复声明信封字段。
+ */
+public class CockpitOverviewReadEnvelope {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String timezone;
+    private final String updatedAt;
+    private final Freshness freshness;
+
+    public CockpitOverviewReadEnvelope(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String timezone,
+            String updatedAt,
+            Freshness freshness) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.timezone = timezone;
+        this.updatedAt = updatedAt;
+        this.freshness = freshness;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getTimezone() { return timezone; }
+    public String getUpdatedAt() { return updatedAt; }
+    public Freshness getFreshness() { return freshness; }
+
+    /** 数据新鲜度:{@code CURRENT} 有源数据,{@code EMPTY} 源集合为空(空 ≠ 失败)。 */
+    public static class Freshness {
+        private final String status;
+        private final String reason;
+        private final String updatedAt;
+        private final String serverUpdatedAt;
+
+        public Freshness(String status, String reason, String updatedAt, String serverUpdatedAt) {
+            this.status = status;
+            this.reason = reason;
+            this.updatedAt = updatedAt;
+            this.serverUpdatedAt = serverUpdatedAt;
+        }
+
+        public String getStatus() { return status; }
+        public String getReason() { return reason; }
+        public String getUpdatedAt() { return updatedAt; }
+        public String getServerUpdatedAt() { return serverUpdatedAt; }
+    }
+}

+ 115 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningStudyResponse.java

@@ -0,0 +1,115 @@
+package com.zksy.cockpit.overview;
+
+import java.util.List;
+import java.util.Map;
+
+/** 预警研判 {@code GET /overview/warning-study} 的响应体(规格 §4)。 */
+public class CockpitOverviewWarningStudyResponse extends CockpitOverviewReadEnvelope {
+    private final Today today;
+    private final Unresolved unresolved;
+    private final List<LevelItem> byLevel;
+    private final TypeRanking typeRanking;
+
+    public CockpitOverviewWarningStudyResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String timezone,
+            String updatedAt,
+            Freshness freshness,
+            Today today,
+            Unresolved unresolved,
+            List<LevelItem> byLevel,
+            TypeRanking typeRanking) {
+        super(requestId, apiVersion, readOnly, timezone, updatedAt, freshness);
+        this.today = today;
+        this.unresolved = unresolved;
+        this.byLevel = byLevel;
+        this.typeRanking = typeRanking;
+    }
+
+    public Today getToday() { return today; }
+    public Unresolved getUnresolved() { return unresolved; }
+    public List<LevelItem> getByLevel() { return byLevel; }
+    public TypeRanking getTypeRanking() { return typeRanking; }
+
+    /** 今日/昨日新增与差值(整数,可为负)。 */
+    public static class Today {
+        private final int newCount;
+        private final int yesterdayCount;
+        private final int delta;
+
+        public Today(int newCount, int yesterdayCount, int delta) {
+            this.newCount = newCount;
+            this.yesterdayCount = yesterdayCount;
+            this.delta = delta;
+        }
+
+        public int getNewCount() { return newCount; }
+        public int getYesterdayCount() { return yesterdayCount; }
+        public int getDelta() { return delta; }
+    }
+
+    /** 未解除集合:{@code statuses} 固定四键(DRAFT/CLOSED 不在集合内)。 */
+    public static class Unresolved {
+        private final int count;
+        private final Map<String, Integer> statuses;
+
+        public Unresolved(int count, Map<String, Integer> statuses) {
+            this.count = count;
+            this.statuses = statuses;
+        }
+
+        public int getCount() { return count; }
+        public Map<String, Integer> getStatuses() { return statuses; }
+    }
+
+    public static class LevelItem {
+        private final int level;
+        private final String name;
+        private final int count;
+
+        public LevelItem(int level, String name, int count) {
+            this.level = level;
+            this.name = name;
+            this.count = count;
+        }
+
+        public int getLevel() { return level; }
+        public String getName() { return name; }
+        public int getCount() { return count; }
+    }
+
+    /** 近 30 日类型排行:{@code total} 是窗口内全部预警条数(标题「共XX起」用它)。 */
+    public static class TypeRanking {
+        private final int rangeDays;
+        private final int total;
+        private final List<TopType> top;
+
+        public TypeRanking(int rangeDays, int total, List<TopType> top) {
+            this.rangeDays = rangeDays;
+            this.total = total;
+            this.top = top;
+        }
+
+        public int getRangeDays() { return rangeDays; }
+        public int getTotal() { return total; }
+        public List<TopType> getTop() { return top; }
+    }
+
+    public static class TopType {
+        private final String code;
+        private final String name;
+        private final int count;
+
+        public TopType(String code, String name, int count) {
+            this.code = code;
+            this.name = name;
+            this.count = count;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public int getCount() { return count; }
+    }
+}

+ 84 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningStudySnapshot.java

@@ -0,0 +1,84 @@
+package com.zksy.cockpit.overview;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 预警研判的源事实(已按 SQL 聚合):今日/昨日计数、未解除 × 等级交叉表、近 30 日类型计数,
+ * 以及等级字典与类型名称表。
+ */
+public class CockpitOverviewWarningStudySnapshot {
+    private final LocalDateTime updatedAt;
+    private final int todayNew;
+    private final int yesterdayNew;
+    private final List<SourceStatusLevelCount> statusLevelCounts;
+    private final int rankingTotal;
+    private final int rangeDays;
+    private final List<SourceTypeCount> typeCounts;
+    private final Map<String, String> typeNames;
+    private final Map<Integer, String> levelNames;
+
+    public CockpitOverviewWarningStudySnapshot(
+            LocalDateTime updatedAt,
+            int todayNew,
+            int yesterdayNew,
+            List<SourceStatusLevelCount> statusLevelCounts,
+            int rankingTotal,
+            int rangeDays,
+            List<SourceTypeCount> typeCounts,
+            Map<String, String> typeNames,
+            Map<Integer, String> levelNames) {
+        this.updatedAt = updatedAt;
+        this.todayNew = todayNew;
+        this.yesterdayNew = yesterdayNew;
+        this.statusLevelCounts = statusLevelCounts;
+        this.rankingTotal = rankingTotal;
+        this.rangeDays = rangeDays;
+        this.typeCounts = typeCounts;
+        this.typeNames = typeNames;
+        this.levelNames = levelNames;
+    }
+
+    public LocalDateTime getUpdatedAt() { return updatedAt; }
+    public int getTodayNew() { return todayNew; }
+    public int getYesterdayNew() { return yesterdayNew; }
+    public List<SourceStatusLevelCount> getStatusLevelCounts() { return statusLevelCounts; }
+    public int getRankingTotal() { return rankingTotal; }
+    public int getRangeDays() { return rangeDays; }
+    public List<SourceTypeCount> getTypeCounts() { return typeCounts; }
+    /** {@code warning_type.warning_code → warning_name};join 不上的 code 由服务层降级。 */
+    public Map<String, String> getTypeNames() { return typeNames; }
+    /** 字典 {@code alert_level}:{@code dict_value → dict_label}。 */
+    public Map<Integer, String> getLevelNames() { return levelNames; }
+
+    /** 未解除集合按 状态 × 等级({@code warning_level} 是字符串,解析不出回 {@code null})的计数。 */
+    public static class SourceStatusLevelCount {
+        private final String status;
+        private final Integer level;
+        private final int count;
+
+        public SourceStatusLevelCount(String status, Integer level, int count) {
+            this.status = status;
+            this.level = level;
+            this.count = count;
+        }
+
+        public String getStatus() { return status; }
+        public Integer getLevel() { return level; }
+        public int getCount() { return count; }
+    }
+
+    public static class SourceTypeCount {
+        private final String typeCode;
+        private final int count;
+
+        public SourceTypeCount(String typeCode, int count) {
+            this.typeCode = typeCode;
+            this.count = count;
+        }
+
+        public String getTypeCode() { return typeCode; }
+        public int getCount() { return count; }
+    }
+}

+ 44 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWindows.java

@@ -0,0 +1,44 @@
+package com.zksy.cockpit.overview;
+
+import java.time.Clock;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+
+/**
+ * 四模块共用的业务时间窗(规格 §0)。
+ *
+ * <p>所有边界都由服务层按 {@code Asia/Shanghai} 算出后传参给 SQL,不依赖数据库会话时区
+ * (调研结论 §11.3)。区间一律左闭右开:今日 {@code [今天 00:00, 明天 00:00)}。
+ */
+public final class CockpitOverviewWindows {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final int TREND_DAYS = 7;
+    private static final int RANKING_RANGE_DAYS = 30;
+
+    private final LocalDate today;
+    private final LocalDateTime yesterdayStart;
+    private final LocalDateTime todayStart;
+    private final LocalDateTime tomorrowStart;
+
+    private CockpitOverviewWindows(LocalDate today) {
+        this.today = today;
+        this.todayStart = today.atStartOfDay();
+        this.tomorrowStart = todayStart.plusDays(1);
+        this.yesterdayStart = todayStart.minusDays(1);
+    }
+
+    public static CockpitOverviewWindows of(Clock clock) {
+        return new CockpitOverviewWindows(
+                LocalDateTime.ofInstant(clock.instant(), BUSINESS_ZONE).toLocalDate());
+    }
+
+    public LocalDate today() { return today; }
+    public LocalDateTime yesterdayStart() { return yesterdayStart; }
+    public LocalDateTime todayStart() { return todayStart; }
+    public LocalDateTime tomorrowStart() { return tomorrowStart; }
+    /** 近 7 日(今天及前 6 天)的起点。 */
+    public LocalDateTime trendStart() { return todayStart.minusDays(TREND_DAYS - 1L); }
+    /** 近 30 日(今天及前 29 天)的起点。 */
+    public LocalDateTime rankingStart() { return todayStart.minusDays(RANKING_RANGE_DAYS - 1L); }
+}

+ 34 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewModulesAdapter.java

@@ -0,0 +1,34 @@
+package com.zksy.cockpit.overview;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+
+/** 无上游数据时的空实现(与其它模块的 Empty*Adapter 同形)。 */
+@Component
+public class EmptyCockpitOverviewModulesAdapter implements CockpitOverviewModulesAdapter {
+    @Override
+    public CockpitOverviewPerceptionSnapshot loadPerception() {
+        return new CockpitOverviewPerceptionSnapshot(
+                null, Collections.emptyList(), Collections.emptyList());
+    }
+
+    @Override
+    public CockpitOverviewPipeNetworkSnapshot loadPipeNetwork() {
+        return new CockpitOverviewPipeNetworkSnapshot(null, Collections.emptyList());
+    }
+
+    @Override
+    public CockpitOverviewWarningStudySnapshot loadWarningStudy(CockpitOverviewWindows windows) {
+        return new CockpitOverviewWarningStudySnapshot(
+                null, 0, 0, Collections.emptyList(), 0, 0,
+                Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap());
+    }
+
+    @Override
+    public CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(CockpitOverviewWindows windows) {
+        return new CockpitOverviewAlarmDisposalSnapshot(
+                null, 0, 0, 0, 0, 0,
+                Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap());
+    }
+}

+ 67 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/mapper/CockpitOverviewModulesMapper.java

@@ -0,0 +1,67 @@
+package com.zksy.cockpit.overview.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 总体概览四模块的聚合查询(规格 §2–§5)。
+ *
+ * <p>聚合全部下推 SQL:单次扫描 + {@code GROUP BY},不做「全表 list() → 内存聚合」,
+ * 也不逐设备回查状态(调研结论 §11.1)。返回行为列名 → 值的 Map,由适配器归一成快照事实。
+ */
+public interface CockpitOverviewModulesMapper {
+
+    /** 设备计数桶:类型 × 片区 × 是否有状态记录 × 是否在线(含任意层级类型树上的根类型)。 */
+    List<Map<String, Object>> selectDeviceBuckets();
+
+    /** 监测内容派生源:根类型 → 该类型树覆盖的监测指标中文名。 */
+    List<Map<String, Object>> selectMonitoringContents();
+
+    /** 管网计数桶:{@code network_type} 原值 → 条数与总长度(米)。 */
+    List<Map<String, Object>> selectPipeBuckets();
+
+    /** 预警今日/昨日新增与源数据最新更新时间。 */
+    Map<String, Object> selectWarningCounts(
+            @Param("yesterdayStart") Date yesterdayStart,
+            @Param("todayStart") Date todayStart,
+            @Param("tomorrowStart") Date tomorrowStart);
+
+    /** 未解除集合按 状态 × 等级 的交叉计数。 */
+    List<Map<String, Object>> selectUnresolvedStatusLevelCounts();
+
+    /** 近 30 日(或指定窗口)预警总条数。 */
+    Map<String, Object> selectWarningCountInWindow(
+            @Param("startInclusive") Date startInclusive,
+            @Param("endExclusive") Date endExclusive);
+
+    /** 窗口内预警按类型分组的计数。 */
+    List<Map<String, Object>> selectWarningTypeCountsInWindow(
+            @Param("startInclusive") Date startInclusive,
+            @Param("endExclusive") Date endExclusive);
+
+    /** 报警基础集合({@code audit_status = 1}):总数、今日/昨日新增、未转工单数。 */
+    Map<String, Object> selectAlarmTotals(
+            @Param("yesterdayStart") Date yesterdayStart,
+            @Param("todayStart") Date todayStart,
+            @Param("tomorrowStart") Date tomorrowStart);
+
+    /** 基础集合中关联工单已结案({@code order_status = 5})的报警数。 */
+    Map<String, Object> selectAlarmClosedCount();
+
+    /** 基础集合按报警等级分组计数。 */
+    List<Map<String, Object>> selectAlarmLevelCounts();
+
+    /** 基础集合按创建日期分组计数。 */
+    List<Map<String, Object>> selectAlarmDailyCounts(
+            @Param("startInclusive") Date startInclusive,
+            @Param("endExclusive") Date endExclusive);
+
+    /**
+     * 响应/处置时长样本:按 {@code oper_type} 聚合(2 = 接单、5 = 验收通过),
+     * 每个工单每类只取 {@code oper_time} 最晚、并列 {@code log_id} 最大的一条。
+     */
+    List<Map<String, Object>> selectAlarmDurationSamples();
+}

+ 197 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/cockpit/CockpitOverviewModulesMapper.xml

@@ -0,0 +1,197 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<!-- 总体概览四模块聚合查询(规格 2026-10-22 §2–§5)。全部单次扫描 + GROUP BY,不下拉明细到 Java。 -->
+<mapper namespace="com.zksy.cockpit.overview.mapper.CockpitOverviewModulesMapper">
+
+    <!-- 设备类型树:根类型(parent_id = 0)→ 任意层级子类型的归属。
+         实测存在 equipment_base.equipment_type_id = 11 的悬空行,LEFT JOIN 保留并计入 unknownTypeCount。 -->
+    <sql id="typeTree">
+        WITH RECURSIVE type_tree AS (
+            SELECT t.id AS id, t.id AS root_id
+            FROM app_user.equipment_type t
+            WHERE t.parent_id = 0
+            UNION ALL
+            SELECT c.id AS id, tt.root_id AS root_id
+            FROM app_user.equipment_type c
+            JOIN type_tree tt ON c.parent_id = tt.id
+        )
+    </sql>
+
+    <!-- 每台设备的最新状态:status_update_time 最新,并列取 status_id 最大,且 del_flag = '0'(规格 §0/§2)。 -->
+    <select id="selectDeviceBuckets" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>,
+        dev AS (
+            SELECT eb.equipment_type_id AS equipment_type_id,
+                   eb.district AS district,
+                   tt.root_id AS root_type_id,
+                   CASE WHEN ls.equipment_id IS NULL THEN 0 ELSE 1 END AS has_status,
+                   CASE WHEN ls.online_status = 1 THEN 1 ELSE 0 END AS is_online,
+                   eb.update_time AS source_updated_at
+            FROM app_user.equipment_base eb
+            LEFT JOIN type_tree tt ON tt.id = eb.equipment_type_id
+            LEFT JOIN LATERAL (
+                SELECT s.equipment_id AS equipment_id, s.online_status AS online_status
+                FROM app_user.equipment_status s
+                WHERE s.equipment_id = eb.equipment_id AND s.del_flag = '0'
+                ORDER BY s.status_update_time DESC NULLS LAST, s.status_id DESC
+                LIMIT 1
+            ) ls ON TRUE
+        )
+        SELECT equipment_type_id, district, root_type_id, has_status, is_online,
+               COUNT(*) AS device_count, MAX(source_updated_at) AS source_updated_at
+        FROM dev
+        GROUP BY equipment_type_id, district, root_type_id, has_status, is_online
+    </select>
+
+    <select id="selectMonitoringContents" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
+        SELECT tt.root_id AS root_type_id,
+               wt.warning_code AS warning_code,
+               wt.warning_name AS warning_name
+        FROM app_user.equipment_warning_type ewt
+        JOIN app_user.warning_type wt ON wt.id = ewt.warning_type_id
+        JOIN type_tree tt ON tt.id = ewt.equipment_type_id
+        ORDER BY tt.root_id, wt.id
+    </select>
+
+    <!-- 长度单位是米;吊销行由 del_flag 显式排除。network_type 的语义映射留在 CockpitPipeNetworkType。 -->
+    <select id="selectPipeBuckets" resultType="java.util.LinkedHashMap">
+        SELECT pb.network_type AS network_type,
+               COUNT(*) AS pipe_count,
+               COALESCE(SUM(pb.length), 0) AS length_meters,
+               MAX(pb.update_time) AS source_updated_at
+        FROM app_user.pipe_network_base pb
+        WHERE pb.del_flag = '0'
+        GROUP BY pb.network_type
+    </select>
+
+    <select id="selectWarningCounts" resultType="java.util.LinkedHashMap">
+        SELECT COALESCE(SUM(CASE WHEN w.create_time &gt;= #{todayStart} AND w.create_time &lt; #{tomorrowStart}
+                                 THEN 1 ELSE 0 END), 0) AS today_new,
+               COALESCE(SUM(CASE WHEN w.create_time &gt;= #{yesterdayStart} AND w.create_time &lt; #{todayStart}
+                                 THEN 1 ELSE 0 END), 0) AS yesterday_new,
+               MAX(w.update_time) AS source_updated_at
+        FROM app_user.early_warning w
+    </select>
+
+    <!-- 未解除集合 = PENDING / RECEIVED / PROCESSING / HANDLED(DRAFT、CLOSED 排除,规格 §4)。
+         不复用 BusinessCockpitEventAdapter 的 HANDLING 拼写错误常量。 -->
+    <select id="selectUnresolvedStatusLevelCounts" resultType="java.util.LinkedHashMap">
+        SELECT UPPER(TRIM(w.status)) AS status,
+               w.warning_level AS warning_level,
+               COUNT(*) AS type_count
+        FROM app_user.early_warning w
+        WHERE UPPER(TRIM(w.status)) IN ('PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED')
+        GROUP BY UPPER(TRIM(w.status)), w.warning_level
+    </select>
+
+    <select id="selectWarningCountInWindow" resultType="java.util.LinkedHashMap">
+        SELECT COUNT(*) AS warning_count
+        FROM app_user.early_warning w
+        WHERE w.create_time &gt;= #{startInclusive} AND w.create_time &lt; #{endExclusive}
+    </select>
+
+    <select id="selectWarningTypeCountsInWindow" resultType="java.util.LinkedHashMap">
+        SELECT w.warning_type AS type_code,
+               COUNT(*) AS type_count
+        FROM app_user.early_warning w
+        WHERE w.create_time &gt;= #{startInclusive} AND w.create_time &lt; #{endExclusive}
+        GROUP BY w.warning_type
+        ORDER BY COUNT(*) DESC, w.warning_type ASC
+    </select>
+
+    <!-- 基础集合 = alarm_data.audit_status = 1(维护者 2026-10-22 裁定,规格 §0)。 -->
+    <select id="selectAlarmTotals" resultType="java.util.LinkedHashMap">
+        SELECT COUNT(*) AS alarm_count,
+               COALESCE(SUM(CASE WHEN a.create_time &gt;= #{todayStart} AND a.create_time &lt; #{tomorrowStart}
+                                 THEN 1 ELSE 0 END), 0) AS today_new,
+               COALESCE(SUM(CASE WHEN a.create_time &gt;= #{yesterdayStart} AND a.create_time &lt; #{todayStart}
+                                 THEN 1 ELSE 0 END), 0) AS yesterday_new,
+               COALESCE(SUM(CASE WHEN a.work_order_id IS NULL THEN 1 ELSE 0 END), 0) AS unconverted_count,
+               MAX(a.update_time) AS source_updated_at
+        FROM app_user.alarm_data a
+        WHERE a.audit_status = 1
+    </select>
+
+    <select id="selectAlarmClosedCount" resultType="java.util.LinkedHashMap">
+        SELECT COUNT(*) AS closed_count
+        FROM app_user.alarm_data a
+        JOIN app_user.work_order w ON w.order_id = a.work_order_id
+        WHERE a.audit_status = 1 AND w.order_status = 5
+    </select>
+
+    <select id="selectAlarmLevelCounts" resultType="java.util.LinkedHashMap">
+        SELECT a.alarm_level AS alarm_level,
+               COUNT(*) AS level_count
+        FROM app_user.alarm_data a
+        WHERE a.audit_status = 1
+        GROUP BY a.alarm_level
+    </select>
+
+    <!-- 按日两个读数(维护者 2026-10-23):
+           · daily_count     —— 当天新增(create_time 落在该日),走面板基础集合 audit_status = 1;
+           · unhandled_total —— 截至当天仍未处理的累计条数(alarm_status = 0,0 未处理 / 1 已处理)。
+
+         **两条线口径不同,是刻意如此**:
+         · 新增按面板基础集合(已核实报警),与上方「今日新增」等读数同口径;
+         · 未处理**不受审核状态限制** —— 一条报警处没处理,跟它审没审核是两个维度。
+           实测:只用 audit_status=1 时未处理恒为 0(那 2 条未处理的都是未审核),
+           这条线会画成一条恒零的直线,"积压还在"的意思就丢了。
+         累计必须从**入库最早一条**起算,不能只用窗口内数据 —— 否则窗口之前的积压会漏掉。 -->
+    <select id="selectAlarmDailyCounts" resultType="java.util.LinkedHashMap">
+        WITH days AS (
+            SELECT TO_CHAR(d, 'YYYY-MM-DD') AS day, d AS day_date
+            FROM generate_series(
+                #{startInclusive}::timestamp,
+                #{endExclusive}::timestamp - interval '1 day',
+                interval '1 day'
+            ) AS d
+        ),
+        created AS (
+            SELECT TO_CHAR(a.create_time, 'YYYY-MM-DD') AS day, COUNT(*) AS c
+            FROM app_user.alarm_data a
+            WHERE a.audit_status = 1
+              AND a.create_time &gt;= #{startInclusive} AND a.create_time &lt; #{endExclusive}
+            GROUP BY 1
+        )
+        SELECT d.day AS day,
+               COALESCE(cr.c, 0) AS daily_count,
+               (SELECT COUNT(*) FROM app_user.alarm_data a2
+                 WHERE a2.alarm_status = 0
+                   AND a2.create_time &lt; (d.day_date + interval '1 day')) AS unhandled_total
+        FROM days d
+        LEFT JOIN created cr ON cr.day = d.day
+        ORDER BY 1
+    </select>
+
+    <!-- 「最近一条」= 同工单同 oper_type 内 oper_time 最大、并列 log_id 最大(规格 §5);
+         负时长(日志早于报警)单独计数,由服务层剔除。 -->
+    <select id="selectAlarmDurationSamples" resultType="java.util.LinkedHashMap">
+        WITH base AS (
+            SELECT a.work_order_id AS work_order_id, a.create_time AS create_time
+            FROM app_user.alarm_data a
+            WHERE a.audit_status = 1 AND a.work_order_id IS NOT NULL AND a.create_time IS NOT NULL
+        ), latest_log AS (
+            SELECT t.work_order_id AS work_order_id, t.oper_type AS oper_type, t.oper_time AS oper_time
+            FROM (
+                SELECT l.work_order_id AS work_order_id,
+                       l.oper_type AS oper_type,
+                       l.oper_time AS oper_time,
+                       ROW_NUMBER() OVER (PARTITION BY l.work_order_id, l.oper_type
+                                          ORDER BY l.oper_time DESC, l.log_id DESC) AS rn
+                FROM app_user.work_order_log l
+                WHERE l.oper_type IN (2, 5) AND l.oper_time IS NOT NULL
+                  AND l.work_order_id IN (SELECT b.work_order_id FROM base b)
+            ) t WHERE t.rn = 1
+        )
+        SELECT ll.oper_type AS oper_type,
+               COALESCE(SUM(CASE WHEN ll.oper_time &gt;= b.create_time
+                                 THEN EXTRACT(EPOCH FROM ll.oper_time) - EXTRACT(EPOCH FROM b.create_time)
+                                 ELSE 0 END), 0) AS total_seconds,
+               COUNT(CASE WHEN ll.oper_time &gt;= b.create_time THEN 1 END) AS valid_samples,
+               COUNT(CASE WHEN ll.oper_time &lt; b.create_time THEN 1 END) AS invalid_samples
+        FROM base b
+        JOIN latest_log ll ON ll.work_order_id = b.work_order_id
+        GROUP BY ll.oper_type
+    </select>
+</mapper>