Selaa lähdekoodia

feat(cockpit): BT-5 真实数据接线收尾 - 四行业与工单字典 0-7 对齐

- 文档同步:alarm_data/case_info/hazard_hidden_account/work_order_log 关联列
  order_id -> work_order_id(随 HEAD 告警重构改名),更新 CONTEXT.md、
  CONTEXT-MAP.md、docs/cockpit/README.md、契约 spec、历史交付说明
- 正式行业扩为 GAS/WATER_SUPPLY/DRAINAGE/MANHOLE:地图快照、监测分布、
  当前事件、历史分析、处置效率、多维分析、基础设施四端点、总体概览监测、
  设备边界全部接受 MANHOLE,错误消息统一为四行业
- 对外工单状态字典对齐运行时 WorkOrderStatusEnum 0-7(新增延期中),
  byLegacyCode 做旧编码 1-7 -> 新 0-7 迁移映射;工单摘要改按运行时
  状态直接解析(修复运行时 0 待派单被误映射为已取消)
- 处置效率/历史分析/预警设置的已结案状态码 6 -> 5,延期中计入在途
- 全部相关 controller/service 契约测试同步更新(316 项全绿)
Kazerin 3 viikkoa sitten
vanhempi
commit
903d44d1c1
77 muutettua tiedostoa jossa 3402 lisäystä ja 363 poistoa
  1. 28 8
      .scratch/cockpit-backend-read-contracts/spec.md
  2. 1 1
      CONTEXT-MAP.md
  3. 1 1
      docs/alarm-review-2026-09-23.md
  4. 61 11
      docs/cockpit/README.md
  5. 19 12
      docs/cockpit/current-warnings-demo.html
  6. 19 11
      docs/cockpit/map-snapshot-demo.html
  7. 4 4
      docs/cockpit/websocket-scope-delta-demo.html
  8. 29 2
      pipe-network-service/CONTEXT.md
  9. 1 1
      pipe-network-service/docs/2026-09-14-water-supply-home-alarm-work-order-e2e.md
  10. 9 3
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  11. 10 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsController.java
  12. 48 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusRedisListener.java
  13. 155 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  14. 193 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMapSnapshotAdapterTest.java
  15. 171 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java
  16. 200 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringOverviewAdapterTest.java
  17. 213 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringSituationAdapterTest.java
  18. 7 5
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitContractDictionaryTest.java
  19. 75 25
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCurrentEventsControllerTest.java
  20. 2 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitDeviceBoundaryControllerTest.java
  21. 29 26
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHandlingEfficiencyControllerTest.java
  22. 5 5
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisControllerTest.java
  23. 7 4
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureBaseArchivesControllerTest.java
  24. 12 10
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureEmergencySupportControllerTest.java
  25. 12 11
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureManagementInformationControllerTest.java
  26. 7 6
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitInfrastructureOperatingInformationControllerTest.java
  27. 146 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  28. 5 5
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  29. 29 22
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java
  30. 27 17
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java
  31. 1 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMultidimensionalAnalysisControllerTest.java
  32. 2 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewMonitoringControllerTest.java
  33. 2 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewMonitoringServiceTest.java
  34. 95 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsControllerTest.java
  35. 22 18
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsServiceTest.java
  36. 13 11
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitWorkOrderReadControllerTest.java
  37. 65 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/websocket/DeviceStatusCockpitWebSocketHandlerTest.java
  38. 19 12
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java
  39. 12 11
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/MonitoringServiceImpl.java
  40. 7 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java
  41. 5 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryService.java
  42. 21 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsResponse.java
  43. 36 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsService.java
  44. 58 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitCurrentEventsSnapshot.java
  45. 6 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHandlingEfficiencyService.java
  46. 4 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java
  47. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitMultidimensionalAnalysisService.java
  48. 65 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitTypeIndustry.java
  49. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureBaseArchivesService.java
  50. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureEmergencySupportService.java
  51. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureManagementInformationService.java
  52. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationService.java
  53. 168 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  54. 49 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  55. 32 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  56. 170 15
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  57. 18 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java
  58. 15 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  59. 175 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  60. 175 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java
  61. 233 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringSituationAdapter.java
  62. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionDeviceStatusDelta.java
  63. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  64. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewDeviceStatusDelta.java
  65. 4 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java
  66. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationDeviceStatusDelta.java
  67. 4 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationService.java
  68. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewMonitoringDeviceStatusDelta.java
  69. 5 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewMonitoringService.java
  70. 141 21
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsService.java
  71. 132 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java
  72. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  73. 21 9
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderStateDictionary.java
  74. 16 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/domain/AlarmDataSchemaTest.java
  75. 3 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewMapperTest.java
  76. 48 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/service/impl/EquipmentBaseServiceImplTest.java
  77. 1 1
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/mapper/WorkOrderAlarmConflictMapperTest.java

+ 28 - 8
.scratch/cockpit-backend-read-contracts/spec.md

@@ -2,6 +2,8 @@
 
 
 Status: ready-for-agent
 Status: ready-for-agent
 
 
+> 2026-09-24 需求裁定完成,后端 BT-5 已从 `needs-triage` 转回 `ready-for-agent`,但当前工作树仍未编译通过、接线验收进度为 0%。本文件的当前实现基线优先于历史目标条目。
+
 ## Problem Statement
 ## Problem Statement
 
 
 城市生命线驾驶舱系统面向指挥中心领导和值班人员,是一个只读展示大屏。UE 需要在进入程序后加载“一张图”,以监测点为首要地图对象,查看关联智能设备的基础档案、当前监测数据、预警、报警和工单处置状态。现有后端主要提供管理型接口,字段、分页和写入能力与驾驶舱展示边界不一致;预警接口仍处于向统一工单模型迁移的阶段,设备状态 WebSocket 也使用旧的驼峰设备字段。
 城市生命线驾驶舱系统面向指挥中心领导和值班人员,是一个只读展示大屏。UE 需要在进入程序后加载“一张图”,以监测点为首要地图对象,查看关联智能设备的基础档案、当前监测数据、预警、报警和工单处置状态。现有后端主要提供管理型接口,字段、分页和写入能力与驾驶舱展示边界不一致;预警接口仍处于向统一工单模型迁移的阶段,设备状态 WebSocket 也使用旧的驼峰设备字段。
@@ -10,12 +12,14 @@ Status: ready-for-agent
 
 
 ## Solution
 ## Solution
 
 
-新增驾驶舱只读聚合服务和 DTO,统一挂载于 `/api/cockpit/v1/`。服务端负责从现有监测点、智能设备、设备类型、预警、报警、`alarm_warning_rel`、工单和指标数据中组合稳定响应;UE 不直接访问管理接口或数据库。
+新增驾驶舱只读聚合服务和 DTO,统一挂载于 `/api/cockpit/v1/`。服务端负责从现有监测点、智能设备、设备类型、预警、报警、`alarm_warning_rel`、工单和指标数据中组合稳定响应;UE 不直接访问管理接口或数据库。当前数据接线必须遵循 2026-09-24 之后的领域模型:设备类型由 `equipment_type.id/type_name/parent_id` 表示,设备由 `equipment_base.equipment_type_id` 关联;工单由 `work_order.device_id` 关联设备,报警转工单由 `alarm_data.work_order_id` 关联工单,不能再依赖已删除的 `work_order.device_code` 或旧的直接报警字段。
 
 
 地图摘要使用一个共享 `snapshotAt` 的分页快照,分别返回监测点摘要和未关联智能设备摘要。后端返回已解析的 `pointStatus`、状态原因、统计和坐标校验结果。监测点优先渲染;已关联设备不重复生成独立点位;未关联且坐标有效的设备才作为直接设备点位;无效坐标对象保留在列表和详情中但禁止地图渲染。
 地图摘要使用一个共享 `snapshotAt` 的分页快照,分别返回监测点摘要和未关联智能设备摘要。后端返回已解析的 `pointStatus`、状态原因、统计和坐标校验结果。监测点优先渲染;已关联设备不重复生成独立点位;未关联且坐标有效的设备才作为直接设备点位;无效坐标对象保留在列表和详情中但禁止地图渲染。
 
 
 设备详情采用基础档案、当前指标、预警、报警、工单、工单时间线和图片等独立只读分区。REST 首次提供权威快照,WebSocket 仅推送状态增量。现有 `/ws/deviceStatus` 增加 `cockpitSubscribe` 范围订阅,规范字段使用 `deviceCode`,迁移期可额外返回旧 `device_code`;UE 以 `serverUpdatedAt` 判断新旧消息,断线后自动重连和恢复订阅。
 设备详情采用基础档案、当前指标、预警、报警、工单、工单时间线和图片等独立只读分区。REST 首次提供权威快照,WebSocket 仅推送状态增量。现有 `/ws/deviceStatus` 增加 `cockpitSubscribe` 范围订阅,规范字段使用 `deviceCode`,迁移期可额外返回旧 `device_code`;UE 以 `serverUpdatedAt` 判断新旧消息,断线后自动重连和恢复订阅。
 
 
+**Current-source precedence (2026-09-24):** The user stories and decisions below have been brought up to date for the current type IDs, 0–7 work-order states, and four formal industries. The earlier nine-state, old `type_id`/`parent_type_id`, and three-industry/manhole-exclusion proposals are historical only. Current runtime facts and implementation gaps are recorded in “Current Implementation Baseline” below; do not infer integration readiness from the target contract.
+
 ## User Stories
 ## User Stories
 
 
 1. As a 指挥中心领导, I want the cockpit to load the map frame before data completes, so that the display never appears frozen during startup.
 1. As a 指挥中心领导, I want the cockpit to load the map frame before data completes, so that the display never appears frozen during startup.
@@ -30,7 +34,7 @@ Status: ready-for-agent
 10. As a 值班人员, I want type-specific current metrics, including sewage environmental metrics when supplied, so that the display reflects the equipment schema.
 10. As a 值班人员, I want type-specific current metrics, including sewage environmental metrics when supplied, so that the display reflects the equipment schema.
 11. As a 值班人员, I want warning and alarm events in separate sections, so that their distinct event families remain clear.
 11. As a 值班人员, I want warning and alarm events in separate sections, so that their distinct event families remain clear.
 12. As a 值班人员, I want current and historical event results with explicit empty and unavailable states, so that missing data is not mistaken for no events.
 12. As a 值班人员, I want current and historical event results with explicit empty and unavailable states, so that missing data is not mistaken for no events.
-13. As a 值班人员, I want every related work order and its complete nine-state timeline, so that handling progress is visible without merging records.
+13. As a 值班人员, I want every related work order and its complete current 0–7 runtime-state timeline, so that handling progress is visible without merging records.
 14. As a 系统集成负责人, I want event and work-order relationships resolved by the backend, so that the cockpit does not infer relationships from names, coordinates or timestamps.
 14. As a 系统集成负责人, I want event and work-order relationships resolved by the backend, so that the cockpit does not infer relationships from names, coordinates or timestamps.
 15. As a 驾驶舱维护者, I want independent detail sections, so that one failed query does not erase successful sibling sections.
 15. As a 驾驶舱维护者, I want independent detail sections, so that one failed query does not erase successful sibling sections.
 16. As a 值班人员, I want a short-lived cached detail with an explicit stale marker, so that I retain context without being misled about freshness.
 16. As a 值班人员, I want a short-lived cached detail with an explicit stale marker, so that I retain context without being misled about freshness.
@@ -43,8 +47,8 @@ Status: ready-for-agent
 23. As a 后端协作者, I want dedicated JSON fields to use camelCase, so that the contract is consistent with the existing Java stack while keeping database names internal.
 23. As a 后端协作者, I want dedicated JSON fields to use camelCase, so that the contract is consistent with the existing Java stack while keeping database names internal.
 24. As a 后端协作者, I want `deviceCode` to be canonical and `device_code` to be a migration alias, so that old WebSocket clients remain compatible.
 24. As a 后端协作者, I want `deviceCode` to be canonical and `device_code` to be a migration alias, so that old WebSocket clients remain compatible.
 25. As a 统计使用者, I want facility counts and intelligent-equipment counts separated, so that water plants and pump stations are not mixed with sensor equipment.
 25. As a 统计使用者, I want facility counts and intelligent-equipment counts separated, so that water plants and pump stations are not mixed with sensor equipment.
-26. As a 业务负责人, I want the formal scope limited to gas, supply water and drainage, so that unknown types are not silently counted in a formal industry total.
-27. As a 业务负责人, I want sensor-equipped manholes represented as intelligent equipment, so that manholes are not modeled as a separate industry or facility entity.
+26. As a 业务负责人, I want the formal cockpit industry scope to include gas, supply water, drainage and manhole, so that a known manhole type is not silently dropped or folded into drainage and unknown types are not silently counted.
+27. As a 业务负责人, I want sensor-equipped manholes represented as intelligent equipment under the formal MANHOLE industry, so that the device and facility boundaries remain distinct.
 28. As a 统计使用者, I want overview, infrastructure, monitoring and warning-disposal metrics to use one timezone and freshness vocabulary, so that dashboards are comparable.
 28. As a 统计使用者, I want overview, infrastructure, monitoring and warning-disposal metrics to use one timezone and freshness vocabulary, so that dashboards are comparable.
 29. As a 后端协作者, I want contract handoff material before warning migration replacement, so that the adapter changes from evidence rather than guessed fields.
 29. As a 后端协作者, I want contract handoff material before warning migration replacement, so that the adapter changes from evidence rather than guessed fields.
 30. As a 测试人员, I want the aggregate behavior observable through one public read model seam, so that contract tests verify user-visible behavior without coupling to database joins.
 30. As a 测试人员, I want the aggregate behavior observable through one public read model seam, so that contract tests verify user-visible behavior without coupling to database joins.
@@ -57,10 +61,11 @@ Status: ready-for-agent
 - Map summaries use `page`, `pageSize` and `snapshotAt`; default page size is 500 and maximum is 1000. The response includes total, batch update time and completion metadata.
 - Map summaries use `page`, `pageSize` and `snapshotAt`; default page size is 500 and maximum is 1000. The response includes total, batch update time and completion metadata.
 - Return monitoring points and unassociated equipment as separate collections. A valid published map coordinate is WGS84; missing, zero, out-of-range or unvalidated coordinates fail the render gate and include a reason.
 - Return monitoring points and unassociated equipment as separate collections. A valid published map coordinate is WGS84; missing, zero, out-of-range or unvalidated coordinates fail the render gate and include a reason.
 - Return `pointStatus` and a status reason from the backend. Fixed codes are `ALARM`, `WARNING`, `WORK_ORDER`, `NORMAL`, `OFFLINE` and `EMPTY`, each with a display label. The backend owns precedence.
 - Return `pointStatus` and a status reason from the backend. Fixed codes are `ALARM`, `WARNING`, `WORK_ORDER`, `NORMAL`, `OFFLINE` and `EMPTY`, each with a display label. The backend owns precedence.
-- Keep facility and intelligent-equipment statistics separate. Facilities include water plants, pump stations and pipelines; sensor-equipped manholes belong to intelligent equipment and are not an industry.
+- Keep facility and intelligent-equipment statistics separate. Facilities include water plants, pump stations and pipelines; sensor-equipped manholes belong to intelligent equipment under the MANHOLE industry, not to a facility count.
 - Use the database `equipment_base.equipment_code` as the preferred shared association key. Dedicated cockpit JSON exposes it as `deviceCode`; legacy data may retain `device_code` or other aliases inside the adapter boundary.
 - Use the database `equipment_base.equipment_code` as the preferred shared association key. Dedicated cockpit JSON exposes it as `deviceCode`; legacy data may retain `device_code` or other aliases inside the adapter boundary.
 - Keep warning and alarm event families distinct. Reuse backend authority for `alarm_warning_rel`, event cardinality and work-order uniqueness; the cockpit never creates or deduplicates work orders.
 - Keep warning and alarm event families distinct. Reuse backend authority for `alarm_warning_rel`, event cardinality and work-order uniqueness; the cockpit never creates or deduplicates work orders.
-- Preserve all nine work-order states and each work order's independent timeline. Do not merge timelines across events.
+- Resolve work-order relationships through current foreign-key paths: device lookup uses `work_order.device_id → equipment_base.equipment_id`; alarm lookup uses `alarm_data.work_order_id → work_order.order_id`; warning lookup uses `alarm_warning_rel.warning_id/alarm_id` followed by `alarm_data.work_order_id`. The cockpit must expose `deviceCode` only after resolving the device record.
+- Preserve the current runtime work-order states from `WorkOrderStatusEnum`: `0` pending dispatch, `1` pending acceptance, `2` accepted, `3` processing, `4` pending verification, `5` closed, `6` delayed, `7` cancelled. Terminal states are 5 and 7; delayed is a processing side path. The older ADR-0008 target encoding must not be used as a runtime database assumption.
 - Use `Asia/Shanghai` for business date and statistic boundaries. Emit ISO-8601 timestamps with timezone information and return selected range, timezone and update time.
 - Use `Asia/Shanghai` for business date and statistic boundaries. Emit ISO-8601 timestamps with timezone information and return selected range, timezone and update time.
 - Extend `/ws/deviceStatus` with `cockpitSubscribe` and `scope=all`, while retaining existing device-code subscription compatibility. REST provides initial snapshots; WebSocket sends changed-device deltas only.
 - Extend `/ws/deviceStatus` with `cockpitSubscribe` and `scope=all`, while retaining existing device-code subscription compatibility. REST provides initial snapshots; WebSocket sends changed-device deltas only.
 - WebSocket payloads use canonical `deviceCode`, may include legacy `device_code`, and include current status, `statusTime` and `serverUpdatedAt`. No sequence field is added; newest `serverUpdatedAt` wins.
 - WebSocket payloads use canonical `deviceCode`, may include legacy `device_code`, and include current status, `statusTime` and `serverUpdatedAt`. No sequence field is added; newest `serverUpdatedAt` wins.
@@ -73,13 +78,28 @@ Status: ready-for-agent
 
 
 - Use one highest-level public seam: a fake read-only cockpit adapter feeds the aggregate presentation contract with deterministic map pages, details and WebSocket updates; assert only public DTO behavior, freshness, selection identity and update results.
 - Use one highest-level public seam: a fake read-only cockpit adapter feeds the aggregate presentation contract with deterministic map pages, details and WebSocket updates; assert only public DTO behavior, freshness, selection identity and update results.
 - Add controller/service contract tests for response envelopes, camelCase fields, status dictionaries, HTTP error categories, pagination snapshot consistency and facility/equipment count separation.
 - Add controller/service contract tests for response envelopes, camelCase fields, status dictionaries, HTTP error categories, pagination snapshot consistency and facility/equipment count separation.
-- Add integration tests against representative database fixtures for monitoring-point association, unassociated equipment, invalid coordinates, event-family separation, `alarm_warning_rel` relationships and nine work-order states.
+- Add integration tests against representative database fixtures for monitoring-point association, unassociated equipment, invalid coordinates, event-family separation, `alarm_warning_rel` relationships and the current 0–7 work-order states (including delayed as a non-terminal state).
 - Add WebSocket tests for canonical and legacy device-code fields, delta-only pushes, `serverUpdatedAt` ordering, missing point status fallback, scope subscription, reconnect subscription and heartbeat behavior.
 - Add WebSocket tests for canonical and legacy device-code fields, delta-only pushes, `serverUpdatedAt` ordering, missing point status fallback, scope subscription, reconnect subscription and heartbeat behavior.
 - Add read-only safety tests using an adapter/request spy; all map loading, detail loading, refresh, retry and subscription operations must produce no business mutation request.
 - Add read-only safety tests using an adapter/request spy; all map loading, detail loading, refresh, retry and subscription operations must produce no business mutation request.
 - Add freshness tests for successful empty results, one missed cycle, two missed cycles, stale retention and independent section failure.
 - Add freshness tests for successful empty results, one missed cycle, two missed cycles, stale retention and independent section failure.
 - Add statistics tests for timezone boundaries, selected ranges, formal industry filtering, unknown-type exclusion and separate facility/equipment totals.
 - Add statistics tests for timezone boundaries, selected ranges, formal industry filtering, unknown-type exclusion and separate facility/equipment totals.
 - Reuse existing Spring controller/service test conventions and add dedicated contract fixtures rather than testing private SQL helpers or requiring a particular join implementation.
 - Reuse existing Spring controller/service test conventions and add dedicated contract fixtures rather than testing private SQL helpers or requiring a particular join implementation.
 
 
+## Current Implementation Baseline (2026-09-24)
+
+This section records the current repository and database facts discovered during the requirements resynchronization. It is not an acceptance claim and does not authorize implementation changes.
+
+- The repository `HEAD` has moved through alarm and work-order structural refactors after the original cockpit contract work. The current worktree also contains uncommitted cockpit adapter changes from the previous connection attempt.
+- `equipment_type` currently exposes `id`, `type_name`, and `parent_id`; the former `type_id`/`parent_type_id` vocabulary is obsolete for the current schema. Human ruling: the cockpit public `typeCode` uses `equipment_type.id` (serialized as a string at the JSON boundary); adapters must not call removed accessors or reuse old slug codes. Type names remain display labels.
+- `work_order` currently contains `order_id`, `order_no`, `device_id`, status and lifecycle fields, but no `device_code`, `alarm_id`, `case_id`, or `hazard_id`. `alarm_data.work_order_id` is the current alarm-to-order link. The shared device-fact resolver now queries `device_id` and treats statuses 0–4 and 6 as open. The public work-order dictionary is still the older 1–7 model and is not yet aligned.
+- Current KingbaseES `app_user` observation is a sparse snapshot of the real-device database: `work_order=1`, `alarm_data=3`, `early_warning=35`, `alarm_warning_rel=3`, `equipment_base=2`, `equipment_status=2`, `monitor_point=7`, `equipment_point_rel=20`, `case_info=2`, `hazard_hidden_account=12`. Treat these rows as real data; the observed counts are time-specific evidence, not production-size guarantees or permission to synthesize more business rows.
+- Current data quality is not the prior 2026-09-18 snapshot: only one alarm row is linked to a work order; only six of 35 warnings resolve to an equipment/type record; the current equipment table has two rows while point relations reference many historical/test IDs. Point coordinates include null and outlier values.
+- Human ruling: add `MANHOLE` as the fourth formal cockpit industry, aligned with the independent manhole type root and frontend filter. `monitoring/overview` and `monitoring/situation` now accept it and resolve industry by walking the type tree to its root. Map, distribution, event, and infrastructure validators still reject it; those endpoints are not done.
+- Human ruling: use the current real KingbaseES data as the integration baseline, including its sparse relationships; do not synthesize business rows to make counts non-empty. Verify schema and legitimate empty responses, and record which scenarios current data cannot exercise.
+- Coordinate choice under the minimal-change constraint: retain the existing `CockpitCoordinateValidator` gate. With no trusted verification source in the current schema, `coordinateVerified=false` and `renderable=false`; the generic WGS84 range check alone would wrongly pass outliers such as `(12,12)`. A future regional boundary validator requires a separately approved boundary/CRS source and is not part of this round.
+- Human clarification: the authoritative work-order "dictionary" means the code enum `WorkOrderStatusEnum`, not a separate database table. Runtime states are 0–7 per that enum and ADR-0010; a read-only check found no work-order-status entry in `app_user.sys_dict_type`/`sys_dict_data`. There is no additional database-dictionary parity prerequisite.
+- The previous “4/22 adapters connected” record is historical progress, not current acceptance. As of 2026-09-24 the worktree compiles (`mvn -pl zksy-admin -am compile -q`) and the overview/situation targeted tests pass (34 tests, 0 failures). BT-5 remains open until the remaining slices and the authenticated current-database checklist pass.
+
 ## Out of Scope
 ## Out of Scope
 
 
 - No UE implementation, UMG changes, Actor implementation, Three.js migration or Pixel Streaming work.
 - No UE implementation, UMG changes, Actor implementation, Three.js migration or Pixel Streaming work.
@@ -95,5 +115,5 @@ Status: ready-for-agent
 - The backend working tree contains pre-existing uncommitted changes. This spec does not authorize reverting or mixing those changes into the cockpit implementation.
 - The backend working tree contains pre-existing uncommitted changes. This spec does not authorize reverting or mixing those changes into the cockpit implementation.
 - The warning lifecycle is expected to migrate to the same work-order model as alarms. The dedicated adapter must remain stable while the backend absorbs that migration.
 - The warning lifecycle is expected to migrate to the same work-order model as alarms. The dedicated adapter must remain stable while the backend absorbs that migration.
 - Before replacing transitional warning mappings, backend handoff must include endpoint paths, request parameters, response examples, field and status dictionaries, relationship/cardinality rules, pagination and freshness semantics, and WebSocket samples.
 - Before replacing transitional warning mappings, backend handoff must include endpoint paths, request parameters, response examples, field and status dictionaries, relationship/cardinality rules, pagination and freshness semantics, and WebSocket samples.
-- The current repository has `/MonitorPoint`, `/EquipmentBase`, `/api/alarm` and `/ws/deviceStatus` management or transitional surfaces, but no completed `/api/cockpit/v1` contract. This spec defines the target seam without claiming those endpoints already exist.
+- Historical note: when this spec was first drafted, `/api/cockpit/v1` was not yet implemented. The current repository now contains cockpit controllers and DTOs, but the real-data adapters in the worktree are not compile-clean under the current domain model; endpoint presence is not evidence of current integration readiness.
 - Apply the `ready-for-agent` triage label when publishing this spec to Gogs Issues.
 - Apply the `ready-for-agent` triage label when publishing this spec to Gogs Issues.

+ 1 - 1
CONTEXT-MAP.md

@@ -11,5 +11,5 @@
 ## Relationships
 ## Relationships
 
 
 - **供水设施与监测设备**:供水设施描述水源地、水厂、泵站、管线等承载供水能力的业务对象;带传感器的监测设备负责采集运行数据,两者不是同一概念。井盖在带传感器并接入监测系统时属于智能设备,不属于设施实体。
 - **供水设施与监测设备**:供水设施描述水源地、水厂、泵站、管线等承载供水能力的业务对象;带传感器的监测设备负责采集运行数据,两者不是同一概念。井盖在带传感器并接入监测系统时属于智能设备,不属于设施实体。
-- **报警记录、设备报警状态与工单处置**:设备自动报警时生成报警记录并进入报警状态;人工可派发故障维修工单,但设备报警状态由运行系统根据有效报警原因自动维护。
+- **报警记录、设备报警状态与工单处置**:设备自动报警时生成报警记录并进入报警状态;人工可派发故障维修工单,但设备报警状态由运行系统根据有效报警原因自动维护。当前数据库关系以 `alarm_data.work_order_id → work_order.order_id` 识别报警转工单,以 `work_order.device_id → equipment_base.equipment_id` 解析设备,不再依赖工单表上的设备编码或报警外键。
 - **供水/排水共用泵站表**:泵站物理表 `app_user.pump_station` 由供水与排水共用,以 `station_type`(water_supply/drainage)区分归属;两侧接口各自强制过滤本系统类型,不建立第二张平行泵站表(详见 pipe-network-service ADR-0007)。
 - **供水/排水共用泵站表**:泵站物理表 `app_user.pump_station` 由供水与排水共用,以 `station_type`(water_supply/drainage)区分归属;两侧接口各自强制过滤本系统类型,不建立第二张平行泵站表(详见 pipe-network-service ADR-0007)。

+ 1 - 1
docs/alarm-review-2026-09-23.md

@@ -7,7 +7,7 @@
 - `audit_status`:0 未审核、1 确认报警、2 误报警。`alarm_level` 继续使用 1~4(一级最严重,红/橙/蓝/绿),不新增或更名为 `case_level`。
 - `audit_status`:0 未审核、1 确认报警、2 误报警。`alarm_level` 继续使用 1~4(一级最严重,红/橙/蓝/绿),不新增或更名为 `case_level`。
 - 确认报警原子更新审核状态、记录审核日志并创建工单;任一步失败整个审核事务回滚。不修改设备状态,也不将报警标为已处理。误报警仅记录结论及意见。
 - 确认报警原子更新审核状态、记录审核日志并创建工单;任一步失败整个审核事务回滚。不修改设备状态,也不将报警标为已处理。误报警仅记录结论及意见。
 - 审核转工单时,`work_order.order_desc` 根据原报警信息生成故障描述,审核意见单独记录到 `alarm_log.oper_desc`,不再覆盖故障描述。实际值为空的报警,页面及自动生成的工单描述均不显示实际值/阈值占位;数值 0 仍视为有效值。
 - 审核转工单时,`work_order.order_desc` 根据原报警信息生成故障描述,审核意见单独记录到 `alarm_log.oper_desc`,不再覆盖故障描述。实际值为空的报警,页面及自动生成的工单描述均不显示实际值/阈值占位;数值 0 仍视为有效值。
-- 报警生成不再提前创建工单;保留井盖必要建案,确认建单时回填已有案件的 `order_id`。普通手动建单、隐患转工单不再限制同设备的工单数量;仅报警转工单按设备主键 + `alarm_data.warning_code` 查重,通过 `alarm_data.order_id` 关联工单,未办结状态使用 `WorkOrderStatusEnum.activeCodes()`(0/1/2/3/4/6),5 已结案和 7 已取消不占名额。不同报警编码可以分别建单,不按显示名称合并;报警已恢复也不释放尚未办结的工单名额。缺失报警编码时明确报错,不能绕过查重。冲突会回滚本次审核。
+- 报警生成不再提前创建工单;保留井盖必要建案,确认建单时回填已有案件的 `work_order_id`。普通手动建单、隐患转工单不再限制同设备的工单数量;仅报警转工单按设备主键 + `alarm_data.warning_code` 查重,通过 `alarm_data.work_order_id` 关联工单,未办结状态使用 `WorkOrderStatusEnum.activeCodes()`(0/1/2/3/4/6),5 已结案和 7 已取消不占名额。不同报警编码可以分别建单,不按显示名称合并;报警已恢复也不释放尚未办结的工单名额。缺失报警编码时明确报错,不能绕过查重。冲突会回滚本次审核。
 - 报警建单在同一事务中先锁定设备档案行,再重新读取报警关联、查询同类未办结工单、创建并关联新工单,避免同设备多条报警并发审核重复建单。锁查询禁用缓存并刷新会话缓存,设备状态不作修改;同一报警重复调用继续返回原关联工单。本项为代码调整,不需要新增数据库结构或迁移历史数据(2026-09-24)。
 - 报警建单在同一事务中先锁定设备档案行,再重新读取报警关联、查询同类未办结工单、创建并关联新工单,避免同设备多条报警并发审核重复建单。锁查询禁用缓存并刷新会话缓存,设备状态不作修改;同一报警重复调用继续返回原关联工单。本项为代码调整,不需要新增数据库结构或迁移历史数据(2026-09-24)。
 - 审核意见选填、最多 500 字,两种审核结果均允许不填;审核结果仍必选。正常关闭审核弹窗不再提示“放弃本次审核”,上传或提交中仍禁止关闭。只允许 JPG/JPEG/PNG 图片,最多 5 张、单张 10 MB,前后端均拒绝 PDF。处置记录显示图片缩略图,点击可查看原图并切换同条记录的多张图片。图片复用管理端框架上传目录,票据在 Redis 保留两小时,绑定当前用户和报警。
 - 审核意见选填、最多 500 字,两种审核结果均允许不填;审核结果仍必选。正常关闭审核弹窗不再提示“放弃本次审核”,上传或提交中仍禁止关闭。只允许 JPG/JPEG/PNG 图片,最多 5 张、单张 10 MB,前后端均拒绝 PDF。处置记录显示图片缩略图,点击可查看原图并切换同条记录的多张图片。图片复用管理端框架上传目录,票据在 Redis 保留两小时,绑定当前用户和报警。
 
 

+ 61 - 11
docs/cockpit/README.md

@@ -1,13 +1,13 @@
 # 城市生命线驾驶舱后端契约:移交与联调手册
 # 城市生命线驾驶舱后端契约:移交与联调手册
 
 
-本手册面向 UE / 前端对接团队,冻结 `/api/cockpit/v1/` 的字段、字典、分页、错误与实时增量口径,并给出联调检查表。对应的可执行基线是 `CockpitContractIntegrationTest` 与 `CockpitContractDictionaryTest`。
+本手册面向 UE / 前端对接团队,记录 `/api/cockpit/v1/` 的目标契约、字典、分页、错误与实时增量口径,并给出联调检查表。**当前代码与数据库已于 2026-09-24 发生结构变化,旧冻结字典不能直接当成当前运行时验收结果;第 8 节的“当前重建基线”优先于历史描述。**对应的契约测试仍为 `CockpitContractIntegrationTest` 与 `CockpitContractDictionaryTest`,但目前工作树编译未通过,不能宣称这些测试已在新模型下通过。
 
 
 ## 1. 契约总则
 ## 1. 契约总则
 
 
 - 所有接口均为**只读**:仅 `GET` + `@PreAuthorize("isAuthenticated()")`,不存在任何处置、派发、确认类写入口。
 - 所有接口均为**只读**:仅 `GET` + `@PreAuthorize("isAuthenticated()")`,不存在任何处置、派发、确认类写入口。
 - 统一响应信封:`{ "code": 200, "msg": "操作成功", "data": {...} }`;错误为 `{ "code": <httpStatus>, "msg": "<message>", "data": { category, message, requestId, serverTime } }`。
 - 统一响应信封:`{ "code": 200, "msg": "操作成功", "data": {...} }`;错误为 `{ "code": <httpStatus>, "msg": "<message>", "data": { category, message, requestId, serverTime } }`。
 - JSON 字段一律 `camelCase`。数据库列名属适配器内部细节,不出现在契约中。
 - JSON 字段一律 `camelCase`。数据库列名属适配器内部细节,不出现在契约中。
-- 设备唯一关联键:`deviceCode`(对应 `equipment_base.equipment_code`)。WebSocket 迁移期同时下发 `device_code` 作为兼容别名。
+- 设备唯一关联键:对外使用 `deviceCode`(对应 `equipment_base.equipment_code`)。设备主键内部使用 `equipment_base.equipment_id`;`work_order` 当前通过 `device_id` 关联设备,不能读取已删除的 `work_order.device_code`。WebSocket 迁移期同时下发 `device_code` 作为兼容别名。
 - 时区:业务时区固定 `Asia/Shanghai`,所有时间戳为带偏移量的 ISO-8601(如 `2026-09-02T10:00:00+08:00`)。
 - 时区:业务时区固定 `Asia/Shanghai`,所有时间戳为带偏移量的 ISO-8601(如 `2026-09-02T10:00:00+08:00`)。
 - 可选请求头 `X-Request-Id`;未提供时服务端生成,响应与错误体中都会回传。
 - 可选请求头 `X-Request-Id`;未提供时服务端生成,响应与错误体中都会回传。
 
 
@@ -18,7 +18,7 @@
 | 等级颜色 | `1 RED` / `2 YELLOW` / `3 BLUE` / `4 GREEN` / `UNKNOWN` | 1红2黄3蓝4绿 |
 | 等级颜色 | `1 RED` / `2 YELLOW` / `3 BLUE` / `4 GREEN` / `UNKNOWN` | 1红2黄3蓝4绿 |
 | 监测点状态 | `ALARM` `WARNING` `WORK_ORDER` `NORMAL` `OFFLINE` `EMPTY` | 优先级即此顺序,后端负责裁决 |
 | 监测点状态 | `ALARM` `WARNING` `WORK_ORDER` `NORMAL` `OFFLINE` `EMPTY` | 优先级即此顺序,后端负责裁决 |
 | 指标状态 | `NORMAL` `WARNING` `ALARM` `EXPIRED` `OFFLINE` `UNKNOWN` | 运行监测专用,`EXPIRED` 不与 `OFFLINE` 合并 |
 | 指标状态 | `NORMAL` `WARNING` `ALARM` `EXPIRED` `OFFLINE` `UNKNOWN` | 运行监测专用,`EXPIRED` 不与 `OFFLINE` 合并 |
-| 工单状态 | 1待派单 2待接单 3已接单 4处理中 5待验收 6已结案 7已取消 | 权威七态;历史"九态"已废弃,不得回填 |
+| 工单状态 | **当前运行时**:0待派单 1待接单 2已接单 3处理中 4待验收 5已结案 6延期中 7已取消 | 以当前 `WorkOrderStatusEnum`/`work_order.order_status` 为准;终态为 5、7,延期中为处理旁路。旧 ADR-0008 的“从 1 开始七态”只保留为历史决策,不得当作当前数据库编码 |
 | 新鲜度 | `CURRENT` `STALE` `EXPIRED` `EMPTY` `FAILED` | 每次响应携带 `freshness` |
 | 新鲜度 | `CURRENT` `STALE` `EXPIRED` `EMPTY` `FAILED` | 每次响应携带 `freshness` |
 | 错误类别 | `UNAUTHORIZED` `SERVER_ERROR` `INVALID_REQUEST` `SNAPSHOT_EXPIRED` `NOT_FOUND` | 401/500/400/400/404 |
 | 错误类别 | `UNAUTHORIZED` `SERVER_ERROR` `INVALID_REQUEST` `SNAPSHOT_EXPIRED` `NOT_FOUND` | 401/500/400/400/404 |
 
 
@@ -32,7 +32,7 @@
 | --- | --- | --- |
 | --- | --- | --- |
 | 总体概览 | `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) |
+| 总体概览 | `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` `region` |
 | 基础设施 | `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` `organizationCode` |
@@ -49,7 +49,7 @@
 
 
 | 用途 | 端点 |
 | 用途 | 端点 |
 | --- | --- |
 | --- | --- |
-| 地图分页快照 | `GET /api/cockpit/v1/map-snapshot?page&pageSize&snapshotAt` |
+| 地图分页快照(**地图取数路径**) | `GET /api/cockpit/v1/map-snapshot?page&pageSize&snapshotAt&industry&region&deviceType` |
 | 监测点详情 | `GET /api/cockpit/v1/monitoring-points/{monitoringPointCode}` |
 | 监测点详情 | `GET /api/cockpit/v1/monitoring-points/{monitoringPointCode}` |
 | 设备档案(含最新图片) | `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` |
@@ -60,6 +60,16 @@
 | 新鲜度 | `GET /api/cockpit/v1/equipments/{deviceCode}/freshness` |
 | 新鲜度 | `GET /api/cockpit/v1/equipments/{deviceCode}/freshness` |
 | 契约自检 | `GET /api/cockpit/v1/foundation`、`GET /api/cockpit/v1/device-boundary` |
 | 契约自检 | `GET /api/cockpit/v1/foundation`、`GET /api/cockpit/v1/device-boundary` |
 
 
+### 地图筛选路径(冻结结论)
+
+**全局行业/范围筛选落到地图,以 `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` 已按此接受 `MANHOLE`;地图、分布、事件和基础设施服务仍校验旧三行业,不能把第四行业说成全接口可用。旧 `type_id/parent_type_id` 不是当前 schema 字段。
+- 服务端完成过滤,并在响应 `mapFilter{industry,region,deviceType,pointCodes,deviceCodes}` 回显**实际生效范围**(编码为过滤后全量、分页前,升序)。
+- `coordinateValidation` 汇总是**当前筛选范围内的全量口径**(再切片分页);逐点/逐设备保留 `renderable=false` 的对象。
+- 逐点 `industry` 由类型推导(口径同 distribution:类型可解析、`objectKind = SMART_DEVICE`、行业属正式行业);不可推导时为 `null`,**默认不排除对象**,仅在请求了 `industry`/`deviceType` 过滤时按推导结果筛选。`region` 空值在响应中归一为 `未分区`。
+
 在线试用每个切片的样例响应:打开 `docs/cockpit/` 下的同名 `*-demo.html`(离线自包含,无需后端)。
 在线试用每个切片的样例响应:打开 `docs/cockpit/` 下的同名 `*-demo.html`(离线自包含,无需后端)。
 
 
 ## 4. 分页、快照与错误口径
 ## 4. 分页、快照与错误口径
@@ -68,7 +78,12 @@
 - `map-snapshot` 的 `page > 1` 必须携带同一 `snapshotAt`,否则 `SNAPSHOT_EXPIRED`;同一 `snapshotAt` 下分页结果稳定,监测点优先、已关联设备不重复出点。
 - `map-snapshot` 的 `page > 1` 必须携带同一 `snapshotAt`,否则 `SNAPSHOT_EXPIRED`;同一 `snapshotAt` 下分页结果稳定,监测点优先、已关联设备不重复出点。
 - 无效或未验证坐标的对象保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
 - 无效或未验证坐标的对象保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
 - 成功但无数据返回 200(例如 `freshness.state = EMPTY`),不得用 5xx 表达空;仅上游读取失败才 `SERVER_ERROR`。
 - 成功但无数据返回 200(例如 `freshness.state = EMPTY`),不得用 5xx 表达空;仅上游读取失败才 `SERVER_ERROR`。
-- 未知类型、非智能设备、非正式行业一律显式排除,不做静默归类。
+- 时间范围口径:预设 `range` 取值 `LAST_24_HOURS` / `LAST_7_DAYS` / `LAST_30_DAYS`(`infrastructure/operating-information` 与 `overview/warning-settings` 同款);显式区间用 `startTime`/`endTime`(ISO-8601 带偏移,必须成对出现且 `startTime ≤ endTime`,回显 `range.type = CUSTOM`)。`overview/warning-settings` 不声明窗口时维持 `CURRENT_BASELINE`(`start`/`end` 为 `null`,趋势桶不筛选)。
+- `overview/warning-settings` 的趋势桶是**业务时区日期**(`yyyy-MM-dd`),声明窗口时按「桶日期落在窗口起止日期之间(含端点)」筛选,**桶顺序沿用适配器顺序**;桶标签无法解析时保留该桶,不静默丢数据。
+- 未知类型、非智能设备、非正式行业在**按行业/类型筛选时**显式排除,不做静默归类;`map-snapshot` 不带筛选时仍返回全部对象(`industry`/`typeName` 为 `null`),保证「全城底图」不因类型数据缺失而丢对象。
+- 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标缺失、越界或未验证时 `renderable = false` 且给出 `reason`。
+- 适配器未给出 `coordinateVerified` 时按**未验证**处理(`state=UNVERIFIED`、`reason=UNVERIFIED_COORDINATE`、`renderable=false`)——字典要求「未验证禁止渲染」,服务端不臆造验证状态。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
+- `events/history-analysis` 的 `rows[]` 是**小时桶聚合行**,不存在逐事件行,因此不携带 `coordinateValidation` 与阈值字段(逐事件坐标只在 `events/current` 提供)。
 
 
 ## 5. WebSocket 实时增量
 ## 5. WebSocket 实时增量
 
 
@@ -79,9 +94,11 @@
 → {"type":"cockpitSubscribe","scope":"all"}
 → {"type":"cockpitSubscribe","scope":"all"}
 ← {"type":"cockpitSubscribed","scope":"all","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}
 ← {"type":"cockpitSubscribed","scope":"all","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}
 
 
-// 增量推送(迁移期同时下发 device_code 别名)
+// 增量推送(迁移期同时下发 device_code 别名;monitoringPointCode 关联监测点,
+// 未关联设备为 null 且不静默丢失)
 ← {"type":"cockpitDeviceStatusDelta","deviceCode":"TC-PS-WS-202609020012",
 ← {"type":"cockpitDeviceStatusDelta","deviceCode":"TC-PS-WS-202609020012",
    "device_code":"TC-PS-WS-202609020012",
    "device_code":"TC-PS-WS-202609020012",
+   "monitoringPointCode":"MP-001",
    "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00",
    "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00",
            "serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
            "serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
 
 
@@ -92,6 +109,8 @@
 
 
 客户端以 `serverUpdatedAt` 判新旧(新者胜),REST 提供首帧权威快照,WebSocket 只推变化设备;断线后重新订阅即可。设备状态校准周期默认 60 秒,可配置延长。
 客户端以 `serverUpdatedAt` 判新旧(新者胜),REST 提供首帧权威快照,WebSocket 只推变化设备;断线后重新订阅即可。设备状态校准周期默认 60 秒,可配置延长。
 
 
+增量帧字段口径:`monitoringPointCode` 为设备关联监测点的编码(`equipment_base.equipment_code` → `equipment_point_rel` → `monitor_point.point_code`);未关联设备该字段显式 `null`,不静默丢失。`data.reason` 无异常时显式 `null`。
+
 ## 6. 启动与联调
 ## 6. 启动与联调
 
 
 后端启动(Java 11+,Maven 3.9):
 后端启动(Java 11+,Maven 3.9):
@@ -124,14 +143,45 @@ mvn -pl zksy-admin -am test -Dtest='Cockpit*Test'
 
 
 本仓库**当前没有** Dockerfile 或容器编排文件,容器化属于后续工作,此处只记录评估结论:
 本仓库**当前没有** Dockerfile 或容器编排文件,容器化属于后续工作,此处只记录评估结论:
 
 
-- 运行面:标准 JVM 应用(Java 11 字节码)+ MySQL/Redis/(可选)Nacos,无平台相关本地依赖,迁移到 aarch64/loongarch64 信创环境不需要改代码,只需对应架构的 JDK 与数据库驱动。
+- 运行面:标准 JVM 应用(Java 11 字节码)+ **当前 KingbaseES** / Redis / Nacos;旧文档中的 MySQL 是历史评估假设,不是当前真实数据源。迁移到 aarch64/loongarch64 信创环境仍需对应架构的 JDK、KingbaseES 驱动与实机验证,不应仅凭代码推断无需修改。
 - 银河麒麟:主要风险在中文字体、时区与 TLS 库,而非业务代码;`Asia/Shanghai` 已由 `ry.sh` 与各切片 `Clock` 统一约束。
 - 银河麒麟:主要风险在中文字体、时区与 TLS 库,而非业务代码;`Asia/Shanghai` 已由 `ry.sh` 与各切片 `Clock` 统一约束。
 - Docker:可行。建议以 `eclipse-temurin:11-jre`(或信创基础镜像)为基座,外置 `application-prod.yml` 与密钥,数据库走独立容器或既有实例。若目标环境禁止容器,直接以 systemd + `ry.sh` 部署同样可行。
 - Docker:可行。建议以 `eclipse-temurin:11-jre`(或信创基础镜像)为基座,外置 `application-prod.yml` 与密钥,数据库走独立容器或既有实例。若目标环境禁止容器,直接以 systemd + `ry.sh` 部署同样可行。
 - 内存基线:`ry.sh` 默认 `-Xms512m -Xmx1024m`,如需在驾驶舱工作站与渲染任务共存,建议按实际并发上调堆上限。
 - 内存基线:`ry.sh` 默认 `-Xms512m -Xmx1024m`,如需在驾驶舱工作站与渲染任务共存,建议按实际并发上调堆上限。
 - 性能测量:本轮未做生产规模压测,属未验证项;建议在取得真实设备规模后,用同一 `snapshotAt` 对 `map-snapshot` 与 `monitoring/distribution` 做分页吞吐与前端渲染测量,再决定是否加投影或索引。
 - 性能测量:本轮未做生产规模压测,属未验证项;建议在取得真实设备规模后,用同一 `snapshotAt` 对 `map-snapshot` 与 `monitoring/distribution` 做分页吞吐与前端渲染测量,再决定是否加投影或索引。
 
 
-## 8. 已知边界与未验证项
+## 8. 当前重建基线(2026-09-24)
+
+本节记录需求重同步时读取到的当前代码与 KingbaseES 事实。它覆盖并取代 2026-09-18 以前的真实库数量与旧字段假设;不表示当前 cockpit 适配器已经可运行。
+
+### 当前数据库模型
+
+- `equipment_type` 当前列为 `id`、`type_name`、`parent_id`;旧 `type_id`、`parent_type_id` 不存在。已裁定对外 `typeCode = String(equipment_type.id)`;行业沿类型树上溯到根,正式行业为 `GAS`、`WATER_SUPPLY`、`DRAINAGE`、`MANHOLE`。`monitoring/overview` 与 `monitoring/situation` 已使用该映射;其余切片尚未改完。
+- `equipment_base.equipment_type_id` 指向 `equipment_type.id`;`equipment_type_name` 是查询补充字段,不是持久化列。
+- `work_order` 当前以 `device_id` 关联 `equipment_base.equipment_id`,不再有 `device_code`、`alarm_id`、`case_id`、`hazard_id` 列;报警转工单使用 `alarm_data.work_order_id → work_order.order_id`,案例和隐患分别通过各自的 `work_order_id` 关系读取。
+- `alarm_data` 持久化 `device_code`、`work_order_id`、`warning_code`、`alarm_level`、`alarm_status`、`create_time`、`audit_status` 等;设备名称、设备类型、坐标、工单补充字段属于查询投影,不是表列。
+- 工单运行时状态为 `0..7`:0待派单、1待接单、2已接单、3处理中、4待验收、5已结案、6延期中、7已取消;终态为 5/7,6 为处理旁路。
+
+### 当前真实库只读观测
+
+当前 KingbaseES `app_user` 观测值:`work_order=1`、`alarm_data=3`、`early_warning=35`、`alarm_warning_rel=3`、`equipment_base=2`、`equipment_status=2`、`monitor_point=7`、`equipment_point_rel=20`、`case_info=2`、`hazard_hidden_account=12`。
+
+- 当前两台设备中,一台为离线报警,一台在线正常;当前样例不能代表生产规模。
+- 20 条点位关系中 10 条逻辑有效,但当前两台设备均无法匹配到有效关系行,旧的“设备回联 3 条/37 台设备”数字已失效。
+- 3 条报警中 1 条通过 `alarm_data.work_order_id` 关联当前唯一工单;35 条预警中仅 6 条能经设备台账和类型层级解析类型。
+- 点位仍存在空坐标与 `(12,12)` 等异常坐标;坐标校核字段仍未出现在当前表模型中。按最小修改与现有校验器复用原则,缺可信校核来源一律 `coordinateVerified=false`、`renderable=false`;单纯经纬度范围校验会放过 `(12,12)`,不得用它代替业务校核。新增区域边界校核需另行确认边界与坐标系来源。
+
+### 当前代码可验收性
+
+本工作树**已重新编译通过**,但仍不是 #63 的验收基线:
+
+- 旧编译阻断(`WorkOrder::getDeviceCode`、`EquipmentType.getTypeId/getParentTypeId`)在当前工作树中已不存在;`mvn -pl zksy-admin -am compile -q` 于 2026-09-24 通过。
+- `monitoring/overview` 与 `monitoring/situation` 已把 `MANHOLE` 纳入正式行业,并沿类型树上溯到根。定向测试 34 例 0 失败:两个控制器测试、两个真实适配器测试。
+- 地图、分布、事件、基础设施等服务仍拒绝 `MANHOLE`。工单对外字典仍是旧 1–7 模型。第 6 节检查表的真实登录态联调没有执行。
+- 历史“4/22 片已接真实库”只代表旧 schema 下的尝试,不能当作当前验收。
+- 人工澄清:“工单字典”指代码枚举 `WorkOrderStatusEnum`,不是独立数据库表;其当前取值为 0–7,与 ADR-0010 一致。只读查询 `app_user.sys_dict_type`/`sys_dict_data` 未发现工单状态字典项,无需再以数据库字典表为联调前置条件。
+
+## 9. 已知边界与未验证项
 
 
-- 预警生命周期仍处于向统一工单模型迁移阶段;当前实现以目录七态为权威,适配器对旧编码做兼容收敛。
-- 数据库级联调、生产规模性能、银河麒麟与 Docker 的实机验证均为**未执行项**,不在本切片结论内。
+- 当前工单运行时状态以 ADR-0010 的 0–7 八值枚举为权威;旧 cockpit 七态目录仍存在于响应模型/测试中,需先确认映射方案后才可宣称真实工单状态已对齐。
+- 本轮已对 KingbaseES 当前 schema 与数据做**只读探查**,但**未启动应用并完成第 6 节检查表 1–7 的真实登录态端到端联调**;生产规模性能、银河麒麟与 Docker 的实机验证仍为未执行项。
 - 本仓库的工作区可能包含与本契约无关的在途改动,联调前请确认部署来源是其预期提交。
 - 本仓库的工作区可能包含与本契约无关的在途改动,联调前请确认部署来源是其预期提交。

+ 19 - 12
docs/cockpit/current-warnings-demo.html

@@ -81,7 +81,7 @@
     </div>
     </div>
 
 
     <h2>当前事件列表</h2>
     <h2>当前事件列表</h2>
-    <table><thead><tr><th>事件</th><th>等级</th><th>数值</th><th>设备</th><th>工单</th></tr></thead>
+    <table><thead><tr><th>事件</th><th>等级</th><th>数值</th><th>设备</th><th>工单</th><th>上图</th></tr></thead>
       <tbody id="eventRows"></tbody></table>
       <tbody id="eventRows"></tbody></table>
     <div class="empty" id="empty" style="display:none">当前筛选下没有事件(空列表状态)。</div>
     <div class="empty" id="empty" style="display:none">当前筛选下没有事件(空列表状态)。</div>
     <div class="panel" id="detail"><h3>详情</h3><p style="color:var(--muted)">点击事件行或地图标记查看设备与工单状态。</p></div>
     <div class="panel" id="detail"><h3>详情</h3><p style="color:var(--muted)">点击事件行或地图标记查看设备与工单状态。</p></div>
@@ -111,25 +111,25 @@
 
 
   const warnings = [
   const warnings = [
     { no: "W-001", name: "燃气浓度预警", level: 1, status: "ACTIVE", value: "12.5", unit: "mg/m³", time: "09:50",
     { no: "W-001", name: "燃气浓度预警", level: 1, status: "ACTIVE", value: "12.5", unit: "mg/m³", time: "09:50",
-      device: "D-GAS-1", deviceName: "燃气浓度计-001", location: "城区A-燃气1号点", type: "GAS_SENSOR", lon: 110.130, lat: 28.440,
+      device: "D-GAS-1", deviceName: "燃气浓度计-001", pointCode: "MP-A-001", pointName: "城区A-燃气1号点", location: "城区A-燃气1号点", type: "GAS_SENSOR", lon: 110.130, lat: 28.440, validation: { state: "VALID", reason: null, renderable: true, crs: "WGS84" }, 
       wo: { code: "WO-1", state: 4, stateName: "处理中", desc: "燃气浓度处置" } },
       wo: { code: "WO-1", state: 4, stateName: "处理中", desc: "燃气浓度处置" } },
     { no: "W-002", name: "燃气压力预警", level: 2, status: "ACTIVE", value: "0.32", unit: "MPa", time: "09:45",
     { no: "W-002", name: "燃气压力预警", level: 2, status: "ACTIVE", value: "0.32", unit: "MPa", time: "09:45",
-      device: "D-GAS-2", deviceName: "燃气浓度计-002", location: "城区A-燃气2号点", type: "GAS_SENSOR", lon: 110.131, lat: 28.441, wo: null },
+      device: "D-GAS-2", deviceName: "燃气浓度计-002", pointCode: "MP-A-002", pointName: "城区A-燃气2号点", location: "城区A-燃气2号点", type: "GAS_SENSOR", lon: 110.131, lat: 28.441, validation: { state: "VALID", reason: null, renderable: true, crs: "WGS84" },  wo: null },
     { no: "W-003", name: "供水压力预警", level: 3, status: "PENDING", value: "0.18", unit: "MPa", time: "09:40",
     { no: "W-003", name: "供水压力预警", level: 3, status: "PENDING", value: "0.18", unit: "MPa", time: "09:40",
-      device: "D-WS-1", deviceName: "供水压力计-001", location: "城区B-供水1号点", type: "WATER_SUPPLY_SENSOR", lon: 110.150, lat: 28.460,
+      device: "D-WS-1", deviceName: "供水压力计-001", pointCode: "MP-B-001", pointName: "城区B-供水1号点", location: "城区B-供水1号点", type: "WATER_SUPPLY_SENSOR", lon: 110.150, lat: 28.460, validation: { state: "VALID", reason: null, renderable: true, crs: "WGS84" }, 
       wo: { code: "WO-2", state: 1, stateName: "待派单", desc: "供水压力处置" } },
       wo: { code: "WO-2", state: 1, stateName: "待派单", desc: "供水压力处置" } },
     { no: "W-004", name: "污水浓度预警", level: 4, status: "RELEASED", value: "18.2", unit: "mg/L", time: "09:35",
     { no: "W-004", name: "污水浓度预警", level: 4, status: "RELEASED", value: "18.2", unit: "mg/L", time: "09:35",
-      device: "D-DR-1", deviceName: "污水环境监测仪-001", location: "城区C-污水1号点", type: "SEWAGE_ENV", lon: 110.170, lat: 28.480, wo: null },
+      device: "D-DR-1", deviceName: "污水环境监测仪-001", pointCode: "MP-C-001", pointName: "城区C-污水1号点", location: "城区C-污水1号点", type: "SEWAGE_ENV", lon: 110.170, lat: 28.480, validation: { state: "UNVERIFIED", reason: "UNVERIFIED_COORDINATE", renderable: false, crs: "WGS84" },  wo: null },
     { no: "W-005", name: "供水流量预警", level: 9, status: "ACTIVE", value: "66.6", unit: "L/s", time: "09:30",
     { no: "W-005", name: "供水流量预警", level: 9, status: "ACTIVE", value: "66.6", unit: "L/s", time: "09:30",
-      device: "D-WS-2", deviceName: "供水压力计-002", location: "城区B-供水2号点", type: "WATER_SUPPLY_SENSOR", lon: 110.151, lat: 28.461,
+      device: "D-WS-2", deviceName: "供水压力计-002", pointCode: "MP-B-002", pointName: "城区B-供水2号点", location: "城区B-供水2号点", type: "WATER_SUPPLY_SENSOR", lon: 110.151, lat: 28.461, validation: { state: "VALID", reason: null, renderable: true, crs: "WGS84" }, 
       wo: { code: "WO-3", state: 6, stateName: "已结案", desc: "供水流量处置" } }
       wo: { code: "WO-3", state: 6, stateName: "已结案", desc: "供水流量处置" } }
   ];
   ];
   const alarms = [
   const alarms = [
     { no: "A-001", name: "燃气浓度报警", level: 1, status: "HANDLED", value: "22.6", unit: "mg/m³", time: "09:55",
     { no: "A-001", name: "燃气浓度报警", level: 1, status: "HANDLED", value: "22.6", unit: "mg/m³", time: "09:55",
-      device: "D-GAS-3", deviceName: "燃气浓度计-003", location: "城区A-燃气3号点", type: "GAS_SENSOR", lon: 110.132, lat: 28.442,
+      device: "D-GAS-3", deviceName: "燃气浓度计-003", pointCode: "MP-A-003", pointName: "城区A-燃气3号点", location: "城区A-燃气3号点", type: "GAS_SENSOR", lon: 110.132, lat: 28.442, validation: { state: "VALID", reason: null, renderable: true, crs: "WGS84" }, thresholdMin: 0, thresholdMax: 20, 
       wo: { code: "WO-4", state: 7, stateName: "已取消", desc: "燃气浓度处置" } },
       wo: { code: "WO-4", state: 7, stateName: "已取消", desc: "燃气浓度处置" } },
     { no: "A-002", name: "污水浓度报警", level: 2, status: "PROCESSING", value: "42.1", unit: "mg/L", time: "09:32",
     { no: "A-002", name: "污水浓度报警", level: 2, status: "PROCESSING", value: "42.1", unit: "mg/L", time: "09:32",
-      device: "D-DR-1", deviceName: "污水环境监测仪-001", location: "城区C-污水1号点", type: "SEWAGE_ENV", lon: 110.170, lat: 28.480, wo: null }
+      device: "D-DR-1", deviceName: "污水环境监测仪-001", pointCode: "MP-C-001", pointName: "城区C-污水1号点", location: "城区C-污水1号点", type: "SEWAGE_ENV", lon: 110.170, lat: 28.480, validation: { state: "UNVERIFIED", reason: "UNVERIFIED_COORDINATE", renderable: false, crs: "WGS84" },  wo: null }
   ];
   ];
 
 
   let mode = "WARNING";
   let mode = "WARNING";
@@ -158,16 +158,18 @@
         <td>${e.value} ${e.unit}</td>
         <td>${e.value} ${e.unit}</td>
         <td>${e.device}</td>
         <td>${e.device}</td>
         <td>${woFailed ? "--" : e.wo ? `<span class="badge">${WO_STATES[e.wo.state]}</span>` : "无工单"}</td>
         <td>${woFailed ? "--" : e.wo ? `<span class="badge">${WO_STATES[e.wo.state]}</span>` : "无工单"}</td>
+        <td>${e.validation.renderable ? "可上图" : "不可上图"}</td>
       </tr>`).join("");
       </tr>`).join("");
 
 
     const map = document.getElementById("map");
     const map = document.getElementById("map");
     map.innerHTML = "";
     map.innerHTML = "";
-    if (filtered.length) {
-      const lons = filtered.map(e => e.lon), lats = filtered.map(e => e.lat);
+    const plottable = filtered.filter(e => e.validation.renderable);
+    if (plottable.length) {
+      const lons = plottable.map(e => e.lon), lats = plottable.map(e => e.lat);
       const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
       const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
       const xs = e => 12 + (e.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
       const xs = e => 12 + (e.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
       const ys = e => 12 + (maxLat - e.lat) / Math.max(0.001, maxLat - minLat) * 76;
       const ys = e => 12 + (maxLat - e.lat) / Math.max(0.001, maxLat - minLat) * 76;
-      filtered.forEach(e => {
+      plottable.forEach(e => {
         const el = document.createElement("div");
         const el = document.createElement("div");
         el.className = "marker c-" + levelColor(e.level);
         el.className = "marker c-" + levelColor(e.level);
         el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
         el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
@@ -181,7 +183,8 @@
       });
       });
     }
     }
     document.getElementById("caption").textContent =
     document.getElementById("caption").textContent =
-      `${mode === "WARNING" ? "预警" : "报警"} ${filtered.length} 条 · ` + filtered.map(e => e.no).join("、") || "无";
+      `${mode === "WARNING" ? "预警" : "报警"} ${filtered.length} 条 · 可上图 ${plottable.length} 条 · ` +
+      (filtered.map(e => e.no).join("、") || "无");
 
 
     document.querySelectorAll("#eventRows a").forEach(a =>
     document.querySelectorAll("#eventRows a").forEach(a =>
       a.addEventListener("click", ev => { ev.preventDefault(); showDetail(filtered[+a.dataset.i]); }));
       a.addEventListener("click", ev => { ev.preventDefault(); showDetail(filtered[+a.dataset.i]); }));
@@ -194,6 +197,10 @@
       <li>等级:${levelColor(e.level)}(${e.level} 级) 状态:${STATUS_NAME[e.status] || e.status} 时间:${e.time}</li>
       <li>等级:${levelColor(e.level)}(${e.level} 级) 状态:${STATUS_NAME[e.status] || e.status} 时间:${e.time}</li>
       <li>数值:${e.value} ${e.unit} 设备:${e.device}(${e.deviceName})</li>
       <li>数值:${e.value} ${e.unit} 设备:${e.device}(${e.deviceName})</li>
       <li>位置:${e.location} 类型:${TYPE[e.type]}</li>
       <li>位置:${e.location} 类型:${TYPE[e.type]}</li>
+      <li>坐标校验:${e.validation.state}${e.validation.reason ? `(${e.validation.reason})` : ""} 可上图:${e.validation.renderable ? "是" : "否"} CRS:${e.validation.crs}</li>
+      ${e.thresholdMin != null || e.thresholdMax != null
+        ? `<li>报警阈值:${e.thresholdMin ?? "-"} ~ ${e.thresholdMax ?? "-"}</li>`
+        : ""}
       ${woFailed
       ${woFailed
         ? `<li class="empty" style="margin-top:6px">工单状态分区不可用(分区失败独立呈现,事件列表不受影响)。</li>`
         ? `<li class="empty" style="margin-top:6px">工单状态分区不可用(分区失败独立呈现,事件列表不受影响)。</li>`
         : e.wo
         : e.wo

+ 19 - 11
docs/cockpit/map-snapshot-demo.html

@@ -58,21 +58,29 @@
 <script>
 <script>
 const snapshotAt='2026-09-02T10:00:00+08:00';
 const snapshotAt='2026-09-02T10:00:00+08:00';
 const pages=[
 const pages=[
-  {page:1,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,monitoringPoints:[
-    {pointCode:'MP-001',pointName:'供水干管监测点',pointStatus:'NORMAL',statusReasons:[],longitude:110.141,latitude:28.452,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}},
-    {pointCode:'MP-002',pointName:'燃气调压站监测点',pointStatus:'ALARM',statusReasons:['EQ-ASSOC-001 报警'],longitude:110.142,latitude:28.453,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}}
+  {page:1,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,
+   mapFilter:{industry:null,region:null,deviceType:null,pointCodes:['MP-001','MP-002','MP-003'],deviceCodes:['EQ-ASSOC-001','EQ-FREE-001','EQ-FREE-002','EQ-FREE-003','EQ-FREE-004']},
+   monitoringPoints:[
+    {pointCode:'MP-001',pointName:'供水干管监测点',region:'城区B',industry:'WATER_SUPPLY',typeCode:'WATER_SUPPLY_SENSOR',typeName:'供水压力计',pointStatus:'NORMAL',statusReasons:[],deviceCodes:[],longitude:110.141,latitude:28.452,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}},
+    {pointCode:'MP-002',pointName:'燃气调压站监测点',region:'城区A',industry:'GAS',typeCode:'GAS_SENSOR',typeName:'燃气浓度计',pointStatus:'ALARM',statusReasons:['EQ-ASSOC-001 报警'],deviceCodes:['EQ-ASSOC-001'],longitude:110.142,latitude:28.453,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}}
   ],unassociatedEquipment:[]},
   ],unassociatedEquipment:[]},
-  {page:2,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,monitoringPoints:[
-    {pointCode:'MP-003',pointName:'污水排口监测点',pointStatus:'WARNING',statusReasons:['EQ-FREE-003 预警'],longitude:110.143,latitude:28.454,coordinateValidation:{state:'UNVERIFIED',reason:'UNVERIFIED_COORDINATE',renderable:false,crs:'WGS84'}}
+  {page:2,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,
+   mapFilter:{industry:null,region:null,deviceType:null,pointCodes:['MP-001','MP-002','MP-003'],deviceCodes:['EQ-ASSOC-001','EQ-FREE-001','EQ-FREE-002','EQ-FREE-003','EQ-FREE-004']},
+   monitoringPoints:[
+    {pointCode:'MP-003',pointName:'污水排口监测点',region:'未分区',industry:'DRAINAGE',typeCode:'SEWAGE_ENV',typeName:'污水环境监测',pointStatus:'WARNING',statusReasons:['EQ-FREE-003 预警'],deviceCodes:[],longitude:110.143,latitude:28.454,coordinateValidation:{state:'UNVERIFIED',reason:'UNVERIFIED_COORDINATE',renderable:false,crs:'WGS84'}}
   ],unassociatedEquipment:[
   ],unassociatedEquipment:[
-    {deviceCode:'EQ-FREE-001',deviceName:'供水压力计-001',longitude:181,latitude:28.455,coordinateValidation:{state:'INVALID',reason:'LONGITUDE_OUT_OF_RANGE',renderable:false,crs:'WGS84'}}
+    {deviceCode:'EQ-FREE-001',deviceName:'供水压力计-001',region:'城区B',industry:'WATER_SUPPLY',typeCode:'WATER_SUPPLY_SENSOR',typeName:'供水压力计',longitude:181,latitude:28.455,coordinateValidation:{state:'INVALID',reason:'LONGITUDE_OUT_OF_RANGE',renderable:false,crs:'WGS84'}}
   ]},
   ]},
-  {page:3,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,monitoringPoints:[],unassociatedEquipment:[
-    {deviceCode:'EQ-FREE-002',deviceName:'智能井盖-002',longitude:110.145,latitude:28.456,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}},
-    {deviceCode:'EQ-FREE-003',deviceName:'污水环境监测仪-003',longitude:110.146,latitude:28.457,coordinateValidation:{state:'UNVERIFIED',reason:'UNVERIFIED_COORDINATE',renderable:false,crs:'WGS84'}}
+  {page:3,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,
+   mapFilter:{industry:null,region:null,deviceType:null,pointCodes:['MP-001','MP-002','MP-003'],deviceCodes:['EQ-ASSOC-001','EQ-FREE-001','EQ-FREE-002','EQ-FREE-003','EQ-FREE-004']},
+   monitoringPoints:[],unassociatedEquipment:[
+    {deviceCode:'EQ-FREE-002',deviceName:'智能井盖-002',region:'未分区',industry:null,typeCode:null,typeName:null,longitude:110.145,latitude:28.456,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}},
+    {deviceCode:'EQ-FREE-003',deviceName:'污水环境监测仪-003',region:'城区C',industry:'DRAINAGE',typeCode:'SEWAGE_ENV',typeName:'污水环境监测',longitude:110.146,latitude:28.457,coordinateValidation:{state:'UNVERIFIED',reason:'UNVERIFIED_COORDINATE',renderable:false,crs:'WGS84'}}
   ]},
   ]},
-  {page:4,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:true,monitoringPoints:[],unassociatedEquipment:[
-    {deviceCode:'EQ-FREE-004',deviceName:'零坐标设备-004',longitude:0,latitude:0,coordinateValidation:{state:'INVALID',reason:'ZERO_COORDINATE',renderable:false,crs:'WGS84'}}
+  {page:4,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:true,
+   mapFilter:{industry:null,region:null,deviceType:null,pointCodes:['MP-001','MP-002','MP-003'],deviceCodes:['EQ-ASSOC-001','EQ-FREE-001','EQ-FREE-002','EQ-FREE-003','EQ-FREE-004']},
+   monitoringPoints:[],unassociatedEquipment:[
+    {deviceCode:'EQ-FREE-004',deviceName:'零坐标设备-004',region:'城区A',industry:'GAS',typeCode:'GAS_SENSOR',typeName:'燃气浓度计',longitude:0,latitude:0,coordinateValidation:{state:'INVALID',reason:'ZERO_COORDINATE',renderable:false,crs:'WGS84'}}
   ]}
   ]}
 ];
 ];
 let pageIndex=0, loaded=0, renderable=0, blocked=0;
 let pageIndex=0, loaded=0, renderable=0, blocked=0;

+ 4 - 4
docs/cockpit/websocket-scope-delta-demo.html

@@ -63,15 +63,15 @@
       { code: "TC-PS-WS-202609020002", name: "Pressure monitor 002", status: "NORMAL", statusTime: "2026-09-02T09:58:00+08:00", serverUpdatedAt: "2026-09-02T10:00:00+08:00" }
       { code: "TC-PS-WS-202609020002", name: "Pressure monitor 002", status: "NORMAL", statusTime: "2026-09-02T09:58:00+08:00", serverUpdatedAt: "2026-09-02T10:00:00+08:00" }
     ];
     ];
     const scriptedMessages = [
     const scriptedMessages = [
-      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001",
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001", monitoringPointCode: "MP-001",
         data: { status: "ALARM", statusTime: "2026-09-02T10:01:00+08:00", serverUpdatedAt: "2026-09-02T10:01:00+08:00" } },
         data: { status: "ALARM", statusTime: "2026-09-02T10:01:00+08:00", serverUpdatedAt: "2026-09-02T10:01:00+08:00" } },
-      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001",
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001", monitoringPointCode: "MP-001",
         data: { status: "NORMAL", statusTime: "2026-09-02T10:00:30+08:00", serverUpdatedAt: "2026-09-02T10:00:30+08:00" } },
         data: { status: "NORMAL", statusTime: "2026-09-02T10:00:30+08:00", serverUpdatedAt: "2026-09-02T10:00:30+08:00" } },
-      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020099", device_code: "TC-PS-WS-202609020099",
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020099", device_code: "TC-PS-WS-202609020099", monitoringPointCode: null,
         data: { status: "EMPTY", reason: "DEVICE_NOT_FOUND", statusTime: null, serverUpdatedAt: "2026-09-02T10:02:00+08:00" } },
         data: { status: "EMPTY", reason: "DEVICE_NOT_FOUND", statusTime: null, serverUpdatedAt: "2026-09-02T10:02:00+08:00" } },
       { type: "__disconnect" },
       { type: "__disconnect" },
       { type: "__reconnect" },
       { type: "__reconnect" },
-      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001",
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001", monitoringPointCode: "MP-001",
         data: { status: "NORMAL", statusTime: "2026-09-02T10:03:00+08:00", serverUpdatedAt: "2026-09-02T10:03:00+08:00" } }
         data: { status: "NORMAL", statusTime: "2026-09-02T10:03:00+08:00", serverUpdatedAt: "2026-09-02T10:03:00+08:00" } }
     ];
     ];
 
 

+ 29 - 2
pipe-network-service/CONTEXT.md

@@ -188,6 +188,33 @@ _Avoid_:工单结案、报警记录已处理
 报警记录进入工单处置流程时生成待派单的故障维修工单,生成时不指定接单责任人;后续派发和处置由工单流程完成。
 报警记录进入工单处置流程时生成待派单的故障维修工单,生成时不指定接单责任人;后续派发和处置由工单流程完成。
 _Avoid_:报警派单、报警处理
 _Avoid_:报警派单、报警处理
 
 
+## 驾驶舱术语(2026-09-24 重新确认)
+
+**设备类型**:
+设备台账所属的层级类别;根类别区分供水、排水、燃气、窨井等业务归属,子类别区分具体监测设备。当前驾驶舱对外 `typeCode` 使用该类型的 ID;展示名称不是编码。
+_Avoid_:把类型名称或历史 slug 当作 `typeCode`
+
+**设备业务编码**:
+设备面向跨系统查询的唯一标识;与数据库内部主键不同。驾驶舱对外使用 `deviceCode`,即使工单等内部关系由设备主键维护,也不改变对外标识。
+_Avoid_:把工单主键、设备主键或事件编号当作设备业务编码
+
+**报警与工单关联**:
+报警记录进入处置流程后指向关联工单;工单同时指向设备。预警与报警仍是不同事件家族,预警需先通过其关联的报警才能推导工单,不能用事件名称或时间猜测。
+_Avoid_:从工单标题、报警时间或旧的工单设备编码字段反推关系
+
+**正式行业**:
+驾驶舱筛选与统计使用供水、排水、燃气、井盖四类;井盖在当前设备类型树中是独立根类型,不应静默归入排水。
+_Avoid_:把井盖当作排水子类或非正式行业
+
+**可上图坐标**:
+已由可信来源完成校核,且坐标自身有效的空间数据。仅有经纬度数值、或仅落在通用经纬度范围内,不等于业务坐标已校核。
+_Avoid_:以“有坐标”或“数值未越界”代替已校核
+
+**驾驶舱已接线**:
+在当前领域模型下,真实数据适配器可编译、装配并通过当前真实库联调,且满足只读契约;仅有历史代码、mock 测试或过期数据探查不构成已接线。
+_Avoid_:把历史“4/22”尝试记录当成当前可验收进度
+
+
 ## 工单处置
 ## 工单处置
 
 
 **工单**:
 **工单**:
@@ -203,11 +230,11 @@ _Avoid_:设备报警状态、报警记录状态、已驳回状态
 _Avoid_:用页面入口、工单标题或报警记录状态推断系统归属
 _Avoid_:用页面入口、工单标题或报警记录状态推断系统归属
 
 
 **故障维修工单**:
 **故障维修工单**:
-用于核实并修复设备故障的工单;由设备维修需求或监测报警转入的处置都属于此类型。工单流程重构后 `work_order` 不再保留 `order_type` 列,报警转入的故障维修工单以 `alarm_data.order_id → work_order.order_id` 关联识别,不得再新增或回滚该列。
+用于核实并修复设备故障的工单;由设备维修需求或监测报警转入的处置都属于此类型。工单流程重构后 `work_order` 不再保留 `order_type` 列,报警转入的故障维修工单以 `alarm_data.work_order_id → work_order.order_id` 关联识别,不得再新增或回滚该列。
 _Avoid_:维修任务、报警工单
 _Avoid_:维修任务、报警工单
 
 
 **同类报警工单**:
 **同类报警工单**:
-同一设备主键针对同一 `alarm_data.warning_code` 建立的故障维修工单,通过 `alarm_data.order_id` 关联;同设备同编码同时只能存在一个未办结工单(0/1/2/3/4/6),已结案和已取消不占名额。报警恢复不代表关联工单办结,不同编码可分别建单,普通手动建单及隐患转工单不受此限制。报警编码缺失时阻止报警转工单,不以报警名称替代(2026-09-24)。
+同一设备主键针对同一 `alarm_data.warning_code` 建立的故障维修工单,通过 `alarm_data.work_order_id` 关联;同设备同编码同时只能存在一个未办结工单(0/1/2/3/4/6),已结案和已取消不占名额。报警恢复不代表关联工单办结,不同编码可分别建单,普通手动建单及隐患转工单不受此限制。报警编码缺失时阻止报警转工单,不以报警名称替代(2026-09-24)。
 _Avoid_:同一报警记录、同设备全部工单
 _Avoid_:同一报警记录、同设备全部工单
 
 
 **工单派发**:
 **工单派发**:

+ 1 - 1
pipe-network-service/docs/2026-09-14-water-supply-home-alarm-work-order-e2e.md

@@ -52,7 +52,7 @@
 - 报警管理为只读报警台账总览,数据范围为供水设备;展示报警等级、设备报警状态、报警展示状态、关联工单、态势统计、趋势图、分类占比、筛选、导出、详情和跳转入口,不承载审核、转工单、解除或派发操作。
 - 报警管理为只读报警台账总览,数据范围为供水设备;展示报警等级、设备报警状态、报警展示状态、关联工单、态势统计、趋势图、分类占比、筛选、导出、详情和跳转入口,不承载审核、转工单、解除或派发操作。
 - 报警展示状态由后端根据报警持久化状态、审核结果和关联工单推导,分为“待审核 / 已转工单 / 已解除”,不直接暴露底层报警记录状态字段。
 - 报警展示状态由后端根据报警持久化状态、审核结果和关联工单推导,分为“待审核 / 已转工单 / 已解除”,不直接暴露底层报警记录状态字段。
 - 报警审核为审核工作台,只提供“转工单”和“误报解除”两个审核动作;完整派单、接单、处置、验收由统一工单页承载。
 - 报警审核为审核工作台,只提供“转工单”和“误报解除”两个审核动作;完整派单、接单、处置、验收由统一工单页承载。
-- 报警转工单创建的工单为故障维修类型,工单状态为 `0-待派单`;创建时不指定接单责任人,后续在统一工单页派发;必须遵守同类报警工单位一性约束。工单流程重构后 `work_order` 不再保留 `order_type` 列,“故障维修类型”由报警来源(`alarm_data.order_id` 关联工单)表达,不得回滚该列;同类工单位一性按“同设备 + 同报警类型 + 未办结工单”判定,终态(5-已结案、7-已取消)不占用名额。
+- 报警转工单创建的工单为故障维修类型,工单状态为 `0-待派单`;创建时不指定接单责任人,后续在统一工单页派发;必须遵守同类报警工单位一性约束。工单流程重构后 `work_order` 不再保留 `order_type` 列,“故障维修类型”由报警来源(`alarm_data.work_order_id` 关联工单)表达,不得回滚该列;同类工单位一性按“同设备 + 同报警类型 + 未办结工单”判定,终态(5-已结案、7-已取消)不占用名额。
 - 工单状态以现有枚举为权威:`0-待派单 / 1-待接单 / 2-已接单 / 3-处理中 / 4-待验收 / 5-已结案 / 6-延期中 / 7-已取消`;终态仅“已结案”和“已取消”,“延期中”是处理旁路。
 - 工单状态以现有枚举为权威:`0-待派单 / 1-待接单 / 2-已接单 / 3-处理中 / 4-待验收 / 5-已结案 / 6-延期中 / 7-已取消`;终态仅“已结案”和“已取消”,“延期中”是处理旁路。
 - 工单系统归属按关联设备的设备类型树确定,不由页面名称或标题推断;未知或未归属设备不得静默归入供水。
 - 工单系统归属按关联设备的设备类型树确定,不由页面名称或标题推断;未知或未归属设备不得静默归入供水。
 - 前端继续保留当前菜单名称,不修改菜单结构命名;“设备维修派单”继续作为进入统一工单页的业务入口。
 - 前端继续保留当前菜单名称,不修改菜单结构命名;“设备维修派单”继续作为进入统一工单页的业务入口。

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

@@ -36,16 +36,22 @@ public class CockpitMapSnapshotController {
             @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,
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "region", required = false) String region,
+            @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);
+                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, region, deviceType);
         return ResponseEntity.ok(AjaxResult.success(response));
         return ResponseEntity.ok(AjaxResult.success(response));
     }
     }
 
 
     @ExceptionHandler(CockpitInvalidRequestException.class)
     @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
         return badRequest(mapSnapshotService.invalidRequest(
         return badRequest(mapSnapshotService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
     }
     }
 
 
     @ExceptionHandler(CockpitSnapshotExpiredException.class)
     @ExceptionHandler(CockpitSnapshotExpiredException.class)

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

@@ -33,15 +33,21 @@ public class CockpitOverviewWarningSettingsController {
     @PreAuthorize("isAuthenticated()")
     @PreAuthorize("isAuthenticated()")
     public ResponseEntity<AjaxResult> warningSettings(
     public ResponseEntity<AjaxResult> warningSettings(
             @RequestParam(value = "mode", required = false) String mode,
             @RequestParam(value = "mode", required = false) String mode,
+            @RequestParam(value = "range", required = false) String range,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @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) {
-        return ResponseEntity.ok(AjaxResult.success(
-                warningSettingsService.load(resolveRequestId(requestId), mode)));
+        return ResponseEntity.ok(AjaxResult.success(warningSettingsService.load(
+                resolveRequestId(requestId), mode, range, startTime, endTime)));
     }
     }
 
 
     @ExceptionHandler(CockpitInvalidRequestException.class)
     @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
         return error(HttpStatus.BAD_REQUEST, warningSettingsService.invalidRequest(
         return error(HttpStatus.BAD_REQUEST, warningSettingsService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
     }
     }
 
 
     @ExceptionHandler(CockpitReadException.class)
     @ExceptionHandler(CockpitReadException.class)

+ 48 - 4
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusRedisListener.java

@@ -1,7 +1,13 @@
 package com.zksy.web.websocket;
 package com.zksy.web.websocket;
 
 
+import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.mapper.EquipmentPointRelMapper;
+import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentStatusService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -16,6 +22,8 @@ import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
 import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
 import java.util.HashSet;
 import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.Set;
 
 
 /**
 /**
@@ -36,6 +44,12 @@ public class DeviceStatusRedisListener implements MessageListener {
     @Autowired
     @Autowired
     private EquipmentStatusService equipmentStatusService;
     private EquipmentStatusService equipmentStatusService;
 
 
+    @Autowired
+    private EquipmentBaseService equipmentBaseService;
+
+    @Autowired
+    private EquipmentPointRelMapper equipmentPointRelMapper;
+
     @Autowired
     @Autowired
     private DeviceStatusWebSocketHandler webSocketHandler;
     private DeviceStatusWebSocketHandler webSocketHandler;
 
 
@@ -109,8 +123,10 @@ public class DeviceStatusRedisListener implements MessageListener {
                 }
                 }
                 pushPayload = legacyPushMessage(deviceCode, data).toJSONString();
                 pushPayload = legacyPushMessage(deviceCode, data).toJSONString();
             }
             }
-            String cockpitPayload = cockpitDeltaMessage(deviceCode, status, statusTime,
-                    serverUpdatedAt, reason).toJSONString();
+            String cockpitPayload = JSON.toJSONString(
+                    cockpitDeltaMessage(deviceCode, monitoringPointCode(deviceCode),
+                            status, statusTime, serverUpdatedAt, reason),
+                    SerializerFeature.WriteMapNullValue);
 
 
             int successCount = 0;
             int successCount = 0;
             int failCount = 0;
             int failCount = 0;
@@ -168,13 +184,14 @@ public class DeviceStatusRedisListener implements MessageListener {
         return value == null || value.isEmpty() ? msgObj.getString("changedAt") : value;
         return value == null || value.isEmpty() ? msgObj.getString("changedAt") : value;
     }
     }
 
 
-    private JSONObject cockpitDeltaMessage(String deviceCode, String status, String statusTime,
-            OffsetDateTime serverUpdatedAt, String reason) {
+    private JSONObject cockpitDeltaMessage(String deviceCode, String monitoringPointCode, String status,
+            String statusTime, OffsetDateTime serverUpdatedAt, String reason) {
         String serverUpdatedAtText = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(serverUpdatedAt);
         String serverUpdatedAtText = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(serverUpdatedAt);
         JSONObject delta = new JSONObject();
         JSONObject delta = new JSONObject();
         delta.put("type", "cockpitDeviceStatusDelta");
         delta.put("type", "cockpitDeviceStatusDelta");
         delta.put("deviceCode", deviceCode);
         delta.put("deviceCode", deviceCode);
         delta.put("device_code", deviceCode);
         delta.put("device_code", deviceCode);
+        delta.put("monitoringPointCode", monitoringPointCode);
         JSONObject data = new JSONObject();
         JSONObject data = new JSONObject();
         data.put("status", status);
         data.put("status", status);
         data.put("statusTime", statusTime);
         data.put("statusTime", statusTime);
@@ -184,6 +201,33 @@ public class DeviceStatusRedisListener implements MessageListener {
         return delta;
         return delta;
     }
     }
 
 
+    /**
+     * 设备编码 → 监测点编码映射:equipment_base.equipment_code → equipment_point_rel →
+     * monitor_point.point_code。未关联设备返回 null(帧内显式 null,不静默丢失);
+     * 查询失败降级为 null,不阻断状态推送。
+     */
+    private String monitoringPointCode(String deviceCode) {
+        try {
+            EquipmentBase equipment = equipmentBaseService.getOne(
+                    new LambdaQueryWrapper<EquipmentBase>()
+                            .eq(EquipmentBase::getEquipmentCode, deviceCode)
+                            .last("limit 1"));
+            if (equipment == null) {
+                return null;
+            }
+            List<Map<String, Object>> rels =
+                    equipmentPointRelMapper.selectPointRelByEquipmentId(equipment.getEquipmentId());
+            if (rels == null || rels.isEmpty()) {
+                return null;
+            }
+            Object pointCode = rels.get(0).get("pointCode");
+            return pointCode == null ? null : String.valueOf(pointCode);
+        } catch (Exception e) {
+            log.warn("解析设备点位映射失败,降级为 null: deviceCode={}", deviceCode, e);
+            return null;
+        }
+    }
+
     private String resolveStatus(EquipmentStatus status) {
     private String resolveStatus(EquipmentStatus status) {
         if (Integer.valueOf(1).equals(status.getAlarmStatus())) {
         if (Integer.valueOf(1).equals(status.getAlarmStatus())) {
             return "ALARM";
             return "ALARM";

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

@@ -0,0 +1,155 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.mockito.ArgumentCaptor;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 设备事实解析器的契约测试:固化「现网状态/预警/工单 → 设备事实」的口径,
+ * 覆盖现网脏值(0-based 工单状态、缺状态记录、预警状态大小写)。
+ */
+class BusinessCockpitDeviceFactResolverTest {
+
+    private EquipmentStatusService equipmentStatusService;
+    private EarlyWarningService earlyWarningService;
+    private WorkOrderService workOrderService;
+    private BusinessCockpitDeviceFactResolver resolver;
+
+    @BeforeEach
+    void setUp() {
+        equipmentStatusService = mock(EquipmentStatusService.class);
+        earlyWarningService = mock(EarlyWarningService.class);
+        workOrderService = mock(WorkOrderService.class);
+        resolver = new BusinessCockpitDeviceFactResolver(
+                equipmentStatusService, earlyWarningService, workOrderService);
+    }
+
+    @Test
+    void alarmStatusAndOpenWarningAndOpenWorkOrderAllResolve() {
+        when(equipmentStatusService.getByDeviceCode("DEV-1")).thenReturn(status(1, 1, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("PENDING")));
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(3)));
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-1"), "压力计");
+
+        assertTrue(fact.isOnline(), "online_status=1 应在线");
+        assertTrue(fact.isAlarm(), "alarm_status=1 应报警");
+        assertTrue(fact.isWarning(), "PENDING 预警应视为预警中");
+        assertTrue(fact.isWorkOrderInProgress(), "现网 0-based 的 3=处理中 应视为在途");
+        ArgumentCaptor<QueryWrapper> orderQuery = ArgumentCaptor.forClass(QueryWrapper.class);
+        verify(workOrderService).list(orderQuery.capture());
+        assertTrue(orderQuery.getValue().getSqlSegment().contains("device_id"), "工单必须按设备主键关联");
+        assertTrue(orderQuery.getValue().getParamNameValuePairs().containsValue("ID-DEV-1"));
+        assertEquals("压力计", fact.getDeviceTypeName());
+        assertEquals(CockpitMonitoringPointStatus.ALARM,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void offlineStatusResolvesToOffline() {
+        when(equipmentStatusService.getByDeviceCode("DEV-2")).thenReturn(status(1, 0, 0));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-2"), null);
+
+        assertFalse(fact.isOnline());
+        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void missingStatusIsReportedOfflineWithExplicitReason() {
+        when(equipmentStatusService.getByDeviceCode("DEV-3")).thenReturn(null);
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-3"), null);
+
+        assertFalse(fact.isOnline(), "缺状态记录按离线");
+        assertTrue(fact.getStatusReasons().stream().anyMatch(reason -> reason.contains("无状态记录")));
+        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void closedOrCancelledWorkOrderIsNotInProgress() {
+        when(equipmentStatusService.getByDeviceCode("DEV-4")).thenReturn(status(1, 0, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(5)));
+
+        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-4"), null);
+
+        assertFalse(fact.isWorkOrderInProgress(), "0-based 的 5=已结案 不在途");
+        assertEquals(CockpitMonitoringPointStatus.NORMAL,
+                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
+    }
+
+    @Test
+    void warningStatusIsCaseInsensitiveAndClosedWarningIsNotWarning() {
+        when(equipmentStatusService.getByDeviceCode("DEV-5")).thenReturn(status(1, 0, 1));
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("processing")));
+        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        assertTrue(resolver.resolve(device("DEV-5"), null).isWarning(), "小写 processing 也视为预警中");
+
+        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("CLOSED")));
+        assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中");
+    }
+
+    // ---- fixtures ----
+
+    private static EquipmentBase device(String code) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId("ID-" + code);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(code);
+        return device;
+    }
+
+    private static EquipmentStatus status(int current, int alarm, int online) {
+        EquipmentStatus status = new EquipmentStatus();
+        status.setCurrentStatus(current);
+        status.setAlarmStatus(alarm);
+        status.setOnlineStatus(online);
+        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
+        return status;
+    }
+
+    private static EarlyWarning warning(String status) {
+        EarlyWarning warning = new EarlyWarning();
+        warning.setStatus(status);
+        return warning;
+    }
+
+    private static WorkOrder order(int status) {
+        WorkOrder order = new WorkOrder();
+        order.setOrderStatus(status);
+        return order;
+    }
+}

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

@@ -0,0 +1,193 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.map.BusinessCockpitMapSnapshotAdapter;
+import com.zksy.cockpit.map.CockpitMapSnapshot;
+import com.zksy.cockpit.map.CockpitMapSnapshotService;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 地图快照真实适配器的契约测试:固化「现网表 → 快照」的构图口径
+ * (点位、类型字典、行业推导、全量设备 vs 关联判定)。
+ * 设备事实(在线/报警/预警/工单)由 {@link BusinessCockpitDeviceFactResolver} 负责,
+ * 其口径在其自身测试中单独覆盖。
+ */
+class BusinessCockpitMapSnapshotAdapterTest {
+
+    private MonitorPointService monitorPointService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
+    private BusinessCockpitDeviceFactResolver deviceFactResolver;
+    private BusinessCockpitMapSnapshotAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        monitorPointService = mock(MonitorPointService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        deviceFactResolver = mock(BusinessCockpitDeviceFactResolver.class);
+        adapter = new BusinessCockpitMapSnapshotAdapter(
+                monitorPointService, equipmentBaseService, equipmentTypeService,
+                deviceFactResolver,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void composesRealPointsDevicesAndTypesWithDerivedIndustry() {
+        stubPoints(Collections.singletonList(
+                point("P-1", "Z-RQXLJC01", "燃气泄漏监测点01", new BigDecimal("110.4024"), new BigDecimal("28.4499"))));
+        stubTypes(Arrays.asList(
+                type(22, "combustible_gas_detector", "可燃气体检测仪", "3"),
+                type(3, "gas", "燃气设备", "0")));
+        EquipmentBase bound = device("D-1", "Z-TEST-RQXL01", "Z-测试燃气泄漏01", "22",
+                new BigDecimal("110.4024"), new BigDecimal("28.4499"));
+        stubDevices(Collections.singletonList(bound), Collections.singletonList(bound));
+        when(deviceFactResolver.resolve(any(), anyString())).thenReturn(fact("Z-TEST-RQXL01"));
+
+        CockpitMapSnapshot snapshot = adapter.load(null);
+
+        assertEquals(1, snapshot.getMonitoringPoints().size());
+        CockpitMapSnapshot.SourceMonitoringPoint point = snapshot.getMonitoringPoints().get(0);
+        assertEquals("Z-RQXLJC01", point.getPointCode());
+        assertEquals("22", point.getTypeCode(), "关联设备类型唯一时点位类型可推导");
+        assertEquals(1, point.getDevices().size());
+        assertEquals("Z-TEST-RQXL01", point.getDevices().get(0).getDeviceCode());
+
+        assertTrue(snapshot.getTypes().stream().anyMatch(
+                type -> "22".equals(type.getCode()) && "GAS".equals(type.getIndustry())),
+                "行业应由类型根 parent_type_id=3 推导为 GAS");
+        assertFalse(point.isCoordinateVerified(), "现网无校核字段,一律不臆造为已校核");
+    }
+
+    @Test
+    void adapterReportsAllSmartDevicesAndServiceDecidesAssociation() {
+        stubPoints(Collections.singletonList(
+                point("P-1", "P-1", "点位一", new BigDecimal("110.4"), new BigDecimal("28.4"))));
+        stubTypes(Collections.singletonList(type(11, "ultrasonic_flowmeter", "超声波流量计", "1")));
+        EquipmentBase bound = device("D-1", "BOUND-1", "已关联", "11",
+                new BigDecimal("110.4"), new BigDecimal("28.4"));
+        EquipmentBase free = device("D-2", "FREE-1", "未关联", "11",
+                new BigDecimal("110.5"), new BigDecimal("28.5"));
+        stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
+        when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
+            EquipmentBase device = invocation.getArgument(0);
+            return fact(device.getEquipmentCode());
+        });
+
+        CockpitMapSnapshot snapshot = adapter.load(null);
+        assertEquals(2, snapshot.getSmartDevices().size(), "适配器上报全量设备");
+
+        CockpitMapSnapshotService service = new CockpitMapSnapshotService(
+                adapter, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        var response = service.load("req", 1, 500, null, null, null, null);
+
+        assertEquals(1, response.getUnassociatedEquipment().size());
+        assertEquals("FREE-1", response.getUnassociatedEquipment().get(0).getDeviceCode());
+        assertEquals(1, response.getMonitoringPoints().size());
+        assertEquals(1, response.getMonitoringPoints().get(0).getDeviceCodes().size());
+    }
+
+    @Test
+    void unknownTypeRootYieldsNullIndustryInsteadOfSilentClassification() {
+        stubPoints(Collections.emptyList());
+        stubTypes(Collections.singletonList(type(99, "mystery_type", "未知类型", "9")));
+        stubDevices(Collections.emptyList(), Collections.emptyList());
+
+        CockpitMapSnapshot snapshot = adapter.load("2026-09-02T10:00:00+08:00");
+
+        assertEquals(1, snapshot.getTypes().size());
+        assertNull(snapshot.getTypes().get(0).getIndustry(), "未知类型根不得静默归类");
+        assertEquals("2026-09-02T10:00:00+08:00", snapshot.getSnapshotAt());
+    }
+
+    @Test
+    void manholeRootIsExposedAsManholeIndustryForContractRuling() {
+        stubPoints(Collections.emptyList());
+        stubTypes(Collections.singletonList(type(24, "manhole_monitor", "窨井盖监测", "4")));
+        stubDevices(Collections.emptyList(), Collections.emptyList());
+
+        CockpitMapSnapshot snapshot = adapter.load(null);
+
+        assertEquals("MANHOLE", snapshot.getTypes().get(0).getIndustry(),
+                "窨井根映射为 MANHOLE 传值,交由服务层判非正式行业,而非在适配器吞掉");
+    }
+
+    // ---- stubbing helpers ----
+
+    private void stubPoints(List<MonitorPoint> points) {
+        when(monitorPointService.list()).thenReturn(points);
+    }
+
+    private void stubTypes(List<EquipmentType> types) {
+        when(equipmentTypeService.list()).thenReturn(types);
+    }
+
+    /** 设备全量来自 list(),点位绑定来自 getEquipmentByPoint。 */
+    private void stubDevices(List<EquipmentBase> allDevices, List<EquipmentBase> boundToFirstPoint) {
+        when(equipmentBaseService.list()).thenReturn(allDevices);
+        when(equipmentBaseService.getEquipmentByPoint(any())).thenReturn(boundToFirstPoint);
+    }
+
+    // ---- fixtures ----
+
+    private static CockpitMonitoringPointDeviceSnapshot fact(String code) {
+        return new CockpitMonitoringPointDeviceSnapshot(
+                code, code, "类型名", true, false, false, false, Collections.emptyList(), null);
+    }
+
+    private static MonitorPoint point(String id, String code, String name, BigDecimal lon, BigDecimal lat) {
+        MonitorPoint point = new MonitorPoint();
+        point.setPointId(id);
+        point.setPointCode(code);
+        point.setPointName(name);
+        point.setLongitude(lon);
+        point.setLatitude(lat);
+        return point;
+    }
+
+    private static EquipmentType type(int id, String typeId, String name, String parent) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(Integer.valueOf(parent));
+        return type;
+    }
+
+    private static EquipmentBase device(String id, String code, String name, String typeId,
+            BigDecimal lon, BigDecimal lat) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(name);
+        device.setEquipmentTypeId(Integer.valueOf(typeId));
+        device.setLongitude(lon);
+        device.setLatitude(lat);
+        return device;
+    }
+}

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

@@ -0,0 +1,171 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.monitoring.BusinessCockpitMonitoringDistributionAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionSnapshot;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 监测分布真实适配器的契约测试:与地图快照同构图,额外固化「未关联智能设备携带设备事实」。
+ */
+class BusinessCockpitMonitoringDistributionAdapterTest {
+
+    private MonitorPointService monitorPointService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
+    private BusinessCockpitDeviceFactResolver deviceFactResolver;
+    private BusinessCockpitMonitoringDistributionAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        monitorPointService = mock(MonitorPointService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        deviceFactResolver = mock(BusinessCockpitDeviceFactResolver.class);
+        adapter = new BusinessCockpitMonitoringDistributionAdapter(
+                monitorPointService, equipmentBaseService, equipmentTypeService,
+                deviceFactResolver,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void composesPointsWithDerivedTypeAndSmartDevicesWithFacts() {
+        stubPoints(Arrays.asList(
+                point("P-1", "Z-RQXLJC01", "燃气泄漏监测点01", new BigDecimal("110.4024"), new BigDecimal("28.4499")),
+                point("P-2", "RQJCD01", "无设备点", null, null)));
+        stubTypes(Arrays.asList(
+                type(22, "22", "可燃气体检测仪", "3")));
+        EquipmentBase bound = device("D-1", "Z-TEST-RQXL01", "Z-测试燃气泄漏01", "22",
+                new BigDecimal("110.4024"), new BigDecimal("28.4499"));
+        EquipmentBase free = device("D-2", "FREE-1", "独立燃气计", "22",
+                new BigDecimal("110.5"), new BigDecimal("28.5"));
+        stubDevices(Arrays.asList(bound, free), Collections.singletonList(bound));
+        when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
+            EquipmentBase device = invocation.getArgument(0);
+            if ("Z-TEST-RQXL01".equals(device.getEquipmentCode())) {
+                return fact("Z-TEST-RQXL01", true, true, false, false);
+            }
+            return fact("FREE-1", false, false, true, true);
+        });
+
+        CockpitMonitoringDistributionSnapshot snapshot = adapter.load(null);
+
+        assertEquals(2, snapshot.getMonitoringPoints().size());
+        CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p1 = snapshot.getMonitoringPoints().get(0);
+        assertEquals("22", p1.getTypeCode(), "关联设备类型唯一时点位类型可推导");
+        assertFalse(p1.isCoordinateVerified());
+        CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint p2 = snapshot.getMonitoringPoints().get(1);
+        assertNull(p2.getTypeCode(), "无关联设备时点位类型不可推导");
+
+        assertEquals(2, snapshot.getSmartDevices().size(), "分布快照上报全量设备(关联由服务层判定)");
+        CockpitMonitoringDistributionSnapshot.SourceSmartDevice freeDevice = snapshot.getSmartDevices().stream()
+                .filter(device -> "FREE-1".equals(device.getDeviceCode()))
+                .findFirst().orElseThrow();
+        assertFalse(freeDevice.isOnline());
+        assertTrue(freeDevice.isWarning());
+        assertTrue(freeDevice.isWorkOrderInProgress());
+        assertEquals("22", freeDevice.getTypeCode());
+
+        assertTrue(snapshot.getTypes().stream().anyMatch(
+                type -> "22".equals(type.getCode()) && "GAS".equals(type.getIndustry())));
+        assertEquals("2026-09-02T10:00:00+08:00", snapshot.getSnapshotAt());
+    }
+
+    @Test
+    void unknownTypeRootAndManholeRootAreReportedNotSwallowed() {
+        stubPoints(Collections.emptyList());
+        stubTypes(Arrays.asList(
+                type(99, "99", "未知类型", "9"),
+                type(24, "24", "窨井盖监测", "4")));
+        stubDevices(Collections.emptyList(), Collections.emptyList());
+
+        CockpitMonitoringDistributionSnapshot snapshot = adapter.load(null);
+
+        assertNull(snapshot.getTypes().stream().filter(t -> "99".equals(t.getCode()))
+                .findFirst().orElseThrow().getIndustry());
+        assertEquals("MANHOLE", snapshot.getTypes().stream().filter(t -> "24".equals(t.getCode()))
+                .findFirst().orElseThrow().getIndustry());
+    }
+
+    // ---- stubbing helpers ----
+
+    private void stubPoints(List<MonitorPoint> points) {
+        when(monitorPointService.list()).thenReturn(points);
+    }
+
+    private void stubTypes(List<EquipmentType> types) {
+        when(equipmentTypeService.list()).thenReturn(types);
+    }
+
+    private void stubDevices(List<EquipmentBase> allDevices, List<EquipmentBase> boundToFirstPoint) {
+        when(equipmentBaseService.list()).thenReturn(allDevices);
+        when(equipmentBaseService.getEquipmentByPoint(any())).thenAnswer(invocation -> {
+            Object pointId = invocation.getArgument(0);
+            return "P-1".equals(pointId) ? boundToFirstPoint : Collections.emptyList();
+        });
+    }
+
+    // ---- fixtures ----
+
+    private static CockpitMonitoringPointDeviceSnapshot fact(
+            String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
+        return new CockpitMonitoringPointDeviceSnapshot(
+                code, code, "类型名", online, alarm, warning, workOrder,
+                Collections.emptyList(), null);
+    }
+
+    private static MonitorPoint point(String id, String code, String name, BigDecimal lon, BigDecimal lat) {
+        MonitorPoint point = new MonitorPoint();
+        point.setPointId(id);
+        point.setPointCode(code);
+        point.setPointName(name);
+        point.setLongitude(lon);
+        point.setLatitude(lat);
+        return point;
+    }
+
+    private static EquipmentType type(int id, String typeId, String name, String parent) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(Integer.valueOf(parent));
+        return type;
+    }
+
+    private static EquipmentBase device(String id, String code, String name, String typeId,
+            BigDecimal lon, BigDecimal lat) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(name);
+        device.setEquipmentTypeId(Integer.valueOf(typeId));
+        device.setLongitude(lon);
+        device.setLatitude(lat);
+        return device;
+    }
+}

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

@@ -0,0 +1,200 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.monitoring.BusinessCockpitMonitoringOverviewAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewSnapshot;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 监测概览真实适配器的契约测试:固化「设备状态/点位归属/类型字典 → 概览快照」口径。
+ * 重点覆盖现网脏值:无状态记录不得冒充 OFFLINE,也不得伪造 STALE。
+ */
+class BusinessCockpitMonitoringOverviewAdapterTest {
+
+    private MonitorPointService monitorPointService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentStatusService equipmentStatusService;
+    private EquipmentTypeService equipmentTypeService;
+    private BusinessCockpitMonitoringOverviewAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        monitorPointService = mock(MonitorPointService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentStatusService = mock(EquipmentStatusService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        adapter = new BusinessCockpitMonitoringOverviewAdapter(
+                monitorPointService, equipmentBaseService, equipmentStatusService, equipmentTypeService,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void deviceStatesAndPointOwnershipAreDerivedFromRealTables() {
+        stubPoints(Collections.singletonList(point("P-1", "Z-RQXLJC01")));
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        EquipmentBase online = device("D-1", "ONLINE-1", "22");
+        EquipmentBase offline = device("D-2", "OFFLINE-1", "22");
+        EquipmentBase noStatus = device("D-3", "NOSTATUS-1", "22");
+        when(equipmentBaseService.list()).thenReturn(Arrays.asList(online, offline, noStatus));
+        when(equipmentBaseService.getEquipmentByPoint("P-1")).thenReturn(Collections.singletonList(online));
+        when(equipmentStatusService.getByDeviceCode("ONLINE-1")).thenReturn(status(1));
+        when(equipmentStatusService.getByDeviceCode("OFFLINE-1")).thenReturn(status(0));
+        when(equipmentStatusService.getByDeviceCode("NOSTATUS-1")).thenReturn(null);
+
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+
+        assertEquals(3, snapshot.getDevices().size());
+        assertEquals("ONLINE", stateOf(snapshot, "ONLINE-1"));
+        assertEquals("OFFLINE", stateOf(snapshot, "OFFLINE-1"));
+        assertEquals("UNKNOWN", stateOf(snapshot, "NOSTATUS-1"), "无状态记录必须 UNKNOWN,不得冒充 OFFLINE 或 STALE");
+
+        assertEquals("Z-RQXLJC01", pointCodeOf(snapshot, "ONLINE-1"), "已关联设备带点位编码");
+        assertNull(pointCodeOf(snapshot, "OFFLINE-1"), "未关联设备点位为空");
+        assertFalse(snapshot.getDevices().stream().anyMatch(d -> "STALE".equals(d.getMonitoringState())),
+                "现网无样本新鲜度依据,不得伪造 STALE");
+        assertEquals(1, snapshot.getMonitoringPoints().size());
+        assertEquals("22", snapshot.getMonitoringPoints().get(0).getTypeCode());
+    }
+
+    @Test
+    void serviceCountsOnlineOfflineAndUnknownSeparately() {
+        stubPoints(Collections.singletonList(point("P-1", "P-1")));
+        stubTypes(Collections.singletonList(type(11, "ultrasonic_flowmeter", "超声波流量计", "1")));
+        EquipmentBase online = device("D-1", "ON-1", "11");
+        EquipmentBase offline = device("D-2", "OFF-1", "11");
+        EquipmentBase noStatus = device("D-3", "NS-1", "11");
+        when(equipmentBaseService.list()).thenReturn(Arrays.asList(online, offline, noStatus));
+        when(equipmentBaseService.getEquipmentByPoint(any())).thenReturn(Collections.emptyList());
+        when(equipmentStatusService.getByDeviceCode("ON-1")).thenReturn(status(1));
+        when(equipmentStatusService.getByDeviceCode("OFF-1")).thenReturn(status(0));
+        when(equipmentStatusService.getByDeviceCode("NS-1")).thenReturn(null);
+
+        CockpitMonitoringOverviewService service = new CockpitMonitoringOverviewService(
+                adapter, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        var response = service.load("req", null, null);
+
+        assertEquals(1, response.getStatus().getOnlineCount());
+        assertEquals(1, response.getStatus().getOfflineCount());
+        assertEquals(1, response.getStatus().getUnknownOrMissingCount());
+        assertEquals(0, response.getStatus().getStaleCount());
+        // 在线率分母只含可判定设备(online+offline+stale),UNKNOWN 不计入
+        assertEquals(2, response.getStatus().getDenominator());
+        assertEquals(new BigDecimal("0.5000"), response.getOnlineRate().getValue());
+    }
+
+    @Test
+    void unknownTypeRootIsReportedNotSwallowed() {
+        stubPoints(Collections.emptyList());
+        stubTypes(Arrays.asList(
+                type(99, "mystery_type", "未知类型", "9"),
+                type(24, "manhole_monitor", "窨井盖监测", "4")));
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+
+        assertNull(snapshot.getTypes().stream().filter(t -> "99".equals(t.getCode()))
+                .findFirst().orElseThrow().getIndustry());
+        assertEquals("MANHOLE", snapshot.getTypes().stream().filter(t -> "24".equals(t.getCode()))
+                .findFirst().orElseThrow().getIndustry());
+    }
+
+    @Test
+    void nestedTypeResolvesIndustryFromTheRootNotTheImmediateParent() {
+        stubPoints(Collections.emptyList());
+        stubTypes(Arrays.asList(
+                type(4, "manhole_root", "窨井设备", "0"),
+                type(24, "manhole_monitor", "窨井盖监测", "4"),
+                type(240, "manhole_child", "窨井子类", "24")));
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+
+        assertEquals("MANHOLE", snapshot.getTypes().stream().filter(t -> "240".equals(t.getCode()))
+                .findFirst().orElseThrow().getIndustry());
+    }
+
+    // ---- helpers ----
+
+    private static String stateOf(CockpitMonitoringOverviewSnapshot snapshot, String deviceCode) {
+        return snapshot.getDevices().stream()
+                .filter(d -> deviceCode.equals(d.getDeviceCode()))
+                .findFirst().orElseThrow().getMonitoringState();
+    }
+
+    private static String pointCodeOf(CockpitMonitoringOverviewSnapshot snapshot, String deviceCode) {
+        return snapshot.getDevices().stream()
+                .filter(d -> deviceCode.equals(d.getDeviceCode()))
+                .findFirst().orElseThrow().getPointCode();
+    }
+
+    private void stubPoints(List<MonitorPoint> points) {
+        when(monitorPointService.list()).thenReturn(points);
+    }
+
+    private void stubTypes(List<EquipmentType> types) {
+        when(equipmentTypeService.list()).thenReturn(types);
+    }
+
+    private static MonitorPoint point(String id, String code) {
+        MonitorPoint point = new MonitorPoint();
+        point.setPointId(id);
+        point.setPointCode(code);
+        point.setPointName(code);
+        return point;
+    }
+
+    private static EquipmentType type(int id, String typeId, String name, String parent) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(Integer.valueOf(parent));
+        return type;
+    }
+
+    private static EquipmentBase device(String id, String code, String typeId) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(code);
+        device.setEquipmentTypeId(Integer.valueOf(typeId));
+        device.setLongitude(new BigDecimal("110.4"));
+        device.setLatitude(new BigDecimal("28.4"));
+        return device;
+    }
+
+    private static EquipmentStatus status(int online) {
+        EquipmentStatus status = new EquipmentStatus();
+        status.setOnlineStatus(online);
+        status.setAlarmStatus(0);
+        status.setCurrentStatus(1);
+        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
+        return status;
+    }
+}

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

@@ -0,0 +1,213 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.monitoring.BusinessCockpitMonitoringSituationAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationSnapshot;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 运行概况真实适配器的契约测试:固化「指标态推导 + 两类事件分离 + 事件类型经台账反查」口径。
+ * 重点覆盖现网脏值:device_type/warning_type 是描述性字符串,不可当 typeCode 用。
+ */
+class BusinessCockpitMonitoringSituationAdapterTest {
+
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentStatusService equipmentStatusService;
+    private EquipmentTypeService equipmentTypeService;
+    private EarlyWarningService earlyWarningService;
+    private AlarmDataService alarmDataService;
+    private BusinessCockpitDeviceFactResolver deviceFactResolver;
+    private BusinessCockpitMonitoringSituationAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentStatusService = mock(EquipmentStatusService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
+        earlyWarningService = mock(EarlyWarningService.class);
+        alarmDataService = mock(AlarmDataService.class);
+        deviceFactResolver = mock(BusinessCockpitDeviceFactResolver.class);
+        adapter = new BusinessCockpitMonitoringSituationAdapter(
+                equipmentBaseService, equipmentStatusService, equipmentTypeService,
+                earlyWarningService, alarmDataService, deviceFactResolver,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void metricStatusIsDerivedWithUnknownNotMasqueradingAsOffline() {
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        EquipmentBase alarmDevice = device("D-1", "ALARM-1", "22");
+        EquipmentBase warningDevice = device("D-2", "WARN-1", "22");
+        EquipmentBase offlineDevice = device("D-3", "OFF-1", "22");
+        EquipmentBase normalDevice = device("D-4", "NORMAL-1", "22");
+        EquipmentBase noStatusDevice = device("D-5", "NS-1", "22");
+        when(equipmentBaseService.list())
+                .thenReturn(Arrays.asList(alarmDevice, warningDevice, offlineDevice, normalDevice, noStatusDevice));
+        when(deviceFactResolver.resolve(any(), anyString())).thenAnswer(invocation -> {
+            EquipmentBase device = invocation.getArgument(0);
+            switch (device.getEquipmentCode()) {
+                case "ALARM-1":
+                    return fact("ALARM-1", true, true, false);
+                case "WARN-1":
+                    return fact("WARN-1", true, false, true);
+                default:
+                    return fact(device.getEquipmentCode(), false, false, false);
+            }
+        });
+        when(equipmentStatusService.getByDeviceCode("ALARM-1")).thenReturn(status(1));
+        when(equipmentStatusService.getByDeviceCode("WARN-1")).thenReturn(status(1));
+        when(equipmentStatusService.getByDeviceCode("OFF-1")).thenReturn(status(0));
+        when(equipmentStatusService.getByDeviceCode("NORMAL-1")).thenReturn(status(1));
+        when(equipmentStatusService.getByDeviceCode("NS-1")).thenReturn(null);
+        stubEvents(Collections.emptyList(), Collections.emptyList());
+
+        CockpitMonitoringSituationSnapshot snapshot = adapter.load();
+
+        assertEquals("ALARM", metricOf(snapshot, "ALARM-1"));
+        assertEquals("WARNING", metricOf(snapshot, "WARN-1"));
+        assertEquals("OFFLINE", metricOf(snapshot, "OFF-1"));
+        assertEquals("NORMAL", metricOf(snapshot, "NORMAL-1"));
+        assertEquals("UNKNOWN", metricOf(snapshot, "NS-1"), "无状态记录必须 UNKNOWN,不得冒充 OFFLINE");
+        assertFalse(snapshot.getDevices().stream().anyMatch(d -> "EXPIRED".equals(d.getMetricStatus())),
+                "现网无样本新鲜度依据,不得产生 EXPIRED");
+    }
+
+    @Test
+    void eventsAreSeparatedAndTheirTypeIsResolvedViaLedgerNotDeviceTypeText() {
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device("D-1", "ALARM-1", "22")));
+        when(equipmentStatusService.getByDeviceCode(anyString())).thenReturn(status(1));
+        when(deviceFactResolver.resolve(any(), anyString())).thenReturn(fact("ALARM-1", false, false, false));
+
+        EarlyWarning warning = new EarlyWarning();
+        warning.setWarningNo("W-1");
+        warning.setWarningLevel("2");
+        warning.setDeviceCode("ALARM-1");
+        // 现网脏值:warning_type 是描述性字符串,不是 type_id
+        warning.setWarningType("GAS_PIPE");
+        warning.setPublishTime(LocalDateTime.parse("2026-09-02T09:30:00"));
+
+        AlarmData alarm = new AlarmData();
+        alarm.setId("A-1");
+        alarm.setDeviceCode("ALARM-1");
+        alarm.setDeviceType("流量监测");
+        alarm.setAlarmLevel(3);
+        alarm.setCreateTime(LocalDateTime.parse("2026-09-02T09:40:00"));
+        stubEvents(Collections.singletonList(warning), Collections.singletonList(alarm));
+
+        CockpitMonitoringSituationSnapshot snapshot = adapter.load();
+
+        assertEquals(1, snapshot.getWarnings().size(), "预警与报警分列,不得混池");
+        assertEquals(1, snapshot.getAlarms().size());
+        assertEquals("W-1", snapshot.getWarnings().get(0).getEventNo());
+        assertEquals("A-1", snapshot.getAlarms().get(0).getEventNo());
+        // typeCode 必须来自台账反查(equipment_type.id=22),而不是 warning_type/device_type 文本
+        assertEquals("22", snapshot.getWarnings().get(0).getTypeCode());
+        assertEquals("22", snapshot.getAlarms().get(0).getTypeCode());
+        assertEquals("GAS", snapshot.getAlarms().get(0).getIndustry());
+        // 时间统一为带偏移的 ISO 串,供服务层入趋势桶
+        assertEquals("2026-09-02T09:40:00+08:00", snapshot.getAlarms().get(0).getEventTime());
+    }
+
+    @Test
+    void eventWithUnknownDeviceYieldsNullTypeInsteadOfGuessing() {
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(deviceFactResolver.resolve(any(), any())).thenReturn(fact("x", false, false, false));
+
+        AlarmData alarm = new AlarmData();
+        alarm.setId("A-ORPHAN");
+        alarm.setDeviceCode("NOT-IN-LEDGER");
+        alarm.setDeviceType("流量监测");
+        alarm.setAlarmLevel(1);
+        alarm.setCreateTime(LocalDateTime.parse("2026-09-02T09:40:00"));
+        stubEvents(Collections.emptyList(), Collections.singletonList(alarm));
+
+        CockpitMonitoringSituationSnapshot snapshot = adapter.load();
+
+        assertNull(snapshot.getAlarms().get(0).getTypeCode(), "台账查不到设备时 typeCode 为 null,交服务层计未知类型");
+        assertNull(snapshot.getAlarms().get(0).getIndustry());
+        assertTrue(snapshot.getDevices().isEmpty());
+    }
+
+    // ---- helpers ----
+
+    private void stubTypes(List<EquipmentType> types) {
+        when(equipmentTypeService.list()).thenReturn(types);
+    }
+
+    private void stubEvents(List<EarlyWarning> warnings, List<AlarmData> alarms) {
+        when(earlyWarningService.list()).thenReturn(warnings);
+        when(alarmDataService.list()).thenReturn(alarms);
+    }
+
+    private static String metricOf(CockpitMonitoringSituationSnapshot snapshot, String deviceCode) {
+        return snapshot.getDevices().stream()
+                .filter(d -> deviceCode.equals(d.getDeviceCode()))
+                .findFirst().orElseThrow().getMetricStatus();
+    }
+
+    private static CockpitMonitoringPointDeviceSnapshot fact(
+            String code, boolean online, boolean alarm, boolean warning) {
+        return new CockpitMonitoringPointDeviceSnapshot(
+                code, code, "类型名", online, alarm, warning, false,
+                Collections.emptyList(), null);
+    }
+
+    private static EquipmentType type(int id, String typeId, String name, String parent) {
+        EquipmentType type = new EquipmentType();
+        type.setId(id);
+        type.setTypeName(name);
+        type.setParentId(Integer.valueOf(parent));
+        return type;
+    }
+
+    private static EquipmentBase device(String id, String code, String typeId) {
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId(id);
+        device.setEquipmentCode(code);
+        device.setEquipmentName(code);
+        device.setEquipmentTypeId(Integer.valueOf(typeId));
+        device.setLongitude(new BigDecimal("110.4"));
+        device.setLatitude(new BigDecimal("28.4"));
+        return device;
+    }
+
+    private static EquipmentStatus status(int online) {
+        EquipmentStatus status = new EquipmentStatus();
+        status.setOnlineStatus(online);
+        status.setAlarmStatus(0);
+        status.setCurrentStatus(1);
+        return status;
+    }
+}

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

@@ -29,15 +29,17 @@ class CockpitContractDictionaryTest {
     private static final String COCKPIT_PACKAGE = "com.zksy.web.controller.cockpit.";
     private static final String COCKPIT_PACKAGE = "com.zksy.web.controller.cockpit.";
 
 
     @Test
     @Test
-    void workOrderDictionaryIsTheSevenStateBaselineWithoutDeprecatedStates() {
+    void workOrderDictionaryMatchesRuntimeZeroToSevenStatesWithoutDeprecatedStates() {
         List<CockpitWorkOrderState> states = CockpitWorkOrderStateDictionary.states();
         List<CockpitWorkOrderState> states = CockpitWorkOrderStateDictionary.states();
-        assertEquals(7, states.size());
+        assertEquals(8, states.size());
         assertEquals(
         assertEquals(
-                Arrays.asList("待派单", "待接单", "已接单", "处理中", "待验收", "已结案", "已取消"),
+                Arrays.asList("待派单", "待接单", "已接单", "处理中", "待验收", "已结案", "延期中", "已取消"),
                 states.stream().map(CockpitWorkOrderState::getName).collect(Collectors.toList()));
                 states.stream().map(CockpitWorkOrderState::getName).collect(Collectors.toList()));
-        assertEquals(1, CockpitWorkOrderStateDictionary.byCode(1).getCode());
+        assertEquals(0, CockpitWorkOrderStateDictionary.byCode(0).getCode());
         assertEquals(7, CockpitWorkOrderStateDictionary.byCode(7).getCode());
         assertEquals(7, CockpitWorkOrderStateDictionary.byCode(7).getCode());
-        // Legacy codes converge on the same seven-state baseline instead of a ninth state.
+        // Legacy codes map onto the same 0-7 baseline instead of a ninth state.
+        assertEquals(0, CockpitWorkOrderStateDictionary.byLegacyCode(1).getCode());
+        assertEquals(5, CockpitWorkOrderStateDictionary.byLegacyCode(6).getCode());
         assertEquals(7, CockpitWorkOrderStateDictionary.byLegacyCode(7).getCode());
         assertEquals(7, CockpitWorkOrderStateDictionary.byLegacyCode(7).getCode());
         assertEquals(7, CockpitWorkOrderStateDictionary.byLegacyCode(99).getCode());
         assertEquals(7, CockpitWorkOrderStateDictionary.byLegacyCode(99).getCode());
     }
     }

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

@@ -82,9 +82,17 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[0].level").value(1))
                 .andExpect(jsonPath("$.data.sections.events.data[0].level").value(1))
                 .andExpect(jsonPath("$.data.sections.events.data[0].levelColor").value("RED"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].levelColor").value("RED"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].deviceCode").value("D-GAS-1"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].deviceCode").value("D-GAS-1"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].monitoringPointCode").value("MP-A-001"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].monitoringPointName").value("城区A-燃气1号点"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].coordinateValidation.reason").value(nullValue()))
+                .andExpect(jsonPath("$.data.sections.events.data[0].coordinateValidation.renderable").value(true))
+                .andExpect(jsonPath("$.data.sections.events.data[0].coordinateValidation.crs").value("WGS84"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMinValue").value(nullValue()))
+                .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMaxValue").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.workOrderCode").value("WO-1"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.workOrderCode").value("WO-1"))
-                .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.stateCode").value(4))
+                .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.stateCode").value(3))
                 .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.stateName").value("处理中"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.stateName").value("处理中"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].eventNo").value("W-002"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].eventNo").value("W-002"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].levelColor").value("YELLOW"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].levelColor").value("YELLOW"))
@@ -93,8 +101,15 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[2].levelColor").value("BLUE"))
                 .andExpect(jsonPath("$.data.sections.events.data[2].levelColor").value("BLUE"))
                 .andExpect(jsonPath("$.data.sections.events.data[3].eventNo").value("W-004"))
                 .andExpect(jsonPath("$.data.sections.events.data[3].eventNo").value("W-004"))
                 .andExpect(jsonPath("$.data.sections.events.data[3].levelColor").value("GREEN"))
                 .andExpect(jsonPath("$.data.sections.events.data[3].levelColor").value("GREEN"))
+                .andExpect(jsonPath("$.data.sections.events.data[3].coordinateValidation.state").value("INVALID"))
+                .andExpect(jsonPath("$.data.sections.events.data[3].coordinateValidation.reason").value("LONGITUDE_OUT_OF_RANGE"))
+                .andExpect(jsonPath("$.data.sections.events.data[3].coordinateValidation.renderable").value(false))
+                .andExpect(jsonPath("$.data.sections.events.data[3].monitoringPointCode").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[4].eventNo").value("W-005"))
                 .andExpect(jsonPath("$.data.sections.events.data[4].eventNo").value("W-005"))
                 .andExpect(jsonPath("$.data.sections.events.data[4].levelColor").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.sections.events.data[4].levelColor").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.state").value("UNVERIFIED"))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.reason").value("UNVERIFIED_COORDINATE"))
+                .andExpect(jsonPath("$.data.sections.events.data[4].coordinateValidation.renderable").value(false))
                 .andExpect(jsonPath("$.data.sections.events.data[4].workOrderSummary.stateName").value("已结案"))
                 .andExpect(jsonPath("$.data.sections.events.data[4].workOrderSummary.stateName").value("已结案"))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.sections.workOrders.reason").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.sections.workOrders.reason").value("SUCCESS"))
@@ -113,7 +128,13 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(jsonPath("$.data.sections.events.data[0].eventNo").value("A-001"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].eventNo").value("A-001"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].levelColor").value("RED"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].levelColor").value("RED"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.stateName").value("已取消"))
                 .andExpect(jsonPath("$.data.sections.events.data[0].workOrderSummary.stateName").value("已取消"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.sections.events.data[0].coordinateValidation.renderable").value(true))
+                .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMinValue").value(0.0))
+                .andExpect(jsonPath("$.data.sections.events.data[0].thresholdMaxValue").value(20.0))
                 .andExpect(jsonPath("$.data.sections.events.data[1].eventNo").value("A-002"))
                 .andExpect(jsonPath("$.data.sections.events.data[1].eventNo").value("A-002"))
+                .andExpect(jsonPath("$.data.sections.events.data[1].coordinateValidation.state").value("UNVERIFIED"))
+                .andExpect(jsonPath("$.data.sections.events.data[1].thresholdMinValue").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[1].workOrderSummary").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.events.data[1].workOrderSummary").value(nullValue()))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.sections.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)));
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)));
@@ -255,10 +276,10 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.data.message").value("level must be an integer between 1 and 4"));
                 .andExpect(jsonPath("$.data.message").value("level must be an integer between 1 and 4"));
 
 
-        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).param("mode", "WARNING").param("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.data.message").value(
                 .andExpect(jsonPath("$.data.message").value(
-                        "industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
 
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("mode", "WARNING")
                         .param("mode", "WARNING")
@@ -328,45 +349,45 @@ class CockpitCurrentEventsControllerTest {
                 Arrays.asList(
                 Arrays.asList(
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
-                                "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "城区A-燃气1号点",
-                                "GAS_SENSOR", "110.130", "28.440"),
+                                "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "MP-A-001", "城区A-燃气1号点", "城区A-燃气1号点",
+                                "GAS_SENSOR", "110.130", "28.440", true),
                         warning("W-002", "燃气压力预警", "GAS_PRESSURE", 2, "ACTIVE",
                         warning("W-002", "燃气压力预警", "GAS_PRESSURE", 2, "ACTIVE",
                                 "2026-09-02T09:45:00+08:00", "2026-09-02T09:46:00+08:00",
                                 "2026-09-02T09:45:00+08:00", "2026-09-02T09:46:00+08:00",
-                                "0.32", "MPa", "D-GAS-2", "燃气浓度计-002", "城区A-燃气2号点",
+                                "0.32", "MPa", "D-GAS-2", "燃气浓度计-002", "MP-A-002", "城区A-燃气2号点", "城区A-燃气2号点",
                                 "GAS_SENSOR", "110.131", "28.441"),
                                 "GAS_SENSOR", "110.131", "28.441"),
                         warning("W-003", "供水压力预警", "WATER_PRESSURE", 3, "PENDING",
                         warning("W-003", "供水压力预警", "WATER_PRESSURE", 3, "PENDING",
                                 "2026-09-02T09:40:00+08:00", "2026-09-02T09:41:00+08:00",
                                 "2026-09-02T09:40:00+08:00", "2026-09-02T09:41:00+08:00",
-                                "0.18", "MPa", "D-WS-1", "供水压力计-001", "城区B-供水1号点",
+                                "0.18", "MPa", "D-WS-1", "供水压力计-001", "MP-B-001", "城区B-供水1号点", "城区B-供水1号点",
                                 "WATER_SUPPLY_SENSOR", "110.150", "28.460"),
                                 "WATER_SUPPLY_SENSOR", "110.150", "28.460"),
                         warning("W-004", "污水浓度预警", "SEWAGE_CONCENTRATION", 4, "RELEASED",
                         warning("W-004", "污水浓度预警", "SEWAGE_CONCENTRATION", 4, "RELEASED",
                                 "2026-09-02T09:35:00+08:00", "2026-09-02T09:36:00+08:00",
                                 "2026-09-02T09:35:00+08:00", "2026-09-02T09:36:00+08:00",
-                                "18.2", "mg/L", "D-DR-1", "污水环境监测仪-001", "城区C-污水1号点",
-                                "SEWAGE_ENV", "110.170", "28.480"),
+                                "18.2", "mg/L", "D-DR-1", "污水环境监测仪-001", null, null, "城区C-污水1号点",
+                                "SEWAGE_ENV", "181", "28.480", true),
                         warning("W-005", "供水流量预警", "WATER_FLOW", 9, "ACTIVE",
                         warning("W-005", "供水流量预警", "WATER_FLOW", 9, "ACTIVE",
                                 "2026-09-02T09:30:00+08:00", "2026-09-02T09:31:00+08:00",
                                 "2026-09-02T09:30:00+08:00", "2026-09-02T09:31:00+08:00",
-                                "66.6", "L/s", "D-WS-2", "供水压力计-002", "城区B-供水2号点",
+                                "66.6", "L/s", "D-WS-2", "供水压力计-002", "MP-B-002", "城区B-供水2号点", "城区B-供水2号点",
                                 "WATER_SUPPLY_SENSOR", "110.151", "28.461"),
                                 "WATER_SUPPLY_SENSOR", "110.151", "28.461"),
                         warning("W-006", "未知类型预警", "LEGACY_EVENT", 1, "ACTIVE",
                         warning("W-006", "未知类型预警", "LEGACY_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
                                 "2026-09-02T09:55:00+08:00", null,
-                                "1", "x", "D-LEGACY", "旧设备", "旧位置",
+                                "1", "x", "D-LEGACY", "旧设备", null, null, "旧位置",
                                 "LEGACY", "110.100", "28.400"),
                                 "LEGACY", "110.100", "28.400"),
                         warning("W-007", "智能井盖预警", "MANHOLE_EVENT", 1, "ACTIVE",
                         warning("W-007", "智能井盖预警", "MANHOLE_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
                                 "2026-09-02T09:55:00+08:00", null,
-                                "1", "x", "D-MH", "智能井盖", "井盖位置",
+                                "1", "x", "D-MH", "智能井盖", null, null, "井盖位置",
                                 "SMART_MANHOLE", "110.101", "28.401")),
                                 "SMART_MANHOLE", "110.101", "28.401")),
                 Arrays.asList(
                 Arrays.asList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
-                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "城区A-燃气3号点",
-                                "GAS_SENSOR", "110.132", "28.442"),
+                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
+                                "GAS_SENSOR", "110.132", "28.442", true, "0.00", "20.00"),
                         alarm("A-002", "污水浓度报警", "SEWAGE_CONCENTRATION", 2, "PROCESSING",
                         alarm("A-002", "污水浓度报警", "SEWAGE_CONCENTRATION", 2, "PROCESSING",
                                 "2026-09-02T09:32:00+08:00", "2026-09-02T09:33:00+08:00",
                                 "2026-09-02T09:32:00+08:00", "2026-09-02T09:33:00+08:00",
-                                "42.1", "mg/L", "D-DR-1", "污水环境监测仪-001", "城区C-污水1号点",
+                                "42.1", "mg/L", "D-DR-1", "污水环境监测仪-001", "MP-C-001", "城区C-污水1号点", "城区C-污水1号点",
                                 "SEWAGE_ENV", "110.170", "28.480")),
                                 "SEWAGE_ENV", "110.170", "28.480")),
                 Arrays.asList(
                 Arrays.asList(
-                        workOrder("W-001", "WO-1", 2, "燃气浓度处置", 4),
+                        workOrder("W-001", "WO-1", 2, "燃气浓度处置", 3),
                         workOrder("W-003", "WO-2", 3, "供水压力处置", 1),
                         workOrder("W-003", "WO-2", 3, "供水压力处置", 1),
-                        workOrder("W-005", "WO-3", 2, "供水流量处置", 6),
+                        workOrder("W-005", "WO-3", 2, "供水流量处置", 5),
                         workOrder("A-001", "WO-4", 1, "燃气浓度处置", 7)));
                         workOrder("A-001", "WO-4", 1, "燃气浓度处置", 7)));
     }
     }
 
 
@@ -391,12 +412,12 @@ class CockpitCurrentEventsControllerTest {
                 Collections.singletonList(
                 Collections.singletonList(
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
                                 "2026-09-02T09:50:00+08:00", "2026-09-02T09:52:00+08:00",
-                                "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "城区A-燃气1号点",
+                                "12.5", "mg/m³", "D-GAS-1", "燃气浓度计-001", "MP-A-001", "城区A-燃气1号点", "城区A-燃气1号点",
                                 "GAS_SENSOR", "110.130", "28.440")),
                                 "GAS_SENSOR", "110.130", "28.440")),
                 Collections.singletonList(
                 Collections.singletonList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
-                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "城区A-燃气3号点",
+                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
                                 "GAS_SENSOR", "110.132", "28.442")),
                                 "GAS_SENSOR", "110.132", "28.442")),
                 Collections.emptyList());
                 Collections.emptyList());
     }
     }
@@ -414,23 +435,52 @@ class CockpitCurrentEventsControllerTest {
     private static CockpitCurrentEventsSnapshot.SourceWarning warning(
     private static CockpitCurrentEventsSnapshot.SourceWarning warning(
             String no, String name, String type, Integer level, String status,
             String no, String name, String type, Integer level, String status,
             String eventTime, String updatedTime, String value, String unit,
             String eventTime, String updatedTime, String value, String unit,
-            String deviceCode, String deviceName, String location, String typeCode,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
             String longitude, String latitude) {
             String longitude, String latitude) {
+        return warning(no, name, type, level, status, eventTime, updatedTime, value, unit,
+                deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, typeCode,
+                longitude, latitude, false);
+    }
+
+    private static CockpitCurrentEventsSnapshot.SourceWarning warning(
+            String no, String name, String type, Integer level, String status,
+            String eventTime, String updatedTime, String value, String unit,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
+            String longitude, String latitude, boolean coordinateVerified) {
         return new CockpitCurrentEventsSnapshot.SourceWarning(
         return new CockpitCurrentEventsSnapshot.SourceWarning(
                 no, name, type, level, status, eventTime, updatedTime,
                 no, name, type, level, status, eventTime, updatedTime,
-                new BigDecimal(value), unit, deviceCode, deviceName, location, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude));
+                new BigDecimal(value), unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
+                location, typeCode,
+                new BigDecimal(longitude), new BigDecimal(latitude), coordinateVerified);
     }
     }
 
 
     private static CockpitCurrentEventsSnapshot.SourceAlarm alarm(
     private static CockpitCurrentEventsSnapshot.SourceAlarm alarm(
             String id, String name, String type, Integer level, String status,
             String id, String name, String type, Integer level, String status,
             String eventTime, String updatedTime, String value, String unit,
             String eventTime, String updatedTime, String value, String unit,
-            String deviceCode, String deviceName, String location, String typeCode,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
             String longitude, String latitude) {
             String longitude, String latitude) {
+        return alarm(id, name, type, level, status, eventTime, updatedTime, value, unit,
+                deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, typeCode,
+                longitude, latitude, false, null, null);
+    }
+
+    private static CockpitCurrentEventsSnapshot.SourceAlarm alarm(
+            String id, String name, String type, Integer level, String status,
+            String eventTime, String updatedTime, String value, String unit,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
+            String longitude, String latitude,
+            boolean coordinateVerified, String thresholdMinValue, String thresholdMaxValue) {
         return new CockpitCurrentEventsSnapshot.SourceAlarm(
         return new CockpitCurrentEventsSnapshot.SourceAlarm(
                 id, name, type, level, status, eventTime, updatedTime,
                 id, name, type, level, status, eventTime, updatedTime,
-                new BigDecimal(value), unit, deviceCode, deviceName, location, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude));
+                new BigDecimal(value), unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
+                location, typeCode,
+                new BigDecimal(longitude), new BigDecimal(latitude), coordinateVerified,
+                thresholdMinValue == null ? null : new BigDecimal(thresholdMinValue),
+                thresholdMaxValue == null ? null : new BigDecimal(thresholdMaxValue));
     }
     }
 
 
     private static CockpitCurrentEventsSnapshot.SourceWorkOrderLink workOrder(
     private static CockpitCurrentEventsSnapshot.SourceWorkOrderLink workOrder(

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

@@ -58,7 +58,7 @@ class CockpitDeviceBoundaryControllerTest {
                 .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-001"))
                 .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-001"))
                 .andExpect(jsonPath("$.data.apiVersion").value("v1"))
                 .andExpect(jsonPath("$.data.apiVersion").value("v1"))
                 .andExpect(jsonPath("$.data.readOnly").value(true))
                 .andExpect(jsonPath("$.data.readOnly").value(true))
-                .andExpect(jsonPath("$.data.typeScope.formalIndustries", hasSize(3)))
+                .andExpect(jsonPath("$.data.typeScope.formalIndustries", hasSize(4)))
                 .andExpect(jsonPath("$.data.typeScope.drainageScopes", hasSize(2)))
                 .andExpect(jsonPath("$.data.typeScope.drainageScopes", hasSize(2)))
                 .andExpect(jsonPath("$.data.typeScope.objectKinds", hasSize(2)))
                 .andExpect(jsonPath("$.data.typeScope.objectKinds", hasSize(2)))
                 .andExpect(jsonPath("$.data.facilities", hasSize(3)))
                 .andExpect(jsonPath("$.data.facilities", hasSize(3)))
@@ -106,6 +106,7 @@ class CockpitDeviceBoundaryControllerTest {
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.smartDeviceCount").value(2))
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.smartDeviceCount").value(2))
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.rainwaterSmartDeviceCount").value(1))
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.rainwaterSmartDeviceCount").value(1))
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.sewageSmartDeviceCount").value(1))
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.sewageSmartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.manhole.smartDeviceCount").value(0))
                 .andReturn();
                 .andReturn();
 
 
         String body = result.getResponse().getContentAsString();
         String body = result.getResponse().getContentAsString();

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

@@ -55,7 +55,7 @@ class CockpitHandlingEfficiencyControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void warningFamilyExposesSummaryExplicitBasisSevenStatesTrendAndEvents() throws Exception {
+    void warningFamilyExposesSummaryExplicitBasisEightStatesTrendAndEvents() throws Exception {
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("eventFamily", "WARNING")
                         .param("startTime", START)
                         .param("startTime", START)
@@ -87,8 +87,8 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.summary.averageDuration.unit").value("HOURS"))
                 .andExpect(jsonPath("$.data.summary.averageDuration.unit").value("HOURS"))
                 .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(2))
                 .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(2))
                 .andExpect(jsonPath("$.data.summary.averageDuration.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.summary.averageDuration.state").value("AVAILABLE"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(7)))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateCode").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(8)))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateCode").value(0))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateName").value("待派单"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[0].stateName").value("待派单"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[0].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[0].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[1].stateName").value("待接单"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[1].stateName").value("待接单"))
@@ -101,8 +101,10 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].stateName").value("已结案"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].stateName").value("已结案"))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].stateName").value("已取消"))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(1))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[6].stateName").value("延期中"))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(0))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[7].stateName").value("已取消"))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1))
                 .andExpect(jsonPath("$.data.events", hasSize(8)))
                 .andExpect(jsonPath("$.data.events", hasSize(8)))
                 .andExpect(jsonPath("$.data.events[0].eventNo").value("EW-007"))
                 .andExpect(jsonPath("$.data.events[0].eventNo").value("EW-007"))
                 .andExpect(jsonPath("$.data.events[0].hasWorkOrder").value(true))
                 .andExpect(jsonPath("$.data.events[0].hasWorkOrder").value(true))
@@ -134,10 +136,11 @@ class CockpitHandlingEfficiencyControllerTest {
                         .param("startTime", START)
                         .param("startTime", START)
                         .param("endTime", END))
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(7)))
+                .andExpect(jsonPath("$.data.workOrderStateCounts", hasSize(8)))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[0].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[3].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
                 .andExpect(jsonPath("$.data.workOrderStateCounts[4].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(1));
+                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1));
     }
     }
 
 
     @Test
     @Test
@@ -182,7 +185,7 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
                 .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(0))
                 .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(0))
                 .andExpect(jsonPath("$.data.summary.cancelledCount").value(2))
                 .andExpect(jsonPath("$.data.summary.cancelledCount").value(2))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(2))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(2))
                 .andExpect(jsonPath("$.data.events[0].workOrderStateName").value("已取消"));
                 .andExpect(jsonPath("$.data.events[0].workOrderStateName").value("已取消"));
     }
     }
 
 
@@ -204,17 +207,17 @@ class CockpitHandlingEfficiencyControllerTest {
     void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {
     void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {
         mockMvc.perform(get(ENDPOINT)
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("eventFamily", "WARNING")
-                        .param("workOrderState", "6")
+                        .param("workOrderState", "5")
                         .param("startTime", START)
                         .param("startTime", START)
                         .param("endTime", END))
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.filters.workOrderState").value(6))
+                .andExpect(jsonPath("$.data.filters.workOrderState").value(5))
                 .andExpect(jsonPath("$.data.pagination.total").value(2))
                 .andExpect(jsonPath("$.data.pagination.total").value(2))
                 .andExpect(jsonPath("$.data.events", hasSize(2)))
                 .andExpect(jsonPath("$.data.events", hasSize(2)))
-                .andExpect(jsonPath("$.data.mapFilter.workOrderState").value(6))
+                .andExpect(jsonPath("$.data.mapFilter.workOrderState").value(5))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2));
+                .andExpect(jsonPath("$.data.workOrderStateCounts[5].count").value(2))
+                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1));
     }
     }
 
 
     @Test
     @Test
@@ -243,7 +246,7 @@ class CockpitHandlingEfficiencyControllerTest {
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
                 .andExpect(status().isBadRequest());
                 .andExpect(status().isBadRequest());
 
 
-        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest());
                 .andExpect(status().isBadRequest());
 
 
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("level", "9"))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("level", "9"))
@@ -253,7 +256,7 @@ class CockpitHandlingEfficiencyControllerTest {
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("workOrderState", "9"))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("workOrderState", "9"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.data.message").value(
                 .andExpect(jsonPath("$.data.message").value(
-                        "workOrderState must be an integer between 1 and 7"));
+                        "workOrderState must be an integer between 0 and 7"));
 
 
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("startTime", START))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("startTime", START))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
@@ -355,14 +358,14 @@ class CockpitHandlingEfficiencyControllerTest {
                         alarm("EA-001", 1, "2026-09-02T10:20:00+08:00", "GAS_SENSOR", "D-GAS-9", "燃气浓度计-009"),
                         alarm("EA-001", 1, "2026-09-02T10:20:00+08:00", "GAS_SENSOR", "D-GAS-9", "燃气浓度计-009"),
                         alarm("EA-002", 2, "2026-09-02T11:00:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-9", "供水压力计-009")),
                         alarm("EA-002", 2, "2026-09-02T11:00:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-9", "供水压力计-009")),
                 Arrays.asList(
                 Arrays.asList(
-                        link("EW-001", 6, "2026-09-02T08:10:00+08:00", "2026-09-02T10:10:00+08:00"),
-                        link("EW-002", 4, "2026-09-02T08:40:00+08:00", null),
-                        link("EW-003", 5, "2026-09-02T09:20:00+08:00", null),
-                        link("EW-004", 1, "2026-09-02T10:05:00+08:00", null),
-                        link("EW-006", 6, "2026-09-02T11:10:00+08:00", "2026-09-02T12:30:00+08:00"),
+                        link("EW-001", 5, "2026-09-02T08:10:00+08:00", "2026-09-02T10:10:00+08:00"),
+                        link("EW-002", 3, "2026-09-02T08:40:00+08:00", null),
+                        link("EW-003", 4, "2026-09-02T09:20:00+08:00", null),
+                        link("EW-004", 0, "2026-09-02T10:05:00+08:00", null),
+                        link("EW-006", 5, "2026-09-02T11:10:00+08:00", "2026-09-02T12:30:00+08:00"),
                         link("EW-007", 7, "2026-09-02T11:35:00+08:00", null),
                         link("EW-007", 7, "2026-09-02T11:35:00+08:00", null),
-                        link("EA-001", 6, "2026-09-02T10:20:00+08:00", "2026-09-02T14:20:00+08:00"),
-                        link("EA-002", 4, "2026-09-02T11:00:00+08:00", null)));
+                        link("EA-001", 5, "2026-09-02T10:20:00+08:00", "2026-09-02T14:20:00+08:00"),
+                        link("EA-002", 3, "2026-09-02T11:00:00+08:00", null)));
     }
     }
 
 
     private static CockpitHandlingEfficiencySnapshot fixtureWithLegacyStateCodes() {
     private static CockpitHandlingEfficiencySnapshot fixtureWithLegacyStateCodes() {
@@ -389,10 +392,10 @@ class CockpitHandlingEfficiencyControllerTest {
                         warning("EW-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
                         warning("EW-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
                 Collections.emptyList(),
                 Collections.emptyList(),
                 Arrays.asList(
                 Arrays.asList(
-                        link("EW-001", 6, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
-                        link("EW-002", 6, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
-                        link("EW-003", 6, "2026-09-02T10:00:00+08:00", null),
-                        link("EW-004", 4, "2026-09-02T11:00:00+08:00", null)));
+                        link("EW-001", 5, "2026-09-02T08:00:00+08:00", "2026-09-02T10:00:00+08:00"),
+                        link("EW-002", 5, "2026-09-02T09:00:00+08:00", "2026-09-02T11:00:00+08:00"),
+                        link("EW-003", 5, "2026-09-02T10:00:00+08:00", null),
+                        link("EW-004", 3, "2026-09-02T11:00:00+08:00", null)));
     }
     }
 
 
     private static java.util.List<CockpitHandlingEfficiencySnapshot.SourceType> types() {
     private static java.util.List<CockpitHandlingEfficiencySnapshot.SourceType> types() {

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

@@ -139,7 +139,7 @@ class CockpitHistoryAnalysisControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void handledScopeCountsOnlyClosedStateSixOrders() throws Exception {
+    void handledScopeCountsOnlyClosedStateFiveOrders() throws Exception {
         CockpitHistoryAnalysisResponse response = service("WARNING", START, END);
         CockpitHistoryAnalysisResponse response = service("WARNING", START, END);
         assertEquals(5, response.getSummary().getEventCount());
         assertEquals(5, response.getSummary().getEventCount());
         assertEquals(2, response.getSummary().getHandledCount());
         assertEquals(2, response.getSummary().getHandledCount());
@@ -211,7 +211,7 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.message").value(
                 .andExpect(jsonPath("$.data.message").value(
                         "level must be an integer between 1 and 4"));
                         "level must be an integer between 1 and 4"));
 
 
-        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest());
                 .andExpect(status().isBadRequest());
 
 
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("startTime", START))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("startTime", START))
@@ -319,12 +319,12 @@ class CockpitHistoryAnalysisControllerTest {
                         alarm("HA-001", 3, "2026-09-02T01:10:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-3"),
                         alarm("HA-001", 3, "2026-09-02T01:10:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-3"),
                         alarm("HA-002", 1, "2026-09-02T01:30:00+08:00", "GAS_SENSOR", "D-GAS-3")),
                         alarm("HA-002", 1, "2026-09-02T01:30:00+08:00", "GAS_SENSOR", "D-GAS-3")),
                 Arrays.asList(
                 Arrays.asList(
-                        workOrder("HW-001", 6),
+                        workOrder("HW-001", 5),
                         workOrder("HW-002", 4),
                         workOrder("HW-002", 4),
                         workOrder("HW-003", 1),
                         workOrder("HW-003", 1),
-                        workOrder("HW-004", 6),
+                        workOrder("HW-004", 5),
                         workOrder("HW-005", 7),
                         workOrder("HW-005", 7),
-                        workOrder("HA-001", 6)));
+                        workOrder("HA-001", 5)));
     }
     }
 
 
     private static CockpitHistoryAnalysisSnapshot.SourceType type(String code, String industry) {
     private static CockpitHistoryAnalysisSnapshot.SourceType type(String code, String industry) {

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

@@ -67,13 +67,15 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
                 .andExpect(jsonPath("$.data.totals.lengthKm").value(8.0))
                 .andExpect(jsonPath("$.data.totals.lengthKm").value(8.0))
                 .andExpect(jsonPath("$.data.totals.scale").value("medium"))
                 .andExpect(jsonPath("$.data.totals.scale").value("medium"))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.industryDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].code").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].code").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[1].code").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].code").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(3))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(3))
                 .andExpect(jsonPath("$.data.industryDistribution[2].code").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryDistribution[2].code").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[2].count").value(1))
+                .andExpect(jsonPath("$.data.industryDistribution[3].code").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.industryDistribution[3].count").value(0))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(5)))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(5)))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].code").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].code").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].count").value(1))
@@ -167,11 +169,11 @@ class CockpitInfrastructureBaseArchivesControllerTest {
     @Test
     @Test
     void invalidFiltersReturnTypedBadRequestBeforeAdapterInteraction() throws Exception {
     void invalidFiltersReturnTypedBadRequestBeforeAdapterInteraction() throws Exception {
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
-                        .queryParam("industry", "MANHOLE"))
+                        .queryParam("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE, MANHOLE"));
 
 
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
                         .queryParam("ageBucket", "OVER_5_YEARS"))
                         .queryParam("ageBucket", "OVER_5_YEARS"))
@@ -204,10 +206,11 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
                 .andExpect(jsonPath("$.data.totals.lengthKm").doesNotExist())
                 .andExpect(jsonPath("$.data.totals.lengthKm").doesNotExist())
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.industryDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].count").value(0))
                 .andExpect(jsonPath("$.data.industryDistribution[0].count").value(0))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(0))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(0))
                 .andExpect(jsonPath("$.data.industryDistribution[2].count").value(0))
                 .andExpect(jsonPath("$.data.industryDistribution[2].count").value(0))
+                .andExpect(jsonPath("$.data.industryDistribution[3].count").value(0))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(0)))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution", hasSize(0)))
                 .andExpect(jsonPath("$.data.ageDistribution[0].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[0].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[1].count").value(0))
                 .andExpect(jsonPath("$.data.ageDistribution[1].count").value(0))

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

@@ -73,9 +73,9 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.selectedFilters.industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.selectedFilters.area").value(nullValue()))
                 .andExpect(jsonPath("$.data.selectedFilters.area").value(nullValue()))
                 .andExpect(jsonPath("$.data.totals.emergencyResourceCount").value(3))
                 .andExpect(jsonPath("$.data.totals.emergencyResourceCount").value(3))
-                .andExpect(jsonPath("$.data.totals.hazardSourceCount").value(1))
+                .andExpect(jsonPath("$.data.totals.hazardSourceCount").value(2))
                 .andExpect(jsonPath("$.data.totals.protectionTargetCount").value(1))
                 .andExpect(jsonPath("$.data.totals.protectionTargetCount").value(1))
-                .andExpect(jsonPath("$.data.totals.totalObjectCount").value(5))
+                .andExpect(jsonPath("$.data.totals.totalObjectCount").value(6))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.state").value("READY"))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.state").value("READY"))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.stateReason").value("READY"))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.stateReason").value("READY"))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items", hasSize(3)))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items", hasSize(3)))
@@ -88,20 +88,22 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].eventAssociations.warningCount").value(2))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].eventAssociations.warningCount").value(2))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].eventAssociations.alarmCount").value(1))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].eventAssociations.alarmCount").value(1))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].eventAssociations.workOrderCount").value(3))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].eventAssociations.workOrderCount").value(3))
-                .andExpect(jsonPath("$.data.sections.hazardSources.items", hasSize(1)))
+                .andExpect(jsonPath("$.data.sections.hazardSources.items", hasSize(2)))
                 .andExpect(jsonPath("$.data.sections.hazardSources.state").value("READY"))
                 .andExpect(jsonPath("$.data.sections.hazardSources.state").value("READY"))
                 .andExpect(jsonPath("$.data.sections.protectionTargets.items", hasSize(1)))
                 .andExpect(jsonPath("$.data.sections.protectionTargets.items", hasSize(1)))
                 .andExpect(jsonPath("$.data.sections.protectionTargets.state").value("READY"))
                 .andExpect(jsonPath("$.data.sections.protectionTargets.state").value("READY"))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.industryDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[0].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(3))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(3))
                 .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[2].count").value(1))
+                .andExpect(jsonPath("$.data.industryDistribution[3].industry").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.industryDistribution[3].count").value(1))
                 .andExpect(jsonPath("$.data.areaDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.areaDistribution", hasSize(3)))
                 .andExpect(jsonPath("$.data.areaDistribution[0].area").value("CN"))
                 .andExpect(jsonPath("$.data.areaDistribution[0].area").value("CN"))
-                .andExpect(jsonPath("$.data.areaDistribution[0].count").value(2))
+                .andExpect(jsonPath("$.data.areaDistribution[0].count").value(3))
                 .andExpect(jsonPath("$.data.areaDistribution[1].area").value("TC"))
                 .andExpect(jsonPath("$.data.areaDistribution[1].area").value("TC"))
                 .andExpect(jsonPath("$.data.areaDistribution[1].count").value(2))
                 .andExpect(jsonPath("$.data.areaDistribution[1].count").value(2))
                 .andExpect(jsonPath("$.data.areaDistribution[2].area").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.areaDistribution[2].area").value("UNKNOWN"))
@@ -110,7 +112,7 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.categoryDistribution[0].category").value("EMERGENCY_RESOURCE"))
                 .andExpect(jsonPath("$.data.categoryDistribution[0].category").value("EMERGENCY_RESOURCE"))
                 .andExpect(jsonPath("$.data.categoryDistribution[0].count").value(3))
                 .andExpect(jsonPath("$.data.categoryDistribution[0].count").value(3))
                 .andExpect(jsonPath("$.data.categoryDistribution[1].category").value("HAZARD_SOURCE"))
                 .andExpect(jsonPath("$.data.categoryDistribution[1].category").value("HAZARD_SOURCE"))
-                .andExpect(jsonPath("$.data.categoryDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.categoryDistribution[1].count").value(2))
                 .andExpect(jsonPath("$.data.categoryDistribution[2].category").value("PROTECTION_TARGET"))
                 .andExpect(jsonPath("$.data.categoryDistribution[2].category").value("PROTECTION_TARGET"))
                 .andExpect(jsonPath("$.data.categoryDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.categoryDistribution[2].count").value(1))
                 .andExpect(jsonPath("$.data.eventSummary.warningCount").value(3))
                 .andExpect(jsonPath("$.data.eventSummary.warningCount").value(3))
@@ -119,9 +121,9 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.category").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.category").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.area").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.area").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.objectCodes", hasSize(5)))
+                .andExpect(jsonPath("$.data.mapFilter.objectCodes", hasSize(6)))
                 .andExpect(jsonPath("$.data.excluded.unknownCategoryCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.unknownCategoryCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.duplicateObjectCount").value(1));
                 .andExpect(jsonPath("$.data.excluded.duplicateObjectCount").value(1));
     }
     }
 
 
@@ -226,11 +228,11 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.message")
                 .andExpect(jsonPath("$.data.message")
                         .value("category must be EMERGENCY_RESOURCE, HAZARD_SOURCE or PROTECTION_TARGET"));
                         .value("category must be EMERGENCY_RESOURCE, HAZARD_SOURCE or PROTECTION_TARGET"));
 
 
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
 
         verifyNoInteractions(adapter);
         verifyNoInteractions(adapter);
     }
     }

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

@@ -71,11 +71,11 @@ class CockpitInfrastructureManagementInformationControllerTest {
                 .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.selectedFilters.organizationCode").value(nullValue()))
-                .andExpect(jsonPath("$.data.totals.organizationCount").value(4))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .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.equipmentCount").value(4))
                 .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2))
                 .andExpect(jsonPath("$.data.totals.unmaintainedCount").value(2))
-                .andExpect(jsonPath("$.data.organizations", hasSize(4)))
+                .andExpect(jsonPath("$.data.organizations", hasSize(5)))
                 .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
                 .andExpect(jsonPath("$.data.organizations[0].organizationCode").value("ORG-WS-A"))
                 .andExpect(jsonPath("$.data.organizations[0].organizationName").value("供水一公司"))
                 .andExpect(jsonPath("$.data.organizations[0].organizationName").value("供水一公司"))
                 .andExpect(jsonPath("$.data.organizations[0].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.organizations[0].industry").value("WATER_SUPPLY"))
@@ -94,19 +94,20 @@ class CockpitInfrastructureManagementInformationControllerTest {
                 .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.organizations[2].ownershipDistribution[1].ownershipType").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.ownershipDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.ownershipDistribution[0].ownershipType").value("OWNED"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[0].ownershipType").value("OWNED"))
-                .andExpect(jsonPath("$.data.ownershipDistribution[0].facilityCount").value(4))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].facilityCount").value(5))
                 .andExpect(jsonPath("$.data.ownershipDistribution[0].equipmentCount").value(1))
                 .andExpect(jsonPath("$.data.ownershipDistribution[0].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.ownershipDistribution[0].total").value(5))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].total").value(6))
                 .andExpect(jsonPath("$.data.ownershipDistribution[1].ownershipType").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[1].ownershipType").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[1].total").value(2))
                 .andExpect(jsonPath("$.data.ownershipDistribution[1].total").value(2))
                 .andExpect(jsonPath("$.data.ownershipDistribution[2].ownershipType").value("ENTRUSTED"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[2].ownershipType").value("ENTRUSTED"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[3].ownershipType").value("LEASED"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[3].ownershipType").value("LEASED"))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(4)))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(5)))
                 .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-WS-A"))
                 .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-WS-A"))
                 .andExpect(jsonPath("$.data.mapFilter.organizationCodes[3]").value("UNMAINTAINED"))
                 .andExpect(jsonPath("$.data.mapFilter.organizationCodes[3]").value("UNMAINTAINED"))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[4]").value("ORG-MH-A"))
                 .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(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.unknownObjectKindCount").value(1));
                 .andExpect(jsonPath("$.data.excluded.unknownObjectKindCount").value(1));
     }
     }
 
 
@@ -133,7 +134,7 @@ class CockpitInfrastructureManagementInformationControllerTest {
 
 
         mockMvc.perform(get(ENDPOINT))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(6))
                 .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
                 .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
                 .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1));
                 .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").value(1));
     }
     }
@@ -146,7 +147,7 @@ class CockpitInfrastructureManagementInformationControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
                 .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
                 .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
                 .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(6))
                 .andExpect(jsonPath("$.data.totals.equipmentCount").value(4));
                 .andExpect(jsonPath("$.data.totals.equipmentCount").value(4));
     }
     }
 
 
@@ -224,11 +225,11 @@ class CockpitInfrastructureManagementInformationControllerTest {
 
 
     @Test
     @Test
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
 
         verifyNoInteractions(adapter);
         verifyNoInteractions(adapter);
     }
     }

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

@@ -73,7 +73,7 @@ class CockpitInfrastructureOperatingInformationControllerTest {
                 .andExpect(jsonPath("$.data.selectedIndustry").value(nullValue()))
                 .andExpect(jsonPath("$.data.selectedIndustry").value(nullValue()))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
-                .andExpect(jsonPath("$.data.industries", hasSize(3)))
+                .andExpect(jsonPath("$.data.industries", hasSize(4)))
                 .andExpect(jsonPath("$.data.industries[0].industry.code").value("GAS"))
                 .andExpect(jsonPath("$.data.industries[0].industry.code").value("GAS"))
                 .andExpect(jsonPath("$.data.industries[0].industry.key").value("gas"))
                 .andExpect(jsonPath("$.data.industries[0].industry.key").value("gas"))
                 .andExpect(jsonPath("$.data.industries[0].industry.label").value("燃气"))
                 .andExpect(jsonPath("$.data.industries[0].industry.label").value("燃气"))
@@ -100,7 +100,7 @@ class CockpitInfrastructureOperatingInformationControllerTest {
                 .andExpect(jsonPath("$.data.industries[0].metrics[1].abnormal").value(false))
                 .andExpect(jsonPath("$.data.industries[0].metrics[1].abnormal").value(false))
                 .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
                 .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1));
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
     }
     }
 
 
     @Test
     @Test
@@ -160,7 +160,7 @@ class CockpitInfrastructureOperatingInformationControllerTest {
                 .andExpect(jsonPath("$.data.selectedIndustry.code").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.selectedIndustry.code").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.selectedIndustry.key").value("drainage"))
                 .andExpect(jsonPath("$.data.selectedIndustry.key").value("drainage"))
                 .andExpect(jsonPath("$.data.selectedIndustry.label").value("排水"))
                 .andExpect(jsonPath("$.data.selectedIndustry.label").value("排水"))
-                .andExpect(jsonPath("$.data.industries", hasSize(3)))
+                .andExpect(jsonPath("$.data.industries", hasSize(4)))
                 .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
                 .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
                 .andExpect(jsonPath("$.data.industries[0].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.industries[0].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.industries[2].metrics", hasSize(1)));
                 .andExpect(jsonPath("$.data.industries[2].metrics", hasSize(1)));
@@ -193,11 +193,11 @@ class CockpitInfrastructureOperatingInformationControllerTest {
 
 
     @Test
     @Test
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
 
         verifyNoInteractions(adapter);
         verifyNoInteractions(adapter);
     }
     }
@@ -238,12 +238,13 @@ class CockpitInfrastructureOperatingInformationControllerTest {
 
 
         mockMvc.perform(get(ENDPOINT))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.industries", hasSize(3)))
+                .andExpect(jsonPath("$.data.industries", hasSize(4)))
                 .andExpect(jsonPath("$.data.industries[0].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.industries[0].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.industries[0].stateReason").value("NO_METRICS"))
                 .andExpect(jsonPath("$.data.industries[0].stateReason").value("NO_METRICS"))
                 .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
                 .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
                 .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
                 .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
                 .andExpect(jsonPath("$.data.industries[2].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.industries[2].state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.industries[3].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("NO_METRICS"));
                 .andExpect(jsonPath("$.data.freshness.reason").value("NO_METRICS"));
     }
     }

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

@@ -22,6 +22,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.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;
@@ -70,8 +71,11 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
                 .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].deviceCodes", hasSize(0)))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.monitoringPoints[1].pointCode").value("MP-002"))
+                .andExpect(jsonPath("$.data.monitoringPoints[1].deviceCodes[0]").value("EQ-ASSOC-001"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.renderable").value(false))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.renderable").value(false))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.state").value("INVALID"))
                 .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.state").value("INVALID"))
@@ -80,13 +84,118 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(7))
                 .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(7))
                 .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].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].typeCode").value("WATER_SUPPLY_SENSOR"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].typeName").value("供水压力计"))
+                .andExpect(jsonPath("$.data.monitoringPoints[2].region").value("未分区"))
+                .andExpect(jsonPath("$.data.monitoringPoints[2].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].region").value("城区B"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].typeCode").value("WATER_SUPPLY_SENSOR"))
+                .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.region").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(3)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)));
+    }
+
+    @Test
+    void industryFilterNarrowsMarkersAndEchoesEffectiveScope() throws Exception {
+        when(adapter.load(null)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("industry", "WATER_SUPPLY"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(2))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
+                .andExpect(jsonPath("$.data.mapFilter.industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(1)))
+                .andExpect(jsonPath("$.data.mapFilter.pointCodes[0]").value("MP-001"))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(1)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes[0]").value("EQ-FREE-001"))
+                .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(2))
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT));
+    }
+
+    @Test
+    void regionFilterMatchesNormalizedUnassignedRegion() throws Exception {
+        when(adapter.load(null)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("region", "未分区"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(2))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-003"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].region").value("未分区"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-002"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].industry").value(nullValue()))
+                .andExpect(jsonPath("$.data.mapFilter.region").value("未分区"));
+    }
+
+    @Test
+    void deviceTypeFilterMatchesTypeCode() throws Exception {
+        when(adapter.load(null)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("deviceType", "GAS_SENSOR"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(2))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-002"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-004"))
+                .andExpect(jsonPath("$.data.mapFilter.deviceType").value("GAS_SENSOR"));
+    }
+
+    @Test
+    void filtersKeepSnapshotLockAndPagingSemantics() throws Exception {
+        when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("industry", "WATER_SUPPLY")
+                        .param("page", "1")
+                        .param("pageSize", "1")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(2))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(0)))
+                .andExpect(jsonPath("$.data.complete").value(false));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("industry", "WATER_SUPPLY")
+                        .param("page", "2")
+                        .param("pageSize", "1")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.total").value(2))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
+                .andExpect(jsonPath("$.data.complete").value(true));
+    }
+
+    @Test
+    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot").param("industry", "ELECTRICITY"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
+
+        verifyNoInteractions(adapter);
     }
     }
 
 
     @Test
     @Test
     void mapSummaryResolvesPointStatusAcrossAllSixStatesFromDeviceFacts() throws Exception {
     void mapSummaryResolvesPointStatusAcrossAllSixStatesFromDeviceFacts() throws Exception {
         when(adapter.load(null)).thenReturn(new CockpitMapSnapshot(
         when(adapter.load(null)).thenReturn(new CockpitMapSnapshot(
                 SNAPSHOT_AT,
                 SNAPSHOT_AT,
+                types(),
                 Arrays.asList(
                 Arrays.asList(
                         sourcePoint("MP-ALARM", Collections.singletonList(
                         sourcePoint("MP-ALARM", Collections.singletonList(
                                 device("EQ-ALARM", true, false, false, true,
                                 device("EQ-ALARM", true, false, false, true,
@@ -264,10 +373,13 @@ class CockpitMapSnapshotControllerTest {
     private static CockpitMapSnapshot fixture() {
     private static CockpitMapSnapshot fixture() {
         return new CockpitMapSnapshot(
         return new CockpitMapSnapshot(
                 SNAPSHOT_AT,
                 SNAPSHOT_AT,
+                types(),
                 Arrays.asList(
                 Arrays.asList(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-002",
                                 "MP-002",
                                 "燃气调压站监测点",
                                 "燃气调压站监测点",
+                                "城区A",
+                                "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 new BigDecimal("28.453"),
                                 true,
                                 true,
@@ -276,6 +388,8 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-001",
                                 "MP-001",
                                 "供水干管监测点",
                                 "供水干管监测点",
+                                "城区B",
+                                "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("110.141"),
                                 new BigDecimal("110.141"),
                                 new BigDecimal("28.452"),
                                 new BigDecimal("28.452"),
                                 true,
                                 true,
@@ -283,6 +397,8 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-003",
                                 "MP-003",
                                 "污水排口监测点",
                                 "污水排口监测点",
+                                null,
+                                "SEWAGE_ENV",
                                 new BigDecimal("110.143"),
                                 new BigDecimal("110.143"),
                                 new BigDecimal("28.454"),
                                 new BigDecimal("28.454"),
                                 false,
                                 false,
@@ -291,40 +407,69 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-ASSOC-001",
                                 "EQ-ASSOC-001",
                                 "燃气浓度计-001",
                                 "燃气浓度计-001",
+                                "城区A",
+                                "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 new BigDecimal("28.453"),
                                 true),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-002",
                                 "EQ-FREE-002",
                                 "智能井盖-002",
                                 "智能井盖-002",
+                                null,
+                                null,
                                 new BigDecimal("110.145"),
                                 new BigDecimal("110.145"),
                                 new BigDecimal("28.456"),
                                 new BigDecimal("28.456"),
                                 true),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-001",
                                 "EQ-FREE-001",
                                 "供水压力计-001",
                                 "供水压力计-001",
+                                "城区B",
+                                "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("181"),
                                 new BigDecimal("181"),
                                 new BigDecimal("28.455"),
                                 new BigDecimal("28.455"),
                                 true),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-003",
                                 "EQ-FREE-003",
                                 "污水环境监测仪-003",
                                 "污水环境监测仪-003",
+                                "城区C",
+                                "SEWAGE_ENV",
                                 new BigDecimal("110.146"),
                                 new BigDecimal("110.146"),
                                 new BigDecimal("28.457"),
                                 new BigDecimal("28.457"),
                                 false),
                                 false),
                         new CockpitMapSnapshot.SourceSmartDevice(
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-004",
                                 "EQ-FREE-004",
                                 "零坐标设备-004",
                                 "零坐标设备-004",
+                                "城区A",
+                                "GAS_SENSOR",
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
                                 true)));
                                 true)));
     }
     }
 
 
+    private static List<CockpitMapSnapshot.SourceType> types() {
+        return Arrays.asList(
+                new CockpitMapSnapshot.SourceType(
+                        "GAS_SENSOR", "燃气浓度计", "GAS", "SMART_DEVICE"),
+                new CockpitMapSnapshot.SourceType(
+                        "WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", "SMART_DEVICE"),
+                new CockpitMapSnapshot.SourceType(
+                        "SEWAGE_ENV", "污水环境监测", "DRAINAGE", "SMART_DEVICE"),
+                new CockpitMapSnapshot.SourceType(
+                        "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"));
+    }
+
     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);
+    }
+
+    private static CockpitMapSnapshot.SourceMonitoringPoint sourcePoint(String pointCode, String region,
+            String typeCode, List<CockpitMonitoringPointDeviceSnapshot> devices) {
         return new CockpitMapSnapshot.SourceMonitoringPoint(
         return new CockpitMapSnapshot.SourceMonitoringPoint(
                 pointCode,
                 pointCode,
                 pointCode + "监测点",
                 pointCode + "监测点",
+                region,
+                typeCode,
                 new BigDecimal("110.141"),
                 new BigDecimal("110.141"),
                 new BigDecimal("28.452"),
                 new BigDecimal("28.452"),
                 true,
                 true,

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

@@ -228,12 +228,12 @@ class CockpitMonitoringDistributionControllerTest {
 
 
     @Test
     @Test
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).param("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.message").value(
                 .andExpect(jsonPath("$.data.message").value(
-                        "industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
 
         verifyNoInteractions(adapter);
         verifyNoInteractions(adapter);
     }
     }
@@ -328,7 +328,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
                 "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 new CockpitMonitoringDistributionDeviceStatusDelta(
                 new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "GAS-003", "ONLINE", "2026-09-02T09:59:00+08:00",
+                        "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:05:00+08:00"));
                         "2026-09-02T10:05:00+08:00"));
 
 
         assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
         assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
@@ -337,7 +337,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
                 "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 new CockpitMonitoringDistributionDeviceStatusDelta(
                 new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "GAS-003", "OFFLINE", "2026-09-02T09:59:00+08:00",
+                        "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:04:00+08:00"));
                         "2026-09-02T10:04:00+08:00"));
 
 
         assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
         assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
@@ -346,7 +346,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
                 "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 new CockpitMonitoringDistributionDeviceStatusDelta(
                 new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "NO-SUCH-DEVICE", "ONLINE", "2026-09-02T09:59:00+08:00",
+                        "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:06:00+08:00"));
                         "2026-09-02T10:06:00+08:00"));
 
 
         assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
         assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));

+ 29 - 22
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java

@@ -67,24 +67,24 @@ class CockpitMonitoringOverviewControllerTest {
                 .andExpect(jsonPath("$.data.displayName").value("监测概览"))
                 .andExpect(jsonPath("$.data.displayName").value("监测概览"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
                 .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(3))
                 .andExpect(jsonPath("$.data.counts.monitoringPointCount").value(3))
-                .andExpect(jsonPath("$.data.counts.equipmentCount").value(5))
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(6))
                 .andExpect(jsonPath("$.data.counts.facilityCount").value(0))
                 .andExpect(jsonPath("$.data.counts.facilityCount").value(0))
-                .andExpect(jsonPath("$.data.status.onlineCount").value(2))
+                .andExpect(jsonPath("$.data.status.onlineCount").value(3))
                 .andExpect(jsonPath("$.data.status.offlineCount").value(1))
                 .andExpect(jsonPath("$.data.status.offlineCount").value(1))
                 .andExpect(jsonPath("$.data.status.staleCount").value(1))
                 .andExpect(jsonPath("$.data.status.staleCount").value(1))
                 .andExpect(jsonPath("$.data.status.unknownOrMissingCount").value(1))
                 .andExpect(jsonPath("$.data.status.unknownOrMissingCount").value(1))
-                .andExpect(jsonPath("$.data.status.denominator").value(4))
-                .andExpect(jsonPath("$.data.onlineRate.value").value("0.5000"))
-                .andExpect(jsonPath("$.data.onlineRate.numerator").value(2))
-                .andExpect(jsonPath("$.data.onlineRate.denominator").value(4))
+                .andExpect(jsonPath("$.data.status.denominator").value(5))
+                .andExpect(jsonPath("$.data.onlineRate.value").value("0.6000"))
+                .andExpect(jsonPath("$.data.onlineRate.numerator").value(3))
+                .andExpect(jsonPath("$.data.onlineRate.denominator").value(5))
                 .andExpect(jsonPath("$.data.onlineRate.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.onlineRate.state").value("AVAILABLE"))
-                .andExpect(jsonPath("$.data.typeDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.typeDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.typeDistribution[0].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.typeDistribution[0].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.typeDistribution[0].onlineCount").value(1))
                 .andExpect(jsonPath("$.data.typeDistribution[0].onlineCount").value(1))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(5)))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(6)))
                 .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.nonFormalIndustryCount").value(1));
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
     }
     }
 
 
     @Test
     @Test
@@ -160,19 +160,26 @@ class CockpitMonitoringOverviewControllerTest {
         mockMvc.perform(get(ENDPOINT))
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.status.staleCount").value(1))
                 .andExpect(jsonPath("$.data.status.staleCount").value(1))
-                .andExpect(jsonPath("$.data.status.denominator").value(4))
-                .andExpect(jsonPath("$.data.onlineRate.numerator").value(2));
+                .andExpect(jsonPath("$.data.status.denominator").value(5))
+                .andExpect(jsonPath("$.data.onlineRate.numerator").value(3));
     }
     }
 
 
     @Test
     @Test
-    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+    void manholeIsAFormalIndustryAndUnknownIndustryIsRejected() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
         mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
         mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.status.onlineCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
+
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "HEAT"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
-
-        verifyNoInteractions(adapter);
+                .andExpect(jsonPath("$.data.message").value(
+                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
     }
     }
 
 
     @Test
     @Test
@@ -202,21 +209,21 @@ class CockpitMonitoringOverviewControllerTest {
         CockpitMonitoringOverviewResponse first = service.applyDeviceStatusDelta(
         CockpitMonitoringOverviewResponse first = service.applyDeviceStatusDelta(
                 "delta-1", null, null,
                 "delta-1", null, null,
                 new CockpitMonitoringOverviewDeviceStatusDelta(
                 new CockpitMonitoringOverviewDeviceStatusDelta(
-                        "GAS-002", "ONLINE", "2026-09-02T09:59:00+08:00",
+                        "GAS-002", null, "ONLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:05:00+08:00"));
                         "2026-09-02T10:05:00+08:00"));
 
 
-        assertEquals(3, first.getStatus().getOnlineCount());
+        assertEquals(4, first.getStatus().getOnlineCount());
         assertEquals(0, first.getStatus().getOfflineCount());
         assertEquals(0, first.getStatus().getOfflineCount());
-        assertEquals(4, first.getStatus().getDenominator());
-        assertEquals("0.7500", first.getOnlineRate().getValue().toPlainString());
+        assertEquals(5, first.getStatus().getDenominator());
+        assertEquals("0.8000", first.getOnlineRate().getValue().toPlainString());
 
 
         CockpitMonitoringOverviewResponse second = service.applyDeviceStatusDelta(
         CockpitMonitoringOverviewResponse second = service.applyDeviceStatusDelta(
                 "delta-2", null, null,
                 "delta-2", null, null,
                 new CockpitMonitoringOverviewDeviceStatusDelta(
                 new CockpitMonitoringOverviewDeviceStatusDelta(
-                        "GAS-002", "OFFLINE", "2026-09-02T09:59:00+08:00",
+                        "GAS-002", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:04:00+08:00"));
                         "2026-09-02T10:04:00+08:00"));
 
 
-        assertEquals(3, second.getStatus().getOnlineCount());
+        assertEquals(4, second.getStatus().getOnlineCount());
         assertEquals(0, second.getStatus().getOfflineCount());
         assertEquals(0, second.getStatus().getOfflineCount());
     }
     }
 
 
@@ -246,7 +253,7 @@ class CockpitMonitoringOverviewControllerTest {
                         type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY"),
                         type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY"),
                         type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
                         type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
                         new CockpitMonitoringOverviewSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                         new CockpitMonitoringOverviewSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
-                        type("SMART_MANHOLE", "智能井盖", null)),
+                        type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                 Arrays.asList(
                         device("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1"),
                         device("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1"),
                         device("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2"),
                         device("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2"),

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

@@ -81,7 +81,7 @@ class CockpitMonitoringSituationControllerTest {
                 .andExpect(jsonPath("$.data.range.endTime").value(END))
                 .andExpect(jsonPath("$.data.range.endTime").value(END))
                 .andExpect(jsonPath("$.data.metricStatusDistribution", hasSize(6)))
                 .andExpect(jsonPath("$.data.metricStatusDistribution", hasSize(6)))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[0].status").value("NORMAL"))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[0].status").value("NORMAL"))
-                .andExpect(jsonPath("$.data.metricStatusDistribution[0].count").value(2))
+                .andExpect(jsonPath("$.data.metricStatusDistribution[0].count").value(3))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[1].status").value("WARNING"))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[1].status").value("WARNING"))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[1].count").value(1))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[1].count").value(1))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[2].status").value("ALARM"))
                 .andExpect(jsonPath("$.data.metricStatusDistribution[2].status").value("ALARM"))
@@ -103,7 +103,7 @@ class CockpitMonitoringSituationControllerTest {
                 .andExpect(jsonPath("$.data.alarmTrend[0].count").value(1))
                 .andExpect(jsonPath("$.data.alarmTrend[0].count").value(1))
                 .andExpect(jsonPath("$.data.alarmTrend[1].count").value(1))
                 .andExpect(jsonPath("$.data.alarmTrend[1].count").value(1))
                 .andExpect(jsonPath("$.data.alarmTrend[2].count").value(0))
                 .andExpect(jsonPath("$.data.alarmTrend[2].count").value(0))
-                .andExpect(jsonPath("$.data.industryCoverage", hasSize(3)))
+                .andExpect(jsonPath("$.data.industryCoverage", hasSize(4)))
                 .andExpect(jsonPath("$.data.industryCoverage[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryCoverage[0].industry").value("GAS"))
                 .andExpect(jsonPath("$.data.industryCoverage[0].deviceCount").value(4))
                 .andExpect(jsonPath("$.data.industryCoverage[0].deviceCount").value(4))
                 .andExpect(jsonPath("$.data.industryCoverage[0].abnormalCount").value(3))
                 .andExpect(jsonPath("$.data.industryCoverage[0].abnormalCount").value(3))
@@ -113,6 +113,9 @@ class CockpitMonitoringSituationControllerTest {
                 .andExpect(jsonPath("$.data.industryCoverage[2].industry").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryCoverage[2].industry").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.industryCoverage[2].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.industryCoverage[2].deviceCount").value(2))
                 .andExpect(jsonPath("$.data.industryCoverage[2].abnormalCount").value(0))
                 .andExpect(jsonPath("$.data.industryCoverage[2].abnormalCount").value(0))
+                .andExpect(jsonPath("$.data.industryCoverage[3].industry").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.industryCoverage[3].deviceCount").value(1))
+                .andExpect(jsonPath("$.data.industryCoverage[3].abnormalCount").value(0))
                 .andExpect(jsonPath("$.data.abnormalEquipment", hasSize(5)))
                 .andExpect(jsonPath("$.data.abnormalEquipment", hasSize(5)))
                 .andExpect(jsonPath("$.data.abnormalEquipment[0].deviceCode").value("D-EXCL-GAS"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[0].deviceCode").value("D-EXCL-GAS"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[0].abnormalType").value("ALARM"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[0].abnormalType").value("ALARM"))
@@ -130,7 +133,7 @@ class CockpitMonitoringSituationControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(4)))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(4)))
                 .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.nonFormalIndustryCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(1))
                 .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(1))
                 .andExpect(jsonPath("$.data.freshness.status").value("STALE"))
                 .andExpect(jsonPath("$.data.freshness.status").value("STALE"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("STALE_SAMPLES"));
                 .andExpect(jsonPath("$.data.freshness.reason").value("STALE_SAMPLES"));
@@ -240,15 +243,23 @@ class CockpitMonitoringSituationControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "MANHOLE"))
+    void manholeIsAFormalIndustryAndUnknownIndustryIsRejected() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("industry", "MANHOLE")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.industryCoverage", hasSize(1)))
+                .andExpect(jsonPath("$.data.industryCoverage[0].industry").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.industryCoverage[0].deviceCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
+
+        mockMvc.perform(get(ENDPOINT).param("industry", "HEAT"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.message").value(
                 .andExpect(jsonPath("$.data.message").value(
-                        "industry must be GAS, WATER_SUPPLY or DRAINAGE"));
-
-        verifyNoInteractions(adapter);
+                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
     }
     }
 
 
     @Test
     @Test
@@ -327,37 +338,37 @@ class CockpitMonitoringSituationControllerTest {
         CockpitMonitoringSituationResponse initial = service.load(
         CockpitMonitoringSituationResponse initial = service.load(
                 "delta-baseline", null, null, START, END, 1, 100);
                 "delta-baseline", null, null, START, END, 1, 100);
         assertEquals(2, distributionCount(initial, "ALARM"));
         assertEquals(2, distributionCount(initial, "ALARM"));
-        assertEquals(2, distributionCount(initial, "NORMAL"));
+        assertEquals(3, distributionCount(initial, "NORMAL"));
         assertEquals(5, initial.getAbnormalEquipment().size());
         assertEquals(5, initial.getAbnormalEquipment().size());
 
 
         CockpitMonitoringSituationResponse applied = service.applyDeviceStatusDelta(
         CockpitMonitoringSituationResponse applied = service.applyDeviceStatusDelta(
                 "delta-1", null, null, START, END, 1, 100,
                 "delta-1", null, null, START, END, 1, 100,
                 new CockpitMonitoringSituationDeviceStatusDelta(
                 new CockpitMonitoringSituationDeviceStatusDelta(
-                        "D-GAS-3", "NORMAL", "2026-09-02T09:59:00+08:00",
+                        "D-GAS-3", null, "NORMAL", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:05:00+08:00"));
                         "2026-09-02T10:05:00+08:00"));
 
 
         assertEquals(1, distributionCount(applied, "ALARM"));
         assertEquals(1, distributionCount(applied, "ALARM"));
-        assertEquals(3, distributionCount(applied, "NORMAL"));
+        assertEquals(4, distributionCount(applied, "NORMAL"));
         assertEquals(4, applied.getAbnormalEquipment().size());
         assertEquals(4, applied.getAbnormalEquipment().size());
         assertEquals(2, applied.getIndustryCoverage().get(0).getAbnormalCount());
         assertEquals(2, applied.getIndustryCoverage().get(0).getAbnormalCount());
 
 
         CockpitMonitoringSituationResponse ignoredOld = service.applyDeviceStatusDelta(
         CockpitMonitoringSituationResponse ignoredOld = service.applyDeviceStatusDelta(
                 "delta-2", null, null, START, END, 1, 100,
                 "delta-2", null, null, START, END, 1, 100,
                 new CockpitMonitoringSituationDeviceStatusDelta(
                 new CockpitMonitoringSituationDeviceStatusDelta(
-                        "D-GAS-3", "ALARM", "2026-09-02T09:59:00+08:00",
+                        "D-GAS-3", null, "ALARM", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:04:00+08:00"));
                         "2026-09-02T10:04:00+08:00"));
 
 
         assertEquals(1, distributionCount(ignoredOld, "ALARM"));
         assertEquals(1, distributionCount(ignoredOld, "ALARM"));
-        assertEquals(3, distributionCount(ignoredOld, "NORMAL"));
+        assertEquals(4, distributionCount(ignoredOld, "NORMAL"));
 
 
         CockpitMonitoringSituationResponse ignoredUnknown = service.applyDeviceStatusDelta(
         CockpitMonitoringSituationResponse ignoredUnknown = service.applyDeviceStatusDelta(
                 "delta-3", null, null, START, END, 1, 100,
                 "delta-3", null, null, START, END, 1, 100,
                 new CockpitMonitoringSituationDeviceStatusDelta(
                 new CockpitMonitoringSituationDeviceStatusDelta(
-                        "NO-SUCH-DEVICE", "ALARM", "2026-09-02T09:59:00+08:00",
+                        "NO-SUCH-DEVICE", null, "ALARM", "2026-09-02T09:59:00+08:00",
                         "2026-09-02T10:06:00+08:00"));
                         "2026-09-02T10:06:00+08:00"));
 
 
         assertEquals(1, distributionCount(ignoredUnknown, "ALARM"));
         assertEquals(1, distributionCount(ignoredUnknown, "ALARM"));
-        assertEquals(3, distributionCount(ignoredUnknown, "NORMAL"));
+        assertEquals(4, distributionCount(ignoredUnknown, "NORMAL"));
     }
     }
 
 
     private int distributionCount(CockpitMonitoringSituationResponse response, String status) {
     private int distributionCount(CockpitMonitoringSituationResponse response, String status) {
@@ -394,8 +405,7 @@ class CockpitMonitoringSituationControllerTest {
                         type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
                         type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
                         new CockpitMonitoringSituationSnapshot.SourceType(
                         new CockpitMonitoringSituationSnapshot.SourceType(
                                 "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
                                 "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
-                        new CockpitMonitoringSituationSnapshot.SourceType(
-                                "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
+                        type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                 Arrays.asList(
                         device("D-GAS-1", "燃气浓度计-001", "城区A", "GAS_SENSOR",
                         device("D-GAS-1", "燃气浓度计-001", "城区A", "GAS_SENSOR",
                                 "NORMAL", Collections.emptyList(), "110.130", "28.440", true),
                                 "NORMAL", Collections.emptyList(), "110.130", "28.440", true),

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

@@ -278,7 +278,7 @@ class CockpitMultidimensionalAnalysisControllerTest {
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
                 .andExpect(status().isBadRequest());
                 .andExpect(status().isBadRequest());
 
 
-        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest());
                 .andExpect(status().isBadRequest());
 
 
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("level", "9"))
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("level", "9"))

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

@@ -85,11 +85,11 @@ class CockpitOverviewMonitoringControllerTest {
     @Test
     @Test
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(get("/api/cockpit/v1/overview/monitoring")
         mockMvc.perform(get("/api/cockpit/v1/overview/monitoring")
-                        .queryParam("industry", "MANHOLE"))
+                        .queryParam("industry", "WHATEVER"))
                 .andExpect(status().isBadRequest())
                 .andExpect(status().isBadRequest())
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
 
         verifyNoInteractions(adapter);
         verifyNoInteractions(adapter);
     }
     }

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

@@ -121,7 +121,7 @@ class CockpitOverviewMonitoringServiceTest {
                 "request-delta-001",
                 "request-delta-001",
                 null,
                 null,
                 new CockpitOverviewMonitoringDeviceStatusDelta(
                 new CockpitOverviewMonitoringDeviceStatusDelta(
-                        "GAS-002", "ONLINE", null, "2026-09-02T10:05:00+08:00"));
+                        "GAS-002", null, "ONLINE", null, "2026-09-02T10:05:00+08:00"));
 
 
         assertEquals(Integer.valueOf(2), first.getCounts().getStatus().getOnline());
         assertEquals(Integer.valueOf(2), first.getCounts().getStatus().getOnline());
         assertEquals(Integer.valueOf(0), first.getCounts().getStatus().getOffline());
         assertEquals(Integer.valueOf(0), first.getCounts().getStatus().getOffline());
@@ -134,7 +134,7 @@ class CockpitOverviewMonitoringServiceTest {
                 "request-delta-002",
                 "request-delta-002",
                 null,
                 null,
                 new CockpitOverviewMonitoringDeviceStatusDelta(
                 new CockpitOverviewMonitoringDeviceStatusDelta(
-                        "GAS-002", "OFFLINE", null, "2026-09-02T10:04:00+08:00"));
+                        "GAS-002", null, "OFFLINE", null, "2026-09-02T10:04:00+08:00"));
 
 
         assertEquals(Integer.valueOf(2), second.getCounts().getStatus().getOnline());
         assertEquals(Integer.valueOf(2), second.getCounts().getStatus().getOnline());
         assertEquals(Integer.valueOf(0), second.getCounts().getStatus().getOffline());
         assertEquals(Integer.valueOf(0), second.getCounts().getStatus().getOffline());

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

@@ -22,6 +22,8 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
 import java.lang.reflect.Method;
 import java.lang.reflect.Method;
 
 
+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;
@@ -62,6 +64,10 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.selectedMode").value("WARNING"))
                 .andExpect(jsonPath("$.data.selectedMode").value("WARNING"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
                 .andExpect(jsonPath("$.data.range.type").value("CURRENT_BASELINE"))
                 .andExpect(jsonPath("$.data.range.type").value("CURRENT_BASELINE"))
+                .andExpect(jsonPath("$.data.range.start").value(nullValue()))
+                .andExpect(jsonPath("$.data.range.end").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.trend", hasSize(4)))
+                .andExpect(jsonPath("$.data.warning.trend[0].bucket").value("2026-09-01"))
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
@@ -106,6 +112,86 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.label").value("报警"));
                 .andExpect(jsonPath("$.data.mapFilter.label").value("报警"));
     }
     }
 
 
+    @Test
+    void rangePresetDeclaresWindowAndFiltersTrendBuckets() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("range", "LAST_7_DAYS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.type").value("LAST_7_DAYS"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-08-26T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.end").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.warning.trend", hasSize(2)))
+                .andExpect(jsonPath("$.data.warning.trend[0].bucket").value("2026-09-01"))
+                .andExpect(jsonPath("$.data.warning.trend[1].bucket").value("2026-09-02"))
+                .andExpect(jsonPath("$.data.alarm.trend", hasSize(2)));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("range", "LAST_30_DAYS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.type").value("LAST_30_DAYS"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-08-03T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.warning.trend", hasSize(3)))
+                .andExpect(jsonPath("$.data.warning.trend[0].bucket").value("2026-09-01"))
+                .andExpect(jsonPath("$.data.warning.trend[1].bucket").value("2026-09-02"))
+                .andExpect(jsonPath("$.data.warning.trend[2].bucket").value("2026-08-20"));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("range", "LAST_24_HOURS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.type").value("LAST_24_HOURS"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-09-01T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.warning.trend", hasSize(2)));
+    }
+
+    @Test
+    void explicitWindowEchoesCustomRangeAndFiltersTrendBuckets() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("startTime", "2026-08-01T00:00:00+08:00")
+                        .queryParam("endTime", "2026-08-31T23:59:59+08:00"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.type").value("CUSTOM"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-08-01T00:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.end").value("2026-08-31T23:59:59+08:00"))
+                .andExpect(jsonPath("$.data.warning.trend", hasSize(2)))
+                .andExpect(jsonPath("$.data.warning.trend[0].bucket").value("2026-08-20"))
+                .andExpect(jsonPath("$.data.warning.trend[1].bucket").value("2026-08-01"));
+    }
+
+    @Test
+    void invalidWindowParametersReturnTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("range", "LAST_YEAR"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message")
+                        .value("range must be LAST_24_HOURS, LAST_7_DAYS or LAST_30_DAYS"));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("startTime", "2026-08-01T00:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message")
+                        .value("startTime and endTime must be provided together"));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("startTime", "2026-08-31T00:00:00+08:00")
+                        .queryParam("endTime", "2026-08-01T00:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message")
+                        .value("startTime must not be after endTime"));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("startTime", "not-a-timestamp")
+                        .queryParam("endTime", "2026-08-01T00:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("invalid startTime"));
+
+        verifyNoInteractions(adapter);
+    }
+
     @Test
     @Test
     void invalidModeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
     void invalidModeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
         mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
@@ -183,10 +269,18 @@ class CockpitOverviewWarningSettingsControllerTest {
                                 "WARNING", "2026-09-01", 2, 1),
                                 "WARNING", "2026-09-01", 2, 1),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "WARNING", "2026-09-02", 1, 0),
                                 "WARNING", "2026-09-02", 1, 0),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-08-20", 3, 2),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-08-01", 1, 0),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "ALARM", "2026-09-01", 1, 0),
                                 "ALARM", "2026-09-01", 1, 0),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
-                                "ALARM", "2026-09-02", 1, 0)));
+                                "ALARM", "2026-09-02", 1, 0),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "ALARM", "2026-08-20", 2, 1),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "ALARM", "2026-08-01", 1, 1)));
     }
     }
 }
 }
 
 

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

@@ -32,37 +32,40 @@ class CockpitOverviewWarningSettingsServiceTest {
     }
     }
 
 
     @Test
     @Test
-    void currentSevenStateModelSeparatesHandledPendingAndUnlinkedEvents() {
-        when(adapter.load()).thenReturn(sevenStateFixture());
+    void currentEightStateModelSeparatesHandledPendingAndUnlinkedEvents() {
+        when(adapter.load()).thenReturn(eightStateFixture());
 
 
-        CockpitOverviewWarningSettingsResponse response = service.load("request-seven", "WARNING");
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-eight", "WARNING", null, null, null);
 
 
         assertEquals(Integer.valueOf(10), response.getWarning().getTotal());
         assertEquals(Integer.valueOf(10), response.getWarning().getTotal());
-        assertEquals(Integer.valueOf(2), response.getWarning().getHandled());
-        assertEquals(Integer.valueOf(5), response.getWarning().getPending());
+        assertEquals(Integer.valueOf(1), response.getWarning().getHandled());
+        assertEquals(Integer.valueOf(6), response.getWarning().getPending());
         assertEquals(Integer.valueOf(1), response.getWarning().getCancelledCount());
         assertEquals(Integer.valueOf(1), response.getWarning().getCancelledCount());
-        assertEquals("0.2000", response.getWarning().getHandlingEfficiency().getValue().toPlainString());
-        assertEquals(Integer.valueOf(2), response.getWarning().getHandlingEfficiency().getNumerator());
+        assertEquals("0.1000", response.getWarning().getHandlingEfficiency().getValue().toPlainString());
+        assertEquals(Integer.valueOf(1), response.getWarning().getHandlingEfficiency().getNumerator());
         assertEquals(Integer.valueOf(10), response.getWarning().getHandlingEfficiency().getDenominator());
         assertEquals(Integer.valueOf(10), response.getWarning().getHandlingEfficiency().getDenominator());
         assertEquals(Integer.valueOf(1), response.getWarning().getUnlinkedEventCount());
         assertEquals(Integer.valueOf(1), response.getWarning().getUnlinkedEventCount());
         assertEquals(Integer.valueOf(1), response.getWarning().getInvalidWorkOrderStateCount());
         assertEquals(Integer.valueOf(1), response.getWarning().getInvalidWorkOrderStateCount());
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(0));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(1));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(1));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(2));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(2));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(3));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(3));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(4));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(4));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(5));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(5));
-        assertEquals(Integer.valueOf(2), response.getWarning().getWorkOrderStates().get(6));
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(6));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(7));
         assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(7));
-        assertEquals(7, response.getWarning().getWorkOrderStates().size());
+        assertEquals(8, response.getWarning().getWorkOrderStates().size());
         assertTrue(response.getWarning().getWorkOrderStates().keySet().stream()
         assertTrue(response.getWarning().getWorkOrderStates().keySet().stream()
-                .allMatch(code -> code >= 1 && code <= 7));
+                .allMatch(code -> code >= 0 && code <= 7));
     }
     }
 
 
     @Test
     @Test
     void emptyFamilyUsesExplicitEmptyStateAndZeroDenominatorEfficiency() {
     void emptyFamilyUsesExplicitEmptyStateAndZeroDenominatorEfficiency() {
         when(adapter.load()).thenReturn(emptyFixture());
         when(adapter.load()).thenReturn(emptyFixture());
 
 
-        CockpitOverviewWarningSettingsResponse response = service.load("request-empty", "ALARM");
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-empty", "ALARM", null, null, null);
 
 
         assertEquals("EMPTY", response.getAlarm().getState());
         assertEquals("EMPTY", response.getAlarm().getState());
         assertEquals("NO_EVENTS", response.getAlarm().getStateReason());
         assertEquals("NO_EVENTS", response.getAlarm().getStateReason());
@@ -80,7 +83,8 @@ class CockpitOverviewWarningSettingsServiceTest {
     void missingSnapshotUsesExplicitEmptyFreshness() {
     void missingSnapshotUsesExplicitEmptyFreshness() {
         when(adapter.load()).thenReturn(null);
         when(adapter.load()).thenReturn(null);
 
 
-        CockpitOverviewWarningSettingsResponse response = service.load("request-missing", "WARNING");
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-missing", "WARNING", null, null, null);
 
 
         assertEquals("EMPTY", response.getFreshness().getStatus());
         assertEquals("EMPTY", response.getFreshness().getStatus());
         assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
         assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
@@ -88,22 +92,22 @@ class CockpitOverviewWarningSettingsServiceTest {
         assertEquals("EMPTY", response.getWarning().getState());
         assertEquals("EMPTY", response.getWarning().getState());
     }
     }
 
 
-    private static CockpitOverviewWarningSettingsSnapshot sevenStateFixture() {
+    private static CockpitOverviewWarningSettingsSnapshot eightStateFixture() {
         return new CockpitOverviewWarningSettingsSnapshot(
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
                 Arrays.asList(
+                        event("WARNING", "WRN-000", "WO-000", 0),
                         event("WARNING", "WRN-001", "WO-001", 1),
                         event("WARNING", "WRN-001", "WO-001", 1),
                         event("WARNING", "WRN-002", "WO-002", 2),
                         event("WARNING", "WRN-002", "WO-002", 2),
                         event("WARNING", "WRN-003", "WO-003", 3),
                         event("WARNING", "WRN-003", "WO-003", 3),
                         event("WARNING", "WRN-004", "WO-004", 4),
                         event("WARNING", "WRN-004", "WO-004", 4),
                         event("WARNING", "WRN-005", "WO-005", 5),
                         event("WARNING", "WRN-005", "WO-005", 5),
                         event("WARNING", "WRN-006", "WO-006", 6),
                         event("WARNING", "WRN-006", "WO-006", 6),
-                        event("WARNING", "WRN-007", "WO-007", 6),
-                        event("WARNING", "WRN-008", "WO-008", 7),
+                        event("WARNING", "WRN-007", "WO-007", 7),
                         new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
                         new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-009", "DEV-009", null, null),
-                        event("WARNING", "WRN-010", "WO-010", 9),
-                        event("ALARM", "ALM-001", "WO-101", 4),
+                                "WARNING", "WRN-008", "DEV-008", null, null),
+                        event("WARNING", "WRN-009", "WO-009", 9),
+                        event("ALARM", "ALM-001", "WO-101", 3),
                         event("ALARM", "ALM-002", "WO-102", 5)),
                         event("ALARM", "ALM-002", "WO-102", 5)),
                 Collections.emptyList());
                 Collections.emptyList());
     }
     }

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

@@ -51,7 +51,7 @@ class CockpitWorkOrderReadControllerTest {
     }
     }
 
 
     @Test
     @Test
-    void deviceWorkOrdersReturnSevenStateDictionary() throws Exception {
+    void deviceWorkOrdersReturnEightStateDictionary() throws Exception {
         when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(sevenStateFixture());
         when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(sevenStateFixture());
 
 
         mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders")
         mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/work-orders")
@@ -62,17 +62,19 @@ class CockpitWorkOrderReadControllerTest {
                 .andExpect(jsonPath("$.data.apiVersion").value("v1"))
                 .andExpect(jsonPath("$.data.apiVersion").value("v1"))
                 .andExpect(jsonPath("$.data.readOnly").value(true))
                 .andExpect(jsonPath("$.data.readOnly").value(true))
                 .andExpect(jsonPath("$.data.deviceCode").value(DEVICE_CODE))
                 .andExpect(jsonPath("$.data.deviceCode").value(DEVICE_CODE))
-                .andExpect(jsonPath("$.data.stateDictionary", hasSize(7)))
-                .andExpect(jsonPath("$.data.stateDictionary[0].code").value(1))
+                .andExpect(jsonPath("$.data.stateDictionary", hasSize(8)))
+                .andExpect(jsonPath("$.data.stateDictionary[0].code").value(0))
                 .andExpect(jsonPath("$.data.stateDictionary[0].name").value("待派单"))
                 .andExpect(jsonPath("$.data.stateDictionary[0].name").value("待派单"))
-                .andExpect(jsonPath("$.data.stateDictionary[5].code").value(6))
+                .andExpect(jsonPath("$.data.stateDictionary[5].code").value(5))
                 .andExpect(jsonPath("$.data.stateDictionary[5].name").value("已结案"))
                 .andExpect(jsonPath("$.data.stateDictionary[5].name").value("已结案"))
-                .andExpect(jsonPath("$.data.stateDictionary[6].code").value(7))
-                .andExpect(jsonPath("$.data.stateDictionary[6].name").value("已取消"))
+                .andExpect(jsonPath("$.data.stateDictionary[6].code").value(6))
+                .andExpect(jsonPath("$.data.stateDictionary[6].name").value("延期中"))
+                .andExpect(jsonPath("$.data.stateDictionary[7].code").value(7))
+                .andExpect(jsonPath("$.data.stateDictionary[7].name").value("已取消"))
                 .andExpect(jsonPath("$.data.stateDictionary[0].legacyCode").doesNotExist())
                 .andExpect(jsonPath("$.data.stateDictionary[0].legacyCode").doesNotExist())
                 .andExpect(jsonPath("$.data.stateDictionary[0].deprecated").doesNotExist())
                 .andExpect(jsonPath("$.data.stateDictionary[0].deprecated").doesNotExist())
                 .andExpect(jsonPath("$.data.workOrders", hasSize(7)))
                 .andExpect(jsonPath("$.data.workOrders", hasSize(7)))
-                .andExpect(jsonPath("$.data.workOrders[0].stateCode").value(1))
+                .andExpect(jsonPath("$.data.workOrders[0].stateCode").value(0))
                 .andExpect(jsonPath("$.data.workOrders[0].stateName").value("待派单"))
                 .andExpect(jsonPath("$.data.workOrders[0].stateName").value("待派单"))
                 .andExpect(jsonPath("$.data.workOrders[6].stateCode").value(7))
                 .andExpect(jsonPath("$.data.workOrders[6].stateCode").value(7))
                 .andExpect(jsonPath("$.data.workOrders[6].stateName").value("已取消"))
                 .andExpect(jsonPath("$.data.workOrders[6].stateName").value("已取消"))
@@ -131,12 +133,12 @@ class CockpitWorkOrderReadControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.requestId").value("timeline-001"))
                 .andExpect(jsonPath("$.data.requestId").value("timeline-001"))
                 .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200001"))
                 .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200001"))
-                .andExpect(jsonPath("$.data.stateDictionary", hasSize(7)))
+                .andExpect(jsonPath("$.data.stateDictionary", hasSize(8)))
                 .andExpect(jsonPath("$.data.timelineEntries", hasSize(3)))
                 .andExpect(jsonPath("$.data.timelineEntries", hasSize(3)))
                 .andExpect(jsonPath("$.data.timelineEntries[0].workOrderCode").value("WO-2026090200001"))
                 .andExpect(jsonPath("$.data.timelineEntries[0].workOrderCode").value("WO-2026090200001"))
-                .andExpect(jsonPath("$.data.timelineEntries[0].stateCode").value(1))
+                .andExpect(jsonPath("$.data.timelineEntries[0].stateCode").value(0))
                 .andExpect(jsonPath("$.data.timelineEntries[0].stateName").value("待派单"))
                 .andExpect(jsonPath("$.data.timelineEntries[0].stateName").value("待派单"))
-                .andExpect(jsonPath("$.data.timelineEntries[2].stateCode").value(3))
+                .andExpect(jsonPath("$.data.timelineEntries[2].stateCode").value(2))
                 .andExpect(jsonPath("$.data.timelineEntries[2].stateName").value("已接单"))
                 .andExpect(jsonPath("$.data.timelineEntries[2].stateName").value("已接单"))
                 .andExpect(jsonPath("$.data.timelineEntries[0].legacyState").doesNotExist());
                 .andExpect(jsonPath("$.data.timelineEntries[0].legacyState").doesNotExist());
 
 
@@ -146,7 +148,7 @@ class CockpitWorkOrderReadControllerTest {
                 .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200002"))
                 .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200002"))
                 .andExpect(jsonPath("$.data.timelineEntries", hasSize(1)))
                 .andExpect(jsonPath("$.data.timelineEntries", hasSize(1)))
                 .andExpect(jsonPath("$.data.timelineEntries[0].workOrderCode").value("WO-2026090200002"))
                 .andExpect(jsonPath("$.data.timelineEntries[0].workOrderCode").value("WO-2026090200002"))
-                .andExpect(jsonPath("$.data.timelineEntries[0].stateCode").value(6))
+                .andExpect(jsonPath("$.data.timelineEntries[0].stateCode").value(5))
                 .andExpect(jsonPath("$.data.timelineEntries[0].stateName").value("已结案"));
                 .andExpect(jsonPath("$.data.timelineEntries[0].stateName").value("已结案"));
     }
     }
 
 

+ 65 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/websocket/DeviceStatusCockpitWebSocketHandlerTest.java

@@ -31,6 +31,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
     private DeviceStatusWebSocketHandler handler;
     private DeviceStatusWebSocketHandler handler;
     private WebSocketSession session;
     private WebSocketSession session;
     private EquipmentStatusService equipmentStatusService;
     private EquipmentStatusService equipmentStatusService;
+    private com.zksy.base.service.EquipmentBaseService equipmentBaseService;
+    private com.zksy.base.mapper.EquipmentPointRelMapper equipmentPointRelMapper;
     private SubscriptionManager subscriptionManager;
     private SubscriptionManager subscriptionManager;
 
 
     @BeforeEach
     @BeforeEach
@@ -43,6 +45,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         org.mockito.Mockito.doReturn(attributes).when(session).getAttributes();
         org.mockito.Mockito.doReturn(attributes).when(session).getAttributes();
 
 
         equipmentStatusService = mock(EquipmentStatusService.class);
         equipmentStatusService = mock(EquipmentStatusService.class);
+        equipmentBaseService = mock(com.zksy.base.service.EquipmentBaseService.class);
+        equipmentPointRelMapper = mock(com.zksy.base.mapper.EquipmentPointRelMapper.class);
         subscriptionManager = new SubscriptionManager();
         subscriptionManager = new SubscriptionManager();
         ReflectionTestUtils.setField(handler, "equipmentStatusService", equipmentStatusService);
         ReflectionTestUtils.setField(handler, "equipmentStatusService", equipmentStatusService);
         ReflectionTestUtils.setField(handler, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(handler, "subscriptionManager", subscriptionManager);
@@ -80,6 +84,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
         DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
         ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
         ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(listener, "equipmentBaseService", equipmentBaseService);
+        ReflectionTestUtils.setField(listener, "equipmentPointRelMapper", equipmentPointRelMapper);
         ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
         ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
         ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
         ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
         listener.onMessage(message("{\"deviceCode\":\"" + DEVICE_CODE + "\","
         listener.onMessage(message("{\"deviceCode\":\"" + DEVICE_CODE + "\","
@@ -99,6 +105,63 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         assertEquals("2026-09-02T10:00:00+08:00", delta.getJSONObject("data").getString("serverUpdatedAt"));
         assertEquals("2026-09-02T10:00:00+08:00", delta.getJSONObject("data").getString("serverUpdatedAt"));
     }
     }
 
 
+    @Test
+    void cockpitDeltaCarriesMonitoringPointCodeWhenLinked() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+
+        com.zksy.base.domain.EquipmentBase equipment = new com.zksy.base.domain.EquipmentBase();
+        equipment.setEquipmentId("EQ-ID-1");
+        when(equipmentBaseService.getOne(org.mockito.ArgumentMatchers.any()))
+                .thenReturn(equipment);
+        java.util.Map<String, Object> rel = new java.util.HashMap<>();
+        rel.put("pointCode", "MP-001");
+        rel.put("pointName", "燃气1号点");
+        when(equipmentPointRelMapper.selectPointRelByEquipmentId("EQ-ID-1"))
+                .thenReturn(java.util.Collections.singletonList(rel));
+
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:00+08:00")), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject delta = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitDeviceStatusDelta".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(delta);
+        assertEquals("MP-001", delta.getString("monitoringPointCode"));
+    }
+
+    @Test
+    void cockpitDeltaKeepsExplicitNullPointCodeWhenUnlinked() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        when(equipmentBaseService.getOne(org.mockito.ArgumentMatchers.any()))
+                .thenReturn(null);
+
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:00+08:00")), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject delta = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitDeviceStatusDelta".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(delta);
+        assertTrue(delta.containsKey("monitoringPointCode"));
+        assertTrue(delta.get("monitoringPointCode") == null);
+    }
+
     @Test
     @Test
     void existingDeviceCodeSubscriptionStillReceivesLegacyPush() throws Exception {
     void existingDeviceCodeSubscriptionStillReceivesLegacyPush() throws Exception {
         handler.afterConnectionEstablished(session);
         handler.afterConnectionEstablished(session);
@@ -291,6 +354,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
         DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
         ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
         ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(listener, "equipmentBaseService", equipmentBaseService);
+        ReflectionTestUtils.setField(listener, "equipmentPointRelMapper", equipmentPointRelMapper);
         ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
         ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
         ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
         ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
         return listener;
         return listener;

+ 19 - 12
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java

@@ -1306,18 +1306,25 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
             }
             }
         }
         }
 
 
-        // 排水模块的顶级名称在历史数据中可能不是"排水",按稳定 type_id 兼容查询。
-        if (typeIds.isEmpty() && StringUtils.isNotEmpty(typeName) && typeName.contains("排水")) {
-            List<EquipmentType> drainageTypes = equipmentTypeMapper.selectList(
-                    new LambdaQueryWrapper<EquipmentType>()
-                            .eq(EquipmentType::getId, EquipmentTypeEnum.ROOT_DRAINAGE_DEVICE.getId())
-                            .eq(EquipmentType::getParentId, 0)
-                            .last("LIMIT 1"));
-            if (CollUtil.isNotEmpty(drainageTypes)) {
-                String drainageId = String.valueOf(drainageTypes.get(0).getId());
-                if (StringUtils.isNotEmpty(drainageId)) {
-                    typeIds.add(drainageId);
-                    parentIds.add(drainageId);
+        // 当前库的顶级名称是“供水设备”/“排水设备”,旧配置仍可能传“供水”/“排水”。
+        // 名称未命中时只回退到对应的规范根主键,且必须校验它确实是根节点。
+        if (typeIds.isEmpty() && StringUtils.isNotEmpty(typeName)) {
+            Integer rootId = null;
+            if (typeName.contains("供水")) {
+                rootId = EquipmentTypeEnum.ROOT_WATER_SUPPLY_DEVICE.getId();
+            } else if (typeName.contains("排水")) {
+                rootId = EquipmentTypeEnum.ROOT_DRAINAGE_DEVICE.getId();
+            }
+            if (rootId != null) {
+                List<EquipmentType> roots = equipmentTypeMapper.selectList(
+                        new LambdaQueryWrapper<EquipmentType>()
+                                .eq(EquipmentType::getId, rootId)
+                                .eq(EquipmentType::getParentId, 0)
+                                .last("LIMIT 1"));
+                if (CollUtil.isNotEmpty(roots) && roots.get(0).getId() != null) {
+                    String resolvedId = String.valueOf(roots.get(0).getId());
+                    typeIds.add(resolvedId);
+                    parentIds.add(resolvedId);
                 }
                 }
             }
             }
         }
         }

+ 12 - 11
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/MonitoringServiceImpl.java

@@ -66,7 +66,7 @@ public class MonitoringServiceImpl implements MonitoringService {
             return emptyPage(pageNum, pageSize);
             return emptyPage(pageNum, pageSize);
         }
         }
 
 
-        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = selectTypeMap(devices);
         Map<String, RadarData> radarMap = selectLatestRadar(devices, startTime, endTime, flowMin, flowMax);
         Map<String, RadarData> radarMap = selectLatestRadar(devices, startTime, endTime, flowMin, flowMax);
         Map<String, TelemetryData> telemetryMap = selectLatestTelemetry(devices, startTime, endTime, flowMin, flowMax);
         Map<String, TelemetryData> telemetryMap = selectLatestTelemetry(devices, startTime, endTime, flowMin, flowMax);
 
 
@@ -111,7 +111,7 @@ public class MonitoringServiceImpl implements MonitoringService {
             return emptyPage(pageNum, pageSize);
             return emptyPage(pageNum, pageSize);
         }
         }
 
 
-        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = selectTypeMap(devices);
         Map<String, FirefightingPressure> pressureMap = new HashMap<>();
         Map<String, FirefightingPressure> pressureMap = new HashMap<>();
         List<String> deviceCodes = deviceCodes(devices);
         List<String> deviceCodes = deviceCodes(devices);
         if (!deviceCodes.isEmpty()) {
         if (!deviceCodes.isEmpty()) {
@@ -151,7 +151,7 @@ public class MonitoringServiceImpl implements MonitoringService {
             return emptyPage(pageNum, pageSize);
             return emptyPage(pageNum, pageSize);
         }
         }
 
 
-        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = selectTypeMap(devices);
         Map<String, NoiseInfo> noiseMap = new HashMap<>();
         Map<String, NoiseInfo> noiseMap = new HashMap<>();
         List<String> deviceCodes = deviceCodes(devices);
         List<String> deviceCodes = deviceCodes(devices);
         if (!deviceCodes.isEmpty()) {
         if (!deviceCodes.isEmpty()) {
@@ -291,9 +291,9 @@ public class MonitoringServiceImpl implements MonitoringService {
                 .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
                 .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
                 .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
                 .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
                 .orderByDesc(EquipmentBase::getCreateTime);
                 .orderByDesc(EquipmentBase::getCreateTime);
-        Set<String> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
+        Set<Integer> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
         wrapper.in(EquipmentBase::getEquipmentTypeId,
         wrapper.in(EquipmentBase::getEquipmentTypeId,
-                monitoringTypeIds.isEmpty() ? Collections.singleton("__NO_WATER_MONITORING_TYPE__") : monitoringTypeIds);
+                monitoringTypeIds.isEmpty() ? Collections.singleton(-1) : monitoringTypeIds);
         return equipmentBaseMapper.selectList(wrapper);
         return equipmentBaseMapper.selectList(wrapper);
     }
     }
 
 
@@ -302,11 +302,11 @@ public class MonitoringServiceImpl implements MonitoringService {
         if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
         if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
             return null;
             return null;
         }
         }
-        Set<String> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
+        Set<Integer> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
         return monitoringTypeIds.contains(device.getEquipmentTypeId()) ? device : null;
         return monitoringTypeIds.contains(device.getEquipmentTypeId()) ? device : null;
     }
     }
 
 
-    private Map<String, EquipmentType> selectTypeMap(List<EquipmentBase> devices) {
+    private Map<Integer, EquipmentType> selectTypeMap(List<EquipmentBase> devices) {
         boolean anyTypePresent = devices.stream()
         boolean anyTypePresent = devices.stream()
                 .map(EquipmentBase::getEquipmentTypeId)
                 .map(EquipmentBase::getEquipmentTypeId)
                 .anyMatch(Objects::nonNull);
                 .anyMatch(Objects::nonNull);
@@ -314,7 +314,7 @@ public class MonitoringServiceImpl implements MonitoringService {
             return Collections.emptyMap();
             return Collections.emptyMap();
         }
         }
         return equipmentTypeMapper.selectList(new LambdaQueryWrapper<>()).stream()
         return equipmentTypeMapper.selectList(new LambdaQueryWrapper<>()).stream()
-                .collect(Collectors.toMap(type -> String.valueOf(type.getId()), Function.identity(), (left, right) -> left));
+                .collect(Collectors.toMap(EquipmentType::getId, Function.identity(), (left, right) -> left));
     }
     }
 
 
     private Map<String, RadarData> selectLatestRadar(List<EquipmentBase> devices,
     private Map<String, RadarData> selectLatestRadar(List<EquipmentBase> devices,
@@ -514,7 +514,7 @@ public class MonitoringServiceImpl implements MonitoringService {
     }
     }
 
 
     /** Restrict monitoring pages and trends to the canonical water-supply monitoring types. */
     /** Restrict monitoring pages and trends to the canonical water-supply monitoring types. */
-    private Set<String> selectMonitoringTypeIds(String monitorKind) {
+    private Set<Integer> selectMonitoringTypeIds(String monitorKind) {
         Integer typeId;
         Integer typeId;
         switch (monitorKind) {
         switch (monitorKind) {
             case "pressure":
             case "pressure":
@@ -538,8 +538,9 @@ public class MonitoringServiceImpl implements MonitoringService {
             return Collections.emptySet();
             return Collections.emptySet();
         }
         }
         return types.stream()
         return types.stream()
-                .filter(type -> typeId.equals(type.getId()))
-                .map(type -> String.valueOf(type.getId()))
+                .filter(type -> typeId.equals(type.getId())
+                        && EquipmentTypeEnum.ROOT_WATER_SUPPLY_DEVICE.getId().equals(type.getParentId()))
+                .map(EquipmentType::getId)
                 .collect(Collectors.toSet());
                 .collect(Collectors.toSet());
     }
     }
 }
 }

+ 7 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java

@@ -120,7 +120,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
         }
         }
         Set<String> waterTypeRecordIds = resolveOwnedTypeRecordIds(allTypes, typeCountMap.keySet());
         Set<String> waterTypeRecordIds = resolveOwnedTypeRecordIds(allTypes, typeCountMap.keySet());
 
 
-        // equipment_base.equipment_type_id 仍为字符串,此处统一用 id 字符串做 key
+        // 统计视图以类型主键的字符串表示分组;设备表中的 equipment_type_id 为整数。
         Map<String, DeviceTypeStatisticsVO> voMap = new HashMap<>();
         Map<String, DeviceTypeStatisticsVO> voMap = new HashMap<>();
         for (EquipmentType type : allTypes) {
         for (EquipmentType type : allTypes) {
             String recordId = String.valueOf(type.getId());
             String recordId = String.valueOf(type.getId());
@@ -173,7 +173,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
     public List<DeviceGisVO> getAllDeviceGisPoints(String equipmentTypeId) {
     public List<DeviceGisVO> getAllDeviceGisPoints(String equipmentTypeId) {
         List<EquipmentBase> list = findWaterDevices().stream()
         List<EquipmentBase> list = findWaterDevices().stream()
                 .filter(device -> StrUtil.isBlank(equipmentTypeId)
                 .filter(device -> StrUtil.isBlank(equipmentTypeId)
-                        || equipmentTypeId.equals(device.getEquipmentTypeId()))
+                        || equipmentTypeId.equals(String.valueOf(device.getEquipmentTypeId())))
                 .filter(device -> device.getLongitude() != null && device.getLatitude() != null)
                 .filter(device -> device.getLongitude() != null && device.getLatitude() != null)
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
         if (CollUtil.isEmpty(list)) {
         if (CollUtil.isEmpty(list)) {
@@ -182,7 +182,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
 
 
         List<String> equipmentIds = list.stream().map(EquipmentBase::getEquipmentId).collect(Collectors.toList());
         List<String> equipmentIds = list.stream().map(EquipmentBase::getEquipmentId).collect(Collectors.toList());
         Map<String, EquipmentStatus> statusMap = getStatusMap(equipmentIds);
         Map<String, EquipmentStatus> statusMap = getStatusMap(equipmentIds);
-        Map<String, EquipmentType> typeMap = getTypeMap(list);
+        Map<Integer, EquipmentType> typeMap = getTypeMap(list);
 
 
         List<DeviceGisVO> result = new ArrayList<>();
         List<DeviceGisVO> result = new ArrayList<>();
         for (EquipmentBase base : list) {
         for (EquipmentBase base : list) {
@@ -288,7 +288,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
 
 
         List<EquipmentBase> devices = ownedDevices.stream()
         List<EquipmentBase> devices = ownedDevices.stream()
                 .filter(device -> StrUtil.isBlank(equipmentTypeId)
                 .filter(device -> StrUtil.isBlank(equipmentTypeId)
-                        || equipmentTypeId.equals(device.getEquipmentTypeId()))
+                        || equipmentTypeId.equals(String.valueOf(device.getEquipmentTypeId())))
                 .filter(device -> device.getLongitude() != null && device.getLatitude() != null)
                 .filter(device -> device.getLongitude() != null && device.getLatitude() != null)
                 .filter(device -> Optional.ofNullable(statusMap.get(device.getEquipmentId()))
                 .filter(device -> Optional.ofNullable(statusMap.get(device.getEquipmentId()))
                         .map(status -> Objects.equals(status.getAlarmStatus(), 1)
                         .map(status -> Objects.equals(status.getAlarmStatus(), 1)
@@ -304,7 +304,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
         Map<String, EquipmentMaintain> maintainMap = getLatestMaintainMap(
         Map<String, EquipmentMaintain> maintainMap = getLatestMaintainMap(
                 devices.stream().map(EquipmentBase::getEquipmentId).collect(Collectors.toList())
                 devices.stream().map(EquipmentBase::getEquipmentId).collect(Collectors.toList())
         );
         );
-        Map<String, EquipmentType> typeMap = getTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = getTypeMap(devices);
 
 
         List<AbnormalDeviceVO> result = new ArrayList<>();
         List<AbnormalDeviceVO> result = new ArrayList<>();
         for (EquipmentBase base : devices) {
         for (EquipmentBase base : devices) {
@@ -513,7 +513,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                 .collect(Collectors.toMap(EquipmentStatus::getEquipmentId, s -> s, (a, b) -> a));
                 .collect(Collectors.toMap(EquipmentStatus::getEquipmentId, s -> s, (a, b) -> a));
     }
     }
 
 
-    private Map<String, EquipmentType> getTypeMap(List<EquipmentBase> devices) {
+    private Map<Integer, EquipmentType> getTypeMap(List<EquipmentBase> devices) {
         if (CollUtil.isEmpty(devices)) {
         if (CollUtil.isEmpty(devices)) {
             return new HashMap<>();
             return new HashMap<>();
         }
         }
@@ -525,7 +525,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
         }
         }
         List<EquipmentType> types = equipmentTypeMapper.selectBatchIds(typeIds);
         List<EquipmentType> types = equipmentTypeMapper.selectBatchIds(typeIds);
         return types.stream()
         return types.stream()
-                .collect(Collectors.toMap(type -> String.valueOf(type.getId()), t -> t, (a, b) -> a));
+                .collect(Collectors.toMap(EquipmentType::getId, t -> t, (a, b) -> a));
     }
     }
 
 
     private Map<String, EquipmentMaintain> getLatestMaintainMap(List<String> equipmentIds) {
     private Map<String, EquipmentMaintain> getLatestMaintainMap(List<String> equipmentIds) {

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

@@ -17,7 +17,7 @@ import java.util.stream.Collectors;
 @Service
 @Service
 public class CockpitDeviceBoundaryService {
 public class CockpitDeviceBoundaryService {
     private static final List<String> FORMAL_INDUSTRIES =
     private static final List<String> FORMAL_INDUSTRIES =
-            Collections.unmodifiableList(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE"));
+            Collections.unmodifiableList(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE"));
     private static final List<String> DRAINAGE_SCOPES =
     private static final List<String> DRAINAGE_SCOPES =
             Collections.unmodifiableList(Arrays.asList("RAINWATER", "SEWAGE"));
             Collections.unmodifiableList(Arrays.asList("RAINWATER", "SEWAGE"));
     private static final List<String> OBJECT_KINDS =
     private static final List<String> OBJECT_KINDS =
@@ -117,6 +117,7 @@ public class CockpitDeviceBoundaryService {
         industries.put("gas", new CockpitIndustryStatistics());
         industries.put("gas", new CockpitIndustryStatistics());
         industries.put("waterSupply", new CockpitIndustryStatistics());
         industries.put("waterSupply", new CockpitIndustryStatistics());
         industries.put("drainage", new CockpitIndustryStatistics());
         industries.put("drainage", new CockpitIndustryStatistics());
+        industries.put("manhole", new CockpitIndustryStatistics());
 
 
         for (CockpitFacilityBoundary facility : facilities) {
         for (CockpitFacilityBoundary facility : facilities) {
             if (facility.isFormalScope()) {
             if (facility.isFormalScope()) {
@@ -208,6 +209,9 @@ public class CockpitDeviceBoundaryService {
         if ("DRAINAGE".equals(industry)) {
         if ("DRAINAGE".equals(industry)) {
             return industries.get("drainage");
             return industries.get("drainage");
         }
         }
+        if ("MANHOLE".equals(industry)) {
+            return industries.get("manhole");
+        }
         return null;
         return null;
     }
     }
 
 

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

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.event;
 package com.zksy.cockpit.event;
 
 
 import com.zksy.cockpit.detail.CockpitSectionResult;
 import com.zksy.cockpit.detail.CockpitSectionResult;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.List;
@@ -151,10 +152,15 @@ public class CockpitCurrentEventsResponse {
         private final String unit;
         private final String unit;
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String location;
         private final String industry;
         private final String industry;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
+        private final CockpitCoordinateValidation coordinateValidation;
+        private final BigDecimal thresholdMinValue;
+        private final BigDecimal thresholdMaxValue;
         private final WorkOrderSummary workOrderSummary;
         private final WorkOrderSummary workOrderSummary;
 
 
         public EventItem(
         public EventItem(
@@ -170,10 +176,15 @@ public class CockpitCurrentEventsResponse {
                 String unit,
                 String unit,
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String location,
                 String industry,
                 String industry,
                 BigDecimal longitude,
                 BigDecimal longitude,
                 BigDecimal latitude,
                 BigDecimal latitude,
+                CockpitCoordinateValidation coordinateValidation,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue,
                 WorkOrderSummary workOrderSummary) {
                 WorkOrderSummary workOrderSummary) {
             this.eventNo = eventNo;
             this.eventNo = eventNo;
             this.eventName = eventName;
             this.eventName = eventName;
@@ -187,10 +198,15 @@ public class CockpitCurrentEventsResponse {
             this.unit = unit;
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.location = location;
             this.industry = industry;
             this.industry = industry;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
+            this.coordinateValidation = coordinateValidation;
+            this.thresholdMinValue = thresholdMinValue;
+            this.thresholdMaxValue = thresholdMaxValue;
             this.workOrderSummary = workOrderSummary;
             this.workOrderSummary = workOrderSummary;
         }
         }
 
 
@@ -206,10 +222,15 @@ public class CockpitCurrentEventsResponse {
         public String getUnit() { return unit; }
         public String getUnit() { return unit; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
         public String getDeviceName() { return deviceName; }
+        public String getMonitoringPointCode() { return monitoringPointCode; }
+        public String getMonitoringPointName() { return monitoringPointName; }
         public String getLocation() { return location; }
         public String getLocation() { return location; }
         public String getIndustry() { return industry; }
         public String getIndustry() { return industry; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
+        public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public BigDecimal getThresholdMinValue() { return thresholdMinValue; }
+        public BigDecimal getThresholdMaxValue() { return thresholdMaxValue; }
         public WorkOrderSummary getWorkOrderSummary() { return workOrderSummary; }
         public WorkOrderSummary getWorkOrderSummary() { return workOrderSummary; }
     }
     }
 
 

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

@@ -1,6 +1,7 @@
 package com.zksy.cockpit.event;
 package com.zksy.cockpit.event;
 
 
 import com.zksy.cockpit.detail.CockpitSectionResult;
 import com.zksy.cockpit.detail.CockpitSectionResult;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 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;
@@ -33,7 +34,7 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitCurrentEventsService {
 public class CockpitCurrentEventsService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final int DEFAULT_PAGE_SIZE = 100;
     private static final int DEFAULT_PAGE_SIZE = 100;
     private static final int MAX_PAGE_SIZE = 1000;
     private static final int MAX_PAGE_SIZE = 1000;
@@ -199,10 +200,15 @@ public class CockpitCurrentEventsService {
                     warning.getUnit(),
                     warning.getUnit(),
                     warning.getDeviceCode(),
                     warning.getDeviceCode(),
                     warning.getDeviceName(),
                     warning.getDeviceName(),
+                    warning.getMonitoringPointCode(),
+                    warning.getMonitoringPointName(),
                     warning.getLocation(),
                     warning.getLocation(),
                     type.getIndustry(),
                     type.getIndustry(),
                     warning.getLongitude(),
                     warning.getLongitude(),
-                    warning.getLatitude()));
+                    warning.getLatitude(),
+                    warning.isCoordinateVerified(),
+                    null,
+                    null));
         }
         }
         return rows;
         return rows;
     }
     }
@@ -243,10 +249,15 @@ public class CockpitCurrentEventsService {
                     alarm.getUnit(),
                     alarm.getUnit(),
                     alarm.getDeviceCode(),
                     alarm.getDeviceCode(),
                     alarm.getDeviceName(),
                     alarm.getDeviceName(),
+                    alarm.getMonitoringPointCode(),
+                    alarm.getMonitoringPointName(),
                     alarm.getLocation(),
                     alarm.getLocation(),
                     type.getIndustry(),
                     type.getIndustry(),
                     alarm.getLongitude(),
                     alarm.getLongitude(),
-                    alarm.getLatitude()));
+                    alarm.getLatitude(),
+                    alarm.isCoordinateVerified(),
+                    alarm.getThresholdMinValue(),
+                    alarm.getThresholdMaxValue()));
         }
         }
         return rows;
         return rows;
     }
     }
@@ -359,7 +370,7 @@ public class CockpitCurrentEventsService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }
@@ -426,10 +437,15 @@ public class CockpitCurrentEventsService {
         private final String unit;
         private final String unit;
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String location;
         private final String industry;
         private final String industry;
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
+        private final boolean coordinateVerified;
+        private final BigDecimal thresholdMinValue;
+        private final BigDecimal thresholdMaxValue;
 
 
         private EventRow(
         private EventRow(
                 String eventNo,
                 String eventNo,
@@ -443,10 +459,15 @@ public class CockpitCurrentEventsService {
                 String unit,
                 String unit,
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String location,
                 String industry,
                 String industry,
                 BigDecimal longitude,
                 BigDecimal longitude,
-                BigDecimal latitude) {
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue) {
             this.eventNo = eventNo;
             this.eventNo = eventNo;
             this.eventName = eventName;
             this.eventName = eventName;
             this.eventType = eventType;
             this.eventType = eventType;
@@ -458,10 +479,15 @@ public class CockpitCurrentEventsService {
             this.unit = unit;
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.location = location;
             this.industry = industry;
             this.industry = industry;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
+            this.thresholdMinValue = thresholdMinValue;
+            this.thresholdMaxValue = thresholdMaxValue;
         }
         }
 
 
         private String getEventNo() {
         private String getEventNo() {
@@ -484,10 +510,15 @@ public class CockpitCurrentEventsService {
                     unit,
                     unit,
                     deviceCode,
                     deviceCode,
                     deviceName,
                     deviceName,
+                    monitoringPointCode,
+                    monitoringPointName,
                     location,
                     location,
                     industry,
                     industry,
                     longitude,
                     longitude,
                     latitude,
                     latitude,
+                    CockpitCoordinateValidator.validate(longitude, latitude, coordinateVerified),
+                    thresholdMinValue,
+                    thresholdMaxValue,
                     key == null ? null : summaries.get(key));
                     key == null ? null : summaries.get(key));
         }
         }
     }
     }

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

@@ -102,10 +102,13 @@ public class CockpitCurrentEventsSnapshot {
         private final String unit;
         private final String unit;
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String location;
         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;
 
 
         public SourceWarning(
         public SourceWarning(
                 String warningNo,
                 String warningNo,
@@ -119,10 +122,13 @@ public class CockpitCurrentEventsSnapshot {
                 String unit,
                 String unit,
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String location,
                 String typeCode,
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal longitude,
-                BigDecimal latitude) {
+                BigDecimal latitude,
+                boolean coordinateVerified) {
             this.warningNo = warningNo;
             this.warningNo = warningNo;
             this.warningName = warningName;
             this.warningName = warningName;
             this.warningType = warningType;
             this.warningType = warningType;
@@ -134,10 +140,13 @@ public class CockpitCurrentEventsSnapshot {
             this.unit = unit;
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.location = location;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
         }
         }
 
 
         public String getWarningNo() {
         public String getWarningNo() {
@@ -184,6 +193,14 @@ public class CockpitCurrentEventsSnapshot {
             return deviceName;
             return deviceName;
         }
         }
 
 
+        public String getMonitoringPointCode() {
+            return monitoringPointCode;
+        }
+
+        public String getMonitoringPointName() {
+            return monitoringPointName;
+        }
+
         public String getLocation() {
         public String getLocation() {
             return location;
             return location;
         }
         }
@@ -199,6 +216,10 @@ public class CockpitCurrentEventsSnapshot {
         public BigDecimal getLatitude() {
         public BigDecimal getLatitude() {
             return latitude;
             return latitude;
         }
         }
+
+        public boolean isCoordinateVerified() {
+            return coordinateVerified;
+        }
     }
     }
 
 
     public static class SourceAlarm {
     public static class SourceAlarm {
@@ -213,10 +234,15 @@ public class CockpitCurrentEventsSnapshot {
         private final String unit;
         private final String unit;
         private final String deviceCode;
         private final String deviceCode;
         private final String deviceName;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String location;
         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 BigDecimal thresholdMinValue;
+        private final BigDecimal thresholdMaxValue;
 
 
         public SourceAlarm(
         public SourceAlarm(
                 String alarmId,
                 String alarmId,
@@ -230,10 +256,15 @@ public class CockpitCurrentEventsSnapshot {
                 String unit,
                 String unit,
                 String deviceCode,
                 String deviceCode,
                 String deviceName,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String location,
                 String typeCode,
                 String typeCode,
                 BigDecimal longitude,
                 BigDecimal longitude,
-                BigDecimal latitude) {
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue) {
             this.alarmId = alarmId;
             this.alarmId = alarmId;
             this.warningName = warningName;
             this.warningName = warningName;
             this.warningType = warningType;
             this.warningType = warningType;
@@ -245,10 +276,15 @@ public class CockpitCurrentEventsSnapshot {
             this.unit = unit;
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.location = location;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
+            this.thresholdMinValue = thresholdMinValue;
+            this.thresholdMaxValue = thresholdMaxValue;
         }
         }
 
 
         public String getAlarmId() {
         public String getAlarmId() {
@@ -295,6 +331,14 @@ public class CockpitCurrentEventsSnapshot {
             return deviceName;
             return deviceName;
         }
         }
 
 
+        public String getMonitoringPointCode() {
+            return monitoringPointCode;
+        }
+
+        public String getMonitoringPointName() {
+            return monitoringPointName;
+        }
+
         public String getLocation() {
         public String getLocation() {
             return location;
             return location;
         }
         }
@@ -310,6 +354,18 @@ public class CockpitCurrentEventsSnapshot {
         public BigDecimal getLatitude() {
         public BigDecimal getLatitude() {
             return latitude;
             return latitude;
         }
         }
+
+        public boolean isCoordinateVerified() {
+            return coordinateVerified;
+        }
+
+        public BigDecimal getThresholdMinValue() {
+            return thresholdMinValue;
+        }
+
+        public BigDecimal getThresholdMaxValue() {
+            return thresholdMaxValue;
+        }
     }
     }
 
 
     public static class SourceWorkOrderLink {
     public static class SourceWorkOrderLink {

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

@@ -38,11 +38,11 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitHandlingEfficiencyService {
 public class CockpitHandlingEfficiencyService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String WARNING = "WARNING";
     private static final String WARNING = "WARNING";
     private static final String ALARM = "ALARM";
     private static final String ALARM = "ALARM";
-    private static final int CLOSED_STATE_CODE = 6;
+    private static final int CLOSED_STATE_CODE = 5;
     private static final int CANCELLED_STATE_CODE = 7;
     private static final int CANCELLED_STATE_CODE = 7;
     private static final String EFFICIENCY_BASIS = "CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS";
     private static final String EFFICIENCY_BASIS = "CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS";
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long DEFAULT_WINDOW_DAYS = 7;
@@ -179,7 +179,7 @@ public class CockpitHandlingEfficiencyService {
         int cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
         int cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
         int inProgressCount = 0;
         int inProgressCount = 0;
         for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
         for (Map.Entry<Integer, Integer> entry : stateCounts.entrySet()) {
-            if (entry.getKey() >= 1 && entry.getKey() <= 5) {
+            if (entry.getKey() == 6 || (entry.getKey() >= 0 && entry.getKey() <= 4)) {
                 inProgressCount += entry.getValue();
                 inProgressCount += entry.getValue();
             }
             }
         }
         }
@@ -496,7 +496,7 @@ public class CockpitHandlingEfficiencyService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }
@@ -524,12 +524,12 @@ public class CockpitHandlingEfficiencyService {
             int resolved = Integer.parseInt(workOrderState.trim());
             int resolved = Integer.parseInt(workOrderState.trim());
             if (CockpitWorkOrderStateDictionary.byCode(resolved) == null) {
             if (CockpitWorkOrderStateDictionary.byCode(resolved) == null) {
                 throw new CockpitInvalidRequestException(
                 throw new CockpitInvalidRequestException(
-                        "workOrderState must be an integer between 1 and 7");
+                        "workOrderState must be an integer between 0 and 7");
             }
             }
             return resolved;
             return resolved;
         } catch (NumberFormatException exception) {
         } catch (NumberFormatException exception) {
             throw new CockpitInvalidRequestException(
             throw new CockpitInvalidRequestException(
-                    "workOrderState must be an integer between 1 and 7");
+                    "workOrderState must be an integer between 0 and 7");
         }
         }
     }
     }
 
 

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

@@ -31,16 +31,16 @@ import java.util.Set;
  * <p>Buckets events hourly in Asia/Shanghai, keeps warning and alarm families
  * <p>Buckets events hourly in Asia/Shanghai, keeps warning and alarm families
  * isolated, and resolves the handled disposal scope through the authoritative
  * isolated, and resolves the handled disposal scope through the authoritative
  * work-order dictionary (ADR-0010): an event counts as handled only when its
  * work-order dictionary (ADR-0010): an event counts as handled only when its
- * associated work order stateCode equals 6 (已结案).
+ * associated work order stateCode equals 5 (已结案).
  */
  */
 @Service
 @Service
 public class CockpitHistoryAnalysisService {
 public class CockpitHistoryAnalysisService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String WARNING = "WARNING";
     private static final String WARNING = "WARNING";
     private static final String ALARM = "ALARM";
     private static final String ALARM = "ALARM";
-    private static final int HANDLED_STATE_CODE = 6;
+    private static final int HANDLED_STATE_CODE = 5;
     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;
     private static final DateTimeFormatter TIMESTAMP =
     private static final DateTimeFormatter TIMESTAMP =
@@ -319,7 +319,7 @@ public class CockpitHistoryAnalysisService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -35,7 +35,7 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitMultidimensionalAnalysisService {
 public class CockpitMultidimensionalAnalysisService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String WARNING = "WARNING";
     private static final String WARNING = "WARNING";
     private static final String ALARM = "ALARM";
     private static final String ALARM = "ALARM";
@@ -444,7 +444,7 @@ public class CockpitMultidimensionalAnalysisService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -0,0 +1,65 @@
+package com.zksy.cockpit.foundation;
+
+import com.zksy.base.domain.EquipmentType;
+
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 设备类型树根 → 行业 的共享推导规则。
+ *
+ * <p>现网 {@code equipment_type} 根({@code parent_id = 0})为:1 供水、2 排水、3 燃气、4 窨井。
+ * 四者都是正式行业:{@code WATER_SUPPLY}、{@code DRAINAGE}、{@code GAS}、{@code MANHOLE}。
+ * 叶子类型沿 {@code parent_id} 上溯到根后再映射,不能只看直接父级,也不能把窨井静默并入排水。
+ *
+ * <p>根不在取值域内、父级缺失或出现环时返回 {@code null},不静默归类。
+ */
+public final class CockpitTypeIndustry {
+
+    private static final Map<Integer, String> INDUSTRY_BY_ROOT = new LinkedHashMap<>();
+
+    static {
+        INDUSTRY_BY_ROOT.put(1, "WATER_SUPPLY");
+        INDUSTRY_BY_ROOT.put(2, "DRAINAGE");
+        INDUSTRY_BY_ROOT.put(3, "GAS");
+        INDUSTRY_BY_ROOT.put(4, "MANHOLE");
+    }
+
+    private CockpitTypeIndustry() {
+    }
+
+    /** 仅按直接父级映射。父级本身不是行业根时返回 {@code null}。 */
+    public static String industryOf(EquipmentType type) {
+        if (type == null) {
+            return null;
+        }
+        return industryOf(type, null);
+    }
+
+    /**
+     * 沿类型树上溯到根再映射。{@code types} 缺少中间节点时停在当前可见父级,不猜测。
+     */
+    public static String industryOf(EquipmentType type, Map<Integer, EquipmentType> types) {
+        EquipmentType current = type;
+        Set<Integer> seen = new HashSet<>();
+        while (current != null) {
+            Integer id = current.getId();
+            if (id != null && !seen.add(id)) {
+                return null;
+            }
+            Integer parentId = current.getParentId();
+            if (parentId == null || parentId == 0) {
+                return id == null ? null : INDUSTRY_BY_ROOT.get(id);
+            }
+            String parentIndustry = INDUSTRY_BY_ROOT.get(parentId);
+            if (parentIndustry != null) {
+                return parentIndustry;
+            }
+            current = types == null ? null : types.get(parentId);
+        }
+        return null;
+    }
+
+}

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

@@ -27,7 +27,7 @@ import java.util.Objects;
 public class CockpitInfrastructureBaseArchivesService {
 public class CockpitInfrastructureBaseArchivesService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final List<String> INDUSTRIES =
     private static final List<String> INDUSTRIES =
-            Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+            Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final List<String> AGE_BUCKETS = Arrays.asList(
     private static final List<String> AGE_BUCKETS = Arrays.asList(
             "UNDER_5_YEARS", "5_TO_10_YEARS", "10_TO_20_YEARS", "20_YEARS_OR_OVER", "UNKNOWN");
             "UNDER_5_YEARS", "5_TO_10_YEARS", "10_TO_20_YEARS", "20_YEARS_OR_OVER", "UNKNOWN");
 
 

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

@@ -25,7 +25,7 @@ public class CockpitInfrastructureEmergencySupportService {
     private static final List<String> CATEGORIES = Arrays.asList(
     private static final List<String> CATEGORIES = Arrays.asList(
             "EMERGENCY_RESOURCE", "HAZARD_SOURCE", "PROTECTION_TARGET");
             "EMERGENCY_RESOURCE", "HAZARD_SOURCE", "PROTECTION_TARGET");
     private static final List<String> INDUSTRIES = Arrays.asList(
     private static final List<String> INDUSTRIES = Arrays.asList(
-            "GAS", "WATER_SUPPLY", "DRAINAGE");
+            "GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
 
 
     private final CockpitInfrastructureEmergencySupportAdapter adapter;
     private final CockpitInfrastructureEmergencySupportAdapter adapter;
     private final Clock clock;
     private final Clock clock;
@@ -227,7 +227,7 @@ public class CockpitInfrastructureEmergencySupportService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -26,7 +26,7 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitInfrastructureManagementInformationService {
 public class CockpitInfrastructureManagementInformationService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    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_CODE = "UNMAINTAINED";
@@ -197,7 +197,7 @@ public class CockpitInfrastructureManagementInformationService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -27,7 +27,7 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitInfrastructureOperatingInformationService {
 public class CockpitInfrastructureOperatingInformationService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final List<String> RANGES = Arrays.asList("LAST_24_HOURS", "LAST_7_DAYS", "LAST_30_DAYS");
     private static final List<String> RANGES = Arrays.asList("LAST_24_HOURS", "LAST_7_DAYS", "LAST_30_DAYS");
     private static final List<String> CATEGORIES =
     private static final List<String> CATEGORIES =
             Arrays.asList("SUPPLY", "FLOW", "PRESSURE", "QUALITY", "CAPACITY");
             Arrays.asList("SUPPLY", "FLOW", "PRESSURE", "QUALITY", "CAPACITY");
@@ -286,7 +286,7 @@ public class CockpitInfrastructureOperatingInformationService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -0,0 +1,168 @@
+package com.zksy.cockpit.map;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 地图快照的真实数据适配器(BT-5 纵向样板)。
+ *
+ * <p>组合既有表与服务(不新增表、不加投影):
+ * <ul>
+ *   <li>点位:{@code monitor_point}(MyBatis-Plus 逻辑删除自动过滤 {@code del_flag});</li>
+ *   <li>点位→设备:{@code equipment_point_rel}(经 {@link EquipmentBaseService#getEquipmentByPoint});</li>
+ *   <li>设备状态:{@code equipment_status}(在线/报警/当前状态);</li>
+ *   <li>预警:{@code early_warning}(按 {@code device_code},未关闭即视为预警中);</li>
+ *   <li>在途工单:{@code work_order}(注意现网状态是 **0-based**:0 待派单 … 5 已结案、7 已取消);</li>
+ *   <li>类型字典:{@code equipment_type}(行业由 {@code parent_id} 根推导:1 供水、2 排水、3 燃气、4 窨井)。</li>
+ * </ul>
+ *
+ * <p><b>如实接线,不臆造</b>:现网没有「坐标是否已校核」字段,故一律按未校核(
+ * {@code coordinateVerified=false})上报,由冻结字典裁决为不可渲染;不为了「图上有东西」而伪造校验状态。
+ * 现网 {@code monitor_point} 也没有行业/类型列,点位的 {@code typeCode} 只能在其关联设备类型唯一时推导。
+ */
+@Component
+@Primary
+public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdapter {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private final MonitorPointService monitorPointService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
+    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
+    private final Clock clock;
+
+    public BusinessCockpitMapSnapshotAdapter(
+            MonitorPointService monitorPointService,
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService,
+            BusinessCockpitDeviceFactResolver deviceFactResolver,
+            Clock clock) {
+        this.monitorPointService = monitorPointService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
+        this.deviceFactResolver = deviceFactResolver;
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitMapSnapshot load(String snapshotAt) {
+        String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
+
+        Map<Integer, EquipmentType> types = equipmentTypes();
+        List<MonitorPoint> points = safeList(monitorPointService.list());
+
+        Map<String, EquipmentBase> deviceIndex = new LinkedHashMap<>();
+        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
+            if (device != null && !isBlank(device.getEquipmentCode())) {
+                deviceIndex.put(device.getEquipmentCode().trim(), device);
+            }
+        }
+
+        List<CockpitMapSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
+        for (MonitorPoint point : points) {
+            if (point == null || isBlank(point.getPointCode())) {
+                continue;
+            }
+            List<EquipmentBase> boundDevices = safeList(
+                    equipmentBaseService.getEquipmentByPoint(point.getPointId()));
+            List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
+            Set<String> deviceTypes = new LinkedHashSet<>();
+            for (EquipmentBase device : boundDevices) {
+                if (device == null || isBlank(device.getEquipmentCode())) {
+                    continue;
+                }
+                EquipmentType boundType = device.getEquipmentTypeId() == null
+                        ? null
+                        : types.get(device.getEquipmentTypeId());
+                String typeName = boundType == null ? null : boundType.getTypeName();
+                devices.add(deviceFactResolver.resolve(device, typeName));
+                if (boundType != null && boundType.getId() != null) {
+                    deviceTypes.add(String.valueOf(boundType.getId()));
+                }
+            }
+            sourcePoints.add(new CockpitMapSnapshot.SourceMonitoringPoint(
+                    point.getPointCode().trim(),
+                    point.getPointName(),
+                    null,
+                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
+                    point.getLongitude(),
+                    point.getLatitude(),
+                    false,
+                    devices));
+        }
+
+        // 智能设备清单按「全量」上报:哪些算「未关联」由 CockpitMapSnapshotService 统一判定,避免双份规则。
+        List<CockpitMapSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
+        for (EquipmentBase device : deviceIndex.values()) {
+            EquipmentType type = device.getEquipmentTypeId() == null
+                    ? null
+                    : types.get(device.getEquipmentTypeId());
+            smartDevices.add(new CockpitMapSnapshot.SourceSmartDevice(
+                    device.getEquipmentCode().trim(),
+                    device.getEquipmentName(),
+                    null,
+                    type == null || type.getId() == null ? null : String.valueOf(type.getId()),
+                    device.getLongitude(),
+                    device.getLatitude(),
+                    false));
+        }
+
+        List<CockpitMapSnapshot.SourceType> sourceTypes = types.values().stream()
+                .filter(type -> type.getId() != null)
+                .map(type -> new CockpitMapSnapshot.SourceType(
+                        String.valueOf(type.getId()),
+                        type.getTypeName(),
+                        CockpitTypeIndustry.industryOf(type),
+                        "SMART_DEVICE"))
+                .collect(Collectors.toList());
+
+        return new CockpitMapSnapshot(resolvedSnapshotAt, sourceTypes, sourcePoints, smartDevices);
+    }
+
+    private Map<Integer, EquipmentType> equipmentTypes() {
+        Map<Integer, EquipmentType> result = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private String businessTime() {
+        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
+    }
+}

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

@@ -8,24 +8,31 @@ import java.util.List;
 
 
 public class CockpitMapSnapshot {
 public class CockpitMapSnapshot {
     private final String snapshotAt;
     private final String snapshotAt;
+    private final List<SourceType> types;
     private final List<SourceMonitoringPoint> monitoringPoints;
     private final List<SourceMonitoringPoint> monitoringPoints;
     private final List<SourceSmartDevice> smartDevices;
     private final List<SourceSmartDevice> smartDevices;
 
 
-    public CockpitMapSnapshot(String snapshotAt, List<SourceMonitoringPoint> monitoringPoints,
-            List<SourceSmartDevice> smartDevices) {
+    public CockpitMapSnapshot(String snapshotAt, List<SourceType> types,
+            List<SourceMonitoringPoint> monitoringPoints, List<SourceSmartDevice> smartDevices) {
         this.snapshotAt = snapshotAt;
         this.snapshotAt = snapshotAt;
+        this.types = types;
         this.monitoringPoints = monitoringPoints;
         this.monitoringPoints = monitoringPoints;
         this.smartDevices = smartDevices;
         this.smartDevices = smartDevices;
     }
     }
 
 
     public static CockpitMapSnapshot empty(String snapshotAt) {
     public static CockpitMapSnapshot empty(String snapshotAt) {
-        return new CockpitMapSnapshot(snapshotAt, Collections.emptyList(), Collections.emptyList());
+        return new CockpitMapSnapshot(
+                snapshotAt, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
     }
     }
 
 
     public String getSnapshotAt() {
     public String getSnapshotAt() {
         return snapshotAt;
         return snapshotAt;
     }
     }
 
 
+    public List<SourceType> getTypes() {
+        return types;
+    }
+
     public List<SourceMonitoringPoint> getMonitoringPoints() {
     public List<SourceMonitoringPoint> getMonitoringPoints() {
         return monitoringPoints;
         return monitoringPoints;
     }
     }
@@ -34,18 +41,46 @@ public class CockpitMapSnapshot {
         return smartDevices;
         return smartDevices;
     }
     }
 
 
+    /**
+     * 类型字典:行业由类型推导,口径与 `/monitoring/distribution` 一致。
+     * 地图快照对不可解析的类型**不做排除**,只在请求了 industry 过滤时按推导结果筛选。
+     */
+    public static class SourceType {
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String objectKind;
+
+        public SourceType(String code, String name, String industry, String objectKind) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+            this.objectKind = objectKind;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public String getIndustry() { return industry; }
+        public String getObjectKind() { return objectKind; }
+    }
+
     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 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, BigDecimal longitude, BigDecimal latitude,
+        public SourceMonitoringPoint(String pointCode, String pointName, String region, String typeCode,
+                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.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
             this.coordinateVerified = coordinateVerified;
@@ -54,6 +89,8 @@ 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 String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
         public boolean isCoordinateVerified() { return coordinateVerified; }
         public boolean isCoordinateVerified() { return coordinateVerified; }
@@ -63,14 +100,18 @@ 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 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, BigDecimal longitude, BigDecimal latitude,
-                boolean coordinateVerified) {
+        public SourceSmartDevice(String deviceCode, String deviceName, String region, String typeCode,
+                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
+            this.region = region;
+            this.typeCode = typeCode;
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
             this.coordinateVerified = coordinateVerified;
@@ -78,6 +119,8 @@ 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 String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
         public boolean isCoordinateVerified() { return coordinateVerified; }
         public boolean isCoordinateVerified() { return coordinateVerified; }

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

@@ -15,12 +15,14 @@ public class CockpitMapSnapshotResponse {
     private final List<CockpitMonitoringPointSummary> monitoringPoints;
     private final List<CockpitMonitoringPointSummary> monitoringPoints;
     private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
     private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
     private final CockpitCoordinateValidationSummary coordinateValidation;
     private final CockpitCoordinateValidationSummary coordinateValidation;
+    private final MapFilter mapFilter;
 
 
     public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
     public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
             String snapshotAt, int total, String batchUpdatedAt, boolean complete,
             String snapshotAt, int total, String batchUpdatedAt, boolean complete,
             List<CockpitMonitoringPointSummary> monitoringPoints,
             List<CockpitMonitoringPointSummary> monitoringPoints,
             List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
             List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
-            CockpitCoordinateValidationSummary coordinateValidation) {
+            CockpitCoordinateValidationSummary coordinateValidation,
+            MapFilter mapFilter) {
         this.requestId = requestId;
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
         this.readOnly = readOnly;
@@ -33,6 +35,7 @@ public class CockpitMapSnapshotResponse {
         this.monitoringPoints = monitoringPoints;
         this.monitoringPoints = monitoringPoints;
         this.unassociatedEquipment = unassociatedEquipment;
         this.unassociatedEquipment = unassociatedEquipment;
         this.coordinateValidation = coordinateValidation;
         this.coordinateValidation = coordinateValidation;
+        this.mapFilter = mapFilter;
     }
     }
 
 
     public String getRequestId() { return requestId; }
     public String getRequestId() { return requestId; }
@@ -47,4 +50,32 @@ public class CockpitMapSnapshotResponse {
     public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
     public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
     public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
     public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
     public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
     public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
+    public MapFilter getMapFilter() { return mapFilter; }
+
+    /**
+     * 生效范围回显:请求的过滤条件(未请求为 null)+ 过滤后**全量**(分页前)的点位/设备编码。
+     * 口径与 `/monitoring/distribution` 的 `mapFilter` 一致。
+     */
+    public static class MapFilter {
+        private final String industry;
+        private final String region;
+        private final String deviceType;
+        private final List<String> pointCodes;
+        private final List<String> deviceCodes;
+
+        public MapFilter(String industry, String region, String deviceType,
+                List<String> pointCodes, List<String> deviceCodes) {
+            this.industry = industry;
+            this.region = region;
+            this.deviceType = deviceType;
+            this.pointCodes = pointCodes;
+            this.deviceCodes = deviceCodes;
+        }
+
+        public String getIndustry() { return industry; }
+        public String getRegion() { return region; }
+        public String getDeviceType() { return deviceType; }
+        public List<String> getPointCodes() { return pointCodes; }
+        public List<String> getDeviceCodes() { return deviceCodes; }
+    }
 }
 }

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

@@ -17,9 +17,14 @@ import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Objects;
 import java.util.Set;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
@@ -29,6 +34,9 @@ public class CockpitMapSnapshotService {
     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 WGS84 = "WGS84";
     private static final String WGS84 = "WGS84";
+    private static final String SMART_DEVICE = "SMART_DEVICE";
+    private static final String UNASSIGNED_REGION = "未分区";
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
 
 
     private final CockpitMapSnapshotAdapter adapter;
     private final CockpitMapSnapshotAdapter adapter;
     private final Clock clock;
     private final Clock clock;
@@ -38,11 +46,19 @@ public class CockpitMapSnapshotService {
         this.clock = clock;
         this.clock = clock;
     }
     }
 
 
-    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt) {
+    /**
+     * 地图分页快照。全局筛选(industry / region / deviceType)生效时服务端完成过滤,
+     * `mapFilter` 回显实际生效范围;分页与 `snapshotAt` 语义不因过滤而改变。
+     */
+    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
+            String industry, String region, 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 selectedRegion = isBlank(region) ? null : region.trim();
+        String selectedDeviceType = isBlank(deviceType) ? null : deviceType.trim();
 
 
         CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
         CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
         if (snapshot == null) {
         if (snapshot == null) {
@@ -55,8 +71,11 @@ public class CockpitMapSnapshotService {
             throw new CockpitSnapshotExpiredException("snapshot timestamp changed");
             throw new CockpitSnapshotExpiredException("snapshot timestamp changed");
         }
         }
 
 
-        List<CockpitMonitoringPointSummary> points = monitoringPoints(snapshot);
-        List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(snapshot);
+        Map<String, CockpitMapSnapshot.SourceType> types = types(snapshot);
+        List<CockpitMonitoringPointSummary> points = monitoringPoints(
+                snapshot, types, selectedIndustry, selectedRegion, selectedDeviceType);
+        List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
+                snapshot, types, selectedIndustry, selectedRegion, 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);
@@ -79,13 +98,14 @@ public class CockpitMapSnapshotService {
                 toIndex >= total,
                 toIndex >= total,
                 pagePoints,
                 pagePoints,
                 pageEquipment,
                 pageEquipment,
-                coordinateValidation(points, equipment));
+                coordinateValidation(points, equipment),
+                mapFilter(selectedIndustry, selectedRegion, selectedDeviceType, points, equipment));
     }
     }
 
 
-    public CockpitErrorPayload invalidRequest(String requestId) {
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
         return new CockpitErrorPayload(
         return new CockpitErrorPayload(
                 CockpitErrorCategory.INVALID_REQUEST,
                 CockpitErrorCategory.INVALID_REQUEST,
-                "地图快照分页参数不合法",
+                message,
                 requestId,
                 requestId,
                 businessTime());
                 businessTime());
     }
     }
@@ -108,7 +128,7 @@ public class CockpitMapSnapshotService {
 
 
     private static void validatePagination(int page, int pageSize, String snapshotAt) {
     private static void validatePagination(int page, int pageSize, String snapshotAt) {
         if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE || (page > 1 && isBlank(snapshotAt))) {
         if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE || (page > 1 && isBlank(snapshotAt))) {
-            throw new CockpitInvalidRequestException("invalid pagination");
+            throw new CockpitInvalidRequestException("地图快照分页参数不合法");
         }
         }
     }
     }
 
 
@@ -119,10 +139,21 @@ public class CockpitMapSnapshotService {
         try {
         try {
             OffsetDateTime.parse(snapshotAt);
             OffsetDateTime.parse(snapshotAt);
         } catch (DateTimeParseException exception) {
         } catch (DateTimeParseException exception) {
-            throw new CockpitInvalidRequestException("invalid snapshot timestamp");
+            throw new CockpitInvalidRequestException("地图快照分页参数不合法");
         }
         }
     }
     }
 
 
+    private static String resolveIndustry(String industry) {
+        if (isBlank(industry)) {
+            return null;
+        }
+        String resolved = industry.trim();
+        if (!INDUSTRIES.contains(resolved)) {
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
+        }
+        return resolved;
+    }
+
     private static boolean isBlank(String value) {
     private static boolean isBlank(String value) {
         return value == null || value.trim().isEmpty();
         return value == null || value.trim().isEmpty();
     }
     }
@@ -131,42 +162,127 @@ public class CockpitMapSnapshotService {
         return snapshotAt == null ? businessTime() : snapshotAt;
         return snapshotAt == null ? businessTime() : snapshotAt;
     }
     }
 
 
-    private static List<CockpitMonitoringPointSummary> monitoringPoints(CockpitMapSnapshot snapshot) {
+    private static Map<String, CockpitMapSnapshot.SourceType> types(CockpitMapSnapshot snapshot) {
+        Map<String, CockpitMapSnapshot.SourceType> result = new LinkedHashMap<>();
+        if (snapshot.getTypes() == null) {
+            return result;
+        }
+        for (CockpitMapSnapshot.SourceType type : snapshot.getTypes()) {
+            if (type == null || isBlank(type.getCode())) {
+                continue;
+            }
+            result.put(type.getCode().trim(), type);
+        }
+        return result;
+    }
+
+    private static CockpitMapSnapshot.SourceType resolveType(
+            Map<String, CockpitMapSnapshot.SourceType> types, String typeCode) {
+        return isBlank(typeCode) ? null : types.get(typeCode.trim());
+    }
+
+    /**
+     * 行业推导口径同 `/monitoring/distribution`:类型可解析、`objectKind = SMART_DEVICE`
+     * 且行业属于正式行业;不满足时该对象的 `industry` 为 `null`(默认不排除对象)。
+     */
+    private static String industryOf(CockpitMapSnapshot.SourceType type) {
+        if (type == null || !SMART_DEVICE.equals(type.getObjectKind())) {
+            return null;
+        }
+        if (type.getIndustry() == null || !INDUSTRIES.contains(type.getIndustry())) {
+            return null;
+        }
+        return type.getIndustry();
+    }
+
+    private static String typeNameOf(CockpitMapSnapshot.SourceType type) {
+        return type == null ? null : type.getName();
+    }
+
+    private static String typeCodeOf(String typeCode) {
+        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) {
+        CockpitMapSnapshot.SourceType type = resolveType(types, typeCode);
+        if (industry != null && !industry.equals(industryOf(type))) {
+            return false;
+        }
+        if (deviceType != null && (type == null || !deviceType.equals(typeCodeOf(type.getCode())))) {
+            return false;
+        }
+        return region == null || region.equals(regionOf(rawRegion));
+    }
+
+    private static List<CockpitMonitoringPointSummary> monitoringPoints(
+            CockpitMapSnapshot snapshot,
+            Map<String, CockpitMapSnapshot.SourceType> types,
+            String industry, String region, String deviceType) {
         if (snapshot.getMonitoringPoints() == null) {
         if (snapshot.getMonitoringPoints() == null) {
             return Collections.emptyList();
             return Collections.emptyList();
         }
         }
         return snapshot.getMonitoringPoints().stream()
         return snapshot.getMonitoringPoints().stream()
-                .map(CockpitMapSnapshotService::monitoringPoint)
+                .filter(Objects::nonNull)
+                .filter(source -> matchesFilters(
+                        industry, region, deviceType, source.getTypeCode(), source.getRegion(), types))
+                .map(source -> monitoringPoint(source, types))
                 .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
                 .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
                         Comparator.nullsLast(String::compareTo)))
                         Comparator.nullsLast(String::compareTo)))
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
     }
     }
 
 
     private static CockpitMonitoringPointSummary monitoringPoint(
     private static CockpitMonitoringPointSummary monitoringPoint(
-            CockpitMapSnapshot.SourceMonitoringPoint source) {
+            CockpitMapSnapshot.SourceMonitoringPoint source,
+            Map<String, CockpitMapSnapshot.SourceType> types) {
         List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
         List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
                 ? Collections.emptyList()
                 ? Collections.emptyList()
                 : source.getDevices();
                 : source.getDevices();
         CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
         CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
+        List<String> deviceCodes = devices.stream()
+                .filter(Objects::nonNull)
+                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
+                .filter(Objects::nonNull)
+                .map(String::trim)
+                .filter(code -> !code.isEmpty())
+                .distinct()
+                .collect(Collectors.toList());
+        CockpitMapSnapshot.SourceType type = resolveType(types, source.getTypeCode());
         return new CockpitMonitoringPointSummary(
         return new CockpitMonitoringPointSummary(
                 source.getPointCode(),
                 source.getPointCode(),
                 source.getPointName(),
                 source.getPointName(),
                 pointStatus,
                 pointStatus,
                 CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
                 CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
+                deviceCodes,
+                regionOf(source.getRegion()),
+                industryOf(type),
+                typeCodeOf(source.getTypeCode()),
+                typeNameOf(type),
                 source.getLongitude(),
                 source.getLongitude(),
                 source.getLatitude(),
                 source.getLatitude(),
                 CockpitCoordinateValidator.validate(
                 CockpitCoordinateValidator.validate(
                         source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
                         source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
     }
     }
 
 
-    private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(CockpitMapSnapshot snapshot) {
+    private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(
+            CockpitMapSnapshot snapshot,
+            Map<String, CockpitMapSnapshot.SourceType> types,
+            String industry, String region, String deviceType) {
         if (snapshot.getSmartDevices() == null) {
         if (snapshot.getSmartDevices() == null) {
             return Collections.emptyList();
             return Collections.emptyList();
         }
         }
         Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
         Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
         return snapshot.getSmartDevices().stream()
         return snapshot.getSmartDevices().stream()
-                .filter(device -> device.getDeviceCode() == null || !associatedDeviceCodes.contains(device.getDeviceCode()))
-                .map(CockpitMapSnapshotService::unassociatedEquipment)
+                .filter(Objects::nonNull)
+                .filter(device -> device.getDeviceCode() == null
+                        || !associatedDeviceCodes.contains(device.getDeviceCode()))
+                .filter(source -> matchesFilters(
+                        industry, region, deviceType, source.getTypeCode(), source.getRegion(), types))
+                .map(source -> unassociatedEquipment(source, types))
                 .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
                 .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
                         Comparator.nullsLast(String::compareTo)))
                         Comparator.nullsLast(String::compareTo)))
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
@@ -186,16 +302,55 @@ public class CockpitMapSnapshotService {
     }
     }
 
 
     private static CockpitUnassociatedEquipmentSummary unassociatedEquipment(
     private static CockpitUnassociatedEquipmentSummary unassociatedEquipment(
-            CockpitMapSnapshot.SourceSmartDevice source) {
+            CockpitMapSnapshot.SourceSmartDevice source,
+            Map<String, CockpitMapSnapshot.SourceType> types) {
+        CockpitMapSnapshot.SourceType type = resolveType(types, source.getTypeCode());
         return new CockpitUnassociatedEquipmentSummary(
         return new CockpitUnassociatedEquipmentSummary(
                 source.getDeviceCode(),
                 source.getDeviceCode(),
                 source.getDeviceName(),
                 source.getDeviceName(),
+                regionOf(source.getRegion()),
+                industryOf(type),
+                typeCodeOf(source.getTypeCode()),
+                typeNameOf(type),
                 source.getLongitude(),
                 source.getLongitude(),
                 source.getLatitude(),
                 source.getLatitude(),
                 CockpitCoordinateValidator.validate(
                 CockpitCoordinateValidator.validate(
                         source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
                         source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
     }
     }
 
 
+    private static CockpitMapSnapshotResponse.MapFilter mapFilter(
+            String industry, String region, String deviceType,
+            List<CockpitMonitoringPointSummary> points,
+            List<CockpitUnassociatedEquipmentSummary> equipment) {
+        Set<String> pointCodes = new LinkedHashSet<>();
+        Set<String> deviceCodes = new LinkedHashSet<>();
+        for (CockpitMonitoringPointSummary point : points) {
+            if (!isBlank(point.getPointCode())) {
+                pointCodes.add(point.getPointCode().trim());
+            }
+            if (point.getDeviceCodes() != null) {
+                for (String code : point.getDeviceCodes()) {
+                    if (!isBlank(code)) {
+                        deviceCodes.add(code.trim());
+                    }
+                }
+            }
+        }
+        for (CockpitUnassociatedEquipmentSummary device : equipment) {
+            if (!isBlank(device.getDeviceCode())) {
+                deviceCodes.add(device.getDeviceCode().trim());
+            }
+        }
+        return new CockpitMapSnapshotResponse.MapFilter(
+                industry, region, deviceType, sorted(pointCodes), sorted(deviceCodes));
+    }
+
+    private static List<String> sorted(Set<String> values) {
+        List<String> result = new ArrayList<>(values);
+        Collections.sort(result, Comparator.nullsLast(String::compareTo));
+        return result;
+    }
+
     private static int clamp(int value, int minimum, int maximum) {
     private static int clamp(int value, int minimum, int maximum) {
         return Math.max(minimum, Math.min(value, maximum));
         return Math.max(minimum, Math.min(value, maximum));
     }
     }

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

@@ -11,17 +11,28 @@ public class CockpitMonitoringPointSummary {
     private final String pointName;
     private final String pointName;
     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 String region;
+    private final String industry;
+    private final String typeCode;
+    private final String typeName;
     private final BigDecimal longitude;
     private final BigDecimal longitude;
     private final BigDecimal latitude;
     private final BigDecimal latitude;
     private final CockpitCoordinateValidation coordinateValidation;
     private final CockpitCoordinateValidation coordinateValidation;
 
 
     public CockpitMonitoringPointSummary(String pointCode, String pointName,
     public CockpitMonitoringPointSummary(String pointCode, String pointName,
-            CockpitMonitoringPointStatus pointStatus, List<String> statusReasons, BigDecimal longitude,
-            BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
+            CockpitMonitoringPointStatus pointStatus, List<String> statusReasons, List<String> deviceCodes,
+            String region, String industry, String typeCode, String typeName,
+            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.region = region;
+        this.industry = industry;
+        this.typeCode = typeCode;
+        this.typeName = typeName;
         this.longitude = longitude;
         this.longitude = longitude;
         this.latitude = latitude;
         this.latitude = latitude;
         this.coordinateValidation = coordinateValidation;
         this.coordinateValidation = coordinateValidation;
@@ -31,6 +42,11 @@ public class CockpitMonitoringPointSummary {
     public String getPointName() { return pointName; }
     public String getPointName() { return pointName; }
     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 String getRegion() { return region; }
+    public String getIndustry() { return industry; }
+    public String getTypeCode() { return typeCode; }
+    public String getTypeName() { return typeName; }
     public BigDecimal getLongitude() { return longitude; }
     public BigDecimal getLongitude() { return longitude; }
     public BigDecimal getLatitude() { return latitude; }
     public BigDecimal getLatitude() { return latitude; }
     public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
     public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }

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

@@ -7,14 +7,23 @@ 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 String industry;
+    private final String typeCode;
+    private final String typeName;
     private final BigDecimal longitude;
     private final BigDecimal longitude;
     private final BigDecimal latitude;
     private final BigDecimal latitude;
     private final CockpitCoordinateValidation coordinateValidation;
     private final CockpitCoordinateValidation coordinateValidation;
 
 
-    public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName, BigDecimal longitude,
-            BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
+    public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
+            String region, String industry, String typeCode, String typeName,
+            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
         this.deviceName = deviceName;
         this.deviceName = deviceName;
+        this.region = region;
+        this.industry = industry;
+        this.typeCode = typeCode;
+        this.typeName = typeName;
         this.longitude = longitude;
         this.longitude = longitude;
         this.latitude = latitude;
         this.latitude = latitude;
         this.coordinateValidation = coordinateValidation;
         this.coordinateValidation = coordinateValidation;
@@ -22,6 +31,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; }
+    public String getIndustry() { return industry; }
+    public String getTypeCode() { return typeCode; }
+    public String getTypeName() { return typeName; }
     public BigDecimal getLongitude() { return longitude; }
     public BigDecimal getLongitude() { return longitude; }
     public BigDecimal getLatitude() { return latitude; }
     public BigDecimal getLatitude() { return latitude; }
     public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
     public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }

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

@@ -0,0 +1,175 @@
+package com.zksy.cockpit.monitoring;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 监测分布的真实数据适配器(BT-5 第 2 片)。
+ *
+ * <p>与地图快照适配器共享同一套构图({@link BusinessCockpitDeviceFactResolver} +
+ * {@link CockpitTypeIndustry}),只换快照形状:分布的未关联智能设备额外携带实时事实
+ * (在线/报警/预警/在途工单 + 原因 + 状态时间),点位的设备仍经 {@code CockpitMonitoringPointDeviceSnapshot} 描述。
+ *
+ * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
+ * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
+ * 区域(region)在现网点位/设备上无可靠来源,统一上报 {@code null} 交由服务层归一为「未分区」。
+ */
+@Component
+@Primary
+public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMonitoringDistributionAdapter {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private final MonitorPointService monitorPointService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
+    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
+    private final Clock clock;
+
+    public BusinessCockpitMonitoringDistributionAdapter(
+            MonitorPointService monitorPointService,
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService,
+            BusinessCockpitDeviceFactResolver deviceFactResolver,
+            Clock clock) {
+        this.monitorPointService = monitorPointService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
+        this.deviceFactResolver = deviceFactResolver;
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitMonitoringDistributionSnapshot load(String snapshotAt) {
+        String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
+
+        Map<Integer, EquipmentType> types = equipmentTypes();
+        List<MonitorPoint> points = safeList(monitorPointService.list());
+
+        Map<String, EquipmentBase> deviceIndex = new LinkedHashMap<>();
+        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
+            if (device != null && !isBlank(device.getEquipmentCode())) {
+                deviceIndex.put(device.getEquipmentCode().trim(), device);
+            }
+        }
+
+        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
+        for (MonitorPoint point : points) {
+            if (point == null || isBlank(point.getPointCode())) {
+                continue;
+            }
+            List<EquipmentBase> boundDevices = safeList(
+                    equipmentBaseService.getEquipmentByPoint(point.getPointId()));
+            List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
+            Set<String> deviceTypes = new LinkedHashSet<>();
+            for (EquipmentBase device : boundDevices) {
+                if (device == null || isBlank(device.getEquipmentCode())) {
+                    continue;
+                }
+                EquipmentType boundType = device.getEquipmentTypeId() == null
+                        ? null
+                        : types.get(device.getEquipmentTypeId());
+                String typeName = boundType == null ? null : boundType.getTypeName();
+                devices.add(deviceFactResolver.resolve(device, typeName));
+                if (boundType != null && boundType.getId() != null) {
+                    deviceTypes.add(String.valueOf(boundType.getId()));
+                }
+            }
+            sourcePoints.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
+                    point.getPointCode().trim(),
+                    point.getPointName(),
+                    null,
+                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
+                    point.getLongitude(),
+                    point.getLatitude(),
+                    false,
+                    devices));
+        }
+
+        List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
+        for (EquipmentBase device : deviceIndex.values()) {
+            EquipmentType type = device.getEquipmentTypeId() == null
+                    ? null
+                    : types.get(device.getEquipmentTypeId());
+            String typeCode = type == null || type.getId() == null ? null : String.valueOf(type.getId());
+            String typeName = type == null ? null : type.getTypeName();
+            CockpitMonitoringPointDeviceSnapshot fact = deviceFactResolver.resolve(device, typeName);
+            smartDevices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
+                    device.getEquipmentCode().trim(),
+                    device.getEquipmentName(),
+                    null,
+                    typeCode,
+                    device.getLongitude(),
+                    device.getLatitude(),
+                    false,
+                    fact.isOnline(),
+                    fact.isAlarm(),
+                    fact.isWarning(),
+                    fact.isWorkOrderInProgress(),
+                    fact.getStatusReasons(),
+                    fact.getStatusTime()));
+        }
+
+        List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
+                .filter(type -> type.getId() != null)
+                .map(type -> new CockpitMonitoringDistributionSnapshot.SourceType(
+                        String.valueOf(type.getId()),
+                        type.getTypeName(),
+                        CockpitTypeIndustry.industryOf(type),
+                        "SMART_DEVICE"))
+                .collect(Collectors.toList());
+
+        return new CockpitMonitoringDistributionSnapshot(
+                resolvedSnapshotAt,
+                null,
+                sourceTypes,
+                sourcePoints,
+                smartDevices);
+    }
+
+    private Map<Integer, EquipmentType> equipmentTypes() {
+        Map<Integer, EquipmentType> result = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private String businessTime() {
+        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
+    }
+}

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

@@ -0,0 +1,175 @@
+package com.zksy.cockpit.monitoring;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.MonitorPoint;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 运行监测 / 监测概览 的真实数据适配器(BT-5 第 3 片)。
+ *
+ * <p>组合既有表与服务:
+ * <ul>
+ *   <li>设备台账 {@code equipment_base};类型字典 {@code equipment_type}(行业由根推导);</li>
+ *   <li>设备在线状态 {@code equipment_status.online_status};</li>
+ *   <li>点位 {@code monitor_point}(仅用于点位计数与设备→点位归属)。</li>
+ * </ul>
+ *
+ * <p><b>如实接线,不臆造</b>:概览的 {@code monitoringState} 只有 ONLINE/OFFLINE/STALE/UNKNOWN 四值,
+ * 由现网可得事实映射——有状态记录且在线 → ONLINE,有状态记录但离线 → OFFLINE,
+ * **无状态记录 → UNKNOWN**(不冒充 OFFLINE,也不伪造 STALE:现网没有「样本过期」的可靠依据,
+ * {@code sampledAt} 直接取状态更新时间,缺失即为 null)。设备→点位归属沿用现网有效关联,未关联即为 null。
+ */
+@Component
+@Primary
+public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitoringOverviewAdapter {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private final MonitorPointService monitorPointService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentStatusService equipmentStatusService;
+    private final EquipmentTypeService equipmentTypeService;
+    private final Clock clock;
+
+    public BusinessCockpitMonitoringOverviewAdapter(
+            MonitorPointService monitorPointService,
+            EquipmentBaseService equipmentBaseService,
+            EquipmentStatusService equipmentStatusService,
+            EquipmentTypeService equipmentTypeService,
+            Clock clock) {
+        this.monitorPointService = monitorPointService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentStatusService = equipmentStatusService;
+        this.equipmentTypeService = equipmentTypeService;
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitMonitoringOverviewSnapshot load() {
+        Map<Integer, EquipmentType> types = equipmentTypes();
+
+        // 点位遍历一次即产出:设备→点位归属、点位类型推导(避免逐点位重复回查关联表)
+        Map<String, String> pointCodeByDevice = new LinkedHashMap<>();
+        List<CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
+        List<MonitorPoint> points = safeList(monitorPointService.list());
+        for (MonitorPoint point : points) {
+            if (point == null || isBlank(point.getPointCode())) {
+                continue;
+            }
+            String pointCode = point.getPointCode().trim();
+            Set<String> deviceTypes = new LinkedHashSet<>();
+            for (EquipmentBase bound : safeList(equipmentBaseService.getEquipmentByPoint(point.getPointId()))) {
+                if (bound == null || isBlank(bound.getEquipmentCode())) {
+                    continue;
+                }
+                pointCodeByDevice.putIfAbsent(bound.getEquipmentCode().trim(), pointCode);
+                if (bound.getEquipmentTypeId() == null) {
+                    continue;
+                }
+                EquipmentType boundType = types.get(bound.getEquipmentTypeId());
+                if (boundType != null && boundType.getId() != null) {
+                    deviceTypes.add(String.valueOf(boundType.getId()));
+                }
+            }
+            sourcePoints.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
+                    pointCode,
+                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null));
+        }
+
+        List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();
+        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
+            if (device == null || isBlank(device.getEquipmentCode())) {
+                continue;
+            }
+            String deviceCode = device.getEquipmentCode().trim();
+            EquipmentType type = device.getEquipmentTypeId() == null
+                    ? null
+                    : types.get(device.getEquipmentTypeId());
+
+            EquipmentStatus status = null;
+            try {
+                status = equipmentStatusService.getByDeviceCode(deviceCode);
+            } catch (Exception ignored) {
+                // 状态缺失按 UNKNOWN 上报,不阻断概览
+            }
+            devices.add(new CockpitMonitoringOverviewSnapshot.SourceDevice(
+                    deviceCode,
+                    type == null || type.getId() == null ? null : String.valueOf(type.getId()),
+                    monitoringState(status),
+                    status == null || status.getStatusUpdateTime() == null
+                            ? null
+                            : TIME_FORMATTER.format(status.getStatusUpdateTime()
+                                    .atZone(BUSINESS_ZONE).toOffsetDateTime()),
+                    pointCodeByDevice.get(deviceCode)));
+        }
+
+        List<CockpitMonitoringOverviewSnapshot.SourceType> sourceTypes = types.values().stream()
+                .filter(type -> type.getId() != null)
+                .map(type -> new CockpitMonitoringOverviewSnapshot.SourceType(
+                        String.valueOf(type.getId()),
+                        type.getTypeName(),
+                        CockpitTypeIndustry.industryOf(type, types),
+                        "SMART_DEVICE"))
+                .collect(Collectors.toList());
+
+        return new CockpitMonitoringOverviewSnapshot(
+                businessTime(),
+                sourceTypes,
+                devices,
+                sourcePoints);
+    }
+
+    /** 现网无状态记录一律 UNKNOWN:既不冒充 OFFLINE,也不伪造 STALE(无样本新鲜度依据)。 */
+    private static String monitoringState(EquipmentStatus status) {
+        if (status == null || status.getOnlineStatus() == null) {
+            return "UNKNOWN";
+        }
+        return Integer.valueOf(1).equals(status.getOnlineStatus()) ? "ONLINE" : "OFFLINE";
+    }
+
+    private Map<Integer, EquipmentType> equipmentTypes() {
+        Map<Integer, EquipmentType> result = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private String businessTime() {
+        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
+    }
+}

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

@@ -0,0 +1,233 @@
+package com.zksy.cockpit.monitoring;
+
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.EquipmentTypeService;
+import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
+import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
+import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 运行监测 / 运行概况 的真实数据适配器(BT-5 第 4 片)。
+ *
+ * <p>组合既有表与服务,不新增表:
+ * <ul>
+ *   <li>设备指标态:{@code equipment_status}(在线/报警)+ {@code early_warning}(未关闭预警)+ {@code work_order}(在途);</li>
+ *   <li>预警事件:{@code early_warning};报警事件:{@code alarm_data};</li>
+ *   <li>类型字典 {@code equipment_type}(事件与设备的 typeCode/行业均由此推导)。</li>
+ * </ul>
+ *
+ * <p><b>关键实测口径</b>:现网 {@code alarm_data.device_type} 与 {@code early_warning.warning_type}
+ * 是**描述性字符串**(如「流量监测」「environment」「GAS_PIPE」),并非 {@code equipment_type.id}。
+ * 因此事件的 {@code typeCode}/{@code industry} 一律经
+ * {@code device_code → equipment_base → equipment_type} 推导,保证与服务层类型字典一致;
+ * 设备无法匹配到类型时 typeCode 为 null(服务层按「未知类型」计入 excluded,不静默归类)。
+ *
+ * <p><b>不臆造</b>:现网无「样本过期」依据 → 不产生 EXPIRED;无坐标校核字段 → {@code coordinateVerified=false};
+ * 无可靠区域来源 → region 为 null。指标态取值优先序:缺状态→UNKNOWN,离线→OFFLINE,报警→ALARM,预警中→WARNING,其余→NORMAL。
+ */
+@Component
+@Primary
+public class BusinessCockpitMonitoringSituationAdapter implements CockpitMonitoringSituationAdapter {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentStatusService equipmentStatusService;
+    private final EquipmentTypeService equipmentTypeService;
+    private final EarlyWarningService earlyWarningService;
+    private final AlarmDataService alarmDataService;
+    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
+    private final Clock clock;
+
+    public BusinessCockpitMonitoringSituationAdapter(
+            EquipmentBaseService equipmentBaseService,
+            EquipmentStatusService equipmentStatusService,
+            EquipmentTypeService equipmentTypeService,
+            EarlyWarningService earlyWarningService,
+            AlarmDataService alarmDataService,
+            BusinessCockpitDeviceFactResolver deviceFactResolver,
+            Clock clock) {
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentStatusService = equipmentStatusService;
+        this.equipmentTypeService = equipmentTypeService;
+        this.earlyWarningService = earlyWarningService;
+        this.alarmDataService = alarmDataService;
+        this.deviceFactResolver = deviceFactResolver;
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitMonitoringSituationSnapshot load() {
+        Map<Integer, EquipmentType> types = equipmentTypes();
+        List<EquipmentBase> ledger = safeList(equipmentBaseService.list());
+
+        // 事件设备编码 → 类型,一次建索引(避免逐事件回查台账造成 N+1)
+        Map<String, EquipmentType> typeByDeviceCode = new HashMap<>();
+        for (EquipmentBase device : ledger) {
+            if (device != null && !isBlank(device.getEquipmentCode())) {
+                typeByDeviceCode.put(device.getEquipmentCode().trim(), typeOf(device, types));
+            }
+        }
+
+        List<CockpitMonitoringSituationSnapshot.SourceMetricDevice> devices = new ArrayList<>();
+        for (EquipmentBase device : ledger) {
+            if (device == null || isBlank(device.getEquipmentCode())) {
+                continue;
+            }
+            String deviceCode = device.getEquipmentCode().trim();
+            EquipmentType type = typeByDeviceCode.get(deviceCode);
+            String typeCode = typeCodeOf(type);
+
+            CockpitMonitoringPointDeviceSnapshot fact = deviceFactResolver.resolve(
+                    device, type == null ? null : type.getTypeName());
+            devices.add(new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
+                    deviceCode,
+                    device.getEquipmentName(),
+                    null,
+                    typeCode,
+                    metricStatus(deviceCode, fact),
+                    fact.getStatusReasons(),
+                    fact.getStatusTime(),
+                    device.getLongitude(),
+                    device.getLatitude(),
+                    false));
+        }
+
+        List<CockpitMonitoringSituationSnapshot.SourceEvent> warnings = new ArrayList<>();
+        for (EarlyWarning warning : safeList(earlyWarningService.list())) {
+            if (warning == null || isBlank(warning.getWarningNo())) {
+                continue;
+            }
+            String deviceCode = isBlank(warning.getDeviceCode()) ? null : warning.getDeviceCode().trim();
+            EquipmentType type = deviceCode == null ? null : typeByDeviceCode.get(deviceCode);
+            warnings.add(new CockpitMonitoringSituationSnapshot.SourceEvent(
+                    warning.getWarningNo().trim(),
+                    warning.getWarningLevel(),
+                    offsetTime(warning.getPublishTime()),
+                    industryOf(type, types),
+                    typeCodeOf(type),
+                    deviceCode,
+                    warning.getDeviceName()));
+        }
+
+        List<CockpitMonitoringSituationSnapshot.SourceEvent> alarms = new ArrayList<>();
+        for (AlarmData alarm : safeList(alarmDataService.list())) {
+            if (alarm == null || isBlank(alarm.getId())) {
+                continue;
+            }
+            String deviceCode = isBlank(alarm.getDeviceCode()) ? null : alarm.getDeviceCode().trim();
+            EquipmentType type = deviceCode == null ? null : typeByDeviceCode.get(deviceCode);
+            alarms.add(new CockpitMonitoringSituationSnapshot.SourceEvent(
+                    alarm.getId().trim(),
+                    alarm.getAlarmLevel() == null ? null : String.valueOf(alarm.getAlarmLevel()),
+                    offsetTime(alarm.getCreateTime()),
+                    industryOf(type, types),
+                    typeCodeOf(type),
+                    deviceCode,
+                    alarm.getDeviceName()));
+        }
+
+        List<CockpitMonitoringSituationSnapshot.SourceType> sourceTypes = types.values().stream()
+                .filter(type -> type.getId() != null)
+                .map(type -> new CockpitMonitoringSituationSnapshot.SourceType(
+                        String.valueOf(type.getId()),
+                        type.getTypeName(),
+                        CockpitTypeIndustry.industryOf(type, types),
+                        "SMART_DEVICE"))
+                .collect(Collectors.toList());
+
+        return new CockpitMonitoringSituationSnapshot(
+                businessTime(),
+                sourceTypes,
+                devices,
+                warnings,
+                alarms);
+    }
+
+    /**
+     * 指标态:缺状态记录 → UNKNOWN(不冒充 OFFLINE);离线 → OFFLINE;报警 → ALARM;预警中 → WARNING;其余 → NORMAL。
+     * 现网无样本新鲜度依据,故不产生 EXPIRED。
+     */
+    private String metricStatus(String deviceCode, CockpitMonitoringPointDeviceSnapshot fact) {
+        EquipmentStatus status = null;
+        try {
+            status = equipmentStatusService.getByDeviceCode(deviceCode);
+        } catch (Exception ignored) {
+            // 状态不可得按 UNKNOWN 处理
+        }
+        if (status == null) {
+            return "UNKNOWN";
+        }
+        if (Integer.valueOf(0).equals(status.getOnlineStatus())) {
+            return "OFFLINE";
+        }
+        if (fact.isAlarm()) {
+            return "ALARM";
+        }
+        if (fact.isWarning()) {
+            return "WARNING";
+        }
+        return "NORMAL";
+    }
+
+    private EquipmentType typeOf(EquipmentBase device, Map<Integer, EquipmentType> types) {
+        return device.getEquipmentTypeId() == null ? null : types.get(device.getEquipmentTypeId());
+    }
+
+    private static String typeCodeOf(EquipmentType type) {
+        return type == null || type.getId() == null ? null : String.valueOf(type.getId());
+    }
+
+    private static String industryOf(EquipmentType type, Map<Integer, EquipmentType> types) {
+        return CockpitTypeIndustry.industryOf(type, types);
+    }
+
+    private static String offsetTime(LocalDateTime value) {
+        return value == null ? null : TIME_FORMATTER.format(value.atZone(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private Map<Integer, EquipmentType> equipmentTypes() {
+        Map<Integer, EquipmentType> result = new HashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private String businessTime() {
+        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
+    }
+}

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

@@ -2,16 +2,19 @@ package com.zksy.cockpit.monitoring;
 
 
 public class CockpitMonitoringDistributionDeviceStatusDelta {
 public class CockpitMonitoringDistributionDeviceStatusDelta {
     private final String deviceCode;
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String monitoringState;
     private final String monitoringState;
     private final String sampledAt;
     private final String sampledAt;
     private final String serverUpdatedAt;
     private final String serverUpdatedAt;
 
 
     public CockpitMonitoringDistributionDeviceStatusDelta(
     public CockpitMonitoringDistributionDeviceStatusDelta(
             String deviceCode,
             String deviceCode,
+            String monitoringPointCode,
             String monitoringState,
             String monitoringState,
             String sampledAt,
             String sampledAt,
             String serverUpdatedAt) {
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.monitoringState = monitoringState;
         this.monitoringState = monitoringState;
         this.sampledAt = sampledAt;
         this.sampledAt = sampledAt;
         this.serverUpdatedAt = serverUpdatedAt;
         this.serverUpdatedAt = serverUpdatedAt;
@@ -21,6 +24,10 @@ public class CockpitMonitoringDistributionDeviceStatusDelta {
         return deviceCode;
         return deviceCode;
     }
     }
 
 
+    public String getMonitoringPointCode() {
+        return monitoringPointCode;
+    }
+
     public String getMonitoringState() {
     public String getMonitoringState() {
         return monitoringState;
         return monitoringState;
     }
     }

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

@@ -39,7 +39,7 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitMonitoringDistributionService {
 public class CockpitMonitoringDistributionService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     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;
@@ -579,7 +579,7 @@ public class CockpitMonitoringDistributionService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -2,22 +2,26 @@ package com.zksy.cockpit.monitoring;
 
 
 public class CockpitMonitoringOverviewDeviceStatusDelta {
 public class CockpitMonitoringOverviewDeviceStatusDelta {
     private final String deviceCode;
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String monitoringState;
     private final String monitoringState;
     private final String sampledAt;
     private final String sampledAt;
     private final String serverUpdatedAt;
     private final String serverUpdatedAt;
 
 
     public CockpitMonitoringOverviewDeviceStatusDelta(
     public CockpitMonitoringOverviewDeviceStatusDelta(
             String deviceCode,
             String deviceCode,
+            String monitoringPointCode,
             String monitoringState,
             String monitoringState,
             String sampledAt,
             String sampledAt,
             String serverUpdatedAt) {
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.monitoringState = monitoringState;
         this.monitoringState = monitoringState;
         this.sampledAt = sampledAt;
         this.sampledAt = sampledAt;
         this.serverUpdatedAt = serverUpdatedAt;
         this.serverUpdatedAt = serverUpdatedAt;
     }
     }
 
 
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceCode() { return deviceCode; }
+    public String getMonitoringPointCode() { return monitoringPointCode; }
     public String getMonitoringState() { return monitoringState; }
     public String getMonitoringState() { return monitoringState; }
     public String getSampledAt() { return sampledAt; }
     public String getSampledAt() { return sampledAt; }
     public String getServerUpdatedAt() { return serverUpdatedAt; }
     public String getServerUpdatedAt() { return serverUpdatedAt; }

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

@@ -27,7 +27,8 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitMonitoringOverviewService {
 public class CockpitMonitoringOverviewService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList(
+            "GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final String SMART_DEVICE = "SMART_DEVICE";
     private static final String SMART_DEVICE = "SMART_DEVICE";
 
 
     private final CockpitMonitoringOverviewAdapter adapter;
     private final CockpitMonitoringOverviewAdapter adapter;
@@ -310,7 +311,8 @@ public class CockpitMonitoringOverviewService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException(
+                    "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -2,16 +2,19 @@ package com.zksy.cockpit.monitoring;
 
 
 public class CockpitMonitoringSituationDeviceStatusDelta {
 public class CockpitMonitoringSituationDeviceStatusDelta {
     private final String deviceCode;
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String metricStatus;
     private final String metricStatus;
     private final String sampledAt;
     private final String sampledAt;
     private final String serverUpdatedAt;
     private final String serverUpdatedAt;
 
 
     public CockpitMonitoringSituationDeviceStatusDelta(
     public CockpitMonitoringSituationDeviceStatusDelta(
             String deviceCode,
             String deviceCode,
+            String monitoringPointCode,
             String metricStatus,
             String metricStatus,
             String sampledAt,
             String sampledAt,
             String serverUpdatedAt) {
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.metricStatus = metricStatus;
         this.metricStatus = metricStatus;
         this.sampledAt = sampledAt;
         this.sampledAt = sampledAt;
         this.serverUpdatedAt = serverUpdatedAt;
         this.serverUpdatedAt = serverUpdatedAt;
@@ -21,6 +24,10 @@ public class CockpitMonitoringSituationDeviceStatusDelta {
         return deviceCode;
         return deviceCode;
     }
     }
 
 
+    public String getMonitoringPointCode() {
+        return monitoringPointCode;
+    }
+
     public String getMetricStatus() {
     public String getMetricStatus() {
         return metricStatus;
         return metricStatus;
     }
     }

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

@@ -39,7 +39,8 @@ import java.util.Set;
 @Service
 @Service
 public class CockpitMonitoringSituationService {
 public class CockpitMonitoringSituationService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> INDUSTRIES = Arrays.asList(
+            "GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
     private static final List<String> METRIC_STATUSES =
     private static final List<String> METRIC_STATUSES =
             Arrays.asList("NORMAL", "WARNING", "ALARM", "EXPIRED", "OFFLINE", "UNKNOWN");
             Arrays.asList("NORMAL", "WARNING", "ALARM", "EXPIRED", "OFFLINE", "UNKNOWN");
     private static final Set<String> ABNORMAL_STATUSES =
     private static final Set<String> ABNORMAL_STATUSES =
@@ -401,7 +402,8 @@ public class CockpitMonitoringSituationService {
         }
         }
         String resolved = industry.trim();
         String resolved = industry.trim();
         if (!INDUSTRIES.contains(resolved)) {
         if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException(
+                    "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
         return resolved;
         return resolved;
     }
     }

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

@@ -2,22 +2,26 @@ package com.zksy.cockpit.overview;
 
 
 public class CockpitOverviewMonitoringDeviceStatusDelta {
 public class CockpitOverviewMonitoringDeviceStatusDelta {
     private final String deviceCode;
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String monitoringState;
     private final String monitoringState;
     private final String statusTime;
     private final String statusTime;
     private final String serverUpdatedAt;
     private final String serverUpdatedAt;
 
 
     public CockpitOverviewMonitoringDeviceStatusDelta(
     public CockpitOverviewMonitoringDeviceStatusDelta(
             String deviceCode,
             String deviceCode,
+            String monitoringPointCode,
             String monitoringState,
             String monitoringState,
             String statusTime,
             String statusTime,
             String serverUpdatedAt) {
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.monitoringState = monitoringState;
         this.monitoringState = monitoringState;
         this.statusTime = statusTime;
         this.statusTime = statusTime;
         this.serverUpdatedAt = serverUpdatedAt;
         this.serverUpdatedAt = serverUpdatedAt;
     }
     }
 
 
     public String getDeviceCode() { return deviceCode; }
     public String getDeviceCode() { return deviceCode; }
+    public String getMonitoringPointCode() { return monitoringPointCode; }
     public String getMonitoringState() { return monitoringState; }
     public String getMonitoringState() { return monitoringState; }
     public String getStatusTime() { return statusTime; }
     public String getStatusTime() { return statusTime; }
     public String getServerUpdatedAt() { return serverUpdatedAt; }
     public String getServerUpdatedAt() { return serverUpdatedAt; }

+ 5 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewMonitoringService.java

@@ -23,9 +23,9 @@ import java.util.Objects;
 public class CockpitOverviewMonitoringService {
 public class CockpitOverviewMonitoringService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final List<String> FORMAL_INDUSTRIES =
     private static final List<String> FORMAL_INDUSTRIES =
-            Collections.unmodifiableList(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE"));
+            Collections.unmodifiableList(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE"));
     private static final List<String> FORMAL_INDUSTRY_KEYS =
     private static final List<String> FORMAL_INDUSTRY_KEYS =
-            Collections.unmodifiableList(Arrays.asList("gas", "waterSupply", "drainage"));
+            Collections.unmodifiableList(Arrays.asList("gas", "waterSupply", "drainage", "manhole"));
 
 
     private final CockpitOverviewMonitoringAdapter adapter;
     private final CockpitOverviewMonitoringAdapter adapter;
     private final Clock clock;
     private final Clock clock;
@@ -52,7 +52,7 @@ public class CockpitOverviewMonitoringService {
     public CockpitErrorPayload invalidRequest(String requestId) {
     public CockpitErrorPayload invalidRequest(String requestId) {
         return new CockpitErrorPayload(
         return new CockpitErrorPayload(
                 CockpitErrorCategory.INVALID_REQUEST,
                 CockpitErrorCategory.INVALID_REQUEST,
-                "industry must be GAS, WATER_SUPPLY or DRAINAGE",
+                "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE",
                 requestId,
                 requestId,
                 businessTime());
                 businessTime());
     }
     }
@@ -124,7 +124,7 @@ public class CockpitOverviewMonitoringService {
 
 
     private void validateIndustry(String industry) {
     private void validateIndustry(String industry) {
         if (!isBlank(industry) && !FORMAL_INDUSTRIES.contains(industry)) {
         if (!isBlank(industry) && !FORMAL_INDUSTRIES.contains(industry)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
         }
         }
     }
     }
 
 
@@ -342,6 +342,7 @@ public class CockpitOverviewMonitoringService {
         if ("GAS".equals(code)) return "燃气";
         if ("GAS".equals(code)) return "燃气";
         if ("WATER_SUPPLY".equals(code)) return "供水";
         if ("WATER_SUPPLY".equals(code)) return "供水";
         if ("DRAINAGE".equals(code)) return "排水";
         if ("DRAINAGE".equals(code)) return "排水";
+        if ("MANHOLE".equals(code)) return "窨井";
         return code;
         return code;
     }
     }
 
 

+ 141 - 21
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsService.java

@@ -10,8 +10,11 @@ import org.springframework.stereotype.Service;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.math.RoundingMode;
 import java.time.Clock;
 import java.time.Clock;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
@@ -25,11 +28,14 @@ public class CockpitOverviewWarningSettingsService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final List<String> MODES =
     private static final List<String> MODES =
             Collections.unmodifiableList(Arrays.asList("WARNING", "ALARM"));
             Collections.unmodifiableList(Arrays.asList("WARNING", "ALARM"));
+    private static final List<String> RANGES = Collections.unmodifiableList(
+            Arrays.asList("LAST_24_HOURS", "LAST_7_DAYS", "LAST_30_DAYS"));
     private static final List<Integer> WORK_ORDER_STATES =
     private static final List<Integer> WORK_ORDER_STATES =
             CockpitWorkOrderStateDictionary.states().stream()
             CockpitWorkOrderStateDictionary.states().stream()
                     .map(CockpitWorkOrderState::getCode)
                     .map(CockpitWorkOrderState::getCode)
                     .collect(Collectors.toList());
                     .collect(Collectors.toList());
-    private static final int HANDLED_STATE = 6;
+    private static final int HANDLED_STATE = 5;
+    private static final DateTimeFormatter BUCKET_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
 
 
     private final CockpitOverviewWarningSettingsAdapter adapter;
     private final CockpitOverviewWarningSettingsAdapter adapter;
     private final Clock clock;
     private final Clock clock;
@@ -41,8 +47,14 @@ public class CockpitOverviewWarningSettingsService {
         this.clock = clock;
         this.clock = clock;
     }
     }
 
 
-    public CockpitOverviewWarningSettingsResponse load(String requestId, String mode) {
+    /**
+     * 预警设置概览。趋势窗口可声明:`range` 预设(同 `infrastructure/operating-information` 三档)
+     * 或 `startTime`/`endTime` 显式区间;都不声明时维持原 `CURRENT_BASELINE` 行为。
+     */
+    public CockpitOverviewWarningSettingsResponse load(String requestId, String mode,
+            String range, String startTime, String endTime) {
         String selectedMode = validateMode(mode);
         String selectedMode = validateMode(mode);
+        Window window = resolveWindow(range, startTime, endTime);
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
         boolean hasSnapshot = snapshot != null && !isBlank(snapshot.getUpdatedAt());
         boolean hasSnapshot = snapshot != null && !isBlank(snapshot.getUpdatedAt());
         String updatedAt = hasSnapshot ? snapshot.getUpdatedAt() : null;
         String updatedAt = hasSnapshot ? snapshot.getUpdatedAt() : null;
@@ -56,22 +68,25 @@ public class CockpitOverviewWarningSettingsService {
                 "预警设置",
                 "预警设置",
                 selectedMode,
                 selectedMode,
                 "Asia/Shanghai",
                 "Asia/Shanghai",
-                new CockpitOverviewWarningSettingsResponse.Range("CURRENT_BASELINE", null, null),
+                new CockpitOverviewWarningSettingsResponse.Range(
+                        window.type,
+                        window.start == null ? null : format(window.start),
+                        window.end == null ? null : format(window.end)),
                 updatedAt,
                 updatedAt,
                 new CockpitOverviewWarningSettingsResponse.Freshness(
                 new CockpitOverviewWarningSettingsResponse.Freshness(
                         freshnessStatus,
                         freshnessStatus,
                         freshnessReason,
                         freshnessReason,
                         updatedAt,
                         updatedAt,
                         businessTime()),
                         businessTime()),
-                familyMetrics(snapshot, "WARNING"),
-                familyMetrics(snapshot, "ALARM"),
+                familyMetrics(snapshot, "WARNING", window),
+                familyMetrics(snapshot, "ALARM", window),
                 mapFilter(selectedMode));
                 mapFilter(selectedMode));
     }
     }
 
 
-    public CockpitErrorPayload invalidRequest(String requestId) {
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
         return new CockpitErrorPayload(
         return new CockpitErrorPayload(
                 CockpitErrorCategory.INVALID_REQUEST,
                 CockpitErrorCategory.INVALID_REQUEST,
-                "mode must be WARNING or ALARM",
+                message,
                 requestId,
                 requestId,
                 businessTime());
                 businessTime());
     }
     }
@@ -94,9 +109,57 @@ public class CockpitOverviewWarningSettingsService {
         return mode;
         return mode;
     }
     }
 
 
+    /**
+     * 窗口解析:显式区间优先;否则按 `range` 预设;都不给时维持 `CURRENT_BASELINE`(无界)。
+     */
+    private Window resolveWindow(String range, String startTime, String endTime) {
+        boolean hasStart = !isBlank(startTime);
+        boolean hasEnd = !isBlank(endTime);
+        if (hasStart != hasEnd) {
+            throw new CockpitInvalidRequestException("startTime and endTime must be provided together");
+        }
+        if (hasStart) {
+            OffsetDateTime start = parseTime(startTime, "startTime");
+            OffsetDateTime end = parseTime(endTime, "endTime");
+            if (start.isAfter(end)) {
+                throw new CockpitInvalidRequestException("startTime must not be after endTime");
+            }
+            return new Window("CUSTOM", start, end);
+        }
+        if (isBlank(range)) {
+            return new Window("CURRENT_BASELINE", null, null);
+        }
+        String resolved = range.trim();
+        if (!RANGES.contains(resolved)) {
+            throw new CockpitInvalidRequestException(
+                    "range must be LAST_24_HOURS, LAST_7_DAYS or LAST_30_DAYS");
+        }
+        OffsetDateTime end = OffsetDateTime.now(clock)
+                .atZoneSameInstant(BUSINESS_ZONE)
+                .toOffsetDateTime();
+        OffsetDateTime start;
+        if ("LAST_7_DAYS".equals(resolved)) {
+            start = end.minusDays(7);
+        } else if ("LAST_30_DAYS".equals(resolved)) {
+            start = end.minusDays(30);
+        } else {
+            start = end.minusHours(24);
+        }
+        return new Window(resolved, start, end);
+    }
+
+    private static OffsetDateTime parseTime(String value, String fieldName) {
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException("invalid " + fieldName);
+        }
+    }
+
     private CockpitOverviewWarningSettingsResponse.FamilyMetrics familyMetrics(
     private CockpitOverviewWarningSettingsResponse.FamilyMetrics familyMetrics(
             CockpitOverviewWarningSettingsSnapshot snapshot,
             CockpitOverviewWarningSettingsSnapshot snapshot,
-            String eventFamily) {
+            String eventFamily,
+            Window window) {
         List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> sourceEvents =
         List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> sourceEvents =
                 snapshot == null || snapshot.getEvents() == null
                 snapshot == null || snapshot.getEvents() == null
                         ? Collections.emptyList()
                         ? Collections.emptyList()
@@ -130,12 +193,12 @@ public class CockpitOverviewWarningSettingsService {
             }
             }
             total++;
             total++;
             workOrderStates.merge(state, 1, Integer::sum);
             workOrderStates.merge(state, 1, Integer::sum);
-            if (state < HANDLED_STATE) {
-                pending++;
-            } else if (state == HANDLED_STATE) {
+            if (state == HANDLED_STATE) {
                 handled++;
                 handled++;
-            } else {
+            } else if (state == 7) {
                 cancelledCount++;
                 cancelledCount++;
+            } else {
+                pending++;
             }
             }
         }
         }
 
 
@@ -164,22 +227,32 @@ public class CockpitOverviewWarningSettingsService {
                 invalidWorkOrderStateCount,
                 invalidWorkOrderStateCount,
                 efficiency,
                 efficiency,
                 workOrderStates,
                 workOrderStates,
-                trend(snapshot, eventFamily));
+                trend(snapshot, eventFamily, window));
     }
     }
 
 
+    /**
+     * 趋势桶跟随声明窗口:桶为业务时区日期(适配器契约 `yyyy-MM-dd`),
+     * 保留桶日期落在窗口起止日期之间(含端点)的桶;窗口无界时不筛选;
+     * 桶标签无法解析时保留该桶,不静默丢数据。
+     */
     private List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend(
     private List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend(
             CockpitOverviewWarningSettingsSnapshot snapshot,
             CockpitOverviewWarningSettingsSnapshot snapshot,
-            String eventFamily) {
+            String eventFamily,
+            Window window) {
         if (snapshot == null || snapshot.getTrendPoints() == null) {
         if (snapshot == null || snapshot.getTrendPoints() == null) {
             return Collections.emptyList();
             return Collections.emptyList();
         }
         }
         List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend = new ArrayList<>();
         List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend = new ArrayList<>();
         for (CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint point : snapshot.getTrendPoints()) {
         for (CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint point : snapshot.getTrendPoints()) {
-            if (point != null && eventFamily.equals(point.getEventFamily())) {
-                trend.add(new CockpitOverviewWarningSettingsResponse.TrendPoint(
-                        point.getBucket(), point.getTotal(), point.getHandled(),
-                        efficiency(point.getTotal(), point.getHandled())));
+            if (point == null || !eventFamily.equals(point.getEventFamily())) {
+                continue;
+            }
+            if (!window.covers(point.getBucket())) {
+                continue;
             }
             }
+            trend.add(new CockpitOverviewWarningSettingsResponse.TrendPoint(
+                    point.getBucket(), point.getTotal(), point.getHandled(),
+                    efficiency(point.getTotal(), point.getHandled())));
         }
         }
         return trend;
         return trend;
     }
     }
@@ -204,12 +277,59 @@ public class CockpitOverviewWarningSettingsService {
     }
     }
 
 
     private String businessTime() {
     private String businessTime() {
-        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
-                .format(clock.instant().atZone(BUSINESS_ZONE));
+        return format(OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private static String format(OffsetDateTime value) {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value);
     }
     }
 
 
     private static boolean isBlank(String value) {
     private static boolean isBlank(String value) {
         return value == null || value.trim().isEmpty();
         return value == null || value.trim().isEmpty();
     }
     }
-}
 
 
+    /** 声明窗口;`start`/`end` 为 `null` 表示无界(`CURRENT_BASELINE`)。 */
+    private static class Window {
+        private final String type;
+        private final OffsetDateTime start;
+        private final OffsetDateTime end;
+
+        private Window(String type, OffsetDateTime start, OffsetDateTime end) {
+            this.type = type;
+            this.start = start;
+            this.end = end;
+        }
+
+        private boolean isBounded() {
+            return start != null && end != null;
+        }
+
+        private boolean covers(String bucket) {
+            if (!isBounded() || isBlank(bucket)) {
+                return true;
+            }
+            LocalDate date = bucketDate(bucket);
+            if (date == null) {
+                return true;
+            }
+            LocalDate startDate = start.atZoneSameInstant(BUSINESS_ZONE).toLocalDate();
+            LocalDate endDate = end.atZoneSameInstant(BUSINESS_ZONE).toLocalDate();
+            return !date.isBefore(startDate) && !date.isAfter(endDate);
+        }
+
+        /** 桶标签:优先按业务日解析,其次按带偏移时间戳解析;都失败返回 `null`。 */
+        private static LocalDate bucketDate(String bucket) {
+            String value = bucket.trim();
+            try {
+                return LocalDate.parse(value, BUCKET_DATE);
+            } catch (DateTimeParseException ignored) {
+                // 继续尝试时间戳形态
+            }
+            try {
+                return OffsetDateTime.parse(value).atZoneSameInstant(BUSINESS_ZONE).toLocalDate();
+            } catch (DateTimeParseException ignored) {
+                return null;
+            }
+        }
+    }
+}

+ 132 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java

@@ -0,0 +1,132 @@
+package com.zksy.cockpit.point;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import org.springframework.stereotype.Component;
+
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 从现网既有表组合出「单台设备的实时事实」(在线/报警/预警/在途工单),供各只读适配器复用。
+ *
+ * <p>只读、不新增表、不加投影;任何一项数据缺失都按保守口径降级(缺状态=离线),
+ * 不为了「看起来正常」而伪造在线/无警状态。
+ */
+@Component
+public class BusinessCockpitDeviceFactResolver {
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    /** 未关闭的预警状态:现网取值含 PENDING/PROCESSING 等。 */
+    private static final Set<String> OPEN_WARNING_STATUS = new LinkedHashSet<>(
+            Arrays.asList("PENDING", "PROCESSING", "HANDLING"));
+
+    private final EquipmentStatusService equipmentStatusService;
+    private final EarlyWarningService earlyWarningService;
+    private final WorkOrderService workOrderService;
+
+    public BusinessCockpitDeviceFactResolver(
+            EquipmentStatusService equipmentStatusService,
+            EarlyWarningService earlyWarningService,
+            WorkOrderService workOrderService) {
+        this.equipmentStatusService = equipmentStatusService;
+        this.earlyWarningService = earlyWarningService;
+        this.workOrderService = workOrderService;
+    }
+
+    public CockpitMonitoringPointDeviceSnapshot resolve(EquipmentBase device, String deviceTypeName) {
+        String deviceCode = device.getEquipmentCode().trim();
+        EquipmentStatus status = null;
+        try {
+            status = equipmentStatusService.getByDeviceCode(deviceCode);
+        } catch (Exception ignored) {
+            // 状态缺失按离线处理,不阻断整张图
+        }
+        boolean online = status != null && Integer.valueOf(1).equals(status.getOnlineStatus());
+        boolean alarm = status != null && Integer.valueOf(1).equals(status.getAlarmStatus());
+
+        List<String> reasons = new ArrayList<>();
+        if (alarm) {
+            reasons.add(deviceCode + " 报警");
+        }
+        if (!online && status != null) {
+            reasons.add(deviceCode + " 离线");
+        }
+        if (status == null) {
+            reasons.add(deviceCode + " 无状态记录");
+        }
+
+        return new CockpitMonitoringPointDeviceSnapshot(
+                deviceCode,
+                device.getEquipmentName(),
+                deviceTypeName,
+                online,
+                alarm,
+                hasOpenWarning(deviceCode),
+                hasOpenWorkOrder(device.getEquipmentId()),
+                reasons,
+                status == null || status.getStatusUpdateTime() == null
+                        ? null
+                        : TIME_FORMATTER.format(status.getStatusUpdateTime()
+                                .atZone(BUSINESS_ZONE).toOffsetDateTime()));
+    }
+
+    private boolean hasOpenWarning(String deviceCode) {
+        try {
+            List<EarlyWarning> warnings = safeList(earlyWarningService.list(
+                    new LambdaQueryWrapper<EarlyWarning>().eq(EarlyWarning::getDeviceCode, deviceCode)));
+            return warnings.stream()
+                    .anyMatch(warning -> warning != null
+                            && warning.getStatus() != null
+                            && OPEN_WARNING_STATUS.contains(warning.getStatus().trim().toUpperCase()));
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
+    /**
+     * 在途工单:现网 {@code work_order.order_status} 是 0-based
+     * (0 待派单、1 待接单、2 已接单、3 处理中、4 待验收、5 已结案、6 延期中、7 已取消),
+     * 与 cockpit 冻结的 1-based 七态**不同值域**;此处只判「是否在途」,不冒充冻结字典。
+     */
+    private boolean hasOpenWorkOrder(String deviceId) {
+        if (deviceId == null || deviceId.trim().isEmpty()) {
+            return false;
+        }
+        try {
+            List<WorkOrder> orders = safeList(workOrderService.list(
+                    new QueryWrapper<WorkOrder>().eq("device_id", deviceId)));
+            return orders.stream()
+                    .anyMatch(order -> order != null && isOpenOrderStatus(order.getOrderStatus()));
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
+    private static boolean isOpenOrderStatus(Integer orderStatus) {
+        if (orderStatus == null) {
+            return false;
+        }
+        // 0-based:0 待派单、1 待接单、2 已接单、3 处理中、4 待验收、6 延期中 均为在途;5 已结案、7 已取消为终态。
+        return (orderStatus >= 0 && orderStatus <= 4) || orderStatus == 6;
+    }
+
+    private static <T> List<T> safeList(List<T> value) {
+        return value == null ? Collections.emptyList() : value;
+    }
+}

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

@@ -120,7 +120,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
     }
     }
 
 
     private CockpitWorkOrderSummary toSummary(WorkOrder order, String eventSource, String eventCode) {
     private CockpitWorkOrderSummary toSummary(WorkOrder order, String eventSource, String eventCode) {
-        CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byLegacyCode(order.getOrderStatus());
+        CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byCode(order.getOrderStatus());
         return new CockpitWorkOrderSummary(
         return new CockpitWorkOrderSummary(
                 order.getOrderNo(),
                 order.getOrderNo(),
                 resolveDeviceCode(order.getDeviceId()),
                 resolveDeviceCode(order.getDeviceId()),

+ 21 - 9
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/CockpitWorkOrderStateDictionary.java

@@ -6,14 +6,22 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
+/**
+ * 对外公开的工单状态字典,与运行时 {@code WorkOrderStatusEnum}(0–7)对齐。
+ *
+ * <p>0 待派单、1 待接单、2 已接单、3 处理中、4 待验收、5 已结案、6 延期中、7 已取消;
+ * 终态为 5、7,延期中是处理旁路。旧 ADR-0008 的“从 1 开始七态”仅保留为历史决策,
+ * 不再作为对外编码。
+ */
 public final class CockpitWorkOrderStateDictionary {
 public final class CockpitWorkOrderStateDictionary {
     private static final List<CockpitWorkOrderState> STATES = Collections.unmodifiableList(Arrays.asList(
     private static final List<CockpitWorkOrderState> STATES = Collections.unmodifiableList(Arrays.asList(
-            new CockpitWorkOrderState(1, "待派单"),
-            new CockpitWorkOrderState(2, "待接单"),
-            new CockpitWorkOrderState(3, "已接单"),
-            new CockpitWorkOrderState(4, "处理中"),
-            new CockpitWorkOrderState(5, "待验收"),
-            new CockpitWorkOrderState(6, "已结案"),
+            new CockpitWorkOrderState(0, "待派单"),
+            new CockpitWorkOrderState(1, "待接单"),
+            new CockpitWorkOrderState(2, "已接单"),
+            new CockpitWorkOrderState(3, "处理中"),
+            new CockpitWorkOrderState(4, "待验收"),
+            new CockpitWorkOrderState(5, "已结案"),
+            new CockpitWorkOrderState(6, "延期中"),
             new CockpitWorkOrderState(7, "已取消")
             new CockpitWorkOrderState(7, "已取消")
     ));
     ));
 
 
@@ -36,13 +44,17 @@ public final class CockpitWorkOrderStateDictionary {
         return code == null ? null : BY_CODE.get(code);
         return code == null ? null : BY_CODE.get(code);
     }
     }
 
 
+    /**
+     * 迁移期兼容:把旧编码(1 待派单 … 6 已结案、7 已取消)转换到运行时 0–7 编码。
+     * 旧 1–6 映射到新 0–5(旧 6 已结案 → 新 5 已结案);旧 7 已取消 → 新 7 已取消;
+     * 未知值收敛为新 7 已取消,不引入额外状态。
+     */
     public static CockpitWorkOrderState byLegacyCode(Integer legacyCode) {
     public static CockpitWorkOrderState byLegacyCode(Integer legacyCode) {
-        // 迁移期兼容:现网旧编码 1-6 与新模型同值;旧 7 为取消/驳回混用,收敛为“已取消”。
         if (legacyCode == null) {
         if (legacyCode == null) {
             return null;
             return null;
         }
         }
-        if (legacyCode >= 1 && legacyCode <= 7) {
-            return BY_CODE.get(legacyCode);
+        if (legacyCode >= 1 && legacyCode <= 6) {
+            return BY_CODE.get(legacyCode - 1);
         }
         }
         return BY_CODE.get(7);
         return BY_CODE.get(7);
     }
     }

+ 16 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/domain/AlarmDataSchemaTest.java

@@ -0,0 +1,16 @@
+package com.zksy.base.alarm.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class AlarmDataSchemaTest {
+    @Test
+    void orderRelationUsesExistingAlarmWorkOrderColumn() throws NoSuchFieldException {
+        TableField field = AlarmData.class.getDeclaredField("workOrderId").getAnnotation(TableField.class);
+        assertNotNull(field);
+        assertEquals("work_order_id", field.value());
+    }
+}

+ 3 - 3
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewMapperTest.java

@@ -29,7 +29,7 @@ class AlarmReviewMapperTest {
         String text = bound.getSql().replaceAll("\\s+"," ");
         String text = bound.getSql().replaceAll("\\s+"," ");
         assertTrue(text.contains("a.alarm_status = ?")); assertTrue(text.contains("a.audit_status = ?"));
         assertTrue(text.contains("a.alarm_status = ?")); assertTrue(text.contains("a.audit_status = ?"));
         assertTrue(text.contains("a.warning_code = ?")); assertTrue(text.contains("e.equipment_type_id IN"));
         assertTrue(text.contains("a.warning_code = ?")); assertTrue(text.contains("e.equipment_type_id IN"));
-        assertTrue(text.contains("ORDER BY a.alarm_time DESC"));
+        assertTrue(text.contains("ORDER BY a.create_time DESC"));
         assertFalse(text.contains("DISTINCT ON")); assertFalse(text.contains("GROUP BY"));
         assertFalse(text.contains("DISTINCT ON")); assertFalse(text.contains("GROUP BY"));
         assertTrue(bound.getParameterMappings().stream().anyMatch(p -> "query.warningCode".equals(p.getProperty())));
         assertTrue(bound.getParameterMappings().stream().anyMatch(p -> "query.warningCode".equals(p.getProperty())));
     }
     }
@@ -40,8 +40,8 @@ class AlarmReviewMapperTest {
         AlarmReviewQuery query = new AlarmReviewQuery(); query.setAuditStatus(1); query.setKeyword("压力");
         AlarmReviewQuery query = new AlarmReviewQuery(); query.setAuditStatus(1); query.setKeyword("压力");
         query.setStartTime(java.time.LocalDateTime.now()); query.setAscending(true);
         query.setStartTime(java.time.LocalDateTime.now()); query.setAscending(true);
         String count = sql("selectStatistics",Arrays.asList(1,12),query).getSql().replaceAll("\\s+"," ");
         String count = sql("selectStatistics",Arrays.asList(1,12),query).getSql().replaceAll("\\s+"," ");
-        assertTrue(count.contains("a.alarm_status = ?")); assertTrue(count.contains("a.alarm_time >= ?"));
+        assertTrue(count.contains("a.alarm_status = ?")); assertTrue(count.contains("a.create_time >= ?"));
         assertTrue(count.contains("equipment_name ILIKE")); assertFalse(count.contains("AND a.audit_status"));
         assertTrue(count.contains("equipment_name ILIKE")); assertFalse(count.contains("AND a.audit_status"));
-        assertTrue(sql("selectPage",Arrays.asList(1,12),query).getSql().replaceAll("\\s+"," ").contains("ORDER BY a.alarm_time ASC"));
+        assertTrue(sql("selectPage",Arrays.asList(1,12),query).getSql().replaceAll("\\s+"," ").contains("ORDER BY a.create_time ASC"));
     }
     }
 }
 }

+ 48 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/service/impl/EquipmentBaseServiceImplTest.java

@@ -2,11 +2,17 @@ package com.zksy.base.service.impl;
 
 
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.domain.EquipmentPointRel;
 import com.zksy.base.domain.EquipmentPointRel;
 import com.zksy.base.mapper.EquipmentBaseMapper;
 import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentTypeMapper;
 import com.zksy.base.mapper.EquipmentMaintainMapper;
 import com.zksy.base.mapper.EquipmentMaintainMapper;
 import com.zksy.base.mapper.EquipmentPointRelMapper;
 import com.zksy.base.mapper.EquipmentPointRelMapper;
 import com.zksy.base.mapper.EquipmentStatusMapper;
 import com.zksy.base.mapper.EquipmentStatusMapper;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.apache.ibatis.annotations.Delete;
 import org.apache.ibatis.annotations.Delete;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
@@ -19,6 +25,7 @@ import org.springframework.test.util.ReflectionTestUtils;
 import java.lang.reflect.Method;
 import java.lang.reflect.Method;
 import java.util.List;
 import java.util.List;
 
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyList;
@@ -41,6 +48,9 @@ class EquipmentBaseServiceImplTest {
     @Mock
     @Mock
     private EquipmentBaseMapper baseMapper;
     private EquipmentBaseMapper baseMapper;
 
 
+    @Mock
+    private EquipmentTypeMapper equipmentTypeMapper;
+
     @Mock
     @Mock
     private EquipmentStatusMapper equipmentStatusMapper;
     private EquipmentStatusMapper equipmentStatusMapper;
 
 
@@ -56,11 +66,49 @@ class EquipmentBaseServiceImplTest {
     void setUp() {
     void setUp() {
         service = Mockito.spy(new EquipmentBaseServiceImpl());
         service = Mockito.spy(new EquipmentBaseServiceImpl());
         ReflectionTestUtils.setField(service, "baseMapper", baseMapper);
         ReflectionTestUtils.setField(service, "baseMapper", baseMapper);
+        ReflectionTestUtils.setField(service, "equipmentTypeMapper", equipmentTypeMapper);
         ReflectionTestUtils.setField(service, "equipmentStatusMapper", equipmentStatusMapper);
         ReflectionTestUtils.setField(service, "equipmentStatusMapper", equipmentStatusMapper);
         ReflectionTestUtils.setField(service, "equipmentPointRelMapper", equipmentPointRelMapper);
         ReflectionTestUtils.setField(service, "equipmentPointRelMapper", equipmentPointRelMapper);
         ReflectionTestUtils.setField(service, "equipmentMaintainMapper", equipmentMaintainMapper);
         ReflectionTestUtils.setField(service, "equipmentMaintainMapper", equipmentMaintainMapper);
     }
     }
 
 
+    @Test
+    void waterScopeUsesCanonicalRootWhenConfiguredNameDiffersFromDatabase() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, EquipmentType.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentBase.class);
+        EquipmentType waterRoot = new EquipmentType();
+        waterRoot.setId(1);
+        waterRoot.setParentId(0);
+        waterRoot.setTypeName("供水设备");
+        EquipmentType waterChild = new EquipmentType();
+        waterChild.setId(11);
+        waterChild.setParentId(1);
+        waterChild.setTypeName("超声波外夹流量计");
+        when(equipmentTypeMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenAnswer(invocation -> {
+                    LambdaQueryWrapper<EquipmentType> query = invocation.getArgument(0);
+                    String sql = query.getSqlSegment();
+                    if (sql.contains("type_name")) {
+                        return List.of();
+                    }
+                    if (sql.contains("parent_id")
+                            && query.getParamNameValuePairs().values().contains(0)) {
+                        assertTrue(sql.contains("id"));
+                        assertTrue(query.getParamNameValuePairs().values().contains(1));
+                        return List.of(waterRoot);
+                    }
+                    if (sql.contains("parent_id")) {
+                        return List.of(waterChild);
+                    }
+                    return List.of(waterRoot, waterChild);
+                });
+        doReturn(List.of(device("EQ-WATER"))).when(service).list(any(LambdaQueryWrapper.class));
+
+        assertEquals(List.of("EQ-WATER"), service.findByTopLevelType("供水").stream()
+                .map(EquipmentBase::getEquipmentId).collect(java.util.stream.Collectors.toList()));
+    }
+
     @Test
     @Test
     void deletingOneDevicePhysicallyRemovesChildRowsThatCarryAForeignKey() {
     void deletingOneDevicePhysicallyRemovesChildRowsThatCarryAForeignKey() {
         EquipmentBase device = device("EQ-1");
         EquipmentBase device = device("EQ-1");

+ 1 - 1
pipe-network-service/zksy-system/src/test/java/com/zksy/ops/mapper/WorkOrderAlarmConflictMapperTest.java

@@ -35,7 +35,7 @@ class WorkOrderAlarmConflictMapperTest {
         MappedStatement statement = configuration.getMappedStatement(WorkOrderMapper.class.getName() + ".existsActiveAlarmOrder");
         MappedStatement statement = configuration.getMappedStatement(WorkOrderMapper.class.getName() + ".existsActiveAlarmOrder");
         BoundSql bound = statement.getBoundSql(params);
         BoundSql bound = statement.getBoundSql(params);
         String sql = bound.getSql().replaceAll("\\s+", " ");
         String sql = bound.getSql().replaceAll("\\s+", " ");
-        assertTrue(sql.contains("JOIN app_user.alarm_data a ON a.order_id = w.order_id"));
+        assertTrue(sql.contains("JOIN app_user.alarm_data a ON a.work_order_id = w.order_id"));
         assertTrue(sql.contains("w.device_id = ?"));
         assertTrue(sql.contains("w.device_id = ?"));
         assertTrue(sql.contains("a.warning_code = ?"));
         assertTrue(sql.contains("a.warning_code = ?"));
         assertTrue(sql.contains("w.order_status IN"));
         assertTrue(sql.contains("w.order_status IN"));