Przeglądaj źródła

feat(cockpit): 报警/工单读模型与 district 片区契约

按决策票 06 / 规格 10 / 执行票 12 的六项维护者裁定实施:把旧自由串
`region`(实测恒为「未分区」,适配器写死 null)改为权威片区 `district`,
并补齐工单读模型的未关联显式提示。

## 片区口径单源(新增)

`cockpit/foundation/CockpitDistrict.java`:归一(codeOf/nameOf)、点位归纳
(aggregate:多设备不一致或无设备→未分区)、入参解析(parseQuery:只认
0/1/2/UNASSIGNED,脏值→400)、匹配(matches)与「未分区」常量。

权威来源=`equipment_base.district`(0城南/1城北/2太常,见 DistrictEnum)。
`monitor_point` 无片区列、`PipeNetworkBase` 无该字段、`equipment_point_rel`
实测 0 行,故点位按「经其上挂设备归纳」、设施一律未分区 —— 如实呈现,
**不加数据库列、不用行业或管理单位冒充**(维护者裁定)。

## 六族统一改造

- 地图摘要:`map-snapshot?district=...`;点位/设备双字段
  `district`(Integer,未分区 null)+`districtName`;`mapFilter` 增
  `districtFilter`(NONE/ASSIGNED/UNASSIGNED) —— 「未筛」与「只看未分区」
  编码都是 null,必须靠该字段区分。
- 运行监测:distribution/situation 的 `regionDistribution` →
  `districtDistribution`,marker 双字段。
- 基础设施:base-archives 同上;管理信息按决策票 06 **删除**
  `organizations`/`ownershipDistribution`/`totals.unmaintainedCount`/
  `totals.organizationCount` 与 Organization/Ownership 内部类,改回
  `districtIndustry[]`(片区×行业的设施/设备计数)。
- 事件多维分析:维度 REGION/区域 → DISTRICT/片区,桶码 0/1/2/UNASSIGNED。

## 工单读模型(范围 1)

`CockpitWorkOrderSummary` 新增显式 `associatedWithCurrentEvent`,
`CockpitWorkOrderReadResponse` 新增 `unassociatedWorkOrderCount`:
设备维度返回该设备已有全部工单,其中未与当前报警关联者被**显式**标出,
调用方不必从 `eventAssociation == null` 反推(本票验收句「未关联当前报警
的工单明确提示」)。关联键以真实库核实为准:
`alarm_data.work_order_id = work_order.order_id`(维护者原述
`alarm_no = order_no` 经实测命中 0 条,且两列均不存在)。

## 验证

`mvn -o -pl pipe-network-service/zksy-admin -am compile` 通过;
`-Dtest=Cockpit*Test` **311/311 通过**(基线 299,新增 12 例,覆盖
district 归一/脏值 400/UNASSIGNED 筛选/点位归纳/删字段形状/关联标记)。

`docs/cockpit/README.md` 同步新契约与片区章节。

未提交前未部署,运行实例仍是旧契约;真实环境复验追加到执行票 17。
基线卡片:驾驶舱真实数据接线地图 handoff/baselines/work-order-read-model-district.md
Kazerin 1 tydzień temu
rodzic
commit
ee2e1b690c
48 zmienionych plików z 1517 dodań i 722 usunięć
  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. 2 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEquipmentDetailAggregateControllerTest.java
  12. 63 30
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureBaseArchivesControllerTest.java
  13. 152 110
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureManagementInformationControllerTest.java
  14. 41 17
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  15. 72 35
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  16. 20 13
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java
  17. 105 20
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMultidimensionalAnalysisControllerTest.java
  18. 64 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadControllerTest.java
  19. 16 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java
  20. 22 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisResponse.java
  21. 32 24
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisService.java
  22. 13 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisSnapshot.java
  23. 146 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitDistrict.java
  24. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureBaseArchivesAdapter.java
  25. 24 22
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/BusinessCockpitInfrastructureManagementInformationAdapter.java
  26. 58 17
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesResponse.java
  27. 45 19
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesService.java
  28. 11 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesSnapshot.java
  29. 68 67
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationResponse.java
  30. 81 117
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationService.java
  31. 11 13
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationSnapshot.java
  32. 7 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  33. 8 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  34. 19 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  35. 28 22
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  36. 8 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java
  37. 8 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  38. 9 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  39. 4 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringSituationAdapter.java
  40. 52 21
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  41. 78 42
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  42. 12 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  43. 10 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationResponse.java
  44. 7 9
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationService.java
  45. 6 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationSnapshot.java
  46. 45 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  47. 15 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderReadResponse.java
  48. 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/infrastructure` | — |
 | 总体概览 | `GET /api/cockpit/v1/overview/monitoring` | `industry` |
 | 总体概览 | `GET /api/cockpit/v1/overview/monitoring` | `industry` |
 | 总体概览 | `GET /api/cockpit/v1/overview/warning-settings` | `mode`(WARNING\|ALARM) `range` `startTime` `endTime` |
 | 总体概览 | `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/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/infrastructure/emergency-support` | `category` `industry` `area` |
 | 运行监测 | `GET /api/cockpit/v1/monitoring/overview` | `industry` `deviceType` |
 | 运行监测 | `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/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/current` | `mode`(必填) `level` `industry` `status` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` |
 | 预警处置 | `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/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/monitoring-points/{monitoringPointCode}?snapshotAt` |
 | 设备档案(含最新图片) | `GET /api/cockpit/v1/equipments/{deviceCode}/archive` |
 | 设备档案(含最新图片) | `GET /api/cockpit/v1/equipments/{deviceCode}/archive` |
 | 类型指标 | `GET /api/cockpit/v1/equipments/{deviceCode}/metrics/current`、`.../metrics/history` |
 | 类型指标 | `GET /api/cockpit/v1/equipments/{deviceCode}/metrics/current`、`.../metrics/history` |
