Parcourir la source

feat(waterSupply): 设备异常管理只读看板统计与筛选契约 (#44)

- 新增 GET /waterSupply/device/abnormal/stats 聚合统计(异常设备/报警中/维修中/离线设备)

- 异常清单与导出支持 abnormalType、region 筛选,异常口径纳入离线设备

- 关联工单状态口径统一到权威枚举 0-7,并补契约测试
Kazerin il y a 1 mois
Parent
commit
630ef1010d

+ 15 - 3
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/DeviceAbnormal/DeviceAbnormalController.java

@@ -49,13 +49,23 @@ public class DeviceAbnormalController {
             @ApiParam("设备编码") @RequestParam(required = false) String equipmentCode,
             @ApiParam("设备类型ID") @RequestParam(required = false) String equipmentTypeId,
             @ApiParam("告警等级 1-4") @RequestParam(required = false) Integer alertLevel,
+            @ApiParam("异常类型 alarm-报警 repair-维修 offline-离线") @RequestParam(required = false) String abnormalType,
+            @ApiParam("区域") @RequestParam(required = false) String region,
             @ApiParam("仅返回没有未结束故障维修工单的可派单设备")
             @RequestParam(required = false, defaultValue = "false") Boolean dispatchableOnly) {
         Page<AbnormalDeviceVO> page = waterSupplyDeviceService.getAbnormalDevicePage(
-                pageNum, pageSize, equipmentName, equipmentCode, equipmentTypeId, alertLevel, dispatchableOnly);
+                pageNum, pageSize, equipmentName, equipmentCode, equipmentTypeId, alertLevel,
+                abnormalType, region, dispatchableOnly);
         return AjaxResult.success(page);
     }
 
+    @GetMapping("/stats")
+    @ApiOperation("供水设备异常统计")
+    @PreAuthorize("@ss.hasPermi('waterSupply:device:abnormal:list')")
+    public AjaxResult getAbnormalDeviceStats() {
+        return AjaxResult.success(waterSupplyDeviceService.getAbnormalDeviceStats());
+    }
+
     @GetMapping("/gisPoints")
     @ApiOperation("获取异常设备GIS点位(地图高亮用)")
     @PreAuthorize("@ss.hasPermi('waterSupply:device:abnormal:list')")
@@ -107,9 +117,11 @@ public class DeviceAbnormalController {
             @ApiParam("设备名称") @RequestParam(required = false) String equipmentName,
             @ApiParam("设备编码") @RequestParam(required = false) String equipmentCode,
             @ApiParam("设备类型ID") @RequestParam(required = false) String equipmentTypeId,
-            @ApiParam("告警等级") @RequestParam(required = false) Integer alertLevel) {
+            @ApiParam("告警等级") @RequestParam(required = false) Integer alertLevel,
+            @ApiParam("异常类型 alarm-报警 repair-维修 offline-离线") @RequestParam(required = false) String abnormalType,
+            @ApiParam("区域") @RequestParam(required = false) String region) {
         List<WaterDeviceAbnormalExportDTO> rows = waterSupplyDeviceService.getAbnormalDeviceExport(
-                        equipmentName, equipmentCode, equipmentTypeId, alertLevel)
+                        equipmentName, equipmentCode, equipmentTypeId, alertLevel, abnormalType, region)
                 .stream()
                 .map(WaterDeviceAbnormalExportDTO::from)
                 .collect(Collectors.toList());

+ 42 - 1
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/DeviceAbnormal/DeviceAbnormalControllerTest.java

@@ -1,7 +1,9 @@
 package com.zksy.web.controller.WaterSupply.DeviceAbnormal;
 
 import com.zksy.base.domain.vo.AbnormalDeviceVO;
+import com.zksy.base.domain.vo.DeviceAbnormalStatsVO;
 import com.zksy.base.service.WaterSupplyDeviceService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.vo.WorkOrderVO;
 import com.zksy.base.domain.vo.MaintainOrderVO;
