Ver Fonte

Merge remote-tracking branch 'origin/master'

zengy há 1 semana atrás
pai
commit
cabd21d8af
35 ficheiros alterados com 1738 adições e 118 exclusões
  1. 3 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureEmergencySupportController.java
  2. 3 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureOperatingInformationController.java
  3. 3 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewController.java
  4. 3 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationController.java
  5. 27 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesController.java
  6. 8 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadController.java
  7. 331 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitDistrictFilterContractTest.java
  8. 4 4
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesControllerTest.java
  9. 18 18
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesServiceTest.java
  10. 64 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitDistrictPrefix.java
  11. 15 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureEmergencySupportAdapter.java
  12. 81 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportResponse.java
  13. 87 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportService.java
  14. 24 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportSnapshot.java
  15. 75 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationResponse.java
  16. 67 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationService.java
  17. 10 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java
  18. 79 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewResponse.java
  19. 86 12
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java
  20. 34 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java
  21. 55 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationResponse.java
  22. 101 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationService.java
  23. 44 14
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewModulesAdapter.java
  24. 20 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesAdapter.java
  25. 164 14
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesService.java
  26. 12 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkSnapshot.java
  27. 31 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewReadEnvelope.java
  28. 11 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewModulesAdapter.java
  29. 55 13
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/mapper/CockpitOverviewModulesMapper.java
  30. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  31. 42 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderListResponse.java
  32. 6 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadAdapter.java
  33. 17 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadService.java
  34. 1 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/EmptyCockpitWorkOrderReadAdapter.java
  35. 144 9
      pipe-network-service/zksy-system/src/main/resources/mapper/cockpit/CockpitOverviewModulesMapper.xml

+ 3 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureEmergencySupportController.java

@@ -32,11 +32,13 @@ public class CockpitInfrastructureEmergencySupportController {
     public ResponseEntity<AjaxResult> emergencySupport(
             @RequestParam(value = "category", required = false) String category,
             @RequestParam(value = "industry", required = false) String industry,
+            // 片区(票 81):0/1/2/UNASSIGNED,不传=全域,非法值 400(校验在 service)。
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "area", required = false) String area,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
                 emergencySupportService.load(
-                        resolveRequestId(requestId), category, industry, area)));
+                        resolveRequestId(requestId), category, industry, area, district)));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)

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

@@ -31,6 +31,8 @@ public class CockpitInfrastructureOperatingInformationController {
     @GetMapping("/api/cockpit/v1/infrastructure/operating-information")
     public ResponseEntity<AjaxResult> operatingInformation(
             @RequestParam(value = "industry", required = false) String industry,
+            // 片区(票 81):0/1/2/UNASSIGNED,不传=全域,非法值 400(校验在 service)。
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "range", required = false) String range,
             @RequestParam(value = "from", required = false) String from,
             @RequestParam(value = "to", required = false) String to,
@@ -39,6 +41,7 @@ public class CockpitInfrastructureOperatingInformationController {
                 operatingInformationService.load(
                         resolveRequestId(requestId),
                         industry,
+                        district,
                         range,
                         from,
                         to)));

+ 3 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewController.java

@@ -31,9 +31,11 @@ public class CockpitMonitoringOverviewController {
     public ResponseEntity<AjaxResult> monitoringOverview(
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "deviceType", required = false) String deviceType,
+            // 片区(票 81):0/1/2/UNASSIGNED,不传=全域,非法值 400(校验在 service)。
+            @RequestParam(value = "district", required = false) String district,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
-                overviewService.load(resolveRequestId(requestId), industry, deviceType)));
+                overviewService.load(resolveRequestId(requestId), industry, deviceType, district)));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)

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

@@ -32,6 +32,8 @@ public class CockpitMonitoringSituationController {
     public ResponseEntity<AjaxResult> monitoringSituation(
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "deviceType", required = false) String deviceType,
+            // 片区(票 81):0/1/2/UNASSIGNED,不传=全域,非法值 400(校验在 service)。
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "startTime", required = false) String startTime,
             @RequestParam(value = "endTime", required = false) String endTime,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
@@ -42,6 +44,7 @@ public class CockpitMonitoringSituationController {
                         resolveRequestId(requestId),
                         industry,
                         deviceType,
+                        district,
                         startTime,
                         endTime,
                         page,

+ 27 - 4
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesController.java

@@ -1,6 +1,7 @@
 package com.zksy.web.controller.cockpit;
 
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitReadException;
 import com.zksy.cockpit.overview.CockpitOverviewModulesService;
 import com.zksy.common.core.domain.AjaxResult;
@@ -11,6 +12,7 @@ import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.servlet.http.HttpServletRequest;
@@ -33,30 +35,51 @@ public class CockpitOverviewModulesController {
 
     @GetMapping("/api/cockpit/v1/overview/perception")
     public ResponseEntity<AjaxResult> perception(
+            @RequestParam(value = "district", required = false) String district,
+            @RequestParam(value = "industry", required = false) String industry,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
-                modulesService.loadPerception(resolveRequestId(requestId))));
+                modulesService.loadPerception(resolveRequestId(requestId), district, industry)));
     }
 
     @GetMapping("/api/cockpit/v1/overview/pipe-network")
     public ResponseEntity<AjaxResult> pipeNetwork(
+            @RequestParam(value = "district", required = false) String district,
+            @RequestParam(value = "industry", required = false) String industry,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
-                modulesService.loadPipeNetwork(resolveRequestId(requestId))));
+                modulesService.loadPipeNetwork(resolveRequestId(requestId), district, industry)));
     }
 
     @GetMapping("/api/cockpit/v1/overview/warning-study")
     public ResponseEntity<AjaxResult> warningStudy(
+            @RequestParam(value = "district", required = false) String district,
+            @RequestParam(value = "industry", required = false) String industry,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
-                modulesService.loadWarningStudy(resolveRequestId(requestId))));
+                modulesService.loadWarningStudy(resolveRequestId(requestId), district, industry)));
     }
 
     @GetMapping("/api/cockpit/v1/overview/alarm-disposal")
     public ResponseEntity<AjaxResult> alarmDisposal(
+            @RequestParam(value = "district", required = false) String district,
+            @RequestParam(value = "industry", required = false) String industry,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
-                modulesService.loadAlarmDisposal(resolveRequestId(requestId))));
+                modulesService.loadAlarmDisposal(resolveRequestId(requestId), district, industry)));
+    }
+
+    /**
+     * 入参非法一律 400(票 81):片区只认 {@code 0/1/2/UNASSIGNED}、行业只认四个正式编码,
+     * 与事件族端点同一条纪律 —— 静默忽略会造出「以为筛了、其实全域」的假绿。
+     */
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, modulesService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
     }
 
     @ExceptionHandler(CockpitReadException.class)

+ 8 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadController.java

@@ -82,6 +82,10 @@ public class CockpitWorkOrderReadController {
      * <p>industry 是票 77 补的:口径经「工单设备 → 设备类型 → 行业」派生,
      * 与 handling-efficiency 的工单范围同一套规则(列表与状态分布必须筛同一批工单)。
      *
+     * <p>district 是票 81 补的:口径经「工单设备 → {@code equipment_base.district}」派生,
+     * 与上面同一条纪律 —— 列表与上方状态分布筛的必须是同一批工单。
+     * 取值 {@code 0/1/2/UNASSIGNED},不传=全域,非法值 400(校验在 service)。
+     *
      * <p>`startTime`/`endTime` 是 2026-10-22 维护者反馈「工单列表没按时间筛」后补的:
      * 面板按近 7/30/90 天切换时,列表必须跟着收窄(否则上方的状态分布按窗口统计、
      * 下方列表是全库,读者会以为自相矛盾)。
@@ -93,11 +97,14 @@ public class CockpitWorkOrderReadController {
             @RequestParam(value = "stateCode", required = false) Integer stateCode,
             // 行业(票 77):GAS/WATER_SUPPLY/DRAINAGE/MANHOLE,不传=全域,非法值 400。
             @RequestParam(value = "industry", required = false) String industry,
+            // 片区(票 81):0/1/2/UNASSIGNED,不传=全域,非法值 400。
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "startTime", required = false) String startTime,
             @RequestParam(value = "endTime", required = false) String endTime,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         CockpitWorkOrderListResponse response = workOrderService.loadAll(
-                resolveRequestId(requestId), page, pageSize, stateCode, industry, startTime, endTime);
+                resolveRequestId(requestId), page, pageSize, stateCode, industry, district,
+                startTime, endTime);
         return ResponseEntity.ok(AjaxResult.success(response));
     }
 

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

@@ -0,0 +1,331 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportSnapshot;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationSnapshot;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewSnapshot;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationService;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationSnapshot;
+import com.zksy.cockpit.overview.CockpitOverviewModulesAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewModulesService;
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionSnapshot;
+import com.zksy.cockpit.workorder.CockpitWorkOrderListSnapshot;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadAdapter;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.web.servlet.MockMvc;
+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.Date;
+import java.util.List;
+
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 票 81 —— 全局筛选(片区/行业)的**契约**测试:每个端点四条判据
+ * (合法值真的生效 / 非法值 400 / 不传=全域 / {@code filters} 双字段回显)。
+ *
+ * <p>与既有 Controller 测试的分工:那些测试锁「既有读数口径」,本类只锁票 81 新加的参数面,
+ * 所以用 mock 适配器 + 真实 service/controller,断言的是「参数怎么进、结果怎么出、回显什么」。
+ */
+class CockpitDistrictFilterContractTest {
+
+    private static final Clock CLOCK =
+            Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
+    private static final String INVALID_DISTRICT_MESSAGE = "district must be 0, 1, 2 or UNASSIGNED";
+
+    // ------------------------------------------------------------ 监测概览
+
+    @Test
+    void monitoringOverviewFiltersDevicesByDistrictAndEchoesFilters() throws Exception {
+        CockpitMonitoringOverviewAdapter adapter = mock(CockpitMonitoringOverviewAdapter.class);
+        when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T10:00:00+08:00",
+                Arrays.asList(
+                        new CockpitMonitoringOverviewSnapshot.SourceType("14", "消火栓", "WATER_SUPPLY", "SMART_DEVICE")),
+                Arrays.asList(
+                        device("DEV-S", 0),
+                        device("DEV-N", 1),
+                        device("DEV-U", null)),
+                Collections.emptyList()));
+        MockMvc mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitMonitoringOverviewController(
+                        new CockpitMonitoringOverviewService(adapter, CLOCK)))
+                .build();
+
+        // 不传=全域:三台都算
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/overview"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(3))
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()));
+
+        // district=0:只剩城南那一台,且回显片区编码与展示名双字段
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/overview").param("district", "0"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.devices", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").value("DEV-S"))
+                .andExpect(jsonPath("$.data.devices[0].district").value(0))
+                .andExpect(jsonPath("$.data.devices[0].districtName").value("城南"))
+                .andExpect(jsonPath("$.data.filters.district").value(0))
+                .andExpect(jsonPath("$.data.filters.districtName").value("城南"));
+
+        // UNASSIGNED:只看未分区
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/overview").param("district", "UNASSIGNED"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").value("DEV-U"))
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value("未分区"));
+
+        // 非法值 400(静默忽略会造假绿)
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/overview").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
+    }
+
+    // ------------------------------------------------------------ 运行概况
+
+    @Test
+    void monitoringSituationFiltersDevicesAndTrendsByDistrict() throws Exception {
+        CockpitMonitoringSituationAdapter adapter = mock(CockpitMonitoringSituationAdapter.class);
+        when(adapter.load()).thenReturn(situationSnapshot());
+        MockMvc mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitMonitoringSituationController(
+                        new CockpitMonitoringSituationService(adapter, CLOCK)))
+                .build();
+
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/situation"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()));
+
+        // KPI(指标态分布)与行业覆盖率都随片区收敛,不是只筛异常清单
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/situation").param("district", "1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.industryCoverage", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.industryCoverage[0].deviceCount").value(1))
+                .andExpect(jsonPath("$.data.abnormalEquipment", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.abnormalEquipment[0].district").value(1))
+                .andExpect(jsonPath("$.data.filters.district").value(1))
+                .andExpect(jsonPath("$.data.filters.districtName").value("城北"));
+
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/situation").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
+    }
+
+    // ------------------------------------------------------------ 运行信息(设施编码前缀)
+
+    @Test
+    void operatingInformationFiltersByFacilityCodePrefix() throws Exception {
+        CockpitInfrastructureOperatingInformationAdapter adapter =
+                mock(CockpitInfrastructureOperatingInformationAdapter.class);
+        when(adapter.load()).thenReturn(new CockpitInfrastructureOperatingInformationSnapshot(
+                "2026-09-02T10:00:00+08:00",
+                Arrays.asList(
+                        operatingMetric("water_plant_current_supply", "TCRQ0001"),
+                        operatingMetric("water_plant_current_supply", "CNRQ0002"))));
+        MockMvc mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitInfrastructureOperatingInformationController(
+                        new CockpitInfrastructureOperatingInformationService(adapter, CLOCK, 60)))
+                .build();
+
+        // 太常(2):只剩 TC 前缀那条,逐条带片区可本地复核
+        mockMvc.perform(get("/api/cockpit/v1/infrastructure/operating-information").param("district", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.industries[1].metrics", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].pointCodes[0]").value("TCRQ0001"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].district").value(2))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].districtName").value("太常"))
+                .andExpect(jsonPath("$.data.filters.district").value(2))
+                .andExpect(jsonPath("$.data.filters.districtName").value("太常"));
+
+        mockMvc.perform(get("/api/cockpit/v1/infrastructure/operating-information").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
+    }
+
+    // ------------------------------------------------------------ 应急保障(对象→设备→片区)
+
+    @Test
+    void emergencySupportFiltersByObjectDistrictAndKeepsAreaSeparate() throws Exception {
+        CockpitInfrastructureEmergencySupportAdapter adapter =
+                mock(CockpitInfrastructureEmergencySupportAdapter.class);
+        when(adapter.load()).thenReturn(new CockpitInfrastructureEmergencySupportSnapshot(
+                "2026-09-02T10:00:00+08:00",
+                Collections.singletonList(
+                        new CockpitInfrastructureEmergencySupportSnapshot.SourceSection(
+                                "EMERGENCY_RESOURCE", "READY", "READY",
+                                Arrays.asList(
+                                        emergencyObject("RES-S", 0),
+                                        emergencyObject("RES-N", 1))))));
+        MockMvc mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitInfrastructureEmergencySupportController(
+                        new CockpitInfrastructureEmergencySupportService(adapter, CLOCK)))
+                .build();
+
+        mockMvc.perform(get("/api/cockpit/v1/infrastructure/emergency-support").param("district", "0"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totals.emergencyResourceCount").value(1))
+                .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].objectCode").value("RES-S"))
+                .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].district").value(0))
+                .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].districtName").value("城南"))
+                // area 仍是地址字段,不被片区码空间冒充
+                .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].area").value("沅陵镇"))
+                .andExpect(jsonPath("$.data.selectedFilters.district").value(0))
+                .andExpect(jsonPath("$.data.selectedFilters.districtName").value("城南"))
+                // 三个正式片区 + 未分区,四行全铺
+                .andExpect(jsonPath("$.data.districtDistribution", org.hamcrest.Matchers.hasSize(4)))
+                .andExpect(jsonPath("$.data.districtDistribution[3].districtName").value("未分区"));
+
+        mockMvc.perform(get("/api/cockpit/v1/infrastructure/emergency-support").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
+    }
+
+    // ------------------------------------------------------------ 全量工单
+
+    @Test
+    void workOrdersPassParsedDistrictToAdapterAndEchoFilters() throws Exception {
+        CockpitWorkOrderReadAdapter adapter = mock(CockpitWorkOrderReadAdapter.class);
+        when(adapter.loadAll(any(), any(), any(), any(), any(), anyInt(), anyInt()))
+                .thenReturn(new CockpitWorkOrderListSnapshot(
+                        Collections.emptyList(), 0, 1, 10, null));
+        MockMvc mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitWorkOrderReadController(
+                        new CockpitWorkOrderReadService(adapter, CLOCK)))
+                .build();
+
+        mockMvc.perform(get("/api/cockpit/v1/work-orders").param("district", "2").param("industry", "GAS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(2))
+                .andExpect(jsonPath("$.data.filters.districtName").value("太常"))
+                .andExpect(jsonPath("$.data.filters.industry").value("GAS"));
+
+        ArgumentCaptor<CockpitDistrict.Query> captor = ArgumentCaptor.forClass(CockpitDistrict.Query.class);
+        verify(adapter).loadAll(any(), any(), any(), any(), captor.capture(), anyInt(), anyInt());
+        assertEquals(Integer.valueOf(2), captor.getValue().code());
+
+        mockMvc.perform(get("/api/cockpit/v1/work-orders").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
+    }
+
+    // ------------------------------------------------------------ 概览四模块(选项 a:下推 SQL)
+
+    @Test
+    void overviewModulesParseDistrictAndIndustryAndRejectIllegalValues() throws Exception {
+        CockpitOverviewModulesAdapter adapter = mock(CockpitOverviewModulesAdapter.class);
+        when(adapter.loadPerception(any(), any())).thenReturn(new CockpitOverviewPerceptionSnapshot(
+                null,
+                Collections.singletonList(new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(
+                        1, 2, true, true, 1)),
+                Collections.emptyList()));
+        MockMvc mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitOverviewModulesController(
+                        new CockpitOverviewModulesService(adapter, CLOCK)))
+                .build();
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/perception")
+                        .param("district", "UNASSIGNED")
+                        .param("industry", "GAS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.filters.industry").value("GAS"));
+
+        // 片区/行业都落成「下推 SQL 用的形态」:未分区=code null,行业=根类型 3(燃气)
+        ArgumentCaptor<CockpitDistrict.Query> districtCaptor =
+                ArgumentCaptor.forClass(CockpitDistrict.Query.class);
+        ArgumentCaptor<Integer> rootTypeCaptor = ArgumentCaptor.forClass(Integer.class);
+        verify(adapter).loadPerception(districtCaptor.capture(), rootTypeCaptor.capture());
+        assertNull(districtCaptor.getValue().code());
+        assertEquals(Boolean.TRUE, districtCaptor.getValue().unassignedOnly());
+        assertEquals(Integer.valueOf(3), rootTypeCaptor.getValue());
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/perception").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/perception").param("industry", "WHATEVER"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.msg").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
+    }
+
+    // ------------------------------------------------------------ 编码前缀规则单点
+
+    @Test
+    void facilityCodePrefixRuleIsSingleSourced() {
+        assertEquals(Integer.valueOf(0), CockpitDistrictPrefix.codeOf("CNRQ202609080001"));
+        assertEquals(Integer.valueOf(1), CockpitDistrictPrefix.codeOf("cbws202610150001"));
+        assertEquals(Integer.valueOf(2), CockpitDistrictPrefix.codeOf(" TCRQ202609080001 "));
+        // 推不出前缀一律未分区,不硬塞
+        assertNull(CockpitDistrictPrefix.codeOf("GS001"));
+        assertNull(CockpitDistrictPrefix.codeOf("C"));
+        assertNull(CockpitDistrictPrefix.codeOf(null));
+    }
+
+    // ------------------------------------------------------------ 夹具
+
+    private static CockpitMonitoringOverviewSnapshot.SourceDevice device(String code, Integer district) {
+        return new CockpitMonitoringOverviewSnapshot.SourceDevice(
+                code, code, "14", "ONLINE", null, null, null, district);
+    }
+
+    private static CockpitMonitoringSituationSnapshot situationSnapshot() {
+        List<String> reasons = Collections.emptyList();
+        return new CockpitMonitoringSituationSnapshot(
+                "2026-09-02T10:00:00+08:00",
+                Collections.singletonList(new CockpitMonitoringSituationSnapshot.SourceType(
+                        "14", "消火栓", "WATER_SUPPLY", "SMART_DEVICE")),
+                Arrays.asList(
+                        new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
+                                "DEV-S", "城南设备", 0, "14", "OFFLINE", reasons, null, null, null, false, null),
+                        new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
+                                "DEV-N", "城北设备", 1, "14", "ALARM", reasons, null, null, null, false, null)),
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+
+    private static CockpitInfrastructureOperatingInformationSnapshot.SourceMetric operatingMetric(
+            String metricCode, String facilityCode) {
+        return new CockpitInfrastructureOperatingInformationSnapshot.SourceMetric(
+                metricCode, "水厂实际供水能力", "SUPPLY", "万m³/d", "WATER_SUPPLY",
+                java.math.BigDecimal.ONE, null, null, false,
+                Collections.singletonList(facilityCode), Collections.emptyList());
+    }
+
+    private static CockpitInfrastructureEmergencySupportSnapshot.SourceObject emergencyObject(
+            String code, Integer district) {
+        return new CockpitInfrastructureEmergencySupportSnapshot.SourceObject(
+                code, code, "EMERGENCY_RESOURCE", "WATER_SUPPLY", "沅陵镇",
+                Collections.emptyList(),
+                new CockpitInfrastructureEmergencySupportSnapshot.SourceEventAssociation(0, 0, 0),
+                district);
+    }
+}

