Procházet zdrojové kódy

merge: 执行票 12 —— 报警/工单读模型与 district 片区契约

合入 feat/cockpit-read-model-district(ee2e1b6 + 905f593 + 18db910):
- 六族统一改造 region → district(口径单源 CockpitDistrict)
- 管理信息删 organizations/ownershipDistribution/unmaintained,改 districtIndustry
- 工单读模型新增显式 associatedWithCurrentEvent + unassociatedWorkOrderCount
- 905f593 的 alarm_no 优先解析已被 18db910 撤回:原按 id 解析本就正确,
  905f593 依据的「id 为全零占位」是控制台渲染假象(实测 UUID 互不相同)

验证:cockpit + adapter 组合 372/372 通过。
Kazerin před 1 týdnem
rodič
revize
818a6d7a20
49 změnil soubory, kde provedl 1755 přidání a 733 odebrání
  1. 17 8
      docs/cockpit/README.md
  2. 2 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureBaseArchivesController.java
  3. 2 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureManagementInformationController.java
  4. 2 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  5. 3 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java
  6. 3 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMultidimensionalAnalysisController.java
  7. 6 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureBaseArchivesAdapterTest.java
  8. 27 13
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureManagementInformationAdapterTest.java
  9. 39 3
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java
  10. 28 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringSituationAdapterTest.java
  11. 170 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitWorkOrderReadAdapterTest.java
  12. 2 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEquipmentDetailAggregateControllerTest.java
  13. 63 30
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureBaseArchivesControllerTest.java
  14. 152 110
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureManagementInformationControllerTest.java
  15. 41 17
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  16. 72 35
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  17. 20 13
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java
  18. 105 20
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMultidimensionalAnalysisControllerTest.java
  19. 64 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadControllerTest.java
  20. 16 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  21. 22 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisResponse.java
  22. 32 24
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisService.java
  23. 13 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisSnapshot.java
  24. 146 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitDistrict.java
  25. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureBaseArchivesAdapter.java
  26. 24 22
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureManagementInformationAdapter.java
  27. 58 17
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesResponse.java
  28. 45 19
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesService.java
  29. 11 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesSnapshot.java
  30. 68 67
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationResponse.java
  31. 81 117
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationService.java
  32. 11 13
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationSnapshot.java
  33. 7 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  34. 8 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  35. 19 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  36. 28 22
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  37. 8 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java
  38. 8 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  39. 9 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  40. 4 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringSituationAdapter.java
  41. 52 21
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  42. 78 42
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  43. 12 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  44. 10 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationResponse.java
  45. 7 9
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationService.java
  46. 6 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationSnapshot.java
  47. 113 17
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  48. 15 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadResponse.java
  49. 15 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderSummary.java

+ 17 - 8
docs/cockpit/README.md

@@ -33,23 +33,23 @@
 | 总体概览 | `GET /api/cockpit/v1/overview/infrastructure` | — |
 | 总体概览 | `GET /api/cockpit/v1/overview/monitoring` | `industry` |
 | 总体概览 | `GET /api/cockpit/v1/overview/warning-settings` | `mode`(WARNING\|ALARM) `range` `startTime` `endTime` |
-| 基础设施 | `GET /api/cockpit/v1/infrastructure/base-archives` | `industry` `facilityType` `ageBucket` `region` |
+| 基础设施 | `GET /api/cockpit/v1/infrastructure/base-archives` | `industry` `facilityType` `ageBucket` `district` |
 | 基础设施 | `GET /api/cockpit/v1/infrastructure/operating-information` | `industry` `range` `from` `to` |
-| 基础设施 | `GET /api/cockpit/v1/infrastructure/management-information` | `industry` `organizationCode` |
+| 基础设施 | `GET /api/cockpit/v1/infrastructure/management-information` | `industry` `district` |
 | 基础设施 | `GET /api/cockpit/v1/infrastructure/emergency-support` | `category` `industry` `area` |
 | 运行监测 | `GET /api/cockpit/v1/monitoring/overview` | `industry` `deviceType` |
-| 运行监测 | `GET /api/cockpit/v1/monitoring/distribution` | `industry` `deviceType` `pointStatus` `region` `page` `pageSize` `snapshotAt` |
+| 运行监测 | `GET /api/cockpit/v1/monitoring/distribution` | `industry` `deviceType` `pointStatus` `district` `page` `pageSize` `snapshotAt` |
 | 运行监测 | `GET /api/cockpit/v1/monitoring/situation` | `industry` `deviceType` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/current` | `mode`(必填) `level` `industry` `status` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` |
 | 预警处置 | `GET /api/cockpit/v1/events/handling-efficiency` | `eventFamily`(必填) `industry` `level` `workOrderState` `startTime` `endTime` `page` `pageSize` |
-| 预警处置 | `GET /api/cockpit/v1/events/multidimensional-analysis` | `eventFamily`(必填) `industry` `level` `eventType` `cause` `deviceType` `region` `startTime` `endTime` |
+| 预警处置 | `GET /api/cockpit/v1/events/multidimensional-analysis` | `eventFamily`(必填) `industry` `level` `eventType` `cause` `deviceType` `district` `startTime` `endTime` |
 
 一张图与详情:
 
 | 用途 | 端点 |
 | --- | --- |
-| 地图分页快照(**地图取数路径**) | `GET /api/cockpit/v1/map-snapshot?page&pageSize&snapshotAt&industry&region&deviceType` |
+| 地图分页快照(**地图取数路径**) | `GET /api/cockpit/v1/map-snapshot?page&pageSize&snapshotAt&industry&district&deviceType` |
 | 监测点详情 | `GET /api/cockpit/v1/monitoring-points/{monitoringPointCode}?snapshotAt` |
 | 设备档案(含最新图片) | `GET /api/cockpit/v1/equipments/{deviceCode}/archive` |
 | 类型指标 | `GET /api/cockpit/v1/equipments/{deviceCode}/metrics/current`、`.../metrics/history` |
@@ -65,10 +65,19 @@
 **全局行业/范围筛选落到地图,以 `map-snapshot` 为准**(本结论替代「以 `/monitoring/distribution` 为准」的候选路径):
 
 - 地图(跨四个模式常驻的中央一张图)只从 `map-snapshot` 取数;`/monitoring/distribution` 是运行监测模式「监测分布」面板的载荷,不作为地图取数路径。
-- `map-snapshot` 的目标契约接受 `industry`、`region`、`deviceType` 三个筛选入参;分页与 `snapshotAt` 语义不因筛选改变。**2026-09-24 人工裁定**:正式行业扩为 `GAS` / `WATER_SUPPLY` / `DRAINAGE` / `MANHOLE`(井盖为独立根类型);对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。`monitoring/overview`、`monitoring/situation`、`map-snapshot`、`monitoring/distribution`、事件和基础设施服务均按该四行业口径校验;对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。旧 `type_id/parent_type_id` 不是当前 schema 字段。
-- 服务端完成过滤,并在响应 `mapFilter{industry,region,deviceType,pointCodes,deviceCodes}` 回显**实际生效范围**(编码为过滤后全量、分页前,升序)。
+- `map-snapshot` 的目标契约接受 `industry`、`district`、`deviceType` 三个筛选入参;分页与 `snapshotAt` 语义不因筛选改变。**2026-09-24 人工裁定**:正式行业扩为 `GAS` / `WATER_SUPPLY` / `DRAINAGE` / `MANHOLE`(井盖为独立根类型);对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。`monitoring/overview`、`monitoring/situation`、`map-snapshot`、`monitoring/distribution`、事件和基础设施服务均按该四行业口径校验;对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。旧 `type_id/parent_type_id` 不是当前 schema 字段。
+- 服务端完成过滤,并在响应 `mapFilter{industry,district,districtName,districtFilter,deviceType,pointCodes,deviceCodes}` 回显**实际生效范围**(编码为过滤后全量、分页前,升序)。
 - `coordinateValidation` 汇总是**当前筛选范围内的全量口径**(再切片分页);逐点/逐设备保留 `renderable=false` 的对象。
