Browse Source

feat(cockpit): district on event family endpoints and industry on work-orders

- events/current、events/history-analysis、events/handling-efficiency 补 district
  (0/1/2/UNASSIGNED;非法值 400;filters 回显 district/districtName,双字段口径)
- 事件族三端点的工单口径(处置率分母、状态分布、工单范围)与事件同片区收敛:
  只筛事件会让同一响应内两个口径打架(票 67 反馈的「各说各话」)
- /work-orders 补 industry(工单设备→设备类型树→行业派生,非法值 400)
- 契约测试覆盖:合法值生效 / 非法值 400 / 不传=全域;全量 Cockpit*Test 576/576
codex 1 tuần trước cách đây
mục cha
commit
6c3c889809
24 tập tin đã thay đổi với 700 bổ sung và 81 xóa
  1. 3 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsController.java
  2. 3 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyController.java
  3. 3 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisController.java
  4. 12 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadController.java
  5. 2 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitWorkOrderReadAdapterTest.java
  6. 105 5
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java
  7. 117 4
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyControllerTest.java
  8. 99 12
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisControllerTest.java
  9. 1 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderAssociationContractTest.java
  10. 57 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderListContractTest.java
  11. 25 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  12. 10 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsResponse.java
  13. 26 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsService.java
  14. 20 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsSnapshot.java
  15. 10 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyResponse.java
  16. 32 13
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyService.java
  17. 19 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencySnapshot.java
  18. 10 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisResponse.java
  19. 29 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java
  20. 31 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisSnapshot.java
  21. 45 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  22. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadAdapter.java
  23. 35 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadService.java
  24. 1 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/EmptyCockpitWorkOrderReadAdapter.java

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

@@ -33,6 +33,8 @@ public class CockpitCurrentEventsController {
             @RequestParam(value = "level", required = false) String level,
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "status", required = false) String status,
+            // 范围维度=片区(票 77):0/1/2 按片区筛、UNASSIGNED 只看未分区、不传=不筛。
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
             @RequestParam(value = "pageSize", defaultValue = "100") Integer pageSize,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
@@ -43,6 +45,7 @@ public class CockpitCurrentEventsController {
                         level,
                         industry,
                         status,
+                        district,
                         page,
                         pageSize)));
     }

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

@@ -34,6 +34,8 @@ public class CockpitHandlingEfficiencyController {
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "level", required = false) String level,
             @RequestParam(value = "workOrderState", required = false) String workOrderState,
+            // 范围维度=片区(票 77):语义与事件族其它端点同套(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,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
@@ -46,6 +48,7 @@ public class CockpitHandlingEfficiencyController {
                         industry,
                         level,
                         workOrderState,
+                        district,
                         startTime,
                         endTime,
                         page,

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

@@ -32,6 +32,8 @@ public class CockpitHistoryAnalysisController {
             @RequestParam(value = "eventFamily", required = false) String eventFamily,
             @RequestParam(value = "level", required = false) String level,
             @RequestParam(value = "industry", required = false) String industry,
+            // 范围维度=片区(票 77):语义与事件族其它端点同套(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,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
@@ -43,6 +45,7 @@ public class CockpitHistoryAnalysisController {
                         eventFamily,
                         level,
                         industry,
+                        district,
                         startTime,
                         endTime,
                         page,

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

@@ -77,7 +77,10 @@ public class CockpitWorkOrderReadController {
      *
      * <p>查询参数都可缺省:`page` 默认 1、`pageSize` 默认 10(上限 100)、
      * `stateCode` 缺省不筛、`startTime`/`endTime`(ISO-8601 带偏移)缺省**不限时间**。
-     * 夹取与时间解析都在 service,控制器不做第二套校验。
+     * 夹取、时间解析与行业校验都在 service,控制器不做第二套校验。
+     *
+     * <p>industry 是票 77 补的:口径经「工单设备 → 设备类型 → 行业」派生,
+     * 与 handling-efficiency 的工单范围同一套规则(列表与状态分布必须筛同一批工单)。
      *
      * <p>`startTime`/`endTime` 是 2026-10-22 维护者反馈「工单列表没按时间筛」后补的:
      * 面板按近 7/30/90 天切换时,列表必须跟着收窄(否则上方的状态分布按窗口统计、
@@ -88,18 +91,23 @@ public class CockpitWorkOrderReadController {
             @RequestParam(value = "page", required = false) Integer page,
             @RequestParam(value = "pageSize", required = false) Integer pageSize,
             @RequestParam(value = "stateCode", required = false) Integer stateCode,
+            // 行业(票 77):GAS/WATER_SUPPLY/DRAINAGE/MANHOLE,不传=全域,非法值 400。
+            @RequestParam(value = "industry", required = false) String industry,
             @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, startTime, endTime);
+                resolveRequestId(requestId), page, pageSize, stateCode, industry, startTime, endTime);
         return ResponseEntity.ok(AjaxResult.success(response));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
         return error(HttpStatus.BAD_REQUEST, workOrderService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
     }
 
     @ExceptionHandler(CockpitDeviceNotFoundException.class)

+ 2 - 1
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitWorkOrderReadAdapterTest.java

@@ -70,7 +70,8 @@ class BusinessCockpitWorkOrderReadAdapterTest {
                 mock(EarlyWarningService.class),
                 mock(AlarmWarningRelMapper.class),
                 alarmDataService,
-                equipmentBaseMapper);
+                equipmentBaseMapper,
+                mock(com.zksy.base.service.EquipmentTypeService.class));
     }
 
     /** 注册实体的 lambda 缓存(等价于 MyBatis-Plus 启动时的 TableInfoHelper 初始化)。 */

+ 105 - 5
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java

@@ -294,6 +294,67 @@ class CockpitCurrentEventsControllerTest {
         verifyNoInteractions(adapter);
     }
 
+    /**
+     * 票 77:片区在这条端点上真的生效,三条判据成对取正反证 ——
+     * 合法值收窄列表、非法值 400(且不读适配器)、不传=全域(不得把「不传」当成某个片区)。
+     *
+     * <p>回显是双字段口径(与 events/multidimensional-analysis 同一份):未筛时
+     * district/districtName 都为 null;只看未分区时 district=null 但 districtName=「未分区」,
+     * 前端据此区分「没筛」和「筛了个空片区」。
+     */
+    @Test
+    void districtFilterNarrowsListEchoesBothFieldsAndKeepsAbsentAsGlobal() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("district", "0"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(0))
+                .andExpect(jsonPath("$.data.filters.districtName").value("城南"))
+                .andExpect(jsonPath("$.data.pagination.total").value(1))
+                .andExpect(jsonPath("$.data.sections.events.data[0].eventNo").value("W-002"));
+
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("district", "1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(1))
+                .andExpect(jsonPath("$.data.pagination.total").value(1))
+                .andExpect(jsonPath("$.data.sections.events.data[0].eventNo").value("W-003"));
+
+        // 只看未分区:没有可信片区的事件如实归入「未分区」,不被静默丢弃
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("district", "UNASSIGNED"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.pagination.total").value(3));
+
+        // 不传=全域
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.pagination.total").value(5));
+
+        // 报警模式同一套语义(片区是事件族共同维度,不是预警专属)
+        mockMvc.perform(get(ENDPOINT).param("mode", "ALARM").param("district", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(2))
+                .andExpect(jsonPath("$.data.filters.districtName").value("太常"))
+                .andExpect(jsonPath("$.data.pagination.total").value(1))
+                .andExpect(jsonPath("$.data.sections.events.data[0].eventNo").value("A-001"));
+    }
+
+    @Test
+    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("district", "NOT_A_DISTRICT"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        verifyNoInteractions(adapter);
+    }
+
     @Test
     void serverFailureReturnsTypedServerError() throws Exception {
         when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
@@ -352,11 +413,11 @@ class CockpitCurrentEventsControllerTest {
                         warning("W-002", "燃气压力预警", "GAS_PRESSURE", 2, "ACTIVE",
                                 "2026-09-02T09:45:00+08:00", "2026-09-02T09:46:00+08:00",
                                 "0.32", "MPa", "D-GAS-2", "燃气浓度计-002", "MP-A-002", "城区A-燃气2号点", "城区A-燃气2号点",
-                                "GAS_SENSOR", "110.361", "28.441"),
+                                "GAS_SENSOR", "110.361", "28.441", 0),
                         warning("W-003", "供水压力预警", "WATER_PRESSURE", 3, "PENDING",
                                 "2026-09-02T09:40:00+08:00", "2026-09-02T09:41:00+08:00",
                                 "0.18", "MPa", "D-WS-1", "供水压力计-001", "MP-B-001", "城区B-供水1号点", "城区B-供水1号点",
-                                "WATER_SUPPLY_SENSOR", "110.380", "28.460"),
+                                "WATER_SUPPLY_SENSOR", "110.380", "28.460", 1),
                         warning("W-004", "污水浓度预警", "SEWAGE_CONCENTRATION", 4, "RELEASED",
                                 "2026-09-02T09:35:00+08:00", "2026-09-02T09:36:00+08:00",
                                 "18.2", "mg/L", "D-DR-1", "污水环境监测仪-001", null, null, "城区C-污水1号点",
@@ -377,7 +438,7 @@ class CockpitCurrentEventsControllerTest {
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
-                                "GAS_SENSOR", "110.362", "28.442", true, "0.00", "20.00"),
+                                "GAS_SENSOR", "110.362", "28.442", true, "0.00", "20.00", 2),
                         alarm("A-002", "污水浓度报警", "SEWAGE_CONCENTRATION", 2, "PROCESSING",
                                 "2026-09-02T09:32:00+08:00", "2026-09-02T09:33:00+08:00",
                                 "42.1", "mg/L", "D-DR-1", "污水环境监测仪-001", "MP-C-001", "城区C-污水1号点", "城区C-污水1号点",
@@ -447,11 +508,35 @@ class CockpitCurrentEventsControllerTest {
             String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
             String location, String typeCode,
             String longitude, String latitude, boolean coordinateVerified) {
+        return warning(no, name, type, level, status, eventTime, updatedTime, value, unit,
+                deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, typeCode,
+                longitude, latitude, coordinateVerified, null);
+    }
+
+    /** 票 77:带片区的预警(片区是筛选维度,必须能单独造样本)。 */
+    private static CockpitCurrentEventsSnapshot.SourceWarning warning(
+            String no, String name, String type, Integer level, String status,
+            String eventTime, String updatedTime, String value, String unit,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
+            String longitude, String latitude, Integer district) {
+        return warning(no, name, type, level, status, eventTime, updatedTime, value, unit,
+                deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, typeCode,
+                longitude, latitude, false, district);
+    }
+
+    /** 最内层:真正构造快照的地方,片区随样本传入(null=未分区)。 */
+    private static CockpitCurrentEventsSnapshot.SourceWarning warning(
+            String no, String name, String type, Integer level, String status,
+            String eventTime, String updatedTime, String value, String unit,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
+            String longitude, String latitude, boolean coordinateVerified, Integer district) {
         return new CockpitCurrentEventsSnapshot.SourceWarning(
                 no, name, type, level, status, eventTime, updatedTime,
                 new BigDecimal(value), unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
                 location, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude), coordinateVerified);
+                new BigDecimal(longitude), new BigDecimal(latitude), coordinateVerified, district);
     }
 
     private static CockpitCurrentEventsSnapshot.SourceAlarm alarm(
@@ -472,13 +557,28 @@ class CockpitCurrentEventsControllerTest {
             String location, String typeCode,
             String longitude, String latitude,
             boolean coordinateVerified, String thresholdMinValue, String thresholdMaxValue) {
+        return alarm(id, name, type, level, status, eventTime, updatedTime, value, unit,
+                deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, typeCode,
+                longitude, latitude, coordinateVerified, thresholdMinValue, thresholdMaxValue, null);
+    }
+
+    /** 票 77:带片区的报警(最内层构造,片区随样本传入;null=未分区)。 */
+    private static CockpitCurrentEventsSnapshot.SourceAlarm alarm(
+            String id, String name, String type, Integer level, String status,
+            String eventTime, String updatedTime, String value, String unit,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
+            String longitude, String latitude,
+            boolean coordinateVerified, String thresholdMinValue, String thresholdMaxValue,
+            Integer district) {
         return new CockpitCurrentEventsSnapshot.SourceAlarm(
                 id, name, type, level, status, eventTime, updatedTime,
                 new BigDecimal(value), unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
                 location, typeCode,
                 new BigDecimal(longitude), new BigDecimal(latitude), coordinateVerified,
                 thresholdMinValue == null ? null : new BigDecimal(thresholdMinValue),
-                thresholdMaxValue == null ? null : new BigDecimal(thresholdMaxValue));
+                thresholdMaxValue == null ? null : new BigDecimal(thresholdMaxValue),
+                district);
     }
 
     private static CockpitCurrentEventsSnapshot.SourceWorkOrderLink workOrder(

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

@@ -138,6 +138,76 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.durationTrend[4].eventCount").value(0));
     }
 
+    /**
+     * 票 77:片区在预警效率上真的生效,且**事件口径与工单口径同时收窄** ——
+     * 只筛事件会让同一响应里「事件 1 条 / 处置率却是全域 2/3」,正是票 67 反馈的
+     * 「同一模式四个数各说各话」;所以工单范围(分母)必须按同一片区收敛。
+     */
+    @Test
+    void districtFilterNarrowsEventsAndWorkOrderScopeTogether() throws Exception {
+        when(adapter.load()).thenReturn(fixtureWithDistricts());
+
+        // 报警族:事件 1 条;分母只算城南的 2 张工单(状态 5 与 4),分子 1
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("district", "0")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(0))
+                .andExpect(jsonPath("$.data.filters.districtName").value("城南"))
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(1))
+                .andExpect(jsonPath("$.data.summary.handledCount").value(1))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.numerator").value(1))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(2))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[0].count").value(0));
+
+        // 预警族:同一套片区语义(预警不挂工单,故只收窄事件口径)
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "2")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(2))
+                .andExpect(jsonPath("$.data.filters.districtName").value("太常"))
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(1));
+
+        // 只看未分区:没有可信片区的预警照实保留
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "UNASSIGNED")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(1));
+
+        // 不传=全域(双字段皆空,前端据此区分「没筛」与「筛了未分区」)
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(4));
+    }
+
+    @Test
+    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "ALARM").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        verifyNoInteractions(adapter);
+    }
+
     @Test
     void warningFamilyPutsUnrecognisedStatusIntoUnknownBucketSoBucketsStillSumToTotal() throws Exception {
         when(adapter.load()).thenReturn(fixtureWithDirtyWarningStatus());
@@ -556,8 +626,14 @@ class CockpitHandlingEfficiencyControllerTest {
 
     private static CockpitHandlingEfficiencySnapshot.SourceWorkOrder scopeOrder(
             String orderNo, Integer stateCode, String typeCode) {
+        return scopeOrder(orderNo, stateCode, typeCode, null);
+    }
+
+    /** 票 77:工单范围带片区(处置率分母与事件同口径收窄)。 */
+    private static CockpitHandlingEfficiencySnapshot.SourceWorkOrder scopeOrder(
+            String orderNo, Integer stateCode, String typeCode, Integer district) {
         return new CockpitHandlingEfficiencySnapshot.SourceWorkOrder(
-                orderNo, stateCode, "2026-09-02T09:00:00+08:00", typeCode);
+                orderNo, stateCode, "2026-09-02T09:00:00+08:00", typeCode, district);
     }
 
     /** 只含状态 5/6/7 的工单,用于验证分母含 6、排除 7。 */
@@ -615,6 +691,29 @@ class CockpitHandlingEfficiencyControllerTest {
                         scopeOrder("WO-M3", 5, "GAS_SENSOR")));
     }
 