@@ -65,10 +65,19 @@
 **全局行业/范围筛选落到地图,以 `map-snapshot` 为准**(本结论替代「以 `/monitoring/distribution` 为准」的候选路径):
 **全局行业/范围筛选落到地图,以 `map-snapshot` 为准**(本结论替代「以 `/monitoring/distribution` 为准」的候选路径):
 
 
 - 地图(跨四个模式常驻的中央一张图)只从 `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` 的对象。
 - `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`(离线自包含,无需后端)。
 在线试用每个切片的样例响应:打开 `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 = "industry", required = false) String industry,
             @RequestParam(value = "facilityType", required = false) String facilityType,
             @RequestParam(value = "facilityType", required = false) String facilityType,
             @RequestParam(value = "ageBucket", required = false) String ageBucket,
             @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) {
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
         return ResponseEntity.ok(AjaxResult.success(
                 baseArchivesService.load(
                 baseArchivesService.load(
@@ -41,7 +41,7 @@ public class CockpitInfrastructureBaseArchivesController {
                         industry,
                         industry,
                         facilityType,
                         facilityType,
                         ageBucket,
                         ageBucket,
-                        region)));
+                        district)));
     }
     }
 
 
     @ExceptionHandler(CockpitInvalidRequestException.class)
     @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")
     @GetMapping("/api/cockpit/v1/infrastructure/management-information")
     public ResponseEntity<AjaxResult> managementInformation(
     public ResponseEntity<AjaxResult> managementInformation(
             @RequestParam(value = "industry", required = false) String industry,
             @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) {
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
         return ResponseEntity.ok(AjaxResult.success(
                 managementInformationService.load(
                 managementInformationService.load(
-                        resolveRequestId(requestId), industry, organizationCode)));
+                        resolveRequestId(requestId), industry, district)));
     }
     }
 
 
     @ExceptionHandler(CockpitInvalidRequestException.class)
     @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 = "pageSize", defaultValue = "500") Integer pageSize,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
             @RequestParam(value = "industry", required = false) String industry,
             @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,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         CockpitMapSnapshotResponse response = mapSnapshotService.load(
         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));
         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 = "industry", required = false) String industry,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestParam(value = "pointStatus", required = false) String pointStatus,
             @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 = "page", defaultValue = "1") Integer page,
             @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
             @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
@@ -45,7 +46,7 @@ public class CockpitMonitoringDistributionController {
                         industry,
                         industry,
                         deviceType,
                         deviceType,
                         pointStatus,
                         pointStatus,
-                        region,
+                        district,
                         page,
                         page,
                         pageSize,
                         pageSize,
                         snapshotAt)));
                         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 = "eventType", required = false) String eventType,
             @RequestParam(value = "cause", required = false) String cause,
             @RequestParam(value = "cause", required = false) String cause,
             @RequestParam(value = "deviceType", required = false) String deviceType,
             @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 = "startTime", required = false) String startTime,
             @RequestParam(value = "endTime", required = false) String endTime,
             @RequestParam(value = "endTime", required = false) String endTime,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
@@ -49,7 +50,7 @@ public class CockpitMultidimensionalAnalysisController {
                         eventType,
                         eventType,
                         cause,
                         cause,
                         deviceType,
                         deviceType,
-                        region,
+                        district,
                         startTime,
                         startTime,
                         endTime)));
                         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.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 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.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
@@ -84,6 +85,9 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
         assertEquals(4, snapshot.getFacilities().size());
         assertEquals(4, snapshot.getFacilities().size());
         assertEquals(8, snapshot.getTypes().size());
         assertEquals(8, snapshot.getTypes().size());
         assertEquals("DRAINAGE_STORAGE", snapshot.getTypes().get(6).getCode());
         assertEquals("DRAINAGE_STORAGE", snapshot.getTypes().get(6).getCode());
+        // 设施表都没有 equipment_base.district 那种权威片区来源,全部如实报未分区。
+        assertTrue(snapshot.getFacilities().stream().allMatch(item -> item.getDistrict() == null),
+                "设施缺少片区来源时一律报 null,不得编造片区");
         CockpitInfrastructureBaseArchivesSnapshot.SourceFacility plantFacility =
         CockpitInfrastructureBaseArchivesSnapshot.SourceFacility plantFacility =
                 snapshot.getFacilities().get(0);
                 snapshot.getFacilities().get(0);
         assertEquals("PLANT-001", plantFacility.getCode());
         assertEquals("PLANT-001", plantFacility.getCode());
@@ -106,7 +110,8 @@ class BusinessCockpitInfrastructureBaseArchivesAdapterTest {
                         .findFirst()
                         .findFirst()
                         .orElseThrow(AssertionError::new);
                         .orElseThrow(AssertionError::new);
         assertEquals("DRAINAGE_STORAGE", drainageFacility.getTypeCode());
         assertEquals("DRAINAGE_STORAGE", drainageFacility.getTypeCode());
-        assertEquals("CB", drainageFacility.getRegion());
+        // 排水工程设施只有自由文本 area,没有权威片区来源 → 必须如实报未分区。
+        assertNull(drainageFacility.getDistrict(), "设施没有片区来源,不得用 area 或管理单位冒充");
     }
     }
 
 
     @Test
     @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.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 
 
-import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.List;
 import java.util.List;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 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.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 
 
@@ -51,7 +50,7 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
     }
     }
 
 
     @Test
     @Test
-    void mapsOwnershipAndManagementRelationsFromExistingSourceFields() {
+    void mapsIndustryAndDistrictFromAuthoritativeSources() {
         EquipmentType waterRoot = type(1, "供水", 0);
         EquipmentType waterRoot = type(1, "供水", 0);
         EquipmentType waterLeaf = type(11, "压力计", 1);
         EquipmentType waterLeaf = type(11, "压力计", 1);
         EquipmentType gasRoot = type(3, "燃气", 0);
         EquipmentType gasRoot = type(3, "燃气", 0);
@@ -61,6 +60,8 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
         device.setEquipmentName("一号压力计");
         device.setEquipmentName("一号压力计");
         device.setEquipmentTypeId(11);
         device.setEquipmentTypeId(11);
         device.setOwnershipUnit("供水公司");
         device.setOwnershipUnit("供水公司");
+        // 片区权威来源:equipment_base.district。
+        device.setDistrict(1);
         device.setUpdateTime(LocalDateTime.parse("2026-09-02T09:58:00"));
         device.setUpdateTime(LocalDateTime.parse("2026-09-02T09:58:00"));
 
 
         ManholeBase manhole = new ManholeBase();
         ManholeBase manhole = new ManholeBase();
@@ -97,44 +98,57 @@ class BusinessCockpitInfrastructureManagementInformationAdapterTest {
                 relation(snapshot.getRelations(), "DEV-001");
                 relation(snapshot.getRelations(), "DEV-001");
         assertEquals("SMART_DEVICE", deviceRelation.getObjectKind());
         assertEquals("SMART_DEVICE", deviceRelation.getObjectKind());
         assertEquals("WATER_SUPPLY", deviceRelation.getIndustry());
         assertEquals("WATER_SUPPLY", deviceRelation.getIndustry());
-        assertEquals("供水公司", deviceRelation.getOrganizationCode());
-        assertEquals("OWNED", deviceRelation.getOwnershipType());
+        assertEquals(1, deviceRelation.getDistrict());
 
 
         CockpitInfrastructureManagementInformationSnapshot.SourceRelation pipeRelation =
         CockpitInfrastructureManagementInformationSnapshot.SourceRelation pipeRelation =
                 relation(snapshot.getRelations(), "PIPE-001");
                 relation(snapshot.getRelations(), "PIPE-001");
         assertEquals("FACILITY", pipeRelation.getObjectKind());
         assertEquals("FACILITY", pipeRelation.getObjectKind());
         assertEquals("DRAINAGE", pipeRelation.getIndustry());
         assertEquals("DRAINAGE", pipeRelation.getIndustry());
-        assertEquals("排水所", pipeRelation.getOrganizationCode());
-        assertEquals("MANAGED", pipeRelation.getOwnershipType());
+        // 管网设施没有片区来源:不得用 managementUnit(排水所)冒充片区。
+        assertNull(pipeRelation.getDistrict());
 
 
         assertEquals("MANHOLE", relation(snapshot.getRelations(), "MH-001").getIndustry());
         assertEquals("MANHOLE", relation(snapshot.getRelations(), "MH-001").getIndustry());
+        assertNull(relation(snapshot.getRelations(), "MH-001").getDistrict());
         assertEquals("WATER_SUPPLY", relation(snapshot.getRelations(), "SOURCE-001").getIndustry());
         assertEquals("WATER_SUPPLY", relation(snapshot.getRelations(), "SOURCE-001").getIndustry());
+        assertNull(relation(snapshot.getRelations(), "SOURCE-001").getDistrict());
     }
     }
 
 
     @Test
     @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();
         EquipmentBase unknownType = new EquipmentBase();
         unknownType.setEquipmentCode("DEV-UNKNOWN-TYPE");
         unknownType.setEquipmentCode("DEV-UNKNOWN-TYPE");
         unknownType.setEquipmentTypeId(99);
         unknownType.setEquipmentTypeId(99);
         unknownType.setOwnershipUnit("未知单位");
         unknownType.setOwnershipUnit("未知单位");
 
 
+        EquipmentBase dirtyDistrict = new EquipmentBase();
+        dirtyDistrict.setEquipmentCode("DEV-DIRTY-DISTRICT");
+        dirtyDistrict.setEquipmentTypeId(1);
+        dirtyDistrict.setDistrict(99);
+
+        // 管网设施无片区来源,但行业可判定,故仍应上报(片区为 null)。
         PipeNetworkBase noManagement = new PipeNetworkBase();
         PipeNetworkBase noManagement = new PipeNetworkBase();
         noManagement.setNetworkCode("PIPE-NO-UNIT");
         noManagement.setNetworkCode("PIPE-NO-UNIT");
         noManagement.setNetworkType("2");
         noManagement.setNetworkType("2");
 
 
         when(equipmentTypeService.list()).thenReturn(Collections.singletonList(type(1, "供水", 0)));
         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(manholeBaseService.list()).thenReturn(Collections.emptyList());
         when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(noManagement));
         when(pipeNetworkBaseService.list()).thenReturn(Collections.singletonList(noManagement));
         when(waterSourceInfoService.list()).thenReturn(Collections.emptyList());
         when(waterSourceInfoService.list()).thenReturn(Collections.emptyList());
 
 
         CockpitInfrastructureManagementInformationSnapshot snapshot = adapter.load();
         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) {
     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(
         stubTypes(Arrays.asList(
                 type(22, "22", "可燃气体检测仪", "3")));
                 type(22, "22", "可燃气体检测仪", "3")));
         EquipmentBase bound = device("D-1", "Z-TEST-RQXL01", "Z-测试燃气泄漏01", "22",
         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",
         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));
         stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
         when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
             EquipmentBase device = invocation.getArgument(0);
             EquipmentBase device = invocation.getArgument(0);
@@ -79,8 +79,10 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p1 = snapshot.getMonitoringPoints().get(0);
         CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p1 = snapshot.getMonitoringPoints().get(0);
         assertEquals("22", p1.getTypeCode(), "关联设备类型唯一时点位类型可推导");
         assertEquals("22", p1.getTypeCode(), "关联设备类型唯一时点位类型可推导");
         assertFalse(p1.isCoordinateVerified());
         assertFalse(p1.isCoordinateVerified());
+        assertEquals(Integer.valueOf(1), p1.getDistrict(), "点位片区按其上挂设备归纳");
         CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p2 = snapshot.getMonitoringPoints().get(1);
         CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p2 = snapshot.getMonitoringPoints().get(1);
         assertNull(p2.getTypeCode(), "无关联设备时点位类型不可推导");
         assertNull(p2.getTypeCode(), "无关联设备时点位类型不可推导");
+        assertNull(p2.getDistrict(), "无关联设备时点位如实归为未分区,不猜");
 
 
         assertEquals(2, snapshot.getSmartDevices().size(), "分布快照上报全量设备(关联由服务层判定)");
         assertEquals(2, snapshot.getSmartDevices().size(), "分布快照上报全量设备(关联由服务层判定)");
         CockpitMonitoringDistributionSnapshot.SourceSmartDevice freeDevice = snapshot.getSmartDevices().stream()
         CockpitMonitoringDistributionSnapshot.SourceSmartDevice freeDevice = snapshot.getSmartDevices().stream()
@@ -90,6 +92,10 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         assertTrue(freeDevice.isWarning());
         assertTrue(freeDevice.isWarning());
         assertTrue(freeDevice.isWorkOrderInProgress());
         assertTrue(freeDevice.isWorkOrderInProgress());
         assertEquals("22", freeDevice.getTypeCode());
         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(
         assertTrue(snapshot.getTypes().stream().anyMatch(
                 type -> "22".equals(type.getCode()) && "GAS".equals(type.getIndustry())));
                 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) {
     private void stubPoints(List<MonitorPoint> points) {
         when(monitorPointService.list()).thenReturn(points);
         when(monitorPointService.list()).thenReturn(points);
     }
     }
@@ -173,7 +207,7 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
     }
     }
 
 
     private static EquipmentBase device(String id, String code, String name, String typeId,
     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();
         EquipmentBase device = new EquipmentBase();
         device.setEquipmentId(id);
         device.setEquipmentId(id);
         device.setEquipmentCode(code);
         device.setEquipmentCode(code);
@@ -181,6 +215,8 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
         device.setEquipmentTypeId(Integer.valueOf(typeId));
         device.setEquipmentTypeId(Integer.valueOf(typeId));
         device.setLongitude(lon);
         device.setLongitude(lon);
         device.setLatitude(lat);
         device.setLatitude(lat);
+        // 片区权威来源:equipment_base.district(Integer,0-城南 1-城北 2-太常)
+        device.setDistrict(district);
         return device;
         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());
         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 ----
     // ---- helpers ----
 
 
     private void stubTypes(List<EquipmentType> types) {
     private void stubTypes(List<EquipmentType> types) {
@@ -177,6 +199,12 @@ class BusinessCockpitMonitoringSituationAdapterTest {
                 .findFirst().orElseThrow().getMetricStatus();
                 .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(
     private static CockpitMonitoringPointDeviceSnapshot fact(
             String code, boolean online, boolean alarm, boolean warning) {
             String code, boolean online, boolean alarm, boolean warning) {
         return new CockpitMonitoringPointDeviceSnapshot(
         return new CockpitMonitoringPointDeviceSnapshot(

+ 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, "处理中",
                 code, DEVICE_CODE, 1, "处理异常", 4, "处理中",
                 "2026-09-02T09:00:00+08:00", "2026-09-02T10:00:00+08:00",
                 "2026-09-02T09:00:00+08:00", "2026-09-02T10:00:00+08:00",
                 "2026-09-03T09:00:00+08:00", null,
                 "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) {
     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 java.util.Collections;
 
 
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -100,17 +101,22 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(jsonPath("$.data.ageReference.commissionedCount").value(4))
                 .andExpect(jsonPath("$.data.ageReference.commissionedCount").value(4))
                 .andExpect(jsonPath("$.data.ageReference.builtCount").value(1))
                 .andExpect(jsonPath("$.data.ageReference.builtCount").value(1))
                 .andExpect(jsonPath("$.data.ageReference.unknownCount").value(0))
                 .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.industry").doesNotExist())
                 .andExpect(jsonPath("$.data.mapFilter.facilityType").doesNotExist())
                 .andExpect(jsonPath("$.data.mapFilter.facilityType").doesNotExist())
                 .andExpect(jsonPath("$.data.mapFilter.ageBucket").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.unknownTypeCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.smartDeviceCount").value(3));
                 .andExpect(jsonPath("$.data.excluded.smartDeviceCount").value(3));
@@ -139,31 +145,58 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                         .queryParam("industry", "WATER_SUPPLY")
                         .queryParam("industry", "WATER_SUPPLY")
                         .queryParam("facilityType", "WATER_PLANT")
                         .queryParam("facilityType", "WATER_PLANT")
                         .queryParam("ageBucket", "UNDER_5_YEARS")
                         .queryParam("ageBucket", "UNDER_5_YEARS")
-                        .queryParam("region", "CN"))
+                        .queryParam("district", "0"))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.selectedFilters.facilityType").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.selectedFilters.facilityType").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.selectedFilters.ageBucket").value("UNDER_5_YEARS"))
                 .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.industry").value("waterSupply"))
                 .andExpect(jsonPath("$.data.mapFilter.facilityType").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.mapFilter.facilityType").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.mapFilter.ageBucket").value("UNDER_5_YEARS"))
                 .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.totals.facilityCount").value(1))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(1)))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(1)))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].code").value("WATER_PLANT"));
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].code").value("WATER_PLANT"));
     }
     }
 
 
     @Test
     @Test
-    void blankRegionFilterSelectsNormalizedUnknownFacilities() throws Exception {
+    void unassignedDistrictFilterSelectsOnlyFacilitiesWithoutDistrict() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
-                        .queryParam("region", "UNKNOWN"))
+                        .queryParam("district", "UNASSIGNED"))
                 .andExpect(status().isOk())
                 .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
     @Test
@@ -217,7 +250,7 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(jsonPath("$.data.ageDistribution[2].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[2].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[3].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[3].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[4].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.unknownTypeCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.smartDeviceCount").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("WATER_PLANT_DEVICE", "水厂设备", "WATER_SUPPLY", "SMART_DEVICE"),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WRONG_KIND", "类型错挂", "WATER_SUPPLY", "OTHER")),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WRONG_KIND", "类型错挂", "WATER_SUPPLY", "OTHER")),
                 Arrays.asList(
                 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-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() {
     private static CockpitInfrastructureBaseArchivesSnapshot ageFixture() {
@@ -293,10 +326,10 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PIPE", "供水管线", "WATER_SUPPLY", "FACILITY")),
                         new CockpitInfrastructureBaseArchivesSnapshot.SourceType("WATER_PIPE", "供水管线", "WATER_SUPPLY", "FACILITY")),
                 Arrays.asList(
                 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) {
     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.Instant;
 import java.time.ZoneOffset;
 import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.List;
 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.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 
+/**
+ * 管理信息契约:**片区 × 行业**计数,组织/权属视图已按决策票 06 删除。
+ */
 class CockpitInfrastructureManagementInformationControllerTest {
 class CockpitInfrastructureManagementInformationControllerTest {
 
 
     private static final String ENDPOINT = "/api/cockpit/v1/infrastructure/management-information";
     private static final String ENDPOINT = "/api/cockpit/v1/infrastructure/management-information";
@@ -53,7 +55,7 @@ class CockpitInfrastructureManagementInformationControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void managementInformationGroupsOrganizationsAndOwnershipDistribution() throws Exception {
+    void managementInformationGroupsCountsByDistrictAndIndustry() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
         mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "management-information-001"))
         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.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READY"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READY"))
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value(nullValue()))
                 .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.duplicateRelationCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
@@ -111,101 +109,112 @@ class CockpitInfrastructureManagementInformationControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void missingOrganizationFallsIntoExplicitUnmaintainedBucket() throws Exception {
+    void removedOrganizationAndOwnershipFieldsAreGoneFromTheResponse() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
         mockMvc.perform(get(ENDPOINT))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .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
     @Test
-    void duplicateRelationsAreCountedOnceAndReportedAsExcluded() throws Exception {
+    void facilitiesAndEquipmentAreCountedSeparatelyAndNeverMerged() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
         mockMvc.perform(get(ENDPOINT))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .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
     @Test
-    void facilitiesAndEquipmentAreCountedSeparatelyAndNeverMerged() throws Exception {
+    void industryFilterRestrictsDistrictIndustryRowsToTheFormalIndustry() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
-        mockMvc.perform(get(ENDPOINT))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WATER_SUPPLY"))
                 .andExpect(status().isOk())
                 .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
     @Test
-    void industryFilterRestrictsOrganizationsToTheFormalIndustry() throws Exception {
+    void districtFilterSelectsOnlyTheRequestedDistrict() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WATER_SUPPLY"))
+        mockMvc.perform(get(ENDPOINT).queryParam("district", "1"))
                 .andExpect(status().isOk())
                 .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
     @Test
-    void organizationCodeFilterSelectsSingleUnit() throws Exception {
+    void unassignedDistrictFilterSelectsOnlyUnassignedRows() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         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(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
     @Test
-    void unmaintainedCodeFilterSelectsOnlyTheUnmaintainedBucket() throws Exception {
+    void dirtyDistrictValuesAreNormalizedToUnassignedInsteadOfInventingDistricts() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
-        mockMvc.perform(get(ENDPOINT).queryParam("organizationCode", "UNMAINTAINED"))
+        mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .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
     @Test
-    void unknownOrganizationCodeReturnsExplicitEmptyResult() throws Exception {
+    void unknownIndustryCombinationReturnsExplicitEmptyResult() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         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(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.facilityCount").value(0))
                 .andExpect(jsonPath("$.data.totals.equipmentCount").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.status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RELATIONS"));
     }
     }
 
 
     @Test
     @Test
@@ -215,11 +224,11 @@ class CockpitInfrastructureManagementInformationControllerTest {
 
 
         mockMvc.perform(get(ENDPOINT))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .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.status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("NO_ORGANIZATIONS"));
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RELATIONS"));
     }
     }
 
 
     @Test
     @Test
@@ -233,6 +242,32 @@ class CockpitInfrastructureManagementInformationControllerTest {
         verifyNoInteractions(adapter);
         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
     @Test
     void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
     void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(post(ENDPOINT))
         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() {
     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(
         return new CockpitInfrastructureManagementInformationSnapshot(
                 "2026-09-02T09:59:50+08:00", relations);
                 "2026-09-02T09:59:50+08:00", relations);
     }
     }
@@ -289,10 +333,8 @@ class CockpitInfrastructureManagementInformationControllerTest {
             String objectCode,
             String objectCode,
             String objectKind,
             String objectKind,
             String industry,
             String industry,
-            String organizationCode,
-            String organizationName,
-            String ownershipType) {
+            Integer district) {
         return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
         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.renderableCount").value(3))
                 .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
                 .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
                 .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").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].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].typeCode").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].typeCode").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].typeName").value("供水压力计"))
                 .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.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].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].typeCode").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].typeCode").value("WATER_SUPPLY_SENSOR"))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
                 .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.deviceType").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(3)))
                 .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(3)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)));
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)));
@@ -123,19 +127,39 @@ class CockpitMapSnapshotControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void regionFilterMatchesNormalizedUnassignedRegion() throws Exception {
+    void districtFilterMatchesNormalizedUnassignedDistrict() throws Exception {
         when(adapter.load(null)).thenReturn(fixture());
         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(status().isOk())
                 .andExpect(jsonPath("$.data.total").value(2))
                 .andExpect(jsonPath("$.data.total").value(2))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
                 .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-003"))
                 .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", hasSize(1)))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-002"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-002"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].industry").value(nullValue()))
                 .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
     @Test
@@ -408,7 +432,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-002",
                                 "MP-002",
                                 "燃气调压站监测点",
                                 "燃气调压站监测点",
-                                "城区A",
+                                0,
                                 "GAS_SENSOR",
                                 "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 new BigDecimal("28.453"),
@@ -418,7 +442,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-001",
                                 "MP-001",
                                 "供水干管监测点",
                                 "供水干管监测点",
-                                "城区B",
+                                0,
                                 "WATER_SUPPLY_SENSOR",
                                 "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("110.141"),
                                 new BigDecimal("110.141"),
                                 new BigDecimal("28.452"),
                                 new BigDecimal("28.452"),
@@ -437,7 +461,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-ASSOC-001",
                                 "EQ-ASSOC-001",
                                 "燃气浓度计-001",
                                 "燃气浓度计-001",
-                                "城区A",
+                                0,
                                 "GAS_SENSOR",
                                 "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 new BigDecimal("28.453"),
@@ -453,7 +477,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-001",
                                 "EQ-FREE-001",
                                 "供水压力计-001",
                                 "供水压力计-001",
-                                "城区B",
+                                0,
                                 "WATER_SUPPLY_SENSOR",
                                 "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("181"),
                                 new BigDecimal("181"),
                                 new BigDecimal("28.455"),
                                 new BigDecimal("28.455"),
@@ -461,7 +485,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-003",
                                 "EQ-FREE-003",
                                 "污水环境监测仪-003",
                                 "污水环境监测仪-003",
-                                "城区C",
+                                2,
                                 "SEWAGE_ENV",
                                 "SEWAGE_ENV",
                                 new BigDecimal("110.146"),
                                 new BigDecimal("110.146"),
                                 new BigDecimal("28.457"),
                                 new BigDecimal("28.457"),
@@ -469,7 +493,7 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-004",
                                 "EQ-FREE-004",
                                 "零坐标设备-004",
                                 "零坐标设备-004",
-                                "城区A",
+                                1,
                                 "GAS_SENSOR",
                                 "GAS_SENSOR",
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
@@ -490,15 +514,15 @@ class CockpitMapSnapshotControllerTest {
 
 
     private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode,
     private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode,
             List<CockpitMonitoringPointDeviceSnapshot> devices) {
             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) {
             String typeCode, List<CockpitMonitoringPointDeviceSnapshot> devices) {
         return new CockpitMapSnapshot.SourceMonitoringPoint(
         return new CockpitMapSnapshot.SourceMonitoringPoint(
                 pointCode,
                 pointCode,
                 pointCode + "监测点",
                 pointCode + "监测点",
-                region,
+                district,
                 typeCode,
                 typeCode,
                 new BigDecimal("110.141"),
                 new BigDecimal("110.141"),
                 new BigDecimal("28.452"),
                 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
     @Test
-    void distributionExposesEnvelopeSixStateDistributionRegionIndustryTypeAndMapFilter() throws Exception {
+    void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception {
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("snapshotAt", SNAPSHOT_AT)
                         .param("snapshotAt", SNAPSHOT_AT)
                         .header("X-Request-Id", "monitoring-distribution-001"))
                         .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[4].count").value(2))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
                 .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", hasSize(3)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
                 .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.industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.pointStatus").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.pointCodes", hasSize(5)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(8)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(8)))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
@@ -200,18 +205,48 @@ class CockpitMonitoringDistributionControllerTest {
     }
     }
 
 
     @Test
     @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(status().isOk())
                 .andExpect(jsonPath("$.data.pagination.total").value(3))
                 .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)));
                 .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
     @Test
     void unknownDeviceTypeReturnsExplicitEmptyDistribution() throws Exception {
     void unknownDeviceTypeReturnsExplicitEmptyDistribution() throws Exception {
         mockMvc.perform(get(ENDPOINT).param("deviceType", "NOT_A_TYPE"))
         mockMvc.perform(get(ENDPOINT).param("deviceType", "NOT_A_TYPE"))
@@ -387,52 +422,54 @@ class CockpitMonitoringDistributionControllerTest {
                         new CockpitMonitoringDistributionSnapshot.SourceType(
                         new CockpitMonitoringDistributionSnapshot.SourceType(
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                 Arrays.asList(
                 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",
                                 "110.150", "28.460", device("WS-001", "供水压力计-001",
                                         true, false, false, true, Collections.emptyList())),
                                         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"),
                                 "110.151", "28.461"),
-                        point("P-GAS-1", "燃气监测点-1", "城区A", "GAS_SENSOR",
+                        point("P-GAS-1", "燃气监测点-1", 0, "GAS_SENSOR",
                                 "110.130", "28.440",
                                 "110.130", "28.440",
                                 device("GAS-001", "燃气浓度计-001",
                                 device("GAS-001", "燃气浓度计-001",
                                         true, false, false, false, Collections.emptyList()),
                                         true, false, false, false, Collections.emptyList()),
                                 device("GAS-002", "燃气浓度计-002",
                                 device("GAS-002", "燃气浓度计-002",
                                         true, true, false, false, Collections.singletonList("浓度超限"))),
                                         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",
                                 "110.131", "28.441", device("GAS-003", "燃气浓度计-003",
                                         false, false, false, false, Collections.emptyList())),
                                         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",
                                 "110.170", "28.480", device("DRAIN-001", "污水环境监测仪-001",
                                         true, false, true, false, Collections.singletonList("浓度预警"))),
                                         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", "水厂设备",
                                 "110.120", "28.430", device("MISMATCH-001", "水厂设备",
                                         true, false, false, false, Collections.emptyList())),
                                         true, false, false, false, Collections.emptyList())),
-                        point("P-UNKNOWN", "未知类型点", "城区A", "LEGACY",
+                        point("P-UNKNOWN", "未知类型点", 0, "LEGACY",
                                 "110.121", "28.431", device("UNKNOWN-001", "旧设备",
                                 "110.121", "28.431", device("UNKNOWN-001", "旧设备",
                                         true, false, false, false, Collections.emptyList()))),
                                         true, false, false, false, Collections.emptyList()))),
                 Arrays.asList(
                 Arrays.asList(
-                        smartDevice("EQ-FREE-003", "压力井盖外设备", "城区C", "WATER_SUPPLY_SENSOR",
+                        // 脏值片区(7)不得冒充合法片区,归一后并入未分区
+                        smartDevice("EQ-FREE-003", "压力井盖外设备", 7, "WATER_SUPPLY_SENSOR",
                                 "110.171", "28.481", true,
                                 "110.171", "28.481", true,
                                 false, false, false, false, Arrays.asList("离线")),
                                 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,
                                 "110.132", "28.442", true,
                                 true, false, false, false, Collections.emptyList()),
                                 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,
                                 "110.152", "28.462", true,
                                 true, false, true, false, Collections.singletonList("设备预警")),
                                 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,
                                 "110.133", "28.443", true,
                                 true, false, false, false, Collections.emptyList()),
                                 true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-005", "越界坐标设备", "城区A", "GAS_SENSOR",
+                        smartDevice("EQ-FREE-005", "越界坐标设备", 0, "GAS_SENSOR",
                                 "181", "28.444", true,
                                 "181", "28.444", true,
                                 true, false, false, false, Collections.emptyList())));
                                 true, false, false, false, Collections.emptyList())));
     }
     }
 
 
     private static CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point(
     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) {
             String longitude, String latitude, CockpitMonitoringPointDeviceSnapshot... devices) {
         return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
         return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                code, name, region, typeCode,
+                code, name, district, typeCode,
                 new BigDecimal(longitude), new BigDecimal(latitude), true,
                 new BigDecimal(longitude), new BigDecimal(latitude), true,
                 Arrays.asList(devices));
                 Arrays.asList(devices));
     }
     }
@@ -446,12 +483,12 @@ class CockpitMonitoringDistributionControllerTest {
     }
     }
 
 
     private static CockpitMonitoringDistributionSnapshot.SourceSmartDevice smartDevice(
     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,
             String longitude, String latitude, boolean verified,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
             List<String> statusReasons) {
             List<String> statusReasons) {
         return new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
         return new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
-                code, name, region, typeCode,
+                code, name, district, typeCode,
                 new BigDecimal(longitude), new BigDecimal(latitude), verified,
                 new BigDecimal(longitude), new BigDecimal(latitude), verified,
                 online, alarm, warning, workOrderInProgress, statusReasons, SNAPSHOT_AT);
                 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[1].abnormalType").value("WARNING"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[4].deviceCode").value("D-WS-2"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[4].deviceCode").value("D-WS-2"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[4].abnormalType").value("OFFLINE"))
                 .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.page").value(1))
                 .andExpect(jsonPath("$.data.pagination.pageSize").value(100))
                 .andExpect(jsonPath("$.data.pagination.pageSize").value(100))
                 .andExpect(jsonPath("$.data.pagination.total").value(5))
                 .andExpect(jsonPath("$.data.pagination.total").value(5))
@@ -401,27 +406,29 @@ class CockpitMonitoringSituationControllerTest {
                                 "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                                 "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                         type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                         type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                 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),
                                 "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),
                                 "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),
                                 "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),
                                 "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),
                                 "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),
                                 "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),
                                 "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),
                                 "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),
                                 "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),
                                 "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)),
                                 "ALARM", Collections.singletonList("浓度超限"), "181", "28.443", true)),
                 Arrays.asList(
                 Arrays.asList(
                         event("W-GAS-1", "一级", "2026-09-02T00:30:00+08:00",
                         event("W-GAS-1", "一级", "2026-09-02T00:30:00+08:00",
@@ -444,10 +451,10 @@ class CockpitMonitoringSituationControllerTest {
     }
     }
 
 
     private static CockpitMonitoringSituationSnapshot.SourceMetricDevice device(
     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) {
             List<String> reasons, String longitude, String latitude, boolean verified) {
         return new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
         return new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
-                code, name, region, typeCode, metricStatus, reasons,
+                code, name, district, typeCode, metricStatus, reasons,
                 "2026-09-02T09:59:00+08:00",
                 "2026-09-02T09:59:00+08:00",
                 new BigDecimal(longitude), new BigDecimal(latitude), verified);
                 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[2].dimensionCode").value("INDUSTRY"))
                 .andExpect(jsonPath("$.data.dimensions[3].dimensionCode").value("DEVICE_TYPE"))
                 .andExpect(jsonPath("$.data.dimensions[3].dimensionCode").value("DEVICE_TYPE"))
                 .andExpect(jsonPath("$.data.dimensions[4].dimensionCode").value("LEVEL"))
                 .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.dimensions[6].dimensionCode").value("TIME"))
                 .andExpect(jsonPath("$.data.range.type").value("ANALYSIS_WINDOW"))
                 .andExpect(jsonPath("$.data.range.type").value("ANALYSIS_WINDOW"))
                 .andExpect(jsonPath("$.data.range.startTime").value(START))
                 .andExpect(jsonPath("$.data.range.startTime").value(START))
@@ -186,7 +186,7 @@ class CockpitMultidimensionalAnalysisControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void causeRegionEventTypeAndDeviceTypeFiltersNarrowConsistently() throws Exception {
+    void causeDistrictEventTypeAndDeviceTypeFiltersNarrowConsistently() throws Exception {
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("eventFamily", "WARNING")
                         .param("cause", "浓度超限")
                         .param("cause", "浓度超限")
@@ -198,12 +198,23 @@ class CockpitMultidimensionalAnalysisControllerTest {
 
 
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("eventFamily", "WARNING")
-                        .param("region", "城区B")
+                        .param("district", "1")
                         .param("startTime", START)
                         .param("startTime", START)
                         .param("endTime", END))
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.totalCount").value(2))
                 .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)
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("eventFamily", "WARNING")
@@ -224,6 +235,80 @@ class CockpitMultidimensionalAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.filters.deviceType").value("WATER_SUPPLY_SENSOR"));
                 .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
     @Test
     void emptyWindowKeepsEveryDimensionWithZeroPercentages() throws Exception {
     void emptyWindowKeepsEveryDimensionWithZeroPercentages() throws Exception {
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
@@ -374,32 +459,32 @@ class CockpitMultidimensionalAnalysisControllerTest {
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                                 "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
                 Arrays.asList(
                 Arrays.asList(
                         warning("MW-001", "GAS_CONCENTRATION", "浓度超限", 1,
                         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,
                         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,
                         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,
                         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,
                         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,
                         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,
                         warning("MW-007", "WATER_PRESSURE", null, 4,
                                 "2026-09-02T12:00:00+08:00", null, "WATER_SUPPLY_SENSOR", "D-WS-3"),
                                 "2026-09-02T12:00:00+08:00", null, "WATER_SUPPLY_SENSOR", "D-WS-3"),
                         warning("MW-008", "GAS_CONCENTRATION", "设备故障", 9,
                         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,
                         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,
                         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,
                         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(
                 Arrays.asList(
                         alarm("MA-001", "GAS_CONCENTRATION", "浓度超限", 1,
                         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,
                         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(
     private static CockpitMultidimensionalAnalysisSnapshot.SourceType type(
@@ -410,15 +495,15 @@ class CockpitMultidimensionalAnalysisControllerTest {
 
 
     private static CockpitMultidimensionalAnalysisSnapshot.SourceWarning warning(
     private static CockpitMultidimensionalAnalysisSnapshot.SourceWarning warning(
             String no, String eventType, String cause, Integer level, String eventTime,
             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(
         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(
     private static CockpitMultidimensionalAnalysisSnapshot.SourceAlarm alarm(
             String id, String eventType, String cause, Integer level, String eventTime,
             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(
         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 java.util.List;
 
 
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 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"));
                 .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
     @Test
     void timelinesAreIndependentBetweenWorkOrders() throws Exception {
     void timelinesAreIndependentBetweenWorkOrders() throws Exception {
         when(adapter.loadTimeline("WO-2026090200001")).thenReturn(timelineFixture("WO-2026090200001", "3"));
         when(adapter.loadTimeline("WO-2026090200001")).thenReturn(timelineFixture("WO-2026090200001", "3"));
@@ -225,8 +268,25 @@ class CockpitWorkOrderReadControllerTest {
         return summary(workOrderCode, state, "ALARM", eventCode);
         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);
         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(
         return new CockpitWorkOrderSummary(
                 workOrderCode,
                 workOrderCode,
                 DEVICE_CODE,
                 DEVICE_CODE,
@@ -238,7 +298,9 @@ class CockpitWorkOrderReadControllerTest {
                 "2026-09-02T10:00:00+08:00",
                 "2026-09-02T10:00:00+08:00",
                 "2026-09-02T09:00:00+08:00",
                 "2026-09-02T09:00:00+08:00",
                 "2026-09-02T09:30: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) {
     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.EquipmentWarningTypeMapper;
 import com.zksy.base.warning.mapper.WarningTypeMapper;
 import com.zksy.base.warning.mapper.WarningTypeMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.ops.service.WorkOrderService;
@@ -42,8 +43,9 @@ import java.util.stream.Collectors;
  * {@code alarm_warning_rel} 回填事件工单关联,避免各端点各自猜测关联键。</p>
  * {@code alarm_warning_rel} 回填事件工单关联,避免各端点各自猜测关联键。</p>
  *
  *
  * <p>现有表没有可信的坐标校核字段,事件坐标即使来自记录也始终标记为未校核,
  * <p>现有表没有可信的坐标校核字段,事件坐标即使来自记录也始终标记为未校核,
- * 由契约层统一判定为不可渲染。成因、区域和预警实时值在当前模型中没有可靠来源,
- * 保持 {@code null}。</p>
+ * 由契约层统一判定为不可渲染。成因在当前模型中没有可靠来源,保持 {@code null};
+ * 事件片区沿设备解析 {@code equipment_base.district},无设备/未识别时如实为
+ * {@code null}(未分区),不由城名或行业字段冒充。</p>
  */
  */
 @Component
 @Component
 @Primary
 @Primary
@@ -498,7 +500,7 @@ public class BusinessCockpitEventAdapter {
                         null,
                         null,
                         level(item.warning.getWarningLevel()),
                         level(item.warning.getWarningLevel()),
                         offsetTime(item.warning.getPublishTime()),
                         offsetTime(item.warning.getPublishTime()),
-                        null,
+                        districtCode(item.warning.getDeviceCode(), facts),
                         typeCode(item.warning.getDeviceCode(), facts),
                         typeCode(item.warning.getDeviceCode(), facts),
                         trimToNull(item.warning.getDeviceCode())))
                         trimToNull(item.warning.getDeviceCode())))
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
@@ -512,7 +514,7 @@ public class BusinessCockpitEventAdapter {
                         null,
                         null,
                         item.alarm.getAlarmLevel(),
                         item.alarm.getAlarmLevel(),
                         offsetTime(item.alarm.getCreateTime()),
                         offsetTime(item.alarm.getCreateTime()),
-                        null,
+                        districtCode(item.alarm.getDeviceCode(), facts),
                         typeCode(item.alarm.getDeviceCode(), facts),
                         typeCode(item.alarm.getDeviceCode(), facts),
                         trimToNull(item.alarm.getDeviceCode())))
                         trimToNull(item.alarm.getDeviceCode())))
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
@@ -707,6 +709,16 @@ public class BusinessCockpitEventAdapter {
         return device == null ? null : typeCode(device, facts.typesById);
         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) {
     private String typeName(EquipmentBase device, Map<Integer, EquipmentType> types) {
         EquipmentType type = device == null || device.getEquipmentTypeId() == null
         EquipmentType type = device == null || device.getEquipmentTypeId() == null
                 ? null : types.get(device.getEquipmentTypeId());
                 ? 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 eventType;
         private final String cause;
         private final String cause;
         private final String deviceType;
         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,
         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.eventFamily = eventFamily;
             this.industry = industry;
             this.industry = industry;
             this.level = level;
             this.level = level;
             this.eventType = eventType;
             this.eventType = eventType;
             this.cause = cause;
             this.cause = cause;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
-            this.region = region;
+            this.district = district;
+            this.districtName = districtName;
         }
         }
 
 
         public String getEventFamily() { return eventFamily; }
         public String getEventFamily() { return eventFamily; }
@@ -105,7 +107,13 @@ public class CockpitMultidimensionalAnalysisResponse {
         public String getEventType() { return eventType; }
         public String getEventType() { return eventType; }
         public String getCause() { return cause; }
         public String getCause() { return cause; }
         public String getDeviceType() { return deviceType; }
         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 {
     public static class MapFilter {
@@ -115,18 +123,21 @@ public class CockpitMultidimensionalAnalysisResponse {
         private final String eventType;
         private final String eventType;
         private final String cause;
         private final String cause;
         private final String deviceType;
         private final String deviceType;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final List<String> deviceCodes;
         private final List<String> deviceCodes;
 
 
         public MapFilter(String eventFamily, String industry, Integer level, String eventType,
         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.eventFamily = eventFamily;
             this.industry = industry;
             this.industry = industry;
             this.level = level;
             this.level = level;
             this.eventType = eventType;
             this.eventType = eventType;
             this.cause = cause;
             this.cause = cause;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
-            this.region = region;
+            this.district = district;
+            this.districtName = districtName;
             this.deviceCodes = deviceCodes;
             this.deviceCodes = deviceCodes;
         }
         }
 
 
@@ -136,7 +147,10 @@ public class CockpitMultidimensionalAnalysisResponse {
         public String getEventType() { return eventType; }
         public String getEventType() { return eventType; }
         public String getCause() { return cause; }
         public String getCause() { return cause; }
         public String getDeviceType() { return deviceType; }
         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; }
         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;
 package com.zksy.cockpit.event;
 
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 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_CAUSE_LABEL = "未知/缺失成因";
     private static final String UNKNOWN_EVENT_TYPE = "UNKNOWN_EVENT_TYPE";
     private static final String UNKNOWN_EVENT_TYPE = "UNKNOWN_EVENT_TYPE";
     private static final String UNKNOWN_EVENT_TYPE_LABEL = "未知事件类型";
     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 String UNKNOWN_LEVEL = "UNKNOWN";
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long MAX_WINDOW_DAYS = 31;
     private static final long MAX_WINDOW_DAYS = 31;
@@ -72,7 +71,7 @@ public class CockpitMultidimensionalAnalysisService {
             String eventType,
             String eventType,
             String cause,
             String cause,
             String deviceType,
             String deviceType,
-            String region,
+            String district,
             String startTime,
             String startTime,
             String endTime) {
             String endTime) {
         String family = resolveFamily(eventFamily);
         String family = resolveFamily(eventFamily);
@@ -81,7 +80,12 @@ public class CockpitMultidimensionalAnalysisService {
         String selectedType = blankToNull(eventType);
         String selectedType = blankToNull(eventType);
         String selectedCause = blankToNull(cause);
         String selectedCause = blankToNull(cause);
         String selectedDeviceType = blankToNull(deviceType);
         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);
         TimeWindow window = resolveWindow(startTime, endTime);
         if (state == null) {
         if (state == null) {
             CockpitMultidimensionalAnalysisSnapshot loaded = adapter.load();
             CockpitMultidimensionalAnalysisSnapshot loaded = adapter.load();
@@ -90,7 +94,7 @@ public class CockpitMultidimensionalAnalysisService {
                     : loaded;
                     : loaded;
         }
         }
         return build(requestId, family, selectedIndustry, selectedLevel, selectedType,
         return build(requestId, family, selectedIndustry, selectedLevel, selectedType,
-                selectedCause, selectedDeviceType, selectedRegion, window);
+                selectedCause, selectedDeviceType, selectedDistrict, window);
     }
     }
 
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -114,7 +118,7 @@ public class CockpitMultidimensionalAnalysisService {
             String eventType,
             String eventType,
             String cause,
             String cause,
             String deviceType,
             String deviceType,
-            String region,
+            CockpitDistrict.Query district,
             TimeWindow window) {
             TimeWindow window) {
         Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types = types(state);
         Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types = types(state);
         List<Fact> facts = WARNING.equals(family)
         List<Fact> facts = WARNING.equals(family)
@@ -141,7 +145,7 @@ public class CockpitMultidimensionalAnalysisService {
             if (deviceType != null && !deviceType.equals(fact.deviceType)) {
             if (deviceType != null && !deviceType.equals(fact.deviceType)) {
                 continue;
                 continue;
             }
             }
-            if (region != null && !region.equals(fact.regionKey())) {
+            if (!CockpitDistrict.matches(district, fact.district)) {
                 continue;
                 continue;
             }
             }
             scoped.add(fact);
             scoped.add(fact);
@@ -161,7 +165,7 @@ public class CockpitMultidimensionalAnalysisService {
         dimensions.add(dimension("INDUSTRY", "行业", total, industryBuckets(scoped)));
         dimensions.add(dimension("INDUSTRY", "行业", total, industryBuckets(scoped)));
         dimensions.add(dimension("DEVICE_TYPE", "设备类型", total, deviceTypeBuckets(scoped, types)));
         dimensions.add(dimension("DEVICE_TYPE", "设备类型", total, deviceTypeBuckets(scoped, types)));
         dimensions.add(dimension("LEVEL", "等级", total, levelBuckets(scoped)));
         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)));
         dimensions.add(dimension("TIME", "时间", total, timeBuckets(scoped, window)));
 
 
         return new CockpitMultidimensionalAnalysisResponse(
         return new CockpitMultidimensionalAnalysisResponse(
@@ -177,10 +181,13 @@ public class CockpitMultidimensionalAnalysisService {
                         timestamp(window.end)),
                         timestamp(window.end)),
                 isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt(),
                 isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt(),
                 new CockpitMultidimensionalAnalysisResponse.Filters(
                 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,
                 total,
                 new CockpitMultidimensionalAnalysisResponse.MapFilter(
                 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);
                 dimensions);
     }
     }
 
 
@@ -275,14 +282,19 @@ public class CockpitMultidimensionalAnalysisService {
                 Comparator.comparingInt(order::indexOf));
                 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, Integer> counts = new LinkedHashMap<>();
         Map<String, String> labels = new LinkedHashMap<>();
         Map<String, String> labels = new LinkedHashMap<>();
         for (Fact fact : facts) {
         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());
         return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
     }
     }
@@ -355,7 +367,7 @@ public class CockpitMultidimensionalAnalysisService {
                     warning.getCause(),
                     warning.getCause(),
                     warning.getLevel(),
                     warning.getLevel(),
                     parseEventTime(warning.getEventTime()),
                     parseEventTime(warning.getEventTime()),
-                    warning.getRegion(),
+                    warning.getDistrict(),
                     type.getIndustry(),
                     type.getIndustry(),
                     type.getCode(),
                     type.getCode(),
                     warning.getDeviceCode()));
                     warning.getDeviceCode()));
@@ -383,7 +395,7 @@ public class CockpitMultidimensionalAnalysisService {
                     alarm.getCause(),
                     alarm.getCause(),
                     alarm.getLevel(),
                     alarm.getLevel(),
                     parseEventTime(alarm.getEventTime()),
                     parseEventTime(alarm.getEventTime()),
-                    alarm.getRegion(),
+                    alarm.getDistrict(),
                     type.getIndustry(),
                     type.getIndustry(),
                     type.getCode(),
                     type.getCode(),
                     alarm.getDeviceCode()));
                     alarm.getDeviceCode()));
@@ -553,18 +565,18 @@ public class CockpitMultidimensionalAnalysisService {
         private final String cause;
         private final String cause;
         private final Integer level;
         private final Integer level;
         private final OffsetDateTime eventTime;
         private final OffsetDateTime eventTime;
-        private final String region;
+        private final Integer district;
         private final String industry;
         private final String industry;
         private final String deviceType;
         private final String deviceType;
         private final String deviceCode;
         private final String deviceCode;
 
 
         private Fact(String eventType, String cause, Integer level, OffsetDateTime eventTime,
         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.eventType = eventType;
             this.cause = cause;
             this.cause = cause;
             this.level = level;
             this.level = level;
             this.eventTime = eventTime;
             this.eventTime = eventTime;
-            this.region = region;
+            this.district = district;
             this.industry = industry;
             this.industry = industry;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
@@ -573,9 +585,5 @@ public class CockpitMultidimensionalAnalysisService {
         private String causeKey() {
         private String causeKey() {
             return isBlank(cause) ? UNKNOWN_CAUSE : cause.trim();
             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 预警处置 / 预警分析.
  * 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
  * 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 {
 public class CockpitMultidimensionalAnalysisSnapshot {
     private final String updatedAt;
     private final String updatedAt;
@@ -65,7 +66,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         private final String cause;
         private final String cause;
         private final Integer level;
         private final Integer level;
         private final String eventTime;
         private final String eventTime;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final String deviceCode;
         private final String deviceCode;
 
 
@@ -75,7 +76,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
                 String cause,
                 String cause,
                 Integer level,
                 Integer level,
                 String eventTime,
                 String eventTime,
-                String region,
+                Integer district,
                 String typeCode,
                 String typeCode,
                 String deviceCode) {
                 String deviceCode) {
             this.warningNo = warningNo;
             this.warningNo = warningNo;
@@ -83,7 +84,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
             this.cause = cause;
             this.cause = cause;
             this.level = level;
             this.level = level;
             this.eventTime = eventTime;
             this.eventTime = eventTime;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
         }
         }
@@ -93,7 +94,8 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         public String getCause() { return cause; }
         public String getCause() { return cause; }
         public Integer getLevel() { return level; }
         public Integer getLevel() { return level; }
         public String getEventTime() { return eventTime; }
         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 getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
     }
     }
@@ -104,7 +106,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         private final String cause;
         private final String cause;
         private final Integer level;
         private final Integer level;
         private final String eventTime;
         private final String eventTime;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final String deviceCode;
         private final String deviceCode;
 
 
@@ -114,7 +116,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
                 String cause,
                 String cause,
                 Integer level,
                 Integer level,
                 String eventTime,
                 String eventTime,
-                String region,
+                Integer district,
                 String typeCode,
                 String typeCode,
                 String deviceCode) {
                 String deviceCode) {
             this.alarmId = alarmId;
             this.alarmId = alarmId;
@@ -122,7 +124,7 @@ public class CockpitMultidimensionalAnalysisSnapshot {
             this.cause = cause;
             this.cause = cause;
             this.level = level;
             this.level = level;
             this.eventTime = eventTime;
             this.eventTime = eventTime;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
         }
         }
@@ -132,7 +134,8 @@ public class CockpitMultidimensionalAnalysisSnapshot {
         public String getCause() { return cause; }
         public String getCause() { return cause; }
         public Integer getLevel() { return level; }
         public Integer getLevel() { return level; }
         public String getEventTime() { return eventTime; }
         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 getTypeCode() { return typeCode; }
         public String getDeviceCode() { return deviceCode; }
         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 片)。
  * 基础档案真实数据适配器(BT-5 第 6 片)。
  *
  *
  * <p>组合现有设施表,不新增投影:水厂、共用泵站、统一管网基础表和排水工程设施。
  * <p>组合现有设施表,不新增投影:水厂、共用泵站、统一管网基础表和排水工程设施。
- * 只有实体明确提供的字段才映射;缺少统一来源的规模、区域或长度保持 null,避免把
- * 容量、坐标或展示文本误当作基础档案事实。
+ * 只有实体明确提供的字段才映射;缺少统一来源的规模、片区或长度保持 null,避免把
+ * 容量、坐标、自由文本位置或展示文本误当作基础档案事实。
+ *
+ * <p>**片区(district)一律传 null**:水厂/泵站/管网/排水设施都没有
+ * {@code equipment_base.district} 那种权威片区列,也不关联设备,故如实归入「未分区」,
+ * 不新增数据库列、不用行业或管理单位冒充(决策票 06 / 执行票 12)。
  */
  */
 @Component
 @Component
 @Primary
 @Primary
@@ -135,7 +139,9 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
             if (code == null || typeCode == null) {
             if (code == null || typeCode == null) {
                 continue;
                 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()));
                     source.getCreateTime() == null ? null : source.getCreateTime().toLocalDate()));
         }
         }
     }
     }
@@ -186,12 +192,12 @@ public class BusinessCockpitInfrastructureBaseArchivesAdapter
     private static CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility(
     private static CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility(
             String code,
             String code,
             String typeCode,
             String typeCode,
-            String region,
+            Integer district,
             BigDecimal lengthKm,
             BigDecimal lengthKm,
             LocalDate commissionedAt,
             LocalDate commissionedAt,
             LocalDate builtAt) {
             LocalDate builtAt) {
         return new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
         return new CockpitInfrastructureBaseArchivesSnapshot.SourceFacility(
-                code, typeCode, region, lengthKm, null, commissionedAt, builtAt);
+                code, typeCode, district, lengthKm, null, commissionedAt, builtAt);
     }
     }
 
 
     private static CockpitInfrastructureBaseArchivesSnapshot.SourceType type(
     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.EquipmentTypeService;
 import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.ManholeBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
 import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
@@ -26,10 +27,16 @@ import java.util.Objects;
 import java.util.stream.Collectors;
 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
 @Component
 @Primary
 @Primary
@@ -70,15 +77,15 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
                 continue;
             }
             }
             String objectCode = trimToNull(device.getEquipmentCode());
             String objectCode = trimToNull(device.getEquipmentCode());
-            String organization = trimToNull(device.getOwnershipUnit());
             String industry = device.getEquipmentTypeId() == null
             String industry = device.getEquipmentTypeId() == null
                     ? null
                     ? null
                     : CockpitTypeIndustry.industryOf(types.get(device.getEquipmentTypeId()), types);
                     : CockpitTypeIndustry.industryOf(types.get(device.getEquipmentTypeId()), types);
-            if (objectCode == null || organization == null || industry == null) {
+            if (objectCode == null || industry == null) {
                 continue;
                 continue;
             }
             }
+            // 设备自身片区即权威来源(equipment_base.district)。
             relations.add(relation(objectCode, "SMART_DEVICE", industry,
             relations.add(relation(objectCode, "SMART_DEVICE", industry,
-                    organization, organization, "OWNED"));
+                    CockpitDistrict.codeOf(device.getDistrict())));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(device.getUpdateTime()));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(device.getUpdateTime()));
         }
         }
 
 
@@ -87,12 +94,11 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
                 continue;
             }
             }
             String objectCode = trimToNull(manhole.getManholeCode());
             String objectCode = trimToNull(manhole.getManholeCode());
-            String organization = trimToNull(manhole.getOwnershipUnit());
-            if (objectCode == null || organization == null) {
+            if (objectCode == null) {
                 continue;
                 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()));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(manhole.getUpdateTime()));
         }
         }
 
 
@@ -101,13 +107,12 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
                 continue;
             }
             }
             String objectCode = trimToNull(pipe.getNetworkCode());
             String objectCode = trimToNull(pipe.getNetworkCode());
-            String organization = trimToNull(pipe.getManagementUnit());
             String industry = pipeIndustry(pipe.getNetworkType());
             String industry = pipeIndustry(pipe.getNetworkType());
-            if (objectCode == null || organization == null || industry == null) {
+            if (objectCode == null || industry == null) {
                 continue;
                 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()));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(pipe.getUpdateTime()));
         }
         }
 
 
@@ -116,12 +121,11 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
                 continue;
                 continue;
             }
             }
             String objectCode = trimToNull(source.getSourceId());
             String objectCode = trimToNull(source.getSourceId());
-            String organization = trimToNull(source.getUnit());
-            if (objectCode == null || organization == null) {
+            if (objectCode == null) {
                 continue;
                 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()));
             latestUpdatedAt[0] = latest(latestUpdatedAt[0], format(source.getUpdateTime()));
         }
         }
 
 
@@ -132,11 +136,9 @@ public class BusinessCockpitInfrastructureManagementInformationAdapter
             String objectCode,
             String objectCode,
             String objectKind,
             String objectKind,
             String industry,
             String industry,
-            String organizationCode,
-            String organizationName,
-            String ownershipType) {
+            Integer district) {
         return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
         return new CockpitInfrastructureManagementInformationSnapshot.SourceRelation(
-                objectCode, objectKind, industry, organizationCode, organizationName, ownershipType);
+                objectCode, objectKind, industry, district);
     }
     }
 
 
     private static Map<Integer, EquipmentType> typeIndex(List<EquipmentType> values) {
     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<TypeSummary> facilityTypeDistribution;
     private final List<AgeDistribution> ageDistribution;
     private final List<AgeDistribution> ageDistribution;
     private final AgeReference ageReference;
     private final AgeReference ageReference;
-    private final List<RegionDistribution> regionDistribution;
+    private final List<DistrictDistribution> districtDistribution;
     private final SelectedFilters selectedFilters;
     private final SelectedFilters selectedFilters;
     private final MapFilter mapFilter;
     private final MapFilter mapFilter;
     private final Excluded excluded;
     private final Excluded excluded;
@@ -36,7 +36,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
             List<TypeSummary> facilityTypeDistribution,
             List<TypeSummary> facilityTypeDistribution,
             List<AgeDistribution> ageDistribution,
             List<AgeDistribution> ageDistribution,
             AgeReference ageReference,
             AgeReference ageReference,
-            List<RegionDistribution> regionDistribution,
+            List<DistrictDistribution> districtDistribution,
             SelectedFilters selectedFilters,
             SelectedFilters selectedFilters,
             MapFilter mapFilter,
             MapFilter mapFilter,
             Excluded excluded) {
             Excluded excluded) {
@@ -53,7 +53,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
         this.facilityTypeDistribution = facilityTypeDistribution;
         this.facilityTypeDistribution = facilityTypeDistribution;
         this.ageDistribution = ageDistribution;
         this.ageDistribution = ageDistribution;
         this.ageReference = ageReference;
         this.ageReference = ageReference;
-        this.regionDistribution = regionDistribution;
+        this.districtDistribution = districtDistribution;
         this.selectedFilters = selectedFilters;
         this.selectedFilters = selectedFilters;
         this.mapFilter = mapFilter;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
         this.excluded = excluded;
@@ -72,7 +72,7 @@ public class CockpitInfrastructureBaseArchivesResponse {
     public List<TypeSummary> getFacilityTypeDistribution() { return facilityTypeDistribution; }
     public List<TypeSummary> getFacilityTypeDistribution() { return facilityTypeDistribution; }
     public List<AgeDistribution> getAgeDistribution() { return ageDistribution; }
     public List<AgeDistribution> getAgeDistribution() { return ageDistribution; }
     public AgeReference getAgeReference() { return ageReference; }
     public AgeReference getAgeReference() { return ageReference; }
-    public List<RegionDistribution> getRegionDistribution() { return regionDistribution; }
+    public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public SelectedFilters getSelectedFilters() { return selectedFilters; }
     public SelectedFilters getSelectedFilters() { return selectedFilters; }
     public MapFilter getMapFilter() { return mapFilter; }
     public MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
     public Excluded getExcluded() { return excluded; }
@@ -211,55 +211,96 @@ public class CockpitInfrastructureBaseArchivesResponse {
         public int getUnknownCount() { return unknownCount; }
         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;
         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;
             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; }
         public int getCount() { return count; }
     }
     }
 
 
+    /**
+     * 已生效筛选条件回显。
+     *
+     * <p>范围维度为片区:`district` 编码 + `districtName` 展示名 + `districtFilter`
+     * (`NONE` 未筛 / `ASSIGNED` 按片区 / `UNASSIGNED` 只看未分区)。未筛与未分区编码都是
+     * {@code null},靠 `districtFilter` 区分,不靠猜。
+     */
     public static class SelectedFilters {
     public static class SelectedFilters {
         private final String industry;
         private final String industry;
         private final String facilityType;
         private final String facilityType;
         private final String ageBucket;
         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.industry = industry;
             this.facilityType = facilityType;
             this.facilityType = facilityType;
             this.ageBucket = ageBucket;
             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 getIndustry() { return industry; }
         public String getFacilityType() { return facilityType; }
         public String getFacilityType() { return facilityType; }
         public String getAgeBucket() { return ageBucket; }
         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 {
     public static class MapFilter {
         private final String industry;
         private final String industry;
         private final String facilityType;
         private final String facilityType;
         private final String ageBucket;
         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.industry = industry;
             this.facilityType = facilityType;
             this.facilityType = facilityType;
             this.ageBucket = ageBucket;
             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 getIndustry() { return industry; }
         public String getFacilityType() { return facilityType; }
         public String getFacilityType() { return facilityType; }
         public String getAgeBucket() { return ageBucket; }
         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 {
     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;
 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.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitReadException;
 import com.zksy.cockpit.foundation.CockpitReadException;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.TypeSummary;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.TypeSummary;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.AgeDistribution;
 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.Industry;
-import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesResponse.RegionDistribution;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -21,7 +23,6 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
-import java.util.Objects;
 
 
 @Service
 @Service
 public class CockpitInfrastructureBaseArchivesService {
 public class CockpitInfrastructureBaseArchivesService {
@@ -41,22 +42,31 @@ public class CockpitInfrastructureBaseArchivesService {
         this.clock = clock;
         this.clock = clock;
     }
     }
 
 
+    /**
+     * 基础档案快照。全局筛选(industry / facilityType / ageBucket / district)由服务端完成,
+     * `selectedFilters` 与 `mapFilter` 回显实际生效范围。
+     *
+     * <p>范围维度自执行票 12 起为**片区**:只认 {@code 0/1/2/UNASSIGNED},其它值 400;
+     * 旧自由串 `region` 不再是正式契约,入参被忽略。设施表没有片区来源,故实际全部落
+     * 「未分区」——这是如实结果,不是缺陷。
+     */
     public CockpitInfrastructureBaseArchivesResponse load(
     public CockpitInfrastructureBaseArchivesResponse load(
             String requestId,
             String requestId,
             String industry,
             String industry,
             String facilityType,
             String facilityType,
             String ageBucket,
             String ageBucket,
-            String region) {
+            String district) {
         validateEnum("industry", industry, INDUSTRIES);
         validateEnum("industry", industry, INDUSTRIES);
         validateEnum("ageBucket", ageBucket, AGE_BUCKETS);
         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();
         CockpitInfrastructureBaseArchivesSnapshot snapshot = adapter.load();
         if (!isBlank(facilityType) && !types(snapshot).containsKey(facilityType)) {
         if (!isBlank(facilityType) && !types(snapshot).containsKey(facilityType)) {
             throw new CockpitInvalidRequestException("unknown facility type");
             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) {
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -81,7 +91,7 @@ public class CockpitInfrastructureBaseArchivesService {
             String industry,
             String industry,
             String facilityType,
             String facilityType,
             String ageBucket,
             String ageBucket,
-            String region) {
+            CockpitDistrict.Query district) {
         Map<String, CockpitInfrastructureBaseArchivesSnapshot.SourceType> types = types(snapshot);
         Map<String, CockpitInfrastructureBaseArchivesSnapshot.SourceType> types = types(snapshot);
         List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities =
         List<CockpitInfrastructureBaseArchivesSnapshot.SourceFacility> facilities =
                 snapshot == null || snapshot.getFacilities() == null
                 snapshot == null || snapshot.getFacilities() == null
@@ -104,7 +114,8 @@ public class CockpitInfrastructureBaseArchivesService {
         int commissionedCount = 0;
         int commissionedCount = 0;
         int builtCount = 0;
         int builtCount = 0;
         int unknownDateCount = 0;
         int unknownDateCount = 0;
-        Map<String, Integer> regionCounts = new LinkedHashMap<>();
+        // 按归一后的片区编码分桶;null 键=未分区(设施无片区来源,如实归入此桶)。
+        Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
 
 
         for (CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility : facilities) {
         for (CockpitInfrastructureBaseArchivesSnapshot.SourceFacility facility : facilities) {
             if (facility == null || isBlank(facility.getTypeCode())) {
             if (facility == null || isBlank(facility.getTypeCode())) {
@@ -129,8 +140,8 @@ public class CockpitInfrastructureBaseArchivesService {
             if (!isBlank(facilityType) && !facilityType.equals(type.getCode())) {
             if (!isBlank(facilityType) && !facilityType.equals(type.getCode())) {
                 continue;
                 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;
                 continue;
             }
             }
             String bucket = ageBucket(facility, LocalDate.now(clock));
             String bucket = ageBucket(facility, LocalDate.now(clock));
@@ -163,7 +174,7 @@ public class CockpitInfrastructureBaseArchivesService {
             } else {
             } else {
                 unknownDateCount++;
                 unknownDateCount++;
             }
             }
-            regionCounts.merge(normalizedRegion, 1, Integer::sum);
+            districtCounts.merge(normalizedDistrict, 1, Integer::sum);
         }
         }
 
 
         List<AgeDistribution> ageDistribution = new ArrayList<>();
         List<AgeDistribution> ageDistribution = new ArrayList<>();
@@ -174,9 +185,18 @@ public class CockpitInfrastructureBaseArchivesService {
         for (String code : INDUSTRIES) {
         for (String code : INDUSTRIES) {
             industryDistribution.add(new Industry(code, industryCounts.get(code)));
             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(
         return new CockpitInfrastructureBaseArchivesResponse(
@@ -204,17 +224,19 @@ public class CockpitInfrastructureBaseArchivesService {
                         commissionedCount,
                         commissionedCount,
                         builtCount,
                         builtCount,
                         unknownDateCount),
                         unknownDateCount),
-                regionDistribution,
+                districtDistribution,
                 new CockpitInfrastructureBaseArchivesResponse.SelectedFilters(
                 new CockpitInfrastructureBaseArchivesResponse.SelectedFilters(
                         industry,
                         industry,
                         facilityType,
                         facilityType,
                         ageBucket,
                         ageBucket,
-                        region),
+                        district.active() ? district.code() : null,
+                        districtFilter(district)),
                 new CockpitInfrastructureBaseArchivesResponse.MapFilter(
                 new CockpitInfrastructureBaseArchivesResponse.MapFilter(
                         mapIndustry(industry),
                         mapIndustry(industry),
                         facilityType,
                         facilityType,
                         ageBucket,
                         ageBucket,
-                        region),
+                        district.active() ? district.code() : null,
+                        districtFilter(district)),
                 new CockpitInfrastructureBaseArchivesResponse.Excluded(
                 new CockpitInfrastructureBaseArchivesResponse.Excluded(
                         unknownTypeCount,
                         unknownTypeCount,
                         typeKindMismatchCount,
                         typeKindMismatchCount,
@@ -283,8 +305,12 @@ public class CockpitInfrastructureBaseArchivesService {
         return left.add(right);
         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) {
     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; }
         public String getObjectKind() { return objectKind; }
     }
     }
 
 
+    /**
+     * 一条设施来源行。
+     *
+     * <p>{@code district} 是片区**原始编码**(未归一见 {@code CockpitDistrict}):
+     * 现网设施表(水厂/泵站/管网/排水设施)没有片区列、也不关联设备,因此适配器一律传
+     * {@code null},由服务层如实归一为「未分区」,不用行业或管理单位冒充。
+     */
     public static class SourceFacility {
     public static class SourceFacility {
         private final String code;
         private final String code;
         private final String typeCode;
         private final String typeCode;
-        private final String region;
+        private final Integer district;
         private final BigDecimal lengthKm;
         private final BigDecimal lengthKm;
         private final String scale;
         private final String scale;
         private final LocalDate commissionedAt;
         private final LocalDate commissionedAt;
@@ -53,14 +60,14 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
         public SourceFacility(
         public SourceFacility(
                 String code,
                 String code,
                 String typeCode,
                 String typeCode,
-                String region,
+                Integer district,
                 BigDecimal lengthKm,
                 BigDecimal lengthKm,
                 String scale,
                 String scale,
                 LocalDate commissionedAt,
                 LocalDate commissionedAt,
                 LocalDate builtAt) {
                 LocalDate builtAt) {
             this.code = code;
             this.code = code;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
-            this.region = region;
+            this.district = district;
             this.lengthKm = lengthKm;
             this.lengthKm = lengthKm;
             this.scale = scale;
             this.scale = scale;
             this.commissionedAt = commissionedAt;
             this.commissionedAt = commissionedAt;
@@ -69,7 +76,7 @@ public class CockpitInfrastructureBaseArchivesSnapshot {
 
 
         public String getCode() { return code; }
         public String getCode() { return code; }
         public String getTypeCode() { return typeCode; }
         public String getTypeCode() { return typeCode; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
         public BigDecimal getLengthKm() { return lengthKm; }
         public BigDecimal getLengthKm() { return lengthKm; }
         public String getScale() { return scale; }
         public String getScale() { return scale; }
         public LocalDate getCommissionedAt() { return commissionedAt; }
         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;
 package com.zksy.cockpit.infrastructure;
 
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
+
 import java.util.List;
 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 {
 public class CockpitInfrastructureManagementInformationResponse {
     private final String requestId;
     private final String requestId;
     private final String apiVersion;
     private final String apiVersion;
@@ -12,8 +26,7 @@ public class CockpitInfrastructureManagementInformationResponse {
     private final Freshness freshness;
     private final Freshness freshness;
     private final SelectedFilters selectedFilters;
     private final SelectedFilters selectedFilters;
     private final Totals totals;
     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 MapFilter mapFilter;
     private final Excluded excluded;
     private final Excluded excluded;
 
 
@@ -27,8 +40,7 @@ public class CockpitInfrastructureManagementInformationResponse {
             Freshness freshness,
             Freshness freshness,
             SelectedFilters selectedFilters,
             SelectedFilters selectedFilters,
             Totals totals,
             Totals totals,
-            List<Organization> organizations,
-            List<Ownership> ownershipDistribution,
+            List<DistrictIndustryCount> districtIndustry,
             MapFilter mapFilter,
             MapFilter mapFilter,
             Excluded excluded) {
             Excluded excluded) {
         this.requestId = requestId;
         this.requestId = requestId;
@@ -40,8 +52,7 @@ public class CockpitInfrastructureManagementInformationResponse {
         this.freshness = freshness;
         this.freshness = freshness;
         this.selectedFilters = selectedFilters;
         this.selectedFilters = selectedFilters;
         this.totals = totals;
         this.totals = totals;
-        this.organizations = organizations;
-        this.ownershipDistribution = ownershipDistribution;
+        this.districtIndustry = districtIndustry;
         this.mapFilter = mapFilter;
         this.mapFilter = mapFilter;
         this.excluded = excluded;
         this.excluded = excluded;
     }
     }
@@ -55,8 +66,8 @@ public class CockpitInfrastructureManagementInformationResponse {
     public Freshness getFreshness() { return freshness; }
     public Freshness getFreshness() { return freshness; }
     public SelectedFilters getSelectedFilters() { return selectedFilters; }
     public SelectedFilters getSelectedFilters() { return selectedFilters; }
     public Totals getTotals() { return totals; }
     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 MapFilter getMapFilter() { return mapFilter; }
     public Excluded getExcluded() { return excluded; }
     public Excluded getExcluded() { return excluded; }
 
 
@@ -79,106 +90,96 @@ public class CockpitInfrastructureManagementInformationResponse {
         public String getServerUpdatedAt() { return serverUpdatedAt; }
         public String getServerUpdatedAt() { return serverUpdatedAt; }
     }
     }
 
 
+    /** 生效筛选回显;组织维度已删除,保留行业与片区。 */
     public static class SelectedFilters {
     public static class SelectedFilters {
         private final String industry;
         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.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 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 {
     public static class Totals {
-        private final int organizationCount;
         private final int facilityCount;
         private final int facilityCount;
         private final int equipmentCount;
         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.facilityCount = facilityCount;
             this.equipmentCount = equipmentCount;
             this.equipmentCount = equipmentCount;
-            this.unmaintainedCount = unmaintainedCount;
         }
         }
 
 
-        public int getOrganizationCount() { return organizationCount; }
         public int getFacilityCount() { return facilityCount; }
         public int getFacilityCount() { return facilityCount; }
         public int getEquipmentCount() { return equipmentCount; }
         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 String industry;
         private final int facilityCount;
         private final int facilityCount;
         private final int equipmentCount;
         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.industry = industry;
             this.facilityCount = facilityCount;
             this.facilityCount = facilityCount;
             this.equipmentCount = equipmentCount;
             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 String getIndustry() { return industry; }
         public int getFacilityCount() { return facilityCount; }
         public int getFacilityCount() { return facilityCount; }
         public int getEquipmentCount() { return equipmentCount; }
         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 {
     public static class MapFilter {
         private final String industry;
         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.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 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 {
     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;
 package com.zksy.cockpit.infrastructure;
 
 
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationResponse.DistrictIndustryCount;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.time.Clock;
 import java.time.Clock;
@@ -18,10 +20,17 @@ import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 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
 @Service
 public class CockpitInfrastructureManagementInformationService {
 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 List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String FACILITY = "FACILITY";
     private static final String FACILITY = "FACILITY";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     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 CockpitInfrastructureManagementInformationAdapter adapter;
     private final Clock clock;
     private final Clock clock;
@@ -46,9 +52,12 @@ public class CockpitInfrastructureManagementInformationService {
     public CockpitInfrastructureManagementInformationResponse load(
     public CockpitInfrastructureManagementInformationResponse load(
             String requestId,
             String requestId,
             String industry,
             String industry,
-            String organizationCode) {
+            String district) {
         String selectedIndustry = resolveIndustry(industry);
         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();
         CockpitInfrastructureManagementInformationSnapshot snapshot = adapter.load();
         List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations =
         List<CockpitInfrastructureManagementInformationSnapshot.SourceRelation> relations =
@@ -61,7 +70,8 @@ public class CockpitInfrastructureManagementInformationService {
         int unknownIndustryCount = 0;
         int unknownIndustryCount = 0;
         int nonFormalIndustryCount = 0;
         int nonFormalIndustryCount = 0;
         int unknownObjectKindCount = 0;
         int unknownObjectKindCount = 0;
-        Map<String, OrganizationAccumulator> accumulators = new LinkedHashMap<>();
+        // 键=归一后的片区编码(null=未分区)+ 行业;一桶一行的设施/设备计数。
+        Map<DistrictIndustryKey, Counter> counters = new LinkedHashMap<>();
 
 
         for (CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation : relations) {
         for (CockpitInfrastructureManagementInformationSnapshot.SourceRelation relation : relations) {
             if (relation == null || isBlank(relation.getObjectCode())) {
             if (relation == null || isBlank(relation.getObjectCode())) {
@@ -89,57 +99,30 @@ public class CockpitInfrastructureManagementInformationService {
             if (selectedIndustry != null && !selectedIndustry.equals(industryCode)) {
             if (selectedIndustry != null && !selectedIndustry.equals(industryCode)) {
                 continue;
                 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;
                 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 facilityCount = 0;
         int equipmentCount = 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 serverUpdatedAt = businessTime();
         String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
         String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
@@ -154,20 +137,23 @@ public class CockpitInfrastructureManagementInformationService {
                 "Asia/Shanghai",
                 "Asia/Shanghai",
                 updatedAt,
                 updatedAt,
                 new CockpitInfrastructureManagementInformationResponse.Freshness(
                 new CockpitInfrastructureManagementInformationResponse.Freshness(
-                        organizations.isEmpty()
+                        districtIndustry.isEmpty()
                                 ? CockpitFreshnessStatus.EMPTY.name()
                                 ? CockpitFreshnessStatus.EMPTY.name()
                                 : CockpitFreshnessStatus.CURRENT.name(),
                                 : CockpitFreshnessStatus.CURRENT.name(),
-                        organizations.isEmpty() ? "NO_ORGANIZATIONS" : "READY",
+                        districtIndustry.isEmpty() ? "NO_RELATIONS" : "READY",
                         updatedAt,
                         updatedAt,
                         serverUpdatedAt),
                         serverUpdatedAt),
                 new CockpitInfrastructureManagementInformationResponse.SelectedFilters(
                 new CockpitInfrastructureManagementInformationResponse.SelectedFilters(
-                        selectedIndustry, selectedOrganization),
+                        selectedIndustry,
+                        selectedDistrict.active() ? selectedDistrict.code() : null,
+                        districtFilter(selectedDistrict)),
                 new CockpitInfrastructureManagementInformationResponse.Totals(
                 new CockpitInfrastructureManagementInformationResponse.Totals(
-                        organizations.size(), facilityCount, equipmentCount, unmaintainedCount),
-                organizations,
-                ownershipDistribution,
+                        facilityCount, equipmentCount),
+                districtIndustry,
                 new CockpitInfrastructureManagementInformationResponse.MapFilter(
                 new CockpitInfrastructureManagementInformationResponse.MapFilter(
-                        selectedIndustry, selectedOrganization, organizationCodes),
+                        selectedIndustry,
+                        selectedDistrict.active() ? selectedDistrict.code() : null,
+                        districtFilter(selectedDistrict)),
                 new CockpitInfrastructureManagementInformationResponse.Excluded(
                 new CockpitInfrastructureManagementInformationResponse.Excluded(
                         unknownIndustryCount,
                         unknownIndustryCount,
                         nonFormalIndustryCount,
                         nonFormalIndustryCount,
@@ -202,18 +188,18 @@ public class CockpitInfrastructureManagementInformationService {
         return resolved;
         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) {
     private static String normalize(String value) {
         return isBlank(value) ? "" : value.trim().toUpperCase();
         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() {
     private String businessTime() {
         return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
         return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
                 OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
                 OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
@@ -223,70 +209,48 @@ public class CockpitInfrastructureManagementInformationService {
         return value == null || value.trim().isEmpty();
         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 facilityCount;
         private int equipmentCount;
         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++;
                 facilityCount++;
             } else {
             } else {
                 equipmentCount++;
                 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; }
     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 {
     public static class SourceRelation {
         private final String objectCode;
         private final String objectCode;
         private final String objectKind;
         private final String objectKind;
         private final String industry;
         private final String industry;
-        private final String organizationCode;
-        private final String organizationName;
-        private final String ownershipType;
+        private final Integer district;
 
 
         public SourceRelation(
         public SourceRelation(
                 String objectCode,
                 String objectCode,
                 String objectKind,
                 String objectKind,
                 String industry,
                 String industry,
-                String organizationCode,
-                String organizationName,
-                String ownershipType) {
+                Integer district) {
             this.objectCode = objectCode;
             this.objectCode = objectCode;
             this.objectKind = objectKind;
             this.objectKind = objectKind;
             this.industry = industry;
             this.industry = industry;
-            this.organizationCode = organizationCode;
-            this.organizationName = organizationName;
-            this.ownershipType = ownershipType;
+            this.district = district;
         }
         }
 
 
         public String getObjectCode() { return objectCode; }
         public String getObjectCode() { return objectCode; }
         public String getObjectKind() { return objectKind; }
         public String getObjectKind() { return objectKind; }
         public String getIndustry() { return industry; }
         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.CockpitSnapshotTime;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Primary;
 import org.springframework.context.annotation.Primary;
@@ -215,6 +216,7 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
                     equipmentBaseService.getEquipmentByPoint(point.getPointId()));
                     equipmentBaseService.getEquipmentByPoint(point.getPointId()));
             List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
             List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
             Set<String> deviceTypes = new LinkedHashSet<>();
             Set<String> deviceTypes = new LinkedHashSet<>();
+            List<Integer> boundDistricts = new ArrayList<>();
             for (EquipmentBase device : boundDevices) {
             for (EquipmentBase device : boundDevices) {
                 if (device == null || isBlank(device.getEquipmentCode())) {
                 if (device == null || isBlank(device.getEquipmentCode())) {
                     continue;
                     continue;
@@ -227,11 +229,13 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
                 if (boundType != null && boundType.getId() != null) {
                 if (boundType != null && boundType.getId() != null) {
                     deviceTypes.add(String.valueOf(boundType.getId()));
                     deviceTypes.add(String.valueOf(boundType.getId()));
                 }
                 }
+                boundDistricts.add(device.getDistrict());
             }
             }
             sourcePoints.add(new CockpitMapSnapshot.SourceMonitoringPoint(
             sourcePoints.add(new CockpitMapSnapshot.SourceMonitoringPoint(
                     point.getPointCode().trim(),
                     point.getPointCode().trim(),
                     point.getPointName(),
                     point.getPointName(),
-                    null,
+                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
+                    CockpitDistrict.aggregate(boundDistricts),
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     point.getLongitude(),
                     point.getLongitude(),
                     point.getLatitude(),
                     point.getLatitude(),
@@ -248,7 +252,8 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
             smartDevices.add(new CockpitMapSnapshot.SourceSmartDevice(
             smartDevices.add(new CockpitMapSnapshot.SourceSmartDevice(
                     device.getEquipmentCode().trim(),
                     device.getEquipmentCode().trim(),
                     device.getEquipmentName(),
                     device.getEquipmentName(),
-                    null,
+                    // 设备自身片区(equipment_base.district)即权威来源。
+                    CockpitDistrict.codeOf(device.getDistrict()),
                     type == null || type.getId() == null ? null : String.valueOf(type.getId()),
                     type == null || type.getId() == null ? null : String.valueOf(type.getId()),
                     device.getLongitude(),
                     device.getLongitude(),
                     device.getLatitude(),
                     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 {
     public static class SourceMonitoringPoint {
         private final String pointCode;
         private final String pointCode;
         private final String pointName;
         private final String pointName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         private final boolean coordinateVerified;
         private final List<CockpitMonitoringPointDeviceSnapshot> devices;
         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,
                 BigDecimal longitude, BigDecimal latitude,
                 boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
                 boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
             this.pointCode = pointCode;
             this.pointCode = pointCode;
             this.pointName = pointName;
             this.pointName = pointName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
@@ -89,7 +89,7 @@ public class CockpitMapSnapshot {
 
 
         public String getPointCode() { return pointCode; }
         public String getPointCode() { return pointCode; }
         public String getPointName() { return pointName; }
         public String getPointName() { return pointName; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
         public String getTypeCode() { return typeCode; }
         public String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
@@ -100,17 +100,17 @@ public class CockpitMapSnapshot {
     public static class SourceSmartDevice {
     public static class SourceSmartDevice {
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         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) {
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
@@ -119,7 +119,7 @@ public class CockpitMapSnapshot {
 
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
         public String getDeviceName() { return deviceName; }
-        public String getRegion() { return region; }
+        public Integer getDistrict() { return district; }
         public String getTypeCode() { return typeCode; }
         public String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         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; }
     public MapFilter getMapFilter() { return mapFilter; }
 
 
     /**
     /**
-     * 生效范围回显:请求的过滤条件(未请求为 null)+ 过滤后**全量**(分页前)的点位/设备编码。
+     * 生效范围回显:请求的过滤条件 + 过滤后**全量**(分页前)的点位/设备编码。
      * 口径与 `/monitoring/distribution` 的 `mapFilter` 一致。
      * 口径与 `/monitoring/distribution` 的 `mapFilter` 一致。
+     *
+     * <p>范围维度自执行票 12 起为**片区**(`district`):旧自由串 `region` 不再作为正式契约。
+     * 「未筛」与「只看未分区」的编码都是 `null`,靠 {@link #districtFilter} 区分
+     * (`NONE` / `UNASSIGNED` / `ASSIGNED`),不靠猜。
      */
      */
     public static class MapFilter {
     public static class MapFilter {
         private final String industry;
         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 String deviceType;
         private final List<String> pointCodes;
         private final List<String> pointCodes;
         private final List<String> deviceCodes;
         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) {
                 List<String> pointCodes, List<String> deviceCodes) {
             this.industry = industry;
             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.deviceType = deviceType;
             this.pointCodes = pointCodes;
             this.pointCodes = pointCodes;
             this.deviceCodes = deviceCodes;
             this.deviceCodes = deviceCodes;
         }
         }
 
 
         public String getIndustry() { return industry; }
         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 String getDeviceType() { return deviceType; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getPointCodes() { return pointCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }
         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.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -35,7 +36,6 @@ public class CockpitMapSnapshotService {
     private static final int MAX_PAGE_SIZE = 1000;
     private static final int MAX_PAGE_SIZE = 1000;
     private static final String WGS84 = "WGS84";
     private static final String WGS84 = "WGS84";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     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 static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
 
 
     private final CockpitMapSnapshotAdapter adapter;
     private final CockpitMapSnapshotAdapter adapter;
@@ -47,17 +47,24 @@ public class CockpitMapSnapshotService {
     }
     }
 
 
     /**
     /**
-     * 地图分页快照。全局筛选(industry / region / deviceType)生效时服务端完成过滤,
+     * 地图分页快照。全局筛选(industry / district / deviceType)生效时服务端完成过滤,
      * `mapFilter` 回显实际生效范围;分页与 `snapshotAt` 语义不因过滤而改变。
      * `mapFilter` 回显实际生效范围;分页与 `snapshotAt` 语义不因过滤而改变。
+     *
+     * <p>范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`);
+     * 旧自由串 `region` 不再是正式契约,入参被忽略。
      */
      */
     public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
     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 safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
         validatePagination(safePage, safePageSize, snapshotAt);
         validateSnapshotAt(snapshotAt);
         validateSnapshotAt(snapshotAt);
         String selectedIndustry = resolveIndustry(industry);
         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();
         String selectedDeviceType = isBlank(deviceType) ? null : deviceType.trim();
 
 
         CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
         CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