+ 4 - 4
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesControllerTest.java

@@ -48,7 +48,7 @@ class CockpitOverviewModulesControllerTest {
 
     @Test
     void perceptionEndpointUsesCockpitEnvelope() throws Exception {
-        when(adapter.loadPerception()).thenReturn(new CockpitOverviewPerceptionSnapshot(
+        when(adapter.loadPerception(any(), any())).thenReturn(new CockpitOverviewPerceptionSnapshot(
                 null,
                 Arrays.asList(new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(1, 0, true, true, 1)),
                 Collections.emptyList()));
@@ -92,7 +92,7 @@ class CockpitOverviewModulesControllerTest {
 
     @Test
     void warningStudyEndpointReturnsFixedStatusKeys() throws Exception {
-        when(adapter.loadWarningStudy(any())).thenReturn(new CockpitOverviewWarningStudySnapshot(
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(new CockpitOverviewWarningStudySnapshot(
                 null, 0, 0, Collections.emptyList(), 0, 30,
                 Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
 
@@ -108,7 +108,7 @@ class CockpitOverviewModulesControllerTest {
 
     @Test
     void alarmDisposalEndpointReturnsSevenTrendPoints() throws Exception {
-        when(adapter.loadAlarmDisposal(any())).thenReturn(new CockpitOverviewAlarmDisposalSnapshot(
+        when(adapter.loadAlarmDisposal(any(), any(), any())).thenReturn(new CockpitOverviewAlarmDisposalSnapshot(
                 null, 0, 0, 0, 0, 0,
                 Collections.emptyList(), Collections.emptyList(),
                 Collections.emptyMap(), Collections.emptyMap()));
@@ -133,7 +133,7 @@ class CockpitOverviewModulesControllerTest {
 
     @Test
     void serverFailureReturnsTypedServerError() throws Exception {
-        when(adapter.loadPerception()).thenThrow(new CockpitReadException("upstream unavailable"));
+        when(adapter.loadPerception(any(), any())).thenThrow(new CockpitReadException("upstream unavailable"));
 
         mockMvc.perform(get("/api/cockpit/v1/overview/perception")
                         .header("X-Request-Id", "perception-http-500"))

+ 18 - 18
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewModulesServiceTest.java

@@ -59,7 +59,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void perceptionCountsAllBucketsAndSplitsFixedCards() {
-        when(adapter.loadPerception()).thenReturn(perception(Arrays.asList(
+        when(adapter.loadPerception(any(), any())).thenReturn(perception(Arrays.asList(
                 bucket(1, 0, true, false, 2),
                 bucket(1, 1, true, false, 1),
                 bucket(2, 0, true, true, 2),
@@ -110,7 +110,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void perceptionWithoutDevicesReturnsEmptyRateInsteadOfZero() {
-        when(adapter.loadPerception()).thenReturn(perception(Collections.emptyList(), Collections.emptyList()));
+        when(adapter.loadPerception(any(), any())).thenReturn(perception(Collections.emptyList(), Collections.emptyList()));
 
         CockpitOverviewPerceptionResponse response = service.loadPerception("perception-002");
 
@@ -127,7 +127,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void perceptionCountsMissingStatusAsOfflineAndExplainsReason() {
-        when(adapter.loadPerception()).thenReturn(perception(
+        when(adapter.loadPerception(any(), any())).thenReturn(perception(
                 Collections.singletonList(bucket(1, 0, false, false, 2))));
 
         CockpitOverviewPerceptionResponse response = service.loadPerception("perception-003");
@@ -141,7 +141,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void perceptionDerivesMonitoringContentAndFallsBackToConstant() {
-        when(adapter.loadPerception()).thenReturn(perception(
+        when(adapter.loadPerception(any(), any())).thenReturn(perception(
                 Arrays.asList(bucket(1, 0, true, true, 1), bucket(3, 0, true, true, 1)),
                 Arrays.asList(
                         content(1, "WARN-PRESSURE", "供水压力监测"),
@@ -162,7 +162,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void perceptionTreatsUnknownTypeAndOutOfDomainDistrictAsExcluded() {
-        when(adapter.loadPerception()).thenReturn(perception(
+        when(adapter.loadPerception(any(), any())).thenReturn(perception(
                 Collections.singletonList(bucket(null, 7, true, true, 3))));
 
         CockpitOverviewPerceptionResponse response = service.loadPerception("perception-005");
@@ -259,12 +259,12 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void warningStudyPassesBusinessWindowsToTheAdapter() {
-        when(adapter.loadWarningStudy(any())).thenReturn(warnings());
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(warnings());
 
         service.loadWarningStudy("warning-001");
 
         ArgumentCaptor<CockpitOverviewWindows> captor = ArgumentCaptor.forClass(CockpitOverviewWindows.class);
-        verify(adapter).loadWarningStudy(captor.capture());
+        verify(adapter).loadWarningStudy(captor.capture(), any(), any());
         CockpitOverviewWindows windows = captor.getValue();
         assertEquals(LocalDateTime.parse("2026-10-21T00:00:00"), windows.yesterdayStart());
         assertEquals(LocalDateTime.parse("2026-10-22T00:00:00"), windows.todayStart());
@@ -276,7 +276,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void warningUnresolvedOnlyCountsFourStatusesAndLevelsAreCrossed() {
-        when(adapter.loadWarningStudy(any())).thenReturn(warnings(1, 0, 42,
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(warnings(1, 0, 42,
                 Arrays.asList(
                         statusLevel("PENDING", 1, 5),
                         statusLevel("PENDING", 2, 4),
@@ -314,7 +314,7 @@ class CockpitOverviewModulesServiceTest {
     void warningByLevelPrefersDictionaryLabelAndFallsBackToFixedFormat() {
         Map<Integer, String> levelNames = new LinkedHashMap<>();
         levelNames.put(1, "一级预警");
-        when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 0,
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(warnings(0, 0, 0,
                 Collections.singletonList(statusLevel("PENDING", 2, 1)),
                 Collections.emptyList(), Collections.emptyMap(), levelNames));
 
@@ -329,7 +329,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void warningTypeRankingTieBreaksByCodeAndDegradesUnknownName() {
-        when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 10,
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(warnings(0, 0, 10,
                 Collections.emptyList(),
                 Arrays.asList(typeCount("WARN-A", 3), typeCount("WARN-B", 2), typeCount("WARN-C", 2),
                         typeCount("WARN-D", 2), typeCount("", 1)),
@@ -355,7 +355,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void warningTypeNameMatchesAcrossDashAndUnderscore() {
-        when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 3,
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(warnings(0, 0, 3,
                 Collections.emptyList(),
                 Collections.singletonList(typeCount("WARN_INSTANT_FLOW", 3)),
                 names("WARN-INSTANT-FLOW", "排水瞬时流量监测"), Collections.emptyMap()));
@@ -368,7 +368,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void warningWithoutRowsIsEmptyNotFailed() {
-        when(adapter.loadWarningStudy(any())).thenReturn(warnings());
+        when(adapter.loadWarningStudy(any(), any(), any())).thenReturn(warnings());
 
         CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-006");
 
@@ -386,7 +386,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void alarmDisposalComputesRateFromTheAuditStatusOneBaseSet() {
-        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(3, 1, 1, 1, 1,
+        when(adapter.loadAlarmDisposal(any(), any(), any())).thenReturn(alarms(3, 1, 1, 1, 1,
                 Arrays.asList(levelCount(1, 1), levelCount(4, 2)),
                 Collections.emptyList(), Collections.emptyMap(),
                 levels(1, "一级报警", 4, "四级报警")));
@@ -412,7 +412,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void alarmDisposalWithoutBaseSetReturnsEmptyRateAndEmptyDurations() {
-        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms());
+        when(adapter.loadAlarmDisposal(any(), any(), any())).thenReturn(alarms());
 
         CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-002");
 
@@ -430,7 +430,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void alarmDisposalSevenDayTrendKeepsZeroPoints() {
-        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(4, 1, 1, 4, 0,
+        when(adapter.loadAlarmDisposal(any(), any(), any())).thenReturn(alarms(4, 1, 1, 4, 0,
                 Collections.emptyList(),
                 Arrays.asList(dayCount("2026-10-16", 1, 5), dayCount("2026-10-21", 1, 3),
                         dayCount("2026-10-22", 1, 3), dayCount("2026-10-15", 9, 9)),
@@ -461,7 +461,7 @@ class CockpitOverviewModulesServiceTest {
         Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> samples = new LinkedHashMap<>();
         samples.put(2, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(109924L, 1, 2));
         samples.put(5, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(789067L, 1, 0));
-        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(2, 0, 0, 1, 1,
+        when(adapter.loadAlarmDisposal(any(), any(), any())).thenReturn(alarms(2, 0, 0, 1, 1,
                 Collections.emptyList(), Collections.emptyList(), samples, Collections.emptyMap()));
 
         CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-004");
@@ -482,7 +482,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void alarmLevelCardsFallBackToFixedAlarmLabels() {
-        when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(1, 0, 0, 1, 0,
+        when(adapter.loadAlarmDisposal(any(), any(), any())).thenReturn(alarms(1, 0, 0, 1, 0,
                 Collections.singletonList(levelCount(1, 1)),
                 Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
 
@@ -496,7 +496,7 @@ class CockpitOverviewModulesServiceTest {
 
     @Test
     void adapterFailureIsReadAsTypedReadException() {
-        when(adapter.loadPerception()).thenThrow(new IllegalStateException("upstream down"));
+        when(adapter.loadPerception(any(), any())).thenThrow(new IllegalStateException("upstream down"));
 
         assertThrows(CockpitReadException.class, () -> service.loadPerception("perception-500"));
         assertTrue(true);

+ 64 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitDistrictPrefix.java

@@ -0,0 +1,64 @@
+package com.zksy.cockpit.foundation;
+
+import com.zksy.base.enums.DistrictEnum;
+
+import java.util.Locale;
+
+/**
+ * 设施编码前缀 → 片区编码的**唯一映射**(执行票 81;规则来源:票 67 §十)。
+ *
+ * <p><b>为什么需要它</b>:设施(管网、水厂、泵站、排水工程设施)在库里**没有片区列** ——
+ * {@code BusinessCockpitInfrastructureBaseArchivesAdapter} 明确写着「设施无权威片区来源,一律保持 null」。
+ * 唯一可用的片区线索是**设施编码前缀**:维护者 2026-10-23 裁定「管线编码 = 片区 + 行业 + 时间编码」,
+ * 前端 {@code domain/pipeNetwork.js} 的 {@code PIPE_DISTRICT_PREFIX} 就是这套规则
+ * ({@code CN}=城南 0 / {@code CB}=城北 1 / {@code TC}=太常 2),并在真实载荷上核过
+ * 6762 条零反例(票 67 §十)。
+ *
+ * <p><b>所以这里把该规则搬到后端</b>,作为后端唯一映射:前端不再自己猜、两端不会各推一套
+ * (票 81 要求 1 的硬条件)。前缀对不上(含编码过短、非 CN/CB/TC)时如实回 {@code null}=未分区,
+ * <b>不硬塞进某个片区</b>,也不按行业或地址字段冒充。
+ *
+ * <p>与 {@link CockpitDistrict} 的分工:那边是**权威片区列**({@code equipment_base.district})
+ * 的归一与入参校验;这边只回答「设施编码能不能推出片区」,产出的编码仍按
+ * {@link CockpitDistrict#codeOf}/{@link CockpitDistrict#nameOf} 的同一套词表解释。
+ */
+public final class CockpitDistrictPrefix {
+
+    /** 城南前缀。 */
+    public static final String SOUTH_CITY = "CN";
+    /** 城北前缀。 */
+    public static final String NORTH_CITY = "CB";
+    /** 太常前缀。 */
+    public static final String TAI_CHANG = "TC";
+
+    private static final int PREFIX_LENGTH = 2;
+
+    private CockpitDistrictPrefix() {
+    }
+
+    /**
+     * 设施编码 → 片区编码({@code 0/1/2});推不出时 {@code null}(=未分区)。
+     *
+     * <p>只取编码前两位、大小写不敏感;不做模糊匹配,也不回退到名称/地址等其它字段。
+     */
+    public static Integer codeOf(String facilityCode) {
+        if (facilityCode == null) {
+            return null;
+        }
+        String trimmed = facilityCode.trim();
+        if (trimmed.length() < PREFIX_LENGTH) {
+            return null;
+        }
+        String prefix = trimmed.substring(0, PREFIX_LENGTH).toUpperCase(Locale.ROOT);
+        if (SOUTH_CITY.equals(prefix)) {
+            return DistrictEnum.SOUTH_CITY.getCode();
+        }
+        if (NORTH_CITY.equals(prefix)) {
+            return DistrictEnum.NORTH_CITY.getCode();
+        }
+        if (TAI_CHANG.equals(prefix)) {
+            return DistrictEnum.TAI_CHANG.getCode();
+        }
+        return null;
+    }
+}

+ 15 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureEmergencySupportAdapter.java

@@ -12,6 +12,7 @@ import com.zksy.base.service.HazardHiddenAccountService;
 import com.zksy.base.service.RiskAssessmentService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.service.WorkOrderService;
@@ -122,7 +123,9 @@ public class BusinessCockpitInfrastructureEmergencySupportAdapter
                     industry,
                     item.getEquipmentLocation(),
                     Collections.emptyList(),
-                    counts));
+                    counts,
+                    // 应急资源就是设备台账行:设备自身片区即权威来源(票 81)
+                    CockpitDistrict.codeOf(item.getDistrict())));
             latestUpdatedAt = latest(latestUpdatedAt, format(item.getUpdateTime()));
         }
 
@@ -152,7 +155,10 @@ public class BusinessCockpitInfrastructureEmergencySupportAdapter
                     industry,
                     null,
                     Collections.emptyList(),
-                    counts));
+                    counts,
+                    // 隐患台账无片区列:沿其关联设备取片区(同名「经设备回联」规则,票 81);
+                    // 未关联设备时如实为 null=未分区,不猜。
+                    linked == null ? null : CockpitDistrict.codeOf(linked.getDistrict())));
             latestUpdatedAt = latest(latestUpdatedAt, format(hazard.getUpdateTime()));
         }
 
@@ -170,7 +176,9 @@ public class BusinessCockpitInfrastructureEmergencySupportAdapter
                     "GAS",
                     null,
                     Collections.emptyList(),
-                    EventCounts.EMPTY));
+                    EventCounts.EMPTY,
+                    // 风险评估与设备台账没有关联键:如实归「未分区」,不按所属工程/地址冒充(票 81)。
+                    null));
             latestUpdatedAt = latest(latestUpdatedAt, format(risk.getUpdateTime()));
         }
 
@@ -228,7 +236,8 @@ public class BusinessCockpitInfrastructureEmergencySupportAdapter
             String industry,
             String area,
             List<String> pointCodes,
-            EventCounts counts) {
+            EventCounts counts,
+            Integer district) {
         return new CockpitInfrastructureEmergencySupportSnapshot.SourceObject(
                 code,
                 firstNonBlank(name, code),
@@ -237,7 +246,8 @@ public class BusinessCockpitInfrastructureEmergencySupportAdapter
                 trimToNull(area),
                 pointCodes,
                 new CockpitInfrastructureEmergencySupportSnapshot.SourceEventAssociation(
-                        counts.warningCount, counts.alarmCount, counts.workOrderCount));
+                        counts.warningCount, counts.alarmCount, counts.workOrderCount),
+                district);
     }
 
     private static boolean isClosedAlarm(AlarmData alarm) {

+ 81 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportResponse.java

@@ -19,6 +19,12 @@ public class CockpitInfrastructureEmergencySupportResponse {
     private final EventSummary eventSummary;
     private final MapFilter mapFilter;
     private final Excluded excluded;
+    /**
+     * 片区分布(票 81):三个正式片区 + 未分区四行全铺,计数口径=本响应的对象集合
+     * (已被 category/industry/area/district 筛过)。与 {@code areaDistribution} 刻意分开 ——
+     * {@code area} 是**地址串**,不是片区码空间,两者不能互相冒充。
+     */
+    private final List<DistrictCount> districtDistribution;
 
     public CockpitInfrastructureEmergencySupportResponse(
             String requestId,
@@ -37,6 +43,29 @@ public class CockpitInfrastructureEmergencySupportResponse {
             EventSummary eventSummary,
             MapFilter mapFilter,
             Excluded excluded) {
+        this(requestId, apiVersion, readOnly, displayName, timezone, updatedAt, freshness,
+                selectedFilters, totals, sections, industryDistribution, areaDistribution,
+                categoryDistribution, eventSummary, mapFilter, excluded, null);
+    }
+
+    public CockpitInfrastructureEmergencySupportResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            String updatedAt,
+            Freshness freshness,
+            SelectedFilters selectedFilters,
+            Totals totals,
+            Sections sections,
+            List<Distribution> industryDistribution,
+            List<Distribution> areaDistribution,
+            List<Distribution> categoryDistribution,
+            EventSummary eventSummary,
+            MapFilter mapFilter,
+            Excluded excluded,
+            List<DistrictCount> districtDistribution) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -53,6 +82,7 @@ public class CockpitInfrastructureEmergencySupportResponse {
         this.eventSummary = eventSummary;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
+        this.districtDistribution = districtDistribution;
     }
 
     public String getRequestId() { return requestId; }
@@ -71,6 +101,7 @@ public class CockpitInfrastructureEmergencySupportResponse {
     public EventSummary getEventSummary() { return eventSummary; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
+    public List<DistrictCount> getDistrictDistribution() { return districtDistribution; }
 
     public static class Freshness {
         private final String status;
@@ -95,16 +126,28 @@ public class CockpitInfrastructureEmergencySupportResponse {
         private final String category;
         private final String industry;
         private final String area;
+        /** 生效片区编码(票 81);未分区为 {@code null},未筛也为 {@code null}(靠 districtName 区分)。 */
+        private final Integer district;
+        private final String districtName;
 
         public SelectedFilters(String category, String industry, String area) {
+            this(category, industry, area, null, null);
+        }
+
+        public SelectedFilters(String category, String industry, String area,
+                Integer district, String districtName) {
             this.category = category;
             this.industry = industry;
             this.area = area;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getCategory() { return category; }
         public String getIndustry() { return industry; }
         public String getArea() { return area; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
     }
 
     public static class Totals {
@@ -170,6 +213,9 @@ public class CockpitInfrastructureEmergencySupportResponse {
         private final String area;
         private final List<String> pointCodes;
         private final EventAssociations eventAssociations;
+        /** 对象片区编码(票 81,沿设备关联取得);未分区为 {@code null}。 */
+        private final Integer district;
+        private final String districtName;
 
         public Item(
                 String objectCode,
@@ -179,6 +225,20 @@ public class CockpitInfrastructureEmergencySupportResponse {
                 String area,
                 List<String> pointCodes,
                 EventAssociations eventAssociations) {
+            this(objectCode, objectName, category, industry, area, pointCodes,
+                    eventAssociations, null, null);
+        }
+
+        public Item(
+                String objectCode,
+                String objectName,
+                String category,
+                String industry,
+                String area,
+                List<String> pointCodes,
+                EventAssociations eventAssociations,
+                Integer district,
+                String districtName) {
             this.objectCode = objectCode;
             this.objectName = objectName;
             this.category = category;
@@ -186,6 +246,8 @@ public class CockpitInfrastructureEmergencySupportResponse {
             this.area = area;
             this.pointCodes = pointCodes;
             this.eventAssociations = eventAssociations;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getObjectCode() { return objectCode; }
@@ -195,6 +257,8 @@ public class CockpitInfrastructureEmergencySupportResponse {
         public String getArea() { return area; }
         public List<String> getPointCodes() { return pointCodes; }
         public EventAssociations getEventAssociations() { return eventAssociations; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
     }
 
     public static class EventAssociations {
@@ -267,6 +331,23 @@ public class CockpitInfrastructureEmergencySupportResponse {
         public List<String> getObjectCodes() { return objectCodes; }
     }
 
+    /** 片区计数行(票 81):编码 + 展示名双字段;未分区行 {@code district=null} + 「未分区」。 */
+    public static class DistrictCount {
+        private final Integer district;
+        private final String districtName;
+        private final int count;
+
+        public DistrictCount(Integer district, String districtName, int count) {
+            this.district = district;
+            this.districtName = districtName;
+            this.count = count;
+        }
+
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+        public int getCount() { return count; }
+    }
+
     public static class Excluded {
         private final int unknownCategoryCount;
         private final int nonFormalIndustryCount;

+ 87 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportService.java

@@ -1,5 +1,7 @@
 package com.zksy.cockpit.infrastructure;
 
+import com.zksy.base.enums.DistrictEnum;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -37,14 +39,38 @@ public class CockpitInfrastructureEmergencySupportService {
         this.clock = clock;
     }
 
+    /** 不带片区的重载(老调用方沿用):等价于 {@code district=null}=全域。 */
     public CockpitInfrastructureEmergencySupportResponse load(
             String requestId,
             String category,
             String industry,
             String area) {
+        return load(requestId, category, industry, area, null);
+    }
+
+    /**
+     * 应急保障(票 81 起收片区)。
+     *
+     * <p><b>片区的来源</b>:三类对象各自的设备关联 —— 应急资源=设备台账行本身、
+     * 危险源=其 {@code equipment_id} 关联设备、防护目标=风险评估记录(**没有设备关联**,
+     * 一律归「未分区」)。片区编码走 {%@code equipment_base.district} 的同一套归一
+     * ({@link CockpitDistrict}),由适配器带进快照、这里只做筛选。
+     *
+     * <p><b>刻意不用 {@code area} 参与片区映射</b>:实测 {@code area} 是**地址串**
+     * (如「湖南省怀化市沅陵县沅陵镇…」)或缺失,不是 {@code CN}/{@code TC} 码空间;
+     * 把它映射成片区只能靠猜。故 {@code area} 仍是独立筛选项(地址精确匹配),
+     * 片区另起一条维度,两者并存、互不冒充。
+     */
+    public CockpitInfrastructureEmergencySupportResponse load(
+            String requestId,
+            String category,
+            String industry,
+            String area,
+            String district) {
         String selectedCategory = resolveCategory(category);
         String selectedIndustry = resolveIndustry(industry);
         String selectedArea = isBlank(area) ? null : area.trim();
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
 
         CockpitInfrastructureEmergencySupportSnapshot snapshot = adapter.load();
         List<CockpitInfrastructureEmergencySupportSnapshot.SourceSection> sourceSections =
@@ -112,6 +138,10 @@ public class CockpitInfrastructureEmergencySupportService {
                 if (selectedArea != null && !selectedArea.equals(sourceArea)) {
                     continue;
                 }
+                // 片区(票 81):沿对象自身的设备关联取片区;无设备关联的对象归「未分区」。
+                if (!CockpitDistrict.matches(selectedDistrict, source.getDistrict())) {
+                    continue;
+                }
                 String objectCode = source.getObjectCode().trim();
                 String objectKey = sourceObjectCategory + "|" + objectCode;
                 if (!seenObjects.add(objectKey)) {
@@ -172,7 +202,11 @@ public class CockpitInfrastructureEmergencySupportService {
                 new CockpitInfrastructureEmergencySupportResponse.Freshness(
                         freshnessStatus.name(), freshnessReason, updatedAt, serverUpdatedAt),
                 new CockpitInfrastructureEmergencySupportResponse.SelectedFilters(
-                        selectedCategory, selectedIndustry, selectedArea),
+                        selectedCategory, selectedIndustry, selectedArea,
+                        selectedDistrict.code(),
+                        selectedDistrict.active()
+                                ? CockpitDistrict.nameOf(selectedDistrict.code())
+                                : null),
                 new CockpitInfrastructureEmergencySupportResponse.Totals(
                         emergencyResourceCount,
                         hazardSourceCount,
@@ -190,7 +224,8 @@ public class CockpitInfrastructureEmergencySupportService {
                 new CockpitInfrastructureEmergencySupportResponse.MapFilter(
                         selectedCategory, selectedIndustry, selectedArea, objectCodes),
                 new CockpitInfrastructureEmergencySupportResponse.Excluded(
-                        unknownCategoryCount, nonFormalIndustryCount, duplicateObjectCount));
+                        unknownCategoryCount, nonFormalIndustryCount, duplicateObjectCount),
+                districtDistribution(includedItems));
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -209,6 +244,53 @@ public class CockpitInfrastructureEmergencySupportService {
                 businessTime());
     }
 
+    /**
+     * 片区分布(票 81):三个正式片区 + 未分区四行**全铺**(缺的是真实计数 0,不是「没有数据」),
+     * 口径与其它端点一致:未分区={@code district=null}。
+     */
+    private static List<CockpitInfrastructureEmergencySupportResponse.DistrictCount> districtDistribution(
+            List<CockpitInfrastructureEmergencySupportResponse.Item> items) {
+        Map<Integer, Integer> counts = new LinkedHashMap<>();
+        for (Integer code : districtCodes()) {
+            counts.put(code, 0);
+        }
+        int unassigned = 0;
+        for (CockpitInfrastructureEmergencySupportResponse.Item item : items) {
+            Integer code = CockpitDistrict.codeOf(item.getDistrict());
+            if (code == null) {
+                unassigned++;
+            } else {
+                counts.merge(code, 1, Integer::sum);
+            }
+        }
+        List<CockpitInfrastructureEmergencySupportResponse.DistrictCount> rows = new ArrayList<>();
+        for (Integer code : districtCodes()) {
+            rows.add(new CockpitInfrastructureEmergencySupportResponse.DistrictCount(
+                    code, CockpitDistrict.nameOf(code), counts.getOrDefault(code, 0)));
+        }
+        // 未分区恒在最后一行:与基础设施基础档案 / 监测分布的 districtDistribution 同一形态。
+        rows.add(new CockpitInfrastructureEmergencySupportResponse.DistrictCount(
+                null, CockpitDistrict.UNASSIGNED_NAME, unassigned));
+        return rows;
+    }
+
+    /** 正式片区编码,顺序即展示顺序({@code DistrictEnum} 声明顺序:城南/城北/太常)。 */
+    private static List<Integer> districtCodes() {
+        List<Integer> codes = new ArrayList<>();
+        for (DistrictEnum item : DistrictEnum.values()) {
+            codes.add(item.getCode());
+        }
+        return codes;
+    }
+
+    private static CockpitDistrict.Query resolveDistrict(String district) {
+        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
+        if (query.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
+        return query;
+    }
+
     private String resolveCategory(String category) {
         if (isBlank(category)) {
             return null;
@@ -253,7 +335,9 @@ public class CockpitInfrastructureEmergencySupportService {
                 new CockpitInfrastructureEmergencySupportResponse.EventAssociations(
                         sourceAssociation.getWarningCount(),
                         sourceAssociation.getAlarmCount(),
-                        sourceAssociation.getWorkOrderCount()));
+                        sourceAssociation.getWorkOrderCount()),
+                source.getDistrict(),
+                CockpitDistrict.nameOf(source.getDistrict()));
     }
 
     private static List<String> pointCodes(

+ 24 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportSnapshot.java

@@ -57,6 +57,12 @@ public class CockpitInfrastructureEmergencySupportSnapshot {
         private final String area;
         private final List<String> pointCodes;
         private final SourceEventAssociation eventAssociations;
+        /**
+         * 应急对象片区(票 81):沿对象自身的设备关联取 {@code equipment_base.district}
+         * (归一后编码),没有设备关联的对象(如防护目标)为 {@code null}=未分区 ——
+         * 不拿地址串 {@code area} 去猜片区(见适配器注释)。
+         */
+        private final Integer district;
 
         public SourceObject(
                 String objectCode,
@@ -66,6 +72,18 @@ public class CockpitInfrastructureEmergencySupportSnapshot {
                 String area,
                 List<String> pointCodes,
                 SourceEventAssociation eventAssociations) {
+            this(objectCode, objectName, category, industry, area, pointCodes, eventAssociations, null);
+        }
+
+        public SourceObject(
+                String objectCode,
+                String objectName,
+                String category,
+                String industry,
+                String area,
+                List<String> pointCodes,
+                SourceEventAssociation eventAssociations,
+                Integer district) {
             this.objectCode = objectCode;
             this.objectName = objectName;
             this.category = category;
@@ -73,6 +91,7 @@ public class CockpitInfrastructureEmergencySupportSnapshot {
             this.area = area;
             this.pointCodes = pointCodes;
             this.eventAssociations = eventAssociations;
+            this.district = district;
         }
 
         public String getObjectCode() {
@@ -102,6 +121,11 @@ public class CockpitInfrastructureEmergencySupportSnapshot {
         public SourceEventAssociation getEventAssociations() {
             return eventAssociations;
         }
+
+        /** 对象片区编码({@code equipment_base.district} 归一后);未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
+        }
     }
 
     public static class SourceEventAssociation {

+ 75 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationResponse.java

@@ -17,6 +17,8 @@ public class CockpitInfrastructureOperatingInformationResponse {
     private final Industry selectedIndustry;
     private final List<IndustryOperatingInformation> industries;
     private final Excluded excluded;
+    /** 生效范围回显(票 81);老调用方传 {@code null} 时序列化为 {@code null},不伪造。 */
+    private final Filters filters;
 
     public CockpitInfrastructureOperatingInformationResponse(
             String requestId,
@@ -30,6 +32,23 @@ public class CockpitInfrastructureOperatingInformationResponse {
             Industry selectedIndustry,
             List<IndustryOperatingInformation> industries,
             Excluded excluded) {
+        this(requestId, apiVersion, readOnly, displayName, timezone, range, updatedAt, freshness,
+                selectedIndustry, industries, excluded, null);
+    }
+
+    public CockpitInfrastructureOperatingInformationResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            Range range,
+            String updatedAt,
+            Freshness freshness,
+            Industry selectedIndustry,
+            List<IndustryOperatingInformation> industries,
+            Excluded excluded,
+            Filters filters) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -41,6 +60,7 @@ public class CockpitInfrastructureOperatingInformationResponse {
         this.selectedIndustry = selectedIndustry;
         this.industries = industries;
         this.excluded = excluded;
+        this.filters = filters;
     }
 
     public String getRequestId() { return requestId; }
@@ -54,6 +74,30 @@ public class CockpitInfrastructureOperatingInformationResponse {
     public Industry getSelectedIndustry() { return selectedIndustry; }
     public List<IndustryOperatingInformation> getIndustries() { return industries; }
     public Excluded getExcluded() { return excluded; }
+    public Filters getFilters() { return filters; }
+
+    /**
+     * 生效范围回显(票 81):与事件族端点的 {@code filters} 同形 ——
+     * 行业原值 + 片区编码与展示名双字段。
+     *
+     * <p>「只看未分区」({@code district=UNASSIGNED})时 {@code district=null} 且
+     * {@code districtName="未分区"};<b>未筛</b>时两者都是 {@code null}。
+     */
+    public static class Filters {
+        private final String industry;
+        private final Integer district;
+        private final String districtName;
+
+        public Filters(String industry, Integer district, String districtName) {
+            this.industry = industry;
+            this.district = district;
+            this.districtName = districtName;
+        }
+
+        public String getIndustry() { return industry; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+    }
 
     public static class Range {
         private final String type;
@@ -167,6 +211,13 @@ public class CockpitInfrastructureOperatingInformationResponse {
         private final List<TrendPoint> trend;
         private final boolean abnormal;
         private final List<String> pointCodes;
+        /**
+         * 指标所属设施的片区(票 81):设施没有片区列,按**设施编码前缀**由
+         * {@code CockpitDistrictPrefix} 做后端唯一映射;推不出时为 {@code null}=未分区。
+         * 逐条带片区,前端可本地分解,不用猜。
+         */
+        private final Integer district;
+        private final String districtName;
 
         public OperatingMetric(
                 String metricCode,
@@ -182,6 +233,26 @@ public class CockpitInfrastructureOperatingInformationResponse {
                 List<TrendPoint> trend,
                 boolean abnormal,
                 List<String> pointCodes) {
+            this(metricCode, metricName, category, unit, value, capacityValue, loadRatio, loadState,
+                    sampledAt, freshness, trend, abnormal, pointCodes, null, null);
+        }
+
+        public OperatingMetric(
+                String metricCode,
+                String metricName,
+                String category,
+                String unit,
+                BigDecimal value,
+                BigDecimal capacityValue,
+                BigDecimal loadRatio,
+                String loadState,
+                String sampledAt,
+                MetricFreshness freshness,
+                List<TrendPoint> trend,
+                boolean abnormal,
+                List<String> pointCodes,
+                Integer district,
+                String districtName) {
             this.metricCode = metricCode;
             this.metricName = metricName;
             this.category = category;
@@ -195,6 +266,8 @@ public class CockpitInfrastructureOperatingInformationResponse {
             this.trend = trend;
             this.abnormal = abnormal;
             this.pointCodes = pointCodes;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getMetricCode() { return metricCode; }
@@ -213,6 +286,8 @@ public class CockpitInfrastructureOperatingInformationResponse {
         public List<TrendPoint> getTrend() { return trend; }
         public boolean isAbnormal() { return abnormal; }
         public List<String> getPointCodes() { return pointCodes; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
     }
 
     public static class TrendPoint {

+ 67 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationService.java

@@ -1,5 +1,7 @@
 package com.zksy.cockpit.infrastructure;
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -54,13 +56,38 @@ public class CockpitInfrastructureOperatingInformationService {
         }
     }
 
+    /** 不带片区的重载(老调用方沿用):等价于 {@code district=null}=全域。 */
     public CockpitInfrastructureOperatingInformationResponse load(
             String requestId,
             String industry,
             String range,
             String from,
             String to) {
+        return load(requestId, industry, null, range, from, to);
+    }
+
+    /**
+     * 运行信息(票 81 起收片区)。
+     *
+     * <p><b>片区的来源在这里是「设施编码前缀」,不是库里的片区列</b> ——
+     * 设施(管网/水厂/泵站/排水工程设施)没有 district 列,
+     * {@code BusinessCockpitInfrastructureBaseArchivesAdapter} 明确「设施无权威片区来源」。
+     * 唯一可用线索是编码前缀({@code CN}=城南 / {@code CB}=城北 / {@code TC}=太常),
+     * 规则来自票 67 §十(维护者 2026-10-23 裁定「管线编码 = 片区 + 行业 + 时间编码」,
+     * 前端已在真实载荷上核过 6762 条零反例)。本端点在**后端**做这一条唯一映射
+     * ({@link CockpitDistrictPrefix}),前端不再自己推 —— 两端不会各推一套。
+     *
+     * <p>推不出前缀的设施指标如实归「未分区」,参与 {@code UNASSIGNED} 筛选;不硬塞、不按行业冒充。
+     */
+    public CockpitInfrastructureOperatingInformationResponse load(
+            String requestId,
+            String industry,
+            String district,
+            String range,
+            String from,
+            String to) {
         String selectedIndustry = resolveIndustry(industry);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         Window window = resolveWindow(range, from, to);
 
         CockpitInfrastructureOperatingInformationSnapshot snapshot = adapter.load();
@@ -93,6 +120,10 @@ public class CockpitInfrastructureOperatingInformationService {
             if (selectedIndustry != null && !selectedIndustry.equals(normalizedIndustry)) {
                 continue;
             }
+            // 片区(票 81):按设施编码前缀做后端唯一映射后再筛;推不出=未分区。
+            if (!CockpitDistrict.matches(selectedDistrict, districtOf(source))) {
+                continue;
+            }
             accumulator(accumulators, normalizedIndustry).add(metric(source));
         }
 
@@ -128,7 +159,15 @@ public class CockpitInfrastructureOperatingInformationService {
                 industries,
                 new CockpitInfrastructureOperatingInformationResponse.Excluded(
                         unknownIndustryCount,
-                        nonFormalIndustryCount));
+                        nonFormalIndustryCount),
+                // 生效范围回显(票 81):未筛时 district/districtName 都是 null,
+                // 未分区时 district=null + districtName="未分区"。
+                new CockpitInfrastructureOperatingInformationResponse.Filters(
+                        selectedIndustry,
+                        selectedDistrict.code(),
+                        selectedDistrict.active()
+                                ? CockpitDistrict.nameOf(selectedDistrict.code())
+                                : null));
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -169,7 +208,9 @@ public class CockpitInfrastructureOperatingInformationService {
                 freshness,
                 trend(source),
                 abnormal,
-                pointCodes(source));
+                pointCodes(source),
+                districtOf(source),
+                CockpitDistrict.nameOf(districtOf(source)));
     }
 
     private static List<CockpitInfrastructureOperatingInformationResponse.TrendPoint> trend(
@@ -191,6 +232,30 @@ public class CockpitInfrastructureOperatingInformationService {
         return points;
     }
 
+    /**
+     * 指标所属设施的片区(票 81):设施编码前缀 → 片区({@link CockpitDistrictPrefix},
+     * 后端唯一映射)。一条指标可能挂多个设施编码,用 {@link CockpitDistrict#aggregate}
+     * 归纳 —— 多个编码前缀不一致或缺失时归「未分区」,不挑代表。
+     */
+    private static Integer districtOf(
+            CockpitInfrastructureOperatingInformationSnapshot.SourceMetric source) {
+        List<Integer> codes = new ArrayList<>();
+        if (source.getPointCodes() != null) {
+            for (String code : source.getPointCodes()) {
+                codes.add(CockpitDistrictPrefix.codeOf(code));
+            }
+        }
+        return CockpitDistrict.aggregate(codes);
+    }
+
+    private static CockpitDistrict.Query resolveDistrict(String district) {
+        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
+        if (query.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
+        return query;
+    }
+
     private static List<String> pointCodes(
             CockpitInfrastructureOperatingInformationSnapshot.SourceMetric source) {
         Set<String> distinct = new LinkedHashSet<>();

+ 10 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java

@@ -9,6 +9,7 @@ import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
 import com.zksy.cockpit.foundation.CockpitCreatedAt;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
@@ -84,11 +85,15 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
             String pointCode = point.getPointCode().trim();
             Set<String> deviceTypes = new LinkedHashSet<>();
             Set<String> boundDeviceCodes = new LinkedHashSet<>();
+            // 点位片区=经其上挂设备归纳(票 81 / 决策票 06 同一规则,与监测分布适配器同款):
+            // 没有设备、或设备片区不一致/不可信时归「未分区」,不挑代表、不按行业冒充。
+            List<Integer> boundDistricts = new ArrayList<>();
             for (EquipmentBase bound : safeList(equipmentBaseService.getEquipmentByPoint(point.getPointId()))) {
                 if (bound == null || isBlank(bound.getEquipmentCode())) {
                     continue;
                 }
                 boundDeviceCodes.add(bound.getEquipmentCode().trim());
+                boundDistricts.add(bound.getDistrict());
                 pointCodeByDevice.putIfAbsent(bound.getEquipmentCode().trim(), pointCode);
                 if (bound.getEquipmentTypeId() == null) {
                     continue;
@@ -102,7 +107,8 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
                     pointCode,
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     boundDeviceCodes.size(),
-                    CockpitCreatedAt.format(point.getCreateTime())));
+                    CockpitCreatedAt.format(point.getCreateTime()),
+                    CockpitDistrict.aggregate(boundDistricts)));
         }
 
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();
@@ -133,7 +139,9 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
                                     .atZone(BUSINESS_ZONE).toOffsetDateTime()),
                     pointCodeByDevice.get(deviceCode),
                     // 数据时间=记录创建时间(equipment_base.create_time),与 sampledAt 分离
-                    CockpitCreatedAt.format(device.getCreateTime())));
+                    CockpitCreatedAt.format(device.getCreateTime()),
+                    // 设备自身片区(equipment_base.district)即权威来源;脏值由 codeOf 归一为未分区
+                    CockpitDistrict.codeOf(device.getDistrict())));
         }
 
         List<CockpitMonitoringOverviewSnapshot.SourceType> sourceTypes = types.values().stream()

+ 79 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewResponse.java

@@ -30,6 +30,8 @@ public class CockpitMonitoringOverviewResponse {
     private final List<Device> devices;
     private final MapFilter mapFilter;
     private final Excluded excluded;
+    /** 生效筛选回显(票 81);老调用方传 {@code null} 时序列化为 {@code null},不伪造。 */
+    private final Filters filters;
 
     public CockpitMonitoringOverviewResponse(
             String requestId,
@@ -48,6 +50,29 @@ public class CockpitMonitoringOverviewResponse {
             List<Device> devices,
             MapFilter mapFilter,
             Excluded excluded) {
+        this(requestId, apiVersion, readOnly, displayName, timezone, range, updatedAt, createdAt,
+                freshness, counts, status, onlineRate, typeDistribution, devices, mapFilter,
+                excluded, null);
+    }
+
+    public CockpitMonitoringOverviewResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            Range range,
+            String updatedAt,
+            String createdAt,
+            Freshness freshness,
+            Counts counts,
+            Status status,
+            OnlineRate onlineRate,
+            List<TypeDistribution> typeDistribution,
+            List<Device> devices,
+            MapFilter mapFilter,
+            Excluded excluded,
+            Filters filters) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -64,6 +89,7 @@ public class CockpitMonitoringOverviewResponse {
         this.devices = devices;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
+        this.filters = filters;
     }
 
     public String getRequestId() { return requestId; }
@@ -82,6 +108,34 @@ public class CockpitMonitoringOverviewResponse {
     public List<Device> getDevices() { return devices; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
+    public Filters getFilters() { return filters; }
+
+    /**
+     * 生效筛选回显(票 81):与事件族端点的 {@code filters} 同形 ——
+     * 行业/设备类型原值 + 片区编码与展示名双字段。
+     *
+     * <p>「只看未分区」({@code district=UNASSIGNED})时 {@code district=null} 且
+     * {@code districtName="未分区"};<b>未筛</b>时两者都是 {@code null} ——
+     * 前端据此区分「看全域」与「只看未分区」,不靠猜。
+     */
+    public static class Filters {
+        private final String industry;
+        private final String deviceType;
+        private final Integer district;
+        private final String districtName;
+
+        public Filters(String industry, String deviceType, Integer district, String districtName) {
+            this.industry = industry;
+            this.deviceType = deviceType;
+            this.district = district;
+            this.districtName = districtName;
+        }
+
+        public String getIndustry() { return industry; }
+        public String getDeviceType() { return deviceType; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+    }
 
     public static class Range {
         private final String type;
@@ -193,6 +247,12 @@ public class CockpitMonitoringOverviewResponse {
         private final String sampledAt;
         /** 数据时间=记录创建时间({@code equipment_base.create_time})。 */
         private final String createdAt;
+        /**
+         * 设备片区(票 81,{@code equipment_base.district} 归一后);未分区为 {@code null}。
+         * 逐行带片区,前端才能本地分解「这台设备算哪个片区」,不用猜。
+         */
+        private final Integer district;
+        private final String districtName;
 
         public Device(
                 String deviceCode,
@@ -203,6 +263,21 @@ public class CockpitMonitoringOverviewResponse {
                 String monitoringPointCode,
                 String sampledAt,
                 String createdAt) {
+            this(deviceCode, deviceName, deviceType, industry, monitoringState,
+                    monitoringPointCode, sampledAt, createdAt, null, null);
+        }
+
+        public Device(
+                String deviceCode,
+                String deviceName,
+                DeviceType deviceType,
+                String industry,
+                String monitoringState,
+                String monitoringPointCode,
+                String sampledAt,
+                String createdAt,
+                Integer district,
+                String districtName) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceType = deviceType;
@@ -211,6 +286,8 @@ public class CockpitMonitoringOverviewResponse {
             this.monitoringPointCode = monitoringPointCode;
             this.sampledAt = sampledAt;
             this.createdAt = createdAt;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getDeviceCode() { return deviceCode; }
@@ -221,6 +298,8 @@ public class CockpitMonitoringOverviewResponse {
         public String getMonitoringPointCode() { return monitoringPointCode; }
         public String getSampledAt() { return sampledAt; }
         public String getCreatedAt() { return createdAt; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
     }
 
     public static class TypeDistribution {

+ 86 - 12
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.monitoring;
 
 import com.zksy.cockpit.foundation.CockpitCreatedAt;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -43,20 +44,48 @@ public class CockpitMonitoringOverviewService {
         this.clock = clock;
     }
 
+    /** 不带片区的重载(老调用方沿用):等价于 {@code district=null}=全域。 */
     public synchronized CockpitMonitoringOverviewResponse load(
             String requestId,
             String industry,
             String deviceType) {
+        return load(requestId, industry, deviceType, null);
+    }
+
+    /**
+     * 监测概览(票 81 起收片区)。
+     *
+     * <p>片区是**行级事实**:设备自身的 {@code equipment_base.district}(适配器带入并归一),
+     * 所以设备状态计数、在线率、类型分布与设备台账都按同一批设备收敛后重算;
+     * 点位计数按「经其上挂设备归纳」的片区同筛(点位没有自己的片区列)。
+     */
+    public synchronized CockpitMonitoringOverviewResponse load(
+            String requestId,
+            String industry,
+            String deviceType,
+            String district) {
         String selectedIndustry = resolveIndustry(industry);
+        // 非法片区在取数之前就拒绝:不让脏参数走到快照加载之后才报错。
+        resolveDistrict(district);
         refreshIfNeeded();
-        return build(requestId, selectedIndustry, deviceType);
+        return build(requestId, selectedIndustry, deviceType, district);
+    }
+
+    public synchronized CockpitMonitoringOverviewResponse applyDeviceStatusDelta(
+            String requestId,
+            String industry,
+            String deviceType,
+            CockpitMonitoringOverviewDeviceStatusDelta delta) {
+        return applyDeviceStatusDelta(requestId, industry, deviceType, null, delta);
     }
 
     public synchronized CockpitMonitoringOverviewResponse applyDeviceStatusDelta(
             String requestId,
             String industry,
             String deviceType,
+            String district,
             CockpitMonitoringOverviewDeviceStatusDelta delta) {
+        resolveDistrict(district);
         refreshIfNeeded();
         if (delta == null || isBlank(delta.getDeviceCode())) {
             throw new CockpitInvalidRequestException("device code is required");
@@ -64,11 +93,11 @@ public class CockpitMonitoringOverviewService {
         OffsetDateTime serverUpdatedAt = parseTime(delta.getServerUpdatedAt(), "serverUpdatedAt");
         DeviceRuntime previous = state.devices.get(delta.getDeviceCode().trim());
         if (previous == null) {
-            return build(requestId, industry, deviceType);
+            return build(requestId, industry, deviceType, district);
         }
         if (previous.serverUpdatedAt != null
                 && !serverUpdatedAt.isAfter(previous.serverUpdatedAt)) {
-            return build(requestId, industry, deviceType);
+            return build(requestId, industry, deviceType, district);
         }
         state.devices.put(
                 previous.deviceCode,
@@ -78,8 +107,10 @@ public class CockpitMonitoringOverviewService {
                         normalizeState(delta.getMonitoringState()),
                         delta.getSampledAt(),
                         previous.pointCode,
-                        serverUpdatedAt));
-        return build(requestId, industry, deviceType);
+                        serverUpdatedAt,
+                        // 片区是台账事实,delta 只改状态,不因此改写片区
+                        previous.district));
+        return build(requestId, industry, deviceType, district);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -117,14 +148,17 @@ public class CockpitMonitoringOverviewService {
                                 normalizeState(source.getMonitoringState()),
                                 source.getSampledAt(),
                                 source.getPointCode(),
-                                null));
+                                null,
+                                source.getDistrict()));
             }
         }
         state = new RuntimeState(snapshot, devices);
     }
 
-    private CockpitMonitoringOverviewResponse build(String requestId, String industry, String deviceType) {
+    private CockpitMonitoringOverviewResponse build(
+            String requestId, String industry, String deviceType, String district) {
         String selectedIndustry = resolveIndustry(industry);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         CockpitMonitoringOverviewSnapshot snapshot = snapshot(state.snapshot);
         Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types = types(snapshot);
 
@@ -136,7 +170,8 @@ public class CockpitMonitoringOverviewService {
         int typeKindMismatchCount = 0;
         int nonFormalIndustryCount = 0;
         Map<String, TypeAccumulator> typeAccumulators = new LinkedHashMap<>();
-        Set<String> selectedPointCodes = selectMonitoringPointCodes(snapshot, selectedIndustry, deviceType, types);
+        Set<String> selectedPointCodes = selectMonitoringPointCodes(
+                snapshot, selectedIndustry, deviceType, selectedDistrict, types);
         Set<String> selectedDeviceCodes = new LinkedHashSet<>();
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> selectedDevices = new ArrayList<>();
 
@@ -161,6 +196,11 @@ public class CockpitMonitoringOverviewService {
             if (!isBlank(deviceType) && !deviceType.trim().equals(typeCode)) {
                 continue;
             }
+            // 片区(票 81):设备自身片区未命中时,状态计数/类型分布/台账全部不收这台设备
+            // —— 与下方台账分支同一套判据,两处读数结构性一致。
+            if (!CockpitDistrict.matches(selectedDistrict, runtime.district)) {
+                continue;
+            }
 
             String monitoringState = runtime.monitoringState;
             if ("ONLINE".equals(monitoringState)) {
@@ -229,7 +269,8 @@ public class CockpitMonitoringOverviewService {
                     || type.getIndustry() == null
                     || !INDUSTRIES.contains(type.getIndustry())
                     || (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry()))
-                    || (!isBlank(deviceType) && !deviceType.trim().equals(typeCode))) {
+                    || (!isBlank(deviceType) && !deviceType.trim().equals(typeCode))
+                    || !CockpitDistrict.matches(selectedDistrict, runtime.district)) {
                 continue;
             }
             CockpitMonitoringOverviewSnapshot.SourceDevice source = snapshotDevice(snapshot, runtime.deviceCode);
@@ -241,7 +282,10 @@ public class CockpitMonitoringOverviewService {
                     runtime.monitoringState,
                     isBlank(runtime.pointCode) ? null : runtime.pointCode,
                     runtime.sampledAt,
-                    source == null ? null : source.getCreatedAt()));
+                    source == null ? null : source.getCreatedAt(),
+                    // 逐行带片区(票 81):前端可本地分解,不用猜这台设备算哪个片区
+                    runtime.district,
+                    CockpitDistrict.nameOf(runtime.district)));
         }
 
         return new CockpitMonitoringOverviewResponse(
@@ -272,7 +316,16 @@ public class CockpitMonitoringOverviewService {
                         pointCodes,
                         deviceCodes),
                 new CockpitMonitoringOverviewResponse.Excluded(
-                        unknownTypeCount, typeKindMismatchCount, nonFormalIndustryCount));
+                        unknownTypeCount, typeKindMismatchCount, nonFormalIndustryCount),
+                // 生效范围回显(票 81):未筛时 district/districtName 都是 null,
+                // 未分区时 district=null + districtName="未分区",前端不靠猜。
+                new CockpitMonitoringOverviewResponse.Filters(
+                        selectedIndustry,
+                        isBlank(deviceType) ? null : deviceType.trim(),
+                        selectedDistrict.code(),
+                        selectedDistrict.active()
+                                ? CockpitDistrict.nameOf(selectedDistrict.code())
+                                : null));
     }
 
     private static CockpitMonitoringOverviewSnapshot snapshot(
@@ -324,6 +377,7 @@ public class CockpitMonitoringOverviewService {
             CockpitMonitoringOverviewSnapshot snapshot,
             String selectedIndustry,
             String selectedDeviceType,
+            CockpitDistrict.Query selectedDistrict,
             Map<String, CockpitMonitoringOverviewSnapshot.SourceType> types) {
         Set<String> unique = new LinkedHashSet<>();
         if (snapshot == null || snapshot.getMonitoringPoints() == null) {
@@ -346,6 +400,10 @@ public class CockpitMonitoringOverviewService {
             if (!isBlank(selectedDeviceType) && (type == null || !selectedDeviceType.trim().equals(type.getCode()))) {
                 continue;
             }
+            // 点位片区=经其上挂设备归纳(票 81):与设备侧同一套片区口径,点位计数才不会与设备台账打架。
+            if (!CockpitDistrict.matches(selectedDistrict, point.getDistrict())) {
+                continue;
+            }
             unique.add(point.getPointCode().trim());
         }
         return unique;
@@ -364,6 +422,18 @@ public class CockpitMonitoringOverviewService {
         return result;
     }
 
+    /**
+     * 片区入参(票 81):{@code null}/空=不筛;{@code 0/1/2}=按片区筛;
+     * {@code UNASSIGNED}=只看未分区;其它值一律 400(与已部署的事件族端点同一文案)。
+     */
+    private static CockpitDistrict.Query resolveDistrict(String district) {
+        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
+        if (query.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
+        return query;
+    }
+
     private String resolveIndustry(String industry) {
         if (isBlank(industry)) {
             return null;
@@ -422,6 +492,8 @@ public class CockpitMonitoringOverviewService {
         private final String sampledAt;
         private final String pointCode;
         private final OffsetDateTime serverUpdatedAt;
+        /** 设备片区(归一口径);台账事实,不随状态 delta 改写。 */
+        private final Integer district;
 
         private DeviceRuntime(
                 String deviceCode,
@@ -429,13 +501,15 @@ public class CockpitMonitoringOverviewService {
                 String monitoringState,
                 String sampledAt,
                 String pointCode,
-                OffsetDateTime serverUpdatedAt) {
+                OffsetDateTime serverUpdatedAt,
+                Integer district) {
             this.deviceCode = deviceCode;
             this.typeCode = typeCode;
             this.monitoringState = monitoringState;
             this.sampledAt = sampledAt;
             this.pointCode = pointCode;
             this.serverUpdatedAt = serverUpdatedAt;
+            this.district = district;
         }
     }
 

+ 34 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java

@@ -53,6 +53,12 @@ public class CockpitMonitoringOverviewSnapshot {
         private final String pointCode;
         /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
         private final String createdAt;
+        /**
+         * 设备片区(票 81):{@code equipment_base.district} 经
+         * {@code CockpitDistrict.codeOf} 归一后的编码,未分区为 {@code null}。
+         * 这是本端点唯一的片区事实来源 —— 设备台账逐行带片区,服务层据此筛。
+         */
+        private final Integer district;
 
         public SourceDevice(
                 String deviceCode,
@@ -82,6 +88,20 @@ public class CockpitMonitoringOverviewSnapshot {
                 String sampledAt,
                 String pointCode,
                 String createdAt) {
+            this(deviceCode, deviceName, typeCode, monitoringState,
+                    sampledAt, pointCode, createdAt, null);
+        }
+
+        /** 票 81:带片区({@code equipment_base.district} 归一后的编码)的设备行。 */
+        public SourceDevice(
+                String deviceCode,
+                String deviceName,
+                String typeCode,
+                String monitoringState,
+                String sampledAt,
+                String pointCode,
+                String createdAt,
+                Integer district) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.typeCode = typeCode;
@@ -89,6 +109,7 @@ public class CockpitMonitoringOverviewSnapshot {
             this.sampledAt = sampledAt;
             this.pointCode = pointCode;
             this.createdAt = createdAt;
+            this.district = district;
         }
 
         public String getDeviceCode() { return deviceCode; }
@@ -98,6 +119,7 @@ public class CockpitMonitoringOverviewSnapshot {
         public String getSampledAt() { return sampledAt; }
         public String getPointCode() { return pointCode; }
         public String getCreatedAt() { return createdAt; }
+        public Integer getDistrict() { return district; }
     }
 
     public static class SourceMonitoringPoint {
@@ -107,6 +129,11 @@ public class CockpitMonitoringOverviewSnapshot {
         private final int associatedDeviceCount;
         /** 记录创建时间({@code monitor_point.create_time});缺值为 {@code null}。 */
         private final String createdAt;
+        /**
+         * 点位片区(票 81):点位没有自己的片区列,按「经其上挂设备归纳」
+         * ({@code CockpitDistrict.aggregate})取得;多设备不一致或无设备时为 {@code null}=未分区。
+         */
+        private final Integer district;
 
         public SourceMonitoringPoint(String pointCode, String typeCode) {
             this(pointCode, typeCode, -1);
@@ -117,15 +144,22 @@ public class CockpitMonitoringOverviewSnapshot {
         }
 
         public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount, String createdAt) {
+            this(pointCode, typeCode, associatedDeviceCount, createdAt, null);
+        }
+
+        public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount,
+                String createdAt, Integer district) {
             this.pointCode = pointCode;
             this.typeCode = typeCode;
             this.associatedDeviceCount = associatedDeviceCount;
             this.createdAt = createdAt;
+            this.district = district;
         }
 
         public String getPointCode() { return pointCode; }
         public String getTypeCode() { return typeCode; }
         public int getAssociatedDeviceCount() { return associatedDeviceCount; }
         public String getCreatedAt() { return createdAt; }
+        public Integer getDistrict() { return district; }
     }
 }

+ 55 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationResponse.java

@@ -28,6 +28,8 @@ public class CockpitMonitoringSituationResponse {
     private final MapFilter mapFilter;
     private final Pagination pagination;
     private final Excluded excluded;
+    /** 生效筛选回显(票 81);老调用方传 {@code null} 时序列化为 {@code null},不伪造。 */
+    private final Filters filters;
 
     public CockpitMonitoringSituationResponse(
             String requestId,
@@ -47,6 +49,30 @@ public class CockpitMonitoringSituationResponse {
             MapFilter mapFilter,
             Pagination pagination,
             Excluded excluded) {
+        this(requestId, apiVersion, readOnly, displayName, timezone, range, updatedAt, createdAt,
+                freshness, metricStatusDistribution, warningTrend, alarmTrend, industryCoverage,
+                abnormalEquipment, mapFilter, pagination, excluded, null);
+    }
+
+    public CockpitMonitoringSituationResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            Range range,
+            String updatedAt,
+            String createdAt,
+            Freshness freshness,
+            List<MetricStatusCount> metricStatusDistribution,
+            List<TrendPoint> warningTrend,
+            List<TrendPoint> alarmTrend,
+            List<IndustryCoverage> industryCoverage,
+            List<AbnormalEquipment> abnormalEquipment,
+            MapFilter mapFilter,
+            Pagination pagination,
+            Excluded excluded,
+            Filters filters) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -64,6 +90,7 @@ public class CockpitMonitoringSituationResponse {
         this.mapFilter = mapFilter;
         this.pagination = pagination;
         this.excluded = excluded;
+        this.filters = filters;
     }
 
     public String getRequestId() { return requestId; }
@@ -83,6 +110,34 @@ public class CockpitMonitoringSituationResponse {
     public MapFilter getMapFilter() { return mapFilter; }
     public Pagination getPagination() { return pagination; }
     public Excluded getExcluded() { return excluded; }
+    public Filters getFilters() { return filters; }
+
+    /**
+     * 生效范围回显(票 81):与事件族端点的 {@code filters} 同形 ——
+     * 行业/设备类型原值 + 片区编码与展示名双字段。
+     *
+     * <p>「只看未分区」({@code district=UNASSIGNED})时 {@code district=null} 且
+     * {@code districtName="未分区"};<b>未筛</b>时两者都是 {@code null} ——
+     * 前端据此区分「看全域」与「只看未分区」,不靠猜。
+     */
+    public static class Filters {
+        private final String industry;
+        private final String deviceType;
+        private final Integer district;
+        private final String districtName;
+
+        public Filters(String industry, String deviceType, Integer district, String districtName) {
+            this.industry = industry;
+            this.deviceType = deviceType;
+            this.district = district;
+            this.districtName = districtName;
+        }
+
+        public String getIndustry() { return industry; }
+        public String getDeviceType() { return deviceType; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+    }
 
     public static class Range {
         private final String type;

+ 101 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationService.java

@@ -66,6 +66,7 @@ public class CockpitMonitoringSituationService {
         this.clock = clock;
     }
 
+    /** 不带片区的重载(老调用方沿用):等价于 {@code district=null}=全域。 */
     public synchronized CockpitMonitoringSituationResponse load(
             String requestId,
             String industry,
@@ -74,13 +75,34 @@ public class CockpitMonitoringSituationService {
             String endTime,
             Integer page,
             Integer pageSize) {
+        return load(requestId, industry, deviceType, null, startTime, endTime, page, pageSize);
+    }
+
+    /**
+     * 运行概况(票 81 起收片区)。
+     *
+     * <p>片区是**行级事实**:设备自身的 {@code equipment_base.district}(经适配器带入并归一),
+     * 所以 KPI(指标态分布)、行业覆盖率、异常清单与趋势都按同一批设备收敛后重算 ——
+     * 不是「只把清单筛一下、KPI 仍报全域」的那种半生效。
+     */
+    public synchronized CockpitMonitoringSituationResponse load(
+            String requestId,
+            String industry,
+            String deviceType,
+            String district,
+            String startTime,
+            String endTime,
+            Integer page,
+            Integer pageSize) {
         String selectedIndustry = resolveIndustry(industry);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize);
         TimeWindow window = resolveWindow(startTime, endTime);
         refreshIfNeeded();
-        return build(requestId, selectedIndustry, deviceType, window, safePage, safePageSize);
+        return build(requestId, selectedIndustry, deviceType, selectedDistrict,
+                window, safePage, safePageSize);
     }
 
     public synchronized CockpitMonitoringSituationResponse applyDeviceStatusDelta(
@@ -92,7 +114,22 @@ public class CockpitMonitoringSituationService {
             Integer page,
             Integer pageSize,
             CockpitMonitoringSituationDeviceStatusDelta delta) {
+        return applyDeviceStatusDelta(requestId, industry, deviceType, null,
+                startTime, endTime, page, pageSize, delta);
+    }
+
+    public synchronized CockpitMonitoringSituationResponse applyDeviceStatusDelta(
+            String requestId,
+            String industry,
+            String deviceType,
+            String district,
+            String startTime,
+            String endTime,
+            Integer page,
+            Integer pageSize,
+            CockpitMonitoringSituationDeviceStatusDelta delta) {
         String selectedIndustry = resolveIndustry(industry);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize);
@@ -101,14 +138,17 @@ public class CockpitMonitoringSituationService {
         if (delta == null || isBlank(delta.getDeviceCode())) {
             throw new CockpitInvalidRequestException("device code is required");
         }
+        // 片区解析在读取 delta 之前完成:非法参数不因「delta 被忽略」而静默通过。
         OffsetDateTime serverUpdatedAt = parseTime(delta.getServerUpdatedAt(), "serverUpdatedAt");
         DeviceRuntime previous = state.devices.get(delta.getDeviceCode().trim());
         if (previous == null) {
-            return build(requestId, selectedIndustry, deviceType, window, safePage, safePageSize);
+            return build(requestId, selectedIndustry, deviceType, selectedDistrict,
+                    window, safePage, safePageSize);
         }
         if (previous.serverUpdatedAt != null
                 && !serverUpdatedAt.isAfter(previous.serverUpdatedAt)) {
-            return build(requestId, selectedIndustry, deviceType, window, safePage, safePageSize);
+            return build(requestId, selectedIndustry, deviceType, selectedDistrict,
+                    window, safePage, safePageSize);
         }
         state.devices.put(
                 previous.deviceCode,
@@ -116,7 +156,8 @@ public class CockpitMonitoringSituationService {
                         previous.deviceCode,
                         normalizeStatus(delta.getMetricStatus()),
                         serverUpdatedAt));
-        return build(requestId, selectedIndustry, deviceType, window, safePage, safePageSize);
+        return build(requestId, selectedIndustry, deviceType, selectedDistrict,
+                window, safePage, safePageSize);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -164,6 +205,7 @@ public class CockpitMonitoringSituationService {
             String requestId,
             String selectedIndustry,
             String deviceType,
+            CockpitDistrict.Query district,
             TimeWindow window,
             int page,
             int pageSize) {
@@ -203,6 +245,11 @@ public class CockpitMonitoringSituationService {
                 if (!isBlank(deviceType) && !deviceType.trim().equals(type.getCode())) {
                     continue;
                 }
+                // 片区(票 81):设备自身片区即权威来源,未命中直接不进任何读数
+                // (KPI、行业覆盖率、异常清单、趋势共用这一批设备)。
+                if (!CockpitDistrict.matches(district, device.getDistrict())) {
+                    continue;
+                }
 
                 DeviceRuntime runtime = state.devices.get(device.getDeviceCode().trim());
                 String status = runtime == null
@@ -312,8 +359,10 @@ public class CockpitMonitoringSituationService {
                 new CockpitMonitoringSituationResponse.Freshness(
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                 metricStatusDistribution,
-                trend(snapshot.getWarnings(), selectedIndustry, deviceType, window, types),
-                trend(snapshot.getAlarms(), selectedIndustry, deviceType, window, types),
+                trend(snapshot.getWarnings(), selectedIndustry, deviceType, district,
+                        districtByDevice(snapshot), window, types),
+                trend(snapshot.getAlarms(), selectedIndustry, deviceType, district,
+                        districtByDevice(snapshot), window, types),
                 coverage,
                 pageAbnormal,
                 new CockpitMonitoringSituationResponse.MapFilter(
@@ -326,13 +375,41 @@ public class CockpitMonitoringSituationService {
                         unknownTypeCount,
                         typeKindMismatchCount,
                         nonFormalIndustryCount,
-                        invalidCoordinateCount));
+                        invalidCoordinateCount),
+                // 生效范围回显(票 81):未筛时 district/districtName 都是 null,
+                // 未分区时 district=null + districtName="未分区",前端不靠猜。
+                new CockpitMonitoringSituationResponse.Filters(
+                        selectedIndustry,
+                        isBlank(deviceType) ? null : deviceType.trim(),
+                        district.code(),
+                        district.active() ? CockpitDistrict.nameOf(district.code()) : null));
+    }
+
+    /**
+     * 趋势按片区收敛(票 81):事件自身没有片区列,沿**事件设备**回联设备台账的片区
+     * ({@link #districtByDevice})—— 与设备 KPI 用的是同一份权威来源,不另立口径。
+     * 事件设备在台账里查不到时归「未分区」,如实参与 {@code UNASSIGNED} 筛选。
+     */
+    private static Map<String, Integer> districtByDevice(CockpitMonitoringSituationSnapshot snapshot) {
+        Map<String, Integer> result = new LinkedHashMap<>();
+        if (snapshot.getDevices() == null) {
+            return result;
+        }
+        for (CockpitMonitoringSituationSnapshot.SourceMetricDevice device : snapshot.getDevices()) {
+            if (device == null || isBlank(device.getDeviceCode())) {
+                continue;
+            }
+            result.putIfAbsent(device.getDeviceCode().trim(), device.getDistrict());
+        }
+        return result;
     }
 
     private List<CockpitMonitoringSituationResponse.TrendPoint> trend(
             List<CockpitMonitoringSituationSnapshot.SourceEvent> events,
             String selectedIndustry,
             String deviceType,
+            CockpitDistrict.Query district,
+            Map<String, Integer> districtByDevice,
             TimeWindow window,
             Map<String, CockpitMonitoringSituationSnapshot.SourceType> types) {
         ZonedDateTime first = window.start
@@ -359,6 +436,11 @@ public class CockpitMonitoringSituationService {
                 if (!isBlank(deviceType) && !deviceType.trim().equals(event.getTypeCode())) {
                     continue;
                 }
+                String eventDevice = isBlank(event.getDeviceCode()) ? null : event.getDeviceCode().trim();
+                if (!CockpitDistrict.matches(
+                        district, eventDevice == null ? null : districtByDevice.get(eventDevice))) {
+                    continue;
+                }
                 CockpitMonitoringSituationSnapshot.SourceType type = resolveType(types, event.getTypeCode());
                 if (type == null
                         || !SMART_DEVICE.equals(type.getObjectKind())
@@ -409,6 +491,18 @@ public class CockpitMonitoringSituationService {
         return isBlank(typeCode) ? null : types.get(typeCode.trim());
     }
 
+    /**
+     * 片区入参(票 81):{@code null}/空=不筛;{@code 0/1/2}=按片区筛;
+     * {@code UNASSIGNED}=只看未分区;其它值一律 400(与已部署的事件族端点同一文案)。
+     */
+    private static CockpitDistrict.Query resolveDistrict(String district) {
+        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
+        if (query.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
+        return query;
+    }
+
     private static String resolveIndustry(String industry) {
         if (isBlank(industry)) {
             return null;

+ 44 - 14
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewModulesAdapter.java

@@ -2,6 +2,7 @@ package com.zksy.cockpit.overview;
 
 import com.zksy.base.warning.domain.WarningType;
 import com.zksy.base.warning.mapper.WarningTypeMapper;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.overview.mapper.CockpitOverviewModulesMapper;
 import com.zksy.common.core.domain.entity.SysDictData;
@@ -51,10 +52,12 @@ public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewMod
     }
 
     @Override
-    public CockpitOverviewPerceptionSnapshot loadPerception() {
+    public CockpitOverviewPerceptionSnapshot loadPerception(
+            CockpitDistrict.Query district, Integer industryRootTypeId) {
         List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets = new ArrayList<>();
         LocalDateTime updatedAt = null;
-        for (Map<String, Object> row : safe(mapper.selectDeviceBuckets())) {
+        for (Map<String, Object> row : safe(mapper.selectDeviceBuckets(
+                districtCode(district), unassigned(district), industryRootTypeId))) {
             updatedAt = latest(updatedAt, localDateTime(row, "source_updated_at"));
             buckets.add(new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(
                     integer(row, "root_type_id"),
@@ -85,23 +88,29 @@ public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewMod
             buckets.add(new CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket(
                     CockpitPipeNetworkType.typeCodeOf(string(row, "network_type")),
                     intValue(row, "pipe_count"),
-                    bigDecimal(row, "length_meters")));
+                    bigDecimal(row, "length_meters"),
+                    // 片区原始前缀(票 81):映射与筛选在服务层用 CockpitDistrictPrefix 单点完成
+                    trimToNull(string(row, "district_prefix"))));
         }
         return new CockpitOverviewPipeNetworkSnapshot(updatedAt, buckets);
     }
 
     @Override
-    public CockpitOverviewWarningStudySnapshot loadWarningStudy(CockpitOverviewWindows windows) {
+    public CockpitOverviewWarningStudySnapshot loadWarningStudy(
+            CockpitOverviewWindows windows, CockpitDistrict.Query district, Integer industryRootTypeId) {
         Map<String, Object> counts = mapper.selectWarningCounts(
                 timestamp(windows == null ? null : windows.yesterdayStart()),
                 timestamp(windows == null ? null : windows.todayStart()),
-                timestamp(windows == null ? null : windows.tomorrowStart()));
+                timestamp(windows == null ? null : windows.tomorrowStart()),
+                districtCode(district), unassigned(district), industryRootTypeId);
         Map<String, Object> range = mapper.selectWarningCountInWindow(
                 timestamp(windows == null ? null : windows.rankingStart()),
-                timestamp(windows == null ? null : windows.tomorrowStart()));
+                timestamp(windows == null ? null : windows.tomorrowStart()),
+                districtCode(district), unassigned(district), industryRootTypeId);
 
         List<CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount> statusLevelCounts = new ArrayList<>();
-        for (Map<String, Object> row : safe(mapper.selectUnresolvedStatusLevelCounts())) {
+        for (Map<String, Object> row : safe(mapper.selectUnresolvedStatusLevelCounts(
+                districtCode(district), unassigned(district), industryRootTypeId))) {
             statusLevelCounts.add(new CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount(
                     trimToNull(string(row, "status")),
                     levelOf(string(row, "warning_level")),
@@ -110,7 +119,8 @@ public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewMod
         List<CockpitOverviewWarningStudySnapshot.SourceTypeCount> typeCounts = new ArrayList<>();
         for (Map<String, Object> row : safe(mapper.selectWarningTypeCountsInWindow(
                 timestamp(windows == null ? null : windows.rankingStart()),
-                timestamp(windows == null ? null : windows.tomorrowStart())))) {
+                timestamp(windows == null ? null : windows.tomorrowStart()),
+                districtCode(district), unassigned(district), industryRootTypeId))) {
             typeCounts.add(new CockpitOverviewWarningStudySnapshot.SourceTypeCount(
                     trimToNull(string(row, "type_code")), intValue(row, "type_count")));
         }
@@ -132,22 +142,27 @@ public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewMod
     }
 
     @Override
-    public CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(CockpitOverviewWindows windows) {
+    public CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(
+            CockpitOverviewWindows windows, CockpitDistrict.Query district, Integer industryRootTypeId) {
         Map<String, Object> totals = mapper.selectAlarmTotals(
                 timestamp(windows == null ? null : windows.yesterdayStart()),
                 timestamp(windows == null ? null : windows.todayStart()),
-                timestamp(windows == null ? null : windows.tomorrowStart()));
-        Map<String, Object> closed = mapper.selectAlarmClosedCount();
+                timestamp(windows == null ? null : windows.tomorrowStart()),
+                districtCode(district), unassigned(district), industryRootTypeId);
+        Map<String, Object> closed = mapper.selectAlarmClosedCount(
+                districtCode(district), unassigned(district), industryRootTypeId);
 
         List<CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount> levelCounts = new ArrayList<>();
-        for (Map<String, Object> row : safe(mapper.selectAlarmLevelCounts())) {
+        for (Map<String, Object> row : safe(mapper.selectAlarmLevelCounts(
+                districtCode(district), unassigned(district), industryRootTypeId))) {
             levelCounts.add(new CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount(
                     integer(row, "alarm_level"), intValue(row, "level_count")));
         }
         List<CockpitOverviewAlarmDisposalSnapshot.SourceDayCount> dailyCounts = new ArrayList<>();
         for (Map<String, Object> row : safe(mapper.selectAlarmDailyCounts(
                 timestamp(windows == null ? null : windows.trendStart()),
-                timestamp(windows == null ? null : windows.tomorrowStart())))) {
+                timestamp(windows == null ? null : windows.tomorrowStart()),
+                districtCode(district), unassigned(district), industryRootTypeId))) {
             String day = trimToNull(string(row, "day"));
             if (day != null) {
                 dailyCounts.add(new CockpitOverviewAlarmDisposalSnapshot.SourceDayCount(
@@ -156,7 +171,8 @@ public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewMod
         }
         Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> durationSamples =
                 new LinkedHashMap<>();
-        for (Map<String, Object> row : safe(mapper.selectAlarmDurationSamples())) {
+        for (Map<String, Object> row : safe(mapper.selectAlarmDurationSamples(
+                districtCode(district), unassigned(district), industryRootTypeId))) {
             Integer operType = integer(row, "oper_type");
             if (operType == null) {
                 continue;
@@ -179,6 +195,20 @@ public class BusinessCockpitOverviewModulesAdapter implements CockpitOverviewMod
                 levelNames(ALARM_LEVEL_DICT));
     }
 
+    /**
+     * 片区筛选的 SQL 取值(票 81):已指派→编码;未分区→{@code null}+unassigned 标记;
+     * 未筛→两者都"不生效"({@code null}+false)。
+     */
+    private static Integer districtCode(CockpitDistrict.Query district) {
+        return district == null || !district.active() || district.unassignedOnly()
+                ? null
+                : district.code();
+    }
+
+    private static boolean unassigned(CockpitDistrict.Query district) {
+        return district != null && district.unassignedOnly();
+    }
+
     /** 等级名称取字典(预警用 {@code alert_level}、报警用 {@code alarm_level});缺项由服务层兜底。 */
     private Map<Integer, String> levelNames(String dictType) {
         Map<Integer, String> names = new LinkedHashMap<>();

+ 20 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesAdapter.java

@@ -8,11 +8,28 @@ package com.zksy.cockpit.overview;
  * 适配器只负责把 SQL 聚合结果归一成源事实,口径判定留在 {@link CockpitOverviewModulesService}。
  */
 public interface CockpitOverviewModulesAdapter {
-    CockpitOverviewPerceptionSnapshot loadPerception();
 
+    /**
+     * 感知设备(票 81 起收片区/行业)。筛选**下推到聚合 SQL 的 WHERE**,
+     * 分桶与总数因此始终描述同一批设备。
+     *
+     * @param district           片区筛选({@code null} 或未生效=不筛)
+     * @param industryRootTypeId 行业对应的根类型 id;{@code null}=不筛
+     */
+    CockpitOverviewPerceptionSnapshot loadPerception(
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
+            Integer industryRootTypeId);
+
+    /** 基础设施管网:桶里带编码前两位,片区/行业由服务层按 {@code CockpitDistrictPrefix} 与类型映射筛。 */
     CockpitOverviewPipeNetworkSnapshot loadPipeNetwork();
 
-    CockpitOverviewWarningStudySnapshot loadWarningStudy(CockpitOverviewWindows windows);
+    CockpitOverviewWarningStudySnapshot loadWarningStudy(
+            CockpitOverviewWindows windows,
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
+            Integer industryRootTypeId);
 
-    CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(CockpitOverviewWindows windows);
+    CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(
+            CockpitOverviewWindows windows,
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
+            Integer industryRootTypeId);
 }

+ 164 - 14
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewModulesService.java

@@ -1,8 +1,10 @@
 package com.zksy.cockpit.overview;
 
 import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
 import com.zksy.cockpit.foundation.CockpitReadException;
 import org.springframework.stereotype.Service;
@@ -60,6 +62,19 @@ public class CockpitOverviewModulesService {
     private static final String[] LEVEL_PREFIXES = {"一", "二", "三", "四"};
     private static final List<Integer> DISTRICT_CODES =
             Collections.unmodifiableList(Arrays.asList(0, 1, 2));
+    /**
+     * 行业 → 设备类型树**根类型 id**(票 81):1 供水 / 2 排水 / 3 燃气 / 4 窨井 ——
+     * 与 {@link #DEVICE_CATEGORIES} 的四张卡片同一套词表,不另立映射。
+     */
+    private static final Map<String, Integer> INDUSTRY_ROOT_TYPES;
+    static {
+        Map<String, Integer> rootTypes = new LinkedHashMap<>();
+        rootTypes.put("WATER_SUPPLY", 1);
+        rootTypes.put("DRAINAGE", 2);
+        rootTypes.put("GAS", 3);
+        rootTypes.put("MANHOLE", 4);
+        INDUSTRY_ROOT_TYPES = Collections.unmodifiableMap(rootTypes);
+    }
     private static final List<String> UNRESOLVED_STATUSES =
             Collections.unmodifiableList(Arrays.asList("PENDING", "RECEIVED", "PROCESSING", "HANDLED"));
 
@@ -87,20 +102,85 @@ public class CockpitOverviewModulesService {
         this.clock = clock;
     }
 
+    /** 不带筛选的重载(老调用方沿用):等价于全域。 */
     public synchronized CockpitOverviewPerceptionResponse loadPerception(String requestId) {
-        return read(() -> perception(requestId));
+        return loadPerception(requestId, null, null);
+    }
+
+    /**
+     * 概览·感知设备(票 81 起收 district/industry,口径=决策 a)。
+     *
+     * <p>筛选**下推到聚合 SQL 的 WHERE**:分桶(byType/byDistrict)与总数(totals/onlineRate)
+     * 因此永远描述同一批设备,不会出现「分桶按片区收窄、总数还是全域」的两个口径。
+     */
+    public synchronized CockpitOverviewPerceptionResponse loadPerception(
+            String requestId, String district, String industry) {
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
+        Integer rootTypeId = resolveIndustryRootType(industry);
+        CockpitOverviewPerceptionResponse response =
+                read(() -> perception(requestId, selectedDistrict, rootTypeId));
+        response.setFilters(filters(industry, selectedDistrict));
+        return response;
     }
 
     public synchronized CockpitOverviewPipeNetworkResponse loadPipeNetwork(String requestId) {
-        return read(() -> pipeNetwork(requestId));
+        return loadPipeNetwork(requestId, null, null);
+    }
+
+    /**
+     * 概览·基础设施(管网)。片区/行业在服务层按**编码前缀**({@link CockpitDistrictPrefix})
+     * 与类型映射收敛 —— 管网没有片区列,SQL 只多 GROUP 出一个原始前缀列,
+     * 映射规则只此一处,不在 SQL 里再写一套。
+     */
+    public synchronized CockpitOverviewPipeNetworkResponse loadPipeNetwork(
+            String requestId, String district, String industry) {
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
+        Integer rootTypeId = resolveIndustryRootType(industry);
+        CockpitOverviewPipeNetworkResponse response =
+                read(() -> pipeNetwork(requestId, selectedDistrict, rootTypeId));
+        response.setFilters(filters(industry, selectedDistrict));
+        return response;
     }
 
     public synchronized CockpitOverviewWarningStudyResponse loadWarningStudy(String requestId) {
-        return read(() -> warningStudy(requestId));
+        return loadWarningStudy(requestId, null, null);
+    }
+
+    /** 概览·预警研判(票 81):片区/行业下推到聚合 SQL 的 WHERE(今日/未解除/排行同一批预警)。 */
+    public synchronized CockpitOverviewWarningStudyResponse loadWarningStudy(
+            String requestId, String district, String industry) {
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
+        Integer rootTypeId = resolveIndustryRootType(industry);
+        CockpitOverviewWarningStudyResponse response =
+                read(() -> warningStudy(requestId, selectedDistrict, rootTypeId));
+        response.setFilters(filters(industry, selectedDistrict));
+        return response;
     }
 
     public synchronized CockpitOverviewAlarmDisposalResponse loadAlarmDisposal(String requestId) {
-        return read(() -> alarmDisposal(requestId));
+        return loadAlarmDisposal(requestId, null, null);
+    }
+
+    /** 概览·报警处置(票 81):片区/行业下推到聚合 SQL 的 WHERE(总数/转化/时效/按日两条线同一批报警)。 */
+    public synchronized CockpitOverviewAlarmDisposalResponse loadAlarmDisposal(
+            String requestId, String district, String industry) {
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
+        Integer rootTypeId = resolveIndustryRootType(industry);
+        CockpitOverviewAlarmDisposalResponse response =
+                read(() -> alarmDisposal(requestId, selectedDistrict, rootTypeId));
+        response.setFilters(filters(industry, selectedDistrict));
+        return response;
+    }
+
+    /**
+     * 入参错误(票 81):与事件族端点同一文案 —— 非法片区/行业一律 400,不静默忽略。
+     */
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                message,
+                requestId,
+                businessTime());
     }
 
     public CockpitErrorPayload serverError(String requestId) {
@@ -113,8 +193,9 @@ public class CockpitOverviewModulesService {
 
     // ---------------------------------------------------------------- 感知设备
 
-    private CockpitOverviewPerceptionResponse perception(String requestId) {
-        CockpitOverviewPerceptionSnapshot snapshot = adapter.loadPerception();
+    private CockpitOverviewPerceptionResponse perception(
+            String requestId, CockpitDistrict.Query district, Integer industryRootTypeId) {
+        CockpitOverviewPerceptionSnapshot snapshot = adapter.loadPerception(district, industryRootTypeId);
         List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets =
                 safe(snapshot == null ? null : snapshot.getBuckets());
 
@@ -149,11 +230,11 @@ public class CockpitOverviewModulesService {
                     byType[categoryIndex][2] += count;
                 }
             }
-            Integer district = CockpitDistrict.codeOf(bucket.getDistrict());
-            if (district == null) {
+            Integer bucketDistrict = CockpitDistrict.codeOf(bucket.getDistrict());
+            if (bucketDistrict == null) {
                 unassignedDistrict += count;
             } else {
-                byDistrict[DISTRICT_CODES.indexOf(district)] += count;
+                byDistrict[DISTRICT_CODES.indexOf(bucketDistrict)] += count;
             }
         }
 
@@ -209,7 +290,8 @@ public class CockpitOverviewModulesService {
 
     // ---------------------------------------------------------------- 基础设施
 
-    private CockpitOverviewPipeNetworkResponse pipeNetwork(String requestId) {
+    private CockpitOverviewPipeNetworkResponse pipeNetwork(
+            String requestId, CockpitDistrict.Query district, Integer industryRootTypeId) {
         CockpitOverviewPipeNetworkSnapshot snapshot = adapter.loadPipeNetwork();
         List<CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket> buckets =
                 safe(snapshot == null ? null : snapshot.getBuckets());
@@ -220,6 +302,17 @@ public class CockpitOverviewModulesService {
         Map<String, BigDecimal> lengths = new LinkedHashMap<>();
         int unclassified = 0;
         for (CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket bucket : buckets) {
+            // 片区(票 81):管网没有片区列,只能按编码前缀(CockpitDistrictPrefix)判定;
+            // 推不出前缀的管网归「未分区」,参与 UNASSIGNED 筛选。
+            if (!CockpitDistrict.matches(
+                    district, CockpitDistrictPrefix.codeOf(bucket.getDistrictPrefix()))) {
+                continue;
+            }
+            // 行业:管网类型 → 根类型(1 供水 / 2 排水 / 3 燃气),与感知卡片同一套词表。
+            if (industryRootTypeId != null
+                    && !industryRootTypeId.equals(pipeRootTypeId(bucket.getTypeCode()))) {
+                continue;
+            }
             pipeCount += bucket.getPipeCount();
             if (bucket.getLengthMeters() != null) {
                 totalLengthMeters = totalLengthMeters.add(bucket.getLengthMeters());
@@ -267,9 +360,11 @@ public class CockpitOverviewModulesService {
 
     // ------------------------------------------------------------ 预警研判
 
-    private CockpitOverviewWarningStudyResponse warningStudy(String requestId) {
+    private CockpitOverviewWarningStudyResponse warningStudy(
+            String requestId, CockpitDistrict.Query district, Integer industryRootTypeId) {
         CockpitOverviewWindows windows = CockpitOverviewWindows.of(clock);
-        CockpitOverviewWarningStudySnapshot snapshot = adapter.loadWarningStudy(windows);
+        CockpitOverviewWarningStudySnapshot snapshot =
+                adapter.loadWarningStudy(windows, district, industryRootTypeId);
         List<CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount> statusLevelCounts =
                 safe(snapshot == null ? null : snapshot.getStatusLevelCounts());
         Map<String, String> typeNames =
@@ -334,9 +429,11 @@ public class CockpitOverviewModulesService {
 
     // ------------------------------------------------------------ 报警处置
 
-    private CockpitOverviewAlarmDisposalResponse alarmDisposal(String requestId) {
+    private CockpitOverviewAlarmDisposalResponse alarmDisposal(
+            String requestId, CockpitDistrict.Query district, Integer industryRootTypeId) {
         CockpitOverviewWindows windows = CockpitOverviewWindows.of(clock);
-        CockpitOverviewAlarmDisposalSnapshot snapshot = adapter.loadAlarmDisposal(windows);
+        CockpitOverviewAlarmDisposalSnapshot snapshot =
+                adapter.loadAlarmDisposal(windows, district, industryRootTypeId);
         int total = snapshot == null ? 0 : snapshot.getTotalCount();
         int todayNew = snapshot == null ? 0 : snapshot.getTodayNew();
         int yesterdayNew = snapshot == null ? 0 : snapshot.getYesterdayNew();
@@ -414,6 +511,59 @@ public class CockpitOverviewModulesService {
 
     // ---------------------------------------------------------------- 内部工具
 
+    /**
+     * 片区入参(票 81):{@code null}/空=不筛;{@code 0/1/2}=按片区筛;
+     * {@code UNASSIGNED}=只看未分区;其它值 400(与已部署的事件族端点同一文案)。
+     */
+    private static CockpitDistrict.Query resolveDistrict(String district) {
+        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
+        if (query.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
+        return query;
+    }
+
+    /**
+     * 行业入参(票 81):{@code null}/空=不筛;正式行业域之外一律 400。
+     * 行业最终落成设备类型树的根类型 id(见 {@link #INDUSTRY_ROOT_TYPES})。
+     */
+    private static Integer resolveIndustryRootType(String industry) {
+        if (industry == null || industry.trim().isEmpty()) {
+            return null;
+        }
+        Integer rootTypeId = INDUSTRY_ROOT_TYPES.get(industry.trim());
+        if (rootTypeId == null) {
+            throw new CockpitInvalidRequestException(
+                    "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
+        }
+        return rootTypeId;
+    }
+
+    /** 管网类型 → 行业根类型 id;无法映射(未分类哨兵/未知类型)回 {@code null}。 */
+    private static Integer pipeRootTypeId(String typeCode) {
+        if (typeCode == null) {
+            return null;
+        }
+        if (CockpitPipeNetworkType.WATER_PIPE.equals(typeCode)) {
+            return 1;
+        }
+        if (CockpitPipeNetworkType.DRAINAGE_PIPE.equals(typeCode)) {
+            return 2;
+        }
+        if (CockpitPipeNetworkType.GAS_PIPE.equals(typeCode)) {
+            return 3;
+        }
+        return null;
+    }
+
+    private static CockpitOverviewReadEnvelope.Filters filters(
+            String industry, CockpitDistrict.Query district) {
+        return new CockpitOverviewReadEnvelope.Filters(
+                industry == null || industry.trim().isEmpty() ? null : industry.trim(),
+                district.code(),
+                district.active() ? CockpitDistrict.nameOf(district.code()) : null);
+    }
+
     private <T> T read(Supplier<T> loader) {
         try {
             return loader.get();

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewPipeNetworkSnapshot.java

@@ -22,15 +22,27 @@ public class CockpitOverviewPipeNetworkSnapshot {
         private final String typeCode;
         private final int pipeCount;
         private final BigDecimal lengthMeters;
+        /**
+         * 管网编码前两位(票 81,原值,不在这里翻译)。管网没有片区列,片区只能由
+         * {@code CockpitDistrictPrefix} 从编码前缀推出 —— 规则留在 Java 单点,SQL 只暴露原始前缀。
+         */
+        private final String districtPrefix;
 
         public SourcePipeBucket(String typeCode, int pipeCount, BigDecimal lengthMeters) {
+            this(typeCode, pipeCount, lengthMeters, null);
+        }
+
+        public SourcePipeBucket(String typeCode, int pipeCount, BigDecimal lengthMeters,
+                String districtPrefix) {
             this.typeCode = typeCode;
             this.pipeCount = pipeCount;
             this.lengthMeters = lengthMeters;
+            this.districtPrefix = districtPrefix;
         }
 
         public String getTypeCode() { return typeCode; }
         public int getPipeCount() { return pipeCount; }
         public BigDecimal getLengthMeters() { return lengthMeters; }
+        public String getDistrictPrefix() { return districtPrefix; }
     }
 }

+ 31 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewReadEnvelope.java

@@ -30,6 +30,20 @@ public class CockpitOverviewReadEnvelope {
         this.freshness = freshness;
     }
 
+    /**
+     * 生效筛选回显(票 81):四个模块共用一份,形状与事件族端点的 {@code filters} 同形 ——
+     * 行业原值 + 片区编码与展示名双字段。
+     *
+     * <p>「只看未分区」({@code district=UNASSIGNED})时 {@code district=null} 且
+     * {@code districtName="未分区"};<b>未筛</b>时两者都是 {@code null}。
+     * 前端据此分清「这份读数是全域」还是「已被片区/行业筛过」,不靠猜。
+     */
+    private Filters filters;
+
+    /** 由服务层在拿到响应后回填:四个模块的构造签名因此不用各加一套参数。 */
+    public void setFilters(Filters filters) { this.filters = filters; }
+    public Filters getFilters() { return filters; }
+
     public String getRequestId() { return requestId; }
     public String getApiVersion() { return apiVersion; }
     public boolean isReadOnly() { return readOnly; }
@@ -37,6 +51,23 @@ public class CockpitOverviewReadEnvelope {
     public String getUpdatedAt() { return updatedAt; }
     public Freshness getFreshness() { return freshness; }
 
+    /** 四模块共用的生效筛选回显(票 81)。 */
+    public static class Filters {
+        private final String industry;
+        private final Integer district;
+        private final String districtName;
+
+        public Filters(String industry, Integer district, String districtName) {
+            this.industry = industry;
+            this.district = district;
+            this.districtName = districtName;
+        }
+
+        public String getIndustry() { return industry; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+    }
+
     /** 数据新鲜度:{@code CURRENT} 有源数据,{@code EMPTY} 源集合为空(空 ≠ 失败)。 */
     public static class Freshness {
         private final String status;

+ 11 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewModulesAdapter.java

@@ -8,7 +8,9 @@ import java.util.Collections;
 @Component
 public class EmptyCockpitOverviewModulesAdapter implements CockpitOverviewModulesAdapter {
     @Override
-    public CockpitOverviewPerceptionSnapshot loadPerception() {
+    public CockpitOverviewPerceptionSnapshot loadPerception(
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
+            Integer industryRootTypeId) {
         return new CockpitOverviewPerceptionSnapshot(
                 null, Collections.emptyList(), Collections.emptyList());
     }
@@ -19,14 +21,20 @@ public class EmptyCockpitOverviewModulesAdapter implements CockpitOverviewModule
     }
 
     @Override
-    public CockpitOverviewWarningStudySnapshot loadWarningStudy(CockpitOverviewWindows windows) {
+    public CockpitOverviewWarningStudySnapshot loadWarningStudy(
+            CockpitOverviewWindows windows,
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
+            Integer industryRootTypeId) {
         return new CockpitOverviewWarningStudySnapshot(
                 null, 0, 0, Collections.emptyList(), 0, 0,
                 Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap());
     }
 
     @Override
-    public CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(CockpitOverviewWindows windows) {
+    public CockpitOverviewAlarmDisposalSnapshot loadAlarmDisposal(
+            CockpitOverviewWindows windows,
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
+            Integer industryRootTypeId) {
         return new CockpitOverviewAlarmDisposalSnapshot(
                 null, 0, 0, 0, 0, 0,
                 Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap());

+ 55 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/mapper/CockpitOverviewModulesMapper.java

@@ -14,54 +14,96 @@ import java.util.Map;
  */
 public interface CockpitOverviewModulesMapper {
 
-    /** 设备计数桶:类型 × 片区 × 是否有状态记录 × 是否在线(含任意层级类型树上的根类型)。 */
-    List<Map<String, Object>> selectDeviceBuckets();
+    /**
+     * 设备计数桶:类型 × 片区 × 是否有状态记录 × 是否在线(含任意层级类型树上的根类型)。
+     *
+     * <p>票 81:片区/行业筛选**下推到 WHERE**(此前 district 只出现在 GROUP BY);
+     * 分桶与 total 必须是同一批设备,否则两个口径会对不上。
+     *
+     * @param district           片区编码 0/1/2;{@code null}=不按片区筛(未分区筛时也是 null,靠 unassigned 区分)
+     * @param unassigned         是否只看未分区(与 {@code CockpitDistrict.codeOf} 的归一判据一致)
+     * @param industryRootTypeId 行业对应的根类型 id(1 供水 / 2 排水 / 3 燃气 / 4 窨井);{@code null}=不筛
+     */
+    List<Map<String, Object>> selectDeviceBuckets(
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 监测内容派生源:根类型 → 该类型树覆盖的监测指标中文名。 */
     List<Map<String, Object>> selectMonitoringContents();
 
-    /** 管网计数桶:{@code network_type} 原值 → 条数与总长度(米)。 */
+    /**
+     * 管网计数桶:{@code network_type} 原值 → 条数与总长度(米),并额外 GROUP 出编码前两位
+     * ({@code district_prefix},票 81)。管网无片区列,片段筛选与映射都在 Java 单点完成。
+     */
     List<Map<String, Object>> selectPipeBuckets();
 
     /** 预警今日/昨日新增与源数据最新更新时间。 */
     Map<String, Object> selectWarningCounts(
             @Param("yesterdayStart") Date yesterdayStart,
             @Param("todayStart") Date todayStart,
-            @Param("tomorrowStart") Date tomorrowStart);
+            @Param("tomorrowStart") Date tomorrowStart,
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
-    /** 未解除集合按 状态 × 等级 的交叉计数。 */
-    List<Map<String, Object>> selectUnresolvedStatusLevelCounts();
+    /** 未解除集合按 状态 × 等级 的交叉计数(票 81:片区/行业筛选下推到 WHERE)。 */
+    List<Map<String, Object>> selectUnresolvedStatusLevelCounts(
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 近 30 日(或指定窗口)预警总条数。 */
     Map<String, Object> selectWarningCountInWindow(
             @Param("startInclusive") Date startInclusive,
-            @Param("endExclusive") Date endExclusive);
+            @Param("endExclusive") Date endExclusive,
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 窗口内预警按类型分组的计数。 */
     List<Map<String, Object>> selectWarningTypeCountsInWindow(
             @Param("startInclusive") Date startInclusive,
-            @Param("endExclusive") Date endExclusive);
+            @Param("endExclusive") Date endExclusive,
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 报警基础集合({@code audit_status = 1}):总数、今日/昨日新增、未转工单数。 */
     Map<String, Object> selectAlarmTotals(
             @Param("yesterdayStart") Date yesterdayStart,
             @Param("todayStart") Date todayStart,
-            @Param("tomorrowStart") Date tomorrowStart);
+            @Param("tomorrowStart") Date tomorrowStart,
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 基础集合中关联工单已结案({@code order_status = 5})的报警数。 */
-    Map<String, Object> selectAlarmClosedCount();
+    Map<String, Object> selectAlarmClosedCount(
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 基础集合按报警等级分组计数。 */
-    List<Map<String, Object>> selectAlarmLevelCounts();
+    List<Map<String, Object>> selectAlarmLevelCounts(
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /** 基础集合按创建日期分组计数。 */
     List<Map<String, Object>> selectAlarmDailyCounts(
             @Param("startInclusive") Date startInclusive,
-            @Param("endExclusive") Date endExclusive);
+            @Param("endExclusive") Date endExclusive,
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 
     /**
      * 响应/处置时长样本:按 {@code oper_type} 聚合(2 = 接单、5 = 验收通过),
      * 每个工单每类只取 {@code oper_time} 最晚、并列 {@code log_id} 最大的一条。
      */
-    List<Map<String, Object>> selectAlarmDurationSamples();
+    List<Map<String, Object>> selectAlarmDurationSamples(
+            @Param("district") Integer district,
+            @Param("unassigned") boolean unassigned,
+            @Param("industryRootTypeId") Integer industryRootTypeId);
 }

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

@@ -14,6 +14,7 @@ import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitReadException;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.apache.ibatis.exceptions.PersistenceException;
@@ -110,6 +111,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
             Date startTime,
             Date endTime,
             String industry,
+            CockpitDistrict.Query district,
             int limit,
             int offset) {
         LambdaQueryWrapper<WorkOrder> wrapper = new LambdaQueryWrapper<>();
@@ -138,6 +140,17 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                     "SELECT equipment_id FROM equipment_base WHERE equipment_type_id IN ("
                             + String.join(", ", typeIds) + ")");
         }
+        if (district != null && district.active()) {
+            // 工单 → 设备 → equipment_base.district(片区权威来源,票 12)。
+            // 与 handling-efficiency 的 scopeWorkOrders(...) 同一派生:列表与上方状态分布
+            // 必须筛同一批工单,否则两处读数对不上。
+            // 未分区判据与 CockpitDistrict.codeOf(...) 逐字对齐:null 与 0/1/2 之外的脏值都算未分区。
+            String condition = district.unassignedOnly()
+                    ? "district IS NULL OR district NOT IN (0, 1, 2)"
+                    : "district = " + district.code();
+            wrapper.inSql(WorkOrder::getDeviceId,
+                    "SELECT equipment_id FROM equipment_base WHERE " + condition);
+        }
         long total = workOrderService.count(wrapper);
         List<WorkOrder> orders = total <= offset
                 ? Collections.emptyList()

+ 42 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderListResponse.java

@@ -18,6 +18,8 @@ public class CockpitWorkOrderListResponse {
     private final List<CockpitWorkOrderState> stateDictionary;
     private final List<CockpitWorkOrderSummary> workOrders;
     private final Pagination pagination;
+    /** 生效筛选回显(票 81);老调用方走 8 参构造时序列化为 {@code null},不伪造。 */
+    private final Filters filters;
 
     public CockpitWorkOrderListResponse(
             String requestId,
@@ -28,6 +30,20 @@ public class CockpitWorkOrderListResponse {
             List<CockpitWorkOrderState> stateDictionary,
             List<CockpitWorkOrderSummary> workOrders,
             Pagination pagination) {
+        this(requestId, apiVersion, readOnly, state, stateReason, stateDictionary,
+                workOrders, pagination, null);
+    }
+
+    public CockpitWorkOrderListResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String state,
+            String stateReason,
+            List<CockpitWorkOrderState> stateDictionary,
+            List<CockpitWorkOrderSummary> workOrders,
+            Pagination pagination,
+            Filters filters) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -36,6 +52,7 @@ public class CockpitWorkOrderListResponse {
         this.stateDictionary = stateDictionary;
         this.workOrders = workOrders;
         this.pagination = pagination;
+        this.filters = filters;
     }
 
     public String getRequestId() { return requestId; }
@@ -46,6 +63,31 @@ public class CockpitWorkOrderListResponse {
     public List<CockpitWorkOrderState> getStateDictionary() { return stateDictionary; }
     public List<CockpitWorkOrderSummary> getWorkOrders() { return workOrders; }
     public Pagination getPagination() { return pagination; }
+    public Filters getFilters() { return filters; }
+
+    /**
+     * 生效筛选回显(票 81):与事件族端点的 {@code filters} 同形 ——
+     * 行业原值 + 片区编码与展示名双字段。
+     *
+     * <p>「只看未分区」({@code district=UNASSIGNED})时 {@code district=null} 且
+     * {@code districtName="未分区"};<b>未筛</b>时两者都是 {@code null} ——
+     * 前端据此区分「看全域」与「只看未分区」,不靠猜(口径同 {@code CockpitDistrict})。
+     */
+    public static class Filters {
+        private final String industry;
+        private final Integer district;
+        private final String districtName;
+
+        public Filters(String industry, Integer district, String districtName) {
+            this.industry = industry;
+            this.district = district;
+            this.districtName = districtName;
+        }
+
+        public String getIndustry() { return industry; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+    }
 
     /** 分页回显(与其它驾驶舱端点同名字段:page/pageSize/total/totalPages,前端读同一套)。 */
     public static class Pagination {

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadAdapter.java

@@ -28,6 +28,11 @@ public interface CockpitWorkOrderReadAdapter {
      *                  口径经「工单设备 → 设备类型 → 行业」派生,与
      *                  {@code CockpitHandlingEfficiencyService.scopeWorkOrders(...)} 同一套规则,
      *                  这样列表与上方状态分布筛的是**同一批工单**。
+     * @param district  片区条件(票 81);{@code null} 或未生效表示不筛。口径经
+     *                  「工单设备 → {@code equipment_base.district}」派生,与
+     *                  {@code CockpitHandlingEfficiencyService.scopeWorkOrders(...)} 同一套规则 ——
+     *                  未分区用「设备片区为 null 或不在 0/1/2 内」表达,与
+     *                  {@code CockpitDistrict.codeOf(...)} 的归一判据逐字对齐。
      * @param limit     本页条数(调用方已夹到合法区间)
      * @param offset    偏移量,{@code (page-1)*limit}
      */
@@ -36,6 +41,7 @@ public interface CockpitWorkOrderReadAdapter {
             Date startTime,
             Date endTime,
             String industry,
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
             int limit,
             int offset);
 }

+ 17 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadService.java

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.workorder;
 
 import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -95,6 +96,9 @@ public class CockpitWorkOrderReadService {
      * @param page      页码 1 起(null/越界 → 1)
      * @param pageSize  每页条数(null/越界 → 10;上限 100,避免一次拉全库)
      * @param stateCode 工单状态码 0–7(null 或越界 → 不筛)
+     * @param district  片区(票 81):{@code 0/1/2} 按片区筛、{@code UNASSIGNED} 只看未分区、
+     *                  空 → 不筛;非法值 400。口径经「工单设备 → {@code equipment_base.district}」派生,
+     *                  与 {@code CockpitHandlingEfficiencyService.scopeWorkOrders(...)} 同一套规则。
      * @param startTime 窗口起点 ISO-8601(含);空 → 不限下界。**不合法一律 400**,
      *                  不静默忽略 —— 静默忽略会让「以为筛了时间、其实全量」这种错看不出
      * @param endTime   窗口终点 ISO-8601(含);空 → 不限上界
@@ -105,9 +109,15 @@ public class CockpitWorkOrderReadService {
             Integer pageSize,
             Integer stateCode,
             String industry,
+            String district,
             String startTime,
             String endTime) {
         String safeIndustry = resolveIndustry(industry);
+        // 片区(票 81):不传=不筛;0/1/2 按片区筛;UNASSIGNED 只看未分区;其它值一律 400。
+        CockpitDistrict.Query safeDistrict = CockpitDistrict.parseQuery(district);
+        if (safeDistrict.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
         int safePage = page == null || page < 1 ? 1 : page;
         int safeSize = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
         Integer safeState = stateCode != null && stateCode >= 0 && stateCode <= 7 ? stateCode : null;
@@ -117,7 +127,7 @@ public class CockpitWorkOrderReadService {
             throw new CockpitInvalidRequestException("startTime must not be after endTime");
         }
         CockpitWorkOrderListSnapshot snapshot = adapter.loadAll(
-                safeState, windowStart, windowEnd, safeIndustry,
+                safeState, windowStart, windowEnd, safeIndustry, safeDistrict,
                 safeSize, (safePage - 1) * safeSize);
         if (snapshot == null) {
             throw new CockpitReadException("work order list unavailable");
@@ -133,7 +143,12 @@ public class CockpitWorkOrderReadService {
                 empty ? "NO_WORK_ORDERS" : "READY",
                 CockpitWorkOrderStateDictionary.states(),
                 workOrders,
-                new CockpitWorkOrderListResponse.Pagination(safePage, safeSize, snapshot.getTotal()));
+                new CockpitWorkOrderListResponse.Pagination(safePage, safeSize, snapshot.getTotal()),
+                // 生效范围回显(票 81):未筛时两个字段都是 null,未分区时 district=null + 名称「未分区」。
+                new CockpitWorkOrderListResponse.Filters(
+                        safeIndustry,
+                        safeDistrict.code(),
+                        safeDistrict.active() ? CockpitDistrict.nameOf(safeDistrict.code()) : null));
     }
 
     /**

+ 1 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/EmptyCockpitWorkOrderReadAdapter.java

@@ -25,6 +25,7 @@ public class EmptyCockpitWorkOrderReadAdapter implements CockpitWorkOrderReadAda
             java.util.Date startTime,
             java.util.Date endTime,
             String industry,
+            com.zksy.cockpit.foundation.CockpitDistrict.Query district,
             int limit,
             int offset) {
         return null;

+ 144 - 9
pipe-network-service/zksy-system/src/main/resources/mapper/cockpit/CockpitOverviewModulesMapper.xml

@@ -1,6 +1,11 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-<!-- 总体概览四模块聚合查询(规格 2026-10-22 §2–§5)。全部单次扫描 + GROUP BY,不下拉明细到 Java。 -->
+<!-- 总体概览四模块聚合查询(规格 2026-10-22 §2–§5)。全部单次扫描 + GROUP BY,不下拉明细到 Java。
+     票 81:四端点补 district/industry 入参后,筛选**下推到 WHERE**(此前 district 只出现在 GROUP BY)。
+     片区的两条来源刻意分开、不互相冒充:
+       · 设备/预警/报警类读数 → 沿设备回联 equipment_base.district(权威片区列);
+       · 管网类读数           → 设施没有片区列,只能取编码前缀(规则在 Java 的 CockpitDistrictPrefix,
+                                这里只多暴露一个原始前缀列,不在 SQL 里再写一套映射)。 -->
 <mapper namespace="com.zksy.cockpit.overview.mapper.CockpitOverviewModulesMapper">
 
     <!-- 设备类型树:根类型(parent_id = 0)→ 任意层级子类型的归属。
@@ -17,7 +22,43 @@
         )
     </sql>
 
-    <!-- 每台设备的最新状态:status_update_time 最新,并列取 status_id 最大,且 del_flag = '0'(规格 §0/§2)。 -->
+    <!-- 片区的 SQL 判据(票 81),与 Java 侧 CockpitDistrict 逐字对齐:
+           · 已指派某片区 → eb.district = #{district}
+           · 未分区       → 没有可回联的设备行,或 eb.district 为 null / 不在 0/1/2 内
+                            (= CockpitDistrict.codeOf 会归一成 null 的那一批)
+         行业按设备类型树的根类型判定(1 供水 / 2 排水 / 3 燃气 / 4 窨井),
+         与感知设备四张固定卡片同一套根类型,不另立词表。 -->
+    <sql id="deviceDistrictFilter">
+        <if test="district != null or unassigned">
+            AND <choose>
+                <when test="unassigned">
+                    NOT EXISTS (
+                        SELECT 1 FROM app_user.equipment_base eb
+                        WHERE eb.equipment_code = w.device_code
+                          AND eb.district IN (0, 1, 2)
+                    )
+                </when>
+                <otherwise>
+                    EXISTS (
+                        SELECT 1 FROM app_user.equipment_base eb
+                        WHERE eb.equipment_code = w.device_code
+                          AND eb.district = #{district}
+                    )
+                </otherwise>
+            </choose>
+        </if>
+        <if test="industryRootTypeId != null">
+            AND EXISTS (
+                SELECT 1 FROM app_user.equipment_base ebi
+                JOIN type_tree tti ON tti.id = ebi.equipment_type_id
+                WHERE ebi.equipment_code = w.device_code
+                  AND tti.root_id = #{industryRootTypeId}
+            )
+        </if>
+    </sql>
+
+    <!-- 每台设备的最新状态:status_update_time 最新,并列取 status_id 最大,且 del_flag = '0'(规格 §0/§2)。
+         票 81:device 过滤加在 dev CTE 内部(equipment_base 就在这一层),聚合层不再过滤。 -->
     <select id="selectDeviceBuckets" resultType="java.util.LinkedHashMap">
         <include refid="typeTree"/>,
         dev AS (
@@ -36,6 +77,20 @@
                 ORDER BY s.status_update_time DESC NULLS LAST, s.status_id DESC
                 LIMIT 1
             ) ls ON TRUE
+            WHERE 1 = 1
+            <if test="district != null or unassigned">
+                AND <choose>
+                    <when test="unassigned">
+                        (eb.district IS NULL OR eb.district NOT IN (0, 1, 2))
+                    </when>
+                    <otherwise>
+                        eb.district = #{district}
+                    </otherwise>
+                </choose>
+            </if>
+            <if test="industryRootTypeId != null">
+                AND tt.root_id = #{industryRootTypeId}
+            </if>
         )
         SELECT equipment_type_id, district, root_type_id, has_status, is_online,
                COUNT(*) AS device_count, MAX(source_updated_at) AS source_updated_at
@@ -54,54 +109,97 @@
         ORDER BY tt.root_id, wt.id
     </select>
 
-    <!-- 长度单位是米;吊销行由 del_flag 显式排除。network_type 的语义映射留在 CockpitPipeNetworkType。 -->
+    <!-- 长度单位是米;吊销行由 del_flag 显式排除。network_type 的语义映射留在 CockpitPipeNetworkType。
+         票 81:管网没有片区列,额外 GROUP 出**编码前两位**(district_prefix),
+         由 Java 的 CockpitDistrictPrefix 做唯一映射与筛选(CN=城南 / CB=城北 / TC=太常)。 -->
     <select id="selectPipeBuckets" resultType="java.util.LinkedHashMap">
         SELECT pb.network_type AS network_type,
+               LEFT(UPPER(COALESCE(pb.network_code, '')), 2) AS district_prefix,
                COUNT(*) AS pipe_count,
                COALESCE(SUM(pb.length), 0) AS length_meters,
                MAX(pb.update_time) AS source_updated_at
         FROM app_user.pipe_network_base pb
         WHERE pb.del_flag = '0'
-        GROUP BY pb.network_type
+        GROUP BY pb.network_type, LEFT(UPPER(COALESCE(pb.network_code, '')), 2)
     </select>
 
     <select id="selectWarningCounts" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT COALESCE(SUM(CASE WHEN w.create_time &gt;= #{todayStart} AND w.create_time &lt; #{tomorrowStart}
                                  THEN 1 ELSE 0 END), 0) AS today_new,
                COALESCE(SUM(CASE WHEN w.create_time &gt;= #{yesterdayStart} AND w.create_time &lt; #{todayStart}
                                  THEN 1 ELSE 0 END), 0) AS yesterday_new,
                MAX(w.update_time) AS source_updated_at
         FROM app_user.early_warning w
+        WHERE 1 = 1
+        <include refid="deviceDistrictFilter"/>
     </select>
 
     <!-- 未解除集合 = PENDING / RECEIVED / PROCESSING / HANDLED(DRAFT、CLOSED 排除,规格 §4)。
          不复用 BusinessCockpitEventAdapter 的 HANDLING 拼写错误常量。 -->
     <select id="selectUnresolvedStatusLevelCounts" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT UPPER(TRIM(w.status)) AS status,
                w.warning_level AS warning_level,
                COUNT(*) AS type_count
         FROM app_user.early_warning w
         WHERE UPPER(TRIM(w.status)) IN ('PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED')
+        <include refid="deviceDistrictFilter"/>
         GROUP BY UPPER(TRIM(w.status)), w.warning_level
     </select>
 
     <select id="selectWarningCountInWindow" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT COUNT(*) AS warning_count
         FROM app_user.early_warning w
         WHERE w.create_time &gt;= #{startInclusive} AND w.create_time &lt; #{endExclusive}
+        <include refid="deviceDistrictFilter"/>
     </select>
 
     <select id="selectWarningTypeCountsInWindow" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT w.warning_type AS type_code,
                COUNT(*) AS type_count
         FROM app_user.early_warning w
         WHERE w.create_time &gt;= #{startInclusive} AND w.create_time &lt; #{endExclusive}
+        <include refid="deviceDistrictFilter"/>
         GROUP BY w.warning_type
         ORDER BY COUNT(*) DESC, w.warning_type ASC
     </select>
 
-    <!-- 基础集合 = alarm_data.audit_status = 1(维护者 2026-10-22 裁定,规格 §0)。 -->
+    <!-- 基础集合 = alarm_data.audit_status = 1(维护者 2026-10-22 裁定,规格 §0)。
+         报警的片区/行业同样沿 a.device_code 回联设备台账(下面各处复用同一判据)。 -->
+    <sql id="alarmDeviceFilter">
+        <if test="district != null or unassigned">
+            AND <choose>
+                <when test="unassigned">
+                    NOT EXISTS (
+                        SELECT 1 FROM app_user.equipment_base eb
+                        WHERE eb.equipment_code = a.device_code
+                          AND eb.district IN (0, 1, 2)
+                    )
+                </when>
+                <otherwise>
+                    EXISTS (
+                        SELECT 1 FROM app_user.equipment_base eb
+                        WHERE eb.equipment_code = a.device_code
+                          AND eb.district = #{district}
+                    )
+                </otherwise>
+            </choose>
+        </if>
+        <if test="industryRootTypeId != null">
+            AND EXISTS (
+                SELECT 1 FROM app_user.equipment_base ebi
+                JOIN type_tree tti ON tti.id = ebi.equipment_type_id
+                WHERE ebi.equipment_code = a.device_code
+                  AND tti.root_id = #{industryRootTypeId}
+            )
+        </if>
+    </sql>
+
     <select id="selectAlarmTotals" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT COUNT(*) AS alarm_count,
                COALESCE(SUM(CASE WHEN a.create_time &gt;= #{todayStart} AND a.create_time &lt; #{tomorrowStart}
                                  THEN 1 ELSE 0 END), 0) AS today_new,
@@ -111,20 +209,25 @@
                MAX(a.update_time) AS source_updated_at
         FROM app_user.alarm_data a
         WHERE a.audit_status = 1
+        <include refid="alarmDeviceFilter"/>
     </select>
 
     <select id="selectAlarmClosedCount" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT COUNT(*) AS closed_count
         FROM app_user.alarm_data a
         JOIN app_user.work_order w ON w.order_id = a.work_order_id
         WHERE a.audit_status = 1 AND w.order_status = 5
+        <include refid="alarmDeviceFilter"/>
     </select>
 
     <select id="selectAlarmLevelCounts" resultType="java.util.LinkedHashMap">
+        <include refid="typeTree"/>
         SELECT a.alarm_level AS alarm_level,
                COUNT(*) AS level_count
         FROM app_user.alarm_data a
         WHERE a.audit_status = 1
+        <include refid="alarmDeviceFilter"/>
         GROUP BY a.alarm_level
     </select>
 
@@ -137,9 +240,11 @@
          · 未处理**不受审核状态限制** —— 一条报警处没处理,跟它审没审核是两个维度。
            实测:只用 audit_status=1 时未处理恒为 0(那 2 条未处理的都是未审核),
            这条线会画成一条恒零的直线,"积压还在"的意思就丢了。
-         累计必须从**入库最早一条**起算,不能只用窗口内数据 —— 否则窗口之前的积压会漏掉。 -->
+         累计必须从**入库最早一条**起算,不能只用窗口内数据 —— 否则窗口之前的积压会漏掉。
+         票 81:片区/行业筛选对两条线**都**生效(同一个「范围内」定义),否则新增与积压会互相打架。 -->
     <select id="selectAlarmDailyCounts" resultType="java.util.LinkedHashMap">
-        WITH days AS (
+        <include refid="typeTree"/>,
+        days AS (
             SELECT TO_CHAR(d, 'YYYY-MM-DD') AS day, d AS day_date
             FROM generate_series(
                 #{startInclusive}::timestamp,
@@ -152,13 +257,41 @@
             FROM app_user.alarm_data a
             WHERE a.audit_status = 1
               AND a.create_time &gt;= #{startInclusive} AND a.create_time &lt; #{endExclusive}
+        <include refid="alarmDeviceFilter"/>
             GROUP BY 1
         )
         SELECT d.day AS day,
                COALESCE(cr.c, 0) AS daily_count,
                (SELECT COUNT(*) FROM app_user.alarm_data a2
                  WHERE a2.alarm_status = 0
-                   AND a2.create_time &lt; (d.day_date + interval '1 day')) AS unhandled_total
+                   AND a2.create_time &lt; (d.day_date + interval '1 day')
+                   <if test="district != null or unassigned">
+                       AND <choose>
+                           <when test="unassigned">
+                               NOT EXISTS (
+                                   SELECT 1 FROM app_user.equipment_base eb2
+                                   WHERE eb2.equipment_code = a2.device_code
+                                     AND eb2.district IN (0, 1, 2)
+                               )
+                           </when>
+                           <otherwise>
+                               EXISTS (
+                                   SELECT 1 FROM app_user.equipment_base eb2
+                                   WHERE eb2.equipment_code = a2.device_code
+                                     AND eb2.district = #{district}
+                               )
+                           </otherwise>
+                       </choose>
+                   </if>
+                   <if test="industryRootTypeId != null">
+                       AND EXISTS (
+                           SELECT 1 FROM app_user.equipment_base ebi2
+                           JOIN type_tree tti2 ON tti2.id = ebi2.equipment_type_id
+                           WHERE ebi2.equipment_code = a2.device_code
+                             AND tti2.root_id = #{industryRootTypeId}
+                       )
+                   </if>
+               ) AS unhandled_total
         FROM days d
         LEFT JOIN created cr ON cr.day = d.day
         ORDER BY 1
@@ -167,10 +300,12 @@
     <!-- 「最近一条」= 同工单同 oper_type 内 oper_time 最大、并列 log_id 最大(规格 §5);
          负时长(日志早于报警)单独计数,由服务层剔除。 -->
     <select id="selectAlarmDurationSamples" resultType="java.util.LinkedHashMap">
-        WITH base AS (
+        <include refid="typeTree"/>,
+        base AS (
             SELECT a.work_order_id AS work_order_id, a.create_time AS create_time
             FROM app_user.alarm_data a
             WHERE a.audit_status = 1 AND a.work_order_id IS NOT NULL AND a.create_time IS NOT NULL
+        <include refid="alarmDeviceFilter"/>
         ), latest_log AS (
             SELECT t.work_order_id AS work_order_id, t.oper_type AS oper_type, t.oper_time AS oper_time
             FROM (