+    /**
+     * 票 77:片区样本 —— 预警/报警各一条城南、一条太常、一条未分区,
+     * 工单范围同样带片区。用它验证**事件计数与工单口径按同一片区收窄**。
+     */
+    private static CockpitHandlingEfficiencySnapshot fixtureWithDistricts() {
+        return new CockpitHandlingEfficiencySnapshot(
+                "2026-09-02T11:59:50+08:00",
+                types(),
+                Arrays.asList(
+                        warningWithDistrict("EW-S", 1, "2026-09-02T08:10:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001", 0),
+                        warningWithDistrict("EW-T", 1, "2026-09-02T09:10:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002", 2),
+                        warningWithDistrict("EW-U", 1, "2026-09-02T10:10:00+08:00", "GAS_SENSOR", "D-GAS-3", "燃气浓度计-003", null)),
+                Arrays.asList(
+                        alarm("EA-S", 1, "2026-09-02T08:20:00+08:00", "GAS_SENSOR", "D-GAS-1", "燃气浓度计-001", 0),
+                        alarm("EA-T", 1, "2026-09-02T09:20:00+08:00", "GAS_SENSOR", "D-GAS-2", "燃气浓度计-002", 2)),
+                Collections.emptyList(),
+                Arrays.asList(
+                        scopeOrder("WO-S5", 5, "GAS_SENSOR", 0),
+                        scopeOrder("WO-S4", 4, "GAS_SENSOR", 0),
+                        scopeOrder("WO-T", 5, "GAS_SENSOR", 2),
+                        scopeOrder("WO-U", 0, "GAS_SENSOR", null)));
+    }
+
     private static java.util.List<CockpitHandlingEfficiencySnapshot.SourceType> types() {
         return Arrays.asList(
                 new CockpitHandlingEfficiencySnapshot.SourceType(
@@ -627,21 +726,35 @@ class CockpitHandlingEfficiencyControllerTest {
 
     private static CockpitHandlingEfficiencySnapshot.SourceWarning warning(
             String no, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName) {
+        return warningWithWorkflow(no, level, eventTime, typeCode, deviceCode, deviceName, null, null);
+    }
+
+    /** 票 77:带片区的预警样本(生命周期留空,只关心片区)。 */
+    private static CockpitHandlingEfficiencySnapshot.SourceWarning warningWithDistrict(
+            String no, Integer level, String eventTime, String typeCode, String deviceCode,
+            String deviceName, Integer district) {
         return new CockpitHandlingEfficiencySnapshot.SourceWarning(
-                no, level, eventTime, typeCode, deviceCode, deviceName);
+                no, level, eventTime, typeCode, deviceCode, deviceName, null, null, district);
     }
 
     private static CockpitHandlingEfficiencySnapshot.SourceWarning warningWithWorkflow(
             String no, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName,
             String status, String updateTime) {
         return new CockpitHandlingEfficiencySnapshot.SourceWarning(
-                no, level, eventTime, typeCode, deviceCode, deviceName, status, updateTime);
+                no, level, eventTime, typeCode, deviceCode, deviceName, status, updateTime, null);
     }
 
     private static CockpitHandlingEfficiencySnapshot.SourceAlarm alarm(
             String id, Integer level, String eventTime, String typeCode, String deviceCode, String deviceName) {
+        return alarm(id, level, eventTime, typeCode, deviceCode, deviceName, null);
+    }
+
+    /** 票 77:带片区的报警样本。 */
+    private static CockpitHandlingEfficiencySnapshot.SourceAlarm alarm(
+            String id, Integer level, String eventTime, String typeCode, String deviceCode,
+            String deviceName, Integer district) {
         return new CockpitHandlingEfficiencySnapshot.SourceAlarm(
-                id, level, eventTime, typeCode, deviceCode, deviceName);
+                id, level, eventTime, typeCode, deviceCode, deviceName, district);
     }
 
     private static CockpitHandlingEfficiencySnapshot.SourceWorkOrderLink link(

+ 99 - 12
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisControllerTest.java

@@ -112,6 +112,72 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.rows[3].handlingEfficiency.value").value("0.0000"));
     }
 
+    /**
+     * 票 77:片区在历史分析上真的生效 —— 事件行与汇总处置率的工单范围**同一片区口径**。
+     *
+     * <p>只筛事件、不筛工单会造出「事件按城南 1 条、处置率却是全域 2/5」这种同一响应内
+     * 两个口径打架的读数(正是票 67 反馈的「四个数各说各话」),所以分母必须一起收窄。
+     */
+    @Test
+    void districtFilterNarrowsEventsAndScopeRateTogether() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "0")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(0))
+                .andExpect(jsonPath("$.data.filters.districtName").value("城南"))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(1)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HW-002"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.numerator").value(1))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(2));
+
+        // 报警族同一套语义
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("district", "2")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(2))
+                .andExpect(jsonPath("$.data.filters.districtName").value("太常"))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(1))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HA-001"));
+
+        // 只看未分区:没有可信片区的事件照实保留,不被静默丢弃
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "UNASSIGNED")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(3));
+
+        // 不传=全域(双字段都为空,不是「未分区」)
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(5));
+    }
+
+    @Test
+    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        verifyNoInteractions(adapter);
+    }
+
     @Test
     void alarmFamilyIsIndependentFromWarnings() throws Exception {
         mockMvc.perform(get(ENDPOINT)
@@ -385,7 +451,7 @@ class CockpitHistoryAnalysisControllerTest {
         return new CockpitHistoryAnalysisService(
                 adapter,
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC))
-                .load("direct", family, null, null, start, end, 1, 100);
+                .load("direct", family, null, null, null, start, end, 1, 100);
     }
 
     private static CockpitHistoryAnalysisSnapshot fixture() {
@@ -401,14 +467,14 @@ class CockpitHistoryAnalysisControllerTest {
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                 Arrays.asList(
                         warning("HW-001", 1, "2026-09-01T23:30:00+08:00", "GAS_SENSOR", "D-GAS-1"),
-                        warning("HW-002", 2, "2026-09-02T00:15:00+08:00", "GAS_SENSOR", "D-GAS-2"),
-                        warning("HW-003", 2, "2026-09-02T00:45:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-1"),
+                        warning("HW-002", 2, "2026-09-02T00:15:00+08:00", "GAS_SENSOR", "D-GAS-2", 0),
+                        warning("HW-003", 2, "2026-09-02T00:45:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-1", 1),
                         warning("HW-004", 4, "2026-09-02T01:20:00+08:00", "SEWAGE_ENV", "D-DR-1"),
                         warning("HW-005", 1, "2026-09-02T01:50:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-2"),
                         warning("HW-LEGACY", 1, "2026-09-02T00:10:00+08:00", "LEGACY", "D-LEGACY"),
                         warning("HW-MH", 1, "2026-09-01T23:59:00+08:00", "SMART_MANHOLE", "D-MH")),
                 Arrays.asList(
-                        alarm("HA-001", 3, "2026-09-02T01:10:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-3"),
+                        alarm("HA-001", 3, "2026-09-02T01:10:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-3", 2),
                         alarm("HA-002", 1, "2026-09-02T01:30:00+08:00", "GAS_SENSOR", "D-GAS-3")),
                 Arrays.asList(
                         workOrder("HW-001", 5),
@@ -420,17 +486,23 @@ class CockpitHistoryAnalysisControllerTest {
                 // 汇总处置率的工单范围(分母来源):状态 5/5/4/1/0 → 分母 5、分子 2(状态 5)
                 // → 0.4000;状态 7 与关联关系无关,故不受 workOrders 影响。
                 Arrays.asList(
-                        scopeOrder(5, "GAS_SENSOR"),
-                        scopeOrder(5, "WATER_SUPPLY_SENSOR"),
-                        scopeOrder(4, "SEWAGE_ENV"),
-                        scopeOrder(1, "GAS_SENSOR"),
-                        scopeOrder(0, "GAS_SENSOR")));
+                        scopeOrder(5, "GAS_SENSOR", 0),
+                        scopeOrder(5, "WATER_SUPPLY_SENSOR", 1),
+                        scopeOrder(4, "SEWAGE_ENV", 0),
+                        scopeOrder(1, "GAS_SENSOR", null),
+                        scopeOrder(0, "GAS_SENSOR", 2)));
     }
 
     private static CockpitHistoryAnalysisSnapshot.SourceWorkOrder scopeOrder(
             Integer stateCode, String typeCode) {
+        return scopeOrder(stateCode, typeCode, null);
+    }
+
+    /** 票 77:工单范围也带片区(处置率的分母与事件同口径收窄)。 */
+    private static CockpitHistoryAnalysisSnapshot.SourceWorkOrder scopeOrder(
+            Integer stateCode, String typeCode, Integer district) {
         return new CockpitHistoryAnalysisSnapshot.SourceWorkOrder(
-                stateCode, "2026-09-02T01:00:00+08:00", typeCode);
+                stateCode, "2026-09-02T01:00:00+08:00", typeCode, district);
     }
 
     private static CockpitHistoryAnalysisSnapshot.SourceType type(String code, String industry) {
@@ -440,14 +512,29 @@ class CockpitHistoryAnalysisControllerTest {
 
     private static CockpitHistoryAnalysisSnapshot.SourceWarning warning(
             String no, Integer level, String eventTime, String typeCode, String deviceCode) {
+        return warning(no, level, eventTime, typeCode, deviceCode, null);
+    }
+
+    /** 票 77:带片区的预警样本。 */
+    private static CockpitHistoryAnalysisSnapshot.SourceWarning warning(
+            String no, Integer level, String eventTime, String typeCode, String deviceCode,
+            Integer district) {
         return new CockpitHistoryAnalysisSnapshot.SourceWarning(
-                no, level, eventTime, typeCode, deviceCode);
+                no, level, eventTime, typeCode, deviceCode, null, null, null, null, district);
     }
 
     private static CockpitHistoryAnalysisSnapshot.SourceAlarm alarm(
             String id, Integer level, String eventTime, String typeCode, String deviceCode) {
+        return alarm(id, level, eventTime, typeCode, deviceCode, null);
+    }
+
+    /** 票 77:带片区的报警样本。 */
+    private static CockpitHistoryAnalysisSnapshot.SourceAlarm alarm(
+            String id, Integer level, String eventTime, String typeCode, String deviceCode,
+            Integer district) {
         return new CockpitHistoryAnalysisSnapshot.SourceAlarm(
-                id, level, eventTime, typeCode, deviceCode);
+                id, level, eventTime, typeCode, deviceCode, null, null, null, null, null,
+                null, null, null, null, district);
     }
 
     private static CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink workOrder(

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

@@ -96,7 +96,7 @@ class CockpitWorkOrderAssociationContractTest {
 
         BusinessCockpitWorkOrderReadAdapter adapter = new BusinessCockpitWorkOrderReadAdapter(
                 workOrderService, mock(EarlyWarningService.class), mock(AlarmWarningRelMapper.class),
-                alarmDataService, equipmentMapper);
+                alarmDataService, equipmentMapper, mock(com.zksy.base.service.EquipmentTypeService.class));
         CockpitWorkOrderReadService service = new CockpitWorkOrderReadService(
                 adapter, Clock.fixed(Instant.parse("2026-10-10T07:11:30Z"), ZoneOffset.UTC));
         mockMvc = MockMvcBuilders.standaloneSetup(new CockpitWorkOrderReadController(service)).build();

+ 57 - 1
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderListContractTest.java

@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.cockpit.workorder.BusinessCockpitWorkOrderReadAdapter;
@@ -96,9 +98,14 @@ class CockpitWorkOrderListContractTest {
         when(alarmDataService.list(any(LambdaQueryWrapper.class)))
                 .thenReturn(Collections.singletonList(alarm));
 
+        EquipmentTypeService equipmentTypeService = mock(EquipmentTypeService.class);
+        // 类型树:1 供水、2 排水、3 燃气三个根 + 一个挂在燃气根下的叶子 11。
+        when(equipmentTypeService.list()).thenReturn(Arrays.asList(
+                equipmentType(1, 0), equipmentType(2, 0), equipmentType(3, 0),
+                equipmentType(11, 3)));
         BusinessCockpitWorkOrderReadAdapter adapter = new BusinessCockpitWorkOrderReadAdapter(
                 workOrderService, mock(EarlyWarningService.class), mock(AlarmWarningRelMapper.class),
-                alarmDataService, equipmentMapper);
+                alarmDataService, equipmentMapper, equipmentTypeService);
         CockpitWorkOrderReadService service = new CockpitWorkOrderReadService(
                 adapter, Clock.fixed(Instant.parse("2026-10-10T07:11:30Z"), ZoneOffset.UTC));
         mockMvc = MockMvcBuilders.standaloneSetup(new CockpitWorkOrderReadController(service)).build();
@@ -131,6 +138,55 @@ class CockpitWorkOrderListContractTest {
                 .list(any(LambdaQueryWrapper.class));
     }
 
+    /**
+     * 票 77:行业筛选必须真的下到 SQL,并且是**同一套**「工单设备 → 设备类型 → 行业」派生
+     * (与上方状态分布同源,否则会出现分布按行业收敛、列表却是全库的自相矛盾)。
+     * 判据:有效的行业会带出设备子查询;不传则一条设备条件都不加。
+     */
+    @Test
+    @SuppressWarnings("unchecked")
+    void industryFilterIsAppliedThroughDeviceTypesAndAbsentMeansGlobal() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders").param("industry", "GAS"))
+                .andExpect(status().isOk());
+
+        ArgumentCaptor<LambdaQueryWrapper<WorkOrder>> scoped = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(workOrderService, org.mockito.Mockito.atLeastOnce()).count(scoped.capture());
+        String scopedSql = scoped.getValue().getSqlSegment();
+        assertTrue(scopedSql.contains("deviceId"), "行业筛选必须落在 device_id 上,实际:" + scopedSql);
+        assertTrue(scopedSql.contains("equipment_base"), "行业必须经设备表派生,实际:" + scopedSql);
+        // 燃气根 3 + 挂在它下面的叶子 11:叶子也要命中,不能只看直接父级。
+        assertTrue(scopedSql.contains("equipment_type_id IN (3, 11)"),
+                "燃气应命中根 3 与其叶子 11,实际:" + scopedSql);
+
+        // 不传行业=全域:不得出现设备子查询
+        ArgumentCaptor<LambdaQueryWrapper<WorkOrder>> clear;
+        org.mockito.Mockito.clearInvocations(workOrderService);
+        mockMvc.perform(get(BASE + "/work-orders")).andExpect(status().isOk());
+        clear = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(workOrderService, org.mockito.Mockito.atLeastOnce()).count(clear.capture());
+        String globalSql = clear.getValue().getSqlSegment();
+        assertFalse(globalSql.contains("equipment_base"),
+                "没传行业却加了设备条件:" + globalSql);
+    }
+
+    /** 非法行业 → 400(静默忽略会造出「以为按行业筛了其实是全量」的假绿)。 */
+    @Test
+    void invalidIndustryIsRejected() throws Exception {
+        mockMvc.perform(get(BASE + "/work-orders").param("industry", "WHATEVER"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.message").value(
+                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
+    }
+
+    private static EquipmentType equipmentType(Integer id, Integer parentId) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setParentId(parentId);
+        type.setTypeName("type-" + id);
+        return type;
+    }
+
     @Test
     void invalidStateCodeFallsBackToNoFilter() throws Exception {
         mockMvc.perform(get(BASE + "/work-orders").param("stateCode", "99"))

+ 25 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java

@@ -124,7 +124,8 @@ public class BusinessCockpitEventAdapter {
             result.add(new CockpitHistoryAnalysisSnapshot.SourceWorkOrder(
                     order.getOrderStatus(),
                     format(order.getCreateTime()),
-                    typeCodeOfDeviceId(order.getDeviceId(), facts)));
+                    typeCodeOfDeviceId(order.getDeviceId(), facts),
+                    districtCodeOfDeviceId(order.getDeviceId(), facts)));
         }
         return result;
     }
