Bladeren bron

Merge remote-tracking branch 'origin/master'

zengy 1 week geleden
bovenliggende
commit
3a9e9ad404
32 gewijzigde bestanden met toevoegingen van 3647 en 209 verwijderingen
  1. 7 1
      flammable-gas-service/src/main/java/com/zksy/gas/domain/GasMonitorData.java
  2. 66 54
      flammable-gas-service/src/main/java/com/zksy/gas/utils/DataParser.java
  3. 4 2
      flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java
  4. 3 2
      flammable-gas-service/src/main/resources/mapper/GasMonitorDataMapper.xml
  5. 76 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesController.java
  6. 189 74
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyControllerTest.java
  7. 158 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesControllerTest.java
  8. 614 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesServiceTest.java
  9. 1 1
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/ApplicationConfig.java
  10. 7 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/gas/domain/GasMonitorData.java
  11. 5 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  12. 56 13
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyResponse.java
  13. 228 49
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyService.java
  14. 20 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencySnapshot.java
  15. 333 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewModulesAdapter.java
  16. 182 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewAlarmDisposalResponse.java
  17. 109 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewAlarmDisposalSnapshot.java
  18. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesAdapter.java
  19. 602 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesService.java
  20. 151 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPerceptionResponse.java
  21. 70 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPerceptionSnapshot.java
  22. 91 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkResponse.java
  23. 36 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkSnapshot.java
  24. 59 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewReadEnvelope.java
  25. 115 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningStudyResponse.java
  26. 84 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningStudySnapshot.java
  27. 44 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWindows.java
  28. 34 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewModulesAdapter.java
  29. 67 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/mapper/CockpitOverviewModulesMapper.java
  30. 2 1
      pipe-network-service/zksy-system/src/main/resources/mapper/base/gas/GasMonitorDataMapper.xml
  31. 197 0
      pipe-network-service/zksy-system/src/main/resources/mapper/cockpit/CockpitOverviewModulesMapper.xml
  32. 19 10
      zk-common/src/main/java/com/zksy/common/netty/DeviceFrameDecoder.java

+ 7 - 1
flammable-gas-service/src/main/java/com/zksy/gas/domain/GasMonitorData.java