@@ -76,9 +83,9 @@ public class CockpitMapSnapshotService {
 
 
         Map<String, CockpitMapSnapshot.SourceType> types = types(snapshot);
         Map<String, CockpitMapSnapshot.SourceType> types = types(snapshot);
         List<CockpitMonitoringPointSummary> points = monitoringPoints(
         List<CockpitMonitoringPointSummary> points = monitoringPoints(
-                snapshot, types, selectedIndustry, selectedRegion, selectedDeviceType);
+                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
         List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
         List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
-                snapshot, types, selectedIndustry, selectedRegion, selectedDeviceType);
+                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
         int total = points.size() + equipment.size();
         int total = points.size() + equipment.size();
         int fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
@@ -102,7 +109,7 @@ public class CockpitMapSnapshotService {
                 pagePoints,
                 pagePoints,
                 pageEquipment,
                 pageEquipment,
                 coordinateValidation(points, equipment),
                 coordinateValidation(points, equipment),
-                mapFilter(selectedIndustry, selectedRegion, selectedDeviceType, points, equipment));
+                mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment));
     }
     }
 
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -209,12 +216,8 @@ public class CockpitMapSnapshotService {
         return isBlank(typeCode) ? null : typeCode.trim();
         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);
         CockpitMapSnapshot.SourceType type = resolveType(types, typeCode);
         if (industry != null && !industry.equals(industryOf(type))) {
         if (industry != null && !industry.equals(industryOf(type))) {
             return false;
             return false;
@@ -222,20 +225,20 @@ public class CockpitMapSnapshotService {
         if (deviceType != null && (type == null || !deviceType.equals(typeCodeOf(type.getCode())))) {
         if (deviceType != null && (type == null || !deviceType.equals(typeCodeOf(type.getCode())))) {
             return false;
             return false;
         }
         }
-        return region == null || region.equals(regionOf(rawRegion));
+        return CockpitDistrict.matches(district, rawDistrict);
     }
     }
 
 
     private static List<CockpitMonitoringPointSummary> monitoringPoints(
     private static List<CockpitMonitoringPointSummary> monitoringPoints(
             CockpitMapSnapshot snapshot,
             CockpitMapSnapshot snapshot,
             Map<String, CockpitMapSnapshot.SourceType> types,
             Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, String region, String deviceType) {
+            String industry, CockpitDistrict.Query district, String deviceType) {
         if (snapshot.getMonitoringPoints() == null) {
         if (snapshot.getMonitoringPoints() == null) {
             return Collections.emptyList();
             return Collections.emptyList();
         }
         }
         return snapshot.getMonitoringPoints().stream()
         return snapshot.getMonitoringPoints().stream()
                 .filter(Objects::nonNull)
                 .filter(Objects::nonNull)
                 .filter(source -> matchesFilters(
                 .filter(source -> matchesFilters(
-                        industry, region, deviceType, source.getTypeCode(), source.getRegion(), types))
+                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> monitoringPoint(source, types))
                 .map(source -> monitoringPoint(source, types))
                 .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
                 .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
                         Comparator.nullsLast(String::compareTo)))
                         Comparator.nullsLast(String::compareTo)))