@@ -332,7 +333,8 @@ public class BusinessCockpitEventAdapter {
                 typeCode(warning.getDeviceCode(), facts),
                 deviceLongitude(warning.getDeviceCode(), facts),
                 deviceLatitude(warning.getDeviceCode(), facts),
-                false);
+                false,
+                districtCode(warning.getDeviceCode(), facts));
     }
 
     private BigDecimal deviceLongitude(String code, Facts facts) {
@@ -370,7 +372,8 @@ public class BusinessCockpitEventAdapter {
                 coordinate(alarm.getLatitude(), device == null ? null : device.getLatitude()),
                 false,
                 alarm.getMinValue(),
-                alarm.getMaxValue());
+                alarm.getMaxValue(),
+                districtCode(alarm.getDeviceCode(), facts));
     }
 
     private List<CockpitCurrentEventsSnapshot.SourceWorkOrderLink> currentWorkOrders(Facts facts) {
@@ -424,7 +427,8 @@ public class BusinessCockpitEventAdapter {
                     trimToNull(warning.getStatus()),
                     firstNonBlank(warning.getDeviceName(), deviceName(warning.getDeviceCode(), facts)),
                     firstNonBlank(warning.getLocation(), point == null ? null : point.location),
-                    offsetTime(warning.getUpdateTime())));
+                    offsetTime(warning.getUpdateTime()),
+                    districtCode(warning.getDeviceCode(), facts)));
         }
         return result;
     }