@@ -139,6 +139,12 @@ public class GasMonitorData implements Serializable {
     @TableField(value = "latitude")
     private BigDecimal latitude;
 
+    /**
+     * 报警项中文描述(逗号分隔),由报警信息位解析得出,便于直接看出触发了哪些报警
+     */
+    @TableField(value = "alarm_names")
+    private String alarmNames;
+
     /**
      * 入库时间
      */
@@ -154,7 +160,7 @@ public class GasMonitorData implements Serializable {
         if (alarmInfo == null) {
             alarmInfo = 0;
         }
-        alarmBits.put("unknownAlarm", (alarmInfo & (1 << 0)) != 0); // Bit0:未知报警
+        alarmBits.put("lowAlarm", (alarmInfo & (1 << 0)) != 0);      // Bit0:低报警
         alarmBits.put("highAlarm", (alarmInfo & (1 << 1)) != 0);     // Bit1:高报警
         alarmBits.put("overRange", (alarmInfo & (1 << 2)) != 0);     // Bit2:超量程
         alarmBits.put("calibrationCycle", (alarmInfo & (1 << 3)) != 0); // Bit3:标定周期

+ 66 - 54
flammable-gas-service/src/main/java/com/zksy/gas/utils/DataParser.java

@@ -44,9 +44,10 @@ public class DataParser {
         data.setProtocolVersion(String.valueOf(frame[offset] & 0xFF));
         offset++;
 
-        // 报警信息 (21~22)
-        int alarmInfo = (frame[offset] & 0xFF ) | ((frame[offset + 1] & 0xFF) << 8);
+        // 报警信息 (21~22):2 字节低字节在前(18 00 → 0x0018),与文档“01 00 低报警、04 00 超量程”一致
+        int alarmInfo = (frame[offset] & 0xFF) | ((frame[offset + 1] & 0xFF) << 8);
         data.setAlarmInfo(alarmInfo);
+        data.setAlarmNames(alarmNames(alarmInfo));
         offset += 2;
 
         // 时间戳 (23~28)
@@ -59,60 +60,49 @@ public class DataParser {
         offset++;
 
         // ------------------- 数据段 -------------------
-
-        // 1. 气体浓度 + 类型 (6 字节)
-        int gasTypeCode = frame[offset] & 0xFF; // 第1字节:气体类型
-        int unitCode = frame[offset + 1] & 0xFF; // 第2字节:单位
-        offset += 2;
-
-        long gasValueRaw = ((long)(frame[offset + 3] & 0xFF) << 24)
-                | ((long)(frame[offset + 2] & 0xFF) << 16)
-                | ((long)(frame[offset + 1] & 0xFF) << 8)
-                | ((long)(frame[offset] & 0xFF));
-        offset += 4;
-
-        String gasType;
-        switch (gasTypeCode) {
-            case 0x04: gasType = "CH4"; break;
-            case 0x05: gasType = "CO"; break;
-            case 0x06: gasType = "O2"; break;
-            default: gasType = "未知";
+        // 数据项总数由 dataCount 决定,数量随设备上报项变化(5 项无经纬度、7 项有经纬度),
+        // 不能按固定 6 项读取,否则短帧会越界。
+
+        // 第 1 项固定为气体浓度:气体类型 1 字节 + 单位 1 字节 + 值 4 字节
+        if (offset + 6 <= frame.length - 2) {
+            int gasTypeCode = frame[offset] & 0xFF;
+            int unitCode = frame[offset + 1] & 0xFF;
+            offset += 2;
+
+            long gasValueRaw = ((long)(frame[offset + 3] & 0xFF) << 24)
+                    | ((long)(frame[offset + 2] & 0xFF) << 16)
+                    | ((long)(frame[offset + 1] & 0xFF) << 8)
+                    | ((long)(frame[offset] & 0xFF));
+            offset += 4;
+
+            String gasType;
+            switch (gasTypeCode) {
+                case 0x04: gasType = "CH4"; break;
+                case 0x05: gasType = "CO"; break;
+                case 0x06: gasType = "O2"; break;
+                default: gasType = "未知";
+            }
+
+            data.setGasType(gasType);
+            data.setGasConcentration(BigDecimal.valueOf(gasValueRaw / 10.0));
+            data.setGasUnit(unitCode == 0x10 ? "%LEL" : "未知单位");
         }
 
-        BigDecimal gasConcentration = BigDecimal.valueOf(gasValueRaw / 10.0);
-        data.setGasType(gasType);
-        data.setGasConcentration(gasConcentration);
-        data.setGasUnit(unitCode == 0x10 ? "%LEL" : "未知单位");
-
-        // 2. 电池电量
-        float battery = ProtocolUtils.readFloat6(frame, offset);
-        data.setBattery(BigDecimal.valueOf(battery));
-        offset += 6;
-
-        // 3. 信号强度
-        float signal = ProtocolUtils.readFloat6(frame, offset);
-        data.setSignalStrength(signal);
-        offset += 6;
-
-        // 4. 温度(保留两位小数)
-        float temperature = ProtocolUtils.readFloat6(frame, offset);
-        data.setTemperature(new BigDecimal(temperature).setScale(2, RoundingMode.HALF_UP).floatValue());
-        offset += 6;
-
-        // 5. 湿度(保留两位小数)
-        float humidity = ProtocolUtils.readFloat6(frame, offset);
-        data.setHumidity(new BigDecimal(humidity).setScale(2, RoundingMode.HALF_UP).floatValue());
-        offset += 6;
-
-        // 6. 经度
-        float lon = ProtocolUtils.readFloat6(frame, offset);
-        data.setLongitude(BigDecimal.valueOf(lon));
-        offset += 6;
-
-        // 7. 纬度
-        float lat = ProtocolUtils.readFloat6(frame, offset);
-        data.setLatitude(BigDecimal.valueOf(lat));
-        offset += 6;
+        // 其余项:[2 字节数据标识][4 字节小端浮点],按标识分派,与上报顺序无关
+        for (int i = 1; i < dataCount && offset + 6 <= frame.length - 2; i++) {
+            int tag = ((frame[offset] & 0xFF) << 8) | (frame[offset + 1] & 0xFF);
+            float value = ProtocolUtils.readFloat6(frame, offset);
+            switch (tag) {
+                case 0xF96F: data.setBattery(BigDecimal.valueOf(value)); break;
+                case 0xF76F: data.setSignalStrength(value); break;
+                case 0xFF6F: data.setTemperature(round2(value)); break;
+                case 0xFE6F: data.setHumidity(round2(value)); break;
+                case 0xFA6F: data.setLongitude(BigDecimal.valueOf(value)); break;
+                case 0xFB6F: data.setLatitude(BigDecimal.valueOf(value)); break;
+                default: break; // 未知标识按 6 字节长度跳过
+            }
+            offset += 6;
+        }
 
         // 入库时间
         data.setCreateTime(LocalDateTime.now());
@@ -120,6 +110,28 @@ public class DataParser {
         return data;
     }
 
+    private static float round2(float value) {
+        return new BigDecimal(value).setScale(2, RoundingMode.HALF_UP).floatValue();
+    }
+
+    /** 报警位名称,下标即位号,顺序与厂商协议一致 */
+    private static final String[] ALARM_NAMES = {
+            "低报警", "高报警", "超量程", "标定周期", "超寿命", "跌倒报警", "欠压报警", "区间报警",
+            "按键报警", "震动报警", "水位报警", "断电报警", "传感器故障", "超湿报警", "超温报警", "系统故障"
+    };
+
+    /** 报警位转可读描述,无报警返回 null */
+    private static String alarmNames(int alarmInfo) {
+        StringBuilder names = new StringBuilder();
+        for (int i = 0; i < ALARM_NAMES.length; i++) {
+            if ((alarmInfo & (1 << i)) != 0) {
+                if (names.length() > 0) names.append(',');
+                names.append(ALARM_NAMES[i]);
+            }
+        }
+        return names.length() == 0 ? null : names.toString();
+    }
+
     public static void main(String[] args) {
         String hex = "7E0046902509250560010000FFFE01AA37FA350813180025101614432007041000000000F96F00004442F76F00000000FF6F102CE841FE6F96646642FA6FEEB3E142FB6FF368E141C08E";
         byte[] frame = ProtocolUtils.hexStringToBytes(hex);

+ 4 - 2
flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java

@@ -297,8 +297,10 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 		JSONObject params = new JSONObject();
 		params.put("deviceNo", deviceNo);
 		params.put("alarmType", alarmType);
-		// 经纬度处理(位置)
-		params.put("location", String.format("经纬度:%.6f,%.8f", lng, lat));
+		// 经纬度处理(位置):5 项上报帧不含经纬度,此时不能格式化 null
+		params.put("location", (lng != null && lat != null)
+				? String.format("经纬度:%.6f,%.8f", lng, lat)
+				: "位置未知");
 		List<String> phoneListByDeviceId = devicePhoneFetchUtil.getPhoneListByDeviceCode(deviceNo);
 		Map<String, Boolean> sendResults = smsUtil.sendBatchSms(phoneListByDeviceId, params.toJSONString());
 		log.info("发送信息完成");

+ 3 - 2
flammable-gas-service/src/main/resources/mapper/GasMonitorDataMapper.xml

@@ -25,6 +25,7 @@
             <result property="humidity" column="humidity" jdbcType="FLOAT"/>
             <result property="longitude" column="longitude" jdbcType="DECIMAL"/>
             <result property="latitude" column="latitude" jdbcType="DECIMAL"/>
+            <result property="alarmNames" column="alarm_names" jdbcType="VARCHAR"/>
             <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
     </resultMap>
 
@@ -33,7 +34,7 @@
         short_address,auto_reply_option,sequence_no,
         command,device_attr,protocol_version,
         alarm_info,report_time,gas_concentration,
-        gas_type,gas_unit,battery,signal_strength,temperature,humidity
-        longitude,latitude,create_time
+        gas_type,gas_unit,battery,signal_strength,temperature,humidity,
+        longitude,latitude,alarm_names,create_time
     </sql>
 </mapper>

+ 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();
+    }
+}

+ 189 - 74
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyControllerTest.java

@@ -1,5 +1,6 @@
 package com.zksy.web.controller.cockpit;
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.event.CockpitHandlingEfficiencyAdapter;
 import com.zksy.cockpit.event.CockpitHandlingEfficiencyService;
 import com.zksy.cockpit.event.CockpitHandlingEfficiencySnapshot;
@@ -34,6 +35,17 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
+/**
+ * 预警效率端点契约(票 74 两族语义分流)。
+ *
+ * <p>两族各有一套口径,**不适用的一方显式为 null**:
+ * <ul>
+ *   <li>报警族:工单八态 + 工单口径处置率 + 关联工单结案时长(工单是报警的处置手段)。</li>
+ *   <li>预警族:预警自身六态 + 已解除率 + 发布→已解除平均时长;工单口径一律 null
+ *       (预警不挂工单,工单读数对预警不成立 —— 票 65 实测 linkedWorkOrderCount=0)。</li>
+ * </ul>
+ * 预警族/报警族的断言因此各自成组:工单口径用例走 ALARM,预警流程用例走 WARNING。
+ */
 class CockpitHandlingEfficiencyControllerTest {
 
     private static final String ENDPOINT = "/api/cockpit/v1/events/handling-efficiency";
@@ -56,7 +68,7 @@ class CockpitHandlingEfficiencyControllerTest {
     }
 
     @Test
-    void warningFamilyExposesSummaryExplicitBasisEightStatesTrendAndEvents() throws Exception {
+    void warningFamilyExposesOwnWorkflowAndNullsWorkOrderCaliber() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("startTime", START)
@@ -72,53 +84,105 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.filters.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.filters.workOrderState").value(nullValue()))
+                // 事件口径两族共用(真实计数,不是第二轮读数)。
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(8))
                 .andExpect(jsonPath("$.data.summary.linkedWorkOrderCount").value(6))
                 .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(2))
-                .andExpect(jsonPath("$.data.summary.handledCount").value(2))
-                .andExpect(jsonPath("$.data.summary.inProgressCount").value(3))
-                .andExpect(jsonPath("$.data.summary.cancelledCount").value(1))
-                // 处置率=状态 5 ÷ 状态 0–6 工单数(决策票 06)。范围工单为 0,3,4,5,5,7:
-                // 分子=2(状态 5)、分母=5(状态 0/3/4/5/5;状态 7 排除)→ 0.4000。
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.4000"))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.numerator").value(2))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(5))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("AVAILABLE"))
-                .andExpect(jsonPath("$.data.summary.handlingEfficiency.basis")
-                        .value("CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS"))
-                .andExpect(jsonPath("$.data.summary.averageDuration.value").value("1.67"))
-                .andExpect(jsonPath("$.data.summary.averageDuration.unit").value("HOURS"))
-                .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(2))
-                .andExpect(jsonPath("$.data.summary.averageDuration.state").value("AVAILABLE"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(8)))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateCode").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateName").value("待派单"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[1].stateName").value("待接单"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[1].count").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[2].stateName").value("已接单"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[2].count").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[3].stateName").value("处理中"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[4].stateName").value("待验收"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[5].stateName").value("已结案"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].stateName").value("延期中"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(0))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[7].stateName").value("已取消"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1))
+                // 票 74:**工单口径**对预警族不成立 → 显式 null(不是 0,也不是别的族的数)。
+                .andExpect(jsonPath("$.data.summary.handledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.inProgressCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.cancelledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.averageDuration").value(nullValue()))
+                .andExpect(jsonPath("$.data.workOrderStateCounts").value(nullValue()))
+                // 预警自身六态全铺(缺的态计 0),顺序即 WarningWorkflow 主线。
+                .andExpect(jsonPath("$.data.warningStateCounts", hasSize(6)))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].stateCode").value("DRAFT"))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].stateName").value("草稿"))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[1].stateCode").value("PENDING"))
+                .andExpect(jsonPath("$.data.warningStateCounts[1].stateName").value("待接收"))
+                .andExpect(jsonPath("$.data.warningStateCounts[1].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[2].stateCode").value("RECEIVED"))
+                .andExpect(jsonPath("$.data.warningStateCounts[2].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[3].stateCode").value("PROCESSING"))
+                .andExpect(jsonPath("$.data.warningStateCounts[3].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[4].stateCode").value("HANDLED"))
+                .andExpect(jsonPath("$.data.warningStateCounts[4].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].stateCode").value("CLOSED"))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].stateName").value("已解除"))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].count").value(3))
+                // 已解除率=已解除 ÷ 窗口内预警数(3/8),基线与工单口径不同名。
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.numerator").value(3))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.denominator").value(8))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.value").value("0.3750"))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.basis")
+                        .value("CLOSED_WARNINGS_OVER_WINDOW_WARNINGS"))
+                // 发布→已解除平均时长:0.50h / 2.00h / 1.00h → 1.17h。
+                .andExpect(jsonPath("$.data.summary.warningDuration.value").value("1.17"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.unit").value("HOURS"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.sampleCount").value(3))
+                .andExpect(jsonPath("$.data.summary.warningDuration.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.events", hasSize(8)))
                 .andExpect(jsonPath("$.data.events[0].eventNo").value("EW-007"))
                 .andExpect(jsonPath("$.data.events[0].hasWorkOrder").value(true))
                 .andExpect(jsonPath("$.data.pagination.total").value(8))