@@ -264,7 +267,7 @@ public class CockpitMapSnapshotService {
                 pointStatus,
                 pointStatus,
                 CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
                 CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
                 deviceCodes,
                 deviceCodes,
-                regionOf(source.getRegion()),
+                source.getDistrict(),
                 industryOf(type),
                 industryOf(type),
                 typeCodeOf(source.getTypeCode()),
                 typeCodeOf(source.getTypeCode()),
                 typeNameOf(type),
                 typeNameOf(type),
@@ -277,7 +280,7 @@ public class CockpitMapSnapshotService {
     private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(
     private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(
             CockpitMapSnapshot snapshot,
             CockpitMapSnapshot snapshot,
             Map<String, CockpitMapSnapshot.SourceType> types,
             Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, String region, String deviceType) {
+            String industry, CockpitDistrict.Query district, String deviceType) {
         if (snapshot.getSmartDevices() == null) {
         if (snapshot.getSmartDevices() == null) {
             return Collections.emptyList();
             return Collections.emptyList();
         }
         }
@@ -287,7 +290,7 @@ public class CockpitMapSnapshotService {
                 .filter(device -> device.getDeviceCode() == null
                 .filter(device -> device.getDeviceCode() == null
                         || !associatedDeviceCodes.contains(device.getDeviceCode()))
                         || !associatedDeviceCodes.contains(device.getDeviceCode()))
                 .filter(source -> matchesFilters(
                 .filter(source -> matchesFilters(
-                        industry, region, deviceType, source.getTypeCode(), source.getRegion(), types))
+                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
                 .map(source -> unassociatedEquipment(source, types))
                 .map(source -> unassociatedEquipment(source, types))
                 .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
                 .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
                         Comparator.nullsLast(String::compareTo)))
                         Comparator.nullsLast(String::compareTo)))
@@ -314,7 +317,7 @@ public class CockpitMapSnapshotService {
         return new CockpitUnassociatedEquipmentSummary(
         return new CockpitUnassociatedEquipmentSummary(
                 source.getDeviceCode(),
                 source.getDeviceCode(),
                 source.getDeviceName(),
                 source.getDeviceName(),
-                regionOf(source.getRegion()),
+                source.getDistrict(),
                 industryOf(type),
                 industryOf(type),
                 typeCodeOf(source.getTypeCode()),
                 typeCodeOf(source.getTypeCode()),
                 typeNameOf(type),
                 typeNameOf(type),
@@ -325,7 +328,7 @@ public class CockpitMapSnapshotService {
     }
     }
 
 
     private static CockpitMapSnapshotResponse.MapFilter mapFilter(
     private static CockpitMapSnapshotResponse.MapFilter mapFilter(
-            String industry, String region, String deviceType,
+            String industry, CockpitDistrict.Query district, String deviceType,
             List<CockpitMonitoringPointSummary> points,
             List<CockpitMonitoringPointSummary> points,
             List<CockpitUnassociatedEquipmentSummary> equipment) {
             List<CockpitUnassociatedEquipmentSummary> equipment) {
         Set<String> pointCodes = new LinkedHashSet<>();
         Set<String> pointCodes = new LinkedHashSet<>();
@@ -348,7 +351,10 @@ public class CockpitMapSnapshotService {
             }
             }
         }
         }
         return new CockpitMapSnapshotResponse.MapFilter(
         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) {
     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;
 package com.zksy.cockpit.map;
 
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -12,7 +13,7 @@ public class CockpitMonitoringPointSummary {
     private final CockpitMonitoringPointStatus pointStatus;
     private final CockpitMonitoringPointStatus pointStatus;
     private final List<String> statusReasons;
     private final List<String> statusReasons;
     private final List<String> deviceCodes;
     private final List<String> deviceCodes;
-    private final String region;
+    private final Integer district;
     private final String industry;
     private final String industry;
     private final String typeCode;
     private final String typeCode;
     private final String typeName;
     private final String typeName;
@@ -22,14 +23,14 @@ public class CockpitMonitoringPointSummary {
 
 
     public CockpitMonitoringPointSummary(String pointCode, String pointName,
     public CockpitMonitoringPointSummary(String pointCode, String pointName,
             CockpitMonitoringPointStatus pointStatus, List<String> statusReasons, List<String> deviceCodes,
             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) {
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
         this.pointCode = pointCode;
         this.pointCode = pointCode;
         this.pointName = pointName;
         this.pointName = pointName;
         this.pointStatus = pointStatus;
         this.pointStatus = pointStatus;
         this.statusReasons = statusReasons;
         this.statusReasons = statusReasons;
         this.deviceCodes = deviceCodes;
         this.deviceCodes = deviceCodes;
-        this.region = region;
+        this.district = CockpitDistrict.codeOf(district);
         this.industry = industry;
         this.industry = industry;
         this.typeCode = typeCode;
         this.typeCode = typeCode;
         this.typeName = typeName;
         this.typeName = typeName;
@@ -43,7 +44,10 @@ public class CockpitMonitoringPointSummary {
     public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
     public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
     public List<String> getStatusReasons() { return statusReasons; }
     public List<String> getStatusReasons() { return statusReasons; }
     public List<String> getDeviceCodes() { return deviceCodes; }
     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 getIndustry() { return industry; }
     public String getTypeCode() { return typeCode; }
     public String getTypeCode() { return typeCode; }
     public String getTypeName() { return typeName; }
     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;
 package com.zksy.cockpit.map;
 
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 
 
 public class CockpitUnassociatedEquipmentSummary {
 public class CockpitUnassociatedEquipmentSummary {
     private final String deviceCode;
     private final String deviceCode;
     private final String deviceName;
     private final String deviceName;
-    private final String region;
+    private final Integer district;
     private final String industry;
     private final String industry;
     private final String typeCode;
     private final String typeCode;
     private final String typeName;
     private final String typeName;
@@ -16,11 +17,11 @@ public class CockpitUnassociatedEquipmentSummary {
     private final CockpitCoordinateValidation coordinateValidation;
     private final CockpitCoordinateValidation coordinateValidation;
 
 
     public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
     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) {
             BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.deviceName = deviceName;
-        this.region = region;
+        this.district = CockpitDistrict.codeOf(district);
         this.industry = industry;
         this.industry = industry;
         this.typeCode = typeCode;
         this.typeCode = typeCode;
         this.typeName = typeName;
         this.typeName = typeName;
@@ -31,7 +32,10 @@ public class CockpitUnassociatedEquipmentSummary {
 
 
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceName() { return deviceName; }
     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 getIndustry() { return industry; }
     public String getTypeCode() { return typeCode; }
     public String getTypeCode() { return typeCode; }
     public String getTypeName() { return typeName; }
     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.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
 import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
@@ -35,7 +36,8 @@ import java.util.stream.Collectors;
  *
  *
  * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
  * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
  * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
  * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
- * 区域(region)在现网点位/设备上无可靠来源,统一上报 {@code null} 交由服务层归一为「未分区」。
+ * 片区(district)的权威来源是 {@code equipment_base.district}:点位按「经其上挂设备归纳」
+ * 取得(不一致/无设备 → 未分区),设备直接用自身片区。
  */
  */
 @Component
 @Component
 @Primary
 @Primary
@@ -86,6 +88,7 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     equipmentBaseService.getEquipmentByPoint(point.getPointId()));
                     equipmentBaseService.getEquipmentByPoint(point.getPointId()));
             List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
             List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
             Set<String> deviceTypes = new LinkedHashSet<>();
             Set<String> deviceTypes = new LinkedHashSet<>();
+            List<Integer> boundDistricts = new ArrayList<>();
             for (EquipmentBase device : boundDevices) {
             for (EquipmentBase device : boundDevices) {
                 if (device == null || isBlank(device.getEquipmentCode())) {
                 if (device == null || isBlank(device.getEquipmentCode())) {
                     continue;
                     continue;
@@ -98,11 +101,13 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                 if (boundType != null && boundType.getId() != null) {
                 if (boundType != null && boundType.getId() != null) {
                     deviceTypes.add(String.valueOf(boundType.getId()));
                     deviceTypes.add(String.valueOf(boundType.getId()));
                 }
                 }
+                boundDistricts.add(device.getDistrict());
             }
             }
             sourcePoints.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
             sourcePoints.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
                     point.getPointCode().trim(),
                     point.getPointCode().trim(),
                     point.getPointName(),
                     point.getPointName(),
-                    null,
+                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
+                    CockpitDistrict.aggregate(boundDistricts),
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     point.getLongitude(),
                     point.getLongitude(),
                     point.getLatitude(),
                     point.getLatitude(),
@@ -121,7 +126,8 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
             smartDevices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
             smartDevices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
                     device.getEquipmentCode().trim(),
                     device.getEquipmentCode().trim(),
                     device.getEquipmentName(),
                     device.getEquipmentName(),
-                    null,
+                    // 设备自身片区(equipment_base.district)即权威来源。
+                    CockpitDistrict.codeOf(device.getDistrict()),
                     typeCode,
                     typeCode,
                     device.getLongitude(),
                     device.getLongitude(),
                     device.getLatitude(),
                     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.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
@@ -45,7 +46,7 @@ import java.util.stream.Collectors;
  * 设备无法匹配到类型时 typeCode 为 null(服务层按「未知类型」计入 excluded,不静默归类)。
  * 设备无法匹配到类型时 typeCode 为 null(服务层按「未知类型」计入 excluded,不静默归类)。
  *
  *
  * <p><b>不臆造</b>:现网无「样本过期」依据 → 不产生 EXPIRED;无坐标校核字段 → {@code coordinateVerified=false};
  * <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
 @Component
 @Primary
 @Primary
@@ -106,7 +107,8 @@ public class BusinessCockpitMonitoringSituationAdapter implements CockpitMonitor
             devices.add(new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
             devices.add(new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
                     deviceCode,
                     deviceCode,
                     device.getEquipmentName(),
                     device.getEquipmentName(),
-                    null,
+                    // 设备自身片区(equipment_base.district)即权威来源;库中脏值由服务层归一为未分区
+                    CockpitDistrict.codeOf(device.getDistrict()),
                     typeCode,
                     typeCode,
                     metricStatus(deviceCode, fact),
                     metricStatus(deviceCode, fact),
                     fact.getStatusReasons(),
                     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;
 package com.zksy.cockpit.monitoring;
 
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
@@ -15,7 +16,7 @@ public class CockpitMonitoringDistributionResponse {
     private final Range range;
     private final Range range;
     private final String updatedAt;
     private final String updatedAt;
     private final Freshness freshness;
     private final Freshness freshness;
-    private final List<RegionDistribution> regionDistribution;
+    private final List<DistrictDistribution> districtDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<DeviceTypeDistribution> deviceTypeDistribution;
     private final List<DeviceTypeDistribution> deviceTypeDistribution;
     private final List<PointStatusCount> pointStatusDistribution;
     private final List<PointStatusCount> pointStatusDistribution;
@@ -34,7 +35,7 @@ public class CockpitMonitoringDistributionResponse {
             Range range,
             Range range,
             String updatedAt,
             String updatedAt,
             Freshness freshness,
             Freshness freshness,
-            List<RegionDistribution> regionDistribution,
+            List<DistrictDistribution> districtDistribution,
             List<IndustryDistribution> industryDistribution,
             List<IndustryDistribution> industryDistribution,
             List<DeviceTypeDistribution> deviceTypeDistribution,
             List<DeviceTypeDistribution> deviceTypeDistribution,
             List<PointStatusCount> pointStatusDistribution,
             List<PointStatusCount> pointStatusDistribution,
@@ -51,7 +52,7 @@ public class CockpitMonitoringDistributionResponse {
         this.range = range;
         this.range = range;
         this.updatedAt = updatedAt;
         this.updatedAt = updatedAt;
         this.freshness = freshness;
         this.freshness = freshness;
-        this.regionDistribution = regionDistribution;
+        this.districtDistribution = districtDistribution;
         this.industryDistribution = industryDistribution;
         this.industryDistribution = industryDistribution;
         this.deviceTypeDistribution = deviceTypeDistribution;
         this.deviceTypeDistribution = deviceTypeDistribution;
         this.pointStatusDistribution = pointStatusDistribution;
         this.pointStatusDistribution = pointStatusDistribution;
@@ -70,7 +71,7 @@ public class CockpitMonitoringDistributionResponse {
     public Range getRange() { return range; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
     public String getUpdatedAt() { return updatedAt; }
     public Freshness getFreshness() { return freshness; }
     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<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
     public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
     public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
     public List<PointStatusCount> getPointStatusDistribution() { return pointStatusDistribution; }
     public List<PointStatusCount> getPointStatusDistribution() { return pointStatusDistribution; }
@@ -136,20 +137,29 @@ public class CockpitMonitoringDistributionResponse {
         public int getEmpty() { return empty; }
         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 pointCount;
         private final int deviceCount;
         private final int deviceCount;
         private final StatusCounts statusCounts;
         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.pointCount = pointCount;
             this.deviceCount = deviceCount;
             this.deviceCount = deviceCount;
             this.statusCounts = statusCounts;
             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 getPointCount() { return pointCount; }
         public int getDeviceCount() { return deviceCount; }
         public int getDeviceCount() { return deviceCount; }
         public StatusCounts getStatusCounts() { return statusCounts; }
         public StatusCounts getStatusCounts() { return statusCounts; }
@@ -223,20 +233,30 @@ public class CockpitMonitoringDistributionResponse {
         public int getCount() { return count; }
         public int getCount() { return count; }
     }
     }
 
 
+    /**
+     * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
+     * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
+     * ({@code NONE} / {@code ASSIGNED} / {@code UNASSIGNED})区分,不靠猜。
+     */
     public static class MapFilter {
     public static class MapFilter {
         private final String industry;
         private final String industry;
         private final String deviceType;
         private final String deviceType;
         private final String pointStatus;
         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> pointCodes;
         private final List<String> deviceCodes;
         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) {
                 List<String> pointCodes, List<String> deviceCodes) {
             this.industry = industry;
             this.industry = industry;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
             this.pointStatus = pointStatus;
             this.pointStatus = pointStatus;
-            this.region = region;
+            this.district = district;
+            this.districtName = district == null ? null : CockpitDistrict.nameOf(district);
+            this.districtFilter = districtFilter;
             this.pointCodes = pointCodes;
             this.pointCodes = pointCodes;
             this.deviceCodes = deviceCodes;
             this.deviceCodes = deviceCodes;
         }
         }
@@ -244,7 +264,12 @@ public class CockpitMonitoringDistributionResponse {
         public String getIndustry() { return industry; }
         public String getIndustry() { return industry; }
         public String getDeviceType() { return deviceType; }
         public String getDeviceType() { return deviceType; }
         public String getPointStatus() { return pointStatus; }
         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> getPointCodes() { return pointCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }
         public List<String> getDeviceCodes() { return deviceCodes; }
     }
     }
@@ -274,7 +299,8 @@ public class CockpitMonitoringDistributionResponse {
     public static class MonitoringPointMarker {
     public static class MonitoringPointMarker {
         private final String pointCode;
         private final String pointCode;
         private final String pointName;
         private final String pointName;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final String industry;
         private final String industry;
         private final DeviceType deviceType;
         private final DeviceType deviceType;
         private final CockpitMonitoringPointStatus pointStatus;
         private final CockpitMonitoringPointStatus pointStatus;
@@ -283,12 +309,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
         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,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
                 BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
                 BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
             this.pointCode = pointCode;
             this.pointCode = pointCode;
             this.pointName = pointName;
             this.pointName = pointName;
-            this.region = region;
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.industry = industry;
             this.industry = industry;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
             this.pointStatus = pointStatus;
             this.pointStatus = pointStatus;
@@ -300,7 +327,8 @@ public class CockpitMonitoringDistributionResponse {
 
 
         public String getPointCode() { return pointCode; }
         public String getPointCode() { return pointCode; }
         public String getPointName() { return pointName; }
         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 String getIndustry() { return industry; }
         public DeviceType getDeviceType() { return deviceType; }
         public DeviceType getDeviceType() { return deviceType; }
         public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
         public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
@@ -313,7 +341,8 @@ public class CockpitMonitoringDistributionResponse {
     public static class UnassociatedEquipmentMarker {
     public static class UnassociatedEquipmentMarker {
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final String industry;
         private final String industry;
         private final DeviceType deviceType;
         private final DeviceType deviceType;
         private final CockpitMonitoringPointStatus pointStatus;
         private final CockpitMonitoringPointStatus pointStatus;
@@ -322,12 +351,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
         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,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
                 BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
                 BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.industry = industry;
             this.industry = industry;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
             this.pointStatus = pointStatus;
             this.pointStatus = pointStatus;
@@ -339,7 +369,8 @@ public class CockpitMonitoringDistributionResponse {
 
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
         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 String getIndustry() { return industry; }
         public DeviceType getDeviceType() { return deviceType; }
         public DeviceType getDeviceType() { return deviceType; }
         public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
         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.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -43,7 +44,8 @@ public class CockpitMonitoringDistributionService {
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final int DEFAULT_PAGE_SIZE = 500;
     private static final int DEFAULT_PAGE_SIZE = 500;
     private static final int MAX_PAGE_SIZE = 1000;
     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 UNKNOWN_TYPE = "UNKNOWN_TYPE";
     private static final String TYPE_KIND_MISMATCH = "TYPE_KIND_MISMATCH";
     private static final String TYPE_KIND_MISMATCH = "TYPE_KIND_MISMATCH";
     private static final String NON_FORMAL_INDUSTRY = "NON_FORMAL_INDUSTRY";
     private static final String NON_FORMAL_INDUSTRY = "NON_FORMAL_INDUSTRY";
@@ -59,17 +61,22 @@ public class CockpitMonitoringDistributionService {
         this.clock = clock;
         this.clock = clock;
     }
     }
 
 
+    /**
+     * 监测分布。范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`):
+     * 不是 {@code 0/1/2/UNASSIGNED} 的入参一律 400 —— 脏值不得参与筛选(决策票 06)。
+     */
     public synchronized CockpitMonitoringDistributionResponse load(
     public synchronized CockpitMonitoringDistributionResponse load(
             String requestId,
             String requestId,
             String industry,
             String industry,
             String deviceType,
             String deviceType,
             String pointStatus,
             String pointStatus,
-            String region,
+            String district,
             Integer page,
             Integer page,
             Integer pageSize,
             Integer pageSize,
             String snapshotAt) {
             String snapshotAt) {
         String selectedIndustry = resolveIndustry(industry);
         String selectedIndustry = resolveIndustry(industry);
         CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
         CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
         validatePagination(safePage, safePageSize, snapshotAt);
@@ -80,7 +87,7 @@ public class CockpitMonitoringDistributionService {
                 selectedIndustry,
                 selectedIndustry,
                 deviceType,
                 deviceType,
                 selectedStatus,
                 selectedStatus,
-                region,
+                selectedDistrict,
                 safePage,
                 safePage,
                 safePageSize);
                 safePageSize);
     }
     }
@@ -90,13 +97,14 @@ public class CockpitMonitoringDistributionService {
             String industry,
             String industry,
             String deviceType,
             String deviceType,
             String pointStatus,
             String pointStatus,
-            String region,
+            String district,
             Integer page,
             Integer page,
             Integer pageSize,
             Integer pageSize,
             String snapshotAt,
             String snapshotAt,
             CockpitMonitoringDistributionDeviceStatusDelta delta) {
             CockpitMonitoringDistributionDeviceStatusDelta delta) {
         String selectedIndustry = resolveIndustry(industry);
         String selectedIndustry = resolveIndustry(industry);
         CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
         CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
+        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
         int safePage = page == null ? 1 : page;
         int safePage = page == null ? 1 : page;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, snapshotAt);
         validatePagination(safePage, safePageSize, snapshotAt);
@@ -113,7 +121,7 @@ public class CockpitMonitoringDistributionService {
                     selectedIndustry,
                     selectedIndustry,
                     deviceType,
                     deviceType,
                     selectedStatus,
                     selectedStatus,
-                    region,
+                    selectedDistrict,
                     safePage,
                     safePage,
                     safePageSize);
                     safePageSize);
         }
         }
@@ -124,7 +132,7 @@ public class CockpitMonitoringDistributionService {
                     selectedIndustry,
                     selectedIndustry,
                     deviceType,
                     deviceType,
                     selectedStatus,
                     selectedStatus,
-                    region,
+                    selectedDistrict,
                     safePage,
                     safePage,
                     safePageSize);
                     safePageSize);
         }
         }
@@ -140,7 +148,7 @@ public class CockpitMonitoringDistributionService {
                 selectedIndustry,
                 selectedIndustry,
                 deviceType,
                 deviceType,
                 selectedStatus,
                 selectedStatus,
-                region,
+                selectedDistrict,
                 safePage,
                 safePage,
                 safePageSize);
                 safePageSize);
     }
     }
@@ -225,7 +233,7 @@ public class CockpitMonitoringDistributionService {
             String selectedIndustry,
             String selectedIndustry,
             String deviceType,
             String deviceType,
             CockpitMonitoringPointStatus selectedStatus,
             CockpitMonitoringPointStatus selectedStatus,
-            String region,
+            CockpitDistrict.Query selectedDistrict,
             int page,
             int page,
             int pageSize) {
             int pageSize) {
         CockpitMonitoringDistributionSnapshot snapshot = state.snapshot;
         CockpitMonitoringDistributionSnapshot snapshot = state.snapshot;
@@ -272,8 +280,9 @@ public class CockpitMonitoringDistributionService {
                     invalidCoordinateCount++;
                     invalidCoordinateCount++;
                     continue;
                     continue;
                 }
                 }
-                String markerRegion = regionOf(point.getRegion());
-                if (!matchesRegion(region, markerRegion)) {
+                // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
+                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
+                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
                     continue;
                     continue;
                 }
                 }
                 List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
                 List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
@@ -293,7 +302,7 @@ public class CockpitMonitoringDistributionService {
                 pointMarkers.add(new Marker(
                 pointMarkers.add(new Marker(
                         point.getPointCode().trim(),
                         point.getPointCode().trim(),
                         point.getPointName(),
                         point.getPointName(),
-                        markerRegion,
+                        markerDistrict,
                         type.getIndustry(),
                         type.getIndustry(),
                         type.getCode(),
                         type.getCode(),
                         type.getName(),
                         type.getName(),
@@ -336,8 +345,9 @@ public class CockpitMonitoringDistributionService {
                     invalidCoordinateCount++;
                     invalidCoordinateCount++;
                     continue;
                     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;
                     continue;
                 }
                 }
                 CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
                 CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
@@ -362,7 +372,7 @@ public class CockpitMonitoringDistributionService {
                 deviceMarkers.add(new Marker(
                 deviceMarkers.add(new Marker(
                         applied.getDeviceCode().trim(),
                         applied.getDeviceCode().trim(),
                         applied.getDeviceName(),
                         applied.getDeviceName(),
-                        markerRegion,
+                        markerDistrict,
                         type.getIndustry(),
                         type.getIndustry(),
                         type.getCode(),
                         type.getCode(),
                         type.getName(),
                         type.getName(),
@@ -380,25 +390,26 @@ public class CockpitMonitoringDistributionService {
         pointMarkers.sort(Comparator.comparing(Marker::getCode, Comparator.nullsLast(String::compareTo)));
         pointMarkers.sort(Comparator.comparing(Marker::getCode, Comparator.nullsLast(String::compareTo)));
         deviceMarkers.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> byIndustry = new LinkedHashMap<>();
         Map<String, Accumulator> byType = new LinkedHashMap<>();
         Map<String, Accumulator> byType = new LinkedHashMap<>();
         int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
         int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
         for (Marker marker : pointMarkers) {
         for (Marker marker : pointMarkers) {
-            accumulate(byRegion, marker.region, marker);
+            accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
             accumulate(byType, marker.typeCode, marker);
             statusCounts[marker.status.ordinal()]++;
             statusCounts[marker.status.ordinal()]++;
         }
         }
         for (Marker marker : deviceMarkers) {
         for (Marker marker : deviceMarkers) {
-            accumulate(byRegion, marker.region, marker);
+            accumulate(byDistrict, marker.district, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byIndustry, marker.industry, marker);
             accumulate(byType, marker.typeCode, marker);
             accumulate(byType, marker.typeCode, marker);
             statusCounts[marker.status.ordinal()]++;
             statusCounts[marker.status.ordinal()]++;
         }
         }
 
 
-        List<CockpitMonitoringDistributionResponse.RegionDistribution> regionRows =
-                regionRows(byRegion);
+        List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
+                districtRows(byDistrict);
         List<CockpitMonitoringDistributionResponse.IndustryDistribution> industryRows =
         List<CockpitMonitoringDistributionResponse.IndustryDistribution> industryRows =
                 industryRows(byIndustry);
                 industryRows(byIndustry);
         List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
         List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
@@ -447,7 +458,7 @@ public class CockpitMonitoringDistributionService {
                         hasResolved ? "READY" : "NO_RESOLVED_DEVICES",
                         hasResolved ? "READY" : "NO_RESOLVED_DEVICES",
                         updatedAt,
                         updatedAt,
                         businessTime()),
                         businessTime()),
-                regionRows,
+                districtRows,
                 industryRows,
                 industryRows,
                 typeRows,
                 typeRows,
                 statusRows,
                 statusRows,
@@ -455,7 +466,10 @@ public class CockpitMonitoringDistributionService {
                         selectedIndustry,
                         selectedIndustry,
                         isBlank(deviceType) ? null : deviceType.trim(),
                         isBlank(deviceType) ? null : deviceType.trim(),
                         selectedStatus == null ? null : selectedStatus.name(),
                         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(pointCodes),
                         sorted(deviceCodes)),
                         sorted(deviceCodes)),
                 new CockpitMonitoringDistributionResponse.Pagination(
                 new CockpitMonitoringDistributionResponse.Pagination(
@@ -488,20 +502,37 @@ public class CockpitMonitoringDistributionService {
                 source.getStatusTime());
                 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) {
     private static void accumulate(Map<String, Accumulator> buckets, String key, Marker marker) {
         Accumulator accumulator = buckets.computeIfAbsent(key, ignore -> new Accumulator());
         Accumulator accumulator = buckets.computeIfAbsent(key, ignore -> new Accumulator());
         accumulator.add(marker);
         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);
             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;
         return rows;
     }
     }
@@ -590,6 +621,19 @@ public class CockpitMonitoringDistributionService {
         return resolved;
         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) {
     private static CockpitMonitoringPointStatus resolvePointStatus(String pointStatus) {
         if (isBlank(pointStatus)) {
         if (isBlank(pointStatus)) {
             return null;
             return null;
@@ -649,14 +693,6 @@ public class CockpitMonitoringDistributionService {
                 OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
                 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) {
     private static List<String> codes(List<CockpitMonitoringPointDeviceSnapshot> devices) {
         List<String> result = new ArrayList<>();
         List<String> result = new ArrayList<>();
         for (CockpitMonitoringPointDeviceSnapshot device : devices) {
         for (CockpitMonitoringPointDeviceSnapshot device : devices) {
@@ -719,7 +755,7 @@ public class CockpitMonitoringDistributionService {
     private static class Marker {
     private static class Marker {
         private final String code;
         private final String code;
         private final String name;
         private final String name;
-        private final String region;
+        private final Integer district;
         private final String industry;
         private final String industry;
         private final String typeCode;
         private final String typeCode;
         private final String typeName;
         private final String typeName;
@@ -731,13 +767,13 @@ public class CockpitMonitoringDistributionService {
         private final boolean point;
         private final boolean point;
         private final List<String> deviceCodes;
         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,
                 CockpitMonitoringPointStatus status, List<String> statusReasons, BigDecimal longitude,
                 BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
                 BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
                 List<String> deviceCodes) {
                 List<String> deviceCodes) {
             this.code = code;
             this.code = code;
             this.name = name;
             this.name = name;
-            this.region = region;
+            this.district = district;
             this.industry = industry;
             this.industry = industry;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.typeName = typeName;
             this.typeName = typeName;
@@ -756,14 +792,14 @@ public class CockpitMonitoringDistributionService {
 
 
         private CockpitMonitoringDistributionResponse.MonitoringPointMarker toMonitoringPointMarker() {
         private CockpitMonitoringDistributionResponse.MonitoringPointMarker toMonitoringPointMarker() {
             return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
             return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
-                    code, name, region, industry,
+                    code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     status, statusReasons, longitude, latitude, coordinateValidation);
                     status, statusReasons, longitude, latitude, coordinateValidation);
         }
         }
 
 
         private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
         private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
             return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
             return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
-                    code, name, region, industry,
+                    code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     status, statusReasons, longitude, latitude, coordinateValidation);
                     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 {
     public static class SourceMonitoringPoint {
         private final String pointCode;
         private final String pointCode;
         private final String pointName;
         private final String pointName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
@@ -109,7 +109,7 @@ public class CockpitMonitoringDistributionSnapshot {
         public SourceMonitoringPoint(
         public SourceMonitoringPoint(
                 String pointCode,
                 String pointCode,
                 String pointName,
                 String pointName,
-                String region,
+                Integer district,
                 String typeCode,
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal longitude,
                 BigDecimal latitude,
                 BigDecimal latitude,
@@ -117,7 +117,7 @@ public class CockpitMonitoringDistributionSnapshot {
                 List<CockpitMonitoringPointDeviceSnapshot> devices) {
                 List<CockpitMonitoringPointDeviceSnapshot> devices) {
             this.pointCode = pointCode;
             this.pointCode = pointCode;
             this.pointName = pointName;
             this.pointName = pointName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
@@ -133,8 +133,9 @@ public class CockpitMonitoringDistributionSnapshot {
             return pointName;
             return pointName;
         }
         }
 
 
-        public String getRegion() {
-            return region;
+        /** 点位无自有片区列,按「经其上挂设备归纳」取得;未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
         }
         }
 
 
         public String getTypeCode() {
         public String getTypeCode() {
@@ -161,7 +162,7 @@ public class CockpitMonitoringDistributionSnapshot {
     public static class SourceSmartDevice {
     public static class SourceSmartDevice {
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
@@ -176,7 +177,7 @@ public class CockpitMonitoringDistributionSnapshot {
         public SourceSmartDevice(
         public SourceSmartDevice(
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
-                String region,
+                Integer district,
                 String typeCode,
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal longitude,
                 BigDecimal latitude,
                 BigDecimal latitude,
@@ -189,7 +190,7 @@ public class CockpitMonitoringDistributionSnapshot {
                 String statusTime) {
                 String statusTime) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
@@ -210,8 +211,9 @@ public class CockpitMonitoringDistributionSnapshot {
             return deviceName;
             return deviceName;
         }
         }
 
 
-        public String getRegion() {
-            return region;
+        /** 设备自身片区(equipment_base.district)即权威来源;未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
         }
         }
 
 
         public String getTypeCode() {
         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;
 package com.zksy.cockpit.monitoring;
 
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.List;
@@ -185,7 +186,8 @@ public class CockpitMonitoringSituationResponse {
         private final String deviceName;
         private final String deviceName;
         private final DeviceType deviceType;
         private final DeviceType deviceType;
         private final String industry;
         private final String industry;
-        private final String region;
+        private final Integer district;
+        private final String districtName;
         private final String abnormalType;
         private final String abnormalType;
         private final String status;
         private final String status;
         private final List<String> statusReasons;
         private final List<String> statusReasons;
@@ -198,7 +200,7 @@ public class CockpitMonitoringSituationResponse {
                 String deviceName,
                 String deviceName,
                 DeviceType deviceType,
                 DeviceType deviceType,
                 String industry,
                 String industry,
-                String region,
+                Integer district,
                 String abnormalType,
                 String abnormalType,
                 String status,
                 String status,
                 List<String> statusReasons,
                 List<String> statusReasons,
@@ -209,7 +211,9 @@ public class CockpitMonitoringSituationResponse {
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
             this.industry = industry;
             this.industry = industry;
-            this.region = region;
+            // 响应值归一:脏值/空值一律收敛为「未分区」,如实呈现,不猜
+            this.district = CockpitDistrict.codeOf(district);
+            this.districtName = CockpitDistrict.nameOf(this.district);
             this.abnormalType = abnormalType;
             this.abnormalType = abnormalType;
             this.status = status;
             this.status = status;
             this.statusReasons = statusReasons;
             this.statusReasons = statusReasons;
@@ -222,7 +226,9 @@ public class CockpitMonitoringSituationResponse {
         public String getDeviceName() { return deviceName; }
         public String getDeviceName() { return deviceName; }
         public DeviceType getDeviceType() { return deviceType; }
         public DeviceType getDeviceType() { return deviceType; }
         public String getIndustry() { return industry; }
         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 getAbnormalType() { return abnormalType; }
         public String getStatus() { return status; }
         public String getStatus() { return status; }
         public List<String> getStatusReasons() { return statusReasons; }
         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.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -221,7 +222,8 @@ public class CockpitMonitoringSituationService {
                             device.getDeviceCode().trim(),
                             device.getDeviceCode().trim(),
                             device.getDeviceName(),
                             device.getDeviceName(),
                             type,
                             type,
-                            regionOf(device.getRegion()),
+                            // 设备自身片区(equipment_base.district)即权威来源;未分区为 null
+                            CockpitDistrict.codeOf(device.getDistrict()),
                             status,
                             status,
                             device.getStatusReasons(),
                             device.getStatusReasons(),
                             device.getLongitude(),
                             device.getLongitude(),
@@ -473,10 +475,6 @@ public class CockpitMonitoringSituationService {
         return index < 0 ? Integer.MAX_VALUE : index;
         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) {
     private static List<String> sorted(Set<String> values) {
         List<String> result = new ArrayList<>(values);
         List<String> result = new ArrayList<>(values);
         Collections.sort(result, Comparator.nullsLast(String::compareTo));
         Collections.sort(result, Comparator.nullsLast(String::compareTo));
@@ -538,7 +536,7 @@ public class CockpitMonitoringSituationService {
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
         private final CockpitMonitoringSituationSnapshot.SourceType type;
         private final CockpitMonitoringSituationSnapshot.SourceType type;
-        private final String region;
+        private final Integer district;
         private final String status;
         private final String status;
         private final List<String> statusReasons;
         private final List<String> statusReasons;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
@@ -549,7 +547,7 @@ public class CockpitMonitoringSituationService {
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
                 CockpitMonitoringSituationSnapshot.SourceType type,
                 CockpitMonitoringSituationSnapshot.SourceType type,
-                String region,
+                Integer district,
                 String status,
                 String status,
                 List<String> statusReasons,
                 List<String> statusReasons,
                 BigDecimal longitude,
                 BigDecimal longitude,
@@ -558,7 +556,7 @@ public class CockpitMonitoringSituationService {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.type = type;
             this.type = type;
-            this.region = region;
+            this.district = district;
             this.status = status;
             this.status = status;
             this.statusReasons = statusReasons;
             this.statusReasons = statusReasons;
             this.longitude = longitude;
             this.longitude = longitude;
@@ -577,7 +575,7 @@ public class CockpitMonitoringSituationService {
                     new CockpitMonitoringSituationResponse.DeviceType(
                     new CockpitMonitoringSituationResponse.DeviceType(
                             type.getCode(), type.getName()),
                             type.getCode(), type.getName()),
                     type.getIndustry(),
                     type.getIndustry(),
-                    region,
+                    district,
                     status,
                     status,
                     status,
                     status,
                     statusReasons == null ? Collections.emptyList() : statusReasons,
                     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 {
     public static class SourceMetricDevice {
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
-        private final String region;
+        private final Integer district;
         private final String typeCode;
         private final String typeCode;
         private final String metricStatus;
         private final String metricStatus;
         private final List<String> statusReasons;
         private final List<String> statusReasons;
@@ -105,7 +105,7 @@ public class CockpitMonitoringSituationSnapshot {
         public SourceMetricDevice(
         public SourceMetricDevice(
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
-                String region,
+                Integer district,
                 String typeCode,
                 String typeCode,
                 String metricStatus,
                 String metricStatus,
                 List<String> statusReasons,
                 List<String> statusReasons,
@@ -115,7 +115,7 @@ public class CockpitMonitoringSituationSnapshot {
                 boolean coordinateVerified) {
                 boolean coordinateVerified) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
-            this.region = region;
+            this.district = district;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.metricStatus = metricStatus;
             this.metricStatus = metricStatus;
             this.statusReasons = statusReasons;
             this.statusReasons = statusReasons;
@@ -133,8 +133,9 @@ public class CockpitMonitoringSituationSnapshot {
             return deviceName;
             return deviceName;
         }
         }
 
 
-        public String getRegion() {
-            return region;
+        /** 设备自身片区(equipment_base.district)即权威来源;未分区为 {@code null}。 */
+        public Integer getDistrict() {
+            return district;
         }
         }
 
 
         public String getTypeCode() {
         public String getTypeCode() {

+ 45 - 6
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.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.Date;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Objects;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
@@ -60,12 +62,43 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                 .eq(WorkOrder::getDeviceId, equipment.getEquipmentId())
                 .eq(WorkOrder::getDeviceId, equipment.getEquipmentId())
                 .orderByDesc(WorkOrder::getCreateTime)
                 .orderByDesc(WorkOrder::getCreateTime)
                 .list();
                 .list();
+        // 设备维度:返回该设备**已有全部工单**。逐条解析该工单实际关联的报警
+        // (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()
         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", alarm.getId(), true);
+                })
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
         return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, summaries);
         return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, summaries);
     }
     }
 
 
+    /** 批量解析「工单 → 关联报警」:一次查询覆盖本页全部工单,避免逐条打库。 */
+    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
     @Override
     public CockpitWorkOrderReadSnapshot loadByEvent(String eventSource, String eventCode) {
     public CockpitWorkOrderReadSnapshot loadByEvent(String eventSource, String eventCode) {
         if ("ALARM".equals(eventSource)) {
         if ("ALARM".equals(eventSource)) {
@@ -75,8 +108,11 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                     .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
                     .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
                     .orderByDesc(WorkOrder::getCreateTime)
                     .orderByDesc(WorkOrder::getCreateTime)
                     .list();
                     .list();
+            // 报警维度:工单经 alarm_data.work_order_id 反查,命中即「实际关联本报警」。
             return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode,
             return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode,
-                    orders.stream().map(order -> toSummary(order, eventSource, eventCode)).collect(Collectors.toList()));
+                    orders.stream()
+                            .map(order -> toSummary(order, eventSource, eventCode, true))
+                            .collect(Collectors.toList()));
         }
         }
         if ("WARNING".equals(eventSource)) {
         if ("WARNING".equals(eventSource)) {
             EarlyWarning warning = earlyWarningService.lambdaQuery()
             EarlyWarning warning = earlyWarningService.lambdaQuery()
@@ -97,7 +133,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                     ? Collections.emptyList()
                     ? Collections.emptyList()
                     : findOrdersByAlarmIds(alarmIds);
                     : findOrdersByAlarmIds(alarmIds);
             List<CockpitWorkOrderSummary> summaries = orders.stream()
             List<CockpitWorkOrderSummary> summaries = orders.stream()
-                    .map(order -> toSummary(order, eventSource, eventCode))
+                    .map(order -> toSummary(order, eventSource, eventCode, true))
                     .collect(Collectors.toList());
                     .collect(Collectors.toList());
             return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode, summaries);
             return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode, summaries);
         }
         }
@@ -116,10 +152,12 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         List<CockpitWorkOrderTimelineEntry> entries = logs.stream()
         List<CockpitWorkOrderTimelineEntry> entries = logs.stream()
                 .map(log -> toTimelineEntry(order, log))
                 .map(log -> toTimelineEntry(order, log))
                 .collect(Collectors.toList());
                 .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());
         CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byCode(order.getOrderStatus());
         return new CockpitWorkOrderSummary(
         return new CockpitWorkOrderSummary(
                 order.getOrderNo(),
                 order.getOrderNo(),
@@ -133,7 +171,8 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                 format(order.getPlanFinishTime()),
                 format(order.getPlanFinishTime()),
                 format(order.getRealFinishTime()),
                 format(order.getRealFinishTime()),
                 eventSource == null ? null : new CockpitWorkOrderEventAssociation(
                 eventSource == null ? null : new CockpitWorkOrderEventAssociation(
-                        eventSource, eventCode, format(order.getCreateTime())));
+                        eventSource, eventCode, format(order.getCreateTime())),
+                associatedWithCurrentEvent);
     }
     }
 
 
     private String resolveDeviceCode(String deviceId) {
     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 String stateReason;
     private final List<CockpitWorkOrderState> stateDictionary;
     private final List<CockpitWorkOrderState> stateDictionary;
     private final List<CockpitWorkOrderSummary> workOrders;
     private final List<CockpitWorkOrderSummary> workOrders;
+    /** 本结果中未与当前报警/预警关联的工单数(设备维度下通常大于 0,须显式提示)。 */
+    private final int unassociatedWorkOrderCount;
 
 
     public CockpitWorkOrderReadResponse(
     public CockpitWorkOrderReadResponse(
             String requestId,
             String requestId,
@@ -35,6 +37,10 @@ public class CockpitWorkOrderReadResponse {
         this.stateReason = stateReason;
         this.stateReason = stateReason;
         this.stateDictionary = stateDictionary;
         this.stateDictionary = stateDictionary;
         this.workOrders = workOrders;
         this.workOrders = workOrders;
+        this.unassociatedWorkOrderCount = workOrders == null ? 0
+                : (int) workOrders.stream()
+                        .filter(order -> order != null && !order.isAssociatedWithCurrentEvent())
+                        .count();
     }
     }
 
 
     public String getRequestId() { return requestId; }
     public String getRequestId() { return requestId; }
@@ -47,4 +53,13 @@ public class CockpitWorkOrderReadResponse {
     public String getStateReason() { return stateReason; }
     public String getStateReason() { return stateReason; }
     public List<CockpitWorkOrderState> getStateDictionary() { return stateDictionary; }
     public List<CockpitWorkOrderState> getStateDictionary() { return stateDictionary; }
     public List<CockpitWorkOrderSummary> getWorkOrders() { return workOrders; }
     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 planFinishTime;
     private final String realFinishTime;
     private final String realFinishTime;
     private final CockpitWorkOrderEventAssociation eventAssociation;
     private final CockpitWorkOrderEventAssociation eventAssociation;
+    /** 该工单是否与本查询的报警/预警**实际关联**(决策票 12:未关联须显式提示,不靠 null 猜)。 */
+    private final boolean associatedWithCurrentEvent;
 
 
     public CockpitWorkOrderSummary(
     public CockpitWorkOrderSummary(
             String workOrderCode,
             String workOrderCode,
@@ -24,7 +26,8 @@ public class CockpitWorkOrderSummary {
             String updateTime,
             String updateTime,
             String planFinishTime,
             String planFinishTime,
             String realFinishTime,
             String realFinishTime,
-            CockpitWorkOrderEventAssociation eventAssociation) {
+            CockpitWorkOrderEventAssociation eventAssociation,
+            boolean associatedWithCurrentEvent) {
         this.workOrderCode = workOrderCode;
         this.workOrderCode = workOrderCode;
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
         this.orderLevel = orderLevel;
         this.orderLevel = orderLevel;
@@ -36,6 +39,7 @@ public class CockpitWorkOrderSummary {
         this.planFinishTime = planFinishTime;
         this.planFinishTime = planFinishTime;
         this.realFinishTime = realFinishTime;
         this.realFinishTime = realFinishTime;
         this.eventAssociation = eventAssociation;
         this.eventAssociation = eventAssociation;
+        this.associatedWithCurrentEvent = associatedWithCurrentEvent;
     }
     }
 
 
     public String getWorkOrderCode() { return workOrderCode; }
     public String getWorkOrderCode() { return workOrderCode; }
@@ -49,4 +53,13 @@ public class CockpitWorkOrderSummary {
     public String getPlanFinishTime() { return planFinishTime; }
     public String getPlanFinishTime() { return planFinishTime; }
     public String getRealFinishTime() { return realFinishTime; }
     public String getRealFinishTime() { return realFinishTime; }
     public CockpitWorkOrderEventAssociation getEventAssociation() { return eventAssociation; }
     public CockpitWorkOrderEventAssociation getEventAssociation() { return eventAssociation; }
-}
+
+    /**
+     * 是否与本次查询的对象(报警/预警)实际关联。
+     *
+     * <p>设备维度的查询(`/equipments/{code}/work-orders`)返回该设备**已有全部工单**,
+     * 其中未与当前报警关联的工单必须能被前端识别 —— 故为每张工单给出显式布尔值,
+     * 而不是让调用方从 {@code eventAssociation == null} 反推(执行票 12 范围 1)。
+     */
+    public boolean isAssociatedWithCurrentEvent() { return associatedWithCurrentEvent; }
+}