@@ -29,6 +31,7 @@ import static org.mockito.Mockito.when;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@@ -60,7 +63,7 @@ class DeviceAbnormalControllerTest {
         row.setEquipmentId("water-pressure-1");
         row.setEquipmentName("供水压力监测点");
         row.setAbnormalReason("供水压力超限");
-        when(deviceService.getAbnormalDeviceExport("供水", null, null, 1))
+        when(deviceService.getAbnormalDeviceExport("供水", null, null, 1, null, null))
                 .thenReturn(Collections.singletonList(row));
 
         mockMvc.perform(post("/waterSupply/device/abnormal/export")
@@ -73,6 +76,44 @@ class DeviceAbnormalControllerTest {
                         result.getResponse().getContentAsByteArray().length > 0));
     }
 
+    @Test
+    void abnormalStatsReturnsAggregatedCounts() throws Exception {
+        DeviceAbnormalStatsVO stats = new DeviceAbnormalStatsVO();
+        stats.setTotal(3);
+        stats.setAlarm(1);
+        stats.setRepair(1);
+        stats.setOffline(1);
+        when(deviceService.getAbnormalDeviceStats()).thenReturn(stats);
+
+        mockMvc.perform(get("/waterSupply/device/abnormal/stats"))
+                .andExpect(status().isOk())
+                .andExpect(result -> {
+                    String body = result.getResponse().getContentAsString();
+                    assertTrue(body.contains("\"total\":3"));
+                    assertTrue(body.contains("\"alarm\":1"));
+                    assertTrue(body.contains("\"repair\":1"));
+                    assertTrue(body.contains("\"offline\":1"));
+                });
+    }
+
+    @Test
+    void abnormalPagePassesAbnormalTypeAndRegionFilters() throws Exception {
+        Page<AbnormalDeviceVO> page = new Page<>(1, 10, 0);
+        page.setRecords(Collections.emptyList());
+        when(deviceService.getAbnormalDevicePage(
+                1, 10, null, null, null, null, "repair", "城北", false)).thenReturn(page);
+
+        mockMvc.perform(get("/waterSupply/device/abnormal/page")
+                        .param("pageNum", "1")
+                        .param("pageSize", "10")
+                        .param("abnormalType", "repair")
+                        .param("region", "城北"))
+                .andExpect(status().isOk());
+
+        verify(deviceService).getAbnormalDevicePage(
+                1, 10, null, null, null, null, "repair", "城北", false);
+    }
+
     @Test
     void abnormalOrderReturnsUnifiedWorkOrderSummary() throws Exception {
         WorkOrder order = new WorkOrder();

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterDeviceAbnormalExportDTO.java

@@ -39,7 +39,7 @@ public class WaterDeviceAbnormalExportDTO {
     @Excel(name = "关联工单号")
     private String relatedWorkOrderNo;
 
-    @Excel(name = "工单状态", readConverterExp = "1=待派单,2=待接单,3=已接单,4=处理中,5=待验收,6=已结案,7=已驳回,8=延期审核,9=已延期")
+    @Excel(name = "工单状态", readConverterExp = "0=待派单,1=待接单,2=已接单,3=处理中,4=待验收,5=已结案,6=延期中,7=已取消")
     private Integer relatedWorkOrderStatus;
 
     @Excel(name = "接单责任人")

+ 4 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/AbnormalDeviceVO.java

@@ -20,6 +20,9 @@ public class AbnormalDeviceVO {
     @ApiModelProperty("设备名称")
     private String equipmentName;
 
+    @ApiModelProperty("所属区域")
+    private String district;
+
     @ApiModelProperty("设备类型ID")
     private String equipmentTypeId;
 
@@ -62,7 +65,7 @@ public class AbnormalDeviceVO {
     @ApiModelProperty("关联统一工单号")
     private String relatedWorkOrderNo;
 
-    @ApiModelProperty("关联统一工单状态:1-待派单 2-待接单 3-已接单 4-处理中 5-待验收 6-已结案 7-已驳回 8-延期审核 9-已延期")
+    @ApiModelProperty("关联统一工单状态:0-待派单 1-待接单 2-已接单 3-处理中 4-待验收 5-已结案 6-延期中 7-已取消")
     private Integer relatedWorkOrderStatus;
 
     @ApiModelProperty("关联统一工单运维人员")

+ 22 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/DeviceAbnormalStatsVO.java

@@ -0,0 +1,22 @@
+package com.zksy.base.domain.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("供水设备异常统计VO")
+public class DeviceAbnormalStatsVO {
+
+    @ApiModelProperty("异常设备总数")
+    private Integer total;
+
+    @ApiModelProperty("报警中设备数")
+    private Integer alarm;
+
+    @ApiModelProperty("维修中设备数")
+    private Integer repair;
+
+    @ApiModelProperty("离线设备数")
+    private Integer offline;
+}

+ 17 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/WaterSupplyDeviceService.java

@@ -45,9 +45,26 @@ public interface WaterSupplyDeviceService extends IService<EquipmentBase> {
                                                   String equipmentTypeId, Integer alertLevel,
                                                   Boolean dispatchableOnly);
 
+    Page<AbnormalDeviceVO> getAbnormalDevicePage(long pageNum, long pageSize,
+                                                  String equipmentName, String equipmentCode,
+                                                  String equipmentTypeId, Integer alertLevel,
+                                                  String abnormalType, String region);
+
+    Page<AbnormalDeviceVO> getAbnormalDevicePage(long pageNum, long pageSize,
+                                                  String equipmentName, String equipmentCode,
+                                                  String equipmentTypeId, Integer alertLevel,
+                                                  String abnormalType, String region,
+                                                  Boolean dispatchableOnly);
+
+    DeviceAbnormalStatsVO getAbnormalDeviceStats();
+
     List<AbnormalDeviceVO> getAbnormalDeviceExport(String equipmentName, String equipmentCode,
                                                     String equipmentTypeId, Integer alertLevel);
 
+    List<AbnormalDeviceVO> getAbnormalDeviceExport(String equipmentName, String equipmentCode,
+                                                    String equipmentTypeId, Integer alertLevel,
+                                                    String abnormalType, String region);
+
     List<AbnormalDeviceVO> getAbnormalDeviceGisPoints(String equipmentTypeId);
 
     List<Map<String, Object>> getMaintainRecordList(String equipmentId);

+ 113 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java

@@ -553,6 +553,81 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                                                          String equipmentName, String equipmentCode,
                                                          String equipmentTypeId, Integer alertLevel,
                                                          Boolean dispatchableOnly) {
+        return getAbnormalDevicePage(pageNum, pageSize, equipmentName, equipmentCode,
+                equipmentTypeId, alertLevel, null, null, dispatchableOnly);
+    }
+
+    @Override
+    public Page<AbnormalDeviceVO> getAbnormalDevicePage(long pageNum, long pageSize,
+                                                         String equipmentName, String equipmentCode,
+                                                         String equipmentTypeId, Integer alertLevel,
+                                                         String abnormalType, String region) {
+        return getAbnormalDevicePage(pageNum, pageSize, equipmentName, equipmentCode,
+                equipmentTypeId, alertLevel, abnormalType, region, false);
+    }
+
+    @Override
+    public Page<AbnormalDeviceVO> getAbnormalDevicePage(long pageNum, long pageSize,
+                                                         String equipmentName, String equipmentCode,
+                                                         String equipmentTypeId, Integer alertLevel,
+                                                         String abnormalType, String region,
+                                                         Boolean dispatchableOnly) {
+        return queryAbnormalDevices(pageNum, pageSize, equipmentName, equipmentCode,
+                equipmentTypeId, alertLevel, abnormalType, region, dispatchableOnly);
+    }
+
+    @Override
+    public DeviceAbnormalStatsVO getAbnormalDeviceStats() {
+        List<EquipmentBase> waterDevices = findWaterDevices();
+        DeviceAbnormalStatsVO stats = new DeviceAbnormalStatsVO();
+        stats.setTotal(0);
+        stats.setAlarm(0);
+        stats.setRepair(0);
+        stats.setOffline(0);
+        if (CollUtil.isEmpty(waterDevices)) {
+            return stats;
+        }
+
+        Map<String, EquipmentStatus> statusMap = getStatusMap(waterDevices.stream()
+                .map(EquipmentBase::getEquipmentId)
+                .collect(Collectors.toList()));
+        Map<String, AlarmData> activeAlarmMap = getActiveAlarmMap(waterDevices);
+
+        int total = 0;
+        int alarm = 0;
+        int repair = 0;
+        int offline = 0;
+        for (EquipmentBase device : waterDevices) {
+            EquipmentStatus status = statusMap.get(device.getEquipmentId());
+            boolean alarmAbnormal = status != null && Objects.equals(status.getAlarmStatus(), 1)
+                    || (status == null && activeAlarmMap.containsKey(device.getEquipmentCode()));
+            boolean repairAbnormal = status != null && Objects.equals(status.getCurrentStatus(), 3);
+            boolean offlineAbnormal = status != null && Objects.equals(status.getOnlineStatus(), 0);
+            if (alarmAbnormal) {
+                alarm++;
+            }
+            if (repairAbnormal) {
+                repair++;
+            }
+            if (offlineAbnormal) {
+                offline++;
+            }
+            if (alarmAbnormal || repairAbnormal || offlineAbnormal) {
+                total++;
+            }
+        }
+        stats.setTotal(total);
+        stats.setAlarm(alarm);
+        stats.setRepair(repair);
+        stats.setOffline(offline);
+        return stats;
+    }
+
+    private Page<AbnormalDeviceVO> queryAbnormalDevices(long pageNum, long pageSize,
+                                                         String equipmentName, String equipmentCode,
+                                                         String equipmentTypeId, Integer alertLevel,
+                                                         String abnormalType, String region,
+                                                         Boolean dispatchableOnly) {
         List<EquipmentBase> waterDevices = findWaterDevices();
         if (CollUtil.isEmpty(waterDevices)) {
             return emptyAbnormalPage(pageNum, pageSize, 0);
@@ -560,7 +635,8 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
 
         LambdaQueryWrapper<EquipmentStatus> statusWrapper = new LambdaQueryWrapper<>();
         statusWrapper.and(w -> w.eq(EquipmentStatus::getAlarmStatus, 1)
-                .or().eq(EquipmentStatus::getCurrentStatus, 3));
+                .or().eq(EquipmentStatus::getCurrentStatus, 3)
+                .or().eq(EquipmentStatus::getOnlineStatus, 0));
 
         List<EquipmentStatus> allAbnormalStatus = equipmentStatusMapper.selectList(statusWrapper);
         Map<String, EquipmentStatus> statusMap = allAbnormalStatus.stream()
@@ -574,9 +650,13 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                         return activeAlarmMap.containsKey(device.getEquipmentCode());
                     }
                     return Objects.equals(status.getAlarmStatus(), 1)
-                            || Objects.equals(status.getCurrentStatus(), 3);
+                            || Objects.equals(status.getCurrentStatus(), 3)
+                            || Objects.equals(status.getOnlineStatus(), 0);
                 })
                 .filter(device -> matchesLedgerFilters(device, equipmentName, equipmentCode, equipmentTypeId))
+                .filter(device -> matchesAbnormalType(device, statusMap.get(device.getEquipmentId()),
+                        activeAlarmMap, abnormalType))
+                .filter(device -> StrUtil.isBlank(region) || region.equals(device.getDistrict()))
                 .collect(Collectors.toList());
 
         Map<String, AlarmData> alarmMap = activeAlarmMap;
@@ -616,8 +696,16 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
     @Override
     public List<AbnormalDeviceVO> getAbnormalDeviceExport(String equipmentName, String equipmentCode,
                                                            String equipmentTypeId, Integer alertLevel) {
-        return getAbnormalDevicePage(1, Integer.MAX_VALUE, equipmentName, equipmentCode,
-                equipmentTypeId, alertLevel).getRecords();
+        return getAbnormalDeviceExport(equipmentName, equipmentCode,
+                equipmentTypeId, alertLevel, null, null);
+    }
+
+    @Override
+    public List<AbnormalDeviceVO> getAbnormalDeviceExport(String equipmentName, String equipmentCode,
+                                                           String equipmentTypeId, Integer alertLevel,
+                                                           String abnormalType, String region) {
+        return queryAbnormalDevices(1, Integer.MAX_VALUE, equipmentName, equipmentCode,
+                equipmentTypeId, alertLevel, abnormalType, region, false).getRecords();
     }
 
     @Override
@@ -856,6 +944,25 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                 && (StrUtil.isBlank(equipmentTypeId) || equipmentTypeId.equals(device.getEquipmentTypeId()));
     }
 