-- 逐点 `industry` 由类型推导(口径同 distribution:类型可解析、`objectKind = SMART_DEVICE`、行业属正式行业);不可推导时为 `null`,**默认不排除对象**,仅在请求了 `industry`/`deviceType` 过滤时按推导结果筛选。`region` 空值在响应中归一为 `未分区`。
+- 逐点 `industry` 由类型推导(口径同 distribution:类型可解析、`objectKind = SMART_DEVICE`、行业属正式行业);不可推导时为 `null`,**默认不排除对象**,仅在请求了 `industry`/`deviceType` 过滤时按推导结果筛选。
+
+### 片区(district)契约(执行票 12,2026-10-08)
+
+- **权威来源**=`equipment_base.district`(`0`城南 / `1`城北 / `2`太常,见 `DistrictEnum`)。**点位没有自有片区列**,按「经其上挂设备归纳」取得:多设备片区不一致、或没有任何设备、或有设备但片区不可信 → **未分区**。管网设施无片区来源,一律未分区。
+- **响应形态=编码 + 中文名**:`district`(Integer,未分区为 `null`)+ `districtName`(String,未分区回字面量「未分区」)。统计保留数字、展示层映射中文。
+- **筛选入参**:`district=0|1|2` 按片区、`district=UNASSIGNED` 只看未分区、不传=不筛;**其它值一律 400**(`district must be 0, 1, 2 or UNASSIGNED`),脏值不参与筛选。
+- **`mapFilter.districtFilter`** 取值 `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)—— 「未筛」与「只看未分区」的编码都是 `null`,必须靠该字段区分。
+- **旧自由串 `region` 已废止**:入参不再接受(被忽略),响应中 `region` / `regionDistribution` 已更名为 `district` / `districtDistribution`。前端 `map.filters.regionMap` 配置映射随之失效。
+- **管理信息已删字段**(决策票 06):`organizations`、`ownershipDistribution`、`totals.unmaintainedCount`;改回 `districtIndustry[]`(片区×行业的设施/设备计数)。
 
 在线试用每个切片的样例响应:打开 `docs/cockpit/` 下的同名 `*-demo.html`(离线自包含,无需后端)。
 

+ 2 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureBaseArchivesController.java

@@ -33,7 +33,7 @@ public class CockpitInfrastructureBaseArchivesController {
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "facilityType", required = false) String facilityType,
             @RequestParam(value = "ageBucket", required = false) String ageBucket,
-            @RequestParam(value = "region", required = false) String region,
+            @RequestParam(value = "district", required = false) String district,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
                 baseArchivesService.load(
@@ -41,7 +41,7 @@ public class CockpitInfrastructureBaseArchivesController {
                         industry,
                         facilityType,
                         ageBucket,
-                        region)));
+                        district)));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)

+ 2 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitInfrastructureManagementInformationController.java

@@ -31,11 +31,11 @@ public class CockpitInfrastructureManagementInformationController {
     @GetMapping("/api/cockpit/v1/infrastructure/management-information")
     public ResponseEntity<AjaxResult> managementInformation(
             @RequestParam(value = "industry", required = false) String industry,
-            @RequestParam(value = "organizationCode", required = false) String organizationCode,
+            @RequestParam(value = "district", required = false) String district,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
                 managementInformationService.load(
-                        resolveRequestId(requestId), industry, organizationCode)));
+                        resolveRequestId(requestId), industry, district)));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)

+ 2 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java

@@ -35,11 +35,11 @@ public class CockpitMapSnapshotController {
             @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
             @RequestParam(value = "industry", required = false) String industry,
-            @RequestParam(value = "region", required = false) String region,
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         CockpitMapSnapshotResponse response = mapSnapshotService.load(
-                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, region, deviceType);
+                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType);
         return ResponseEntity.ok(AjaxResult.success(response));
     }
 

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

@@ -34,7 +34,8 @@ public class CockpitMonitoringDistributionController {
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestParam(value = "pointStatus", required = false) String pointStatus,
-            @RequestParam(value = "region", required = false) String region,
+            // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "page", defaultValue = "1") Integer page,
             @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
@@ -45,7 +46,7 @@ public class CockpitMonitoringDistributionController {
                         industry,
                         deviceType,
                         pointStatus,
-                        region,
+                        district,
                         page,
                         pageSize,
                         snapshotAt)));

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

@@ -36,7 +36,8 @@ public class CockpitMultidimensionalAnalysisController {
             @RequestParam(value = "eventType", required = false) String eventType,
             @RequestParam(value = "cause", required = false) String cause,
             @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "region", required = false) String region,
+            // 范围维度为片区(district):0/1/2 按片区筛,UNASSIGNED 只看未分区,不传=不筛。
+            @RequestParam(value = "district", required = false) String district,
             @RequestParam(value = "startTime", required = false) String startTime,
             @RequestParam(value = "endTime", required = false) String endTime,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
@@ -49,7 +50,7 @@ public class CockpitMultidimensionalAnalysisController {
                         eventType,
                         cause,
                         deviceType,
-                        region,
+                        district,
                         startTime,
                         endTime)));
     }

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

@@ -25,6 +25,7 @@ import java.util.Collections;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -84,6 +85,9 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         assertEquals(4, snapshot.getFacilities().size());
         assertEquals(8, snapshot.getTypes().size());
         assertEquals("DRAINAGE_STORAGE", snapshot.getTypes().get(6).getCode());
+        // 设施表都没有 equipment_base.district 那种权威片区来源,全部如实报未分区。
+        assertTrue(snapshot.getFacilities().stream().allMatch(item -> item.getDistrict() == null),
+                "设施缺少片区来源时一律报 null,不得编造片区");
         CockpitInfrastructureBaseArchivesSnapshot.SourceFacility plantFacility =
                 snapshot.getFacilities().get(0);
         assertEquals("PLANT-001", plantFacility.getCode());
@@ -106,7 +110,8 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
                         .findFirst()
                         .orElseThrow(AssertionError::new);
         assertEquals("DRAINAGE_STORAGE", drainageFacility.getTypeCode());
-        assertEquals("CB", drainageFacility.getRegion());
+        // 排水工程设施只有自由文本 area,没有权威片区来源 → 必须如实报未分区。
+        assertNull(drainageFacility.getDistrict(), "设施没有片区来源,不得用 area 或管理单位冒充");
     }
 
     @Test

+ 27 - 13
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureManagementInformationAdapterTest.java

@@ -15,14 +15,13 @@ import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformatio
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -51,7 +50,7 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
     }
 
     @Test
-    void mapsOwnershipAndManagementRelationsFromExistingSourceFields() {
+    void mapsIndustryAndDistrictFromAuthoritativeSources() {
         EquipmentType waterRoot = type(1, "供水", 0);
         EquipmentType waterLeaf = type(11, "压力计", 1);
         EquipmentType gasRoot = type(3, "燃气", 0);
@@ -61,6 +60,8 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
         device.setEquipmentName("一号压力计");
         device.setEquipmentTypeId(11);
         device.setOwnershipUnit("供水公司");
+        // 片区权威来源:equipment_base.district。
+        device.setDistrict(1);
         device.setUpdateTime(LocalDateTime.parse("2026-09-02T09:58:00"));
 
         ManholeBase manhole = new ManholeBase();
@@ -97,44 +98,57 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
                 relation(snapshot.getRelations(), "DEV-001");
         assertEquals("SMART_DEVICE", deviceRelation.getObjectKind());
         assertEquals("WATER_SUPPLY", deviceRelation.getIndustry());
-        assertEquals("供水公司", deviceRelation.getOrganizationCode());
-        assertEquals("OWNED", deviceRelation.getOwnershipType());
+        assertEquals(1, deviceRelation.getDistrict());
 
         CockpitInfrastructureManagementInformationSnapshot.SourceRelation pipeRelation =
                 relation(snapshot.getRelations(), "PIPE-001");
         assertEquals("FACILITY", pipeRelation.getObjectKind());
         assertEquals("DRAINAGE", pipeRelation.getIndustry());
-        assertEquals("排水所", pipeRelation.getOrganizationCode());
-        assertEquals("MANAGED", pipeRelation.getOwnershipType());
+        // 管网设施没有片区来源:不得用 managementUnit(排水所)冒充片区。
+        assertNull(pipeRelation.getDistrict());
 
         assertEquals("MANHOLE", relation(snapshot.getRelations(), "MH-001").getIndustry());
+        assertNull(relation(snapshot.getRelations(), "MH-001").getDistrict());
         assertEquals("WATER_SUPPLY", relation(snapshot.getRelations(), "SOURCE-001").getIndustry());
+        assertNull(relation(snapshot.getRelations(), "SOURCE-001").getDistrict());
     }
 
     @Test
-    void skipsRowsWithoutAuthoritativeIndustryOrOrganization() {
-        EquipmentBase noUnit = new EquipmentBase();
-        noUnit.setEquipmentCode("DEV-NO-UNIT");
-        noUnit.setEquipmentTypeId(1);
+    void skipsRowsWithoutAuthoritativeIndustryAndNormalizesDirtyDistrict() {
+        EquipmentBase noType = new EquipmentBase();
+        noType.setEquipmentCode("DEV-NO-TYPE");
+        noType.setDistrict(0);
 
         EquipmentBase unknownType = new EquipmentBase();
         unknownType.setEquipmentCode("DEV-UNKNOWN-TYPE");
         unknownType.setEquipmentTypeId(99);
         unknownType.setOwnershipUnit("未知单位");
 
+        EquipmentBase dirtyDistrict = new EquipmentBase();
+        dirtyDistrict.setEquipmentCode("DEV-DIRTY-DISTRICT");
+        dirtyDistrict.setEquipmentTypeId(1);
+        dirtyDistrict.setDistrict(99);
+
+        // 管网设施无片区来源,但行业可判定,故仍应上报(片区为 null)。
         PipeNetworkBase noManagement = new PipeNetworkBase();
         noManagement.setNetworkCode("PIPE-NO-UNIT");
         noManagement.setNetworkType("2");
 
         when(equipmentTypeService.list()).thenReturn(Collections.singletonList(type(1, "供水", 0)));
-        when(equipmentBaseService.list()).thenReturn(Arrays.asList(noUnit, unknownType));
+        when(equipmentBaseService.list()).thenReturn(Arrays.asList(noType, unknownType, dirtyDistrict));
         when(manholeBaseService.list()).thenReturn(Collections.emptyList());
         when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(noManagement));
         when(waterSourceInfoService.list()).thenReturn(Collections.emptyList());
 
         CockpitInfrastructureManagementInformationSnapshot snapshot = adapter.load();
 
-        assertTrue(snapshot.getRelations().isEmpty());
+        // 设备类型无法归属正式行业的被跳过(DEV-NO-TYPE、DEV-UNKNOWN-TYPE);
+        // 无片区来源的管网设施照常上报,脏片区值的设备也上报但片区归一为 null。
+        assertEquals(2, snapshot.getRelations().size());
+        assertNull(relation(snapshot.getRelations(), "DEV-DIRTY-DISTRICT").getDistrict(),
+                "库中脏片区值 99 不得穿透到快照,更不得被当成一个片区");
+        assertNull(relation(snapshot.getRelations(), "PIPE-NO-UNIT").getDistrict(),
+                "管网设施没有片区来源,应如实报未分区");
     }
 
     private static EquipmentType type(int id, String name, int parentId) {

+ 39 - 3
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java

@@ -61,9 +61,9 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         stubTypes(Arrays.asList(
                 type(22, "22", "可燃气体检测仪", "3")));
         EquipmentBase bound = device("D-1", "Z-TEST-RQXL01", "Z-测试燃气泄漏01", "22",
-                new BigDecimal("110.4024"), new BigDecimal("28.4499"));
+                new BigDecimal("110.4024"), new BigDecimal("28.4499"), 1);
         EquipmentBase free = device("D-2", "FREE-1", "独立燃气计", "22",
-                new BigDecimal("110.5"), new BigDecimal("28.5"));
+                new BigDecimal("110.5"), new BigDecimal("28.5"), null);
         stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
             EquipmentBase device = invocation.getArgument(0);
@@ -79,8 +79,10 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p1 = snapshot.getMonitoringPoints().get(0);
         assertEquals("22", p1.getTypeCode(), "关联设备类型唯一时点位类型可推导");
         assertFalse(p1.isCoordinateVerified());
+        assertEquals(Integer.valueOf(1), p1.getDistrict(), "点位片区按其上挂设备归纳");
         CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p2 = snapshot.getMonitoringPoints().get(1);
         assertNull(p2.getTypeCode(), "无关联设备时点位类型不可推导");
+        assertNull(p2.getDistrict(), "无关联设备时点位如实归为未分区,不猜");
 
         assertEquals(2, snapshot.getSmartDevices().size(), "分布快照上报全量设备(关联由服务层判定)");
         CockpitMonitoringDistributionSnapshot.SourceSmartDevice freeDevice = snapshot.getSmartDevices().stream()
@@ -90,6 +92,10 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         assertTrue(freeDevice.isWarning());
         assertTrue(freeDevice.isWorkOrderInProgress());
         assertEquals("22", freeDevice.getTypeCode());
+        assertNull(freeDevice.getDistrict(), "设备无片区列值时如实归为未分区");
+        assertEquals(Integer.valueOf(1), snapshot.getSmartDevices().stream()
+                .filter(device -> "Z-TEST-RQXL01".equals(device.getDeviceCode()))
+                .findFirst().orElseThrow().getDistrict(), "设备自身片区即权威来源");
 
         assertTrue(snapshot.getTypes().stream().anyMatch(
                 type -> "22".equals(type.getCode()) && "GAS".equals(type.getIndustry())));
@@ -129,6 +135,34 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
     }
 
 
+    @Test
+    void conflictingOrDirtyDeviceDistrictsDegradeToUnassignedInsteadOfGuessing() {
+        stubPoints(Collections.singletonList(
+                point("P-1", "Z-RQXLJC01", "燃气泄漏监测点01",
+                        new BigDecimal("110.4024"), new BigDecimal("28.4499"))));
+        stubTypes(Collections.singletonList(type(22, "22", "可燃气体检测仪", "3")));
+        // 同一测点两台设备片区不一致 → 点位归为未分区;另有一台设备片区为库中脏值 7
+        EquipmentBase south = device("D-1", "BOUND-SOUTH", "南片区设备", "22",
+                new BigDecimal("110.4024"), new BigDecimal("28.4499"), 0);
+        EquipmentBase north = device("D-2", "BOUND-NORTH", "北片区设备", "22",
+                new BigDecimal("110.4025"), new BigDecimal("28.4500"), 1);
+        EquipmentBase dirty = device("D-3", "DIRTY-DISTRICT", "脏片区设备", "22",
+                new BigDecimal("110.4026"), new BigDecimal("28.4501"), 7);
+        stubDevices(Arrays.asList(south, north, dirty), Arrays.asList(south, north));
+        when(deviceFactResolver.resolve(any(), anyString()))
+                .thenAnswer(invocation -> fact(
+                        ((EquipmentBase) invocation.getArgument(0)).getEquipmentCode(),
+                        true, false, false, false));
+
+        CockpitMonitoringDistributionSnapshot snapshot = adapter.load(null);
+
+        assertNull(snapshot.getMonitoringPoints().get(0).getDistrict(),
+                "多设备片区不一致 → 未分区,不挑一个代表");
+        assertNull(snapshot.getSmartDevices().stream()
+                .filter(device -> "DIRTY-DISTRICT".equals(device.getDeviceCode()))
+                .findFirst().orElseThrow().getDistrict(), "库中脏片区不得冒充合法片区");
+    }
+
     private void stubPoints(List<MonitorPoint> points) {
         when(monitorPointService.list()).thenReturn(points);
     }
@@ -173,7 +207,7 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
     }
 
     private static EquipmentBase device(String id, String code, String name, String typeId,
-            BigDecimal lon, BigDecimal lat) {
+            BigDecimal lon, BigDecimal lat, Integer district) {
         EquipmentBase device = new EquipmentBase();
         device.setEquipmentId(id);
         device.setEquipmentCode(code);
@@ -181,6 +215,8 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         device.setEquipmentTypeId(Integer.valueOf(typeId));
         device.setLongitude(lon);
         device.setLatitude(lat);
+        // 片区权威来源:equipment_base.district(Integer,0-城南 1-城北 2-太常)
+        device.setDistrict(district);
         return device;
     }
 }

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

@@ -160,6 +160,28 @@ class BusinessCockpitMonitoringSituationAdapterTest {
         assertTrue(snapshot.getDevices().isEmpty());
     }
 
+    @Test
+    void metricDeviceDistrictComesFromEquipmentLedgerAndDirtyValueDegradesToUnassigned() {
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        EquipmentBase southDevice = device("D-1", "SOUTH-1", "22");
+        southDevice.setDistrict(0);
+        EquipmentBase dirtyDevice = device("D-2", "DIRTY-1", "22");
+        dirtyDevice.setDistrict(7); // 库中脏值
+        EquipmentBase noDistrictDevice = device("D-3", "NONE-1", "22");
+        when(equipmentBaseService.list())
+                .thenReturn(Arrays.asList(southDevice, dirtyDevice, noDistrictDevice));
+        when(equipmentStatusService.getByDeviceCode(anyString())).thenReturn(status(1));
+        when(deviceFactResolver.resolve(any(), anyString()))
+                .thenReturn(fact("x", false, false, false));
+        stubEvents(Collections.emptyList(), Collections.emptyList());
+
+        CockpitMonitoringSituationSnapshot snapshot = adapter.load();
+
+        assertEquals(Integer.valueOf(0), districtOf(snapshot, "SOUTH-1"), "设备片区取自 equipment_base.district");
+        assertNull(districtOf(snapshot, "DIRTY-1"), "脏片区值不得冒充合法片区");
+        assertNull(districtOf(snapshot, "NONE-1"), "无片区来源如实归为未分区,不猜");
+    }
+
     // ---- helpers ----
 
     private void stubTypes(List<EquipmentType> types) {
@@ -177,6 +199,12 @@ class BusinessCockpitMonitoringSituationAdapterTest {
                 .findFirst().orElseThrow().getMetricStatus();
     }
 
+    private static Integer districtOf(CockpitMonitoringSituationSnapshot snapshot, String deviceCode) {
+        return snapshot.getDevices().stream()
+                .filter(d -> deviceCode.equals(d.getDeviceCode()))
+                .findFirst().orElseThrow().getDistrict();
+    }
+
     private static CockpitMonitoringPointDeviceSnapshot fact(
             String code, boolean online, boolean alarm, boolean warning) {
         return new CockpitMonitoringPointDeviceSnapshot(

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

@@ -0,0 +1,170 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.workorder.BusinessCockpitWorkOrderReadAdapter;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadSnapshot;
+import com.zksy.cockpit.workorder.CockpitWorkOrderSummary;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 工单读适配器契约(执行票 12 范围 1)。
+ *
+ * <p>断言针对真实库核实过的行为:
+ * <ol>
+ *   <li>报警事件端点用 {@code alarm_data.id}(真实 UUID,各报警互不相同)定位,
+ *       命中后回该报警实际关联的工单,而不是恒回 EMPTY。</li>
+ *   <li>设备维度返回该设备已有**全部**工单,未关联报警者须**显式**标 false,
+ *       且不得伪造 `eventAssociation`。</li>
+ * </ol>
+ */
+class BusinessCockpitWorkOrderReadAdapterTest {
+
+    private static final String DEVICE_CODE = "EQ-001";
+    private static final String EQUIPMENT_ID = "EQID-001";
+    /** 报警主键:真实库中为 32 位 UUID,各报警互不相同。 */
+    private static final String ALARM_ID = "fca8fd6b6d6f3408e964db88640dc399";
+    private static final String ALARM_NO = "BJ202609291243545660";
+
+    private WorkOrderService workOrderService;
+    private AlarmDataService alarmDataService;
+    private EquipmentBaseMapper equipmentBaseMapper;
+    private BusinessCockpitWorkOrderReadAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        // MyBatis-Plus 的 LambdaQueryWrapper 需要实体的 lambda 缓存才能解析
+        // method reference(`EquipmentBase::getEquipmentCode`)。纯单测里没有
+        // Spring/MyBatis 上下文,需显式注册一次,否则构建 wrapper 就抛
+        // "can not find lambda cache for this entity"。
+        initLambdaCache(EquipmentBase.class);
+        initLambdaCache(WorkOrder.class);
+        initLambdaCache(AlarmData.class);
+
+        workOrderService = mock(WorkOrderService.class);
+        alarmDataService = mock(AlarmDataService.class);
+        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
+        adapter = new BusinessCockpitWorkOrderReadAdapter(
+                workOrderService,
+                mock(EarlyWarningService.class),
+                mock(AlarmWarningRelMapper.class),
+                alarmDataService,
+                equipmentBaseMapper);
+    }
+
+    /** 注册实体的 lambda 缓存(等价于 MyBatis-Plus 启动时的 TableInfoHelper 初始化)。 */
+    private static void initLambdaCache(Class<?> entityClass) {
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new org.apache.ibatis.session.Configuration(), ""),
+                entityClass);
+    }
+
+    /** 报警维度:按报警主键解析,命中后回该报警实际关联的工单(不得恒回 EMPTY)。 */
+    @Test
+    void alarmEventResolvesByAlarmIdAndReturnsItsLinkedOrders() {
+        AlarmData alarm = alarm(ALARM_ID, ALARM_NO, 279L);
+        when(alarmDataService.getById(ALARM_ID)).thenReturn(alarm);
+        when(workOrderService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(order(279L, "WO-279", 5)));
+
+        CockpitWorkOrderReadSnapshot snapshot = adapter.loadByEvent("ALARM", ALARM_ID);
+
+        assertNotNull(snapshot, "按报警主键必须能解析到报警");
+        assertEquals(1, snapshot.getWorkOrders().size(), "该报警确有工单,不得回 EMPTY");
+        CockpitWorkOrderSummary summary = snapshot.getWorkOrders().get(0);
+        assertEquals("WO-279", summary.getWorkOrderCode());
+        assertTrue(summary.isAssociatedWithCurrentEvent(), "报警维度命中的工单即实际关联");
+        // 对外标识回该报警主键(真实 UUID),与请求标识一致
+        assertEquals(ALARM_ID, summary.getEventAssociation().getEventCode());
+        assertEquals(ALARM_ID, snapshot.getEventCode());
+    }
+
+    /** 主键查不到时回退按 alarm_no 定位,便于调用方用真实单号查询。 */
+    @Test
+    void alarmEventFallsBackToAlarmNoWhenIdMisses() {
+        AlarmData alarm = alarm(ALARM_ID, ALARM_NO, 279L);
+        when(alarmDataService.getById(ALARM_NO)).thenReturn(null);
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(alarm));
+        when(workOrderService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(order(279L, "WO-279", 5)));
+
+        CockpitWorkOrderReadSnapshot snapshot = adapter.loadByEvent("ALARM", ALARM_NO);
+
+        assertNotNull(snapshot);
+        assertEquals(1, snapshot.getWorkOrders().size());
+        // 回显仍用报警主键,而不是请求里那个单号
+        assertEquals(ALARM_ID, snapshot.getEventCode());
+    }
+
+    /** 未关联报警的工单:显式 false 且不得伪造 eventAssociation。 */
+    @Test
+    void deviceScopeMarksUnassociatedOrdersExplicitlyWithoutFabricatingAssociation() {
+        when(equipmentBaseMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(equipment());
+        List<WorkOrder> orders = Arrays.asList(
+                order(279L, "WO-LINKED", 5),
+                order(281L, "WO-ORPHAN", 1));
+        // 只有 279 被报警引用;281 无任何报警关联
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(alarm(ALARM_ID, ALARM_NO, 279L)));
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(orders);
+
+        CockpitWorkOrderReadSnapshot snapshot = adapter.loadByDevice(DEVICE_CODE);
+
+        assertEquals(2, snapshot.getWorkOrders().size(), "设备维度返回已有全部工单");
+        CockpitWorkOrderSummary linked = snapshot.getWorkOrders().get(0);
+        assertTrue(linked.isAssociatedWithCurrentEvent());
+        assertEquals(ALARM_ID, linked.getEventAssociation().getEventCode());
+
+        CockpitWorkOrderSummary orphan = snapshot.getWorkOrders().get(1);
+        assertFalse(orphan.isAssociatedWithCurrentEvent(), "未关联必须显式 false");
+        assertNull(orphan.getEventAssociation(), "未关联不得伪造关联对象");
+    }
+
+    // --- fixtures ---
+
+    private static AlarmData alarm(String id, String alarmNo, Long workOrderId) {
+        AlarmData alarm = new AlarmData();
+        alarm.setId(id);
+        alarm.setAlarmNo(alarmNo);
+        alarm.setWorkOrderId(workOrderId);
+        return alarm;
+    }
+
+    private static WorkOrder order(Long orderId, String orderNo, int status) {
+        WorkOrder order = new WorkOrder();
+        order.setOrderId(orderId);
+        order.setOrderNo(orderNo);
+        order.setOrderStatus(status);
+        return order;
+    }
+
+    private static EquipmentBase equipment() {
+        EquipmentBase base = new EquipmentBase();
+        base.setEquipmentId(EQUIPMENT_ID);
+        base.setEquipmentCode(DEVICE_CODE);
+        return base;
+    }
+}

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

@@ -415,7 +415,8 @@ class CockpitEquipmentDetailAggregateControllerTest {
                 code, DEVICE_CODE, 1, "处理异常", 4, "处理中",
                 "2026-09-02T09:00:00+08:00", "2026-09-02T10:00:00+08:00",
                 "2026-09-03T09:00:00+08:00", null,
-                new CockpitWorkOrderEventAssociation("ALARM", "ALM-20260902-001", "2026-09-02T10:00:00+08:00"));
+                new CockpitWorkOrderEventAssociation("ALARM", "ALM-20260902-001", "2026-09-02T10:00:00+08:00"),
+                true);
     }
 
     private CockpitEventWarning warning(String warningNo) {

+ 63 - 30
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureBaseArchivesControllerTest.java

@@ -20,6 +20,7 @@ import java.util.Arrays;
 import java.util.Collections;
 
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -100,17 +101,22 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(jsonPath("$.data.ageReference.commissionedCount").value(4))
                 .andExpect(jsonPath("$.data.ageReference.builtCount").value(1))
                 .andExpect(jsonPath("$.data.ageReference.unknownCount").value(0))
-                .andExpect(jsonPath("$.data.regionDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.regionDistribution[0].code").value("CN"))
-                .andExpect(jsonPath("$.data.regionDistribution[0].count").value(3))
-                .andExpect(jsonPath("$.data.regionDistribution[1].code").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.regionDistribution[1].count").value(1))
-                .andExpect(jsonPath("$.data.regionDistribution[2].code").value("CB"))
-                .andExpect(jsonPath("$.data.regionDistribution[2].count").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
+                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
+                .andExpect(jsonPath("$.data.districtDistribution[0].count").value(3))
+                .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
+                .andExpect(jsonPath("$.data.districtDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution[2].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtDistribution[2].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.mapFilter.industry").doesNotExist())
                 .andExpect(jsonPath("$.data.mapFilter.facilityType").doesNotExist())
                 .andExpect(jsonPath("$.data.mapFilter.ageBucket").doesNotExist())
-                .andExpect(jsonPath("$.data.mapFilter.region").doesNotExist())
+                .andExpect(jsonPath("$.data.mapFilter.district").doesNotExist())
+                .andExpect(jsonPath("$.data.mapFilter.districtName").doesNotExist())
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.smartDeviceCount").value(3));
@@ -139,31 +145,58 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                         .queryParam("industry", "WATER_SUPPLY")
                         .queryParam("facilityType", "WATER_PLANT")
                         .queryParam("ageBucket", "UNDER_5_YEARS")
-                        .queryParam("region", "CN"))
+                        .queryParam("district", "0"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.selectedFilters.facilityType").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.selectedFilters.ageBucket").value("UNDER_5_YEARS"))
-                .andExpect(jsonPath("$.data.selectedFilters.region").value("CN"))
+                .andExpect(jsonPath("$.data.selectedFilters.district").value(0))
+                .andExpect(jsonPath("$.data.selectedFilters.districtName").value("城南"))
+                .andExpect(jsonPath("$.data.selectedFilters.districtFilter").value("ASSIGNED"))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value("waterSupply"))
                 .andExpect(jsonPath("$.data.mapFilter.facilityType").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.mapFilter.ageBucket").value("UNDER_5_YEARS"))
-                .andExpect(jsonPath("$.data.mapFilter.region").value("CN"))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(0))
+                .andExpect(jsonPath("$.data.mapFilter.districtName").value("城南"))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"))
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(1)))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].code").value("WATER_PLANT"));
     }
 
     @Test
-    void blankRegionFilterSelectsNormalizedUnknownFacilities() throws Exception {
+    void unassignedDistrictFilterSelectsOnlyFacilitiesWithoutDistrict() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
-                        .queryParam("region", "UNKNOWN"))
+                        .queryParam("district", "UNASSIGNED"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.selectedFilters.region").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.mapFilter.region").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(1));
+                .andExpect(jsonPath("$.data.selectedFilters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.selectedFilters.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.selectedFilters.districtFilter").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
+                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtDistribution[0].count").value(1));
+    }
+
+    @Test
+    void invalidDistrictReturnsTypedBadRequestBeforeAdapterInteraction() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
+                        .queryParam("district", "城区A"))
+                .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("/api/cockpit/v1/infrastructure/base-archives")
+                        .queryParam("district", "3"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        verifyNoInteractions(adapter);
     }
 
     @Test
@@ -217,7 +250,7 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(jsonPath("$.data.ageDistribution[2].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[3].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[4].count").value(0))
-                .andExpect(jsonPath("$.data.regionDistribution", hasSize(0)))
+                .andExpect(jsonPath("$.data.districtDistribution", hasSize(0)))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.smartDeviceCount").value(0))
@@ -274,16 +307,16 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PLANT_DEVICE", "水厂设备", "WATER_SUPPLY", "SMART_DEVICE"),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WRONG_KIND", "类型错挂", "WATER_SUPPLY", "OTHER")),
                 Arrays.asList(
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-001", "WATER_PLANT", "CN", null, "medium", LocalDate.parse("2023-01-01"), LocalDate.parse("2022-01-01")),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-001", "WATER_PLANT", 0, null, "medium", LocalDate.parse("2023-01-01"), LocalDate.parse("2022-01-01")),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-002", "PUMP_STATION", null, null, null, LocalDate.parse("2018-01-01"), LocalDate.parse("2017-01-01")),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-003", "WATER_PIPE", "CN", new BigDecimal("5.5"), null, LocalDate.parse("2011-01-01"), LocalDate.parse("2010-01-01")),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-004", "GAS_PIPE", "CB", new BigDecimal("2.5"), null, LocalDate.parse("2004-01-01"), LocalDate.parse("2003-01-01")),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-005", "DRAINAGE_PIPE", "CN", null, null, null, LocalDate.parse("2004-01-01")),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("D-001", "WATER_SUPPLY_SENSOR", "CN", null, null, LocalDate.parse("2024-01-01"), null),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("D-002", "SMART_MANHOLE", "CN", null, null, LocalDate.parse("2024-01-01"), null),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("X-001", "UNKNOWN_TYPE", "CN", null, null, LocalDate.parse("2024-01-01"), null),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("X-002", "WATER_PLANT_DEVICE", "CN", null, null, LocalDate.parse("2024-01-01"), null),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("X-003", "WRONG_KIND", "CN", null, null, LocalDate.parse("2024-01-01"), null)));
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-003", "WATER_PIPE", 0, new BigDecimal("5.5"), null, LocalDate.parse("2011-01-01"), LocalDate.parse("2010-01-01")),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-004", "GAS_PIPE", 1, new BigDecimal("2.5"), null, LocalDate.parse("2004-01-01"), LocalDate.parse("2003-01-01")),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-005", "DRAINAGE_PIPE", 0, null, null, null, LocalDate.parse("2004-01-01")),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("D-001", "WATER_SUPPLY_SENSOR", 0, null, null, LocalDate.parse("2024-01-01"), null),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("D-002", "SMART_MANHOLE", 0, null, null, LocalDate.parse("2024-01-01"), null),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("X-001", "UNKNOWN_TYPE", 0, null, null, LocalDate.parse("2024-01-01"), null),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("X-002", "WATER_PLANT_DEVICE", 0, null, null, LocalDate.parse("2024-01-01"), null),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("X-003", "WRONG_KIND", 0, null, null, LocalDate.parse("2024-01-01"), null)));
     }
 
     private static CockpitInfrastructureBaseArchivesSnapshot ageFixture() {
@@ -293,10 +326,10 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PIPE", "供水管线", "WATER_SUPPLY", "FACILITY")),
                 Arrays.asList(
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-001", "WATER_PLANT", "CN", null, null, LocalDate.parse("2018-01-01"), LocalDate.parse("2017-01-01")),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-002", "WATER_PIPE", "CN", null, null, null, null),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-003", "WATER_PIPE", "CN", null, null, LocalDate.parse("2030-01-01"), null),
-                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-004", "WATER_PIPE", "CN", null, null, null, LocalDate.parse("2030-01-01"))));
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-001", "WATER_PLANT", 0, null, null, LocalDate.parse("2018-01-01"), LocalDate.parse("2017-01-01")),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-002", "WATER_PIPE", 0, null, null, null, null),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-003", "WATER_PIPE", 0, null, null, LocalDate.parse("2030-01-01"), null),
+                        new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility("F-004", "WATER_PIPE", 0, null, null, null, LocalDate.parse("2030-01-01"))));
     }
 
     private static boolean isReadOnlyMappedMethod(Method method) {

+ 152 - 110
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureManagementInformationControllerTest.java

@@ -18,7 +18,6 @@ import java.time.Clock;
 import java.time.Instant;
 import java.time.ZoneOffset;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
@@ -33,6 +32,9 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
+/**
+ * 管理信息契约:**片区 × 行业**计数,组织/权属视图已按决策票 06 删除。
+ */
 class CockpitInfrastructureManagementInformationControllerTest {
 
     private static final String ENDPOINT = "/api/cockpit/v1/infrastructure/management-information";
@@ -53,7 +55,7 @@ class CockpitInfrastructureManagementInformationControllerTest {
     }
 
     @Test
-    void managementInformationGroupsOrganizationsAndOwnershipDistribution() throws Exception {
+    void managementInformationGroupsCountsByDistrictAndIndustry() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
         mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "management-information-001"))
@@ -69,41 +71,37 @@ class CockpitInfrastructureManagementInformationControllerTest {
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READY"))
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.selectedFilters.organizationCode").value(nullValue()))
-                .andExpect(jsonPath("$.data.totals.organizationCount").value(5))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(6))
-                .andExpect(jsonPath("$.data.totals.equipmentCount").value(4))
-                .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2))
-                .andExpect(jsonPath("$.data.organizations", hasSize(5)))
-                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
-                .andExpect(jsonPath("$.data.organizations[0].organizationName").value("供水一公司"))
-                .andExpect(jsonPath("$.data.organizations[0].industry").value("WATER_SUPPLY"))
-                .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
-                .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.organizations[0].unmaintained").value(false))
-                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution", hasSize(2)))
-                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].ownershipType").value("OWNED"))
-                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].facilityCount").value(2))
-                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].equipmentCount").value(0))
-                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[0].total").value(2))
-                .andExpect(jsonPath("$.data.organizations[0].ownershipDistribution[1].ownershipType").value("LEASED"))
-                .andExpect(jsonPath("$.data.organizations[1].organizationCode").value("ORG-GAS-A"))
-                .andExpect(jsonPath("$.data.organizations[2].organizationCode").value("ORG-WS-B"))
-                .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[0].ownershipType").value("ENTRUSTED"))
-                .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(4)))
-                .andExpect(jsonPath("$.data.ownershipDistribution[0].ownershipType").value("OWNED"))
-                .andExpect(jsonPath("$.data.ownershipDistribution[0].facilityCount").value(5))
-                .andExpect(jsonPath("$.data.ownershipDistribution[0].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.ownershipDistribution[0].total").value(6))
-                .andExpect(jsonPath("$.data.ownershipDistribution[1].ownershipType").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.ownershipDistribution[1].total").value(2))
-                .andExpect(jsonPath("$.data.ownershipDistribution[2].ownershipType").value("ENTRUSTED"))
-                .andExpect(jsonPath("$.data.ownershipDistribution[3].ownershipType").value("LEASED"))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(5)))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-WS-A"))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[3]").value("UNMAINTAINED"))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[4]").value("ORG-MH-A"))
+                .andExpect(jsonPath("$.data.selectedFilters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.selectedFilters.districtFilter").value("NONE"))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
+                // 片区升序、未分区最后;未分区内部按行业排序。
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(5)))
+                .andExpect(jsonPath("$.data.districtIndustry[0].district").value(0))
+                .andExpect(jsonPath("$.data.districtIndustry[0].districtName").value("城南"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.districtIndustry[0].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[1].district").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[1].districtName").value("城北"))
+                .andExpect(jsonPath("$.data.districtIndustry[1].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.districtIndustry[1].facilityCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[1].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[2].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtIndustry[2].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[2].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.districtIndustry[2].facilityCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[2].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[3].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtIndustry[3].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[3].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.districtIndustry[3].facilityCount").value(0))
+                .andExpect(jsonPath("$.data.districtIndustry[3].equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[4].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtIndustry[4].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[4].industry").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.districtIndustry[4].facilityCount").value(1))
+                .andExpect(jsonPath("$.data.districtIndustry[4].equipmentCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
@@ -111,101 +109,112 @@ class CockpitInfrastructureManagementInformationControllerTest {
     }
 
     @Test
-    void missingOrganizationFallsIntoExplicitUnmaintainedBucket() throws Exception {
+    void removedOrganizationAndOwnershipFieldsAreGoneFromTheResponse() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.organizations[3].organizationCode").value("UNMAINTAINED"))
-                .andExpect(jsonPath("$.data.organizations[3].organizationName").value("未维护"))
-                .andExpect(jsonPath("$.data.organizations[3].unmaintained").value(true))
-                .andExpect(jsonPath("$.data.organizations[3].industry").value("DRAINAGE"))
-                .andExpect(jsonPath("$.data.organizations[3].facilityCount").value(1))
-                .andExpect(jsonPath("$.data.organizations[3].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.organizations[3].ownershipDistribution[0].ownershipType").value("OWNED"))
-                .andExpect(jsonPath("$.data.organizations[3].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
-                .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2));
+                .andExpect(jsonPath("$.data.organizations").doesNotExist())
+                .andExpect(jsonPath("$.data.ownershipDistribution").doesNotExist())
+                .andExpect(jsonPath("$.data.totals.unmaintainedCount").doesNotExist())
+                .andExpect(jsonPath("$.data.totals.organizationCount").doesNotExist())
+                .andExpect(jsonPath("$.data.selectedFilters.organizationCode").doesNotExist())
+                .andExpect(jsonPath("$.data.mapFilter.organizationCode").doesNotExist())
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes").doesNotExist());
     }
 
     @Test
-    void duplicateRelationsAreCountedOnceAndReportedAsExcluded() throws Exception {
+    void facilitiesAndEquipmentAreCountedSeparatelyAndNeverMerged() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(6))
-                .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
-                .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1));
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(4))
+                .andExpect(jsonPath("$.data.districtIndustry[0].facilityCount").value(2))
+                .andExpect(jsonPath("$.data.districtIndustry[0].equipmentCount").value(1));
     }
 
     @Test
-    void facilitiesAndEquipmentAreCountedSeparatelyAndNeverMerged() throws Exception {
+    void industryFilterRestrictsDistrictIndustryRowsToTheFormalIndustry() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
-        mockMvc.perform(get(ENDPOINT))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WATER_SUPPLY"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
-                .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(6))
-                .andExpect(jsonPath("$.data.totals.equipmentCount").value(4));
+                .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(1)))
+                .andExpect(jsonPath("$.data.districtIndustry[0].district").value(0))
+                .andExpect(jsonPath("$.data.districtIndustry[0].districtName").value("城南"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(2))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(1));
     }
 
     @Test
-    void industryFilterRestrictsOrganizationsToTheFormalIndustry() throws Exception {
+    void districtFilterSelectsOnlyTheRequestedDistrict() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WATER_SUPPLY"))
+        mockMvc.perform(get(ENDPOINT).queryParam("district", "1"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
-                .andExpect(jsonPath("$.data.organizations", hasSize(2)))
-                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
-                .andExpect(jsonPath("$.data.organizations[1].organizationCode").value("ORG-WS-B"))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(3))
-                .andExpect(jsonPath("$.data.totals.equipmentCount").value(2));
+                .andExpect(jsonPath("$.data.selectedFilters.district").value(1))
+                .andExpect(jsonPath("$.data.selectedFilters.districtName").value("城北"))
+                .andExpect(jsonPath("$.data.selectedFilters.districtFilter").value("ASSIGNED"))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(1))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"))
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(1)))
+                .andExpect(jsonPath("$.data.districtIndustry[0].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(1));
     }
 
     @Test
-    void organizationCodeFilterSelectsSingleUnit() throws Exception {
+    void unassignedDistrictFilterSelectsOnlyUnassignedRows() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
-        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-GAS-A"))
+        mockMvc.perform(get(ENDPOINT).queryParam("district", "UNASSIGNED"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.selectedFilters.organizationCode").value("ORG-GAS-A"))
-                .andExpect(jsonPath("$.data.organizations", hasSize(1)))
-                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-GAS-A"))
-                .andExpect(jsonPath("$.data.organizations[0].organizationName").value("燃气公司"))
-                .andExpect(jsonPath("$.data.organizations[0].industry").value("GAS"))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
-                .andExpect(jsonPath("$.data.totals.equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-GAS-A"));
+                .andExpect(jsonPath("$.data.selectedFilters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.selectedFilters.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.selectedFilters.districtFilter").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(3)))
+                .andExpect(jsonPath("$.data.districtIndustry[0].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[0].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.districtIndustry[1].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[1].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.districtIndustry[2].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[2].industry").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(2))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(2));
     }
 
     @Test
-    void unmaintainedCodeFilterSelectsOnlyTheUnmaintainedBucket() throws Exception {
+    void dirtyDistrictValuesAreNormalizedToUnassignedInsteadOfInventingDistricts() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
-        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "UNMAINTAINED"))
+        mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.organizations", hasSize(1)))
-                .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("UNMAINTAINED"))
-                .andExpect(jsonPath("$.data.organizations[0].unmaintained").value(true))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(1))
-                .andExpect(jsonPath("$.data.totals.equipmentCount").value(1));
+                // 片区 99 是脏值 → 与 null 合并进同一「未分区 GAS」桶,不得冒充第 4 个片区。
+                .andExpect(jsonPath("$.data.districtIndustry[3].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtIndustry[3].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtIndustry[3].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.districtIndustry[3].equipmentCount").value(1))
+                // 合法片区只有 0/1 两行,没有 99。
+                .andExpect(jsonPath("$.data.districtIndustry[?(@.district == 99)]", hasSize(0)));
     }
 
     @Test
-    void unknownOrganizationCodeReturnsExplicitEmptyResult() throws Exception {
+    void unknownIndustryCombinationReturnsExplicitEmptyResult() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
-        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-MISSING"))
+        mockMvc.perform(get(ENDPOINT)
+                        .queryParam("industry", "MANHOLE")
+                        .queryParam("district", "0"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.organizations", hasSize(0)))
-                .andExpect(jsonPath("$.data.totals.organizationCount").value(0))
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(0)))
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
                 .andExpect(jsonPath("$.data.totals.equipmentCount").value(0))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(0)))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RELATIONS"));
     }
 
     @Test
@@ -215,11 +224,11 @@ class CockpitInfrastructureManagementInformationControllerTest {
 
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.organizations", hasSize(0)))
-                .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(0)))
-                .andExpect(jsonPath("$.data.totals.organizationCount").value(0))
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(0)))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
+                .andExpect(jsonPath("$.data.totals.equipmentCount").value(0))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RELATIONS"));
     }
 
     @Test
@@ -233,6 +242,32 @@ class CockpitInfrastructureManagementInformationControllerTest {
         verifyNoInteractions(adapter);
     }
 
+    @Test
+    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).queryParam("district", "城区A"))
+                .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).queryParam("district", "3"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void legacyOrganizationCodeParameterIsNoLongerAContract() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        // 旧组织入参已删除:不再被当成筛选条件(绑定层忽略未知入参),响应里也没有组织字段。
+        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "ORG-GAS-A"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.districtIndustry", hasSize(5)))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes").doesNotExist());
+    }
+
     @Test
     void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(post(ENDPOINT))
@@ -266,21 +301,30 @@ class CockpitInfrastructureManagementInformationControllerTest {
         }
     }
 
+    /**
+     * 片区×行业用例:
+     * 城南 WSS 设施2/设备1;城北 GAS 设施1/设备1;未分区 DRAINAGE 设施1/设备1、
+     * MANHOLE 设施1/设备0;脏值片区 99 的 GAS 设备1 归一为未分区 GAS。
+     */
     private static CockpitInfrastructureManagementInformationSnapshot fixture() {
-        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations = new ArrayList<>();
-        relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
-        relations.add(relation("F-WS-002", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
-        relations.add(relation("E-WS-001", "SMART_DEVICE", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "LEASED"));
-        relations.add(relation("F-WS-003", "FACILITY", "WATER_SUPPLY", "ORG-WS-B", "供水二公司", "ENTRUSTED"));
-        relations.add(relation("E-WS-002", "SMART_DEVICE", "WATER_SUPPLY", "ORG-WS-B", "供水二公司", null));
-        relations.add(relation("F-GAS-001", "FACILITY", "GAS", "ORG-GAS-A", "燃气公司", "OWNED"));
-        relations.add(relation("E-GAS-001", "SMART_DEVICE", "GAS", "ORG-GAS-A", "燃气公司", "OWNED"));
-        relations.add(relation("F-DR-001", "FACILITY", "DRAINAGE", null, null, "OWNED"));
-        relations.add(relation("E-DR-001", "SMART_DEVICE", "DRAINAGE", "   ", null, null));
-        relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
-        relations.add(relation("F-MH-001", "FACILITY", "MANHOLE", "ORG-MH-A", "井盖公司", "OWNED"));
-        relations.add(relation("E-XX-001", "SMART_DEVICE", null, "ORG-WS-A", "供水一公司", "OWNED"));
-        relations.add(relation("E-XX-002", "SENSOR", "WATER_SUPPLY", "ORG-WS-A", "供水一公司", "OWNED"));
+        List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations =
+                new ArrayList<>();
+        relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", 0));
+        relations.add(relation("F-WS-002", "FACILITY", "WATER_SUPPLY", 0));
+        relations.add(relation("E-WS-001", "SMART_DEVICE", "WATER_SUPPLY", 0));
+        relations.add(relation("F-GAS-001", "FACILITY", "GAS", 1));
+        relations.add(relation("E-GAS-001", "SMART_DEVICE", "GAS", 1));
+        relations.add(relation("F-DR-001", "FACILITY", "DRAINAGE", null));
+        relations.add(relation("E-DR-001", "SMART_DEVICE", "DRAINAGE", null));
+        relations.add(relation("F-MH-001", "FACILITY", "MANHOLE", null));
+        // 重复对象:只应计一次,并计入 duplicateRelationCount。
+        relations.add(relation("F-WS-001", "FACILITY", "WATER_SUPPLY", 0));
+        // 行业缺失:计入 unknownIndustryCount。
+        relations.add(relation("E-XX-001", "SMART_DEVICE", null, 0));
+        // 对象类型不识别:计入 unknownObjectKindCount。
+        relations.add(relation("E-XX-002", "SENSOR", "WATER_SUPPLY", 0));
+        // 库中脏片区值:归一为未分区,不得冒充第 4 个片区。
+        relations.add(relation("E-GAS-DIRTY", "SMART_DEVICE", "GAS", 99));
         return new CockpitInfrastructureManagementInformationSnapshot(
                 "2026-09-02T09:59:50+08:00", relations);
     }
@@ -289,10 +333,8 @@ class CockpitInfrastructureManagementInformationControllerTest {
             String objectCode,
             String objectKind,
             String industry,
-            String organizationCode,
-            String organizationName,
-            String ownershipType) {
+            Integer district) {
         return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
-                objectCode, objectKind, industry, organizationCode, organizationName, ownershipType);
+                objectCode, objectKind, industry, district);
     }
 }

+ 41 - 17
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java

@@ -85,17 +85,21 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(3))
                 .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
                 .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(2))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].region").value("城区B"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].district").value(0))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].districtName").value("城南"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].typeCode").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].typeName").value("供水压力计"))
-                .andExpect(jsonPath("$.data.monitoringPoints[2].region").value("未分区"))
+                .andExpect(jsonPath("$.data.monitoringPoints[2].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.monitoringPoints[2].districtName").value("未分区"))
                 .andExpect(jsonPath("$.data.monitoringPoints[2].industry").value("DRAINAGE"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[0].region").value("城区B"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].district").value(0))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].districtName").value("城南"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].typeCode").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.region").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(3)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)));
@@ -123,19 +127,39 @@ class CockpitMapSnapshotControllerTest {
     }
 
     @Test
-    void regionFilterMatchesNormalizedUnassignedRegion() throws Exception {
+    void districtFilterMatchesNormalizedUnassignedDistrict() throws Exception {
         when(adapter.load(null)).thenReturn(fixture());
 
-        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("region", "未分区"))
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("district", "UNASSIGNED"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.total").value(2))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-003"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].region").value("未分区"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].districtName").value("未分区"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-002"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.region").value("未分区"));
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("UNASSIGNED"));
+    }
+
+    @Test
+    void districtFilterMatchesAssignedDistrict() throws Exception {
+        when(adapter.load(null)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("district", "0"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.mapFilter.district").value(0))
+                .andExpect(jsonPath("$.data.mapFilter.districtName").value("城南"))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"));
+    }
+
+    @Test
+    void unknownDistrictIsRejectedWithBadRequest() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("district", "99"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
     }
 
     @Test
@@ -408,7 +432,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-002",
                                 "燃气调压站监测点",
-                                "城区A",
+                                0,
                                 "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
@@ -418,7 +442,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-001",
                                 "供水干管监测点",
-                                "城区B",
+                                0,
                                 "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("110.141"),
                                 new BigDecimal("28.452"),
@@ -437,7 +461,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-ASSOC-001",
                                 "燃气浓度计-001",
-                                "城区A",
+                                0,
                                 "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
@@ -453,7 +477,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-001",
                                 "供水压力计-001",
-                                "城区B",
+                                0,
                                 "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("181"),
                                 new BigDecimal("28.455"),
@@ -461,7 +485,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-003",
                                 "污水环境监测仪-003",
-                                "城区C",
+                                2,
                                 "SEWAGE_ENV",
                                 new BigDecimal("110.146"),
                                 new BigDecimal("28.457"),
@@ -469,7 +493,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-004",
                                 "零坐标设备-004",
-                                "城区A",
+                                1,
                                 "GAS_SENSOR",
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
@@ -490,15 +514,15 @@ class CockpitMapSnapshotControllerTest {
 
     private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode,
             List<CockpitMonitoringPointDeviceSnapshot> devices) {
-        return sourcePoint(pointCode, "城区A", "GAS_SENSOR", devices);
+        return sourcePoint(pointCode, 0, "GAS_SENSOR", devices);
     }
 
-    private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode, String region,
+    private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode, Integer district,
             String typeCode, List<CockpitMonitoringPointDeviceSnapshot> devices) {
         return new CockpitMapSnapshot.SourceMonitoringPoint(
                 pointCode,
                 pointCode + "监测点",
-                region,
+                district,
                 typeCode,
                 new BigDecimal("110.141"),
                 new BigDecimal("28.452"),

+ 72 - 35
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java

@@ -63,7 +63,7 @@ class CockpitMonitoringDistributionControllerTest {
     }
 
     @Test
-    void distributionExposesEnvelopeSixStateDistributionRegionIndustryTypeAndMapFilter() throws Exception {
+    void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("snapshotAt", SNAPSHOT_AT)
                         .header("X-Request-Id", "monitoring-distribution-001"))
@@ -111,16 +111,19 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
-                .andExpect(jsonPath("$.data.regionDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.regionDistribution[0].region").value("城区A"))
-                .andExpect(jsonPath("$.data.regionDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.regionDistribution[0].deviceCount").value(4))
-                .andExpect(jsonPath("$.data.regionDistribution[1].region").value("城区B"))
-                .andExpect(jsonPath("$.data.regionDistribution[1].pointCount").value(2))
-                .andExpect(jsonPath("$.data.regionDistribution[1].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.regionDistribution[2].region").value("城区C"))
-                .andExpect(jsonPath("$.data.regionDistribution[2].pointCount").value(1))
-                .andExpect(jsonPath("$.data.regionDistribution[2].deviceCount").value(2))
+                .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
+                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
+                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
+                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(4))
+                .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
+                .andExpect(jsonPath("$.data.districtDistribution[1].pointCount").value(2))
+                .andExpect(jsonPath("$.data.districtDistribution[1].deviceCount").value(2))
+                .andExpect(jsonPath("$.data.districtDistribution[2].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtDistribution[2].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtDistribution[2].pointCount").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution[2].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
@@ -137,7 +140,9 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.region").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
                 .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(5)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(8)))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
@@ -200,18 +205,48 @@ class CockpitMonitoringDistributionControllerTest {
     }
 
     @Test
-    void regionFilterNarrowsDistributionAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("region", "城区B"))
+    void districtFilterNarrowsDistributionAndMapFilter() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("district", "1"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(3))
-                .andExpect(jsonPath("$.data.mapFilter.region").value("城区B"))
-                .andExpect(jsonPath("$.data.regionDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.regionDistribution[0].region").value("城区B"))
-                .andExpect(jsonPath("$.data.regionDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.regionDistribution[0].deviceCount").value(2))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(1))
+                .andExpect(jsonPath("$.data.mapFilter.districtName").value("城北"))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"))
+                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
+                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城北"))
+                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
+                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.industryDistribution", hasSize(2)));
     }
 
+    @Test
+    void unassignedDistrictFilterSelectsOnlyUnassignedAndEchoesFilterFlag() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.total").value(2))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
+                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
+                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(1))
+                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2));
+    }
+
+    @Test
+    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("district", "城区B"))
+                .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"));
+
+        verifyNoInteractions(adapter);
+    }
+
     @Test
     void unknownDeviceTypeReturnsExplicitEmptyDistribution() throws Exception {
         mockMvc.perform(get(ENDPOINT).param("deviceType", "NOT_A_TYPE"))
@@ -387,52 +422,54 @@ class CockpitMonitoringDistributionControllerTest {
                         new CockpitMonitoringDistributionSnapshot.SourceType(
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                 Arrays.asList(
-                        point("P-WS-1", "供水监测点-1", "城区B", "WATER_SUPPLY_SENSOR",
+                        point("P-WS-1", "供水监测点-1", 1, "WATER_SUPPLY_SENSOR",
                                 "110.150", "28.460", device("WS-001", "供水压力计-001",
                                         true, false, false, true, Collections.emptyList())),
-                        point("P-WS-2", "供水监测点-2", "城区B", "WATER_SUPPLY_SENSOR",
+                        point("P-WS-2", "供水监测点-2", 1, "WATER_SUPPLY_SENSOR",
                                 "110.151", "28.461"),
-                        point("P-GAS-1", "燃气监测点-1", "城区A", "GAS_SENSOR",
+                        point("P-GAS-1", "燃气监测点-1", 0, "GAS_SENSOR",
                                 "110.130", "28.440",
                                 device("GAS-001", "燃气浓度计-001",
                                         true, false, false, false, Collections.emptyList()),
                                 device("GAS-002", "燃气浓度计-002",
                                         true, true, false, false, Collections.singletonList("浓度超限"))),
-                        point("P-GAS-2", "燃气监测点-2", "城区A", "GAS_SENSOR",
+                        point("P-GAS-2", "燃气监测点-2", 0, "GAS_SENSOR",
                                 "110.131", "28.441", device("GAS-003", "燃气浓度计-003",
                                         false, false, false, false, Collections.emptyList())),
-                        point("P-DR-1", "污水监测点-1", "城区C", "SEWAGE_ENV",
+                        // 快照里点位片区为 null(如实未分区)→ 与脏值设备一起落入未分区桶
+                        point("P-DR-1", "污水监测点-1", null, "SEWAGE_ENV",
                                 "110.170", "28.480", device("DRAIN-001", "污水环境监测仪-001",
                                         true, false, true, false, Collections.singletonList("浓度预警"))),
-                        point("P-MISMATCH", "水厂设施点", "城区A", "WATER_PLANT",
+                        point("P-MISMATCH", "水厂设施点", 0, "WATER_PLANT",
                                 "110.120", "28.430", device("MISMATCH-001", "水厂设备",
                                         true, false, false, false, Collections.emptyList())),
-                        point("P-UNKNOWN", "未知类型点", "城区A", "LEGACY",
+                        point("P-UNKNOWN", "未知类型点", 0, "LEGACY",
                                 "110.121", "28.431", device("UNKNOWN-001", "旧设备",
                                         true, false, false, false, Collections.emptyList()))),
                 Arrays.asList(
-                        smartDevice("EQ-FREE-003", "压力井盖外设备", "城区C", "WATER_SUPPLY_SENSOR",
+                        // 脏值片区(7)不得冒充合法片区,归一后并入未分区
+                        smartDevice("EQ-FREE-003", "压力井盖外设备", 7, "WATER_SUPPLY_SENSOR",
                                 "110.171", "28.481", true,
                                 false, false, false, false, Arrays.asList("离线")),
-                        smartDevice("EQ-FREE-001", "井盖外燃气计", "城区A", "GAS_SENSOR",
+                        smartDevice("EQ-FREE-001", "井盖外燃气计", 0, "GAS_SENSOR",
                                 "110.132", "28.442", true,
                                 true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-002", "污水环境监测-独立", "城区B", "SEWAGE_ENV",
+                        smartDevice("EQ-FREE-002", "污水环境监测-独立", 1, "SEWAGE_ENV",
                                 "110.152", "28.462", true,
                                 true, false, true, false, Collections.singletonList("设备预警")),
-                        smartDevice("EQ-FREE-004", "智能井盖独立", "城区A", "SMART_MANHOLE",
+                        smartDevice("EQ-FREE-004", "智能井盖独立", 0, "SMART_MANHOLE",
                                 "110.133", "28.443", true,
                                 true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-005", "越界坐标设备", "城区A", "GAS_SENSOR",
+                        smartDevice("EQ-FREE-005", "越界坐标设备", 0, "GAS_SENSOR",
                                 "181", "28.444", true,
                                 true, false, false, false, Collections.emptyList())));
     }
 
     private static CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point(
-            String code, String name, String region, String typeCode,
+            String code, String name, Integer district, String typeCode,
             String longitude, String latitude, CockpitMonitoringPointDeviceSnapshot... devices) {
         return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                code, name, region, typeCode,
+                code, name, district, typeCode,
                 new BigDecimal(longitude), new BigDecimal(latitude), true,
                 Arrays.asList(devices));
     }
@@ -446,12 +483,12 @@ class CockpitMonitoringDistributionControllerTest {
     }
 
     private static CockpitMonitoringDistributionSnapshot.SourceSmartDevice smartDevice(
-            String code, String name, String region, String typeCode,
+            String code, String name, Integer district, String typeCode,
             String longitude, String latitude, boolean verified,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             List<String> statusReasons) {
         return new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
-                code, name, region, typeCode,
+                code, name, district, typeCode,
                 new BigDecimal(longitude), new BigDecimal(latitude), verified,
                 online, alarm, warning, workOrderInProgress, statusReasons, SNAPSHOT_AT);
     }

+ 20 - 13
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java

@@ -122,6 +122,11 @@ class CockpitMonitoringSituationControllerTest {
                 .andExpect(jsonPath("$.data.abnormalEquipment[1].abnormalType").value("WARNING"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[4].deviceCode").value("D-WS-2"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[4].abnormalType").value("OFFLINE"))
+                // 片区双字段:编码 + 中文名;无来源时编码 null、名称「未分区」
+                .andExpect(jsonPath("$.data.abnormalEquipment[0].district").value(0))
+                .andExpect(jsonPath("$.data.abnormalEquipment[0].districtName").value("城南"))
+                .andExpect(jsonPath("$.data.abnormalEquipment[4].district").value(nullValue()))
+                .andExpect(jsonPath("$.data.abnormalEquipment[4].districtName").value("未分区"))
                 .andExpect(jsonPath("$.data.pagination.page").value(1))
                 .andExpect(jsonPath("$.data.pagination.pageSize").value(100))
                 .andExpect(jsonPath("$.data.pagination.total").value(5))
@@ -401,27 +406,29 @@ class CockpitMonitoringSituationControllerTest {
                                 "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                         type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
-                        device("D-GAS-1", "燃气浓度计-001", "城区A", "GAS_SENSOR",
+                        device("D-GAS-1", "燃气浓度计-001", 0, "GAS_SENSOR",
                                 "NORMAL", Collections.emptyList(), "110.130", "28.440", true),
-                        device("D-GAS-2", "燃气浓度计-002", "城区A", "GAS_SENSOR",
+                        device("D-GAS-2", "燃气浓度计-002", 0, "GAS_SENSOR",
                                 "WARNING", Collections.singletonList("流量波动"), "110.131", "28.441", true),
-                        device("D-GAS-3", "燃气浓度计-003", "城区A", "GAS_SENSOR",
+                        device("D-GAS-3", "燃气浓度计-003", 0, "GAS_SENSOR",
                                 "ALARM", Collections.singletonList("浓度超限"), "110.132", "28.442", true),
-                        device("D-WS-1", "供水压力计-001", "城区B", "WATER_SUPPLY_SENSOR",
+                        device("D-WS-1", "供水压力计-001", 1, "WATER_SUPPLY_SENSOR",
                                 "EXPIRED", Collections.singletonList("采样过期"), "110.150", "28.460", true),
-                        device("D-WS-2", "供水压力计-002", "城区B", "WATER_SUPPLY_SENSOR",
+                        // 台账未登记片区 → 未分区(null),如实呈现而非猜一个片区
+                        device("D-WS-2", "供水压力计-002", null, "WATER_SUPPLY_SENSOR",
                                 "OFFLINE", Collections.singletonList("离线"), "110.151", "28.461", true),
-                        device("D-DR-1", "污水环境监测仪-001", "城区C", "SEWAGE_ENV",
+                        // 无片区来源 → 未分区(null),如实呈现而非猜一个片区
+                        device("D-DR-1", "污水环境监测仪-001", null, "SEWAGE_ENV",
                                 "NORMAL", Collections.emptyList(), "110.170", "28.480", true),
-                        device("D-DR-2", "污水环境监测仪-002", "城区C", "SEWAGE_ENV",
+                        device("D-DR-2", "污水环境监测仪-002", null, "SEWAGE_ENV",
                                 "UNKNOWN", Collections.singletonList("状态缺失"), "110.171", "28.481", true),
-                        device("D-MISMATCH", "水厂设备", "城区A", "WATER_PLANT",
+                        device("D-MISMATCH", "水厂设备", 0, "WATER_PLANT",
                                 "NORMAL", Collections.emptyList(), "110.120", "28.430", true),
-                        device("D-UNKNOWN", "旧设备", "城区A", "LEGACY",
+                        device("D-UNKNOWN", "旧设备", 0, "LEGACY",
                                 "NORMAL", Collections.emptyList(), "110.121", "28.431", true),
-                        device("D-MH", "智能井盖", "城区A", "SMART_MANHOLE",
+                        device("D-MH", "智能井盖", 2, "SMART_MANHOLE",
                                 "NORMAL", Collections.emptyList(), "110.122", "28.432", true),
-                        device("D-EXCL-GAS", "越界坐标燃气计", "城区A", "GAS_SENSOR",
+                        device("D-EXCL-GAS", "越界坐标燃气计", 0, "GAS_SENSOR",
                                 "ALARM", Collections.singletonList("浓度超限"), "181", "28.443", true)),
                 Arrays.asList(
                         event("W-GAS-1", "一级", "2026-09-02T00:30:00+08:00",
@@ -444,10 +451,10 @@ class CockpitMonitoringSituationControllerTest {
     }
 
     private static CockpitMonitoringSituationSnapshot.SourceMetricDevice device(
-            String code, String name, String region, String typeCode, String metricStatus,
+            String code, String name, Integer district, String typeCode, String metricStatus,
             List<String> reasons, String longitude, String latitude, boolean verified) {
         return new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
-                code, name, region, typeCode, metricStatus, reasons,
+                code, name, district, typeCode, metricStatus, reasons,
                 "2026-09-02T09:59:00+08:00",
                 new BigDecimal(longitude), new BigDecimal(latitude), verified);
     }

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

@@ -78,7 +78,7 @@ class CockpitMultidimensionalAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.dimensions[2].dimensionCode").value("INDUSTRY"))
                 .andExpect(jsonPath("$.data.dimensions[3].dimensionCode").value("DEVICE_TYPE"))
                 .andExpect(jsonPath("$.data.dimensions[4].dimensionCode").value("LEVEL"))
-                .andExpect(jsonPath("$.data.dimensions[5].dimensionCode").value("REGION"))
+                .andExpect(jsonPath("$.data.dimensions[5].dimensionCode").value("DISTRICT"))
                 .andExpect(jsonPath("$.data.dimensions[6].dimensionCode").value("TIME"))
                 .andExpect(jsonPath("$.data.range.type").value("ANALYSIS_WINDOW"))
                 .andExpect(jsonPath("$.data.range.startTime").value(START))
@@ -186,7 +186,7 @@ class CockpitMultidimensionalAnalysisControllerTest {
     }
 
     @Test
-    void causeRegionEventTypeAndDeviceTypeFiltersNarrowConsistently() throws Exception {
+    void causeDistrictEventTypeAndDeviceTypeFiltersNarrowConsistently() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("cause", "浓度超限")
@@ -198,12 +198,23 @@ class CockpitMultidimensionalAnalysisControllerTest {
 
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
-                        .param("region", "城区B")
+                        .param("district", "1")
                         .param("startTime", START)
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.totalCount").value(2))
-                .andExpect(jsonPath("$.data.filters.region").value("城区B"));
+                .andExpect(jsonPath("$.data.filters.district").value(1))
+                .andExpect(jsonPath("$.data.filters.districtName").value("城北"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "UNASSIGNED")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(1))
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value("未分区"));
 
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
@@ -224,6 +235,80 @@ class CockpitMultidimensionalAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.filters.deviceType").value("WATER_SUPPLY_SENSOR"));
     }
 
+    /** 片区维度按归一后的编码分桶,未分区独立成桶;枚举名与筛选入参同词表。 */
+    @Test
+    void districtDimensionBucketsByCodeWithAnExplicitUnassignedBucket() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.dimensions[5].dimensionCode").value("DISTRICT"))
+                .andExpect(jsonPath("$.data.dimensions[5].dimensionLabel").value("片区"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets", hasSize(4)))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[0].bucketCode").value("0"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[0].bucketLabel").value("城南"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[0].count").value(3))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[1].bucketCode").value("1"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[1].bucketLabel").value("城北"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[1].count").value(2))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[2].bucketCode").value("2"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[2].bucketLabel").value("太常"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[2].count").value(2))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[3].bucketCode").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[3].bucketLabel").value("未分区"))
+                .andExpect(jsonPath("$.data.dimensions[5].buckets[3].count").value(1));
+    }
+
+    /** district 认不出的值一律 400,且在触达适配器之前就被拒。 */
+    @Test
+    void unknownDistrictIsRejectedWithBadRequestBeforeAdapter() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "城区B")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .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("eventFamily", "WARNING")
+                        .param("district", "9")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("district", "-1")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("district must be 0, 1, 2 or UNASSIGNED"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    /** 旧自由串 region 不再是正式契约:入参被忽略(不筛),响应里也不再有 region 字段。 */
+    @Test
+    void legacyRegionParameterIsIgnoredAndAbsentFromTheResponse() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("region", "城区B")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(8))
+                .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.districtName").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.region").doesNotExist())
+                .andExpect(jsonPath("$.data.mapFilter.region").doesNotExist());
+    }
+
     @Test
     void emptyWindowKeepsEveryDimensionWithZeroPercentages() throws Exception {
         mockMvc.perform(get(ENDPOINT)
@@ -374,32 +459,32 @@ class CockpitMultidimensionalAnalysisControllerTest {
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                 Arrays.asList(
                         warning("MW-001", "GAS_CONCENTRATION", "浓度超限", 1,
-                                "2026-09-01T09:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-1"),
+                                "2026-09-01T09:00:00+08:00", 0, "GAS_SENSOR", "D-GAS-1"),
                         warning("MW-002", "GAS_CONCENTRATION", "浓度超限", 1,
-                                "2026-09-01T10:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-2"),
+                                "2026-09-01T10:00:00+08:00", 0, "GAS_SENSOR", "D-GAS-2"),
                         warning("MW-003", "WATER_PRESSURE", null, 2,
-                                "2026-09-01T11:00:00+08:00", "城区B", "WATER_SUPPLY_SENSOR", "D-WS-1"),
+                                "2026-09-01T11:00:00+08:00", 1, "WATER_SUPPLY_SENSOR", "D-WS-1"),
                         warning("MW-004", "WATER_PRESSURE", "压力波动", 2,
-                                "2026-09-02T09:00:00+08:00", "城区B", "WATER_SUPPLY_SENSOR", "D-WS-2"),
+                                "2026-09-02T09:00:00+08:00", 1, "WATER_SUPPLY_SENSOR", "D-WS-2"),
                         warning("MW-005", "SEWAGE_CONCENTRATION", "浓度超限", 2,
-                                "2026-09-02T10:00:00+08:00", "城区C", "SEWAGE_ENV", "D-DR-1"),
+                                "2026-09-02T10:00:00+08:00", 2, "SEWAGE_ENV", "D-DR-1"),
                         warning("MW-006", "SEWAGE_CONCENTRATION", "设备故障", 3,
-                                "2026-09-02T11:00:00+08:00", "城区C", "SEWAGE_ENV", "D-DR-2"),
+                                "2026-09-02T11:00:00+08:00", 2, "SEWAGE_ENV", "D-DR-2"),
                         warning("MW-007", "WATER_PRESSURE", null, 4,
                                 "2026-09-02T12:00:00+08:00", null, "WATER_SUPPLY_SENSOR", "D-WS-3"),
                         warning("MW-008", "GAS_CONCENTRATION", "设备故障", 9,
-                                "2026-09-02T13:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-3"),
+                                "2026-09-02T13:00:00+08:00", 0, "GAS_SENSOR", "D-GAS-3"),
                         warning("MW-LEGACY", "LEGACY_TYPE", "old", 1,
-                                "2026-09-02T13:30:00+08:00", "城区A", "LEGACY", "D-LEGACY"),
+                                "2026-09-02T13:30:00+08:00", 0, "LEGACY", "D-LEGACY"),
                         warning("MW-MISMATCH", "FACILITY_TYPE", "x", 1,
-                                "2026-09-02T13:40:00+08:00", "城区A", "WATER_PLANT", "D-PLANT"),
+                                "2026-09-02T13:40:00+08:00", 0, "WATER_PLANT", "D-PLANT"),
                         warning("MW-MH", "MANHOLE_TYPE", "x", 1,
-                                "2026-09-02T13:50:00+08:00", "城区A", "SMART_MANHOLE", "D-MH")),
+                                "2026-09-02T13:50:00+08:00", 0, "SMART_MANHOLE", "D-MH")),
                 Arrays.asList(
                         alarm("MA-001", "GAS_CONCENTRATION", "浓度超限", 1,
-                                "2026-09-02T14:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-9"),
+                                "2026-09-02T14:00:00+08:00", 0, "GAS_SENSOR", "D-GAS-9"),
                         alarm("MA-002", "WATER_PRESSURE", null, 2,
-                                "2026-09-02T15:00:00+08:00", "城区B", "WATER_SUPPLY_SENSOR", "D-WS-9")));
+                                "2026-09-02T15:00:00+08:00", 1, "WATER_SUPPLY_SENSOR", "D-WS-9")));
     }
 
     private static CockpitMultidimensionalAnalysisSnapshot.SourceType type(
@@ -410,15 +495,15 @@ class CockpitMultidimensionalAnalysisControllerTest {
 
     private static CockpitMultidimensionalAnalysisSnapshot.SourceWarning warning(
             String no, String eventType, String cause, Integer level, String eventTime,
-            String region, String typeCode, String deviceCode) {
+            Integer district, String typeCode, String deviceCode) {
         return new CockpitMultidimensionalAnalysisSnapshot.SourceWarning(
-                no, eventType, cause, level, eventTime, region, typeCode, deviceCode);
+                no, eventType, cause, level, eventTime, district, typeCode, deviceCode);
     }
 
     private static CockpitMultidimensionalAnalysisSnapshot.SourceAlarm alarm(
             String id, String eventType, String cause, Integer level, String eventTime,
-            String region, String typeCode, String deviceCode) {
+            Integer district, String typeCode, String deviceCode) {
         return new CockpitMultidimensionalAnalysisSnapshot.SourceAlarm(
-                id, eventType, cause, level, eventTime, region, typeCode, deviceCode);
+                id, eventType, cause, level, eventTime, district, typeCode, deviceCode);
     }
 }

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

@@ -25,6 +25,7 @@ import java.util.Collections;
 import java.util.List;
 
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
@@ -123,6 +124,48 @@ class CockpitWorkOrderReadControllerTest {
                 .andExpect(jsonPath("$.data.workOrders[1].eventAssociation.eventCode").value("ALM-WS_ENV_PH-002"));
     }
 
+    /**
+     * 执行票 12 范围 1:「设备详情展示该设备已有全部工单,其中未与当前报警关联的工单必须明确提示」。
+     * 该提示是**显式**的 —— 每张工单带 associatedWithCurrentEvent,另有顶层未关联计数,
+     * 调用方不必从 eventAssociation == null 反推。
+     */
+    @Test
+    void deviceOrdersFlagUnassociatedWorkOrdersExplicitly() throws Exception {
+        when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(new CockpitWorkOrderReadSnapshot(
+                DEVICE_CODE,
+                null,
+                null,
+                Arrays.asList(
+                        summary("WO-ASSOCIATED", 5, "ALARM", "ALM-20260902-001"),
+                        unassociated("WO-LINKED-OTHER", 1),
+                        unassociated("WO-NO-ALARM", 3))));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.workOrders", hasSize(3)))
+                // 已关联报警的那张:显式 true 且带回关联信息
+                .andExpect(jsonPath("$.data.workOrders[0].workOrderCode").value("WO-ASSOCIATED"))
+                .andExpect(jsonPath("$.data.workOrders[0].associatedWithCurrentEvent").value(true))
+                .andExpect(jsonPath("$.data.workOrders[0].eventAssociation.eventCode").value("ALM-20260902-001"))
+                // 未关联的两张:显式 false(而非仅 eventAssociation 为 null)
+                .andExpect(jsonPath("$.data.workOrders[1].associatedWithCurrentEvent").value(false))
+                .andExpect(jsonPath("$.data.workOrders[1].eventAssociation").value(nullValue()))
+                .andExpect(jsonPath("$.data.workOrders[2].associatedWithCurrentEvent").value(false))
+                .andExpect(jsonPath("$.data.workOrders[2].eventAssociation").value(nullValue()))
+                .andExpect(jsonPath("$.data.unassociatedWorkOrderCount").value(2));
+    }
+
+    /** 事件维度:命中的工单都是「实际关联本事件」,未关联计数恒为 0。 */
+    @Test
+    void eventOrdersAreAllMarkedAssociated() throws Exception {
+        when(adapter.loadByEvent("ALARM", "ALM-20260902-001")).thenReturn(singleAlarmFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/events/ALARM/ALM-20260902-001/work-orders"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.workOrders[0].associatedWithCurrentEvent").value(true))
+                .andExpect(jsonPath("$.data.unassociatedWorkOrderCount").value(0));
+    }
+
     @Test
     void timelinesAreIndependentBetweenWorkOrders() throws Exception {
         when(adapter.loadTimeline("WO-2026090200001")).thenReturn(timelineFixture("WO-2026090200001", "3"));
@@ -225,8 +268,25 @@ class CockpitWorkOrderReadControllerTest {
         return summary(workOrderCode, state, "ALARM", eventCode);
     }
 
-    private CockpitWorkOrderSummary summary(String workOrderCode, int state, String eventSource, String eventCode) {
+    /** 未与任何报警关联的工单(设备已有全部工单里的那一类)。 */
+    private CockpitWorkOrderSummary unassociated(String workOrderCode, int state) {
         CockpitWorkOrderState stateEntry = CockpitWorkOrderStateDictionary.byLegacyCode(state);
+        return new CockpitWorkOrderSummary(
+                workOrderCode,
+                DEVICE_CODE,
+                2,
+                "处理异常",
+                stateEntry.getCode(),
+                stateEntry.getName(),
+                "2026-09-02T09:59:30+08:00",
+                "2026-09-02T10:00:00+08:00",
+                "2026-09-02T09:00:00+08:00",
+                null,
+                null,
+                false);
+    }
+
+    private CockpitWorkOrderSummary summary(String workOrderCode, int state, String eventSource, String eventCode) {        CockpitWorkOrderState stateEntry = CockpitWorkOrderStateDictionary.byLegacyCode(state);
         return new CockpitWorkOrderSummary(
                 workOrderCode,
                 DEVICE_CODE,
@@ -238,7 +298,9 @@ class CockpitWorkOrderReadControllerTest {
                 "2026-09-02T10:00:00+08:00",
                 "2026-09-02T09:00:00+08:00",
                 "2026-09-02T09:30:00+08:00",
-                                new CockpitWorkOrderEventAssociation(eventSource, eventCode, "2026-09-02T10:00:00+08:00"));
+                new CockpitWorkOrderEventAssociation(eventSource, eventCode, "2026-09-02T10:00:00+08:00"),
+                // 事件维度构造的工单即「实际关联本事件」(执行票 12 的显式关联标记)。
+                true);
     }
 
     private CockpitWorkOrderTimelineSnapshot timelineFixture(String workOrderCode, String lastState) {

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

@@ -17,6 +17,7 @@ import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.mapper.EquipmentWarningTypeMapper;
 import com.zksy.base.warning.mapper.WarningTypeMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.service.WorkOrderService;
@@ -42,8 +43,9 @@ import java.util.stream.Collectors;
  * {@code alarm_warning_rel} 回填事件工单关联,避免各端点各自猜测关联键。</p>
  *
  * <p>现有表没有可信的坐标校核字段,事件坐标即使来自记录也始终标记为未校核,
- * 由契约层统一判定为不可渲染。成因、区域和预警实时值在当前模型中没有可靠来源,
- * 保持 {@code null}。</p>
+ * 由契约层统一判定为不可渲染。成因在当前模型中没有可靠来源,保持 {@code null};
+ * 事件片区沿设备解析 {@code equipment_base.district},无设备/未识别时如实为
+ * {@code null}(未分区),不由城名或行业字段冒充。</p>
  */
 @Component
 @Primary
@@ -498,7 +500,7 @@ public class BusinessCockpitEventAdapter {
                         null,
                         level(item.warning.getWarningLevel()),
                         offsetTime(item.warning.getPublishTime()),
-                        null,
+                        districtCode(item.warning.getDeviceCode(), facts),
                         typeCode(item.warning.getDeviceCode(), facts),
                         trimToNull(item.warning.getDeviceCode())))
                 .collect(Collectors.toList());
@@ -512,7 +514,7 @@ public class BusinessCockpitEventAdapter {
                         null,
                         item.alarm.getAlarmLevel(),
                         offsetTime(item.alarm.getCreateTime()),
-                        null,
+                        districtCode(item.alarm.getDeviceCode(), facts),
                         typeCode(item.alarm.getDeviceCode(), facts),
                         trimToNull(item.alarm.getDeviceCode())))
                 .collect(Collectors.toList());
@@ -707,6 +709,16 @@ public class BusinessCockpitEventAdapter {
         return device == null ? null : typeCode(device, facts.typesById);
     }
 
+    /**
+     * 事件片区:沿事件设备解析到 {@code equipment_base.district}(片区权威来源),
+     * 再经 {@link CockpitDistrict#codeOf} 归一。设备缺失或片区未识别时如实回 {@code null}
+     * (=未分区),不由城名、行业或其它字段冒充。
+     */
+    private Integer districtCode(String deviceCode, Facts facts) {
+        EquipmentBase device = facts.devicesByCode.get(normalize(deviceCode));
+        return device == null ? null : CockpitDistrict.codeOf(device.getDistrict());
+    }
+
     private String typeName(EquipmentBase device, Map<Integer, EquipmentType> types) {
         EquipmentType type = device == null || device.getEquipmentTypeId() == null
                 ? null : types.get(device.getEquipmentTypeId());

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

@@ -86,17 +86,19 @@ public class CockpitMultidimensionalAnalysisResponse {
         private final String eventType;
         private final String cause;
         private final String deviceType;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
 
         public Filters(String eventFamily, String industry, Integer level, String eventType,
-                String cause, String deviceType, String region) {
+                String cause, String deviceType, Integer district, String districtName) {
             this.eventFamily = eventFamily;
             this.industry = industry;
             this.level = level;
             this.eventType = eventType;
             this.cause = cause;
             this.deviceType = deviceType;
-            this.region = region;
+            this.district = district;
+            this.districtName = districtName;
         }
 
         public String getEventFamily() { return eventFamily; }
@@ -105,7 +107,13 @@ public class CockpitMultidimensionalAnalysisResponse {
         public String getEventType() { return eventType; }
         public String getCause() { return cause; }
         public String getDeviceType() { return deviceType; }
-        public String getRegion() { return region; }
+        /** 生效片区编码(0-城南 1-城北 2-太常);未筛时为 {@code null}。 */
+        public Integer getDistrict() { return district; }
+        /**
+         * 生效片区展示名:未筛为 {@code null},只看未分区时为「未分区」,按片区筛时为中文片区名。
+         * 用「名是否为 null」即可区分未筛与只看未分区(决策票 06 的双字段口径)。
+         */
+        public String getDistrictName() { return districtName; }
     }
 
     public static class MapFilter {
@@ -115,18 +123,21 @@ public class CockpitMultidimensionalAnalysisResponse {
         private final String eventType;
         private final String cause;
         private final String deviceType;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final List<String> deviceCodes;
 
         public MapFilter(String eventFamily, String industry, Integer level, String eventType,
-                String cause, String deviceType, String region, List<String> deviceCodes) {
+                String cause, String deviceType, Integer district, String districtName,
+                List<String> deviceCodes) {
             this.eventFamily = eventFamily;
             this.industry = industry;
             this.level = level;
             this.eventType = eventType;
             this.cause = cause;
             this.deviceType = deviceType;
-            this.region = region;
+            this.district = district;
+            this.districtName = districtName;
             this.deviceCodes = deviceCodes;
         }
 
@@ -136,7 +147,10 @@ public class CockpitMultidimensionalAnalysisResponse {
         public String getEventType() { return eventType; }
         public String getCause() { return cause; }
         public String getDeviceType() { return deviceType; }
-        public String getRegion() { return region; }
+        /** 生效片区编码;未筛与只看未分区都为 {@code null},靠 {@link #getDistrictName()} 区分。 */
+        public Integer getDistrict() { return district; }
+        /** 生效片区展示名:未筛为 {@code null},只看未分区为「未分区」。 */
+        public String getDistrictName() { return districtName; }
         public List<String> getDeviceCodes() { return deviceCodes; }
     }
 

+ 32 - 24
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisService.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;
@@ -43,8 +44,6 @@ public class CockpitMultidimensionalAnalysisService {
     private static final String UNKNOWN_CAUSE_LABEL = "未知/缺失成因";
     private static final String UNKNOWN_EVENT_TYPE = "UNKNOWN_EVENT_TYPE";
     private static final String UNKNOWN_EVENT_TYPE_LABEL = "未知事件类型";
-    private static final String UNASSIGNED_REGION = "UNASSIGNED_REGION";
-    private static final String UNASSIGNED_REGION_LABEL = "未分区";
     private static final String UNKNOWN_LEVEL = "UNKNOWN";
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long MAX_WINDOW_DAYS = 31;
@@ -72,7 +71,7 @@ public class CockpitMultidimensionalAnalysisService {
             String eventType,
             String cause,
             String deviceType,
-            String region,
+            String district,
             String startTime,
             String endTime) {
         String family = resolveFamily(eventFamily);
@@ -81,7 +80,12 @@ public class CockpitMultidimensionalAnalysisService {
         String selectedType = blankToNull(eventType);
         String selectedCause = blankToNull(cause);
         String selectedDeviceType = blankToNull(deviceType);
-        String selectedRegion = blankToNull(region);
+        // 范围维度=片区:0/1/2 按片区筛、UNASSIGNED 只看未分区、不传=不筛;
+        // 其它值(含旧自由串 region 的任意值)一律 400,脏值不得参与筛选(决策票 06)。
+        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);
         if (state == null) {
             CockpitMultidimensionalAnalysisSnapshot loaded = adapter.load();
@@ -90,7 +94,7 @@ public class CockpitMultidimensionalAnalysisService {
                     : loaded;
         }
         return build(requestId, family, selectedIndustry, selectedLevel, selectedType,
-                selectedCause, selectedDeviceType, selectedRegion, window);
+                selectedCause, selectedDeviceType, selectedDistrict, window);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -114,7 +118,7 @@ public class CockpitMultidimensionalAnalysisService {
             String eventType,
             String cause,
             String deviceType,
-            String region,
+            CockpitDistrict.Query district,
             TimeWindow window) {
         Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types = types(state);
         List<Fact> facts = WARNING.equals(family)
@@ -141,7 +145,7 @@ public class CockpitMultidimensionalAnalysisService {
             if (deviceType != null && !deviceType.equals(fact.deviceType)) {
                 continue;
             }
-            if (region != null && !region.equals(fact.regionKey())) {
+            if (!CockpitDistrict.matches(district, fact.district)) {
                 continue;
             }
             scoped.add(fact);
@@ -161,7 +165,7 @@ public class CockpitMultidimensionalAnalysisService {
         dimensions.add(dimension("INDUSTRY", "行业", total, industryBuckets(scoped)));
         dimensions.add(dimension("DEVICE_TYPE", "设备类型", total, deviceTypeBuckets(scoped, types)));
         dimensions.add(dimension("LEVEL", "等级", total, levelBuckets(scoped)));
-        dimensions.add(dimension("REGION", "区域", total, regionBuckets(scoped)));
+        dimensions.add(dimension("DISTRICT", "片区", total, districtBuckets(scoped)));
         dimensions.add(dimension("TIME", "时间", total, timeBuckets(scoped, window)));
 
         return new CockpitMultidimensionalAnalysisResponse(
@@ -177,10 +181,13 @@ public class CockpitMultidimensionalAnalysisService {
                         timestamp(window.end)),
                 isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt(),
                 new CockpitMultidimensionalAnalysisResponse.Filters(
-                        family, industry, level, eventType, cause, deviceType, region),
+                        family, industry, level, eventType, cause, deviceType,
+                        district.code(), district.active() ? CockpitDistrict.nameOf(district.code()) : null),
                 total,
                 new CockpitMultidimensionalAnalysisResponse.MapFilter(
-                        family, industry, level, eventType, cause, deviceType, region, sorted(deviceCodes)),
+                        family, industry, level, eventType, cause, deviceType,
+                        district.code(), district.active() ? CockpitDistrict.nameOf(district.code()) : null,
+                        sorted(deviceCodes)),
                 dimensions);
     }
 
@@ -275,14 +282,19 @@ public class CockpitMultidimensionalAnalysisService {
                 Comparator.comparingInt(order::indexOf));
     }
 
-    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> regionBuckets(List<Fact> facts) {
+    /**
+     * 片区桶:按归一后的片区编码计数({@code 0/1/2}),未分区(含脏值/缺失)统一落到
+     * {@link CockpitDistrict#UNASSIGNED_KEY} 哨兵桶、标签「未分区」——如实呈现,不猜片区。
+     * 桶码与筛选入参同词表,前端可直接回传。
+     */
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> districtBuckets(List<Fact> facts) {
         Map<String, Integer> counts = new LinkedHashMap<>();
         Map<String, String> labels = new LinkedHashMap<>();
         for (Fact fact : facts) {
-            String code = fact.regionKey();
-            String label = isBlank(fact.region) ? UNASSIGNED_REGION_LABEL : fact.region.trim();
-            counts.merge(code, 1, Integer::sum);
-            labels.putIfAbsent(code, label);
+            Integer code = CockpitDistrict.codeOf(fact.district);
+            String key = code == null ? CockpitDistrict.UNASSIGNED_KEY : String.valueOf(code);
+            counts.merge(key, 1, Integer::sum);
+            labels.putIfAbsent(key, CockpitDistrict.nameOf(code));
         }
         return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
     }
@@ -355,7 +367,7 @@ public class CockpitMultidimensionalAnalysisService {
                     warning.getCause(),
                     warning.getLevel(),
                     parseEventTime(warning.getEventTime()),
-                    warning.getRegion(),
+                    warning.getDistrict(),
                     type.getIndustry(),
                     type.getCode(),
                     warning.getDeviceCode()));
@@ -383,7 +395,7 @@ public class CockpitMultidimensionalAnalysisService {
                     alarm.getCause(),
                     alarm.getLevel(),
                     parseEventTime(alarm.getEventTime()),
-                    alarm.getRegion(),
+                    alarm.getDistrict(),
                     type.getIndustry(),
                     type.getCode(),
                     alarm.getDeviceCode()));
@@ -553,18 +565,18 @@ public class CockpitMultidimensionalAnalysisService {
         private final String cause;
         private final Integer level;
         private final OffsetDateTime eventTime;
-        private final String region;
+        private final Integer district;
         private final String industry;
         private final String deviceType;
         private final String deviceCode;
 
         private Fact(String eventType, String cause, Integer level, OffsetDateTime eventTime,
-                String region, String industry, String deviceType, String deviceCode) {
+                Integer district, String industry, String deviceType, String deviceCode) {
             this.eventType = eventType;
             this.cause = cause;
             this.level = level;
             this.eventTime = eventTime;
-            this.region = region;
+            this.district = district;
             this.industry = industry;
             this.deviceType = deviceType;
             this.deviceCode = deviceCode;
@@ -573,9 +585,5 @@ public class CockpitMultidimensionalAnalysisService {
         private String causeKey() {
             return isBlank(cause) ? UNKNOWN_CAUSE : cause.trim();
         }
-
-        private String regionKey() {
-            return isBlank(region) ? UNASSIGNED_REGION : region.trim();
-        }
     }
 }

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

@@ -6,9 +6,10 @@ import java.util.List;
 /**
  * Read snapshot for 预警处置 / 预警分析.
  *
- * <p>Events carry the raw dimension values (type, cause, region, level, device
+ * <p>Events carry the raw dimension values (type, cause, district, level, device
  * type) so the service can aggregate every dimension consistently and keep an
- * explicit bucket for unknown / missing causes.
+ * explicit bucket for unknown / missing causes. 范围维度为片区(district):
+ * 未分区如实为 {@code null},由服务层统一归一到「未分区」。
  */
 public class CockpitMultidimensionalAnalysisSnapshot {
     private final String updatedAt;
@@ -65,7 +66,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         private final String cause;
         private final Integer level;
         private final String eventTime;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String deviceCode;
 
@@ -75,7 +76,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
                 String cause,
                 Integer level,
                 String eventTime,
-                String region,
+                Integer district,
                 String typeCode,
                 String deviceCode) {
             this.warningNo = warningNo;
@@ -83,7 +84,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
             this.cause = cause;
             this.level = level;
             this.eventTime = eventTime;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
         }
@@ -93,7 +94,8 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         public String getCause() { return cause; }
         public Integer getLevel() { return level; }
         public String getEventTime() { return eventTime; }
-        public String getRegion() { return region; }
+        /** 片区编码(0-城南 1-城北 2-太常,权威来源 equipment_base.district);未分区为 {@code null},不猜。 */
+        public Integer getDistrict() { return district; }
         public String getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
     }
@@ -104,7 +106,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         private final String cause;
         private final Integer level;
         private final String eventTime;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String deviceCode;
 
@@ -114,7 +116,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
                 String cause,
                 Integer level,
                 String eventTime,
-                String region,
+                Integer district,
                 String typeCode,
                 String deviceCode) {
             this.alarmId = alarmId;
@@ -122,7 +124,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
             this.cause = cause;
             this.level = level;
             this.eventTime = eventTime;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
         }
@@ -132,7 +134,8 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         public String getCause() { return cause; }
         public Integer getLevel() { return level; }
         public String getEventTime() { return eventTime; }
-        public String getRegion() { return region; }
+        /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
+        public Integer getDistrict() { return district; }
         public String getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
     }

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

@@ -0,0 +1,146 @@
+package com.zksy.cockpit.foundation;
+
+import com.zksy.base.enums.DistrictEnum;
+
+/**
+ * 片区(district)契约的唯一权威来源(决策票 06 / 规格 10 / 执行票 12)。
+ *
+ * <p>权威来源是 {@code equipment_base.district}(0-城南 / 1-城北 / 2-太常),
+ * 与 {@link DistrictEnum} 同源。点位没有自己的片区列,按「经其上挂设备归纳」取得:
+ * 多设备片区不一致或没有任何设备时归为**未分区**,不猜、不按行业或其它字段冒充。
+ *
+ * <p>两类输入刻意分开处理:
+ * <ul>
+ *   <li><b>响应值归一</b>({@link #codeOf} / {@link #nameOf}):库里的脏值、空值、
+ *       未识别值一律收敛为「未分区」({@code code=null}),如实呈现。</li>
+ *   <li><b>请求入参校验</b>({@link #parseQuery}):只认 {@code 0/1/2/UNASSIGNED},
+ *       其它一律 400 —— 脏值不得参与筛选(决策票 06)。</li>
+ * </ul>
+ */
+public final class CockpitDistrict {
+
+    /** 未分区(空值/乱码/未识别)的统一展示词;也是筛选入参里「只看未分区」的取值。 */
+    public static final String UNASSIGNED_NAME = "未分区";
+
+    /** 未分区在筛选入参里的显式取值。 */
+    public static final String UNASSIGNED_KEY = "UNASSIGNED";
+
+    private CockpitDistrict() {
+    }
+
+    /**
+     * 响应值归一:合法编码原样返回,其余(含 null)收敛为 {@code null}=未分区。
+     */
+    public static Integer codeOf(Integer rawCode) {
+        return DistrictEnum.isValid(rawCode) ? rawCode : null;
+    }
+
+    /** 展示名:未分区(或脏值)回 {@link #UNASSIGNED_NAME},其余回中文片区名。 */
+    public static String nameOf(Integer rawCode) {
+        String name = DistrictEnum.getDescByCode(rawCode);
+        return name == null ? UNASSIGNED_NAME : name;
+    }
+
+    /** 该归一后的编码是否「未分区」。 */
+    public static boolean isUnassigned(Integer normalizedCode) {
+        return normalizedCode == null;
+    }
+
+    /**
+     * 点位片区归纳:取其上挂设备的片区。
+     *
+     * <p>没有任何设备、或设备片区不一致(含部分设备无片区)时归为 {@code null}=未分区 ——
+     * 不挑一个代表、不按行业或权属字段冒充(决策票 06)。当前 `equipment_point_rel` 为空,
+     * 故点位实际全部落为未分区,这是如实结果而非缺陷。
+     */
+    public static Integer aggregate(Iterable<Integer> deviceDistricts) {
+        Integer agreed = null;
+        boolean any = false;
+        if (deviceDistricts != null) {
+            for (Integer raw : deviceDistricts) {
+                any = true;
+                Integer normalized = codeOf(raw);
+                if (normalized == null) {
+                    return null; // 有设备但没有可信片区 → 整体不可信
+                }
+                if (agreed == null) {
+                    agreed = normalized;
+                } else if (!agreed.equals(normalized)) {
+                    return null; // 多设备片区不一致
+                }
+            }
+        }
+        return any ? agreed : null;
+    }
+
+    /**
+     * 请求入参解析:{@code null}/空串=不筛(回 {@code null} 且 {@link Query#active()} 为 false);
+     * {@code 0/1/2}=按该片区筛;{@code UNASSIGNED}=只看未分区;
+     * 其它值回 {@link Query#invalid()},由控制器转 400。
+     */
+    public static Query parseQuery(String district) {
+        if (district == null || district.trim().isEmpty()) {
+            return new Query(false, false, null);
+        }
+        String value = district.trim();
+        if (UNASSIGNED_KEY.equalsIgnoreCase(value) || UNASSIGNED_NAME.equals(value)) {
+            return new Query(true, false, null);
+        }
+        try {
+            int code = Integer.parseInt(value);
+            if (DistrictEnum.isValid(code)) {
+                return new Query(true, false, code);
+            }
+        } catch (NumberFormatException ignored) {
+            // 落到 invalid
+        }
+        return new Query(false, true, null);
+    }
+
+    /**
+     * 归一后的对象片区是否命中筛选。未筛({@code !active})一律命中。
+     */
+    public static boolean matches(Query query, Integer rawCode) {
+        if (query == null || !query.active()) {
+            return true;
+        }
+        Integer normalized = codeOf(rawCode);
+        if (query.unassignedOnly()) {
+            return normalized == null;
+        }
+        return query.code() != null && query.code().equals(normalized);
+    }
+
+    /** 解析后的片区筛选条件。 */
+    public static final class Query {
+        private final boolean active;
+        private final boolean invalid;
+        private final Integer code;
+
+        private Query(boolean active, boolean invalid, Integer code) {
+            this.active = active;
+            this.invalid = invalid;
+            this.code = code;
+        }
+
+        /** 是否生效(未传参=不筛)。 */
+        public boolean active() {
+            return active;
+        }
+
+        /** 入参是否为无法识别的值,需要 400。 */
+        public boolean invalid() {
+            return invalid;
+        }
+
+        /** 目标片区编码;未分区筛选时为 {@code null}。 */
+        public Integer code() {
+            return code;
+        }
+
+        /** 是否只筛未分区。 */
+        public boolean unassignedOnly() {
+            return active && code == null;
+        }
+    }
+}

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

@@ -29,8 +29,12 @@ import java.util.stream.Collectors;
  * 基础档案真实数据适配器(BT-5 第 6 片)。
  *
  * <p>组合现有设施表,不新增投影:水厂、共用泵站、统一管网基础表和排水工程设施。
- * 只有实体明确提供的字段才映射;缺少统一来源的规模、区域或长度保持 null,避免把
- * 容量、坐标或展示文本误当作基础档案事实。
+ * 只有实体明确提供的字段才映射;缺少统一来源的规模、片区或长度保持 null,避免把
+ * 容量、坐标、自由文本位置或展示文本误当作基础档案事实。
+ *
+ * <p>**片区(district)一律传 null**:水厂/泵站/管网/排水设施都没有
+ * {@code equipment_base.district} 那种权威片区列,也不关联设备,故如实归入「未分区」,
+ * 不新增数据库列、不用行业或管理单位冒充(决策票 06 / 执行票 12)。
  */
 @Component
 @Primary
@@ -135,7 +139,9 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
             if (code == null || typeCode == null) {
                 continue;
             }
-            facilities.add(facility(code, typeCode, trimToNull(source.getArea()), null, null,
+            // 片区一律 null:排水工程设施只有自由文本 area,不是 equipment_base.district,
+            // 用它冒充片区会凭空造出片区归属(决策票 06),故如实交未分区。
+            facilities.add(facility(code, typeCode, null, null, null,
                     source.getCreateTime() == null ? null : source.getCreateTime().toLocalDate()));
         }
     }
@@ -186,12 +192,12 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
     private static CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility(
             String code,
             String typeCode,
-            String region,
+            Integer district,
             BigDecimal lengthKm,
             LocalDate commissionedAt,
             LocalDate builtAt) {
         return new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
-                code, typeCode, region, lengthKm, null, commissionedAt, builtAt);
+                code, typeCode, district, lengthKm, null, commissionedAt, builtAt);
     }
 
     private static CockpitInfrastructureBaseArchivesSnapshot.SourceType type(

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

@@ -10,6 +10,7 @@ import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
@@ -26,10 +27,16 @@ import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
- * 管理信息真实数据适配器。
+ * 管理信息真实数据适配器(决策票 06 / 执行票 12)。
  *
- * <p>当前基础表没有独立组织主数据表,因此将现有的权属/管养/所属单位原值作为
- * 组织编码和名称。没有单位、设备类型无法归属正式行业的记录不猜测归属并跳过。
+ * <p>按「对象 → 行业 + 片区」上报关系,供服务层做片区×行业计数。片区**只**取
+ * {@code equipment_base.district}(设备的权威片区来源):
+ * <ul>
+ *   <li>设备({@link EquipmentBase}):用自身 {@code district},归一交给服务层。</li>
+ *   <li>窨井 / 管网设施 / 水源地:这些表没有片区列、也不关联设备,故如实传
+ *       {@code null}=未分区,**不用管养单位或权属单位冒充片区**。</li>
+ * </ul>
+ * 组织与权属字段已随决策票 06 删除,不再上报。
  */
 @Component
 @Primary
@@ -70,15 +77,15 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
             }
             String objectCode = trimToNull(device.getEquipmentCode());
-            String organization = trimToNull(device.getOwnershipUnit());
             String industry = device.getEquipmentTypeId() == null
                     ? null
                     : CockpitTypeIndustry.industryOf(types.get(device.getEquipmentTypeId()), types);
-            if (objectCode == null || organization == null || industry == null) {
+            if (objectCode == null || industry == null) {
                 continue;
             }
+            // 设备自身片区即权威来源(equipment_base.district)。
             relations.add(relation(objectCode, "SMART_DEVICE", industry,
-                    organization, organization, "OWNED"));
+                    CockpitDistrict.codeOf(device.getDistrict())));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(device.getUpdateTime()));
         }
 
@@ -87,12 +94,11 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
             }
             String objectCode = trimToNull(manhole.getManholeCode());
-            String organization = trimToNull(manhole.getOwnershipUnit());
-            if (objectCode == null || organization == null) {
+            if (objectCode == null) {
                 continue;
             }
-            relations.add(relation(objectCode, "FACILITY", "MANHOLE",
-                    organization, organization, "OWNED"));
+            // 窨井无片区来源 → 未分区。
+            relations.add(relation(objectCode, "FACILITY", "MANHOLE", null));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(manhole.getUpdateTime()));
         }
 
@@ -101,13 +107,12 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
             }
             String objectCode = trimToNull(pipe.getNetworkCode());
-            String organization = trimToNull(pipe.getManagementUnit());
             String industry = pipeIndustry(pipe.getNetworkType());
-            if (objectCode == null || organization == null || industry == null) {
+            if (objectCode == null || industry == null) {
                 continue;
             }
-            relations.add(relation(objectCode, "FACILITY", industry,
-                    organization, organization, "MANAGED"));
+            // 管网设施无片区来源 → 未分区,不用 managementUnit 冒充。
+            relations.add(relation(objectCode, "FACILITY", industry, null));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(pipe.getUpdateTime()));
         }
 
@@ -116,12 +121,11 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
             }
             String objectCode = trimToNull(source.getSourceId());
-            String organization = trimToNull(source.getUnit());
-            if (objectCode == null || organization == null) {
+            if (objectCode == null) {
                 continue;
             }
-            relations.add(relation(objectCode, "FACILITY", "WATER_SUPPLY",
-                    organization, organization, "OWNED"));
+            // 水源地无片区来源 → 未分区。
+            relations.add(relation(objectCode, "FACILITY", "WATER_SUPPLY", null));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(source.getUpdateTime()));
         }
 
@@ -132,11 +136,9 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
             String objectCode,
             String objectKind,
             String industry,
-            String organizationCode,
-            String organizationName,
-            String ownershipType) {
+            Integer district) {
         return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
-                objectCode, objectKind, industry, organizationCode, organizationName, ownershipType);
+                objectCode, objectKind, industry, district);
     }
 
     private static Map<Integer, EquipmentType> typeIndex(List<EquipmentType> values) {

+ 58 - 17
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesResponse.java

@@ -17,7 +17,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
     private final List<TypeSummary> facilityTypeDistribution;
     private final List<AgeDistribution> ageDistribution;
     private final AgeReference ageReference;
-    private final List<RegionDistribution> regionDistribution;
+    private final List<DistrictDistribution> districtDistribution;
     private final SelectedFilters selectedFilters;
     private final MapFilter mapFilter;
     private final Excluded excluded;
@@ -36,7 +36,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
             List<TypeSummary> facilityTypeDistribution,
             List<AgeDistribution> ageDistribution,
             AgeReference ageReference,
-            List<RegionDistribution> regionDistribution,
+            List<DistrictDistribution> districtDistribution,
             SelectedFilters selectedFilters,
             MapFilter mapFilter,
             Excluded excluded) {
@@ -53,7 +53,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
         this.facilityTypeDistribution = facilityTypeDistribution;
         this.ageDistribution = ageDistribution;
         this.ageReference = ageReference;
-        this.regionDistribution = regionDistribution;
+        this.districtDistribution = districtDistribution;
         this.selectedFilters = selectedFilters;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
@@ -72,7 +72,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
     public List<TypeSummary> getFacilityTypeDistribution() { return facilityTypeDistribution; }
     public List<AgeDistribution> getAgeDistribution() { return ageDistribution; }
     public AgeReference getAgeReference() { return ageReference; }
-    public List<RegionDistribution> getRegionDistribution() { return regionDistribution; }
+    public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public SelectedFilters getSelectedFilters() { return selectedFilters; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
@@ -211,55 +211,96 @@ public class CockpitInfrastructureBaseArchivesResponse {
         public int getUnknownCount() { return unknownCount; }
     }
 
-    public static class RegionDistribution {
-        private final String code;
+    /**
+     * 片区分布行:按片区**编码**分桶,并回中文展示名。
+     *
+     * <p>自执行票 12 起范围维度为片区(`district`),旧自由串 `region` 不再是契约。
+     * 未分区(设施无片区来源)回 {@code district = null} + {@code districtName = "未分区"},
+     * 如实呈现而不是丢进「未知」或冒充行业。
+     */
+    public static class DistrictDistribution {
+        private final Integer district;
+        private final String districtName;
         private final int count;
 
-        public RegionDistribution(String code, int count) {
-            this.code = code;
+        public DistrictDistribution(Integer district, int count) {
+            // 与其它片区 DTO 一致:先归一再取名,脏值不得渗出成第 4 个片区。
+            this.district = com.zksy.cockpit.foundation.CockpitDistrict.codeOf(district);
+            this.districtName = com.zksy.cockpit.foundation.CockpitDistrict.nameOf(this.district);
             this.count = count;
         }
 
-        public String getCode() { return code; }
+        /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
+        public Integer getDistrict() { return district; }
+        /** 片区中文名;未分区回字面量「未分区」。 */
+        public String getDistrictName() { return districtName; }
         public int getCount() { return count; }
     }
 
+    /**
+     * 已生效筛选条件回显。
+     *
+     * <p>范围维度为片区:`district` 编码 + `districtName` 展示名 + `districtFilter`
+     * (`NONE` 未筛 / `ASSIGNED` 按片区 / `UNASSIGNED` 只看未分区)。未筛与未分区编码都是
+     * {@code null},靠 `districtFilter` 区分,不靠猜。
+     */
     public static class SelectedFilters {
         private final String industry;
         private final String facilityType;
         private final String ageBucket;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
+        private final String districtFilter;
 
-        public SelectedFilters(String industry, String facilityType, String ageBucket, String region) {
+        public SelectedFilters(String industry, String facilityType, String ageBucket,
+                Integer district, String districtFilter) {
             this.industry = industry;
             this.facilityType = facilityType;
             this.ageBucket = ageBucket;
-            this.region = region;
+            this.district = district;
+            this.districtFilter = districtFilter;
+            this.districtName = district == null
+                    ? null
+                    : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
         }
 
         public String getIndustry() { return industry; }
         public String getFacilityType() { return facilityType; }
         public String getAgeBucket() { return ageBucket; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
+        public String getDistrictFilter() { return districtFilter; }
     }
 
+    /** 生效范围回显,口径与 {@link SelectedFilters} 一致。 */
     public static class MapFilter {
         private final String industry;
         private final String facilityType;
         private final String ageBucket;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
+        private final String districtFilter;
 
-        public MapFilter(String industry, String facilityType, String ageBucket, String region) {
+        public MapFilter(String industry, String facilityType, String ageBucket,
+                Integer district, String districtFilter) {
             this.industry = industry;
             this.facilityType = facilityType;
             this.ageBucket = ageBucket;
-            this.region = region;
+            this.district = district;
+            this.districtFilter = districtFilter;
+            this.districtName = district == null
+                    ? null
+                    : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
         }
 
         public String getIndustry() { return industry; }
         public String getFacilityType() { return facilityType; }
         public String getAgeBucket() { return ageBucket; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
+        public String getDistrictFilter() { return districtFilter; }
     }
 
     public static class Excluded {

+ 45 - 19
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesService.java

@@ -1,13 +1,15 @@
 package com.zksy.cockpit.infrastructure;
 
+import com.zksy.base.enums.DistrictEnum;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitReadException;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.TypeSummary;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.AgeDistribution;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.DistrictDistribution;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.Industry;
-import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.RegionDistribution;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -21,7 +23,6 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 
 @Service
 public class CockpitInfrastructureBaseArchivesService {
@@ -41,22 +42,31 @@ public class CockpitInfrastructureBaseArchivesService {
         this.clock = clock;
     }
 
+    /**
+     * 基础档案快照。全局筛选(industry / facilityType / ageBucket / district)由服务端完成,
+     * `selectedFilters` 与 `mapFilter` 回显实际生效范围。
+     *
+     * <p>范围维度自执行票 12 起为**片区**:只认 {@code 0/1/2/UNASSIGNED},其它值 400;
+     * 旧自由串 `region` 不再是正式契约,入参被忽略。设施表没有片区来源,故实际全部落
+     * 「未分区」——这是如实结果,不是缺陷。
+     */
     public CockpitInfrastructureBaseArchivesResponse load(
             String requestId,
             String industry,
             String facilityType,
             String ageBucket,
-            String region) {
+            String district) {
         validateEnum("industry", industry, INDUSTRIES);
         validateEnum("ageBucket", ageBucket, AGE_BUCKETS);
-        if (isBlank(industry) && isBlank(facilityType) && isBlank(ageBucket) && isBlank(region)) {
-            return build(requestId, adapter.load(), null, null, null, null);
+        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
+        if (selectedDistrict.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
         }
         CockpitInfrastructureBaseArchivesSnapshot snapshot = adapter.load();
         if (!isBlank(facilityType) && !types(snapshot).containsKey(facilityType)) {
             throw new CockpitInvalidRequestException("unknown facility type");
         }
-        return build(requestId, snapshot, industry, facilityType, ageBucket, region);
+        return build(requestId, snapshot, industry, facilityType, ageBucket, selectedDistrict);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -81,7 +91,7 @@ public class CockpitInfrastructureBaseArchivesService {
             String industry,
             String facilityType,
             String ageBucket,
-            String region) {
+            CockpitDistrict.Query district) {
         Map<String, CockpitInfrastructureBaseArchivesSnapshot.SourceType> types = types(snapshot);
         List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities =
                 snapshot == null || snapshot.getFacilities() == null
@@ -104,7 +114,8 @@ public class CockpitInfrastructureBaseArchivesService {
         int commissionedCount = 0;
         int builtCount = 0;
         int unknownDateCount = 0;
-        Map<String, Integer> regionCounts = new LinkedHashMap<>();
+        // 按归一后的片区编码分桶;null 键=未分区(设施无片区来源,如实归入此桶)。
+        Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
 
         for (CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility : facilities) {
             if (facility == null || isBlank(facility.getTypeCode())) {
@@ -129,8 +140,8 @@ public class CockpitInfrastructureBaseArchivesService {
             if (!isBlank(facilityType) && !facilityType.equals(type.getCode())) {
                 continue;
             }
-            String normalizedRegion = normalizeRegion(facility.getRegion());
-            if (!isBlank(region) && !region.equals(normalizedRegion)) {
+            Integer normalizedDistrict = CockpitDistrict.codeOf(facility.getDistrict());
+            if (!CockpitDistrict.matches(district, facility.getDistrict())) {
                 continue;
             }
             String bucket = ageBucket(facility, LocalDate.now(clock));
@@ -163,7 +174,7 @@ public class CockpitInfrastructureBaseArchivesService {
             } else {
                 unknownDateCount++;
             }
-            regionCounts.merge(normalizedRegion, 1, Integer::sum);
+            districtCounts.merge(normalizedDistrict, 1, Integer::sum);
         }
 
         List<AgeDistribution> ageDistribution = new ArrayList<>();
@@ -174,9 +185,18 @@ public class CockpitInfrastructureBaseArchivesService {
         for (String code : INDUSTRIES) {
             industryDistribution.add(new Industry(code, industryCounts.get(code)));
         }
-        List<RegionDistribution> regionDistribution = new ArrayList<>();
-        for (Map.Entry<String, Integer> entry : regionCounts.entrySet()) {
-            regionDistribution.add(new RegionDistribution(entry.getKey(), entry.getValue()));
+        // 分桶顺序固定为 城南 → 城北 → 太常 → 未分区,保证响应可预期;
+        // 未分区哨兵用 null 键(归一的必然结果),展示名由响应类统一回「未分区」。
+        List<DistrictDistribution> districtDistribution = new ArrayList<>();
+        for (DistrictEnum item : DistrictEnum.values()) {
+            Integer count = districtCounts.get(item.getCode());
+            if (count != null) {
+                districtDistribution.add(new DistrictDistribution(item.getCode(), count));
+            }
+        }
+        Integer unassignedCount = districtCounts.get(null);
+        if (unassignedCount != null) {
+            districtDistribution.add(new DistrictDistribution(null, unassignedCount));
         }
 
         return new CockpitInfrastructureBaseArchivesResponse(
@@ -204,17 +224,19 @@ public class CockpitInfrastructureBaseArchivesService {
                         commissionedCount,
                         builtCount,
                         unknownDateCount),
-                regionDistribution,
+                districtDistribution,
                 new CockpitInfrastructureBaseArchivesResponse.SelectedFilters(
                         industry,
                         facilityType,
                         ageBucket,
-                        region),
+                        district.active() ? district.code() : null,
+                        districtFilter(district)),
                 new CockpitInfrastructureBaseArchivesResponse.MapFilter(
                         mapIndustry(industry),
                         facilityType,
                         ageBucket,
-                        region),
+                        district.active() ? district.code() : null,
+                        districtFilter(district)),
                 new CockpitInfrastructureBaseArchivesResponse.Excluded(
                         unknownTypeCount,
                         typeKindMismatchCount,
@@ -283,8 +305,12 @@ public class CockpitInfrastructureBaseArchivesService {
         return left.add(right);
     }
 
-    private static String normalizeRegion(String region) {
-        return isBlank(region) ? "UNKNOWN" : region.trim();
+    /** 片区筛选回显标记;与地图族同一口径。 */
+    private static String districtFilter(CockpitDistrict.Query district) {
+        if (!district.active()) {
+            return "NONE";
+        }
+        return district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED";
     }
 
     private static String mapIndustry(String industry) {

+ 11 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesSnapshot.java

@@ -41,10 +41,17 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
         public String getObjectKind() { return objectKind; }
     }
 
+    /**
+     * 一条设施来源行。
+     *
+     * <p>{@code district} 是片区**原始编码**(未归一见 {@code CockpitDistrict}):
+     * 现网设施表(水厂/泵站/管网/排水设施)没有片区列、也不关联设备,因此适配器一律传
+     * {@code null},由服务层如实归一为「未分区」,不用行业或管理单位冒充。
+     */
     public static class SourceFacility {
         private final String code;
         private final String typeCode;
-        private final String region;
+        private final Integer district;
         private final BigDecimal lengthKm;
         private final String scale;
         private final LocalDate commissionedAt;
@@ -53,14 +60,14 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
         public SourceFacility(
                 String code,
                 String typeCode,
-                String region,
+                Integer district,
                 BigDecimal lengthKm,
                 String scale,
                 LocalDate commissionedAt,
                 LocalDate builtAt) {
             this.code = code;
             this.typeCode = typeCode;
-            this.region = region;
+            this.district = district;
             this.lengthKm = lengthKm;
             this.scale = scale;
             this.commissionedAt = commissionedAt;
@@ -69,7 +76,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
 
         public String getCode() { return code; }
         public String getTypeCode() { return typeCode; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
         public BigDecimal getLengthKm() { return lengthKm; }
         public String getScale() { return scale; }
         public LocalDate getCommissionedAt() { return commissionedAt; }

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

@@ -1,7 +1,21 @@
 package com.zksy.cockpit.infrastructure;
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
+
 import java.util.List;
 
+/**
+ * 管理信息只读视图(决策票 06 / 执行票 12)。
+ *
+ * <p>原「组织 × 权属」视图已按决策票 06 删除:`organizations`、`ownershipDistribution`、
+ * `Totals.unmaintainedCount` 以及 `Organization` / `Ownership` 内部类都不再返回,
+ * `SelectedFilters.organizationCode` 与 `MapFilter.organizationCode(s)` 也随之删除,
+ * 因为它们只服务于被删的组织视图。
+ *
+ * <p>替代视图是**片区 × 行业**的设施/设备计数:`districtIndustry` 每项回
+ * 片区编码 + 中文名 + 行业 + 设施数 + 设备数,`totals` 保留设施/设备总数。
+ * 片区权威来源是 `equipment_base.district`,设施无片区来源则如实归「未分区」。
+ */
 public class CockpitInfrastructureManagementInformationResponse {
     private final String requestId;
     private final String apiVersion;
@@ -12,8 +26,7 @@ public class CockpitInfrastructureManagementInformationResponse {
     private final Freshness freshness;
     private final SelectedFilters selectedFilters;
     private final Totals totals;
-    private final List<Organization> organizations;
-    private final List<Ownership> ownershipDistribution;
+    private final List<DistrictIndustryCount> districtIndustry;
     private final MapFilter mapFilter;
     private final Excluded excluded;
 
@@ -27,8 +40,7 @@ public class CockpitInfrastructureManagementInformationResponse {
             Freshness freshness,
             SelectedFilters selectedFilters,
             Totals totals,
-            List<Organization> organizations,
-            List<Ownership> ownershipDistribution,
+            List<DistrictIndustryCount> districtIndustry,
             MapFilter mapFilter,
             Excluded excluded) {
         this.requestId = requestId;
@@ -40,8 +52,7 @@ public class CockpitInfrastructureManagementInformationResponse {
         this.freshness = freshness;
         this.selectedFilters = selectedFilters;
         this.totals = totals;
-        this.organizations = organizations;
-        this.ownershipDistribution = ownershipDistribution;
+        this.districtIndustry = districtIndustry;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
     }
@@ -55,8 +66,8 @@ public class CockpitInfrastructureManagementInformationResponse {
     public Freshness getFreshness() { return freshness; }
     public SelectedFilters getSelectedFilters() { return selectedFilters; }
     public Totals getTotals() { return totals; }
-    public List<Organization> getOrganizations() { return organizations; }
-    public List<Ownership> getOwnershipDistribution() { return ownershipDistribution; }
+    /** 片区 × 行业计数;未分区行的 `district` 为 {@code null}、`districtName` 为「未分区」。 */
+    public List<DistrictIndustryCount> getDistrictIndustry() { return districtIndustry; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
 
@@ -79,106 +90,96 @@ public class CockpitInfrastructureManagementInformationResponse {
         public String getServerUpdatedAt() { return serverUpdatedAt; }
     }
 
+    /** 生效筛选回显;组织维度已删除,保留行业与片区。 */
     public static class SelectedFilters {
         private final String industry;
-        private final String organizationCode;
+        private final Integer district;
+        private final String districtName;
+        private final String districtFilter;
 
-        public SelectedFilters(String industry, String organizationCode) {
+        public SelectedFilters(String industry, Integer district, String districtFilter) {
             this.industry = industry;
-            this.organizationCode = organizationCode;
+            this.district = district;
+            this.districtFilter = districtFilter;
+            this.districtName = district == null ? null : CockpitDistrict.nameOf(district);
         }
 
         public String getIndustry() { return industry; }
-        public String getOrganizationCode() { return organizationCode; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
+        public String getDistrictFilter() { return districtFilter; }
     }
 
+    /**
+     * 计数总量。
+     *
+     * <p>`organizationCount` 与 `unmaintainedCount` 都随组织视图删除:它们的分母已不存在,
+     * 保留只会逼出编造值。设施与设备永远分开计数,不合并。
+     */
     public static class Totals {
-        private final int organizationCount;
         private final int facilityCount;
         private final int equipmentCount;
-        private final int unmaintainedCount;
 
-        public Totals(int organizationCount, int facilityCount, int equipmentCount, int unmaintainedCount) {
-            this.organizationCount = organizationCount;
+        public Totals(int facilityCount, int equipmentCount) {
             this.facilityCount = facilityCount;
             this.equipmentCount = equipmentCount;
-            this.unmaintainedCount = unmaintainedCount;
         }
 
-        public int getOrganizationCount() { return organizationCount; }
         public int getFacilityCount() { return facilityCount; }
         public int getEquipmentCount() { return equipmentCount; }
-        public int getUnmaintainedCount() { return unmaintainedCount; }
     }
 
-    public static class Organization {
-        private final String organizationCode;
-        private final String organizationName;
+    /**
+     * 一行「片区 × 行业」计数。
+     *
+     * <p>片区用编码 + 中文名双字段:未分区(设施无片区来源)回 {@code district = null}
+     * 与字面量「未分区」,不猜、不按行业或权属单位冒充(决策票 06)。
+     */
+    public static class DistrictIndustryCount {
+        private final Integer district;
+        private final String districtName;
         private final String industry;
         private final int facilityCount;
         private final int equipmentCount;
-        private final boolean unmaintained;
-        private final List<Ownership> ownershipDistribution;
-
-        public Organization(
-                String organizationCode,
-                String organizationName,
-                String industry,
-                int facilityCount,
-                int equipmentCount,
-                boolean unmaintained,
-                List<Ownership> ownershipDistribution) {
-            this.organizationCode = organizationCode;
-            this.organizationName = organizationName;
+
+        public DistrictIndustryCount(
+                Integer district, String industry, int facilityCount, int equipmentCount) {
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.industry = industry;
             this.facilityCount = facilityCount;
             this.equipmentCount = equipmentCount;
-            this.unmaintained = unmaintained;
-            this.ownershipDistribution = ownershipDistribution;
         }
 
-        public String getOrganizationCode() { return organizationCode; }
-        public String getOrganizationName() { return organizationName; }
+        /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
+        public Integer getDistrict() { return district; }
+        /** 片区中文名;未分区回「未分区」。 */
+        public String getDistrictName() { return districtName; }
         public String getIndustry() { return industry; }
         public int getFacilityCount() { return facilityCount; }
         public int getEquipmentCount() { return equipmentCount; }
-        public boolean isUnmaintained() { return unmaintained; }
-        public List<Ownership> getOwnershipDistribution() { return ownershipDistribution; }
-    }
-
-    public static class Ownership {
-        private final String ownershipType;
-        private final int facilityCount;
-        private final int equipmentCount;
-        private final int total;
-
-        public Ownership(String ownershipType, int facilityCount, int equipmentCount, int total) {
-            this.ownershipType = ownershipType;
-            this.facilityCount = facilityCount;
-            this.equipmentCount = equipmentCount;
-            this.total = total;
-        }
-
-        public String getOwnershipType() { return ownershipType; }
-        public int getFacilityCount() { return facilityCount; }
-        public int getEquipmentCount() { return equipmentCount; }
-        public int getTotal() { return total; }
     }
 
+    /** 生效范围回显;组织维度已删除,保留行业与片区,口径与 {@link SelectedFilters} 一致。 */
     public static class MapFilter {
         private final String industry;
-        private final String organizationCode;
-        private final List<String> organizationCodes;
+        private final Integer district;
+        private final String districtName;
+        private final String districtFilter;
 
-        public MapFilter(String industry, String organizationCode, List<String> organizationCodes) {
+        public MapFilter(String industry, Integer district, String districtFilter) {
             this.industry = industry;
-            this.organizationCode = organizationCode;
-            this.organizationCodes = organizationCodes;
+            this.district = district;
+            this.districtFilter = districtFilter;
+            this.districtName = district == null ? null : CockpitDistrict.nameOf(district);
         }
 
         public String getIndustry() { return industry; }
-        public String getOrganizationCode() { return organizationCode; }
-        public List<String> getOrganizationCodes() { return organizationCodes; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
+        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
+        public String getDistrictFilter() { return districtFilter; }
     }
 
     public static class Excluded {

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

@@ -1,9 +1,11 @@
 package com.zksy.cockpit.infrastructure;
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationResponse.DistrictIndustryCount;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
@@ -18,10 +20,17 @@ import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 /**
- * Read-only management-ownership view: which unit manages which facilities and smart devices.
+ * 管理信息只读视图:**片区 × 行业**的设施/设备计数(决策票 06 / 执行票 12)。
+ *
+ * <p>原「组织 × 权属」聚合已按决策票 06 删除(`OrganizationAccumulator`、`OwnershipCounter`、
+ * `UNMAINTAINED_*` 一并移除),因此不再有组织编码、权属类型与「未维护」计数。
+ *
+ * <p>片区权威来源是 `equipment_base.district`:设备行取自身片区,设施没有该来源则如实归
+ * 「未分区」,不用管养单位冒充。片区入参只认 {@code 0/1/2/UNASSIGNED},其它值 400。
  */
 @Service
 public class CockpitInfrastructureManagementInformationService {
@@ -29,9 +38,6 @@ public class CockpitInfrastructureManagementInformationService {
     private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String FACILITY = "FACILITY";
     private static final String SMART_DEVICE = "SMART_DEVICE";
-    private static final String UNMAINTAINED_CODE = "UNMAINTAINED";
-    private static final String UNMAINTAINED_NAME = "未维护";
-    private static final String UNKNOWN_OWNERSHIP = "UNKNOWN";
 
     private final CockpitInfrastructureManagementInformationAdapter adapter;
     private final Clock clock;
@@ -46,9 +52,12 @@ public class CockpitInfrastructureManagementInformationService {
     public CockpitInfrastructureManagementInformationResponse load(
             String requestId,
             String industry,
-            String organizationCode) {
+            String district) {
         String selectedIndustry = resolveIndustry(industry);
-        String selectedOrganization = isBlank(organizationCode) ? null : organizationCode.trim();
+        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
+        if (selectedDistrict.invalid()) {
+            throw new CockpitInvalidRequestException("district must be 0, 1, 2 or UNASSIGNED");
+        }
 
         CockpitInfrastructureManagementInformationSnapshot snapshot = adapter.load();
         List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations =
@@ -61,7 +70,8 @@ public class CockpitInfrastructureManagementInformationService {
         int unknownIndustryCount = 0;
         int nonFormalIndustryCount = 0;
         int unknownObjectKindCount = 0;
-        Map<String, OrganizationAccumulator> accumulators = new LinkedHashMap<>();
+        // 键=归一后的片区编码(null=未分区)+ 行业;一桶一行的设施/设备计数。
+        Map<DistrictIndustryKey, Counter> counters = new LinkedHashMap<>();
 
         for (CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation : relations) {
             if (relation == null || isBlank(relation.getObjectCode())) {
@@ -89,57 +99,30 @@ public class CockpitInfrastructureManagementInformationService {
             if (selectedIndustry != null && !selectedIndustry.equals(industryCode)) {
                 continue;
             }
-            boolean unmaintained = isBlank(relation.getOrganizationCode());
-            String code = unmaintained ? UNMAINTAINED_CODE : relation.getOrganizationCode().trim();
-            if (selectedOrganization != null && !selectedOrganization.equalsIgnoreCase(code)) {
+            if (!CockpitDistrict.matches(selectedDistrict, relation.getDistrict())) {
                 continue;
             }
-            OrganizationAccumulator accumulator = accumulators.computeIfAbsent(
-                    code, key -> new OrganizationAccumulator(key, unmaintained));
-            accumulator.add(industryCode, kind, ownershipType(relation), relation);
-        }
-
-        List<CockpitInfrastructureManagementInformationResponse.Organization> organizations = new ArrayList<>();
-        for (OrganizationAccumulator accumulator : accumulators.values()) {
-            organizations.add(accumulator.build());
+            DistrictIndustryKey key = new DistrictIndustryKey(
+                    CockpitDistrict.codeOf(relation.getDistrict()), industryCode);
+            counters.computeIfAbsent(key, ignored -> new Counter())
+                    .add(FACILITY.equals(kind));
         }
-        organizations.sort(Comparator
-                .comparingInt(CockpitInfrastructureManagementInformationResponse.Organization::getFacilityCount)
-                .thenComparingInt(CockpitInfrastructureManagementInformationResponse.Organization::getEquipmentCount)
-                .reversed()
-                .thenComparing(CockpitInfrastructureManagementInformationResponse.Organization::getOrganizationCode));
 
-        Map<String, OwnershipCounter> overallOwnership = new LinkedHashMap<>();
-        List<String> organizationCodes = new ArrayList<>();
+        List<DistrictIndustryCount> districtIndustry = new ArrayList<>();
         int facilityCount = 0;
         int equipmentCount = 0;
-        int unmaintainedCount = 0;
-        for (CockpitInfrastructureManagementInformationResponse.Organization organization : organizations) {
-            organizationCodes.add(organization.getOrganizationCode());
-            facilityCount += organization.getFacilityCount();
-            equipmentCount += organization.getEquipmentCount();
-            if (organization.isUnmaintained()) {
-                unmaintainedCount += organization.getFacilityCount() + organization.getEquipmentCount();
-            }
-            for (CockpitInfrastructureManagementInformationResponse.Ownership ownership
-                    : organization.getOwnershipDistribution()) {
-                overallOwnership
-                        .computeIfAbsent(ownership.getOwnershipType(), key -> new OwnershipCounter())
-                        .add(ownership.getFacilityCount(), ownership.getEquipmentCount());
-            }
-        }
-
-        List<CockpitInfrastructureManagementInformationResponse.Ownership> ownershipDistribution =
-                new ArrayList<>();
-        for (Map.Entry<String, OwnershipCounter> entry : overallOwnership.entrySet()) {
-            OwnershipCounter counter = entry.getValue();
-            ownershipDistribution.add(new CockpitInfrastructureManagementInformationResponse.Ownership(
-                    entry.getKey(), counter.facilityCount, counter.equipmentCount, counter.total()));
+        for (Map.Entry<DistrictIndustryKey, Counter> entry : counters.entrySet()) {
+            DistrictIndustryKey key = entry.getKey();
+            Counter counter = entry.getValue();
+            facilityCount += counter.facilityCount;
+            equipmentCount += counter.equipmentCount;
+            districtIndustry.add(new DistrictIndustryCount(
+                    key.district, key.industry, counter.facilityCount, counter.equipmentCount));
         }
-        ownershipDistribution.sort(Comparator
-                .comparingInt(CockpitInfrastructureManagementInformationResponse.Ownership::getTotal)
-                .reversed()
-                .thenComparing(CockpitInfrastructureManagementInformationResponse.Ownership::getOwnershipType));
+        districtIndustry.sort(Comparator
+                .comparing(DistrictIndustryCount::getDistrict,
+                        Comparator.nullsLast(Comparator.<Integer>naturalOrder()))
+                .thenComparing(DistrictIndustryCount::getIndustry));
 
         String serverUpdatedAt = businessTime();
         String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
@@ -154,20 +137,23 @@ public class CockpitInfrastructureManagementInformationService {
                 "Asia/Shanghai",
                 updatedAt,
                 new CockpitInfrastructureManagementInformationResponse.Freshness(
-                        organizations.isEmpty()
+                        districtIndustry.isEmpty()
                                 ? CockpitFreshnessStatus.EMPTY.name()
                                 : CockpitFreshnessStatus.CURRENT.name(),
-                        organizations.isEmpty() ? "NO_ORGANIZATIONS" : "READY",
+                        districtIndustry.isEmpty() ? "NO_RELATIONS" : "READY",
                         updatedAt,
                         serverUpdatedAt),
                 new CockpitInfrastructureManagementInformationResponse.SelectedFilters(
-                        selectedIndustry, selectedOrganization),
+                        selectedIndustry,
+                        selectedDistrict.active() ? selectedDistrict.code() : null,
+                        districtFilter(selectedDistrict)),
                 new CockpitInfrastructureManagementInformationResponse.Totals(
-                        organizations.size(), facilityCount, equipmentCount, unmaintainedCount),
-                organizations,
-                ownershipDistribution,
+                        facilityCount, equipmentCount),
+                districtIndustry,
                 new CockpitInfrastructureManagementInformationResponse.MapFilter(
-                        selectedIndustry, selectedOrganization, organizationCodes),
+                        selectedIndustry,
+                        selectedDistrict.active() ? selectedDistrict.code() : null,
+                        districtFilter(selectedDistrict)),
                 new CockpitInfrastructureManagementInformationResponse.Excluded(
                         unknownIndustryCount,
                         nonFormalIndustryCount,
@@ -202,18 +188,18 @@ public class CockpitInfrastructureManagementInformationService {
         return resolved;
     }
 
-    private static String ownershipType(
-            CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation) {
-        if (isBlank(relation.getOwnershipType())) {
-            return UNKNOWN_OWNERSHIP;
-        }
-        return relation.getOwnershipType().trim().toUpperCase();
-    }
-
     private static String normalize(String value) {
         return isBlank(value) ? "" : value.trim().toUpperCase();
     }
 
+    /** 片区筛选回显标记;与地图族同一口径。 */
+    private static String districtFilter(CockpitDistrict.Query district) {
+        if (!district.active()) {
+            return "NONE";
+        }
+        return district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED";
+    }
+
     private String businessTime() {
         return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
                 OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
@@ -223,70 +209,48 @@ public class CockpitInfrastructureManagementInformationService {
         return value == null || value.trim().isEmpty();
     }
 
-    private static class OwnershipCounter {
-        private int facilityCount;
-        private int equipmentCount;
+    /**
+     * 「片区 × 行业」分桶键。片区用**归一后**的编码,故未分区稳定落 {@code null} 键,
+     * 不会把脏值当成另一个片区。
+     */
+    private static final class DistrictIndustryKey {
+        private final Integer district;
+        private final String industry;
+
+        private DistrictIndustryKey(Integer district, String industry) {
+            this.district = district;
+            this.industry = industry;
+        }
 
-        private void add(int facilities, int equipment) {
-            this.facilityCount += facilities;
-            this.equipmentCount += equipment;
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+            if (!(other instanceof DistrictIndustryKey)) {
+                return false;
+            }
+            DistrictIndustryKey that = (DistrictIndustryKey) other;
+            return Objects.equals(district, that.district)
+                    && Objects.equals(industry, that.industry);
         }
 
-        private int total() {
-            return facilityCount + equipmentCount;
+        @Override
+        public int hashCode() {
+            return Objects.hash(district, industry);
         }
     }
 
-    private static class OrganizationAccumulator {
-        private final String code;
-        private final boolean unmaintained;
-        private String name;
-        private final Set<String> industries = new LinkedHashSet<>();
+    private static final class Counter {
         private int facilityCount;
         private int equipmentCount;
-        private final Map<String, OwnershipCounter> ownership = new LinkedHashMap<>();
 
-        private OrganizationAccumulator(String code, boolean unmaintained) {
-            this.code = code;
-            this.unmaintained = unmaintained;
-            this.name = unmaintained ? UNMAINTAINED_NAME : code;
-        }
-
-        private void add(
-                String industry,
-                String kind,
-                String ownershipType,
-                CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation) {
-            if (!unmaintained && !isBlank(relation.getOrganizationName())) {
-                this.name = relation.getOrganizationName().trim();
-            }
-            industries.add(industry);
-            if (FACILITY.equals(kind)) {
+        private void add(boolean facility) {
+            if (facility) {
                 facilityCount++;
             } else {
                 equipmentCount++;
             }
-            ownership.computeIfAbsent(ownershipType, key -> new OwnershipCounter()).add(
-                    FACILITY.equals(kind) ? 1 : 0,
-                    SMART_DEVICE.equals(kind) ? 1 : 0);
-        }
-
-        private CockpitInfrastructureManagementInformationResponse.Organization build() {
-            List<CockpitInfrastructureManagementInformationResponse.Ownership> distribution = new ArrayList<>();
-            for (Map.Entry<String, OwnershipCounter> entry : ownership.entrySet()) {
-                OwnershipCounter counter = entry.getValue();
-                distribution.add(new CockpitInfrastructureManagementInformationResponse.Ownership(
-                        entry.getKey(), counter.facilityCount, counter.equipmentCount, counter.total()));
-            }
-            distribution.sort(Comparator
-                    .comparingInt(CockpitInfrastructureManagementInformationResponse.Ownership::getTotal)
-                    .reversed()
-                    .thenComparing(CockpitInfrastructureManagementInformationResponse.Ownership::getOwnershipType));
-            String industry = industries.isEmpty()
-                    ? null
-                    : industries.size() == 1 ? industries.iterator().next() : "MIXED";
-            return new CockpitInfrastructureManagementInformationResponse.Organization(
-                    code, name, industry, facilityCount, equipmentCount, unmaintained, distribution);
         }
     }
 }

+ 11 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationSnapshot.java

@@ -17,36 +17,34 @@ public class CockpitInfrastructureManagementInformationSnapshot {
     public List<SourceRelation> getRelations() { return relations; }
 
     /**
-     * One management-ownership relation: an object, the unit that manages it and the ownership type.
+     * 一条对象记录:对象、所属行业、所属片区。
+     *
+     * <p>组织/权属字段(`organizationCode` / `organizationName` / `ownershipType`)已随决策票 06
+     * 删除:它们只服务被删的「组织 × 权属」视图,留着会重新诱导出编造归属。
+     *
+     * <p>`district` 是**原始片区编码**(未归一见 {@code CockpitDistrict}),权威来源
+     * `equipment_base.district`;设施没有该来源时传 {@code null},由服务层如实归「未分区」。
      */
     public static class SourceRelation {
         private final String objectCode;
         private final String objectKind;
         private final String industry;
-        private final String organizationCode;
-        private final String organizationName;
-        private final String ownershipType;
+        private final Integer district;
 
         public SourceRelation(
                 String objectCode,
                 String objectKind,
                 String industry,
-                String organizationCode,
-                String organizationName,
-                String ownershipType) {
+                Integer district) {
             this.objectCode = objectCode;
             this.objectKind = objectKind;
             this.industry = industry;
-            this.organizationCode = organizationCode;
-            this.organizationName = organizationName;
-            this.ownershipType = ownershipType;
+            this.district = district;
         }
 
         public String getObjectCode() { return objectCode; }
         public String getObjectKind() { return objectKind; }
         public String getIndustry() { return industry; }
-        public String getOrganizationCode() { return organizationCode; }
-        public String getOrganizationName() { return organizationName; }
-        public String getOwnershipType() { return ownershipType; }
+        public Integer getDistrict() { return district; }
     }
 }

+ 7 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java

@@ -12,6 +12,7 @@ import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
 import com.zksy.cockpit.foundation.CockpitSnapshotTime;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Primary;
@@ -215,6 +216,7 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
                     equipmentBaseService.getEquipmentByPoint(point.getPointId()));
             List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
             Set<String> deviceTypes = new LinkedHashSet<>();
+            List<Integer> boundDistricts = new ArrayList<>();
             for (EquipmentBase device : boundDevices) {
                 if (device == null || isBlank(device.getEquipmentCode())) {
                     continue;
@@ -227,11 +229,13 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
                 if (boundType != null && boundType.getId() != null) {
                     deviceTypes.add(String.valueOf(boundType.getId()));
                 }
+                boundDistricts.add(device.getDistrict());
             }
             sourcePoints.add(new CockpitMapSnapshot.SourceMonitoringPoint(
                     point.getPointCode().trim(),
                     point.getPointName(),
-                    null,
+                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
+                    CockpitDistrict.aggregate(boundDistricts),
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     point.getLongitude(),
                     point.getLatitude(),
@@ -248,7 +252,8 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
             smartDevices.add(new CockpitMapSnapshot.SourceSmartDevice(
                     device.getEquipmentCode().trim(),
                     device.getEquipmentName(),
-                    null,
+                    // 设备自身片区(equipment_base.district)即权威来源。
+                    CockpitDistrict.codeOf(device.getDistrict()),
                     type == null || type.getId() == null ? null : String.valueOf(type.getId()),
                     device.getLongitude(),
                     device.getLatitude(),

+ 8 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java

@@ -67,19 +67,19 @@ public class CockpitMapSnapshot {
     public static class SourceMonitoringPoint {
         private final String pointCode;
         private final String pointName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         private final List<CockpitMonitoringPointDeviceSnapshot> devices;
 
-        public SourceMonitoringPoint(String pointCode, String pointName, String region, String typeCode,
+        public SourceMonitoringPoint(String pointCode, String pointName, Integer district, String typeCode,
                 BigDecimal longitude, BigDecimal latitude,
                 boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
             this.pointCode = pointCode;
             this.pointName = pointName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
@@ -89,7 +89,7 @@ public class CockpitMapSnapshot {
 
         public String getPointCode() { return pointCode; }
         public String getPointName() { return pointName; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
         public String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
@@ -100,17 +100,17 @@ public class CockpitMapSnapshot {
     public static class SourceSmartDevice {
         private final String deviceCode;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
 
-        public SourceSmartDevice(String deviceCode, String deviceName, String region, String typeCode,
+        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
@@ -119,7 +119,7 @@ public class CockpitMapSnapshot {
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
         public String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }

+ 19 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java

@@ -53,27 +53,41 @@ public class CockpitMapSnapshotResponse {
     public MapFilter getMapFilter() { return mapFilter; }
 
     /**
-     * 生效范围回显:请求的过滤条件(未请求为 null)+ 过滤后**全量**(分页前)的点位/设备编码。
+     * 生效范围回显:请求的过滤条件 + 过滤后**全量**(分页前)的点位/设备编码。
      * 口径与 `/monitoring/distribution` 的 `mapFilter` 一致。
+     *
+     * <p>范围维度自执行票 12 起为**片区**(`district`):旧自由串 `region` 不再作为正式契约。
+     * 「未筛」与「只看未分区」的编码都是 `null`,靠 {@link #districtFilter} 区分
+     * (`NONE` / `UNASSIGNED` / `ASSIGNED`),不靠猜。
      */
     public static class MapFilter {
         private final String industry;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
+        private final String districtFilter;
         private final String deviceType;
         private final List<String> pointCodes;
         private final List<String> deviceCodes;
 
-        public MapFilter(String industry, String region, String deviceType,
+        public MapFilter(String industry, Integer district, String districtFilter, String deviceType,
                 List<String> pointCodes, List<String> deviceCodes) {
             this.industry = industry;
-            this.region = region;
+            this.district = district;
+            this.districtFilter = districtFilter;
+            this.districtName = district == null ? null
+                    : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
             this.deviceType = deviceType;
             this.pointCodes = pointCodes;
             this.deviceCodes = deviceCodes;
         }
 
         public String getIndustry() { return industry; }
-        public String getRegion() { return region; }
+        /** 生效片区编码;未筛或「只看未分区」时为 {@code null}(用 {@link #getDistrictFilter()} 区分)。 */
+        public Integer getDistrict() { return district; }
+        /** 生效片区展示名;未筛与未分区都为 {@code null 或「未分区」},详见 {@link #getDistrictFilter()}。 */
+        public String getDistrictName() { return districtName; }
+        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
+        public String getDistrictFilter() { return districtFilter; }
         public String getDeviceType() { return deviceType; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }

+ 28 - 22
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java

@@ -2,6 +2,7 @@ package com.zksy.cockpit.map;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 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;
@@ -35,7 +36,6 @@ public class CockpitMapSnapshotService {
     private static final int MAX_PAGE_SIZE = 1000;
     private static final String WGS84 = "WGS84";
     private static final String SMART_DEVICE = "SMART_DEVICE";
-    private static final String UNASSIGNED_REGION = "未分区";
     private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
 
     private final CockpitMapSnapshotAdapter adapter;
@@ -47,17 +47,24 @@ public class CockpitMapSnapshotService {
     }
 
     /**
-     * 地图分页快照。全局筛选(industry / region / deviceType)生效时服务端完成过滤,
+     * 地图分页快照。全局筛选(industry / district / deviceType)生效时服务端完成过滤,
      * `mapFilter` 回显实际生效范围;分页与 `snapshotAt` 语义不因过滤而改变。
+     *
+     * <p>范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`);
+     * 旧自由串 `region` 不再是正式契约,入参被忽略。
      */
     public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
