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

feat(water): deliver threshold management (#9)

Kazerin 2 дней назад
Родитель
Сommit
5a0a996a5c

+ 6 - 7
pipe-network-service/sql/water_supply_alarm.sql

@@ -1,8 +1,7 @@
 -- ============================================================
 -- 供水管网安全运行监测子系统 - 监测报警模块 数据库脚本
--- 数据库:PostgreSQL
--- 说明:本项目运行库为 PostgreSQL(schema: app_user),
---       若 warning_threshold 不在 app_user schema 下,请去掉前缀或调整 search_path。
+-- 数据库:KingbaseES
+-- 说明:KingbaseES 是本项目唯一生产验收真相源;业务表位于 app_user schema。
 -- ============================================================
 
 -- ------------------------------------------------------------
@@ -41,11 +40,11 @@ COMMENT ON COLUMN app_user.alarm_audit.audit_result IS '审核结果:1-属实/
 --    period_start / period_end 格式 HH:mm,为空表示全天生效;
 --    同一设备+预警编码可配置多条不同时段阈值,时段不允许重叠。
 -- ------------------------------------------------------------
-ALTER TABLE warning_threshold ADD COLUMN IF NOT EXISTS period_start varchar(5) NULL;
-ALTER TABLE warning_threshold ADD COLUMN IF NOT EXISTS period_end varchar(5) NULL;
+ALTER TABLE app_user.warning_threshold ADD COLUMN IF NOT EXISTS period_start varchar(5) NULL;
+ALTER TABLE app_user.warning_threshold ADD COLUMN IF NOT EXISTS period_end varchar(5) NULL;
 
-COMMENT ON COLUMN warning_threshold.period_start IS '分时阈值生效开始时间(HH:mm),为空表示全天生效';
-COMMENT ON COLUMN warning_threshold.period_end IS '分时阈值生效结束时间(HH:mm),为空表示全天生效';
+COMMENT ON COLUMN app_user.warning_threshold.period_start IS '分时阈值生效开始时间(HH:mm),为空表示全天生效';
+COMMENT ON COLUMN app_user.warning_threshold.period_end IS '分时阈值生效结束时间(HH:mm),为空表示全天生效';
 
 -- ------------------------------------------------------------
 -- 3. 可选:菜单/权限初始化(按需执行)

+ 51 - 0
pipe-network-service/sql/water_supply_threshold_test_data.sql

@@ -0,0 +1,51 @@
+-- ============================================================
+-- 供水管网 T5 阈值管理 测试数据脚本(KingbaseES)
+-- 说明:
+--   1. 依赖 T4 已初始化的规范设备类型和供水监测设备:
+--      ws_flow / ws_pressure / ws_leak,父类型 parent_type_id=1。
+--   2. 阈值测试主键使用 WS-TEST-THR- 前缀,可重复执行。
+--   3. 覆盖全天生效、分时生效、流量/压力/漏失三类传感器和筛选场景。
+-- ============================================================
+
+-- 清理历史阈值测试数据
+DELETE FROM app_user.warning_threshold
+ WHERE id LIKE 'WS-TEST-THR-%'
+    OR warning_code IN ('WARN-T5-FLOW', 'WARN-T5-PRESSURE', 'WARN-T5-LEAK');
+
+-- 校验规范设备类型存在,避免把阈值测试数据写到非供水设备上
+DO $$
+DECLARE
+    v_type_count integer;
+BEGIN
+    SELECT count(*) INTO v_type_count
+      FROM app_user.equipment_type
+     WHERE type_id IN ('ws_flow', 'ws_pressure', 'ws_leak')
+       AND parent_type_id = '1';
+    IF v_type_count <> 3 THEN
+        RAISE EXCEPTION '缺少规范供水监测子类型:ws_flow/ws_pressure/ws_leak 必须 parent_type_id=1';
+    END IF;
+END $$;
+
+INSERT INTO app_user.warning_threshold
+    (id, device_code, warning_type, warning_code, min_value, max_value,
+     period_start, period_end, remark, create_time, update_time)
+VALUES
+    ('WS-TEST-THR-FLOW-ALL', 'CN-GS-202609020001', '流量预警', 'WARN-T5-FLOW',
+     20.00, 40.00, NULL, NULL, '供水T5阈值测试-流量全天生效',
+     now(), now()),
+    ('WS-TEST-THR-FLOW-PERIOD', 'CN-GS-202609020002', '流量预警', 'WARN-T5-FLOW',
+     25.00, 45.00, '08:00', '20:00', '供水T5阈值测试-流量分时生效',
+     now(), now()),
+    ('WS-TEST-THR-PRESSURE-ALL', 'CB-GS-202609020001', '压力预警', 'WARN-T5-PRESSURE',
+     0.30, 0.60, NULL, NULL, '供水T5阈值测试-压力全天生效',
+     now(), now()),
+    ('WS-TEST-THR-LEAK-PERIOD', 'TC-GS-202609020001', '漏失预警', 'WARN-T5-LEAK',
+     8.00, 15.00, '08:00', '20:00', '供水T5阈值测试-漏失分时生效',
+     now(), now());
+
+-- 快速核验:
+--   阈值分页 total=4;
+--   设备编码筛选 CN-GS-202609020001 返回 1 条;
+--   预警分类筛选 流量预警 返回 2 条;
+--   WARN-T5-FLOW 在 CN-GS-202609020002 上已存在 08:00-20:00,
+--   再次保存 19:00-21:00 应返回重叠时段错误。

+ 97 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyThresholdServiceImpl.java

@@ -9,14 +9,20 @@ import com.zksy.WaterSupply.MonitorAlarm.dto.in.WaterThresholdSaveInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyThresholdService;
 import com.zksy.base.alarm.domain.WarningThreshold;
 import com.zksy.base.alarm.mapper.WarningThresholdMapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.common.exception.ServiceException;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
-import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
@@ -30,19 +36,33 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
 
     private static final Pattern TIME_PATTERN = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d$");
 
+    @Value("${waterSupply.threshold.top-level-type:供水}")
+    private String waterTopLevelTypeName = "供水";
+
+    @Autowired
+    private EquipmentBaseService equipmentBaseService;
+
     @Override
     public Page<WarningThreshold> findThresholdPage(long pageNum, long pageSize,
                                                     String deviceCode, String warningType,
                                                     String warningCode, String keyword) {
+        Set<String> waterDeviceCodes = resolveWaterDeviceCodes();
+        if (waterDeviceCodes.isEmpty()) {
+            return new Page<>(pageNum, pageSize);
+        }
         Page<WarningThreshold> page = new Page<>(pageNum, pageSize);
-        LambdaQueryWrapper<WarningThreshold> wrapper = buildQueryWrapper(deviceCode, warningType, warningCode, keyword);
+        LambdaQueryWrapper<WarningThreshold> wrapper = buildQueryWrapper(deviceCode, warningType, warningCode, keyword, waterDeviceCodes);
         wrapper.orderByDesc(WarningThreshold::getUpdateTime);
         return this.page(page, wrapper);
     }
 
     @Override
     public List<WarningThreshold> findThresholdList(String deviceCode, String warningType, String warningCode) {
-        LambdaQueryWrapper<WarningThreshold> wrapper = buildQueryWrapper(deviceCode, warningType, warningCode, null);
+        Set<String> waterDeviceCodes = resolveWaterDeviceCodes();
+        if (waterDeviceCodes.isEmpty()) {
+            return Collections.emptyList();
+        }
+        LambdaQueryWrapper<WarningThreshold> wrapper = buildQueryWrapper(deviceCode, warningType, warningCode, null, waterDeviceCodes);
         wrapper.orderByDesc(WarningThreshold::getUpdateTime);
         return this.list(wrapper);
     }
@@ -53,12 +73,19 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
         if (threshold == null) {
             throw new ServiceException("阈值信息不存在");
         }
+        assertWaterDeviceCodes(splitDeviceCodes(threshold.getDeviceCode()), resolveWaterDeviceCodes());
         return threshold;
     }
 
     private LambdaQueryWrapper<WarningThreshold> buildQueryWrapper(String deviceCode, String warningType,
-                                                                   String warningCode, String keyword) {
+                                                                   String warningCode, String keyword,
+                                                                   Set<String> waterDeviceCodes) {
         LambdaQueryWrapper<WarningThreshold> wrapper = new LambdaQueryWrapper<>();
+        wrapper.and(w -> {
+            for (String code : waterDeviceCodes) {
+                w.or().apply("CONCAT(',', device_code, ',') LIKE CONCAT('%,', {0}, ',%')", code);
+            }
+        });
         if (StrUtil.isNotBlank(deviceCode)) {
             // device_code 支持多个设备编号逗号分隔
             wrapper.apply("CONCAT(',', device_code, ',') LIKE CONCAT('%,', {0}, ',%')", deviceCode.trim());
@@ -79,20 +106,22 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
         if (inDTO == null || CollUtil.isEmpty(inDTO.getDeviceCodes())) {
             throw new ServiceException("设备编码不能为空");
         }
+        List<String> deviceCodes = inDTO.getDeviceCodes().stream()
+                .filter(StrUtil::isNotBlank)
+                .map(String::trim)
+                .distinct()
+                .collect(Collectors.toList());
+        if (CollUtil.isEmpty(deviceCodes)) {
+            throw new ServiceException("设备编码不能为空");
+        }
         if (StrUtil.isBlank(inDTO.getWarningType()) || StrUtil.isBlank(inDTO.getWarningCode())) {
             throw new ServiceException("预警类型和预警编码不能为空");
         }
-        if (inDTO.getMinValue() == null && inDTO.getMaxValue() == null) {
-            throw new ServiceException("阈值最小值/最大值至少填写一项");
-        }
+        assertWaterDeviceCodes(deviceCodes, resolveWaterDeviceCodes());
+        validateThresholdBounds(inDTO.getMinValue(), inDTO.getMaxValue());
         validatePeriod(inDTO.getPeriodStart(), inDTO.getPeriodEnd());
 
         int count = 0;
-        List<String> deviceCodes = inDTO.getDeviceCodes().stream()
-                .filter(StrUtil::isNotBlank)
-                .map(String::trim)
-                .distinct()
-                .collect(Collectors.toList());
         for (String deviceCode : deviceCodes) {
             checkPeriodConflict(deviceCode, inDTO.getWarningCode(),
                     inDTO.getPeriodStart(), inDTO.getPeriodEnd(), null);
@@ -123,10 +152,16 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
         if (exist == null) {
             throw new ServiceException("阈值信息不存在");
         }
+        String deviceCode = StrUtil.blankToDefault(threshold.getDeviceCode(), exist.getDeviceCode()).trim();
+        String warningCode = StrUtil.blankToDefault(threshold.getWarningCode(), exist.getWarningCode()).trim();
+        assertWaterDeviceCodes(splitDeviceCodes(deviceCode), resolveWaterDeviceCodes());
+        validateThresholdBounds(threshold.getMinValue(), threshold.getMaxValue());
         validatePeriod(threshold.getPeriodStart(), threshold.getPeriodEnd());
-        checkPeriodConflict(exist.getDeviceCode(), exist.getWarningCode(),
+        checkPeriodConflict(deviceCode, warningCode,
                 threshold.getPeriodStart(), threshold.getPeriodEnd(), exist.getId());
 
+        threshold.setDeviceCode(deviceCode);
+        threshold.setWarningCode(warningCode);
         threshold.setCreateTime(exist.getCreateTime());
         threshold.setUpdateTime(LocalDateTime.now());
         return this.updateById(threshold);
@@ -138,12 +173,61 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
         if (CollUtil.isEmpty(ids)) {
             throw new ServiceException("请选择要删除的阈值");
         }
+        List<WarningThreshold> thresholds = this.listByIds(ids);
+        Set<String> waterDeviceCodes = resolveWaterDeviceCodes();
+        for (WarningThreshold threshold : thresholds) {
+            assertWaterDeviceCodes(splitDeviceCodes(threshold.getDeviceCode()), waterDeviceCodes);
+        }
         return this.removeByIds(ids);
     }
 
+    private Set<String> resolveWaterDeviceCodes() {
+        List<EquipmentBase> equipmentList = equipmentBaseService.findByTopLevelType(waterTopLevelTypeName);
+        if (CollUtil.isEmpty(equipmentList)) {
+            return Collections.emptySet();
+        }
+        return equipmentList.stream()
+                .map(EquipmentBase::getEquipmentCode)
+                .filter(StrUtil::isNotBlank)
+                .map(String::trim)
+                .collect(Collectors.toSet());
+    }
+
+    private List<String> splitDeviceCodes(String deviceCode) {
+        if (StrUtil.isBlank(deviceCode)) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(deviceCode.split(","))
+                .map(String::trim)
+                .filter(StrUtil::isNotBlank)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private void assertWaterDeviceCodes(List<String> deviceCodes, Set<String> waterDeviceCodes) {
+        if (deviceCodes.isEmpty()) {
+            throw new ServiceException("设备编码不能为空");
+        }
+        if (waterDeviceCodes.isEmpty()) {
+            throw new ServiceException("未找到供水设备,无法配置阈值");
+        }
+        if (!waterDeviceCodes.containsAll(deviceCodes)) {
+            throw new ServiceException("仅支持供水设备配置阈值");
+        }
+    }
+
     /**
      * 校验分时时间格式与先后关系
      */
+    private void validateThresholdBounds(Double minValue, Double maxValue) {
+        if (minValue == null && maxValue == null) {
+            throw new ServiceException("阈值最小值/最大值至少填写一项");
+        }
+        if (minValue != null && maxValue != null && minValue.compareTo(maxValue) > 0) {
+            throw new ServiceException("阈值最小值不能大于最大值");
+        }
+    }
+
     private void validatePeriod(String periodStart, String periodEnd) {
         boolean hasStart = StrUtil.isNotBlank(periodStart);
         boolean hasEnd = StrUtil.isNotBlank(periodEnd);

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

@@ -0,0 +1,328 @@
+package com.zksy.WaterSupply.MonitorAlarm;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.MonitorAlarm.dto.in.WaterThresholdSaveInDTO;
+import com.zksy.WaterSupply.MonitorAlarm.service.impl.WaterSupplyThresholdServiceImpl;
+import com.zksy.base.alarm.domain.WarningThreshold;
+import com.zksy.base.alarm.mapper.WarningThresholdMapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.common.exception.ServiceException;
+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.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class WaterSupplyThresholdServiceContractTest {
+
+    @BeforeAll
+    static void initializeMybatisPlusLambdaCache() {
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), WarningThreshold.class);
+    }
+
+    private WaterSupplyThresholdServiceImpl service;
+    private WarningThresholdMapper thresholdMapper;
+    private EquipmentBaseService equipmentBaseService;
+
+    @BeforeEach
+    void setUp() {
+        service = new WaterSupplyThresholdServiceImpl();
+        thresholdMapper = mock(WarningThresholdMapper.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        ReflectionTestUtils.setField(service, "baseMapper", thresholdMapper);
+        ReflectionTestUtils.setField(service, "equipmentBaseService", equipmentBaseService);
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(
+                equipment("WS-FLOW-1"), equipment("WS-FLOW-2"), equipment("WS-PRESSURE-1")
+        ));
+        when(thresholdMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(thresholdMapper.insert(any(WarningThreshold.class))).thenReturn(1);
+    }
+
+    @Test
+    void batchSaveCreatesOneTrimmedThresholdPerDistinctDevice() {
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Arrays.asList(" WS-FLOW-1 ", "WS-FLOW-1", "WS-FLOW-2"));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(20D);
+        request.setMaxValue(40D);
+        request.setPeriodStart("08:00");
+        request.setPeriodEnd("20:00");
+
+        int count = service.saveThresholdBatch(request);
+
+        assertEquals(2, count);
+        ArgumentCaptor<WarningThreshold> captor = ArgumentCaptor.forClass(WarningThreshold.class);
+        verify(thresholdMapper, times(2)).insert(captor.capture());
+        List<WarningThreshold> saved = captor.getAllValues();
+        assertEquals("WS-FLOW-1", saved.get(0).getDeviceCode());
+        assertEquals("WS-FLOW-2", saved.get(1).getDeviceCode());
+        assertEquals("流量预警", saved.get(0).getWarningType());
+        assertEquals("WARN-FLOW", saved.get(0).getWarningCode());
+        assertEquals("08:00", saved.get(0).getPeriodStart());
+        assertEquals("20:00", saved.get(0).getPeriodEnd());
+    }
+
+    @Test
+    void batchSaveRejectsRequestsWithOnlyBlankDeviceCodes() {
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Arrays.asList(" ", ""));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(20D);
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.saveThresholdBatch(request));
+
+        assertEquals("设备编码不能为空", error.getMessage());
+        verify(thresholdMapper, never()).insert(any(WarningThreshold.class));
+    }
+
+    @Test
+    void batchSaveRejectsInvertedThresholdBounds() {
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Collections.singletonList("WS-FLOW-1"));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(40D);
+        request.setMaxValue(20D);
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.saveThresholdBatch(request));
+
+        assertEquals("阈值最小值不能大于最大值", error.getMessage());
+        verify(thresholdMapper, never()).insert(any(WarningThreshold.class));
+    }
+
+    @Test
+    void batchSaveFallsBackToAllDayWhenPeriodIsOmitted() {
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Collections.singletonList("WS-FLOW-1"));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(20D);
+        request.setMaxValue(40D);
+
+        service.saveThresholdBatch(request);
+
+        ArgumentCaptor<WarningThreshold> captor = ArgumentCaptor.forClass(WarningThreshold.class);
+        verify(thresholdMapper).insert(captor.capture());
+        assertNull(captor.getValue().getPeriodStart());
+        assertNull(captor.getValue().getPeriodEnd());
+    }
+
+    @Test
+    void batchSaveRejectsPartialPeriod() {
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Collections.singletonList("WS-FLOW-1"));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(20D);
+        request.setPeriodStart("08:00");
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.saveThresholdBatch(request));
+
+        assertEquals("分时阈值的生效开始时间和结束时间必须同时填写", error.getMessage());
+        verify(thresholdMapper, never()).insert(any(WarningThreshold.class));
+    }
+
+    @Test
+    void batchSaveRejectsOverlappingPeriodForSameDeviceAndWarningCode() {
+        WarningThreshold existing = new WarningThreshold();
+        existing.setId("existing-id");
+        existing.setDeviceCode("WS-FLOW-1");
+        existing.setWarningCode("WARN-FLOW");
+        existing.setPeriodStart("08:00");
+        existing.setPeriodEnd("20:00");
+        when(thresholdMapper.selectList(any())).thenReturn(Collections.singletonList(existing));
+
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Collections.singletonList("WS-FLOW-1"));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(20D);
+        request.setPeriodStart("19:00");
+        request.setPeriodEnd("21:00");
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.saveThresholdBatch(request));
+
+        assertEquals("设备[WS-FLOW-1]在相同预警编码[WARN-FLOW]下已存在重叠时段阈值,请调整分时设置",
+                error.getMessage());
+        verify(thresholdMapper, never()).insert(any(WarningThreshold.class));
+    }
+
+    @Test
+    void updateRejectsOverlappingPeriodForIncomingDeviceAndWarningCode() {
+        WarningThreshold current = new WarningThreshold();
+        current.setId("current-id");
+        current.setDeviceCode("WS-FLOW-1");
+        current.setWarningCode("WARN-FLOW-1");
+        current.setMinValue(20D);
+        when(thresholdMapper.selectById("current-id")).thenReturn(current);
+
+        WarningThreshold conflict = new WarningThreshold();
+        conflict.setId("conflict-id");
+        conflict.setDeviceCode("WS-FLOW-2");
+        conflict.setWarningCode("WARN-FLOW-2");
+        conflict.setPeriodStart("08:00");
+        conflict.setPeriodEnd("20:00");
+        when(thresholdMapper.selectList(any())).thenAnswer(invocation -> {
+            Object wrapper = invocation.getArgument(0);
+            Set<String> values = wrapperParamValues(wrapper);
+            return values.contains("WS-FLOW-2") && values.contains("WARN-FLOW-2")
+                    ? Collections.singletonList(conflict)
+                    : Collections.emptyList();
+        });
+
+        WarningThreshold update = new WarningThreshold();
+        update.setId("current-id");
+        update.setDeviceCode("WS-FLOW-2");
+        update.setWarningCode("WARN-FLOW-2");
+        update.setMinValue(20D);
+        update.setPeriodStart("19:00");
+        update.setPeriodEnd("21:00");
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.updateThreshold(update));
+
+        assertEquals("设备[WS-FLOW-2]在相同预警编码[WARN-FLOW-2]下已存在重叠时段阈值,请调整分时设置",
+                error.getMessage());
+        verify(thresholdMapper, never()).updateById(any(WarningThreshold.class));
+    }
+
+    @Test
+    void updateRejectsThresholdWithoutLowerOrUpperBound() {
+        WarningThreshold current = new WarningThreshold();
+        current.setId("current-id");
+        current.setDeviceCode("WS-FLOW-1");
+        current.setWarningCode("WARN-FLOW-1");
+        current.setMinValue(20D);
+        when(thresholdMapper.selectById("current-id")).thenReturn(current);
+
+        WarningThreshold update = new WarningThreshold();
+        update.setId("current-id");
+        update.setDeviceCode("WS-FLOW-1");
+        update.setWarningCode("WARN-FLOW-1");
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.updateThreshold(update));
+
+        assertEquals("阈值最小值/最大值至少填写一项", error.getMessage());
+        verify(thresholdMapper, never()).updateById(any(WarningThreshold.class));
+    }
+
+    @Test
+    void batchSaveRejectsNonWaterDevice() {
+        WaterThresholdSaveInDTO request = new WaterThresholdSaveInDTO();
+        request.setDeviceCodes(Collections.singletonList("GAS-FLOW-1"));
+        request.setWarningType("流量预警");
+        request.setWarningCode("WARN-FLOW");
+        request.setMinValue(20D);
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.saveThresholdBatch(request));
+
+        assertEquals("仅支持供水设备配置阈值", error.getMessage());
+        verify(thresholdMapper, never()).insert(any(WarningThreshold.class));
+    }
+
+    @Test
+    void updateRejectsNonWaterDevice() {
+        WarningThreshold current = new WarningThreshold();
+        current.setId("current-id");
+        current.setDeviceCode("WS-FLOW-1");
+        current.setWarningCode("WARN-FLOW-1");
+        current.setMinValue(20D);
+        when(thresholdMapper.selectById("current-id")).thenReturn(current);
+
+        WarningThreshold update = new WarningThreshold();
+        update.setId("current-id");
+        update.setDeviceCode("GAS-FLOW-1");
+        update.setWarningCode("WARN-FLOW-1");
+        update.setMinValue(20D);
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.updateThreshold(update));
+
+        assertEquals("仅支持供水设备配置阈值", error.getMessage());
+        verify(thresholdMapper, never()).updateById(any(WarningThreshold.class));
+    }
+
+    @Test
+    void listOnlyQueriesWaterDevices() {
+        service.findThresholdList("WS-FLOW-1", null, null);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WarningThreshold>> captor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
+        verify(thresholdMapper).selectList(captor.capture());
+        Set<String> values = wrapperParamValues(captor.getValue());
+        assertTrue(values.contains("WS-FLOW-1"));
+        assertTrue(values.contains("WS-FLOW-2"));
+        assertTrue(values.contains("WS-PRESSURE-1"));
+        assertFalse(values.contains("GAS-FLOW-1"));
+    }
+
+    @Test
+    void pageReturnsEmptyDataWhenNoWaterDevicesExist() {
+        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Collections.emptyList());
+
+        Page<WarningThreshold> page = service.findThresholdPage(1, 10, null, null, null, null);
+
+        assertTrue(page.getRecords().isEmpty());
+        assertEquals(0, page.getTotal());
+        verify(thresholdMapper, never()).selectList(any());
+    }
+
+    @Test
+    void deleteRejectsNonWaterThreshold() {
+        WarningThreshold threshold = new WarningThreshold();
+        threshold.setId("threshold-id");
+        threshold.setDeviceCode("GAS-FLOW-1");
+        when(thresholdMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(threshold));
+
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.deleteThresholdBatch(Collections.singletonList("threshold-id")));
+
+        assertEquals("仅支持供水设备配置阈值", error.getMessage());
+        verify(thresholdMapper, never()).deleteBatchIds(any());
+    }
+
+    private static Set<String> wrapperParamValues(Object wrapper) {
+        ((com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<?>) wrapper).getSqlSegment();
+        return ((java.util.Map<?, ?>) ReflectionTestUtils.getField(wrapper, "paramNameValuePairs"))
+                .values().stream()
+                .map(String::valueOf)
+                .collect(Collectors.toSet());
+    }
+
+    private static EquipmentBase equipment(String equipmentCode) {
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentCode(equipmentCode);
+        return equipment;
+    }
+}