@@ -450,7 +454,8 @@ public class BusinessCockpitEventAdapter {
                     alarm.getActualValue(),
                     warningTypeUnit(metadata),
                     alarm.getMinValue(),
-                    alarm.getMaxValue()));
+                    alarm.getMaxValue(),
+                    districtCode(alarm.getDeviceCode(), facts)));
         }
         return result;
     }
@@ -478,7 +483,8 @@ public class BusinessCockpitEventAdapter {
                         // 票 74:预警自身生命周期与解除时刻来源(update_time)随快照下传,
                         // 供服务层按窗口分族聚合;工单口径与这两者无关。
                         trimToNull(item.warning.getStatus()),
-                        offsetTime(item.warning.getUpdateTime())))
+                        offsetTime(item.warning.getUpdateTime()),
+                        districtCode(item.warning.getDeviceCode(), facts)))
                 .collect(Collectors.toList());
     }
 
@@ -490,7 +496,8 @@ public class BusinessCockpitEventAdapter {
                         offsetTime(item.alarm.getCreateTime()),
                         typeCode(item.alarm.getDeviceCode(), facts),
                         trimToNull(item.alarm.getDeviceCode()),
-                        firstNonBlank(item.alarm.getDeviceName(), deviceName(item.alarm.getDeviceCode(), facts))))
+                        firstNonBlank(item.alarm.getDeviceName(), deviceName(item.alarm.getDeviceCode(), facts)),
+                        districtCode(item.alarm.getDeviceCode(), facts)))
                 .collect(Collectors.toList());
     }
 
@@ -521,11 +528,21 @@ public class BusinessCockpitEventAdapter {
                     trimToNull(order.getOrderNo()),
                     order.getOrderStatus(),
                     format(order.getCreateTime()),
-                    typeCodeOfDeviceId(order.getDeviceId(), facts)));
+                    typeCodeOfDeviceId(order.getDeviceId(), facts),
+                    districtCodeOfDeviceId(order.getDeviceId(), facts)));
         }
         return result;
     }
 