+                // 趋势桶:事件发布量照实给,工单结案量与结案时长两族口径 → null。
                 .andExpect(jsonPath("$.data.durationTrend", hasSize(5)))
                 .andExpect(jsonPath("$.data.durationTrend[0].timeBucket").value("2026-09-02T08:00:00+08:00"))
-                .andExpect(jsonPath("$.data.durationTrend[4].timeBucket").value("2026-09-02T12:00:00+08:00"));
+                .andExpect(jsonPath("$.data.durationTrend[0].eventCount").value(2))
+                .andExpect(jsonPath("$.data.durationTrend[0].handledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.durationTrend[0].averageDuration").value(nullValue()))
+                .andExpect(jsonPath("$.data.durationTrend[4].timeBucket").value("2026-09-02T12:00:00+08:00"))
+                .andExpect(jsonPath("$.data.durationTrend[4].eventCount").value(0));
     }
 
     @Test
-    void alarmFamilyIsIndependentFromWarnings() throws Exception {
+    void warningFamilyPutsUnrecognisedStatusIntoUnknownBucketSoBucketsStillSumToTotal() throws Exception {
+        when(adapter.load()).thenReturn(fixtureWithDirtyWarningStatus());
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(3))
+                .andExpect(jsonPath("$.data.warningStateCounts", hasSize(7)))
+                .andExpect(jsonPath("$.data.warningStateCounts[3].count").value(1))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].count").value(1))
+                // 脏状态进 UNKNOWN(最后一个桶),不静默丢 —— 六个已知态 2 + UNKNOWN 1 = 3。
+                .andExpect(jsonPath("$.data.warningStateCounts[6].stateCode").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.warningStateCounts[6].stateName").value("未知"))
+                .andExpect(jsonPath("$.data.warningStateCounts[6].count").value(1))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.numerator").value(1))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.denominator").value(3));
+    }
+
+    @Test
+    void warningFamilyEmptyWindowReportsTypedEmptyStatesInsteadOfFakeZero() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", "2026-09-05T00:00:00+08:00")
+                        .param("endTime", "2026-09-05T02:00:00+08:00"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(0))
+                .andExpect(jsonPath("$.data.warningStateCounts", hasSize(6)))
+                .andExpect(jsonPath("$.data.warningStateCounts[0].count").value(0))
+                .andExpect(jsonPath("$.data.warningStateCounts[5].count").value(0))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.denominator").value(0))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency.reason").value("NO_WARNINGS"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.sampleCount").value(0))
+                .andExpect(jsonPath("$.data.summary.warningDuration.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.summary.warningDuration.reason").value("NO_CLOSED_WARNINGS"))
+                // 预警族照样没有工单口径。
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency").value(nullValue()))
+                .andExpect(jsonPath("$.data.workOrderStateCounts").value(nullValue()));
+    }
+
+    @Test
+    void alarmFamilyKeepsWorkOrderCaliberAndNullsTheWarningSection() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "ALARM")
                         .param("startTime", START)
@@ -127,17 +191,28 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.eventFamily").value("ALARM"))
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
                 .andExpect(jsonPath("$.data.summary.handledCount").value(2))
+                .andExpect(jsonPath("$.data.summary.inProgressCount").value(3))
+                .andExpect(jsonPath("$.data.summary.cancelledCount").value(1))
                 // 处置率是**工单范围**读数,不随事件 family 变化(决策票 06:分母不要求事件回联)。
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.4000"))
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(5))
+                // 报警族可算结案时长的样本只有 EA-001(EA-002 的工单还在处理中)→ 10:20→14:20 共 4 小时。
+                .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(1))
+                .andExpect(jsonPath("$.data.summary.averageDuration.value").value("4.00"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1));
+                .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
+                // 票 74:报警族不吃预警口径 —— 预警段显式 null,不用 0 冒充。
+                .andExpect(jsonPath("$.data.warningStateCounts").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.warningHandlingEfficiency").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.warningDuration").value(nullValue()))
+                // 报警族的趋势桶仍带工单口径(不是 null)。
+                .andExpect(jsonPath("$.data.durationTrend[0].handledCount").value(0));
     }
 
     @Test
     void everyWorkOrderStateIsReportedUnmerged() throws Exception {
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -151,7 +226,7 @@ class CockpitHandlingEfficiencyControllerTest {
     @Test
     void emptyWindowReportsEmptyEfficiencyAndDurationStates() throws Exception {
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", "2026-09-05T00:00:00+08:00")
                         .param("endTime", "2026-09-05T02:00:00+08:00"))
                 .andExpect(status().isOk())
@@ -164,11 +239,12 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(0))
                 .andExpect(jsonPath("$.data.summary.averageDuration.state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.summary.averageDuration.reason")
-                        .value("NO_CLOSED_WORK_ORDERS"));
+                        .value("NO_CLOSED_WORK_ORDERS"))
+                .andExpect(jsonPath("$.data.warningStateCounts").value(nullValue()));
     }
 
     @Test
-    void eventsWithoutWorkOrdersAreCountedAsMissingAndKeepStateCountsStable() throws Exception {
+    void eventsWithoutWorkOrdersAreCountedAsMissing() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("startTime", START)
@@ -184,7 +260,7 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithLegacyStateCodes());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -200,7 +276,7 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithMixedDurations());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -214,7 +290,7 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithDelayingAndCancelledOrders());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -231,8 +307,8 @@ class CockpitHandlingEfficiencyControllerTest {
         when(adapter.load()).thenReturn(fixtureWithDelayingAndCancelledOrders());
 
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
-                        // 事件在 08:10,工单在 09:00:窗口排除全部事件但含工单。
+                        .param("eventFamily", "ALARM")
+                        // 工单在 09:00,窗口 08:30 起:窗口排除事件但含工单。
                         .param("startTime", "2026-09-02T08:30:00+08:00")
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -247,7 +323,7 @@ class CockpitHandlingEfficiencyControllerTest {
     @Test
     void summaryNumeratorAndDenominatorMatchTheReportedStateDistribution() throws Exception {
         mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+                        .param("eventFamily", "ALARM")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
@@ -279,17 +355,19 @@ class CockpitHandlingEfficiencyControllerTest {
     }
 
     @Test
-    void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {        mockMvc.perform(get(ENDPOINT)
-                        .param("eventFamily", "WARNING")
+    void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
                         .param("workOrderState", "5")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.filters.workOrderState").value(5))
-                .andExpect(jsonPath("$.data.pagination.total").value(2))
-                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.pagination.total").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(1)))
                 .andExpect(jsonPath("$.data.mapFilter.workOrderState").value(5))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(1)))
+                // 状态分布是**范围工单**口径,不随 workOrderState 过滤收窄。
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1));
     }
@@ -409,19 +487,32 @@ class CockpitHandlingEfficiencyControllerTest {
                 && !method.isAnnotationPresent(DeleteMapping.class);
     }
 