-            String industry, String region, String deviceType) {
+            String industry, String district, String deviceType) {
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
         validateSnapshotAt(snapshotAt);
         String selectedIndustry = resolveIndustry(industry);
-        String selectedRegion = isBlank(region) ? null : region.trim();
+        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
+        if (selectedDistrict.invalid()) {
+            throw new CockpitInvalidRequestException(
+                    "district must be 0, 1, 2 or UNASSIGNED");
+        }
         String selectedDeviceType = isBlank(deviceType) ? null : deviceType.trim();
 
         CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
@@ -76,9 +83,9 @@ public class CockpitMapSnapshotService {
 
         Map<String, CockpitMapSnapshot.SourceType> types = types(snapshot);
         List<CockpitMonitoringPointSummary> points = monitoringPoints(
-                snapshot, types, selectedIndustry, selectedRegion, selectedDeviceType);
+                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
         List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
-                snapshot, types, selectedIndustry, selectedRegion, selectedDeviceType);
+                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
         int total = points.size() + equipment.size();
         int fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
@@ -102,7 +109,7 @@ public class CockpitMapSnapshotService {
                 pagePoints,
                 pageEquipment,
                 coordinateValidation(points, equipment),
-                mapFilter(selectedIndustry, selectedRegion, selectedDeviceType, points, equipment));
+                mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment));
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -209,12 +216,8 @@ public class CockpitMapSnapshotService {
         return isBlank(typeCode) ? null : typeCode.trim();
     }
 