+    /** 工单设备 → 片区编码(与 {@link #districtCode} 同一权威来源);设备缺失或未识别回 null。 */
+    private Integer districtCodeOfDeviceId(String deviceId, Facts facts) {
+        if (isBlank(deviceId) || facts.devicesById == null) {
+            return null;
+        }
+        EquipmentBase device = facts.devicesById.get(deviceId.trim());
+        return device == null ? null : CockpitDistrict.codeOf(device.getDistrict());
+    }
+
     /** 工单设备 → 设备类型编码;设备缺失或类型未知时回 null。 */
     private String typeCodeOfDeviceId(String deviceId, Facts facts) {
         if (isBlank(deviceId) || facts.devicesById == null) {

+ 10 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsResponse.java

@@ -71,18 +71,27 @@ public class CockpitCurrentEventsResponse {
         private final Integer level;
         private final String industry;
         private final String status;
+        private final Integer district;
+        private final String districtName;
 
-        public Filters(String mode, Integer level, String industry, String status) {
+        public Filters(String mode, Integer level, String industry, String status,
+                Integer district, String districtName) {
             this.mode = mode;
             this.level = level;
             this.industry = industry;
             this.status = status;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getMode() { return mode; }
         public Integer getLevel() { return level; }
         public String getIndustry() { return industry; }
         public String getStatus() { return status; }
+        /** 生效片区编码(0-城南 1-城北 2-太常);未筛为 {@code null}。 */
+        public Integer getDistrict() { return district; }
+        /** 生效片区名:未筛为 {@code null},只看未分区时为「未分区」;双字段口径见 {@code CockpitDistrict}。 */
+        public String getDistrictName() { return districtName; }
     }
 
     public static class Pagination {

+ 26 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsService.java

@@ -2,6 +2,7 @@ package com.zksy.cockpit.event;
 
 import com.zksy.cockpit.detail.CockpitSectionResult;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -58,12 +59,19 @@ public class CockpitCurrentEventsService {
             String level,
             String industry,
             String status,
+            String district,
             Integer page,
             Integer pageSize) {
         String resolvedMode = resolveMode(mode);
         Integer resolvedLevel = resolveLevel(level);
         String selectedIndustry = resolveIndustry(industry);
         String selectedStatus = isBlank(status) ? null : status.trim();
+        // 范围维度=片区(票 77):0/1/2 按片区筛、UNASSIGNED 只看未分区、不传=不筛;
+        // 其它值一律 400,报文与 events/multidimensional-analysis 同一份(脏值不得参与筛选)。
+        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
+        if (selectedDistrict.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize);
@@ -74,6 +82,7 @@ public class CockpitCurrentEventsService {
                 resolvedLevel,
                 selectedIndustry,
                 selectedStatus,
+                selectedDistrict,
                 safePage,
                 safePageSize);
     }
@@ -110,13 +119,14 @@ public class CockpitCurrentEventsService {
             Integer level,
             String industry,
             String status,
+            CockpitDistrict.Query district,
             int page,
             int pageSize) {
         CockpitCurrentEventsSnapshot snapshot = state.snapshot;
         Map<String, CockpitCurrentEventsSnapshot.SourceType> types = types(snapshot);
         List<EventRow> rows = WARNING.equals(mode)
-                ? warningRows(snapshot, types, level, industry, status)
-                : alarmRows(snapshot, types, level, industry, status);
+                ? warningRows(snapshot, types, level, industry, status, district)
+                : alarmRows(snapshot, types, level, industry, status, district);
         rows.sort(eventOrdering());
 
         int total = rows.size();
@@ -156,7 +166,10 @@ public class CockpitCurrentEventsService {
                 "Asia/Shanghai",
                 mode,
                 updatedAt,
-                new CockpitCurrentEventsResponse.Filters(mode, level, industry, status),
+                new CockpitCurrentEventsResponse.Filters(
+                        mode, level, industry, status,
+                        district.code(),
+                        district.active() ? CockpitDistrict.nameOf(district.code()) : null),
                 new CockpitCurrentEventsResponse.Pagination(
                         page, pageSize, total, totalPages, toIndex < total),
                 new CockpitCurrentEventsResponse.MapFilter(
@@ -169,7 +182,8 @@ public class CockpitCurrentEventsService {
             Map<String, CockpitCurrentEventsSnapshot.SourceType> types,
             Integer level,
             String industry,
-            String status) {
+            String status,
+            CockpitDistrict.Query district) {
         List<EventRow> rows = new ArrayList<>();
         if (snapshot.getWarnings() == null) {
             return rows;
@@ -188,6 +202,9 @@ public class CockpitCurrentEventsService {
             if (status != null && !status.equals(warning.getStatus())) {
                 continue;
             }
+            if (!CockpitDistrict.matches(district, warning.getDistrict())) {
+                continue;
+            }
             rows.add(new EventRow(
                     warning.getWarningNo(),
                     warning.getWarningName(),
@@ -218,7 +235,8 @@ public class CockpitCurrentEventsService {
             Map<String, CockpitCurrentEventsSnapshot.SourceType> types,
             Integer level,
             String industry,
-            String status) {
+            String status,
+            CockpitDistrict.Query district) {
         List<EventRow> rows = new ArrayList<>();
         if (snapshot.getAlarms() == null) {
             return rows;
@@ -237,6 +255,9 @@ public class CockpitCurrentEventsService {
             if (status != null && !status.equals(alarm.getStatus())) {
                 continue;
             }
+            if (!CockpitDistrict.matches(district, alarm.getDistrict())) {
+                continue;
+            }
             rows.add(new EventRow(
                     alarm.getAlarmId(),
                     alarm.getWarningName(),

+ 20 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsSnapshot.java

@@ -109,6 +109,8 @@ public class CockpitCurrentEventsSnapshot {
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
+        /** 事件片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
+        private final Integer district;
 
         public SourceWarning(
                 String warningNo,
@@ -128,7 +130,8 @@ public class CockpitCurrentEventsSnapshot {
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal latitude,
-                boolean coordinateVerified) {
+                boolean coordinateVerified,
+                Integer district) {
             this.warningNo = warningNo;
             this.warningName = warningName;
             this.warningType = warningType;
@@ -147,6 +150,7 @@ public class CockpitCurrentEventsSnapshot {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
+            this.district = district;
         }
 
         public String getWarningNo() {
@@ -220,6 +224,11 @@ public class CockpitCurrentEventsSnapshot {
         public boolean isCoordinateVerified() {
             return coordinateVerified;
         }
+
+        /** 片区原始编码;未经筛时也照实给出(调用方按 {@code CockpitDistrict} 归一)。 */
+        public Integer getDistrict() {
+            return district;
+        }
     }
 
     public static class SourceAlarm {
@@ -243,6 +252,8 @@ public class CockpitCurrentEventsSnapshot {
         private final boolean coordinateVerified;
         private final BigDecimal thresholdMinValue;
         private final BigDecimal thresholdMaxValue;
+        /** 报警片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
+        private final Integer district;
 
         public SourceAlarm(
                 String alarmId,
@@ -264,7 +275,8 @@ public class CockpitCurrentEventsSnapshot {
                 BigDecimal latitude,
                 boolean coordinateVerified,
                 BigDecimal thresholdMinValue,
-                BigDecimal thresholdMaxValue) {
+                BigDecimal thresholdMaxValue,
+                Integer district) {
             this.alarmId = alarmId;
             this.warningName = warningName;
             this.warningType = warningType;
@@ -285,6 +297,7 @@ public class CockpitCurrentEventsSnapshot {
             this.coordinateVerified = coordinateVerified;
             this.thresholdMinValue = thresholdMinValue;
             this.thresholdMaxValue = thresholdMaxValue;
+            this.district = district;
         }
 
         public String getAlarmId() {
@@ -366,6 +379,11 @@ public class CockpitCurrentEventsSnapshot {
         public BigDecimal getThresholdMaxValue() {
             return thresholdMaxValue;
         }
+
+        /** 片区原始编码;未经筛时也照实给出(调用方按 {@code CockpitDistrict} 归一)。 */
+        public Integer getDistrict() {
+            return district;
+        }
     }
 
     public static class SourceWorkOrderLink {

+ 10 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyResponse.java

@@ -103,18 +103,27 @@ public class CockpitHandlingEfficiencyResponse {
         private final String industry;
         private final Integer level;
         private final Integer workOrderState;
+        private final Integer district;
+        private final String districtName;
 
-        public Filters(String eventFamily, String industry, Integer level, Integer workOrderState) {
+        public Filters(String eventFamily, String industry, Integer level, Integer workOrderState,
+                Integer district, String districtName) {
             this.eventFamily = eventFamily;
             this.industry = industry;
             this.level = level;
             this.workOrderState = workOrderState;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getEventFamily() { return eventFamily; }
         public String getIndustry() { return industry; }
         public Integer getLevel() { return level; }
         public Integer getWorkOrderState() { return workOrderState; }
+        /** 生效片区编码(0-城南 1-城北 2-太常);未筛为 {@code null}。 */
+        public Integer getDistrict() { return district; }
+        /** 生效片区名:未筛为 {@code null},只看未分区时为「未分区」;双字段口径见 {@code CockpitDistrict}。 */
+        public String getDistrictName() { return districtName; }
     }
 
     public static class Pagination {

+ 32 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyService.java

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.event;
 
 import com.zksy.base.warning.domain.WarningWorkflow;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -94,6 +95,7 @@ public class CockpitHandlingEfficiencyService {
             String industry,
             String level,
             String workOrderState,
+            String district,
             String startTime,
             String endTime,
             Integer page,
@@ -102,6 +104,11 @@ public class CockpitHandlingEfficiencyService {
         String selectedIndustry = resolveIndustry(industry);
         Integer selectedLevel = resolveLevel(level);
         Integer selectedState = resolveWorkOrderState(workOrderState);
+        // 范围维度=片区(票 77):0/1/2 按片区筛、UNASSIGNED 只看未分区、不传=不筛;其它值 400。
+        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
+        if (selectedDistrict.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
         TimeWindow window = resolveWindow(startTime, endTime);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
@@ -113,7 +120,7 @@ public class CockpitHandlingEfficiencyService {
                     : loaded;
         }
         return build(requestId, family, selectedIndustry, selectedLevel, selectedState,
-                window, safePage, safePageSize);
+                selectedDistrict, window, safePage, safePageSize);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -135,6 +142,7 @@ public class CockpitHandlingEfficiencyService {
             String industry,
             Integer level,
             Integer workOrderState,
+            CockpitDistrict.Query district,
             TimeWindow window,
             int page,
             int pageSize) {
@@ -142,8 +150,8 @@ public class CockpitHandlingEfficiencyService {
         Map<String, Link> links = links(state.getWorkOrders());
         boolean warningFamily = WARNING.equals(family);
         List<EventFact> facts = warningFamily
-                ? warningFacts(state, types, industry, level)
-                : alarmFacts(state, types, industry, level);
+                ? warningFacts(state, types, industry, level, district)
+                : alarmFacts(state, types, industry, level, district);
         facts = inWindow(facts, window);
 
         // 处置率口径(决策票 06 / 规格 10):分母=范围内**全部工单**(状态 0–6,
@@ -158,7 +166,7 @@ public class CockpitHandlingEfficiencyService {
                 stateCounts.put(workOrderStateEntry.getCode(), 0);
             }
             for (CockpitHandlingEfficiencySnapshot.SourceWorkOrder order
-                    : scopeWorkOrders(state, types, industry, window)) {
+                    : scopeWorkOrders(state, types, industry, district, window)) {
                 stateCounts.merge(resolveStateCode(order.getStateCode()), 1, Integer::sum);
             }
         }
@@ -295,7 +303,10 @@ public class CockpitHandlingEfficiencyService {
                         timestamp(window.start),
                         timestamp(window.end)),
                 isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt(),
-                new CockpitHandlingEfficiencyResponse.Filters(family, industry, level, workOrderState),
+                new CockpitHandlingEfficiencyResponse.Filters(
+                        family, industry, level, workOrderState,
+                        district.code(),
+                        district.active() ? CockpitDistrict.nameOf(district.code()) : null),
                 new CockpitHandlingEfficiencyResponse.Pagination(
                         page, pageSize, total, totalPages, toIndex < total),
                 new CockpitHandlingEfficiencyResponse.MapFilter(
@@ -313,7 +324,7 @@ public class CockpitHandlingEfficiencyService {
                         warningDuration),
                 stateRows,
                 warningStateRows,
-                durationTrend(state, family, types, industry, level, links, window),
+                durationTrend(state, family, types, industry, level, district, links, window),
                 items);
     }
 
@@ -449,6 +460,7 @@ public class CockpitHandlingEfficiencyService {
             Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
             String industry,
             Integer level,
+            CockpitDistrict.Query district,
             Map<String, Link> links,
             TimeWindow window) {
         ZonedDateTime first = window.start.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.HOURS);
@@ -466,8 +478,8 @@ public class CockpitHandlingEfficiencyService {
         }
         boolean warningFamily = WARNING.equals(family);
         List<EventFact> facts = warningFamily
-                ? warningFacts(snapshot, types, industry, level)
-                : alarmFacts(snapshot, types, industry, level);
+                ? warningFacts(snapshot, types, industry, level, district)
+                : alarmFacts(snapshot, types, industry, level, district);
         for (EventFact fact : facts) {
             OffsetDateTime eventTime = parseEventTime(fact.eventTime);
             if (eventTime == null) {
@@ -507,7 +519,8 @@ public class CockpitHandlingEfficiencyService {
             CockpitHandlingEfficiencySnapshot snapshot,
             Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
             String industry,
-            Integer level) {
+            Integer level,
+            CockpitDistrict.Query district) {
         List<EventFact> facts = new ArrayList<>();
         if (snapshot.getWarnings() == null) {
             return facts;
@@ -515,7 +528,8 @@ public class CockpitHandlingEfficiencyService {
         for (CockpitHandlingEfficiencySnapshot.SourceWarning warning : snapshot.getWarnings()) {
             if (warning == null
                     || !inScope(types, warning.getTypeCode(), industry)
-                    || (level != null && !level.equals(warning.getLevel()))) {
+                    || (level != null && !level.equals(warning.getLevel()))
+                    || !CockpitDistrict.matches(district, warning.getDistrict())) {
                 continue;
             }
             facts.add(new EventFact(
@@ -534,7 +548,8 @@ public class CockpitHandlingEfficiencyService {
             CockpitHandlingEfficiencySnapshot snapshot,
             Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
             String industry,
-            Integer level) {
+            Integer level,
+            CockpitDistrict.Query district) {
         List<EventFact> facts = new ArrayList<>();
         if (snapshot.getAlarms() == null) {
             return facts;
@@ -542,7 +557,8 @@ public class CockpitHandlingEfficiencyService {
         for (CockpitHandlingEfficiencySnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
             if (alarm == null
                     || !inScope(types, alarm.getTypeCode(), industry)
-                    || (level != null && !level.equals(alarm.getLevel()))) {
+                    || (level != null && !level.equals(alarm.getLevel()))
+                    || !CockpitDistrict.matches(district, alarm.getDistrict())) {
                 continue;
             }
             facts.add(new EventFact(
@@ -599,13 +615,16 @@ public class CockpitHandlingEfficiencyService {
             CockpitHandlingEfficiencySnapshot snapshot,
             Map<String, CockpitHandlingEfficiencySnapshot.SourceType> types,
             String industry,
+            CockpitDistrict.Query district,
             TimeWindow window) {
         List<CockpitHandlingEfficiencySnapshot.SourceWorkOrder> result = new ArrayList<>();
         if (snapshot.getScopeWorkOrders() == null) {
             return result;
         }
         for (CockpitHandlingEfficiencySnapshot.SourceWorkOrder order : snapshot.getScopeWorkOrders()) {
-            if (order == null || !inScope(types, order.getTypeCode(), industry)) {
+            if (order == null
+                    || !inScope(types, order.getTypeCode(), industry)
+                    || !CockpitDistrict.matches(district, order.getDistrict())) {
                 continue;
             }
             OffsetDateTime createTime = parseEventTime(order.getCreateTime());

+ 19 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencySnapshot.java

@@ -69,18 +69,23 @@ public class CockpitHandlingEfficiencySnapshot {
         private final Integer stateCode;
         private final String createTime;
         private final String typeCode;
+        /** 工单设备所属片区(原始编码);无设备/未识别时为 null=未分区(票 77)。 */
+        private final Integer district;
 
-        public SourceWorkOrder(String orderNo, Integer stateCode, String createTime, String typeCode) {
+        public SourceWorkOrder(String orderNo, Integer stateCode, String createTime, String typeCode,
+                Integer district) {
             this.orderNo = orderNo;
             this.stateCode = stateCode;
             this.createTime = createTime;
             this.typeCode = typeCode;
+            this.district = district;
         }
 
         public String getOrderNo() { return orderNo; }
         public Integer getStateCode() { return stateCode; }
         public String getCreateTime() { return createTime; }
         public String getTypeCode() { return typeCode; }
+        public Integer getDistrict() { return district; }
     }
 
     public static class SourceType {
@@ -113,6 +118,8 @@ public class CockpitHandlingEfficiencySnapshot {
         private final String status;
         /** 预警记录最后更新时间:{@code CLOSED} 是终态且解除动作会写入该时间,故用作解除时刻来源。 */
         private final String updateTime;
+        /** 事件片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
+        private final Integer district;
 
         public SourceWarning(
                 String warningNo,
@@ -121,7 +128,7 @@ public class CockpitHandlingEfficiencySnapshot {
                 String typeCode,
                 String deviceCode,
                 String deviceName) {
-            this(warningNo, level, eventTime, typeCode, deviceCode, deviceName, null, null);
+            this(warningNo, level, eventTime, typeCode, deviceCode, deviceName, null, null, null);
         }
 
         public SourceWarning(
@@ -132,7 +139,8 @@ public class CockpitHandlingEfficiencySnapshot {
                 String deviceCode,
                 String deviceName,
                 String status,
-                String updateTime) {
+                String updateTime,
+                Integer district) {
             this.warningNo = warningNo;
             this.level = level;
             this.eventTime = eventTime;
@@ -141,6 +149,7 @@ public class CockpitHandlingEfficiencySnapshot {
             this.deviceName = deviceName;
             this.status = status;
             this.updateTime = updateTime;
+            this.district = district;
         }
 
         public String getWarningNo() { return warningNo; }
@@ -151,6 +160,7 @@ public class CockpitHandlingEfficiencySnapshot {
         public String getDeviceName() { return deviceName; }
         public String getStatus() { return status; }
         public String getUpdateTime() { return updateTime; }
+        public Integer getDistrict() { return district; }
     }
 
     public static class SourceAlarm {
@@ -160,6 +170,8 @@ public class CockpitHandlingEfficiencySnapshot {
         private final String typeCode;
         private final String deviceCode;
         private final String deviceName;
+        /** 报警片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
+        private final Integer district;
 
         public SourceAlarm(
                 String alarmId,
@@ -167,13 +179,15 @@ public class CockpitHandlingEfficiencySnapshot {
                 String eventTime,
                 String typeCode,
                 String deviceCode,
-                String deviceName) {
+                String deviceName,
+                Integer district) {
             this.alarmId = alarmId;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.district = district;
         }
 
         public String getAlarmId() { return alarmId; }
@@ -182,6 +196,7 @@ public class CockpitHandlingEfficiencySnapshot {
         public String getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
+        public Integer getDistrict() { return district; }
     }
 
     public static class SourceWorkOrderLink {

+ 10 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisResponse.java

@@ -102,16 +102,25 @@ public class CockpitHistoryAnalysisResponse {
         private final String eventFamily;
         private final Integer level;
         private final String industry;
+        private final Integer district;
+        private final String districtName;
 
-        public Filters(String eventFamily, Integer level, String industry) {
+        public Filters(String eventFamily, Integer level, String industry,
+                Integer district, String districtName) {
             this.eventFamily = eventFamily;
             this.level = level;
             this.industry = industry;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getEventFamily() { return eventFamily; }
         public Integer getLevel() { return level; }
         public String getIndustry() { return industry; }
+        /** 生效片区编码(0-城南 1-城北 2-太常);未筛为 {@code null}。 */
+        public Integer getDistrict() { return district; }
+        /** 生效片区名:未筛为 {@code null},只看未分区时为「未分区」;双字段口径见 {@code CockpitDistrict}。 */
+        public String getDistrictName() { return districtName; }
     }
 
     public static class Summary {

+ 29 - 10
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.event;
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -70,6 +71,7 @@ public class CockpitHistoryAnalysisService {
             String eventFamily,
             String level,
             String industry,
+            String district,
             String startTime,
             String endTime,
             Integer page,
@@ -77,6 +79,11 @@ public class CockpitHistoryAnalysisService {
         String family = resolveFamily(eventFamily);
         Integer resolvedLevel = resolveLevel(level);
         String selectedIndustry = resolveIndustry(industry);
+        // 范围维度=片区(票 77):0/1/2 按片区筛、UNASSIGNED 只看未分区、不传=不筛;其它值 400。
+        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
+        if (selectedDistrict.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
         TimeWindow window = resolveWindow(startTime, endTime);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
@@ -87,7 +94,8 @@ public class CockpitHistoryAnalysisService {
                     ? CockpitHistoryAnalysisSnapshot.empty(businessTime())
                     : loaded;
         }
-        return build(requestId, family, resolvedLevel, selectedIndustry, window, safePage, safePageSize);
+        return build(requestId, family, resolvedLevel, selectedIndustry, selectedDistrict,
+                window, safePage, safePageSize);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -111,14 +119,15 @@ public class CockpitHistoryAnalysisService {
             String family,
             Integer level,
             String industry,
+            CockpitDistrict.Query district,
             TimeWindow window,
             int page,
             int pageSize) {
         Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types = types(state);
         Map<String, Integer> handledLinks = handledLinks(state.getWorkOrders());
         List<EventRow> eventRows = WARNING.equals(family)
-                ? warningRows(state, types, level, industry)
-                : alarmRows(state, types, level, industry);
+                ? warningRows(state, types, level, industry, district)
+                : alarmRows(state, types, level, industry, district);
 
         ZonedDateTime first = window.start
                 .atZoneSameInstant(BUSINESS_ZONE)
@@ -185,7 +194,7 @@ public class CockpitHistoryAnalysisService {
         String updatedAt = isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt();
         // 汇总处置率=范围内全部工单(状态 0–6)口径(决策票 06),与行级事件桶分开;
         // 行级桶仍是事件时间桶,其 efficiency 保留事件口径。
-        ScopeRate scopeRate = scopeRate(state, types, industry, window);
+        ScopeRate scopeRate = scopeRate(state, types, industry, district, window);
         return new CockpitHistoryAnalysisResponse(
                 requestId,
                 "v1",
@@ -198,7 +207,10 @@ public class CockpitHistoryAnalysisService {
                         timestamp(window.start),
                         timestamp(window.end)),
                 updatedAt,
-                new CockpitHistoryAnalysisResponse.Filters(family, level, industry),
+                new CockpitHistoryAnalysisResponse.Filters(
+                        family, level, industry,
+                        district.code(),
+                        district.active() ? CockpitDistrict.nameOf(district.code()) : null),
                 new CockpitHistoryAnalysisResponse.Summary(
                         totalEvents,
                         // 保持事件口径,与行级 handledCount 同维(决策 q7:只改汇总处置率)。
@@ -215,7 +227,8 @@ public class CockpitHistoryAnalysisService {
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             Integer level,
-            String industry) {
+            String industry,
+            CockpitDistrict.Query district) {
         List<EventRow> rows = new ArrayList<>();
         if (snapshot.getWarnings() == null) {
             return rows;
@@ -223,7 +236,8 @@ public class CockpitHistoryAnalysisService {
         for (CockpitHistoryAnalysisSnapshot.SourceWarning warning : snapshot.getWarnings()) {
             if (warning == null
                     || !inScope(types, warning.getTypeCode(), industry)
-                    || (level != null && !level.equals(warning.getLevel()))) {
+                    || (level != null && !level.equals(warning.getLevel()))
+                    || !CockpitDistrict.matches(district, warning.getDistrict())) {
                 continue;
             }
             OffsetDateTime eventTime = parseEventTime(warning.getEventTime());
@@ -254,7 +268,8 @@ public class CockpitHistoryAnalysisService {
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             Integer level,
-            String industry) {
+            String industry,
+            CockpitDistrict.Query district) {
         List<EventRow> rows = new ArrayList<>();
         if (snapshot.getAlarms() == null) {
             return rows;
@@ -262,7 +277,8 @@ public class CockpitHistoryAnalysisService {
         for (CockpitHistoryAnalysisSnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
             if (alarm == null
                     || !inScope(types, alarm.getTypeCode(), industry)
-                    || (level != null && !level.equals(alarm.getLevel()))) {
+                    || (level != null && !level.equals(alarm.getLevel()))
+                    || !CockpitDistrict.matches(district, alarm.getDistrict())) {
                 continue;
             }
             OffsetDateTime eventTime = parseEventTime(alarm.getEventTime());
@@ -321,6 +337,7 @@ public class CockpitHistoryAnalysisService {
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             String industry,
+            CockpitDistrict.Query district,
             TimeWindow window) {
         int handled = 0;
         int eligible = 0;
@@ -328,7 +345,9 @@ public class CockpitHistoryAnalysisService {
             return new ScopeRate(0, 0);
         }
         for (CockpitHistoryAnalysisSnapshot.SourceWorkOrder order : snapshot.getScopeWorkOrders()) {
-            if (order == null || !inScope(types, order.getTypeCode(), industry)) {
+            if (order == null
+                    || !inScope(types, order.getTypeCode(), industry)
+                    || !CockpitDistrict.matches(district, order.getDistrict())) {
                 continue;
             }
             OffsetDateTime createTime = parseEventTime(order.getCreateTime());

+ 31 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisSnapshot.java

@@ -90,11 +90,14 @@ public class CockpitHistoryAnalysisSnapshot {
         private final Integer stateCode;
         private final String createTime;
         private final String typeCode;
+        /** 工单设备所属片区(原始编码);无设备/未识别时为 null=未分区(票 77)。 */
+        private final Integer district;
 
-        public SourceWorkOrder(Integer stateCode, String createTime, String typeCode) {
+        public SourceWorkOrder(Integer stateCode, String createTime, String typeCode, Integer district) {
             this.stateCode = stateCode;
             this.createTime = createTime;
             this.typeCode = typeCode;
+            this.district = district;
         }
 
         public Integer getStateCode() {
@@ -108,6 +111,11 @@ public class CockpitHistoryAnalysisSnapshot {
         public String getTypeCode() {
             return typeCode;
         }
+
+        /** 片区原始编码;未经筛时照实给出。 */
+        public Integer getDistrict() {
+            return district;
+        }
     }
 
     public static class SourceType {
@@ -150,6 +158,8 @@ public class CockpitHistoryAnalysisSnapshot {
         private final String deviceName;
         private final String location;
         private final String updatedTime;
+        /** 事件片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
+        private final Integer district;
 
         /** 聚合最小字段(既有测试/空适配器用)。 */
         public SourceWarning(
@@ -158,7 +168,7 @@ public class CockpitHistoryAnalysisSnapshot {
                 String eventTime,
                 String typeCode,
                 String deviceCode) {
-            this(warningNo, level, eventTime, typeCode, deviceCode, null, null, null, null);
+            this(warningNo, level, eventTime, typeCode, deviceCode, null, null, null, null, null);
         }
 
         /** 票 61:逐事件行展示字段(业务适配器用)。 */
@@ -171,7 +181,8 @@ public class CockpitHistoryAnalysisSnapshot {
                 String status,
                 String deviceName,
                 String location,
-                String updatedTime) {
+                String updatedTime,
+                Integer district) {
             this.warningNo = warningNo;
             this.level = level;
             this.eventTime = eventTime;
@@ -181,6 +192,7 @@ public class CockpitHistoryAnalysisSnapshot {
             this.deviceName = deviceName;
             this.location = location;
             this.updatedTime = updatedTime;
+            this.district = district;
         }
 
         public String getWarningNo() {
@@ -218,6 +230,11 @@ public class CockpitHistoryAnalysisSnapshot {
         public String getUpdatedTime() {
             return updatedTime;
         }
+
+        /** 片区原始编码(未经筛时照实给出,调用方按 {@code CockpitDistrict} 归一)。 */
+        public Integer getDistrict() {
+            return district;
+        }
     }
 
     public static class SourceAlarm {
@@ -235,6 +252,8 @@ public class CockpitHistoryAnalysisSnapshot {
         private final String unit;
         private final BigDecimal minValue;
         private final BigDecimal maxValue;
+        /** 报警片区(原始编码,未归一见 {@code CockpitDistrict});无设备/未识别时为 null=未分区。 */
+        private final Integer district;
 
         /** 聚合最小字段(既有测试/空适配器用)。 */
         public SourceAlarm(
@@ -244,7 +263,7 @@ public class CockpitHistoryAnalysisSnapshot {
                 String typeCode,
                 String deviceCode) {
             this(alarmId, level, eventTime, typeCode, deviceCode,
-                    null, null, null, null, null, null, null, null, null);
+                    null, null, null, null, null, null, null, null, null, null);
         }
 
         /** 票 61:逐事件行展示字段(业务适配器用)。 */
@@ -262,7 +281,8 @@ public class CockpitHistoryAnalysisSnapshot {
                 BigDecimal value,
                 String unit,
                 BigDecimal minValue,
-                BigDecimal maxValue) {
+                BigDecimal maxValue,
+                Integer district) {
             this.alarmId = alarmId;
             this.level = level;
             this.eventTime = eventTime;
@@ -277,6 +297,7 @@ public class CockpitHistoryAnalysisSnapshot {
             this.unit = unit;
             this.minValue = minValue;
             this.maxValue = maxValue;
+            this.district = district;
         }
 
         public String getAlarmId() {
@@ -334,6 +355,11 @@ public class CockpitHistoryAnalysisSnapshot {
         public BigDecimal getMaxValue() {
             return maxValue;
         }
+
+        /** 片区原始编码(未经筛时照实给出,调用方按 {@code CockpitDistrict} 归一)。 */
+        public Integer getDistrict() {
+            return district;
+        }
     }
 
     public static class SourceWorkOrderLink {

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

@@ -7,12 +7,15 @@ import com.zksy.ops.service.WorkOrderService;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.service.EquipmentTypeService;
 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.CockpitReadException;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.apache.ibatis.exceptions.PersistenceException;
 import org.springframework.context.annotation.Primary;
 import org.springframework.dao.DataAccessException;
@@ -20,6 +23,7 @@ import org.springframework.stereotype.Component;
 
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
 import java.util.LinkedHashMap;
@@ -38,18 +42,21 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
     private final AlarmWarningRelMapper alarmWarningRelMapper;
     private final AlarmDataService alarmDataService;
     private final EquipmentBaseMapper equipmentBaseMapper;
+    private final EquipmentTypeService equipmentTypeService;
 
     public BusinessCockpitWorkOrderReadAdapter(
             WorkOrderService workOrderService,
             EarlyWarningService earlyWarningService,
             AlarmWarningRelMapper alarmWarningRelMapper,
             AlarmDataService alarmDataService,
-            EquipmentBaseMapper equipmentBaseMapper) {
+            EquipmentBaseMapper equipmentBaseMapper,
+            EquipmentTypeService equipmentTypeService) {
         this.workOrderService = workOrderService;
         this.earlyWarningService = earlyWarningService;
         this.alarmWarningRelMapper = alarmWarningRelMapper;
         this.alarmDataService = alarmDataService;
         this.equipmentBaseMapper = equipmentBaseMapper;
+        this.equipmentTypeService = equipmentTypeService;
     }
 
     @Override
@@ -102,6 +109,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
             Integer stateCode,
             Date startTime,
             Date endTime,
+            String industry,
             int limit,
             int offset) {
         LambdaQueryWrapper<WorkOrder> wrapper = new LambdaQueryWrapper<>();
@@ -114,6 +122,22 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         if (endTime != null) {
             wrapper.le(WorkOrder::getCreateTime, endTime);
         }
+        if (industry != null) {
+            List<String> typeIds = typeIdsOfIndustry(industry);
+            if (typeIds.isEmpty()) {
+                // 该行业在类型树上没有任何设备类型 ⇒ 不可能有工单命中。
+                // 直接回空页,不把空的 IN 条件下到 SQL。
+                return new CockpitWorkOrderListSnapshot(
+                        Collections.emptyList(), 0,
+                        limit <= 0 ? 1 : (offset / limit) + 1, limit, stateCode);
+            }
+            // 工单 → 设备 → 设备类型 → 行业:与 CockpitHandlingEfficiencyService.scopeWorkOrders(...)
+            // 同一套派生,两处筛的必须是同一批工单(票 65 的纪律)。设备表在默认 search_path 上,
+            // 用子查询而不是把上千个设备 ID 塞进 IN 列表。
+            wrapper.inSql(WorkOrder::getDeviceId,
+                    "SELECT equipment_id FROM equipment_base WHERE equipment_type_id IN ("
+                            + String.join(", ", typeIds) + ")");
+        }
         long total = workOrderService.count(wrapper);
         List<WorkOrder> orders = total <= offset
                 ? Collections.emptyList()
@@ -304,6 +328,26 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                 associatedWithCurrentEvent);
     }
 
+    /**
+     * 行业 → 该行业在设备类型树上的全部类型 ID(含叶子;行业沿父级上溯判定,规则单源
+     * {@link CockpitTypeIndustry})。该行业没有登记任何类型时回空列表。
+     */
+    private List<String> typeIdsOfIndustry(String industry) {
+        Map<Integer, EquipmentType> types = new LinkedHashMap<>();
+        for (EquipmentType type : equipmentTypeService.list()) {
+            if (type != null && type.getId() != null) {
+                types.put(type.getId(), type);
+            }
+        }
+        List<String> ids = new ArrayList<>();
+        for (EquipmentType type : types.values()) {
+            if (industry.equals(CockpitTypeIndustry.industryOf(type, types))) {
+                ids.add(String.valueOf(type.getId()));
+            }
+        }
+        return ids;
+    }
+
     private String resolveDeviceCode(String deviceId) {
         EquipmentBase equipment = deviceId == null ? null : equipmentBaseMapper.selectById(deviceId);
         return equipment == null ? null : equipment.getEquipmentCode();

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

@@ -24,6 +24,10 @@ public interface CockpitWorkOrderReadAdapter {
      * @param stateCode 工单状态码 0–7;{@code null} 表示不筛
      * @param startTime 窗口起点(含);{@code null} 表示不限下界
      * @param endTime   窗口终点(含);{@code null} 表示不限上界
+     * @param industry  行业编码(GAS/WATER_SUPPLY/DRAINAGE/MANHOLE 之一);{@code null} 表示不筛(票 77):
+     *                  口径经「工单设备 → 设备类型 → 行业」派生,与
+     *                  {@code CockpitHandlingEfficiencyService.scopeWorkOrders(...)} 同一套规则,
+     *                  这样列表与上方状态分布筛的是**同一批工单**。
      * @param limit     本页条数(调用方已夹到合法区间)
      * @param offset    偏移量,{@code (page-1)*limit}
      */
@@ -31,6 +35,7 @@ public interface CockpitWorkOrderReadAdapter {
             Integer stateCode,
             Date startTime,
             Date endTime,
+            String industry,
             int limit,
             int offset);
 }

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

@@ -21,6 +21,10 @@ import java.util.List;
 public class CockpitWorkOrderReadService {
     private static final List<String> VALID_EVENT_SOURCES = Arrays.asList("ALARM", "WARNING");
 
+    /** 正式行业域(与事件族端点同一份词表;票 77 为 /work-orders 补上行业筛选)。 */
+    private static final List<String> INDUSTRIES =
+            Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
+
     /** 全量工单列表的默认页大小(与驾驶舱其它列表端点一致)。 */
     private static final int DEFAULT_PAGE_SIZE = 10;
 
@@ -70,7 +74,15 @@ public class CockpitWorkOrderReadService {
     }
 
     public CockpitErrorPayload invalidRequest(String requestId) {
-        return error(requestId, CockpitErrorCategory.INVALID_REQUEST, "工单查询参数不合法");
+        return invalidRequest(requestId, "工单查询参数不合法");
+    }
+
+    /**
+     * 带原因的入参错误(票 77):与事件族端点同一纪律 —— 非法值要能说清哪一条不合法,
+     * 前端据此提示、排障时也能直接看出被拒的是哪个参数。
+     */
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
+        return error(requestId, CockpitErrorCategory.INVALID_REQUEST, message);
     }
 
     /**
@@ -92,8 +104,10 @@ public class CockpitWorkOrderReadService {
             Integer page,
             Integer pageSize,
             Integer stateCode,
+            String industry,
             String startTime,
             String endTime) {
+        String safeIndustry = resolveIndustry(industry);
         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;
@@ -103,7 +117,8 @@ public class CockpitWorkOrderReadService {
             throw new CockpitInvalidRequestException("startTime must not be after endTime");
         }
         CockpitWorkOrderListSnapshot snapshot = adapter.loadAll(
-                safeState, windowStart, windowEnd, safeSize, (safePage - 1) * safeSize);
+                safeState, windowStart, windowEnd, safeIndustry,
+                safeSize, (safePage - 1) * safeSize);
         if (snapshot == null) {
             throw new CockpitReadException("work order list unavailable");
         }
@@ -121,6 +136,24 @@ public class CockpitWorkOrderReadService {
                 new CockpitWorkOrderListResponse.Pagination(safePage, safeSize, snapshot.getTotal()));
     }
 
+    /**
+     * 行业入参:空/null=不筛;不在正式行业域内一律 400。
+     *
+     * <p>为什么不容错成「忽略这个参数」:静默忽略会造出「以为按行业筛了、其实是全量」的假绿,
+     * 与票 65 补时间窗时的同一条纪律(非法值一律 400)。
+     */
+    private static String resolveIndustry(String industry) {
+        if (industry == null || industry.trim().isEmpty()) {
+            return null;
+        }
+        String value = industry.trim();
+        if (!INDUSTRIES.contains(value)) {
+            throw new CockpitInvalidRequestException(
+                    "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
+        }
+        return value;
+    }
+
     /**
      * 时间窗口端点 → {@link Date}(ISO-8601,必须带时区偏移)。
      *

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

@@ -24,6 +24,7 @@ public class EmptyCockpitWorkOrderReadAdapter implements CockpitWorkOrderReadAda
             Integer stateCode,
             java.util.Date startTime,
             java.util.Date endTime,
+            String industry,
             int limit,
             int offset) {
         return null;