+    private boolean matchesAbnormalType(EquipmentBase device, EquipmentStatus status,
+                                        Map<String, AlarmData> activeAlarmMap, String abnormalType) {
+        if (StrUtil.isBlank(abnormalType)) {
+            return true;
+        }
+        boolean alarmAbnormal = status != null && Objects.equals(status.getAlarmStatus(), 1)
+                || (status == null && activeAlarmMap.containsKey(device.getEquipmentCode()));
+        if ("alarm".equals(abnormalType)) {
+            return alarmAbnormal;
+        }
+        if ("repair".equals(abnormalType)) {
+            return status != null && Objects.equals(status.getCurrentStatus(), 3);
+        }
+        if ("offline".equals(abnormalType)) {
+            return status != null && Objects.equals(status.getOnlineStatus(), 0);
+        }
+        return false;
+    }
+
     private <T> Page<T> paginate(List<T> records, long pageNum, long pageSize) {
         Page<T> page = new Page<>(pageNum, pageSize, records.size());
         if (pageNum < 1 || pageSize < 1) {
@@ -973,6 +1080,8 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
             vo.setAlarmTime(alarm.getAlarmTime());
         } else if (Objects.equals(vo.getCurrentStatus(), 3)) {
             vo.setAbnormalReason("设备维修中");
+        } else if (Objects.equals(vo.getOnlineStatus(), 0)) {
+            vo.setAbnormalReason("设备离线");
         } else {
             vo.setAbnormalReason("设备异常");
         }

+ 50 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/AbnormalWorkOrderStatusContractTest.java

@@ -0,0 +1,50 @@
+package com.zksy.WaterSupply;
+
+import com.zksy.WaterSupply.export.WaterDeviceAbnormalExportDTO;
+import com.zksy.base.domain.vo.AbnormalDeviceVO;
+import com.zksy.common.annotation.Excel;
+import com.zksy.ops.enums.WorkOrderStatusEnum;
+import io.swagger.annotations.ApiModelProperty;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 设备异常看板的关联工单状态口径必须与权威工单状态枚举(0-7)一致。
+ */
+class AbnormalWorkOrderStatusContractTest {
+
+    @Test
+    void abnormalExportWorkOrderStatusFollowsAuthoritativeEnum() throws Exception {
+        Field field = WaterDeviceAbnormalExportDTO.class.getDeclaredField("relatedWorkOrderStatus");
+        Excel excel = field.getAnnotation(Excel.class);
+        assertNotNull(excel, "工单状态导出列缺少 @Excel 注解");
+
+        String mapping = excel.readConverterExp();
+        for (WorkOrderStatusEnum status : WorkOrderStatusEnum.values()) {
+            assertTrue(mapping.contains(status.getCode() + "=" + status.getDesc()),
+                    "导出工单状态映射缺少权威枚举项:" + status.getCode() + "=" + status.getDesc()
+                            + ",实际=" + mapping);
+        }
+        assertTrue(!mapping.contains("已驳回") && !mapping.contains("延期审核"),
+                "导出工单状态映射仍包含废弃状态:" + mapping);
+    }
+
+    @Test
+    void abnormalDeviceVoWorkOrderStatusDocFollowsAuthoritativeEnum() throws Exception {
+        Field field = AbnormalDeviceVO.class.getDeclaredField("relatedWorkOrderStatus");
+        ApiModelProperty doc = field.getAnnotation(ApiModelProperty.class);
+        assertNotNull(doc, "关联工单状态缺少字段说明");
+
+        String text = doc.value();
+        for (WorkOrderStatusEnum status : WorkOrderStatusEnum.values()) {
+            assertTrue(text.contains(status.getCode() + "-" + status.getDesc()),
+                    "字段说明缺少权威枚举项:" + status.getCode() + "-" + status.getDesc() + ",实际=" + text);
+        }
+        assertTrue(!text.contains("已驳回") && !text.contains("延期审核"),
+                "字段说明仍包含废弃状态:" + text);
+    }
+}

+ 108 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImplTest.java

@@ -13,6 +13,7 @@ import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentType;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.base.domain.vo.AbnormalDeviceVO;
+import com.zksy.base.domain.vo.DeviceAbnormalStatsVO;
 import com.zksy.base.domain.vo.DeviceGisVO;
 import com.zksy.base.domain.vo.DeviceLedgerStatisticsVO;
 import com.zksy.base.domain.vo.DeviceTypeStatisticsVO;
@@ -75,6 +76,7 @@ class WaterSupplyDeviceServiceImplTest {
 
     private EquipmentBase pressureDevice;
     private EquipmentBase repairDevice;
+    private EquipmentBase offlineDevice;
     private EquipmentBase gasDevice;
 
     @BeforeEach
@@ -98,6 +100,7 @@ class WaterSupplyDeviceServiceImplTest {
 
         pressureDevice = device("water-pressure-1", "WS-PRESSURE-001", "供水压力监测点", "water-pressure");
         repairDevice = device("water-flow-1", "WS-FLOW-001", "供水流量监测点", "water-flow");
+        offlineDevice = device("water-offline-1", "WS-OFFLINE-001", "供水离线监测点", "water-flow");
         gasDevice = device("gas-1", "GAS-001", "燃气监测点", "gas-root");
 
         when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(
@@ -465,6 +468,111 @@ class WaterSupplyDeviceServiceImplTest {
                 .collect(Collectors.toList()));
     }
 
+    @Test
+    void abnormalStatsCountsAlarmRepairAndOfflineWaterDevices() {
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(
+                pressureDevice, repairDevice, offlineDevice));
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Arrays.asList(
+                status(pressureDevice.getEquipmentId(), 1, 1, 1),
+                status(repairDevice.getEquipmentId(), 3, 0, 1),
+                status(offlineDevice.getEquipmentId(), 1, 0, 0),
+                status(gasDevice.getEquipmentId(), 1, 1, 1)));
+        when(alarmDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                alarm(pressureDevice.getEquipmentCode(), "供水压力超限", 1),
+                alarm(gasDevice.getEquipmentCode(), "燃气浓度超限", 2)));
+
+        DeviceAbnormalStatsVO stats = service.getAbnormalDeviceStats();
+
+        assertEquals(3, stats.getTotal());
+        assertEquals(1, stats.getAlarm());
+        assertEquals(1, stats.getRepair());
+        assertEquals(1, stats.getOffline());
+    }
+
+    @Test
+    void abnormalStatsExcludeNormalAndNonWaterDevicesAndMissingStatusAlarmFallback() {
+        EquipmentBase normalDevice = device("water-normal-1", "WS-NORMAL-001", "供水正常设备", "water-pressure");
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(
+                pressureDevice, repairDevice, offlineDevice, normalDevice));
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Arrays.asList(
+                status(repairDevice.getEquipmentId(), 3, 0, 1),
+                status(offlineDevice.getEquipmentId(), 1, 0, 0),
+                status(normalDevice.getEquipmentId(), 1, 0, 1),
+                status(gasDevice.getEquipmentId(), 1, 1, 1)));
+        when(alarmDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                alarm(pressureDevice.getEquipmentCode(), "供水压力超限", 1),
+                alarm(gasDevice.getEquipmentCode(), "燃气浓度超限", 2)));
+
+        DeviceAbnormalStatsVO stats = service.getAbnormalDeviceStats();
+
+        assertEquals(3, stats.getTotal());
+        assertEquals(1, stats.getAlarm());
+        assertEquals(1, stats.getRepair());
+        assertEquals(1, stats.getOffline());
+    }
+
+    @Test
+    void abnormalPageIncludesOfflineReasonAndFiltersByAbnormalTypeAndRegion() {
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(
+                pressureDevice, repairDevice, offlineDevice));
+        pressureDevice.setDistrict("城南");
+        repairDevice.setDistrict("城北");
+        offlineDevice.setDistrict("太常");
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Arrays.asList(
+                status(pressureDevice.getEquipmentId(), 1, 1, 1),
+                status(repairDevice.getEquipmentId(), 3, 0, 1),
+                status(offlineDevice.getEquipmentId(), 1, 0, 0),
+                status(gasDevice.getEquipmentId(), 1, 1, 1)));
+        when(alarmDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                alarm(pressureDevice.getEquipmentCode(), "供水压力超限", 1),
+                alarm(gasDevice.getEquipmentCode(), "燃气浓度超限", 2)));
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Page<AbnormalDeviceVO> offlinePage = service.getAbnormalDevicePage(
+                1, 10, null, null, null, null, "offline", "太常");
+        assertEquals(Collections.singletonList("water-offline-1"), offlinePage.getRecords().stream()
+                .map(AbnormalDeviceVO::getEquipmentId)
+                .collect(Collectors.toList()));
+        assertEquals("设备离线", ReflectionTestUtils.getField(offlinePage.getRecords().get(0), "abnormalReason"));
+
+        Page<AbnormalDeviceVO> repairPage = service.getAbnormalDevicePage(
+                1, 10, null, null, null, null, "repair", "城北");
+        assertEquals(Collections.singletonList("water-flow-1"), repairPage.getRecords().stream()
+                .map(AbnormalDeviceVO::getEquipmentId)
+                .collect(Collectors.toList()));
+
+        Page<AbnormalDeviceVO> regionPage = service.getAbnormalDevicePage(
+                1, 10, null, null, null, null, null, "城南");
+        assertEquals(Collections.singletonList("water-pressure-1"), regionPage.getRecords().stream()
+                .map(AbnormalDeviceVO::getEquipmentId)
+                .collect(Collectors.toList()));
+    }
+
+    @Test
+    void abnormalExportAppliesAbnormalTypeAndRegionFilters() {
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(
+                pressureDevice, repairDevice, offlineDevice));
+        pressureDevice.setDistrict("城南");
+        repairDevice.setDistrict("城北");
+        offlineDevice.setDistrict("太常");
+        when(equipmentStatusMapper.selectList(any())).thenReturn(Arrays.asList(
+                status(pressureDevice.getEquipmentId(), 1, 1, 1),
+                status(repairDevice.getEquipmentId(), 3, 0, 1),
+                status(offlineDevice.getEquipmentId(), 1, 0, 0),
+                status(gasDevice.getEquipmentId(), 1, 1, 1)));
+        when(alarmDataMapper.selectList(any())).thenReturn(Arrays.asList(
+                alarm(pressureDevice.getEquipmentCode(), "供水压力超限", 1),
+                alarm(gasDevice.getEquipmentCode(), "燃气浓度超限", 2)));
+        when(workOrderMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<AbnormalDeviceVO> result = service.getAbnormalDeviceExport(
+                null, null, null, null, "alarm", "城南");
+
+        assertEquals(Collections.singletonList("water-pressure-1"), result.stream()
+                .map(AbnormalDeviceVO::getEquipmentId)
+                .collect(Collectors.toList()));
+    }
+
     @Test
     void deviceGisPointsIncludeWaterOnlyWithCoordinatesAndStatuses() {
         pressureDevice.setLongitude(new BigDecimal("113.28000000"));