-    private static String regionOf(String value) {
-        return isBlank(value) ? UNASSIGNED_REGION : value.trim();
-    }
-
-    private static boolean matchesFilters(String industry, String region, String deviceType,
-            String typeCode, String rawRegion, Map<String, CockpitMapSnapshot.SourceType> types) {
+    private static boolean matchesFilters(String industry, CockpitDistrict.Query district, String deviceType,
+            String typeCode, Integer rawDistrict, Map<String, CockpitMapSnapshot.SourceType> types) {
         CockpitMapSnapshot.SourceType type = resolveType(types, typeCode);
         if (industry != null && !industry.equals(industryOf(type))) {
             return false;
@@ -222,20 +225,20 @@ public class CockpitMapSnapshotService {
         if (deviceType != null && (type == null || !deviceType.equals(typeCodeOf(type.getCode())))) {
             return false;
         }
-        return region == null || region.equals(regionOf(rawRegion));
+        return CockpitDistrict.matches(district, rawDistrict);
     }
 
     private static List<CockpitMonitoringPointSummary> monitoringPoints(
             CockpitMapSnapshot snapshot,
             Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, String region, String deviceType) {
+            String industry, CockpitDistrict.Query district, String deviceType) {
         if (snapshot.getMonitoringPoints() == null) {
             return Collections.emptyList();
         }
         return snapshot.getMonitoringPoints().stream()
                 .filter(Objects::nonNull)
                 .filter(source -> matchesFilters(
-                        industry, region, deviceType, source.getTypeCode(), source.getRegion(), types))
+                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> monitoringPoint(source, types))
                 .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
                         Comparator.nullsLast(String::compareTo)))
@@ -264,7 +267,7 @@ public class CockpitMapSnapshotService {
                 pointStatus,
                 CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
                 deviceCodes,
-                regionOf(source.getRegion()),
+                source.getDistrict(),
                 industryOf(type),
                 typeCodeOf(source.getTypeCode()),
                 typeNameOf(type),
@@ -277,7 +280,7 @@ public class CockpitMapSnapshotService {
     private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(
             CockpitMapSnapshot snapshot,
             Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, String region, String deviceType) {
+            String industry, CockpitDistrict.Query district, String deviceType) {
         if (snapshot.getSmartDevices() == null) {
             return Collections.emptyList();
         }
@@ -287,7 +290,7 @@ public class CockpitMapSnapshotService {
                 .filter(device -> device.getDeviceCode() == null
                         || !associatedDeviceCodes.contains(device.getDeviceCode()))
                 .filter(source -> matchesFilters(
-                        industry, region, deviceType, source.getTypeCode(), source.getRegion(), types))
+                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> unassociatedEquipment(source, types))
                 .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
                         Comparator.nullsLast(String::compareTo)))
@@ -314,7 +317,7 @@ public class CockpitMapSnapshotService {
         return new CockpitUnassociatedEquipmentSummary(
                 source.getDeviceCode(),
                 source.getDeviceName(),
-                regionOf(source.getRegion()),
+                source.getDistrict(),
                 industryOf(type),
                 typeCodeOf(source.getTypeCode()),
                 typeNameOf(type),
@@ -325,7 +328,7 @@ public class CockpitMapSnapshotService {
     }
 
     private static CockpitMapSnapshotResponse.MapFilter mapFilter(
-            String industry, String region, String deviceType,
+            String industry, CockpitDistrict.Query district, String deviceType,
             List<CockpitMonitoringPointSummary> points,
             List<CockpitUnassociatedEquipmentSummary> equipment) {
         Set<String> pointCodes = new LinkedHashSet<>();
@@ -348,7 +351,10 @@ public class CockpitMapSnapshotService {
             }
         }
         return new CockpitMapSnapshotResponse.MapFilter(
-                industry, region, deviceType, sorted(pointCodes), sorted(deviceCodes));
+                industry,
+                district.active() ? district.code() : null,
+                !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
+                deviceType, sorted(pointCodes), sorted(deviceCodes));
     }
 
     private static List<String> sorted(Set<String> values) {

+ 8 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.map;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 
 import java.math.BigDecimal;
@@ -12,7 +13,7 @@ public class CockpitMonitoringPointSummary {
     private final CockpitMonitoringPointStatus pointStatus;
     private final List<String> statusReasons;
     private final List<String> deviceCodes;
-    private final String region;
+    private final Integer district;
     private final String industry;
     private final String typeCode;
     private final String typeName;
@@ -22,14 +23,14 @@ public class CockpitMonitoringPointSummary {
 
     public CockpitMonitoringPointSummary(String pointCode, String pointName,
             CockpitMonitoringPointStatus pointStatus, List<String> statusReasons, List<String> deviceCodes,
-            String region, String industry, String typeCode, String typeName,
+            Integer district, String industry, String typeCode, String typeName,
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
         this.pointCode = pointCode;
         this.pointName = pointName;
         this.pointStatus = pointStatus;
         this.statusReasons = statusReasons;
         this.deviceCodes = deviceCodes;
-        this.region = region;
+        this.district = CockpitDistrict.codeOf(district);
         this.industry = industry;
         this.typeCode = typeCode;
         this.typeName = typeName;
@@ -43,7 +44,10 @@ public class CockpitMonitoringPointSummary {
     public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
     public List<String> getStatusReasons() { return statusReasons; }
     public List<String> getDeviceCodes() { return deviceCodes; }
-    public String getRegion() { return region; }
+    /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null},名称见 {@link #getDistrictName()}。 */
+    public Integer getDistrict() { return district; }
+    /** 片区展示名;未分区(空值/乱码/未识别)如实回「未分区」。 */
+    public String getDistrictName() { return CockpitDistrict.nameOf(district); }
     public String getIndustry() { return industry; }
     public String getTypeCode() { return typeCode; }
     public String getTypeName() { return typeName; }

+ 8 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java

@@ -1,13 +1,14 @@
 package com.zksy.cockpit.map;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 
 import java.math.BigDecimal;
 
 public class CockpitUnassociatedEquipmentSummary {
     private final String deviceCode;
     private final String deviceName;
-    private final String region;
+    private final Integer district;
     private final String industry;
     private final String typeCode;
     private final String typeName;
@@ -16,11 +17,11 @@ public class CockpitUnassociatedEquipmentSummary {
     private final CockpitCoordinateValidation coordinateValidation;
 
     public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
-            String region, String industry, String typeCode, String typeName,
+            Integer district, String industry, String typeCode, String typeName,
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
         this.deviceCode = deviceCode;
         this.deviceName = deviceName;
-        this.region = region;
+        this.district = CockpitDistrict.codeOf(district);
         this.industry = industry;
         this.typeCode = typeCode;
         this.typeName = typeName;
@@ -31,7 +32,10 @@ public class CockpitUnassociatedEquipmentSummary {
 
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceName() { return deviceName; }
-    public String getRegion() { return region; }
+    /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
+    public Integer getDistrict() { return district; }
+    /** 片区展示名;未分区如实回「未分区」。 */
+    public String getDistrictName() { return CockpitDistrict.nameOf(district); }
     public String getIndustry() { return industry; }
     public String getTypeCode() { return typeCode; }
     public String getTypeName() { return typeName; }

+ 9 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java

@@ -6,6 +6,7 @@ import com.zksy.base.domain.MonitorPoint;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
@@ -35,7 +36,8 @@ import java.util.stream.Collectors;
  *
  * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
  * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
- * 区域(region)在现网点位/设备上无可靠来源,统一上报 {@code null} 交由服务层归一为「未分区」。
+ * 片区(district)的权威来源是 {@code equipment_base.district}:点位按「经其上挂设备归纳」
+ * 取得(不一致/无设备 → 未分区),设备直接用自身片区。
  */
 @Component
 @Primary
@@ -86,6 +88,7 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     equipmentBaseService.getEquipmentByPoint(point.getPointId()));
             List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
             Set<String> deviceTypes = new LinkedHashSet<>();
+            List<Integer> boundDistricts = new ArrayList<>();
             for (EquipmentBase device : boundDevices) {
                 if (device == null || isBlank(device.getEquipmentCode())) {
                     continue;
@@ -98,11 +101,13 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                 if (boundType != null && boundType.getId() != null) {
                     deviceTypes.add(String.valueOf(boundType.getId()));
                 }
+                boundDistricts.add(device.getDistrict());
             }
             sourcePoints.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
                     point.getPointCode().trim(),
                     point.getPointName(),
-                    null,
+                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
+                    CockpitDistrict.aggregate(boundDistricts),
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     point.getLongitude(),
                     point.getLatitude(),
@@ -121,7 +126,8 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
             smartDevices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
                     device.getEquipmentCode().trim(),
                     device.getEquipmentName(),
-                    null,
+                    // 设备自身片区(equipment_base.district)即权威来源。
+                    CockpitDistrict.codeOf(device.getDistrict()),
                     typeCode,
                     device.getLongitude(),
                     device.getLatitude(),

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

@@ -10,6 +10,7 @@ import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
@@ -45,7 +46,7 @@ import java.util.stream.Collectors;
  * 设备无法匹配到类型时 typeCode 为 null(服务层按「未知类型」计入 excluded,不静默归类)。
  *
  * <p><b>不臆造</b>:现网无「样本过期」依据 → 不产生 EXPIRED;无坐标校核字段 → {@code coordinateVerified=false};
- * 无可靠区域来源 → region 为 null。指标态取值优先序:缺状态→UNKNOWN,离线→OFFLINE,报警→ALARM,预警中→WARNING,其余→NORMAL。
+ * 片区取设备自身 {@code equipment_base.district}(未分区为 null)。指标态取值优先序:缺状态→UNKNOWN,离线→OFFLINE,报警→ALARM,预警中→WARNING,其余→NORMAL。
  */
 @Component
 @Primary
@@ -106,7 +107,8 @@ public class BusinessCockpitMonitoringSituationAdapter implements CockpitMonitor
             devices.add(new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
                     deviceCode,
                     device.getEquipmentName(),
-                    null,
+                    // 设备自身片区(equipment_base.district)即权威来源;库中脏值由服务层归一为未分区
+                    CockpitDistrict.codeOf(device.getDistrict()),
                     typeCode,
                     metricStatus(deviceCode, fact),
                     fact.getStatusReasons(),

+ 52 - 21
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.monitoring;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 
 import java.math.BigDecimal;
@@ -15,7 +16,7 @@ public class CockpitMonitoringDistributionResponse {
     private final Range range;
     private final String updatedAt;
     private final Freshness freshness;
-    private final List<RegionDistribution> regionDistribution;
+    private final List<DistrictDistribution> districtDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<DeviceTypeDistribution> deviceTypeDistribution;
     private final List<PointStatusCount> pointStatusDistribution;
@@ -34,7 +35,7 @@ public class CockpitMonitoringDistributionResponse {
             Range range,
             String updatedAt,
             Freshness freshness,
-            List<RegionDistribution> regionDistribution,
+            List<DistrictDistribution> districtDistribution,
             List<IndustryDistribution> industryDistribution,
             List<DeviceTypeDistribution> deviceTypeDistribution,
             List<PointStatusCount> pointStatusDistribution,
@@ -51,7 +52,7 @@ public class CockpitMonitoringDistributionResponse {
         this.range = range;
         this.updatedAt = updatedAt;
         this.freshness = freshness;
-        this.regionDistribution = regionDistribution;
+        this.districtDistribution = districtDistribution;
         this.industryDistribution = industryDistribution;
         this.deviceTypeDistribution = deviceTypeDistribution;
         this.pointStatusDistribution = pointStatusDistribution;
@@ -70,7 +71,7 @@ public class CockpitMonitoringDistributionResponse {
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
     public Freshness getFreshness() { return freshness; }
-    public List<RegionDistribution> getRegionDistribution() { return regionDistribution; }
+    public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
     public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
     public List<PointStatusCount> getPointStatusDistribution() { return pointStatusDistribution; }
@@ -136,20 +137,29 @@ public class CockpitMonitoringDistributionResponse {
         public int getEmpty() { return empty; }
     }
 
-    public static class RegionDistribution {
-        private final String region;
+    /**
+     * 片区分布行。片区的权威来源是 {@code equipment_base.district}(0-城南 1-城北 2-太常),
+     * 点位按「经其上挂设备归纳」取得;未分区(无来源/不一致/脏值)回 {@code district=null} +
+     * {@code districtName="未分区"},不猜、不按行业冒充。
+     */
+    public static class DistrictDistribution {
+        private final Integer district;
+        private final String districtName;
         private final int pointCount;
         private final int deviceCount;
         private final StatusCounts statusCounts;
 
-        public RegionDistribution(String region, int pointCount, int deviceCount, StatusCounts statusCounts) {
-            this.region = region;
+        public DistrictDistribution(Integer district, int pointCount, int deviceCount, StatusCounts statusCounts) {
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.pointCount = pointCount;
             this.deviceCount = deviceCount;
             this.statusCounts = statusCounts;
         }
 
-        public String getRegion() { return region; }
+        /** 片区编码;未分区为 {@code null},名称见 {@link #getDistrictName()}。 */
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
         public int getPointCount() { return pointCount; }
         public int getDeviceCount() { return deviceCount; }
         public StatusCounts getStatusCounts() { return statusCounts; }
@@ -223,20 +233,30 @@ public class CockpitMonitoringDistributionResponse {
         public int getCount() { return count; }
     }
 
+    /**
+     * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
+     * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
+     * ({@code NONE} / {@code ASSIGNED} / {@code UNASSIGNED})区分,不靠猜。
+     */
     public static class MapFilter {
         private final String industry;
         private final String deviceType;
         private final String pointStatus;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
+        private final String districtFilter;
         private final List<String> pointCodes;
         private final List<String> deviceCodes;
 
-        public MapFilter(String industry, String deviceType, String pointStatus, String region,
+        public MapFilter(String industry, String deviceType, String pointStatus,
+                Integer district, String districtFilter,
                 List<String> pointCodes, List<String> deviceCodes) {
             this.industry = industry;
             this.deviceType = deviceType;
             this.pointStatus = pointStatus;
-            this.region = region;
+            this.district = district;
+            this.districtName = district == null ? null : CockpitDistrict.nameOf(district);
+            this.districtFilter = districtFilter;
             this.pointCodes = pointCodes;
             this.deviceCodes = deviceCodes;
         }
@@ -244,7 +264,12 @@ public class CockpitMonitoringDistributionResponse {
         public String getIndustry() { return industry; }
         public String getDeviceType() { return deviceType; }
         public String getPointStatus() { return pointStatus; }
-        public String getRegion() { return region; }
+        /** 生效片区编码;未筛或「只看未分区」时为 {@code null}(用 {@link #getDistrictFilter()} 区分)。 */
+        public Integer getDistrict() { return district; }
+        /** 生效片区展示名;未筛为 {@code null},只看未分区为「未分区」。 */
+        public String getDistrictName() { return districtName; }
+        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
+        public String getDistrictFilter() { return districtFilter; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }
     }
@@ -274,7 +299,8 @@ public class CockpitMonitoringDistributionResponse {
     public static class MonitoringPointMarker {
         private final String pointCode;
         private final String pointName;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final String industry;
         private final DeviceType deviceType;
         private final CockpitMonitoringPointStatus pointStatus;
@@ -283,12 +309,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
 
-        public MonitoringPointMarker(String pointCode, String pointName, String region, String industry,
+        public MonitoringPointMarker(String pointCode, String pointName, Integer district, String industry,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
                 BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
             this.pointCode = pointCode;
             this.pointName = pointName;
-            this.region = region;
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.industry = industry;
             this.deviceType = deviceType;
             this.pointStatus = pointStatus;
@@ -300,7 +327,8 @@ public class CockpitMonitoringDistributionResponse {
 
         public String getPointCode() { return pointCode; }
         public String getPointName() { return pointName; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
         public String getIndustry() { return industry; }
         public DeviceType getDeviceType() { return deviceType; }
         public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
@@ -313,7 +341,8 @@ public class CockpitMonitoringDistributionResponse {
     public static class UnassociatedEquipmentMarker {
         private final String deviceCode;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final String industry;
         private final DeviceType deviceType;
         private final CockpitMonitoringPointStatus pointStatus;
@@ -322,12 +351,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
 
-        public UnassociatedEquipmentMarker(String deviceCode, String deviceName, String region, String industry,
+        public UnassociatedEquipmentMarker(String deviceCode, String deviceName, Integer district, String industry,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
                 BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.industry = industry;
             this.deviceType = deviceType;
             this.pointStatus = pointStatus;
@@ -339,7 +369,8 @@ public class CockpitMonitoringDistributionResponse {
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
         public String getIndustry() { return industry; }
         public DeviceType getDeviceType() { return deviceType; }
         public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }

+ 78 - 42
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java

@@ -2,6 +2,7 @@ package com.zksy.cockpit.monitoring;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 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.CockpitFreshnessStatus;
@@ -43,7 +44,8 @@ public class CockpitMonitoringDistributionService {
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final int DEFAULT_PAGE_SIZE = 500;
     private static final int MAX_PAGE_SIZE = 1000;
-    private static final String UNASSIGNED_REGION = "未分区";
+    /** 未分区在片区分桶里的哨兵 key:不用 null 做 key,且自然排序后落在所有合法片区之后。 */
+    private static final Integer UNASSIGNED_BUCKET = Integer.MAX_VALUE;
     private static final String UNKNOWN_TYPE = "UNKNOWN_TYPE";
     private static final String TYPE_KIND_MISMATCH = "TYPE_KIND_MISMATCH";
     private static final String NON_FORMAL_INDUSTRY = "NON_FORMAL_INDUSTRY";
@@ -59,17 +61,22 @@ public class CockpitMonitoringDistributionService {
         this.clock = clock;
     }
 
+    /**
+     * 监测分布。范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`):
+     * 不是 {@code 0/1/2/UNASSIGNED} 的入参一律 400 —— 脏值不得参与筛选(决策票 06)。
+     */
     public synchronized CockpitMonitoringDistributionResponse load(
             String requestId,
             String industry,
             String deviceType,
             String pointStatus,
-            String region,
+            String district,
             Integer page,
             Integer pageSize,
             String snapshotAt) {
         String selectedIndustry = resolveIndustry(industry);
         CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
@@ -80,7 +87,7 @@ public class CockpitMonitoringDistributionService {
                 selectedIndustry,
                 deviceType,
                 selectedStatus,
-                region,
+                selectedDistrict,
                 safePage,
                 safePageSize);
     }
@@ -90,13 +97,14 @@ public class CockpitMonitoringDistributionService {
             String industry,
             String deviceType,
             String pointStatus,
-            String region,
+            String district,
             Integer page,
             Integer pageSize,
             String snapshotAt,
             CockpitMonitoringDistributionDeviceStatusDelta delta) {
         String selectedIndustry = resolveIndustry(industry);
         CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
@@ -113,7 +121,7 @@ public class CockpitMonitoringDistributionService {
                     selectedIndustry,
                     deviceType,
                     selectedStatus,
-                    region,
+                    selectedDistrict,
                     safePage,
                     safePageSize);
         }
@@ -124,7 +132,7 @@ public class CockpitMonitoringDistributionService {
                     selectedIndustry,
                     deviceType,
                     selectedStatus,
-                    region,
+                    selectedDistrict,
                     safePage,
                     safePageSize);
         }
@@ -140,7 +148,7 @@ public class CockpitMonitoringDistributionService {
                 selectedIndustry,
                 deviceType,
                 selectedStatus,
-                region,
+                selectedDistrict,
                 safePage,
                 safePageSize);
     }
@@ -225,7 +233,7 @@ public class CockpitMonitoringDistributionService {
             String selectedIndustry,
             String deviceType,
             CockpitMonitoringPointStatus selectedStatus,
-            String region,
+            CockpitDistrict.Query selectedDistrict,
             int page,
             int pageSize) {
         CockpitMonitoringDistributionSnapshot snapshot = state.snapshot;
@@ -272,8 +280,9 @@ public class CockpitMonitoringDistributionService {
                     invalidCoordinateCount++;
                     continue;
                 }
-                String markerRegion = regionOf(point.getRegion());
-                if (!matchesRegion(region, markerRegion)) {
+                // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
+                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
+                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
                     continue;
                 }
                 List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
@@ -293,7 +302,7 @@ public class CockpitMonitoringDistributionService {
                 pointMarkers.add(new Marker(
                         point.getPointCode().trim(),
                         point.getPointName(),
-                        markerRegion,
+                        markerDistrict,
                         type.getIndustry(),
                         type.getCode(),
                         type.getName(),
@@ -336,8 +345,9 @@ public class CockpitMonitoringDistributionService {
                     invalidCoordinateCount++;
                     continue;
                 }
-                String markerRegion = regionOf(device.getRegion());
-                if (!matchesRegion(region, markerRegion)) {
+                // 设备自身片区(equipment_base.district)即权威来源
+                Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
+                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
                     continue;
                 }
                 CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
@@ -362,7 +372,7 @@ public class CockpitMonitoringDistributionService {
                 deviceMarkers.add(new Marker(
                         applied.getDeviceCode().trim(),
                         applied.getDeviceName(),
-                        markerRegion,
+                        markerDistrict,
                         type.getIndustry(),
                         type.getCode(),
                         type.getName(),
@@ -380,25 +390,26 @@ public class CockpitMonitoringDistributionService {
         pointMarkers.sort(Comparator.comparing(Marker::getCode, Comparator.nullsLast(String::compareTo)));
         deviceMarkers.sort(Comparator.comparing(Marker::getCode, Comparator.nullsLast(String::compareTo)));
 
-        Map<String, Accumulator> byRegion = new LinkedHashMap<>();
+        // 片区分桶按**归一后的编码**归并:未分区(null)自成一桶,不与任何合法片区混同
+        Map<Integer, Accumulator> byDistrict = new LinkedHashMap<>();
         Map<String, Accumulator> byIndustry = new LinkedHashMap<>();
         Map<String, Accumulator> byType = new LinkedHashMap<>();
         int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
         for (Marker marker : pointMarkers) {
-            accumulate(byRegion, marker.region, marker);
+            accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
             statusCounts[marker.status.ordinal()]++;
         }
         for (Marker marker : deviceMarkers) {
-            accumulate(byRegion, marker.region, marker);
+            accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
             statusCounts[marker.status.ordinal()]++;
         }
 
-        List<CockpitMonitoringDistributionResponse.RegionDistribution> regionRows =
-                regionRows(byRegion);
+        List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
+                districtRows(byDistrict);
         List<CockpitMonitoringDistributionResponse.IndustryDistribution> industryRows =
                 industryRows(byIndustry);
         List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
@@ -447,7 +458,7 @@ public class CockpitMonitoringDistributionService {
                         hasResolved ? "READY" : "NO_RESOLVED_DEVICES",
                         updatedAt,
                         businessTime()),
-                regionRows,
+                districtRows,
                 industryRows,
                 typeRows,
                 statusRows,
@@ -455,7 +466,10 @@ public class CockpitMonitoringDistributionService {
                         selectedIndustry,
                         isBlank(deviceType) ? null : deviceType.trim(),
                         selectedStatus == null ? null : selectedStatus.name(),
-                        isBlank(region) ? null : region.trim(),
+                        // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot)
+                        selectedDistrict.code(),
+                        !selectedDistrict.active() ? "NONE"
+                                : (selectedDistrict.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
                         sorted(pointCodes),
                         sorted(deviceCodes)),
                 new CockpitMonitoringDistributionResponse.Pagination(
@@ -488,20 +502,37 @@ public class CockpitMonitoringDistributionService {
                 source.getStatusTime());
     }
 
+    /**
+     * 片区分桶:未分区用哨兵 key,避免把 null 当 Map key;出口再还原为响应契约的 {@code null}。
+     */
+    private static void accumulate(Map<Integer, Accumulator> buckets, Integer key, Marker marker) {
+        Accumulator accumulator = buckets.computeIfAbsent(
+                key == null ? UNASSIGNED_BUCKET : key, ignore -> new Accumulator());
+        accumulator.add(marker);
+    }
+
     private static void accumulate(Map<String, Accumulator> buckets, String key, Marker marker) {
         Accumulator accumulator = buckets.computeIfAbsent(key, ignore -> new Accumulator());
         accumulator.add(marker);
     }
 
-    private static List<CockpitMonitoringDistributionResponse.RegionDistribution> regionRows(
-            Map<String, Accumulator> buckets) {
-        List<String> keys = new ArrayList<>(buckets.keySet());
-        Collections.sort(keys, Comparator.nullsLast(String::compareTo));
-        List<CockpitMonitoringDistributionResponse.RegionDistribution> rows = new ArrayList<>();
-        for (String key : keys) {
+    /**
+     * 片区行顺序固定为 城南(0) → 城北(1) → 太常(2) → 未分区,保证响应可预期;
+     * 未分区是归一的必然结果,排最后(口径同基础设施基础档案的 districtDistribution)。
+     */
+    private static List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows(
+            Map<Integer, Accumulator> buckets) {
+        List<Integer> keys = new ArrayList<>(buckets.keySet());
+        Collections.sort(keys);
+        List<CockpitMonitoringDistributionResponse.DistrictDistribution> rows = new ArrayList<>();
+        for (Integer key : keys) {
             Accumulator accumulator = buckets.get(key);
-            rows.add(new CockpitMonitoringDistributionResponse.RegionDistribution(
-                    key, accumulator.pointCount, accumulator.deviceCount, accumulator.statusCounts()));
+            // 哨兵 key 在出口还原为 null:响应层只认「未分区=编码 null + 名称未分区」
+            rows.add(new CockpitMonitoringDistributionResponse.DistrictDistribution(
+                    UNASSIGNED_BUCKET.equals(key) ? null : key,
+                    accumulator.pointCount,
+                    accumulator.deviceCount,
+                    accumulator.statusCounts()));
         }
         return rows;
     }
@@ -590,6 +621,19 @@ public class CockpitMonitoringDistributionService {
         return resolved;
     }
 
+    /**
+     * 片区入参解析:不传=不筛,{@code 0/1/2} 按片区,{@code UNASSIGNED} 只看未分区;
+     * 其它值一律 400 —— 脏值不得静默变成「不筛」而返回全量。
+     */
+    private static CockpitDistrict.Query resolveDistrict(String district) {
+        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
+        if (query.invalid()) {
+            throw new CockpitInvalidRequestException(
+                    "district must be 0, 1, 2 or UNASSIGNED");
+        }
+        return query;
+    }
+
     private static CockpitMonitoringPointStatus resolvePointStatus(String pointStatus) {
         if (isBlank(pointStatus)) {
             return null;
@@ -649,14 +693,6 @@ public class CockpitMonitoringDistributionService {
                 OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
     }
 
-    private static String regionOf(String value) {
-        return isBlank(value) ? UNASSIGNED_REGION : value.trim();
-    }
-
-    private static boolean matchesRegion(String filter, String markerRegion) {
-        return isBlank(filter) || filter.trim().equals(markerRegion);
-    }
-
     private static List<String> codes(List<CockpitMonitoringPointDeviceSnapshot> devices) {
         List<String> result = new ArrayList<>();
         for (CockpitMonitoringPointDeviceSnapshot device : devices) {
@@ -719,7 +755,7 @@ public class CockpitMonitoringDistributionService {
     private static class Marker {
         private final String code;
         private final String name;
-        private final String region;
+        private final Integer district;
         private final String industry;
         private final String typeCode;
         private final String typeName;
@@ -731,13 +767,13 @@ public class CockpitMonitoringDistributionService {
         private final boolean point;
         private final List<String> deviceCodes;
 
-        private Marker(String code, String name, String region, String industry, String typeCode, String typeName,
+        private Marker(String code, String name, Integer district, String industry, String typeCode, String typeName,
                 CockpitMonitoringPointStatus status, List<String> statusReasons, BigDecimal longitude,
                 BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
                 List<String> deviceCodes) {
             this.code = code;
             this.name = name;
-            this.region = region;
+            this.district = district;
             this.industry = industry;
             this.typeCode = typeCode;
             this.typeName = typeName;
@@ -756,14 +792,14 @@ public class CockpitMonitoringDistributionService {
 
         private CockpitMonitoringDistributionResponse.MonitoringPointMarker toMonitoringPointMarker() {
             return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
-                    code, name, region, industry,
+                    code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     status, statusReasons, longitude, latitude, coordinateValidation);
         }
 
         private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
             return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
-                    code, name, region, industry,
+                    code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     status, statusReasons, longitude, latitude, coordinateValidation);
         }

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

@@ -99,7 +99,7 @@ public class CockpitMonitoringDistributionSnapshot {
     public static class SourceMonitoringPoint {
         private final String pointCode;
         private final String pointName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
@@ -109,7 +109,7 @@ public class CockpitMonitoringDistributionSnapshot {
         public SourceMonitoringPoint(
                 String pointCode,
                 String pointName,
-                String region,
+                Integer district,
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal latitude,
@@ -117,7 +117,7 @@ public class CockpitMonitoringDistributionSnapshot {
                 List<CockpitMonitoringPointDeviceSnapshot> devices) {
             this.pointCode = pointCode;
             this.pointName = pointName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
@@ -133,8 +133,9 @@ public class CockpitMonitoringDistributionSnapshot {
             return pointName;
         }
 
-        public String getRegion() {
-            return region;
+        /** 点位无自有片区列,按「经其上挂设备归纳」取得;未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
         }
 
         public String getTypeCode() {
@@ -161,7 +162,7 @@ public class CockpitMonitoringDistributionSnapshot {
     public static class SourceSmartDevice {
         private final String deviceCode;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
@@ -176,7 +177,7 @@ public class CockpitMonitoringDistributionSnapshot {
         public SourceSmartDevice(
                 String deviceCode,
                 String deviceName,
-                String region,
+                Integer district,
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal latitude,
@@ -189,7 +190,7 @@ public class CockpitMonitoringDistributionSnapshot {
                 String statusTime) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
@@ -210,8 +211,9 @@ public class CockpitMonitoringDistributionSnapshot {
             return deviceName;
         }
 
-        public String getRegion() {
-            return region;
+        /** 设备自身片区(equipment_base.district)即权威来源;未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
         }
 
         public String getTypeCode() {

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

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.monitoring;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -185,7 +186,8 @@ public class CockpitMonitoringSituationResponse {
         private final String deviceName;
         private final DeviceType deviceType;
         private final String industry;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final String abnormalType;
         private final String status;
         private final List<String> statusReasons;
@@ -198,7 +200,7 @@ public class CockpitMonitoringSituationResponse {
                 String deviceName,
                 DeviceType deviceType,
                 String industry,
-                String region,
+                Integer district,
                 String abnormalType,
                 String status,
                 List<String> statusReasons,
@@ -209,7 +211,9 @@ public class CockpitMonitoringSituationResponse {
             this.deviceName = deviceName;
             this.deviceType = deviceType;
             this.industry = industry;
-            this.region = region;
+            // 响应值归一:脏值/空值一律收敛为「未分区」,如实呈现,不猜
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.abnormalType = abnormalType;
             this.status = status;
             this.statusReasons = statusReasons;
@@ -222,7 +226,9 @@ public class CockpitMonitoringSituationResponse {
         public String getDeviceName() { return deviceName; }
         public DeviceType getDeviceType() { return deviceType; }
         public String getIndustry() { return industry; }
-        public String getRegion() { return region; }
+        /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null},名称见 {@link #getDistrictName()}。 */
+        public Integer getDistrict() { return district; }
+        public String getDistrictName() { return districtName; }
         public String getAbnormalType() { return abnormalType; }
         public String getStatus() { return status; }
         public List<String> getStatusReasons() { return statusReasons; }

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

@@ -2,6 +2,7 @@ package com.zksy.cockpit.monitoring;
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 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.CockpitFreshnessStatus;
@@ -221,7 +222,8 @@ public class CockpitMonitoringSituationService {
                             device.getDeviceCode().trim(),
                             device.getDeviceName(),
                             type,
-                            regionOf(device.getRegion()),
+                            // 设备自身片区(equipment_base.district)即权威来源;未分区为 null
+                            CockpitDistrict.codeOf(device.getDistrict()),
                             status,
                             device.getStatusReasons(),
                             device.getLongitude(),
@@ -473,10 +475,6 @@ public class CockpitMonitoringSituationService {
         return index < 0 ? Integer.MAX_VALUE : index;
     }
 
-    private static String regionOf(String value) {
-        return isBlank(value) ? "未分区" : value.trim();
-    }
-
     private static List<String> sorted(Set<String> values) {
         List<String> result = new ArrayList<>(values);
         Collections.sort(result, Comparator.nullsLast(String::compareTo));
@@ -538,7 +536,7 @@ public class CockpitMonitoringSituationService {
         private final String deviceCode;
         private final String deviceName;
         private final CockpitMonitoringSituationSnapshot.SourceType type;
-        private final String region;
+        private final Integer district;
         private final String status;
         private final List<String> statusReasons;
         private final BigDecimal longitude;
@@ -549,7 +547,7 @@ public class CockpitMonitoringSituationService {
                 String deviceCode,
                 String deviceName,
                 CockpitMonitoringSituationSnapshot.SourceType type,
-                String region,
+                Integer district,
                 String status,
                 List<String> statusReasons,
                 BigDecimal longitude,
@@ -558,7 +556,7 @@ public class CockpitMonitoringSituationService {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.type = type;
-            this.region = region;
+            this.district = district;
             this.status = status;
             this.statusReasons = statusReasons;
             this.longitude = longitude;
@@ -577,7 +575,7 @@ public class CockpitMonitoringSituationService {
                     new CockpitMonitoringSituationResponse.DeviceType(
                             type.getCode(), type.getName()),
                     type.getIndustry(),
-                    region,
+                    district,
                     status,
                     status,
                     statusReasons == null ? Collections.emptyList() : statusReasons,

+ 6 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationSnapshot.java

@@ -93,7 +93,7 @@ public class CockpitMonitoringSituationSnapshot {
     public static class SourceMetricDevice {
         private final String deviceCode;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String metricStatus;
         private final List<String> statusReasons;
@@ -105,7 +105,7 @@ public class CockpitMonitoringSituationSnapshot {
         public SourceMetricDevice(
                 String deviceCode,
                 String deviceName,
-                String region,
+                Integer district,
                 String typeCode,
                 String metricStatus,
                 List<String> statusReasons,
@@ -115,7 +115,7 @@ public class CockpitMonitoringSituationSnapshot {
                 boolean coordinateVerified) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.metricStatus = metricStatus;
             this.statusReasons = statusReasons;
@@ -133,8 +133,9 @@ public class CockpitMonitoringSituationSnapshot {
             return deviceName;
         }
 
-        public String getRegion() {
-            return region;
+        /** 设备自身片区(equipment_base.district)即权威来源;未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
         }
 
         public String getTypeCode() {

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

@@ -19,7 +19,9 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
@@ -56,27 +58,118 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         if (equipment == null) {
             return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, Collections.emptyList());
         }
-        List<WorkOrder> orders = workOrderService.lambdaQuery()
-                .eq(WorkOrder::getDeviceId, equipment.getEquipmentId())
-                .orderByDesc(WorkOrder::getCreateTime)
-                .list();
+        List<WorkOrder> orders = deviceOrders(equipment.getEquipmentId());
+        // 设备维度:返回该设备**已有全部工单**。逐条解析该工单实际关联的报警
+        // (alarm_data.work_order_id = work_order.order_id,经真实库核实),
+        // 未关联任何报警的工单以 associatedWithCurrentEvent=false 显式标出 ——
+        // 前端据此提示「未关联当前报警」,而不是从 null 反推(执行票 12 范围 1)。
+        Map<Long, AlarmData> alarmByOrderId = alarmsByOrderId(orders);
         List<CockpitWorkOrderSummary> summaries = orders.stream()
-                .map(order -> toSummary(order, null, null))
+                .map(order -> {
+                    AlarmData alarm = alarmByOrderId.get(order.getOrderId());
+                    return alarm == null
+                            ? toSummary(order, null, null, false)
+                            : toSummary(order, "ALARM", alarmReference(alarm), true);
+                })
                 .collect(Collectors.toList());
         return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, summaries);
     }
 
+    /**
+     * 按对外报警标识解析报警:先按 {@code alarm_no}(真实单号),再退回主键 {@code id}。
+     *
+     * <p>报警对外标识就是 `alarm_data.id`(真实 UUID,实测 6 行互不相同)。
+     * 主键缺失时回退按 `alarm_no` 查,便于调用方用真实单号定位。
+     */
+    private AlarmData findAlarmByReference(String eventCode) {
+        if (isBlank(eventCode)) {
+            return null;
+        }
+        String reference = eventCode.trim();
+        AlarmData byId = alarmDataService.getById(reference);
+        if (byId != null) {
+            return byId;
+        }
+        List<AlarmData> byNo = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
+                .eq(AlarmData::getAlarmNo, reference)
+                .last("LIMIT 1"));
+        if (byNo != null) {
+            for (AlarmData alarm : byNo) {
+                if (alarm != null) {
+                    return alarm;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 报警的对外可读标识。
+     *
+     * <p>`alarm_data.id` 是真实 UUID、可区分报警,直接用即可;仅在缺失时退回
+     * `alarm_no`(不编造)。
+     */
+    private static String alarmReference(AlarmData alarm) {
+        return isBlank(alarm.getId()) ? alarm.getAlarmNo() : alarm.getId();
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    /**
+     * 设备已有全部工单,按创建时间倒序。
+     *
+     * <p>用 `list(wrapper)` 而非 `lambdaQuery()` 链式调用:与仓库其它只读适配器一致,
+     * 也让单测可以像其它适配器测试那样直接桩 `list(...)`,不必伪造 MyBatis 链式对象。
+     */
+    private List<WorkOrder> deviceOrders(String equipmentId) {
+        if (isBlank(equipmentId)) {
+            return Collections.emptyList();
+        }
+        return workOrderService.list(new LambdaQueryWrapper<WorkOrder>()
+                .eq(WorkOrder::getDeviceId, equipmentId.trim())
+                .orderByDesc(WorkOrder::getCreateTime));
+    }
+
+    /** 批量解析「工单 → 关联报警」:一次查询覆盖本页全部工单,避免逐条打库。 */
+    private Map<Long, AlarmData> alarmsByOrderId(List<WorkOrder> orders) {
+        List<Long> orderIds = orders.stream()
+                .map(WorkOrder::getOrderId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (orderIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<Long, AlarmData> result = new LinkedHashMap<>();
+        for (AlarmData alarm : alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
+                .in(AlarmData::getWorkOrderId, orderIds))) {
+            if (alarm != null && alarm.getWorkOrderId() != null) {
+                // 同一工单若被多条报警引用,取最先返回的一条即可(不臆造「主报警」规则)。
+                result.putIfAbsent(alarm.getWorkOrderId(), alarm);
+            }
+        }
+        return result;
+    }
+
     @Override
     public CockpitWorkOrderReadSnapshot loadByEvent(String eventSource, String eventCode) {
         if ("ALARM".equals(eventSource)) {
-            AlarmData alarm = alarmDataService.getById(eventCode);
-            List<WorkOrder> orders = alarm == null || alarm.getWorkOrderId() == null ? Collections.emptyList()
-                    : workOrderService.lambdaQuery()
-                    .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
-                    .orderByDesc(WorkOrder::getCreateTime)
-                    .list();
-            return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode,
-                    orders.stream().map(order -> toSummary(order, eventSource, eventCode)).collect(Collectors.toList()));
+            // 报警对外标识=alarm_data.id(真实 UUID,实测互不相同);按 alarm_no 亦可定位。
+            AlarmData alarm = findAlarmByReference(eventCode);
+            List<WorkOrder> orders = alarm == null || alarm.getWorkOrderId() == null
+                    ? Collections.emptyList()
+                    : workOrderService.list(new LambdaQueryWrapper<WorkOrder>()
+                            .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
+                            .orderByDesc(WorkOrder::getCreateTime));
+            // 报警维度:工单经 alarm_data.work_order_id 反查,命中即「实际关联本报警」。
+            // 回显的 eventCode 用报警真实单号(与请求标识一致的可读值)。
+            String resolvedEventCode = alarm == null ? eventCode : alarmReference(alarm);
+            return new CockpitWorkOrderReadSnapshot(null, eventSource, resolvedEventCode,
+                    orders.stream()
+                            .map(order -> toSummary(order, eventSource, resolvedEventCode, true))
+                            .collect(Collectors.toList()));
         }
         if ("WARNING".equals(eventSource)) {
             EarlyWarning warning = earlyWarningService.lambdaQuery()
@@ -97,7 +190,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                     ? Collections.emptyList()
                     : findOrdersByAlarmIds(alarmIds);
             List<CockpitWorkOrderSummary> summaries = orders.stream()
-                    .map(order -> toSummary(order, eventSource, eventCode))
+                    .map(order -> toSummary(order, eventSource, eventCode, true))
                     .collect(Collectors.toList());
             return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode, summaries);
         }
@@ -116,10 +209,12 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         List<CockpitWorkOrderTimelineEntry> entries = logs.stream()
                 .map(log -> toTimelineEntry(order, log))
                 .collect(Collectors.toList());
-        return new CockpitWorkOrderTimelineSnapshot(toSummary(order, null, null), entries);
+        // 时间线单张工单:无「当前报警」语境,关联标记同设备维度。
+        return new CockpitWorkOrderTimelineSnapshot(toSummary(order, null, null, false), entries);
     }
 
-    private CockpitWorkOrderSummary toSummary(WorkOrder order, String eventSource, String eventCode) {
+    private CockpitWorkOrderSummary toSummary(
+            WorkOrder order, String eventSource, String eventCode, boolean associatedWithCurrentEvent) {
         CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byCode(order.getOrderStatus());
         return new CockpitWorkOrderSummary(
                 order.getOrderNo(),
@@ -133,7 +228,8 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                 format(order.getPlanFinishTime()),
                 format(order.getRealFinishTime()),
                 eventSource == null ? null : new CockpitWorkOrderEventAssociation(
-                        eventSource, eventCode, format(order.getCreateTime())));
+                        eventSource, eventCode, format(order.getCreateTime())),
+                associatedWithCurrentEvent);
     }
 
     private String resolveDeviceCode(String deviceId) {

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

@@ -13,6 +13,8 @@ public class CockpitWorkOrderReadResponse {
     private final String stateReason;
     private final List<CockpitWorkOrderState> stateDictionary;
     private final List<CockpitWorkOrderSummary> workOrders;
+    /** 本结果中未与当前报警/预警关联的工单数(设备维度下通常大于 0,须显式提示)。 */
+    private final int unassociatedWorkOrderCount;
 
     public CockpitWorkOrderReadResponse(
             String requestId,
@@ -35,6 +37,10 @@ public class CockpitWorkOrderReadResponse {
         this.stateReason = stateReason;
         this.stateDictionary = stateDictionary;
         this.workOrders = workOrders;
+        this.unassociatedWorkOrderCount = workOrders == null ? 0
+                : (int) workOrders.stream()
+                        .filter(order -> order != null && !order.isAssociatedWithCurrentEvent())
+                        .count();
     }
 
     public String getRequestId() { return requestId; }
@@ -47,4 +53,13 @@ public class CockpitWorkOrderReadResponse {
     public String getStateReason() { return stateReason; }
     public List<CockpitWorkOrderState> getStateDictionary() { return stateDictionary; }
     public List<CockpitWorkOrderSummary> getWorkOrders() { return workOrders; }
+
+    /**
+     * 未关联当前报警/预警的工单数。
+     *
+     * <p>设备维度查询返回该设备全部工单,其中可能有未与当前报警关联者 ——
+     * 该计数与每张工单的 {@code associatedWithCurrentEvent} 同源,供界面显式提示
+     * (执行票 12 范围 1:「未关联当前报警的工单明确提示」)。
+     */
+    public int getUnassociatedWorkOrderCount() { return unassociatedWorkOrderCount; }
 }

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

@@ -12,6 +12,8 @@ public class CockpitWorkOrderSummary {
     private final String planFinishTime;
     private final String realFinishTime;
     private final CockpitWorkOrderEventAssociation eventAssociation;
+    /** 该工单是否与本查询的报警/预警**实际关联**(决策票 12:未关联须显式提示,不靠 null 猜)。 */
+    private final boolean associatedWithCurrentEvent;
 
     public CockpitWorkOrderSummary(
             String workOrderCode,
@@ -24,7 +26,8 @@ public class CockpitWorkOrderSummary {
             String updateTime,
             String planFinishTime,
             String realFinishTime,
-            CockpitWorkOrderEventAssociation eventAssociation) {
+            CockpitWorkOrderEventAssociation eventAssociation,
+            boolean associatedWithCurrentEvent) {
         this.workOrderCode = workOrderCode;
         this.deviceCode = deviceCode;
         this.orderLevel = orderLevel;
@@ -36,6 +39,7 @@ public class CockpitWorkOrderSummary {
         this.planFinishTime = planFinishTime;
         this.realFinishTime = realFinishTime;
         this.eventAssociation = eventAssociation;
+        this.associatedWithCurrentEvent = associatedWithCurrentEvent;
     }
 
     public String getWorkOrderCode() { return workOrderCode; }
@@ -49,4 +53,13 @@ public class CockpitWorkOrderSummary {
     public String getPlanFinishTime() { return planFinishTime; }
     public String getRealFinishTime() { return realFinishTime; }
     public CockpitWorkOrderEventAssociation getEventAssociation() { return eventAssociation; }
-}
+
+    /**
+     * 是否与本次查询的对象(报警/预警)实际关联。
+     *
+     * <p>设备维度的查询(`/equipments/{code}/work-orders`)返回该设备**已有全部工单**,
+     * 其中未与当前报警关联的工单必须能被前端识别 —— 故为每张工单给出显式布尔值,
+     * 而不是让调用方从 {@code eventAssociation == null} 反推(执行票 12 范围 1)。
+     */
+    public boolean isAssociatedWithCurrentEvent() { return associatedWithCurrentEvent; }
+}