1
0

3 Коммиты bf10005a59 ... 5ae1e0c19e

Автор SHA1 Сообщение Дата
  Kazerin 5ae1e0c19e fix(cockpit): exclude boundary mismatches from industry counts (#19) 2 дней назад
  Kazerin 5a0a996a5c feat(water): deliver threshold management (#9) 2 дней назад
  Kazerin b5d7e8678c feat(cockpit): define device identity and domain boundary (#19) 2 дней назад
18 измененных файлов с 1320 добавлено и 20 удалено
  1. 62 0
      docs/cockpit/device-boundary-demo.html
  2. 6 7
      pipe-network-service/sql/water_supply_alarm.sql
  3. 51 0
      pipe-network-service/sql/water_supply_threshold_test_data.sql
  4. 47 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitDeviceBoundaryController.java
  5. 237 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitDeviceBoundaryControllerTest.java
  6. 97 13
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyThresholdServiceImpl.java
  7. 33 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundary.java
  8. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryAdapter.java
  9. 33 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryResponse.java
  10. 218 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryService.java
  11. 82 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundarySnapshot.java
  12. 30 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryStatistics.java
  13. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceType.java
  14. 27 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitFacilityBoundary.java
  15. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitIndustryStatistics.java
  16. 19 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitTypeScope.java
  17. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/EmptyCockpitDeviceBoundaryAdapter.java
  18. 328 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyThresholdServiceContractTest.java

+ 62 - 0
docs/cockpit/device-boundary-demo.html

@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Device Boundary Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#0b1622; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1180px,calc(100vw - 32px)); margin:32px auto; background:#102030; border:1px solid #21465f; padding:24px; }
+    h1 { margin:0 0 6px; font-size:22px; } .endpoint { color:#78a2b8; font-family:Consolas,monospace; margin:0 0 18px; }
+    .summary { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:18px; }
+    .card { padding:12px; border:1px solid #2a5a75; background:#0d1922; }
+    .card strong { display:block; font-size:22px; } .card span { color:#8fb3c7; }
+    table { width:100%; border-collapse:collapse; margin-top:16px; } th,td { border:1px solid #2a5a75; padding:8px; text-align:left; }
+    th { background:#123247; } .facility td:first-child { color:#61d095; } .device td:first-child { color:#78a2b8; }
+    .unknown { color:#f0ad4e; } code { color:#c9e7f7; }
+  </style>
+</head>
+<body>
+<main>
+  <h1>驾驶舱设备身份与领域边界演示</h1>
+  <p class="endpoint">GET /api/cockpit/v1/device-boundary</p>
+  <section class="summary">
+    <div class="card"><strong id="facility-count"></strong><span>设施</span></div>
+    <div class="card"><strong id="device-count"></strong><span>智能设备</span></div>
+    <div class="card"><strong id="formal-device-count"></strong><span>正式范围设备</span></div>
+    <div class="card"><strong id="unknown-count"></strong><span>未知类型</span></div>
+    <div class="card"><strong id="mismatch-count"></strong><span>类型边界冲突</span></div>
+  </section>
+  <table>
+    <thead><tr><th>编码</th><th>名称</th><th>类型</th><th>行业</th><th>排水范围</th><th>对象</th><th>正式范围</th></tr></thead>
+    <tbody id="objects"></tbody>
+  </table>
+</main>
+<script>
+const payload = {
+  requestId:'demo-device-boundary',apiVersion:'v1',readOnly:true,
+  typeScope:{formalIndustries:['GAS','WATER_SUPPLY','DRAINAGE'],drainageScopes:['RAINWATER','SEWAGE'],objectKinds:['FACILITY','SMART_DEVICE']},
+  facilities:[
+    {facilityCode:'F-WATER-001',facilityName:'第一水厂',facilityType:{code:'WATER_PLANT',name:'水厂'},industry:'WATER_SUPPLY',objectKind:'FACILITY',formalScope:true},
+    {facilityCode:'F-PUMP-001',facilityName:'城北泵站',facilityType:{code:'PUMP_STATION',name:'泵站'},industry:'WATER_SUPPLY',objectKind:'FACILITY',formalScope:true},
+    {facilityCode:'F-PIPE-001',facilityName:'供水主线',facilityType:{code:'PIPELINE',name:'供水管线'},industry:'WATER_SUPPLY',objectKind:'FACILITY',formalScope:true}
+  ],
+  devices:[
+    {deviceCode:'EQ-GAS-001',deviceName:'燃气浓度计-001',deviceType:{code:'GAS_SENSOR',name:'燃气浓度计'},industry:'GAS',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-WS-001',deviceName:'供水压力计-001',deviceType:{code:'WATER_SUPPLY_SENSOR',name:'供水压力计'},industry:'WATER_SUPPLY',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-RAIN-001',deviceName:'雨水流量计-001',deviceType:{code:'RAINWATER_SENSOR',name:'雨水流量计'},industry:'DRAINAGE',drainageScope:'RAINWATER',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-SEW-001',deviceName:'污水环境监测仪-001',deviceType:{code:'SEWAGE_ENVIRONMENT_SENSOR',name:'污水环境监测仪'},industry:'DRAINAGE',drainageScope:'SEWAGE',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-MH-001',deviceName:'智能井盖-001',deviceType:{code:'SMART_MANHOLE',name:'智能井盖'},objectKind:'SMART_DEVICE',formalScope:false,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-UNKNOWN-001',deviceName:'未知类型设备-001',deviceType:{code:'LEGACY_UNKNOWN_TYPE'},objectKind:'SMART_DEVICE',formalScope:false,typeResolution:'UNKNOWN_TYPE'}
+  ],
+  statistics:{facilityCount:3,smartDeviceCount:6,formalSmartDeviceCount:4,unknownTypeCount:1,typeKindMismatchCount:0}
+};
+function row(item,isFacility){
+  return `<tr class="${isFacility?'facility':'device'} ${item.typeResolution==='UNKNOWN_TYPE'?'unknown':''}"><td><code>${item.facilityCode||item.deviceCode}</code></td><td>${item.facilityName||item.deviceName}</td><td>${(item.facilityType||item.deviceType||{}).name||'未知类型'}</td><td>${item.industry||'—'}</td><td>${item.drainageScope||'—'}</td><td>${item.objectKind}</td><td>${item.formalScope?'是':'否'}</td></tr>`;
+}
+document.getElementById('objects').innerHTML = payload.facilities.map(x=>row(x,true)).concat(payload.devices.map(x=>row(x,false))).join('');
+const stats=payload.statistics;
+facilityCount.textContent=stats.facilityCount;deviceCount.textContent=stats.smartDeviceCount;formalDeviceCount.textContent=stats.formalSmartDeviceCount;unknownCount.textContent=stats.unknownTypeCount;mismatchCount.textContent=stats.typeKindMismatchCount;
+</script>
+</body>
+</html>

+ 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 应返回重叠时段错误。

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

@@ -0,0 +1,47 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitDeviceBoundaryController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitDeviceBoundaryService deviceBoundaryService;
+
+    public CockpitDeviceBoundaryController(CockpitDeviceBoundaryService deviceBoundaryService) {
+        this.deviceBoundaryService = deviceBoundaryService;
+    }
+
+    @GetMapping("/api/cockpit/v1/device-boundary")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> deviceBoundary(
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(deviceBoundaryService.load(resolveRequestId(requestId))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        CockpitErrorPayload payload = deviceBoundaryService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)));
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(new AjaxResult(HttpStatus.INTERNAL_SERVER_ERROR.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

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

@@ -0,0 +1,237 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.device.CockpitDeviceBoundaryAdapter;
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceDevice;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceFacility;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceType;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitDeviceBoundaryControllerTest {
+
+    private CockpitDeviceBoundaryAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitDeviceBoundaryAdapter.class);
+        CockpitDeviceBoundaryService service = new CockpitDeviceBoundaryService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        CockpitDeviceBoundaryController controller = new CockpitDeviceBoundaryController(service);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void deviceBoundaryUsesCanonicalDeviceCodeAndSeparatesFacilitiesFromSmartDevices() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        MvcResult result = mockMvc.perform(get("/api/cockpit/v1/device-boundary")
+                        .header("X-Request-Id", "device-boundary-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.typeScope.formalIndustries", hasSize(3)))
+                .andExpect(jsonPath("$.data.typeScope.drainageScopes", hasSize(2)))
+                .andExpect(jsonPath("$.data.typeScope.objectKinds", hasSize(2)))
+                .andExpect(jsonPath("$.data.facilities", hasSize(3)))
+                .andExpect(jsonPath("$.data.devices", hasSize(6)))
+                .andExpect(jsonPath("$.data.facilities[0].facilityCode").value("F-WATER-001"))
+                .andExpect(jsonPath("$.data.facilities[0].objectKind").value("FACILITY"))
+                .andExpect(jsonPath("$.data.facilities[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.facilities[1].facilityCode").value("F-PUMP-001"))
+                .andExpect(jsonPath("$.data.facilities[1].objectKind").value("FACILITY"))
+                .andExpect(jsonPath("$.data.facilities[2].facilityCode").value("F-PIPE-001"))
+                .andExpect(jsonPath("$.data.facilities[2].objectKind").value("FACILITY"))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-GAS-001"))
+                .andExpect(jsonPath("$.data.devices[0].deviceType.code").value("GAS_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[0].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.devices[0].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[0].formalScope").value(true))
+                .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-WS-001"))
+                .andExpect(jsonPath("$.data.devices[1].deviceType.code").value("WATER_SUPPLY_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[1].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.devices[1].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-RAIN-001"))
+                .andExpect(jsonPath("$.data.devices[2].deviceType.code").value("RAINWATER_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[2].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.devices[2].drainageScope").value("RAINWATER"))
+                .andExpect(jsonPath("$.data.devices[2].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-SEW-001"))
+                .andExpect(jsonPath("$.data.devices[3].deviceType.code").value("SEWAGE_ENVIRONMENT_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[3].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.devices[3].drainageScope").value("SEWAGE"))
+                .andExpect(jsonPath("$.data.devices[3].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-MH-001"))
+                .andExpect(jsonPath("$.data.devices[4].deviceType.code").value("SMART_MANHOLE"))
+                .andExpect(jsonPath("$.data.devices[4].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[5].deviceCode").value("EQ-UNKNOWN-001"))
+                .andExpect(jsonPath("$.data.devices[5].typeResolution").value("UNKNOWN_TYPE"))
+                .andExpect(jsonPath("$.data.devices[5].formalScope").value(false))
+                .andExpect(jsonPath("$.data.statistics.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.smartDeviceCount").value(6))
+                .andExpect(jsonPath("$.data.statistics.formalSmartDeviceCount").value(4))
+                .andExpect(jsonPath("$.data.statistics.unknownTypeCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(0))
+                .andExpect(jsonPath("$.data.statistics.industries.gas.smartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.smartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.drainage.smartDeviceCount").value(2))
+                .andExpect(jsonPath("$.data.statistics.industries.drainage.rainwaterSmartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.drainage.sewageSmartDeviceCount").value(1))
+                .andReturn();
+
+        String body = result.getResponse().getContentAsString();
+        assertFalse(body.contains("equipment_code"));
+        assertFalse(body.contains("equipmentId"));
+        assertFalse(body.contains("equipmentTypeId"));
+    }
+
+    @Test
+    void serverFailureReturnsTypedErrorInsteadOfPretendingToBeEmpty() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/device-boundary")
+                        .header("X-Request-Id", "device-boundary-request-002"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value("驾驶舱设备边界数据暂不可用"))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱设备边界数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-002"))
+                .andExpect(jsonPath("$.data.serverTime").value("2026-09-02T10:00:00+08:00"));
+    }
+
+    @Test
+    void facilityFormalScopeRequiresFormalIndustryAndFacilityObjectKind() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitDeviceBoundarySnapshot(
+                Arrays.asList(
+                        new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY"),
+                        new SourceType("WATER_PLANT_DEVICE", "水厂设备", "WATER_SUPPLY", null, "SMART_DEVICE")),
+                Arrays.asList(
+                        new SourceFacility("F-WATER-001", "第一水厂", "WATER_PLANT"),
+                        new SourceFacility("F-UNKNOWN-001", "未知类型设施", "LEGACY_UNKNOWN_FACILITY_TYPE"),
+                        new SourceFacility("F-MISMATCH-001", "误挂设备类型的设施", "WATER_PLANT_DEVICE")),
+                Collections.emptyList()));
+
+        mockMvc.perform(get("/api/cockpit/v1/device-boundary"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.facilities[0].formalScope").value(true))
+                .andExpect(jsonPath("$.data.facilities[1].formalScope").value(false))
+                .andExpect(jsonPath("$.data.facilities[2].formalScope").value(false))
+                .andExpect(jsonPath("$.data.statistics.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.facilityCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.unknownTypeCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(1));
+    }
+
+    @Test
+    void mismatchedDeviceTypeIsExcludedFromFormalIndustryStatistics() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitDeviceBoundarySnapshot(
+                Arrays.asList(
+                        new SourceType("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", null, "SMART_DEVICE"),
+                        new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY")),
+                Collections.emptyList(),
+                Arrays.asList(
+                        new SourceDevice("EQ-WS-001", "供水压力计-001", "WATER_SUPPLY_SENSOR"),
+                        new SourceDevice("EQ-MISMATCH-001", "误挂设施类型的设备-001", "WATER_PLANT"))));
+
+        mockMvc.perform(get("/api/cockpit/v1/device-boundary"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.devices[0].formalScope").value(true))
+                .andExpect(jsonPath("$.data.devices[1].formalScope").value(false))
+                .andExpect(jsonPath("$.data.devices[1].typeResolution").value("TYPE_KIND_MISMATCH"))
+                .andExpect(jsonPath("$.data.statistics.smartDeviceCount").value(2))
+                .andExpect(jsonPath("$.data.statistics.formalSmartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.smartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(1));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/device-boundary"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitDeviceBoundaryController.class.getDeclaredMethods())
+                .filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class))
+                .allMatch(CockpitDeviceBoundaryControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitDeviceBoundaryController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(org.springframework.security.access.prepost.PreAuthorize.class)
+                        && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class)
+                        .value()
+                        .equals("isAuthenticated()")));
+    }
+
+    private static boolean isReadOnlyMappedMethod(java.lang.reflect.Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static CockpitDeviceBoundarySnapshot fixture() {
+        List<SourceType> types = Arrays.asList(
+                new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY"),
+                new SourceType("PUMP_STATION", "泵站", "WATER_SUPPLY", null, "FACILITY"),
+                new SourceType("PIPELINE", "供水管线", "WATER_SUPPLY", null, "FACILITY"),
+                new SourceType("GAS_SENSOR", "燃气浓度计", "GAS", null, "SMART_DEVICE"),
+                new SourceType("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", null, "SMART_DEVICE"),
+                new SourceType("RAINWATER_SENSOR", "雨水流量计", "DRAINAGE", "RAINWATER", "SMART_DEVICE"),
+                new SourceType("SEWAGE_ENVIRONMENT_SENSOR", "污水环境监测仪", "DRAINAGE", "SEWAGE", "SMART_DEVICE"),
+                new SourceType("SMART_MANHOLE", "智能井盖", null, null, "SMART_DEVICE"));
+
+        List<SourceFacility> facilities = Arrays.asList(
+                new SourceFacility("F-WATER-001", "第一水厂", "WATER_PLANT"),
+                new SourceFacility("F-PUMP-001", "城北泵站", "PUMP_STATION"),
+                new SourceFacility("F-PIPE-001", "供水主线", "PIPELINE"));
+
+        List<SourceDevice> devices = Arrays.asList(
+                new SourceDevice("EQ-GAS-001", "燃气浓度计-001", "GAS_SENSOR"),
+                new SourceDevice("EQ-WS-001", "供水压力计-001", "WATER_SUPPLY_SENSOR"),
+                new SourceDevice("EQ-RAIN-001", "雨水流量计-001", "RAINWATER_SENSOR"),
+                new SourceDevice("EQ-SEW-001", "污水环境监测仪-001", "SEWAGE_ENVIRONMENT_SENSOR"),
+                new SourceDevice("EQ-MH-001", "智能井盖-001", "SMART_MANHOLE"),
+                new SourceDevice("EQ-UNKNOWN-001", "未知类型设备-001", "LEGACY_UNKNOWN_TYPE"));
+
+        return new CockpitDeviceBoundarySnapshot(types, facilities, devices);
+    }
+}

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

+ 33 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundary.java

@@ -0,0 +1,33 @@
+package com.zksy.cockpit.device;
+
+public class CockpitDeviceBoundary {
+    private final String deviceCode;
+    private final String deviceName;
+    private final CockpitDeviceType deviceType;
+    private final String industry;
+    private final String drainageScope;
+    private final String objectKind;
+    private final boolean formalScope;
+    private final String typeResolution;
+
+    public CockpitDeviceBoundary(String deviceCode, String deviceName, CockpitDeviceType deviceType,
+            String industry, String drainageScope, String objectKind, boolean formalScope, String typeResolution) {
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.deviceType = deviceType;
+        this.industry = industry;
+        this.drainageScope = drainageScope;
+        this.objectKind = objectKind;
+        this.formalScope = formalScope;
+        this.typeResolution = typeResolution;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public CockpitDeviceType getDeviceType() { return deviceType; }
+    public String getIndustry() { return industry; }
+    public String getDrainageScope() { return drainageScope; }
+    public String getObjectKind() { return objectKind; }
+    public boolean isFormalScope() { return formalScope; }
+    public String getTypeResolution() { return typeResolution; }
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryAdapter.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.device;
+
+public interface CockpitDeviceBoundaryAdapter {
+    CockpitDeviceBoundarySnapshot load();
+}

+ 33 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryResponse.java

@@ -0,0 +1,33 @@
+package com.zksy.cockpit.device;
+
+import java.util.List;
+
+public class CockpitDeviceBoundaryResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final CockpitTypeScope typeScope;
+    private final List<CockpitFacilityBoundary> facilities;
+    private final List<CockpitDeviceBoundary> devices;
+    private final CockpitDeviceBoundaryStatistics statistics;
+
+    public CockpitDeviceBoundaryResponse(String requestId, String apiVersion, boolean readOnly,
+            CockpitTypeScope typeScope, List<CockpitFacilityBoundary> facilities,
+            List<CockpitDeviceBoundary> devices, CockpitDeviceBoundaryStatistics statistics) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.typeScope = typeScope;
+        this.facilities = facilities;
+        this.devices = devices;
+        this.statistics = statistics;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public CockpitTypeScope getTypeScope() { return typeScope; }
+    public List<CockpitFacilityBoundary> getFacilities() { return facilities; }
+    public List<CockpitDeviceBoundary> getDevices() { return devices; }
+    public CockpitDeviceBoundaryStatistics getStatistics() { return statistics; }
+}

+ 218 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryService.java

@@ -0,0 +1,218 @@
+package com.zksy.cockpit.device;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class CockpitDeviceBoundaryService {
+    private static final List<String> FORMAL_INDUSTRIES =
+            Collections.unmodifiableList(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE"));
+    private static final List<String> DRAINAGE_SCOPES =
+            Collections.unmodifiableList(Arrays.asList("RAINWATER", "SEWAGE"));
+    private static final List<String> OBJECT_KINDS =
+            Collections.unmodifiableList(Arrays.asList("FACILITY", "SMART_DEVICE"));
+
+    private final CockpitDeviceBoundaryAdapter adapter;
+    private final Clock clock;
+
+    public CockpitDeviceBoundaryService(CockpitDeviceBoundaryAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitDeviceBoundaryResponse load(String requestId) {
+        CockpitDeviceBoundarySnapshot snapshot = adapter.load();
+        Map<String, CockpitDeviceBoundarySnapshot.SourceType> types = snapshot == null || snapshot.getTypes() == null
+                ? Collections.emptyMap()
+                : snapshot.getTypes().stream()
+                        .filter(type -> type.getCode() != null)
+                        .collect(Collectors.toMap(
+                                CockpitDeviceBoundarySnapshot.SourceType::getCode,
+                                type -> type,
+                                (left, right) -> left));
+
+        List<CockpitFacilityBoundary> facilities = snapshot == null || snapshot.getFacilities() == null
+                ? Collections.emptyList()
+                : snapshot.getFacilities().stream()
+                        .map(facility -> mapFacility(facility, types))
+                        .collect(Collectors.toList());
+        List<CockpitDeviceBoundary> devices = snapshot == null || snapshot.getDevices() == null
+                ? Collections.emptyList()
+                : snapshot.getDevices().stream()
+                        .map(device -> mapDevice(device, types))
+                        .collect(Collectors.toList());
+        int typeKindMismatchCount = typeKindMismatchCount(snapshot, types);
+        int unknownTypeCount = unknownTypeCount(snapshot, types);
+
+        return new CockpitDeviceBoundaryResponse(
+                requestId,
+                "v1",
+                true,
+                new CockpitTypeScope(FORMAL_INDUSTRIES, DRAINAGE_SCOPES, OBJECT_KINDS),
+                facilities,
+                devices,
+                statistics(facilities, devices, unknownTypeCount, typeKindMismatchCount));
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱设备边界数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private static CockpitFacilityBoundary mapFacility(CockpitDeviceBoundarySnapshot.SourceFacility facility,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        CockpitDeviceBoundarySnapshot.SourceType type = types.get(facility.getTypeCode());
+        String industry = type == null ? null : type.getIndustry();
+        return new CockpitFacilityBoundary(
+                facility.getFacilityCode(),
+                facility.getFacilityName(),
+                new CockpitDeviceType(facility.getTypeCode(), type == null ? null : type.getName()),
+                "FACILITY",
+                industry,
+                type != null
+                        && "FACILITY".equals(type.getObjectKind())
+                        && industry != null
+                        && FORMAL_INDUSTRIES.contains(industry));
+    }
+
+    private static CockpitDeviceBoundary mapDevice(CockpitDeviceBoundarySnapshot.SourceDevice device,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        CockpitDeviceBoundarySnapshot.SourceType type = types.get(device.getTypeCode());
+        String industry = type == null ? null : type.getIndustry();
+        String drainageScope = type == null ? null : type.getDrainageScope();
+        boolean formalScope = industry != null
+                && FORMAL_INDUSTRIES.contains(industry)
+                && (type != null && "SMART_DEVICE".equals(type.getObjectKind()));
+        String typeResolution = type == null
+                ? "UNKNOWN_TYPE"
+                : "SMART_DEVICE".equals(type.getObjectKind()) ? "RESOLVED" : "TYPE_KIND_MISMATCH";
+        return new CockpitDeviceBoundary(
+                device.getEquipmentCode(),
+                device.getDeviceName(),
+                new CockpitDeviceType(device.getTypeCode(), type == null ? null : type.getName()),
+                industry,
+                drainageScope,
+                "SMART_DEVICE",
+                formalScope,
+                typeResolution);
+    }
+
+    private static CockpitDeviceBoundaryStatistics statistics(List<CockpitFacilityBoundary> facilities,
+            List<CockpitDeviceBoundary> devices, int unknownTypeCount, int typeKindMismatchCount) {
+        Map<String, CockpitIndustryStatistics> industries = new LinkedHashMap<>();
+        industries.put("gas", new CockpitIndustryStatistics());
+        industries.put("waterSupply", new CockpitIndustryStatistics());
+        industries.put("drainage", new CockpitIndustryStatistics());
+
+        for (CockpitFacilityBoundary facility : facilities) {
+            if (facility.isFormalScope()) {
+                CockpitIndustryStatistics industryStatistics = industryBucket(industries, facility.getIndustry());
+                if (industryStatistics != null) {
+                    industryStatistics.addFacility();
+                }
+            }
+        }
+        for (CockpitDeviceBoundary device : devices) {
+            if (device.isFormalScope()) {
+                CockpitIndustryStatistics industryStatistics = industryBucket(industries, device.getIndustry());
+                if (industryStatistics != null) {
+                    industryStatistics.addSmartDevice();
+                }
+                if ("DRAINAGE".equals(device.getIndustry()) && "RAINWATER".equals(device.getDrainageScope())) {
+                    industries.get("drainage").addRainwaterSmartDevice();
+                } else if ("DRAINAGE".equals(device.getIndustry()) && "SEWAGE".equals(device.getDrainageScope())) {
+                    industries.get("drainage").addSewageSmartDevice();
+                }
+            }
+        }
+
+        return new CockpitDeviceBoundaryStatistics(
+                facilities.size(),
+                devices.size(),
+                (int) devices.stream().filter(CockpitDeviceBoundary::isFormalScope).count(),
+                unknownTypeCount,
+                typeKindMismatchCount,
+                industries);
+    }
+
+    private static int unknownTypeCount(CockpitDeviceBoundarySnapshot snapshot,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        if (snapshot == null) {
+            return 0;
+        }
+        int unknownCount = 0;
+        if (snapshot.getFacilities() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceFacility facility : snapshot.getFacilities()) {
+                if (!types.containsKey(facility.getTypeCode())) {
+                    unknownCount++;
+                }
+            }
+        }
+        if (snapshot.getDevices() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceDevice device : snapshot.getDevices()) {
+                if (!types.containsKey(device.getTypeCode())) {
+                    unknownCount++;
+                }
+            }
+        }
+        return unknownCount;
+    }
+
+    private static int typeKindMismatchCount(CockpitDeviceBoundarySnapshot snapshot,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        if (snapshot == null) {
+            return 0;
+        }
+        int mismatchCount = 0;
+        if (snapshot.getFacilities() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceFacility facility : snapshot.getFacilities()) {
+                CockpitDeviceBoundarySnapshot.SourceType type = types.get(facility.getTypeCode());
+                if (type != null && !"FACILITY".equals(type.getObjectKind())) {
+                    mismatchCount++;
+                }
+            }
+        }
+        if (snapshot.getDevices() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceDevice device : snapshot.getDevices()) {
+                CockpitDeviceBoundarySnapshot.SourceType type = types.get(device.getTypeCode());
+                if (type != null && !"SMART_DEVICE".equals(type.getObjectKind())) {
+                    mismatchCount++;
+                }
+            }
+        }
+        return mismatchCount;
+    }
+
+    private static CockpitIndustryStatistics industryBucket(Map<String, CockpitIndustryStatistics> industries,
+            String industry) {
+        if ("GAS".equals(industry)) {
+            return industries.get("gas");
+        }
+        if ("WATER_SUPPLY".equals(industry)) {
+            return industries.get("waterSupply");
+        }
+        if ("DRAINAGE".equals(industry)) {
+            return industries.get("drainage");
+        }
+        return null;
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}

+ 82 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundarySnapshot.java

@@ -0,0 +1,82 @@
+package com.zksy.cockpit.device;
+
+import java.util.List;
+
+public class CockpitDeviceBoundarySnapshot {
+    private final List<SourceType> types;
+    private final List<SourceFacility> facilities;
+    private final List<SourceDevice> devices;
+
+    public CockpitDeviceBoundarySnapshot(List<SourceType> types, List<SourceFacility> facilities,
+            List<SourceDevice> devices) {
+        this.types = types;
+        this.facilities = facilities;
+        this.devices = devices;
+    }
+
+    public List<SourceType> getTypes() {
+        return types;
+    }
+
+    public List<SourceFacility> getFacilities() {
+        return facilities;
+    }
+
+    public List<SourceDevice> getDevices() {
+        return devices;
+    }
+
+    public static class SourceType {
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String drainageScope;
+        private final String objectKind;
+
+        public SourceType(String code, String name, String industry, String drainageScope, String objectKind) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+            this.drainageScope = drainageScope;
+            this.objectKind = objectKind;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public String getIndustry() { return industry; }
+        public String getDrainageScope() { return drainageScope; }
+        public String getObjectKind() { return objectKind; }
+    }
+
+    public static class SourceFacility {
+        private final String facilityCode;
+        private final String facilityName;
+        private final String typeCode;
+
+        public SourceFacility(String facilityCode, String facilityName, String typeCode) {
+            this.facilityCode = facilityCode;
+            this.facilityName = facilityName;
+            this.typeCode = typeCode;
+        }
+
+        public String getFacilityCode() { return facilityCode; }
+        public String getFacilityName() { return facilityName; }
+        public String getTypeCode() { return typeCode; }
+    }
+
+    public static class SourceDevice {
+        private final String equipmentCode;
+        private final String deviceName;
+        private final String typeCode;
+
+        public SourceDevice(String equipmentCode, String deviceName, String typeCode) {
+            this.equipmentCode = equipmentCode;
+            this.deviceName = deviceName;
+            this.typeCode = typeCode;
+        }
+
+        public String getEquipmentCode() { return equipmentCode; }
+        public String getDeviceName() { return deviceName; }
+        public String getTypeCode() { return typeCode; }
+    }
+}

+ 30 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryStatistics.java

@@ -0,0 +1,30 @@
+package com.zksy.cockpit.device;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class CockpitDeviceBoundaryStatistics {
+    private final int facilityCount;
+    private final int smartDeviceCount;
+    private final int formalSmartDeviceCount;
+    private final int unknownTypeCount;
+    private final int typeKindMismatchCount;
+    private final Map<String, CockpitIndustryStatistics> industries;
+
+    public CockpitDeviceBoundaryStatistics(int facilityCount, int smartDeviceCount, int formalSmartDeviceCount,
+            int unknownTypeCount, int typeKindMismatchCount, Map<String, CockpitIndustryStatistics> industries) {
+        this.facilityCount = facilityCount;
+        this.smartDeviceCount = smartDeviceCount;
+        this.formalSmartDeviceCount = formalSmartDeviceCount;
+        this.unknownTypeCount = unknownTypeCount;
+        this.typeKindMismatchCount = typeKindMismatchCount;
+        this.industries = new LinkedHashMap<>(industries);
+    }
+
+    public int getFacilityCount() { return facilityCount; }
+    public int getSmartDeviceCount() { return smartDeviceCount; }
+    public int getFormalSmartDeviceCount() { return formalSmartDeviceCount; }
+    public int getUnknownTypeCount() { return unknownTypeCount; }
+    public int getTypeKindMismatchCount() { return typeKindMismatchCount; }
+    public Map<String, CockpitIndustryStatistics> getIndustries() { return industries; }
+}

+ 14 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceType.java

@@ -0,0 +1,14 @@
+package com.zksy.cockpit.device;
+
+public class CockpitDeviceType {
+    private final String code;
+    private final String name;
+
+    public CockpitDeviceType(String code, String name) {
+        this.code = code;
+        this.name = name;
+    }
+
+    public String getCode() { return code; }
+    public String getName() { return name; }
+}

+ 27 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitFacilityBoundary.java

@@ -0,0 +1,27 @@
+package com.zksy.cockpit.device;
+
+public class CockpitFacilityBoundary {
+    private final String facilityCode;
+    private final String facilityName;
+    private final CockpitDeviceType facilityType;
+    private final String objectKind;
+    private final String industry;
+    private final boolean formalScope;
+
+    public CockpitFacilityBoundary(String facilityCode, String facilityName, CockpitDeviceType facilityType,
+            String objectKind, String industry, boolean formalScope) {
+        this.facilityCode = facilityCode;
+        this.facilityName = facilityName;
+        this.facilityType = facilityType;
+        this.objectKind = objectKind;
+        this.industry = industry;
+        this.formalScope = formalScope;
+    }
+
+    public String getFacilityCode() { return facilityCode; }
+    public String getFacilityName() { return facilityName; }
+    public CockpitDeviceType getFacilityType() { return facilityType; }
+    public String getObjectKind() { return objectKind; }
+    public String getIndustry() { return industry; }
+    public boolean isFormalScope() { return formalScope; }
+}

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

@@ -0,0 +1,18 @@
+package com.zksy.cockpit.device;
+
+public class CockpitIndustryStatistics {
+    private int facilityCount;
+    private int smartDeviceCount;
+    private int rainwaterSmartDeviceCount;
+    private int sewageSmartDeviceCount;
+
+    public int getFacilityCount() { return facilityCount; }
+    public int getSmartDeviceCount() { return smartDeviceCount; }
+    public int getRainwaterSmartDeviceCount() { return rainwaterSmartDeviceCount; }
+    public int getSewageSmartDeviceCount() { return sewageSmartDeviceCount; }
+
+    void addFacility() { facilityCount++; }
+    void addSmartDevice() { smartDeviceCount++; }
+    void addRainwaterSmartDevice() { rainwaterSmartDeviceCount++; }
+    void addSewageSmartDevice() { sewageSmartDeviceCount++; }
+}

+ 19 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitTypeScope.java

@@ -0,0 +1,19 @@
+package com.zksy.cockpit.device;
+
+import java.util.List;
+
+public class CockpitTypeScope {
+    private final List<String> formalIndustries;
+    private final List<String> drainageScopes;
+    private final List<String> objectKinds;
+
+    public CockpitTypeScope(List<String> formalIndustries, List<String> drainageScopes, List<String> objectKinds) {
+        this.formalIndustries = formalIndustries;
+        this.drainageScopes = drainageScopes;
+        this.objectKinds = objectKinds;
+    }
+
+    public List<String> getFormalIndustries() { return formalIndustries; }
+    public List<String> getDrainageScopes() { return drainageScopes; }
+    public List<String> getObjectKinds() { return objectKinds; }
+}

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/EmptyCockpitDeviceBoundaryAdapter.java

@@ -0,0 +1,13 @@
+package com.zksy.cockpit.device;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+
+@Component
+public class EmptyCockpitDeviceBoundaryAdapter implements CockpitDeviceBoundaryAdapter {
+    @Override
+    public CockpitDeviceBoundarySnapshot load() {
+        return new CockpitDeviceBoundarySnapshot(Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
+    }
+}

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