+    /**
+     * 主样本:8 条在范围内预警(`EW-LEGACY` 的类型不在设备类型表里,被行业范围判定排除),
+     * 状态覆盖六态 —— DRAFT 1 / PENDING 1 / RECEIVED 1 / PROCESSING 1 / HANDLED 1 / CLOSED 3,
+     * 其中三条 CLOSED 的 `update_time` 分别距发布 0.50h / 2.00h / 1.00h。
+     */
     private static CockpitHandlingEfficiencySnapshot fixture() {
         return new CockpitHandlingEfficiencySnapshot(
                 "2026-09-02T11:59:50+08:00",
                 types(),
                 Arrays.asList(
-                        warning("EW-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
-                        warning("EW-002", 2, "2026-09-02T08:40:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002"),
-                        warning("EW-003", 2, "2026-09-02T09:20:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-1", "供水压力计-001"),
-                        warning("EW-004", 3, "2026-09-02T10:05:00+08:00", "SEWAGE_ENV", "D-DR-1", "污水环境监测仪-001"),
-                        warning("EW-005", 4, "2026-09-02T10:45:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-2", "供水压力计-002"),
-                        warning("EW-006", 1, "2026-09-02T11:10:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003"),
-                        warning("EW-007", 2, "2026-09-02T11:35:00+08:00", "SEWAGE_ENV", "D-DR-2", "污水环境监测仪-002"),
-                        warning("EW-008", 1, "2026-09-02T09:55:00+08:00", "SEWAGE_ENV", "D-DR-3", "污水环境监测仪-003"),
+                        warningWithWorkflow("EW-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-1", "燃气浓度计-001", WarningWorkflow.CLOSED, "2026-09-02T08:40:00+08:00"),
+                        warningWithWorkflow("EW-002", 2, "2026-09-02T08:40:00+08:00", "GAS_SENSOR",
+                                "D-GAS-2", "燃气浓度计-002", WarningWorkflow.PROCESSING, null),
+                        warningWithWorkflow("EW-003", 2, "2026-09-02T09:20:00+08:00", "WATER_SUPPLY_SENSOR",
+                                "D-WS-1", "供水压力计-001", WarningWorkflow.RECEIVED, null),
+                        warningWithWorkflow("EW-004", 3, "2026-09-02T10:05:00+08:00", "SEWAGE_ENV",
+                                "D-DR-1", "污水环境监测仪-001", WarningWorkflow.CLOSED, "2026-09-02T12:05:00+08:00"),
+                        warningWithWorkflow("EW-005", 4, "2026-09-02T10:45:00+08:00", "WATER_SUPPLY_SENSOR",
+                                "D-WS-2", "供水压力计-002", WarningWorkflow.PENDING, null),
+                        warningWithWorkflow("EW-006", 1, "2026-09-02T11:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-3", "燃气浓度计-003", WarningWorkflow.HANDLED, null),
+                        warningWithWorkflow("EW-007", 2, "2026-09-02T11:35:00+08:00", "SEWAGE_ENV",
+                                "D-DR-2", "污水环境监测仪-002", WarningWorkflow.CLOSED, "2026-09-02T12:35:00+08:00"),
+                        warningWithWorkflow("EW-008", 1, "2026-09-02T09:55:00+08:00", "SEWAGE_ENV",
+                                "D-DR-3", "污水环境监测仪-003", WarningWorkflow.DRAFT, null),
                         warning("EW-LEGACY", 1, "2026-09-02T09:05:00+08:00", "LEGACY", "D-LEGACY", "旧设备")),
                 Arrays.asList(
                         alarm("EA-001", 1, "2026-09-02T10:20:00+08:00", "GAS_SENSOR", "D-GAS-9", "燃气浓度计-009"),
@@ -446,6 +537,23 @@ class CockpitHandlingEfficiencyControllerTest {
                         scopeOrder("WO-S7", 7, "GAS_SENSOR")));
     }
 
+    /** 一条脏状态(非六态字典值):必须进 UNKNOWN 桶,六态 + UNKNOWN 之和仍等于窗口内条数。 */
+    private static CockpitHandlingEfficiencySnapshot fixtureWithDirtyWarningStatus() {
+        return new CockpitHandlingEfficiencySnapshot(
+                "2026-09-02T11:59:50+08:00",
+                types(),
+                Arrays.asList(
+                        warningWithWorkflow("EW-U1", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-1", "燃气浓度计-001", WarningWorkflow.CLOSED, "2026-09-02T09:10:00+08:00"),
+                        warningWithWorkflow("EW-U2", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-2", "燃气浓度计-002", WarningWorkflow.PROCESSING, null),
+                        warningWithWorkflow("EW-U3", 1, "2026-09-02T10:10:00+08:00", "GAS_SENSOR",
+                                "D-GAS-3", "燃气浓度计-003", "WEIRD", null)),
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+
     private static CockpitHandlingEfficiencySnapshot.SourceWorkOrder scopeOrder(
             String orderNo, Integer stateCode, String typeCode) {
         return new CockpitHandlingEfficiencySnapshot.SourceWorkOrder(
@@ -472,13 +580,13 @@ class CockpitHandlingEfficiencyControllerTest {
         return new CockpitHandlingEfficiencySnapshot(
                 "2026-09-02T11:59:50+08:00",
                 types(),
-                Arrays.asList(
-                        warning("EW-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
-                        warning("EW-002", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002")),
                 Collections.emptyList(),
                 Arrays.asList(
-                        link("EW-001", 99, "2026-09-02T08:10:00+08:00", null),
-                        link("EW-002", 7, "2026-09-02T09:10:00+08:00", null)),
+                        alarm("EA-001", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
+                        alarm("EA-002", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002")),
+                Arrays.asList(
+                        link("EA-001", 99, "2026-09-02T08:10:00+08:00", null),
+                        link("EA-002", 7, "2026-09-02T09:10:00+08:00", null)),
                 // 旧码 99 与 7 都经字典收敛为 7(已取消)→ 分母 0、计数[7]=2。
                 Arrays.asList(
                         scopeOrder("WO-L1", 99, "GAS_SENSOR"),
@@ -489,17 +597,17 @@ class CockpitHandlingEfficiencyControllerTest {
         return new CockpitHandlingEfficiencySnapshot(
                 "2026-09-02T11:59:50+08:00",
                 types(),
-                Arrays.asList(
-                        warning("EW-001", 1, "2026-09-02T08:00:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
-                        warning("EW-002", 1, "2026-09-02T09:00:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002"),
-                        warning("EW-003", 1, "2026-09-02T10:00:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003"),
-                        warning("EW-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
                 Collections.emptyList(),
                 Arrays.asList(
-                        link("EW-001", 5, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
-                        link("EW-002", 5, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
-                        link("EW-003", 5, "2026-09-02T10:00:00+08:00", null),
-                        link("EW-004", 3, "2026-09-02T11:00:00+08:00", null)),
+                        alarm("EA-001", 1, "2026-09-02T08:00:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001"),
+                        alarm("EA-002", 1, "2026-09-02T09:00:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002"),
+                        alarm("EA-003", 1, "2026-09-02T10:00:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003"),
+                        alarm("EA-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
+                Arrays.asList(
+                        link("EA-001", 5, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
+                        link("EA-002", 5, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
+                        link("EA-003", 5, "2026-09-02T10:00:00+08:00", null),
+                        link("EA-004", 3, "2026-09-02T11:00:00+08:00", null)),
                 // 三张已结案工单 → handledCount 3、分母 3、处置率 1.0000。
                 Arrays.asList(
                         scopeOrder("WO-M1", 5, "GAS_SENSOR"),
@@ -523,6 +631,13 @@ class CockpitHandlingEfficiencyControllerTest {
                 no, level, eventTime, typeCode, deviceCode, deviceName);
     }
 
+    private static CockpitHandlingEfficiencySnapshot.SourceWarning warningWithWorkflow(
+            String no, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName,
+            String status, String updateTime) {
+        return new CockpitHandlingEfficiencySnapshot.SourceWarning(
+                no, level, eventTime, typeCode, deviceCode, deviceName, status, updateTime);
+    }
+
     private static CockpitHandlingEfficiencySnapshot.SourceAlarm alarm(
             String id, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName) {
         return new CockpitHandlingEfficiencySnapshot.SourceAlarm(

+ 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
 {
     /**

+ 7 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/gas/domain/GasMonitorData.java

@@ -122,6 +122,12 @@ public class GasMonitorData implements Serializable {
     @TableField(value = "latitude")
     private BigDecimal latitude;
 
+    /**
+     * 报警项中文描述(逗号分隔),由报警信息位解析得出,便于直接看出触发了哪些报警
+     */
+    @TableField(value = "alarm_names")
+    private String alarmNames;
+
     /**
      * 入库时间
      */
@@ -137,7 +143,7 @@ public class GasMonitorData implements Serializable {
         if (alarmInfo == null) {
             alarmInfo = 0;
         }
-        alarmBits.put("unknownAlarm", (alarmInfo & (1 << 0)) != 0); // Bit0:未知报警
+        alarmBits.put("lowAlarm", (alarmInfo & (1 << 0)) != 0);      // Bit0:低报警
         alarmBits.put("highAlarm", (alarmInfo & (1 << 1)) != 0);     // Bit1:高报警
         alarmBits.put("overRange", (alarmInfo & (1 << 2)) != 0);     // Bit2:超量程
         alarmBits.put("calibrationCycle", (alarmInfo & (1 << 3)) != 0); // Bit3:标定周期

+ 5 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java

@@ -474,7 +474,11 @@ public class BusinessCockpitEventAdapter {
                         offsetTime(item.warning.getPublishTime()),
                         typeCode(item.warning.getDeviceCode(), facts),
                         trimToNull(item.warning.getDeviceCode()),
-                        firstNonBlank(item.warning.getDeviceName(), deviceName(item.warning.getDeviceCode(), facts))))
+                        firstNonBlank(item.warning.getDeviceName(), deviceName(item.warning.getDeviceCode(), facts)),
+                        // 票 74:预警自身生命周期与解除时刻来源(update_time)随快照下传,
+                        // 供服务层按窗口分族聚合;工单口径与这两者无关。
+                        trimToNull(item.warning.getStatus()),
+                        offsetTime(item.warning.getUpdateTime())))
                 .collect(Collectors.toList());
     }
 

+ 56 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyResponse.java

@@ -19,7 +19,10 @@ public class CockpitHandlingEfficiencyResponse {
     private final Pagination pagination;
     private final MapFilter mapFilter;
     private final Summary summary;
+    /** 工单八态(报警族的工单口径);预警族为 {@code null}(不适用,不用 0 冒充)。 */
     private final List<WorkOrderStateCount> workOrderStateCounts;
+    /** 预警自身六态(预警族);报警族为 {@code null}。票 74 新增。 */
+    private final List<WarningStateCount> warningStateCounts;
     private final List<DurationPoint> durationTrend;
     private final List<EventItem> events;
 
@@ -37,6 +40,7 @@ public class CockpitHandlingEfficiencyResponse {
             MapFilter mapFilter,
             Summary summary,
             List<WorkOrderStateCount> workOrderStateCounts,
+            List<WarningStateCount> warningStateCounts,
             List<DurationPoint> durationTrend,
             List<EventItem> events) {
         this.requestId = requestId;
@@ -52,6 +56,7 @@ public class CockpitHandlingEfficiencyResponse {
         this.mapFilter = mapFilter;
         this.summary = summary;
         this.workOrderStateCounts = workOrderStateCounts;
+        this.warningStateCounts = warningStateCounts;
         this.durationTrend = durationTrend;
         this.events = events;
     }
@@ -69,6 +74,7 @@ public class CockpitHandlingEfficiencyResponse {
     public MapFilter getMapFilter() { return mapFilter; }
     public Summary getSummary() { return summary; }
     public List<WorkOrderStateCount> getWorkOrderStateCounts() { return workOrderStateCounts; }
+    public List<WarningStateCount> getWarningStateCounts() { return warningStateCounts; }
     public List<DurationPoint> getDurationTrend() { return durationTrend; }
     public List<EventItem> getEvents() { return events; }
     public CockpitFreshnessStatus getFreshness() {
@@ -149,25 +155,36 @@ public class CockpitHandlingEfficiencyResponse {
         public List<String> getDeviceCodes() { return deviceCodes; }
     }
 
+    /**
+     * 摘要。两族各有一套口径,**不适用的一方显式为 {@code null}**(票 74):
+     * 工单口径(handledCount/inProgressCount/cancelledCount/handlingEfficiency/averageDuration)
+     * 只属报警族 —— 工单是报警的处置手段;预警不挂工单,那三个计数若照旧回工单读数,
+     * 前端画出来的「预警处置中 = N」就是拿一个与预警无关的对象在统计预警。
+     * 预警族对应给 {@code warningHandlingEfficiency} / {@code warningDuration}。
+     */
     public static class Summary {
         private final int totalEvents;
         private final int linkedWorkOrderCount;
         private final int missingWorkOrderCount;
-        private final int handledCount;
-        private final int inProgressCount;
-        private final int cancelledCount;
+        private final Integer handledCount;
+        private final Integer inProgressCount;
+        private final Integer cancelledCount;
         private final HandlingEfficiency handlingEfficiency;
         private final AverageDuration averageDuration;
+        private final HandlingEfficiency warningHandlingEfficiency;
+        private final AverageDuration warningDuration;
 
         public Summary(
                 int totalEvents,
                 int linkedWorkOrderCount,
                 int missingWorkOrderCount,
-                int handledCount,
-                int inProgressCount,
-                int cancelledCount,
+                Integer handledCount,
+                Integer inProgressCount,
+                Integer cancelledCount,
                 HandlingEfficiency handlingEfficiency,
-                AverageDuration averageDuration) {
+                AverageDuration averageDuration,
+                HandlingEfficiency warningHandlingEfficiency,
+                AverageDuration warningDuration) {
             this.totalEvents = totalEvents;
             this.linkedWorkOrderCount = linkedWorkOrderCount;
             this.missingWorkOrderCount = missingWorkOrderCount;
@@ -176,16 +193,20 @@ public class CockpitHandlingEfficiencyResponse {
             this.cancelledCount = cancelledCount;
             this.handlingEfficiency = handlingEfficiency;
             this.averageDuration = averageDuration;
+            this.warningHandlingEfficiency = warningHandlingEfficiency;
+            this.warningDuration = warningDuration;
         }
 
         public int getTotalEvents() { return totalEvents; }
         public int getLinkedWorkOrderCount() { return linkedWorkOrderCount; }
         public int getMissingWorkOrderCount() { return missingWorkOrderCount; }
-        public int getHandledCount() { return handledCount; }
-        public int getInProgressCount() { return inProgressCount; }
-        public int getCancelledCount() { return cancelledCount; }
+        public Integer getHandledCount() { return handledCount; }
+        public Integer getInProgressCount() { return inProgressCount; }
+        public Integer getCancelledCount() { return cancelledCount; }
         public HandlingEfficiency getHandlingEfficiency() { return handlingEfficiency; }
         public AverageDuration getAverageDuration() { return averageDuration; }
+        public HandlingEfficiency getWarningHandlingEfficiency() { return warningHandlingEfficiency; }
+        public AverageDuration getWarningDuration() { return warningDuration; }
     }
 
     public static class HandlingEfficiency {
@@ -254,13 +275,35 @@ public class CockpitHandlingEfficiencyResponse {
         public int getCount() { return count; }
     }
 
+    /**
+     * 逐桶事件量与工单结案时长。{@code eventCount} 是**事件自身**的真实发布量(两族都可用);
+     * {@code handledCount}/{@code averageDuration} 是**工单口径**,预警族为 {@code null}(票 74)。
+     */
+    /** 预警自身生命周期桶:编码({@code WarningWorkflow} 六态或 UNKNOWN)+ 中文名 + 计数。 */
+    public static class WarningStateCount {
+        private final String stateCode;
+        private final String stateName;
+        private final int count;
+
+        public WarningStateCount(String stateCode, String stateName, int count) {
+            this.stateCode = stateCode;
+            this.stateName = stateName;
+            this.count = count;
+        }
+
+        public String getStateCode() { return stateCode; }
+        public String getStateName() { return stateName; }
+        public int getCount() { return count; }
+    }
+
     public static class DurationPoint {
         private final String timeBucket;
         private final int eventCount;
-        private final int handledCount;
+        private final Integer handledCount;
         private final AverageDuration averageDuration;
 
-        public DurationPoint(String timeBucket, int eventCount, int handledCount, AverageDuration averageDuration) {
+        public DurationPoint(
+                String timeBucket, int eventCount, Integer handledCount, AverageDuration averageDuration) {
             this.timeBucket = timeBucket;
             this.eventCount = eventCount;
             this.handledCount = handledCount;
@@ -269,7 +312,7 @@ public class CockpitHandlingEfficiencyResponse {
 
         public String getTimeBucket() { return timeBucket; }
         public int getEventCount() { return eventCount; }
-        public int getHandledCount() { return handledCount; }
+        public Integer getHandledCount() { return handledCount; }
         public AverageDuration getAverageDuration() { return averageDuration; }
     }
 

+ 228 - 49
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyService.java

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.event;
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -30,10 +31,20 @@ import java.util.Set;
 /**
  * Read-only aggregation for 预警处置 / 预警效率.
  *
+ * <p><b>两族语义分流(票 74)</b>:
+ * <ul>
+ *   <li><b>报警族</b>保留工单口径 —— 工单是报警的处置手段:工单八态、工单口径处置率
+ *       (状态 5 ÷ 状态 0–6)、关联工单结案时长。</li>
+ *   <li><b>预警族只给预警自身流程</b> —— 预警不挂工单(实测 {@code linkedWorkOrderCount=0}),
+ *       故工单口径字段一律 {@code null}(不用 0、也不用别的族的数冒充),
+ *       改给 {@code warningStateCounts}({@link WarningWorkflow} 六态)、
+ *       {@code warningHandlingEfficiency}(已解除 ÷ 窗口内预警数)、
+ *       {@code warningDuration}(发布→已解除平均时长)。</li>
+ * </ul>
+ *
  * <p>Keeps every work-order state from {@link CockpitWorkOrderStateDictionary}
  * unmerged, states the efficiency basis explicitly and deliberately never judges
- * whether an event was a false positive or accurate. Handled means the linked
- * work order resolved to state 6 已结案.
+ * whether an event was a false positive or accurate.
  */
 @Service
 public class CockpitHandlingEfficiencyService {
@@ -46,6 +57,19 @@ public class CockpitHandlingEfficiencyService {
     private static final int CANCELLED_STATE_CODE = CockpitWorkOrderStateDictionary.CANCELLED_RATE_CODE;
     private static final String EFFICIENCY_BASIS = CockpitWorkOrderStateDictionary.HANDLING_RATE_BASIS;
     private static final String NO_ELIGIBLE_WORK_ORDERS = "NO_ELIGIBLE_WORK_ORDERS";
+    /** 预警族处置率口径:已解除预警 ÷ 窗口内预警数(票 74)。 */
+    private static final String WARNING_EFFICIENCY_BASIS = "CLOSED_WARNINGS_OVER_WINDOW_WARNINGS";
+    private static final String NO_WARNINGS = "NO_WARNINGS";
+    private static final String NO_CLOSED_WARNINGS = "NO_CLOSED_WARNINGS";
+    private static final String UNKNOWN_WARNING_STATUS = "UNKNOWN";
+    /** 预警自身生命周期主线({@link WarningWorkflow} 六态),顺序即展示顺序。 */
+    private static final List<String> WARNING_STATUSES = Collections.unmodifiableList(Arrays.asList(
+            WarningWorkflow.DRAFT,
+            WarningWorkflow.PENDING,
+            WarningWorkflow.RECEIVED,
+            WarningWorkflow.PROCESSING,
+            WarningWorkflow.HANDLED,
+            WarningWorkflow.CLOSED));
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long MAX_WINDOW_DAYS = 31;
     private static final int DEFAULT_PAGE_SIZE = 100;
@@ -116,7 +140,8 @@ public class CockpitHandlingEfficiencyService {
             int pageSize) {
         Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types = types(state);
         Map<String, Link> links = links(state.getWorkOrders());
-        List<EventFact> facts = WARNING.equals(family)
+        boolean warningFamily = WARNING.equals(family);
+        List<EventFact> facts = warningFamily
                 ? warningFacts(state, types, industry, level)
                 : alarmFacts(state, types, industry, level);
         facts = inWindow(facts, window);
@@ -124,13 +149,18 @@ public class CockpitHandlingEfficiencyService {
         // 处置率口径(决策票 06 / 规格 10):分母=范围内**全部工单**(状态 0–6,
         // 状态 7 已取消排除),不要求关联报警/事件;范围按 create_time 界定。
         // 事件回联只决定 linkedCount / missingCount 与事件明细,不再参与分母。
-        Map<Integer, Integer> stateCounts = new LinkedHashMap<>();
-        for (CockpitWorkOrderState workOrderStateEntry : CockpitWorkOrderStateDictionary.states()) {
-            stateCounts.put(workOrderStateEntry.getCode(), 0);
-        }
-        for (CockpitHandlingEfficiencySnapshot.SourceWorkOrder order
-                : scopeWorkOrders(state, types, industry, window)) {
-            stateCounts.merge(resolveStateCode(order.getStateCode()), 1, Integer::sum);
+        // **只属报警族**(票 74):预警不挂工单,整套工单读数为预警族不成立,
+        // 故预警族的 summary/stateCounts 一律 null,由 warning* 字段替代。
+        Map<Integer, Integer> stateCounts = null;
+        if (!warningFamily) {
+            stateCounts = new LinkedHashMap<>();
+            for (CockpitWorkOrderState workOrderStateEntry : CockpitWorkOrderStateDictionary.states()) {
+                stateCounts.put(workOrderStateEntry.getCode(), 0);
+            }
+            for (CockpitHandlingEfficiencySnapshot.SourceWorkOrder order
+                    : scopeWorkOrders(state, types, industry, window)) {
+                stateCounts.merge(resolveStateCode(order.getStateCode()), 1, Integer::sum);
+            }
         }
         int linkedCount = 0;
         int missingCount = 0;
@@ -179,41 +209,78 @@ public class CockpitHandlingEfficiencyService {
                     link != null));
         }
 
-        // Summary metrics: 事件计数取自事件范围,处置率取自工单范围(两者口径不同)。
-        int handledCount = stateCounts.getOrDefault(CLOSED_STATE_CODE, 0);
-        int cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
-        int inProgressCount = 0;
-        for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
-            if (entry.getKey() == 6 || (entry.getKey() >= 0 && entry.getKey() <= 4)) {
-                inProgressCount += entry.getValue();
-            }
-        }
         int totalEvents = facts.size();
-        int eligibleWorkOrders = 0;
-        for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
-            if (CockpitWorkOrderStateDictionary.isRateEligible(entry.getKey())) {
-                eligibleWorkOrders += entry.getValue();
-            }
-        }
 
-        List<BigDecimal> closedDurations = new ArrayList<>();
-        for (EventFact fact : facts) {
-            Link link = linkOf(links, fact);
-            if (link == null || link.stateCode != CLOSED_STATE_CODE) {
-                continue;
+        // 两族各算各的口径,不适用的一方显式 null(票 74)。
+        Integer handledCount = null;
+        Integer inProgressCount = null;
+        Integer cancelledCount = null;
+        CockpitHandlingEfficiencyResponse.HandlingEfficiency handlingEfficiency = null;
+        CockpitHandlingEfficiencyResponse.AverageDuration averageDuration = null;
+        List<CockpitHandlingEfficiencyResponse.WorkOrderStateCount> stateRows = null;
+        List<CockpitHandlingEfficiencyResponse.WarningStateCount> warningStateRows = null;
+        CockpitHandlingEfficiencyResponse.HandlingEfficiency warningEfficiency = null;
+        CockpitHandlingEfficiencyResponse.AverageDuration warningDuration = null;
+
+        if (warningFamily) {
+            Map<String, Integer> statusCounts = new LinkedHashMap<>();
+            int closedWarnings = 0;
+            List<BigDecimal> warningDurations = new ArrayList<>();
+            for (EventFact fact : facts) {
+                String status = normalizeWarningStatus(fact.status);
+                // 无法识别的状态如实进 UNKNOWN 桶,不静默丢(丢了 sum(桶) 就小于总条数)。
+                statusCounts.merge(status == null ? UNKNOWN_WARNING_STATUS : status, 1, Integer::sum);
+                if (!WarningWorkflow.CLOSED.equals(status)) {
+                    continue;
+                }
+                closedWarnings++;
+                BigDecimal duration = warningDurationHours(fact);
+                if (duration != null) {
+                    warningDurations.add(duration);
+                }
             }
-            BigDecimal duration = durationHours(link);
-            if (duration != null) {
-                closedDurations.add(duration);
+            warningStateRows = warningStateRows(statusCounts);
+            warningEfficiency = warningEfficiency(closedWarnings, totalEvents);
+            warningDuration = warningAverageDuration(warningDurations);
+        } else {
+            // Summary metrics: 事件计数取自事件范围,处置率取自工单范围(两者口径不同)。
+            handledCount = stateCounts.getOrDefault(CLOSED_STATE_CODE, 0);
+            cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
+            int inProgress = 0;
+            for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
+                if (entry.getKey() == 6 || (entry.getKey() >= 0 && entry.getKey() <= 4)) {
+                    inProgress += entry.getValue();
+                }
+            }
+            inProgressCount = inProgress;
+            int eligibleWorkOrders = 0;
+            for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
+                if (CockpitWorkOrderStateDictionary.isRateEligible(entry.getKey())) {
+                    eligibleWorkOrders += entry.getValue();
+                }
             }
-        }
 
-        List<CockpitHandlingEfficiencyResponse.WorkOrderStateCount> stateRows = new ArrayList<>();
-        for (CockpitWorkOrderState entry : CockpitWorkOrderStateDictionary.states()) {
-            stateRows.add(new CockpitHandlingEfficiencyResponse.WorkOrderStateCount(
-                    entry.getCode(),
-                    entry.getName(),
-                    stateCounts.getOrDefault(entry.getCode(), 0)));
+            List<BigDecimal> closedDurations = new ArrayList<>();
+            for (EventFact fact : facts) {
+                Link link = linkOf(links, fact);
+                if (link == null || link.stateCode != CLOSED_STATE_CODE) {
+                    continue;
+                }
+                BigDecimal duration = durationHours(link);
+                if (duration != null) {
+                    closedDurations.add(duration);
+                }
+            }
+
+            handlingEfficiency = efficiency(handledCount, eligibleWorkOrders);
+            averageDuration = averageDuration(closedDurations);
+            stateRows = new ArrayList<>();
+            for (CockpitWorkOrderState entry : CockpitWorkOrderStateDictionary.states()) {
+                stateRows.add(new CockpitHandlingEfficiencyResponse.WorkOrderStateCount(
+                        entry.getCode(),
+                        entry.getName(),
+                        stateCounts.getOrDefault(entry.getCode(), 0)));
+            }
         }
 
         return new CockpitHandlingEfficiencyResponse(
@@ -240,9 +307,12 @@ public class CockpitHandlingEfficiencyService {
                         handledCount,
                         inProgressCount,
                         cancelledCount,
-                        efficiency(handledCount, eligibleWorkOrders),
-                        averageDuration(closedDurations)),
+                        handlingEfficiency,
+                        averageDuration,
+                        warningEfficiency,
+                        warningDuration),
                 stateRows,
+                warningStateRows,
                 durationTrend(state, family, types, industry, level, links, window),
                 items);
     }
@@ -286,6 +356,93 @@ public class CockpitHandlingEfficiencyService {
                 null);
     }
 
+    /**
+     * 预警族处置率=**已解除预警数 ÷ 窗口内预警条数**(同上层的报警族工单口径刻意不同源)。
+     * 分母为 0 → {@code state=EMPTY} 且 {@code reason=NO_WARNINGS},前端显示「不适用」而非 0%。
+     */
+    private static CockpitHandlingEfficiencyResponse.HandlingEfficiency warningEfficiency(
+            int closedWarnings, int totalWarnings) {
+        if (totalWarnings <= 0) {
+            return new CockpitHandlingEfficiencyResponse.HandlingEfficiency(
+                    BigDecimal.ZERO.setScale(4), closedWarnings, totalWarnings,
+                    "EMPTY", NO_WARNINGS, WARNING_EFFICIENCY_BASIS);
+        }
+        return new CockpitHandlingEfficiencyResponse.HandlingEfficiency(
+                BigDecimal.valueOf(closedWarnings)
+                        .divide(BigDecimal.valueOf(totalWarnings), 4, RoundingMode.HALF_UP),
+                closedWarnings,
+                totalWarnings,
+                "AVAILABLE",
+                null,
+                WARNING_EFFICIENCY_BASIS);
+    }
+
+    /**
+     * 预警发布→已解除平均时长。无样本 → {@code state=EMPTY} + {@code NO_CLOSED_WARNINGS},
+     * 不回 0 时冒充真实读数。
+     *
+     * <p>解除时刻来源是 {@code early_warning.update_time}:该表没有独立的解除时间列,
+     * 而解除动作({@code clearWarning})在置 {@code CLOSED} 的同一次写入里显式更新它,
+     * 且 {@code CLOSED} 是终态无后续业务动作 —— 故 CLOSED 行上它即解除时刻。
+     * 这是**有前提的读数**,不假装是独立采集的时间戳;时间缺失/倒挂的样本跳过,不补 0。
+     */
+    private static CockpitHandlingEfficiencyResponse.AverageDuration warningAverageDuration(
+            List<BigDecimal> durations) {
+        if (durations.isEmpty()) {
+            return new CockpitHandlingEfficiencyResponse.AverageDuration(
+                    BigDecimal.ZERO.setScale(2), "HOURS", 0, "EMPTY", NO_CLOSED_WARNINGS);
+        }
+        BigDecimal sum = BigDecimal.ZERO;
+        for (BigDecimal duration : durations) {
+            sum = sum.add(duration);
+        }
+        return new CockpitHandlingEfficiencyResponse.AverageDuration(
+                sum.divide(BigDecimal.valueOf(durations.size()), 2, RoundingMode.HALF_UP),
+                "HOURS",
+                durations.size(),
+                "AVAILABLE",
+                null);
+    }
+
+    private static BigDecimal warningDurationHours(EventFact fact) {
+        OffsetDateTime published = parseEventTime(fact.eventTime);
+        OffsetDateTime resolved = parseEventTime(fact.updateTime);
+        if (published == null || resolved == null || resolved.isBefore(published)) {
+            return null;
+        }
+        long minutes = Duration.between(published, resolved).toMinutes();
+        return BigDecimal.valueOf(minutes)
+                .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 六态**全铺**(缺的态是真实计数 0,不是「没有数据」),确有无法识别的状态时补 {@code UNKNOWN} 桶,
+     * 故 {@code sum(桶计数) == 窗口内预警条数} 恒成立。
+     */
+    private static List<CockpitHandlingEfficiencyResponse.WarningStateCount> warningStateRows(
+            Map<String, Integer> statusCounts) {
+        List<CockpitHandlingEfficiencyResponse.WarningStateCount> rows = new ArrayList<>();
+        for (String status : WARNING_STATUSES) {
+            rows.add(new CockpitHandlingEfficiencyResponse.WarningStateCount(
+                    status, WarningWorkflow.label(status), statusCounts.getOrDefault(status, 0)));
+        }
+        int unknown = statusCounts.getOrDefault(UNKNOWN_WARNING_STATUS, 0);
+        if (unknown > 0) {
+            rows.add(new CockpitHandlingEfficiencyResponse.WarningStateCount(
+                    UNKNOWN_WARNING_STATUS, "未知", unknown));
+        }
+        return rows;
+    }
+
+    /** 只认 {@link WarningWorkflow} 六态;脏值/空值回 {@code null}(由调用方归入未知桶)。 */
+    private static String normalizeWarningStatus(String status) {
+        if (isBlank(status)) {
+            return null;
+        }
+        String value = status.trim().toUpperCase();
+        return WARNING_STATUSES.contains(value) ? value : null;
+    }
+
     private List<CockpitHandlingEfficiencyResponse.DurationPoint> durationTrend(
             CockpitHandlingEfficiencySnapshot snapshot,
             String family,
@@ -307,7 +464,8 @@ public class CockpitHandlingEfficiencyService {
             eventCounts.put(key, 0);
             handledCounts.put(key, 0);
         }
-        List<EventFact> facts = WARNING.equals(family)
+        boolean warningFamily = WARNING.equals(family);
+        List<EventFact> facts = warningFamily
                 ? warningFacts(snapshot, types, industry, level)
                 : alarmFacts(snapshot, types, industry, level);
         for (EventFact fact : facts) {
@@ -321,6 +479,10 @@ public class CockpitHandlingEfficiencyService {
                 continue;
             }
             eventCounts.put(key, current + 1);
+            // 预警族只取**事件自身**的发布量;工单结案数与结案时长是报警族的口径(票 74)。
+            if (warningFamily) {
+                continue;
+            }
             Link link = linkOf(links, fact);
             if (link != null && link.stateCode == CLOSED_STATE_CODE) {
                 handledCounts.put(key, handledCounts.get(key) + 1);
@@ -335,8 +497,8 @@ public class CockpitHandlingEfficiencyService {
             points.add(new CockpitHandlingEfficiencyResponse.DurationPoint(
                     key,
                     eventCounts.get(key),
-                    handledCounts.get(key),
-                    averageDuration(durations.get(key))));
+                    warningFamily ? null : handledCounts.get(key),
+                    warningFamily ? null : averageDuration(durations.get(key))));
         }
         return points;
     }
@@ -361,7 +523,9 @@ public class CockpitHandlingEfficiencyService {
                     warning.getLevel(),
                     warning.getEventTime(),
                     warning.getDeviceCode(),
-                    warning.getDeviceName()));
+                    warning.getDeviceName(),
+                    warning.getStatus(),
+                    warning.getUpdateTime()));
         }
         return facts;
     }
@@ -386,7 +550,9 @@ public class CockpitHandlingEfficiencyService {
                     alarm.getLevel(),
                     alarm.getEventTime(),
                     alarm.getDeviceCode(),
-                    alarm.getDeviceName()));
+                    alarm.getDeviceName(),
+                    null,
+                    null));
         }
         return facts;
     }
@@ -665,13 +831,26 @@ public class CockpitHandlingEfficiencyService {
         private final String eventTime;
         private final String deviceCode;
         private final String deviceName;
-
-        private EventFact(String eventNo, Integer level, String eventTime, String deviceCode, String deviceName) {
+        /** 预警自身生命周期状态;报警族为 null。 */
+        private final String status;
+        /** 预警记录最后更新时间(解除时刻来源);报警族为 null。 */
+        private final String updateTime;
+
+        private EventFact(
+                String eventNo,
+                Integer level,
+                String eventTime,
+                String deviceCode,
+                String deviceName,
+                String status,
+                String updateTime) {
             this.eventNo = eventNo;
             this.level = level;
             this.eventTime = eventTime;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.status = status;
+            this.updateTime = updateTime;
         }
 
         private String getEventNo() {

+ 20 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencySnapshot.java

@@ -109,6 +109,10 @@ public class CockpitHandlingEfficiencySnapshot {
         private final String typeCode;
         private final String deviceCode;
         private final String deviceName;
+        /** 预警自身生命周期({@code WarningWorkflow} 六态);脏值由服务层归入未知桶。 */
+        private final String status;
+        /** 预警记录最后更新时间:{@code CLOSED} 是终态且解除动作会写入该时间,故用作解除时刻来源。 */
+        private final String updateTime;
 
         public SourceWarning(
                 String warningNo,
@@ -117,12 +121,26 @@ public class CockpitHandlingEfficiencySnapshot {
                 String typeCode,
                 String deviceCode,
                 String deviceName) {
+            this(warningNo, level, eventTime, typeCode, deviceCode, deviceName, null, null);
+        }
+
+        public SourceWarning(
+                String warningNo,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String deviceName,
+                String status,
+                String updateTime) {
             this.warningNo = warningNo;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.status = status;
+            this.updateTime = updateTime;
         }
 
         public String getWarningNo() { return warningNo; }
@@ -131,6 +149,8 @@ public class CockpitHandlingEfficiencySnapshot {
         public String getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
+        public String getStatus() { return status; }
+        public String getUpdateTime() { return updateTime; }
     }
 
     public static class SourceAlarm {

+ 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();
+}

+ 2 - 1
pipe-network-service/zksy-system/src/main/resources/mapper/base/gas/GasMonitorDataMapper.xml

@@ -22,6 +22,7 @@
             <result property="signalStrength" column="signal_strength" jdbcType="FLOAT"/>
             <result property="longitude" column="longitude" jdbcType="DECIMAL"/>
             <result property="latitude" column="latitude" jdbcType="DECIMAL"/>
+            <result property="alarmNames" column="alarm_names" jdbcType="VARCHAR"/>
             <result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
     </resultMap>
 
@@ -31,6 +32,6 @@
         command,device_attr,protocol_version,
         alarm_info,report_time,gas_concentration,
         gas_type,battery,signal_strength,
-        longitude,latitude,create_time
+        longitude,latitude,alarm_names,create_time
     </sql>
 </mapper>

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

+ 19 - 10
zk-common/src/main/java/com/zksy/common/netty/DeviceFrameDecoder.java

@@ -15,7 +15,7 @@ public final class DeviceFrameDecoder extends ByteToMessageDecoder {
 
     @Override
     protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
-        int minimum = protocol == Protocol.MANHOLE ? 72 : protocol == Protocol.GAS ? 74 : 5;
+        int minimum = protocol == Protocol.MANHOLE ? 72 : 5;
         while (in.readableBytes() >= minimum) {
             int start = in.readerIndex();
             int length;
@@ -38,10 +38,19 @@ public final class DeviceFrameDecoder extends ByteToMessageDecoder {
                 length = 72;
                 if (crc(in, start, 70) != in.getUnsignedShortLE(start + 70)) { in.skipBytes(1); continue; }
             } else if (protocol == Protocol.GAS) {
-                // 当前已支持的7指标上报结构固定为74字节,内外两层校验与接收器一致。
-                length = 74;
-                if (in.getUnsignedByte(start) != 0x7E || in.getUnsignedByte(start + 15) != 0xAA
-                        || !gasChecksum(in, start)) { in.skipBytes(1); continue; }
+                // 协议:7E + 2 字节长度(帧类型→校验07)+ 数据 + 校验07 + 校验16,总长 = 长度字段 + 4。
+                // 数据项数量随设备上报内容变化(5 项无经纬度、7 项含经纬度),帧长不能写死。
+                if (in.getUnsignedByte(start) != 0x7E) { in.skipBytes(1); continue; }
+                length = in.getUnsignedShort(start + 1) + 4;
+                if (length < 20 || length > MAX_FRAME) { in.skipBytes(1); continue; }
+                if (in.readableBytes() < length) {
+                    if (in.readableBytes() >= MAX_FRAME) { in.skipBytes(1); continue; }
+                    return;
+                }
+                if (in.getUnsignedByte(start + 15) != 0xAA || !gasChecksum(in, start, length)) {
+                    in.skipBytes(1);
+                    continue;
+                }
             } else {
                 if (in.getUnsignedShort(start) != 0x7E7E) { in.skipBytes(1); continue; }
                 // 现有消防设备长度字段不作为可信边界,使用结束符 + 完整CRC确认边界。
@@ -66,12 +75,12 @@ public final class DeviceFrameDecoder extends ByteToMessageDecoder {
         }
     }
 
-    private static boolean gasChecksum(ByteBuf in, int start) {
+    private static boolean gasChecksum(ByteBuf in, int start, int frameLen) {
         int body = 0, full = 0;
-        for (int i = 16; i < 72; i++) body += in.getUnsignedByte(start + i);
-        for (int i = 3; i < 73; i++) full += in.getUnsignedByte(start + i);
-        return (body & 255) == in.getUnsignedByte(start + 72)
-                && ((255 - full) & 255) == in.getUnsignedByte(start + 73);
+        for (int i = 16; i < frameLen - 2; i++) body += in.getUnsignedByte(start + i);
+        for (int i = 3; i < frameLen - 1; i++) full += in.getUnsignedByte(start + i);
+        return (body & 255) == in.getUnsignedByte(start + frameLen - 2)
+                && ((255 - full) & 255) == in.getUnsignedByte(start + frameLen - 1);
     }
 
     private static int nextPingshengFrame(ByteBuf in, int from) {