Ver código fonte

feat(waterSupply): 报警管理只读台账与态势统计 (#45)

新增供水报警台账分页、导出与态势统计接口,展示状态由后端推导;前端报警管理改为只读台账并增加趋势与分类占比图表,移除派单与标记处理入口。
Kazerin 1 mês atrás
pai
commit
71626ed361

+ 65 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyAlarmController.java

@@ -4,14 +4,17 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.AlarmAuditInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.AlarmDispatchInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmAuditOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmFlowOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmMapPointOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmStatsOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService;
+import com.zksy.WaterSupply.export.WaterAlarmLedgerExportDTO;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.enums.BusinessType;
+import com.zksy.common.utils.poi.ExcelUtil;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -21,8 +24,10 @@ import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.stream.Collectors;
 
 /**
  * 供水管网-监测报警接口
@@ -81,6 +86,66 @@ public class WaterSupplyAlarmController {
         return detail != null ? AjaxResult.success(detail) : AjaxResult.error("报警信息不存在");
     }
 
+    // ------------------------------------------------------------------
+    // 报警管理只读台账(T02)
+    // ------------------------------------------------------------------
+
+    @GetMapping("/ledger/page")
+    @ApiOperation("报警管理只读台账分页(仅供水设备,展示状态后端推导)")
+    @PreAuthorize("@ss.hasPermi('waterSupply:alarm:list')")
+    public AjaxResult getAlarmLedgerPage(
+            @ApiParam(value = "页码", defaultValue = "1") @RequestParam(defaultValue = "1") long pageNum,
+            @ApiParam(value = "每页数量", defaultValue = "10") @RequestParam(defaultValue = "10") long pageSize,
+            @ApiParam("设备编码") @RequestParam(required = false) String deviceCode,
+            @ApiParam("报警类型") @RequestParam(required = false) String warningType,
+            @ApiParam("报警等级 1-4") @RequestParam(required = false) Integer alarmLevel,
+            @ApiParam("报警展示状态 1-待审核 2-已转工单 3-已解除") @RequestParam(required = false) Integer displayStatus,
+            @ApiParam("设备报警状态 0-正常 1-报警") @RequestParam(required = false) Integer deviceAlarmStatus,
+            @ApiParam(value = "开始时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false)
+            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @ApiParam(value = "结束时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false)
+            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @ApiParam("关键字(设备编码/报警类型/预警编码)") @RequestParam(required = false) String keyword) {
+        Page<WaterAlarmOutDTO> page = waterSupplyAlarmService.getAlarmLedgerPage(
+                pageNum, pageSize, deviceCode, warningType, alarmLevel, displayStatus,
+                deviceAlarmStatus, startTime, endTime, keyword);
+        return AjaxResult.success(page);
+    }
+
+    @GetMapping("/ledger/dashboard")
+    @ApiOperation("报警管理态势统计(展示状态分布、报警趋势、报警与设备分类占比)")
+    @PreAuthorize("@ss.hasPermi('waterSupply:alarm:list')")
+    public AjaxResult getAlarmDashboard(
+            @ApiParam(value = "趋势回溯天数(含当天)", defaultValue = "7")
+            @RequestParam(defaultValue = "7") int days) {
+        AlarmDashboardOutDTO dashboard = waterSupplyAlarmService.getAlarmDashboard(days);
+        return AjaxResult.success(dashboard);
+    }
+
+    @PostMapping("/ledger/export")
+    @ApiOperation("导出报警管理只读台账")
+    @Log(title = "导出供水报警台账", businessType = BusinessType.EXPORT)
+    @PreAuthorize("@ss.hasPermi('waterSupply:alarm:list')")
+    public void exportAlarmLedger(HttpServletResponse response,
+            @ApiParam("设备编码") @RequestParam(required = false) String deviceCode,
+            @ApiParam("报警类型") @RequestParam(required = false) String warningType,
+            @ApiParam("报警等级 1-4") @RequestParam(required = false) Integer alarmLevel,
+            @ApiParam("报警展示状态 1-待审核 2-已转工单 3-已解除") @RequestParam(required = false) Integer displayStatus,
+            @ApiParam("设备报警状态 0-正常 1-报警") @RequestParam(required = false) Integer deviceAlarmStatus,
+            @ApiParam(value = "开始时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false)
+            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @ApiParam(value = "结束时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false)
+            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @ApiParam("关键字(设备编码/报警类型/预警编码)") @RequestParam(required = false) String keyword) {
+        List<WaterAlarmLedgerExportDTO> rows = waterSupplyAlarmService.getAlarmLedgerExport(
+                        deviceCode, warningType, alarmLevel, displayStatus, deviceAlarmStatus,
+                        startTime, endTime, keyword)
+                .stream()
+                .map(WaterAlarmLedgerExportDTO::from)
+                .collect(Collectors.toList());
+        new ExcelUtil<>(WaterAlarmLedgerExportDTO.class).exportExcel(response, rows, "供水报警台账");
+    }
+
     @PutMapping("/resolve/{alarmId}")
     @ApiOperation("一键标记已处理")
     @Log(title = "供水报警标记已处理", businessType = BusinessType.UPDATE)

+ 145 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyAlarmLedgerControllerTest.java

@@ -0,0 +1,145 @@
+package com.zksy.web.controller.WaterSupply.MonitorAlarm;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+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.status;
+
+/**
+ * 供水报警管理只读台账契约测试(T02 / Issue #45)。
+ *
+ * <p>被测公共 seam:{@code GET /waterSupply/alarm/ledger/page}。
+ * 只验证外部可见行为:响应字段与筛选条件透传。</p>
+ */
+class WaterSupplyAlarmLedgerControllerTest {
+
+    private IWaterSupplyAlarmService alarmService;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        alarmService = mock(IWaterSupplyAlarmService.class);
+        WaterSupplyAlarmController controller = new WaterSupplyAlarmController();
+        ReflectionTestUtils.setField(controller, "waterSupplyAlarmService", alarmService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    /**
+     * MockMvc 默认以 ISO-8859-1 解码响应,这里显式按 UTF-8 取体,便于断言中文字段。
+     */
+    private static String body(org.springframework.test.web.servlet.MvcResult result) throws Exception {
+        return new String(result.getResponse().getContentAsByteArray(), java.nio.charset.StandardCharsets.UTF_8);
+    }
+
+    @Test
+    void ledgerPageExposesDerivedDisplayStatusAndDeviceAlarmStatus() throws Exception {
+        WaterAlarmOutDTO row = new WaterAlarmOutDTO();
+        row.setId("alarm-1");
+        row.setDeviceCode("CN-GS-202609020001");
+        row.setWarningType("压力预警");
+        row.setWarningCode("WARN-PRESSURE");
+        row.setAlarmLevel(1);
+        row.setAlarmLevelText("一级报警");
+        row.setDeviceAlarmStatus(1);
+        row.setDeviceAlarmStatusText("报警中");
+        row.setDisplayStatus(2);
+        row.setDisplayStatusText("已转工单");
+        row.setOrderNo("WO202606101703482640");
+        Page<WaterAlarmOutDTO> page = new Page<>(1, 10, 1);
+        page.setRecords(Collections.singletonList(row));
+        when(alarmService.getAlarmLedgerPage(anyLong(), anyLong(), any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(page);
+
+        mockMvc.perform(get("/waterSupply/alarm/ledger/page")
+                        .param("pageNum", "1")
+                        .param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(result -> {
+                    String json = body(result);
+                    assertThat(json, "\"displayStatus\":2");
+                    assertThat(json, "\"displayStatusText\":\"已转工单\"");
+                    assertThat(json, "\"deviceAlarmStatus\":1");
+                    assertThat(json, "\"deviceAlarmStatusText\":\"报警中\"");
+                    assertThat(json, "WO202606101703482640");
+                    assertThat(json, "WARN-PRESSURE");
+                });
+    }
+
+    @Test
+    void dashboardExposesDisplayStatusCountsAndRatios() throws Exception {
+        AlarmDashboardOutDTO dashboard = new AlarmDashboardOutDTO();
+        dashboard.setTotalCount(6);
+        dashboard.setPendingAuditCount(2);
+        dashboard.setTransferredCount(3);
+        dashboard.setReleasedCount(1);
+        dashboard.setAlarmingDeviceCount(4);
+        AlarmDashboardOutDTO.TrendItem trend = new AlarmDashboardOutDTO.TrendItem();
+        trend.setDate("2026-09-14");
+        trend.setCount(4);
+        trend.setHighLevelCount(2);
+        dashboard.setTrend(Collections.singletonList(trend));
+        AlarmDashboardOutDTO.RatioItem ratio = new AlarmDashboardOutDTO.RatioItem();
+        ratio.setName("压力预警");
+        ratio.setCount(4);
+        dashboard.setWarningTypeRatio(Collections.singletonList(ratio));
+        when(alarmService.getAlarmDashboard(anyInt())).thenReturn(dashboard);
+
+        mockMvc.perform(get("/waterSupply/alarm/ledger/dashboard").param("days", "7"))
+                .andExpect(status().isOk())
+                .andExpect(result -> {
+                    String json = body(result);
+                    assertThat(json, "\"pendingAuditCount\":2");
+                    assertThat(json, "\"transferredCount\":3");
+                    assertThat(json, "\"releasedCount\":1");
+                    assertThat(json, "\"alarmingDeviceCount\":4");
+                    assertThat(json, "\"highLevelCount\":2");
+                    assertThat(json, "\"name\":\"压力预警\"");
+                });
+    }
+
+    @Test
+    void ledgerExportStreamsExcelWithFilteredRows() throws Exception {
+        WaterAlarmOutDTO row = new WaterAlarmOutDTO();
+        row.setId("alarm-1");
+        row.setDeviceCode("CN-GS-202609020001");
+        row.setWarningType("压力预警");
+        row.setAlarmLevelText("一级报警");
+        row.setDeviceAlarmStatusText("报警中");
+        row.setDisplayStatusText("已转工单");
+        row.setOrderNo("WO202606101703482640");
+        when(alarmService.getAlarmLedgerExport(any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(List.of(row));
+
+        mockMvc.perform(post("/waterSupply/alarm/ledger/export"))
+                .andExpect(status().isOk())
+                .andExpect(result -> {
+                    String contentType = result.getResponse().getContentType();
+                    if (contentType == null || !contentType.contains("spreadsheet")) {
+                        throw new AssertionError("导出响应不是 Excel:" + contentType);
+                    }
+                });
+    }
+
+    private static void assertThat(String json, String expected) {
+        if (!json.contains(expected)) {
+            throw new AssertionError("响应缺少期望内容:" + expected + "\n实际:" + json);
+        }
+    }
+}

+ 75 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/AlarmDashboardOutDTO.java

@@ -0,0 +1,75 @@
+package com.zksy.WaterSupply.MonitorAlarm.dto.out;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 供水报警管理态势统计-出参(展示状态分布 + 报警趋势 + 分类占比)
+ */
+@Data
+@ApiModel(value = "供水报警态势统计-出参", description = "报警管理页顶部态势卡片、趋势图与分类占比")
+public class AlarmDashboardOutDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "供水报警总数(台账全量)", example = "6")
+    private long totalCount;
+
+    @ApiModelProperty(value = "待审核报警数", example = "2")
+    private long pendingAuditCount;
+
+    @ApiModelProperty(value = "已转工单报警数", example = "3")
+    private long transferredCount;
+
+    @ApiModelProperty(value = "已解除报警数", example = "1")
+    private long releasedCount;
+
+    @ApiModelProperty(value = "当前处于报警中的设备数(设备报警状态=1 去重)", example = "3")
+    private long alarmingDeviceCount;
+
+    @ApiModelProperty(value = "报警趋势(按天,含空日)")
+    private List<TrendItem> trend = new ArrayList<>();
+
+    @ApiModelProperty(value = "报警分类占比(按预警类型)")
+    private List<RatioItem> warningTypeRatio = new ArrayList<>();
+
+    @ApiModelProperty(value = "设备分类占比(按设备类型)")
+    private List<RatioItem> deviceTypeRatio = new ArrayList<>();
+
+    @ApiModelProperty(value = "报警等级占比(1-4 级)")
+    private List<RatioItem> alarmLevelRatio = new ArrayList<>();
+
+    @Data
+    @ApiModel(value = "报警趋势项")
+    public static class TrendItem implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        @ApiModelProperty(value = "日期", example = "2026-09-14")
+        private String date;
+
+        @ApiModelProperty(value = "当日报警数", example = "4")
+        private long count;
+
+        @ApiModelProperty(value = "其中高等级(1-2 级)报警数", example = "2")
+        private long highLevelCount;
+    }
+
+    @Data
+    @ApiModel(value = "占比项")
+    public static class RatioItem implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        @ApiModelProperty(value = "分类名称", example = "压力预警")
+        private String name;
+
+        @ApiModelProperty(value = "数量", example = "4")
+        private long count;
+    }
+}

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/WaterAlarmOutDTO.java

@@ -72,6 +72,18 @@ public class WaterAlarmOutDTO implements Serializable {
     @ApiModelProperty(value = "报警状态文本", example = "待处理")
     private String alarmStatusText;
 
+    @ApiModelProperty(value = "设备报警状态:0-正常 1-报警(设备级状态,判断设备当前是否报警的主要依据)", example = "1")
+    private Integer deviceAlarmStatus;
+
+    @ApiModelProperty(value = "设备报警状态文本", example = "报警中")
+    private String deviceAlarmStatusText;
+
+    @ApiModelProperty(value = "报警展示状态:1-待审核 2-已转工单 3-已解除(后端推导,不等同报警记录状态)", example = "2")
+    private Integer displayStatus;
+
+    @ApiModelProperty(value = "报警展示状态文本", example = "已转工单")
+    private String displayStatusText;
+
     @ApiModelProperty(value = "报警时间")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private LocalDateTime alarmTime;

+ 30 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/IWaterSupplyAlarmService.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.AlarmAuditInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.AlarmDispatchInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmAuditOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmFlowOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmMapPointOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmStatsOutDTO;
@@ -36,6 +37,35 @@ public interface IWaterSupplyAlarmService {
                                                LocalDateTime startTime, LocalDateTime endTime,
                                                String keyword);
 
+    /**
+     * 报警管理只读台账分页:仅供水设备报警,展示状态由后端推导
+     *
+     * @param displayStatus     报警展示状态:1-待审核 2-已转工单 3-已解除
+     * @param deviceAlarmStatus 设备报警状态:0-正常 1-报警
+     */
+    Page<WaterAlarmOutDTO> getAlarmLedgerPage(long pageNum, long pageSize,
+                                              String deviceCode, String warningType,
+                                              Integer alarmLevel, Integer displayStatus,
+                                              Integer deviceAlarmStatus,
+                                              LocalDateTime startTime, LocalDateTime endTime,
+                                              String keyword);
+
+    /**
+     * 报警管理只读台账导出行(与分页同一推导口径,不分页)
+     */
+    List<WaterAlarmOutDTO> getAlarmLedgerExport(String deviceCode, String warningType,
+                                                Integer alarmLevel, Integer displayStatus,
+                                                Integer deviceAlarmStatus,
+                                                LocalDateTime startTime, LocalDateTime endTime,
+                                                String keyword);
+
+    /**
+     * 报警管理态势统计:展示状态分布、报警趋势、报警/设备分类占比
+     *
+     * @param days 趋势回溯天数(含当天),非正数时按 7 天处理
+     */
+    AlarmDashboardOutDTO getAlarmDashboard(int days);
+
     /**
      * 报警详情(报警数据 + 设备数据 + 关联工单)
      */

+ 332 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyAlarmServiceImpl.java

@@ -9,6 +9,7 @@ import com.zksy.WaterSupply.MonitorAlarm.domain.AlarmAudit;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.AlarmAuditInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.AlarmDispatchInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmAuditOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmFlowOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmMapPointOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmStatsOutDTO;
@@ -18,6 +19,10 @@ import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.mapper.EquipmentStatusMapper;
+import com.zksy.base.mapper.EquipmentTypeMapper;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrderLog;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
@@ -41,6 +46,7 @@ import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.LinkedHashMap;
@@ -85,6 +91,17 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
     @Autowired
     private AlarmAuditMapper alarmAuditMapper;
 
+    @Autowired
+    private EquipmentStatusMapper equipmentStatusMapper;
+
+    @Autowired
+    private EquipmentTypeMapper equipmentTypeMapper;
+
+    /** 报警展示状态:1-待审核 2-已转工单 3-已解除 */
+    private static final int DISPLAY_PENDING_AUDIT = 1;
+    private static final int DISPLAY_TRANSFERRED = 2;
+    private static final int DISPLAY_RELEASED = 3;
+
     // ------------------------------------------------------------------
     // 供水设备范围解析
     // ------------------------------------------------------------------
@@ -107,6 +124,18 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
                 .collect(Collectors.toMap(EquipmentBase::getEquipmentCode, Function.identity(), (a, b) -> a));
     }
 
+    /**
+     * 指定设备编码 -> 设备信息映射:只取台账当前涉及的设备,避免按报警量放大全表查询。
+     */
+    private Map<String, EquipmentBase> loadEquipmentCodeMap(Collection<String> deviceCodes) {
+        if (CollUtil.isEmpty(deviceCodes)) {
+            return Collections.emptyMap();
+        }
+        return equipmentBaseMapper.selectList(new LambdaQueryWrapper<EquipmentBase>()
+                        .in(EquipmentBase::getEquipmentCode, deviceCodes)).stream()
+                .collect(Collectors.toMap(EquipmentBase::getEquipmentCode, Function.identity(), (a, b) -> a));
+    }
+
     /**
      * 设备编码 -> 工单映射(同一报警派发多个工单时取最新)
      */
@@ -232,6 +261,309 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
         return vos.isEmpty() ? null : vos.get(0);
     }
 
+    // ------------------------------------------------------------------
+    // 报警管理只读台账(供水设备 + 后端推导展示状态)
+    // ------------------------------------------------------------------
+
+    @Override
+    public Page<WaterAlarmOutDTO> getAlarmLedgerPage(long pageNum, long pageSize,
+                                                     String deviceCode, String warningType,
+                                                     Integer alarmLevel, Integer displayStatus,
+                                                     Integer deviceAlarmStatus,
+                                                     LocalDateTime startTime, LocalDateTime endTime,
+                                                     String keyword) {
+        List<WaterAlarmOutDTO> rows = getAlarmLedgerExport(deviceCode, warningType, alarmLevel,
+                displayStatus, deviceAlarmStatus, startTime, endTime, keyword);
+        Page<WaterAlarmOutDTO> page = new Page<>(pageNum, pageSize, rows.size());
+        if (pageNum < 1 || pageSize < 1) {
+            page.setRecords(new ArrayList<>());
+            return page;
+        }
+        int from = (int) Math.min((pageNum - 1) * pageSize, rows.size());
+        int to = (int) Math.min(from + pageSize, rows.size());
+        page.setRecords(new ArrayList<>(rows.subList(from, to)));
+        return page;
+    }
+
+    @Override
+    public List<WaterAlarmOutDTO> getAlarmLedgerExport(String deviceCode, String warningType,
+                                                       Integer alarmLevel, Integer displayStatus,
+                                                       Integer deviceAlarmStatus,
+                                                       LocalDateTime startTime, LocalDateTime endTime,
+                                                       String keyword) {
+        Set<String> waterCodes = resolveWaterDeviceCodes();
+        if (waterCodes.isEmpty()) {
+            return new ArrayList<>();
+        }
+        LambdaQueryWrapper<AlarmData> wrapper = new LambdaQueryWrapper<AlarmData>()
+                .in(AlarmData::getDeviceCode, waterCodes)
+                .eq(StrUtil.isNotBlank(deviceCode), AlarmData::getDeviceCode, deviceCode)
+                .like(StrUtil.isNotBlank(warningType), AlarmData::getWarningType, warningType)
+                .eq(alarmLevel != null, AlarmData::getAlarmLevel, alarmLevel)
+                .ge(startTime != null, AlarmData::getAlarmTime, startTime)
+                .le(endTime != null, AlarmData::getAlarmTime, endTime)
+                .and(StrUtil.isNotBlank(keyword), w -> w.like(AlarmData::getDeviceCode, keyword)
+                        .or().like(AlarmData::getWarningType, keyword)
+                        .or().like(AlarmData::getWarningCode, keyword))
+                .orderByDesc(AlarmData::getAlarmTime);
+        List<AlarmData> alarms = alarmDataService.list(wrapper);
+        List<WaterAlarmOutDTO> rows = buildLedgerVoList(alarms);
+        return rows.stream()
+                .filter(row -> displayStatus == null || displayStatus.equals(row.getDisplayStatus()))
+                .filter(row -> deviceAlarmStatus == null || deviceAlarmStatus.equals(row.getDeviceAlarmStatus()))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 报警记录 -> 台账出参:补充设备报警状态与后端推导的报警展示状态。
+     *
+     * <p>展示状态口径:存在关联工单即“已转工单”;否则报警记录已处理或审核结论为误报即“已解除”;
+     * 其余为“待审核”。展示状态不替代设备报警状态。</p>
+     */
+    private List<WaterAlarmOutDTO> buildLedgerVoList(List<AlarmData> alarms) {
+        if (CollUtil.isEmpty(alarms)) {
+            return new ArrayList<>();
+        }
+        List<String> alarmIds = alarms.stream().map(AlarmData::getId).collect(Collectors.toList());
+        Map<String, EquipmentBase> eqMap = loadEquipmentCodeMap(alarms.stream()
+                .map(AlarmData::getDeviceCode)
+                .filter(StrUtil::isNotBlank)
+                .collect(Collectors.toSet()));
+        Map<String, WorkOrder> orderMap = loadWorkOrderMapByAlarmIds(alarmIds);
+        Map<String, AlarmAudit> latestAuditMap = loadLatestAuditMap(alarmIds);
+        Map<String, EquipmentStatus> statusMap = loadStatusMap(eqMap.values());
+        List<WaterAlarmOutDTO> vos = new ArrayList<>();
+        for (AlarmData alarm : alarms) {
+            WaterAlarmOutDTO vo = BeanUtil.copyProperties(alarm, WaterAlarmOutDTO.class);
+            EquipmentBase eq = alarm.getDeviceCode() == null ? null : eqMap.get(alarm.getDeviceCode());
+            if (eq != null) {
+                vo.setEquipmentId(eq.getEquipmentId());
+                if (StrUtil.isBlank(vo.getDeviceName())) {
+                    vo.setDeviceName(eq.getEquipmentName());
+                }
+                vo.setEquipmentLocation(eq.getEquipmentLocation());
+                if (eq.getLongitude() != null) {
+                    vo.setLongitude(eq.getLongitude().toPlainString());
+                }
+                if (eq.getLatitude() != null) {
+                    vo.setLatitude(eq.getLatitude().toPlainString());
+                }
+            }
+            vo.setAlarmLevelText(formatAlarmLevel(alarm.getAlarmLevel()));
+            vo.setAlarmStatusText(alarm.getAlarmStatus() != null && alarm.getAlarmStatus() == 0 ? "待处理" : "已处理");
+            WorkOrder order = alarm.getId() == null ? null : orderMap.get(alarm.getId());
+            if (order != null) {
+                vo.setOrderNo(order.getOrderNo());
+                vo.setOrderStatus(order.getOrderStatus());
+            }
+            int deviceAlarm = resolveDeviceAlarmStatus(alarm, eq, statusMap, latestAuditMap.get(alarm.getId()));
+            vo.setDeviceAlarmStatus(deviceAlarm);
+            vo.setDeviceAlarmStatusText(deviceAlarm == 1 ? "报警中" : "正常");
+            int display = resolveDisplayStatus(alarm, order, latestAuditMap.get(alarm.getId()));
+            vo.setDisplayStatus(display);
+            vo.setDisplayStatusText(displayStatusText(display));
+            vos.add(vo);
+        }
+        return vos;
+    }
+
+    /**
+     * 设备报警状态:以设备状态表为准;无状态记录时回退到报警记录是否未处理,
+     * 但经审核判定为误报解除的报警不再视为设备报警中。
+     */
+    private int resolveDeviceAlarmStatus(AlarmData alarm, EquipmentBase eq,
+                                         Map<String, EquipmentStatus> statusMap, AlarmAudit audit) {
+        if (eq != null && eq.getEquipmentId() != null) {
+            EquipmentStatus status = statusMap.get(eq.getEquipmentId());
+            if (status != null && status.getAlarmStatus() != null) {
+                return status.getAlarmStatus();
+            }
+        }
+        if (audit != null && (Integer.valueOf(2).equals(audit.getAuditResult())
+                || Integer.valueOf(2).equals(audit.getAuditAction()))) {
+            return 0;
+        }
+        return alarm.getAlarmStatus() != null && alarm.getAlarmStatus() == 0 ? 1 : 0;
+    }
+
+    /**
+     * 报警展示状态推导:
+     * 关联工单未取消 -> 已转工单;否则报警记录已处理或最新审核为误报 -> 已解除;其余 -> 待审核。
+     * 工单已取消代表处置已撤销,按报警事实自身归档为已解除,展示口径由后端统一。
+     */
+    private int resolveDisplayStatus(AlarmData alarm, WorkOrder order, AlarmAudit audit) {
+        boolean orderAlive = order != null && !Integer.valueOf(7).equals(order.getOrderStatus());
+        if (orderAlive) {
+            return DISPLAY_TRANSFERRED;
+        }
+        boolean alarmHandled = alarm.getAlarmStatus() != null && alarm.getAlarmStatus() == 1;
+        boolean falseAlarm = audit != null && Integer.valueOf(2).equals(audit.getAuditResult());
+        return alarmHandled || falseAlarm ? DISPLAY_RELEASED : DISPLAY_PENDING_AUDIT;
+    }
+
+    private String displayStatusText(int displayStatus) {
+        switch (displayStatus) {
+            case DISPLAY_TRANSFERRED:
+                return "已转工单";
+            case DISPLAY_RELEASED:
+                return "已解除";
+            default:
+                return "待审核";
+        }
+    }
+
+    /**
+     * 报警ID -> 最新审核记录
+     */
+    private Map<String, AlarmAudit> loadLatestAuditMap(List<String> alarmIds) {
+        if (CollUtil.isEmpty(alarmIds)) {
+            return Collections.emptyMap();
+        }
+        List<AlarmAudit> audits = alarmAuditMapper.selectList(new LambdaQueryWrapper<AlarmAudit>()
+                .in(AlarmAudit::getAlarmId, alarmIds)
+                .orderByDesc(AlarmAudit::getAuditId));
+        return audits.stream()
+                .collect(Collectors.toMap(AlarmAudit::getAlarmId, Function.identity(), (a, b) -> a));
+    }
+
+    private Map<String, EquipmentStatus> loadStatusMap(Collection<EquipmentBase> devices) {
+        List<String> equipmentIds = devices.stream()
+                .map(EquipmentBase::getEquipmentId)
+                .filter(StrUtil::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+        if (equipmentIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return equipmentStatusMapper.selectList(new LambdaQueryWrapper<EquipmentStatus>()
+                        .in(EquipmentStatus::getEquipmentId, equipmentIds)).stream()
+                .collect(Collectors.toMap(EquipmentStatus::getEquipmentId, Function.identity(), (a, b) -> a));
+    }
+
+    // ------------------------------------------------------------------
+    // 报警管理态势统计
+    // ------------------------------------------------------------------
+
+    @Override
+    public AlarmDashboardOutDTO getAlarmDashboard(int days) {
+        int trendDays = days > 0 ? days : 7;
+        AlarmDashboardOutDTO dashboard = new AlarmDashboardOutDTO();
+        Set<String> waterCodes = resolveWaterDeviceCodes();
+        if (waterCodes.isEmpty()) {
+            dashboard.setTrend(emptyTrend(trendDays));
+            return dashboard;
+        }
+
+        // 台账全量口径与列表一致:调用同一推导逻辑,避免统计与列表两套口径漂移
+        List<WaterAlarmOutDTO> rows = buildLedgerVoList(alarmDataService.list(
+                new LambdaQueryWrapper<AlarmData>()
+                        .in(AlarmData::getDeviceCode, waterCodes)
+                        .orderByDesc(AlarmData::getAlarmTime)));
+
+        dashboard.setTotalCount(rows.size());
+        dashboard.setPendingAuditCount(countDisplayStatus(rows, DISPLAY_PENDING_AUDIT));
+        dashboard.setTransferredCount(countDisplayStatus(rows, DISPLAY_TRANSFERRED));
+        dashboard.setReleasedCount(countDisplayStatus(rows, DISPLAY_RELEASED));
+        dashboard.setAlarmingDeviceCount(rows.stream()
+                .filter(row -> Integer.valueOf(1).equals(row.getDeviceAlarmStatus()))
+                .map(WaterAlarmOutDTO::getDeviceCode)
+                .filter(StrUtil::isNotBlank)
+                .distinct()
+                .count());
+
+        dashboard.setTrend(buildTrend(rows, trendDays));
+        dashboard.setWarningTypeRatio(buildRatio(rows, WaterAlarmOutDTO::getWarningType, "未分类"));
+        dashboard.setAlarmLevelRatio(buildRatio(rows,
+                row -> formatAlarmLevel(row.getAlarmLevel()), "未知等级"));
+        dashboard.setDeviceTypeRatio(buildDeviceTypeRatio(rows, loadEquipmentCodeMap(rows.stream()
+                .map(WaterAlarmOutDTO::getDeviceCode)
+                .filter(StrUtil::isNotBlank)
+                .collect(Collectors.toSet()))));
+        return dashboard;
+    }
+
+    private long countDisplayStatus(List<WaterAlarmOutDTO> rows, int displayStatus) {
+        return rows.stream().filter(row -> Integer.valueOf(displayStatus).equals(row.getDisplayStatus())).count();
+    }
+
+    /**
+     * 报警趋势:按自然日分桶,区间内无报警的日期补 0,保证图表横轴连续。
+     */
+    private List<AlarmDashboardOutDTO.TrendItem> buildTrend(List<WaterAlarmOutDTO> rows, int days) {
+        LocalDate today = LocalDate.now();
+        LocalDate from = today.minusDays(days - 1L);
+        Map<String, AlarmDashboardOutDTO.TrendItem> byDate = new LinkedHashMap<>();
+        for (int offset = 0; offset < days; offset++) {
+            AlarmDashboardOutDTO.TrendItem item = new AlarmDashboardOutDTO.TrendItem();
+            item.setDate(from.plusDays(offset).toString());
+            byDate.put(item.getDate(), item);
+        }
+        for (WaterAlarmOutDTO row : rows) {
+            if (row.getAlarmTime() == null) {
+                continue;
+            }
+            AlarmDashboardOutDTO.TrendItem item = byDate.get(row.getAlarmTime().toLocalDate().toString());
+            if (item == null) {
+                continue;
+            }
+            item.setCount(item.getCount() + 1);
+            if (row.getAlarmLevel() != null && row.getAlarmLevel() <= 2) {
+                item.setHighLevelCount(item.getHighLevelCount() + 1);
+            }
+        }
+        return new ArrayList<>(byDate.values());
+    }
+
+    private List<AlarmDashboardOutDTO.TrendItem> emptyTrend(int days) {
+        List<AlarmDashboardOutDTO.TrendItem> empty = new ArrayList<>();
+        LocalDate from = LocalDate.now().minusDays(days - 1L);
+        for (int offset = 0; offset < days; offset++) {
+            AlarmDashboardOutDTO.TrendItem item = new AlarmDashboardOutDTO.TrendItem();
+            item.setDate(from.plusDays(offset).toString());
+            empty.add(item);
+        }
+        return empty;
+    }
+
+    private List<AlarmDashboardOutDTO.RatioItem> buildRatio(List<WaterAlarmOutDTO> rows,
+                                                            Function<WaterAlarmOutDTO, String> classify,
+                                                            String fallbackName) {
+        Map<String, Long> grouped = new LinkedHashMap<>();
+        for (WaterAlarmOutDTO row : rows) {
+            String name = classify.apply(row);
+            String key = StrUtil.isBlank(name) ? fallbackName : name;
+            grouped.merge(key, 1L, Long::sum);
+        }
+        return grouped.entrySet().stream()
+                .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
+                .map(entry -> {
+                    AlarmDashboardOutDTO.RatioItem item = new AlarmDashboardOutDTO.RatioItem();
+                    item.setName(entry.getKey());
+                    item.setCount(entry.getValue());
+                    return item;
+                })
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 设备分类占比取自报警所属设备在设备类别表中的真实类型名称,未知类型归入“未分类”。
+     */
+    private List<AlarmDashboardOutDTO.RatioItem> buildDeviceTypeRatio(List<WaterAlarmOutDTO> rows,
+                                                                      Map<String, EquipmentBase> eqMap) {
+        List<String> typeIds = eqMap.values().stream()
+                .map(EquipmentBase::getEquipmentTypeId)
+                .filter(StrUtil::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+        Map<String, String> typeNames = typeIds.isEmpty() ? Collections.emptyMap()
+                : equipmentTypeMapper.selectBatchIds(typeIds).stream()
+                .collect(Collectors.toMap(EquipmentType::getId, EquipmentType::getTypeName, (a, b) -> a));
+        return buildRatio(rows, row -> {
+            EquipmentBase eq = row.getDeviceCode() == null ? null : eqMap.get(row.getDeviceCode());
+            return eq == null ? null : typeNames.get(eq.getEquipmentTypeId());
+        }, "未分类");
+    }
+
     /**
      * 报警记录 -> 出参(填充设备名称/位置/经纬度及关联工单)
      */

+ 87 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterAlarmLedgerExportDTO.java

@@ -0,0 +1,87 @@
+package com.zksy.WaterSupply.export;
+
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
+import com.zksy.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 报警管理只读台账导出行(T02 / Issue #45)。
+ *
+ * <p>导出列与列表同口径:报警等级、设备报警状态、报警展示状态与关联工单编号,
+ * 展示状态使用后端推导结果,不暴露底层报警记录状态字段。</p>
+ */
+@Data
+public class WaterAlarmLedgerExportDTO {
+
+    @Excel(name = "报警编码")
+    private String warningCode;
+
+    @Excel(name = "设备编码")
+    private String deviceCode;
+
+    @Excel(name = "设备名称")
+    private String deviceName;
+
+    @Excel(name = "报警类型")
+    private String warningType;
+
+    @Excel(name = "报警等级")
+    private String alarmLevelText;
+
+    @Excel(name = "设备报警状态")
+    private String deviceAlarmStatusText;
+
+    @Excel(name = "报警展示状态")
+    private String displayStatusText;
+
+    @Excel(name = "关联工单号")
+    private String orderNo;
+
+    @Excel(name = "实际值")
+    private BigDecimal actualValue;
+
+    @Excel(name = "阈值下限")
+    private BigDecimal minValue;
+
+    @Excel(name = "阈值上限")
+    private BigDecimal maxValue;
+
+    @Excel(name = "安装位置")
+    private String equipmentLocation;
+
+    @Excel(name = "报警时间", dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime alarmTime;
+
+    @Excel(name = "处理时间", dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime handleTime;
+
+    @Excel(name = "处理人")
+    private String handleUser;
+
+    @Excel(name = "处理备注")
+    private String handleRemark;
+
+    public static WaterAlarmLedgerExportDTO from(WaterAlarmOutDTO value) {
+        WaterAlarmLedgerExportDTO dto = new WaterAlarmLedgerExportDTO();
+        dto.warningCode = value.getWarningCode();
+        dto.deviceCode = value.getDeviceCode();
+        dto.deviceName = value.getDeviceName();
+        dto.warningType = value.getWarningType();
+        dto.alarmLevelText = value.getAlarmLevelText();
+        dto.deviceAlarmStatusText = value.getDeviceAlarmStatusText();
+        dto.displayStatusText = value.getDisplayStatusText();
+        dto.orderNo = value.getOrderNo();
+        dto.actualValue = value.getActualValue();
+        dto.minValue = value.getMinValue();
+        dto.maxValue = value.getMaxValue();
+        dto.equipmentLocation = value.getEquipmentLocation();
+        dto.alarmTime = value.getAlarmTime();
+        dto.handleTime = value.getHandleTime();
+        dto.handleUser = value.getHandleUser();
+        dto.handleRemark = value.getHandleRemark();
+        return dto;
+    }
+}

+ 207 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmDashboardServiceTest.java

@@ -0,0 +1,207 @@
+package com.zksy.WaterSupply.MonitorAlarm;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.zksy.WaterSupply.MonitorAlarm.domain.AlarmAudit;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.mapper.AlarmAuditMapper;
+import com.zksy.WaterSupply.MonitorAlarm.service.impl.WaterSupplyAlarmServiceImpl;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentStatusMapper;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.mapper.EquipmentTypeMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.mapper.WorkOrderMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.when;
+
+/**
+ * 报警管理态势统计口径测试(T02 / Issue #45)。
+ *
+ * <p>被测公共 seam:{@link com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService#getAlarmDashboard(int)}。
+ * 期望值全部来自手算的独立样本,不重算实现逻辑。</p>
+ */
+class WaterSupplyAlarmDashboardServiceTest {
+
+    @BeforeAll
+    static void initLambdaCache() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, AlarmData.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentBase.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentStatus.class);
+        TableInfoHelper.initTableInfo(assistant, WorkOrder.class);
+        TableInfoHelper.initTableInfo(assistant, AlarmAudit.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentType.class);
+    }
+
+    private WaterSupplyAlarmServiceImpl service;
+    private AlarmDataService alarmDataService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentBaseMapper equipmentBaseMapper;
+    private EquipmentStatusMapper equipmentStatusMapper;
+    private EquipmentTypeMapper equipmentTypeMapper;
+    private WorkOrderMapper workOrderMapper;
+    private AlarmAuditMapper alarmAuditMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new WaterSupplyAlarmServiceImpl();
+        alarmDataService = mock(AlarmDataService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
+        equipmentStatusMapper = mock(EquipmentStatusMapper.class);
+        equipmentTypeMapper = mock(EquipmentTypeMapper.class);
+        workOrderMapper = mock(WorkOrderMapper.class);
+        alarmAuditMapper = mock(AlarmAuditMapper.class);
+        ReflectionTestUtils.setField(service, "alarmDataService", alarmDataService);
+        ReflectionTestUtils.setField(service, "equipmentBaseService", equipmentBaseService);
+        ReflectionTestUtils.setField(service, "equipmentBaseMapper", equipmentBaseMapper);
+        ReflectionTestUtils.setField(service, "equipmentStatusMapper", equipmentStatusMapper);
+        ReflectionTestUtils.setField(service, "equipmentTypeMapper", equipmentTypeMapper);
+        ReflectionTestUtils.setField(service, "workOrderMapper", workOrderMapper);
+        ReflectionTestUtils.setField(service, "alarmAuditMapper", alarmAuditMapper);
+        ReflectionTestUtils.setField(service, "waterTopLevelTypeName", "供水");
+
+        EquipmentBase pressure = device("EQ-P-1", "CN-GS-202609020001", "TYPE-P");
+        EquipmentBase flow = device("EQ-F-1", "CN-GS-202609020002", "TYPE-F");
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(pressure, flow));
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Arrays.asList(pressure, flow));
+        when(equipmentTypeMapper.selectBatchIds(any())).thenReturn(Arrays.asList(
+                type("TYPE-P", "压力监测"), type("TYPE-F", "流量监测")));
+        when(alarmAuditMapper.selectList(any())).thenReturn(Collections.emptyList());
+    }
+
+    private static EquipmentBase device(String id, String code, String typeId) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName("监测点");
+        device.setEquipmentTypeId(typeId);
+        device.setLongitude(new BigDecimal("113.28"));
+        device.setLatitude(new BigDecimal("23.12"));
+        return device;
+    }
+
+    private static EquipmentType type(String id, String name) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        return type;
+    }
+
+    private static AlarmData alarm(String id, String deviceCode, String warningType, Integer alarmStatus,
+                                    Integer alarmLevel, LocalDateTime alarmTime) {
+        AlarmData alarm = new AlarmData();
+        alarm.setId(id);
+        alarm.setDeviceCode(deviceCode);
+        alarm.setWarningType(warningType);
+        alarm.setAlarmStatus(alarmStatus);
+        alarm.setAlarmLevel(alarmLevel);
+        alarm.setAlarmTime(alarmTime);
+        return alarm;
+    }
+
+    @Test
+    void dashboardCountsDisplayStatusTrendAndRatios() {
+        LocalDateTime today = LocalDate.now().atTime(9, 0);
+        List<AlarmData> alarms = new ArrayList<>(Arrays.asList(
+                // 今日:压力预警 1 级未审核(待审核)+ 同设备已转工单
+                alarm("a1", "CN-GS-202609020001", "压力预警", 0, 1, today),
+                alarm("a2", "CN-GS-202609020001", "压力预警", 1, 2, today.plusHours(1)),
+                // 昨日:流量预警 3 级,误报解除(已解除)
+                alarm("a3", "CN-GS-202609020002", "流量预警", 0, 3, today.minusDays(1))));
+        when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(alarms);
+
+        WorkOrder order = new WorkOrder();
+        order.setAlarmId("a2");
+        order.setOrderNo("WO-T02-002");
+        order.setOrderStatus(3);
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.singletonList(order));
+
+        AlarmAudit released = new AlarmAudit();
+        released.setAlarmId("a3");
+        released.setAuditAction(2);
+        released.setAuditResult(2);
+        when(alarmAuditMapper.selectList(any())).thenReturn(Collections.singletonList(released));
+
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Collections.singletonList(
+                status("EQ-P-1", 1, 1, 1)));
+
+        AlarmDashboardOutDTO dashboard = service.getAlarmDashboard(7);
+
+        // 手算期望:总量 3;待审核 a1;已转工单 a2;已解除 a3
+        assertEquals(3, dashboard.getTotalCount());
+        assertEquals(1, dashboard.getPendingAuditCount());
+        assertEquals(1, dashboard.getTransferredCount());
+        assertEquals(1, dashboard.getReleasedCount());
+        assertEquals(1, dashboard.getAlarmingDeviceCount());
+
+        // 趋势覆盖 7 天,含无报警日
+        assertEquals(7, dashboard.getTrend().size());
+        assertEquals(2, countOn(dashboard, LocalDate.now().toString()));
+        assertEquals(1, countOn(dashboard, LocalDate.now().minusDays(1).toString()));
+        // 今日两条报警分别为 1 级与 2 级,均属于“高等级(1-2 级)”口径
+        assertEquals(2, highLevelCountOn(dashboard, LocalDate.now().toString()));
+
+        // 报警分类占比:压力预警 2 条,流量预警 1 条
+        assertEquals(2, ratioOf(dashboard.getWarningTypeRatio(), "压力预警"));
+        assertEquals(1, ratioOf(dashboard.getWarningTypeRatio(), "流量预警"));
+        // 设备分类占比来自报警设备实际类型:压力监测 2 条,流量监测 1 条
+        assertEquals(2, ratioOf(dashboard.getDeviceTypeRatio(), "压力监测"));
+        assertEquals(1, ratioOf(dashboard.getDeviceTypeRatio(), "流量监测"));
+        // 报警等级占比:1 级 1 条、2 级 1 条、3 级 1 条
+        assertEquals(1, ratioOf(dashboard.getAlarmLevelRatio(), "一级报警"));
+        assertEquals(1, ratioOf(dashboard.getAlarmLevelRatio(), "二级报警"));
+        assertEquals(1, ratioOf(dashboard.getAlarmLevelRatio(), "三级报警"));
+        assertTrue(dashboard.getTrend().stream().allMatch(item -> item.getDate() != null),
+                "趋势项应带日期");
+    }
+
+    private static EquipmentStatus status(String equipmentId, Integer alarmStatus, Integer currentStatus,
+                                          Integer onlineStatus) {
+        EquipmentStatus status = new EquipmentStatus();
+        status.setEquipmentId(equipmentId);
+        status.setAlarmStatus(alarmStatus);
+        status.setCurrentStatus(currentStatus);
+        status.setOnlineStatus(onlineStatus);
+        return status;
+    }
+
+    private static long countOn(AlarmDashboardOutDTO dashboard, String date) {
+        return dashboard.getTrend().stream().filter(item -> date.equals(item.getDate()))
+                .mapToLong(AlarmDashboardOutDTO.TrendItem::getCount).sum();
+    }
+
+    private static long highLevelCountOn(AlarmDashboardOutDTO dashboard, String date) {
+        return dashboard.getTrend().stream().filter(item -> date.equals(item.getDate()))
+                .mapToLong(AlarmDashboardOutDTO.TrendItem::getHighLevelCount).sum();
+    }
+
+    private static long ratioOf(List<AlarmDashboardOutDTO.RatioItem> items, String name) {
+        return items.stream().filter(item -> name.equals(item.getName()))
+                .mapToLong(AlarmDashboardOutDTO.RatioItem::getCount).sum();
+    }
+}

+ 211 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmLedgerServiceTest.java

@@ -0,0 +1,211 @@
+package com.zksy.WaterSupply.MonitorAlarm;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.MonitorAlarm.domain.AlarmAudit;
+import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
+import com.zksy.WaterSupply.MonitorAlarm.mapper.AlarmAuditMapper;
+import com.zksy.WaterSupply.MonitorAlarm.service.impl.WaterSupplyAlarmServiceImpl;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentStatusMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.mapper.WorkOrderMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 报警管理只读台账推导口径测试(T02 / Issue #45)。
+ *
+ * <p>被测公共 seam:{@link com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService#getAlarmLedgerPage}。
+ * 验证报警展示状态、设备报警状态与供水数据边界三项外部可见行为。</p>
+ */
+class WaterSupplyAlarmLedgerServiceTest {
+
+    @BeforeAll
+    static void initLambdaCache() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, AlarmData.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentBase.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentStatus.class);
+        TableInfoHelper.initTableInfo(assistant, WorkOrder.class);
+        TableInfoHelper.initTableInfo(assistant, AlarmAudit.class);
+    }
+
+    private WaterSupplyAlarmServiceImpl service;
+    private AlarmDataService alarmDataService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentBaseMapper equipmentBaseMapper;
+    private EquipmentStatusMapper equipmentStatusMapper;
+    private WorkOrderMapper workOrderMapper;
+    private AlarmAuditMapper alarmAuditMapper;
+
+    private EquipmentBase pressureDevice;
+    private EquipmentBase flowDevice;
+
+    @BeforeEach
+    void setUp() {
+        service = new WaterSupplyAlarmServiceImpl();
+        alarmDataService = mock(AlarmDataService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
+        equipmentStatusMapper = mock(EquipmentStatusMapper.class);
+        workOrderMapper = mock(WorkOrderMapper.class);
+        alarmAuditMapper = mock(AlarmAuditMapper.class);
+        ReflectionTestUtils.setField(service, "alarmDataService", alarmDataService);
+        ReflectionTestUtils.setField(service, "equipmentBaseService", equipmentBaseService);
+        ReflectionTestUtils.setField(service, "equipmentBaseMapper", equipmentBaseMapper);
+        ReflectionTestUtils.setField(service, "equipmentStatusMapper", equipmentStatusMapper);
+        ReflectionTestUtils.setField(service, "workOrderMapper", workOrderMapper);
+        ReflectionTestUtils.setField(service, "alarmAuditMapper", alarmAuditMapper);
+        ReflectionTestUtils.setField(service, "waterTopLevelTypeName", "供水");
+
+        pressureDevice = device("EQ-P-1", "CN-GS-202609020001", "城南压力监测点");
+        flowDevice = device("EQ-F-1", "CN-GS-202609020002", "城北流量监测点");
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(pressureDevice, flowDevice));
+        when(equipmentBaseMapper.selectList(any())).thenReturn(Arrays.asList(pressureDevice, flowDevice));
+    }
+
+    private static EquipmentBase device(String id, String code, String name) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(name);
+        device.setEquipmentLocation("测试大道1号");
+        device.setLongitude(new BigDecimal("113.28"));
+        device.setLatitude(new BigDecimal("23.12"));
+        return device;
+    }
+
+    private static AlarmData alarm(String id, String deviceCode, String warningType, Integer alarmStatus,
+                                   Integer alarmLevel) {
+        AlarmData alarm = new AlarmData();
+        alarm.setId(id);
+        alarm.setDeviceCode(deviceCode);
+        alarm.setWarningType(warningType);
+        alarm.setWarningCode("WARN-" + warningType);
+        alarm.setAlarmStatus(alarmStatus);
+        alarm.setAlarmLevel(alarmLevel);
+        alarm.setAlarmTime(LocalDateTime.of(2026, 9, 14, 10, 0));
+        return alarm;
+    }
+
+    private void stubLedger(AlarmData... alarms) {
+        when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Arrays.asList(alarms));
+    }
+
+    @SuppressWarnings("unchecked")
+    private Page<WaterAlarmOutDTO> ledgerPage() {
+        return service.getAlarmLedgerPage(1, 10, null, null, null, null, null, null, null, null);
+    }
+
+    @Test
+    void pendingAlarmWithoutAuditOrOrderShowsPendingAudit() {
+        stubLedger(alarm("alarm-1", "CN-GS-202609020001", "压力预警", 0, 1));
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(alarmAuditMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Collections.singletonList(
+                status("EQ-P-1", 1, 1, 0)));
+
+        List<WaterAlarmOutDTO> rows = ledgerPage().getRecords();
+
+        assertEquals(1, rows.size());
+        WaterAlarmOutDTO row = rows.get(0);
+        assertEquals(1, row.getDisplayStatus());
+        assertEquals("待审核", row.getDisplayStatusText());
+        assertEquals(1, row.getDeviceAlarmStatus());
+        assertEquals("报警中", row.getDeviceAlarmStatusText());
+    }
+
+    @Test
+    void alarmWithLiveWorkOrderShowsTransferred() {
+        stubLedger(alarm("alarm-1", "CN-GS-202609020001", "压力预警", 1, 1));
+        WorkOrder order = new WorkOrder();
+        order.setAlarmId("alarm-1");
+        order.setOrderNo("WO202606101703482640");
+        order.setOrderStatus(3);
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.singletonList(order));
+        when(alarmAuditMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Collections.singletonList(
+                status("EQ-P-1", 1, 1, 0)));
+
+        WaterAlarmOutDTO row = ledgerPage().getRecords().get(0);
+
+        assertEquals(2, row.getDisplayStatus());
+        assertEquals("已转工单", row.getDisplayStatusText());
+        assertEquals("WO202606101703482640", row.getOrderNo());
+    }
+
+    @Test
+    void falseAlarmAuditWithoutOrderShowsReleased() {
+        stubLedger(alarm("alarm-1", "CN-GS-202609020002", "流量预警", 0, 2));
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.emptyList());
+        AlarmAudit audit = new AlarmAudit();
+        audit.setAlarmId("alarm-1");
+        audit.setAuditAction(2);
+        audit.setAuditResult(2);
+        when(alarmAuditMapper.selectList(any())).thenReturn(Collections.singletonList(audit));
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Collections.singletonList(
+                status("EQ-F-1", 0, 1, 1)));
+
+        WaterAlarmOutDTO row = ledgerPage().getRecords().get(0);
+
+        assertEquals(3, row.getDisplayStatus());
+        assertEquals("已解除", row.getDisplayStatusText());
+        // 报警记录自身仍未处理,但设备已不在报警中:展示状态与设备报警状态各自独立
+        assertEquals(0, row.getDeviceAlarmStatus());
+        assertEquals("正常", row.getDeviceAlarmStatusText());
+    }
+
+    @Test
+    void ledgerQueryIsScopedToWaterDevices() {
+        stubLedger();
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(alarmAuditMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        ledgerPage();
+
+        ArgumentCaptor<AbstractWrapper<AlarmData, ?, ?>> captor = ArgumentCaptor.forClass(AbstractWrapper.class);
+        verify(alarmDataService).list(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        List<Object> values = Arrays.asList(captor.getValue().getParamNameValuePairs().values().toArray());
+        // 只允许供水设备编码进入 IN 条件,不得混入排水/燃气/窨井设备
+        assertEquals(true, sql.contains("device_code"));
+        assertEquals(true, values.contains("CN-GS-202609020001"));
+        assertEquals(true, values.contains("CN-GS-202609020002"));
+        assertEquals(false, values.contains("YJ-EE-20260702"));
+    }
+
+    private static EquipmentStatus status(String equipmentId, Integer alarmStatus, Integer currentStatus,
+                                          Integer onlineStatus) {
+        EquipmentStatus status = new EquipmentStatus();
+        status.setEquipmentId(equipmentId);
+        status.setAlarmStatus(alarmStatus);
+        status.setCurrentStatus(currentStatus);
+        status.setOnlineStatus(onlineStatus);
+        return status;
+    }
+}