Procházet zdrojové kódy

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 před 3 týdny
rodič
revize
903d44d1c1
77 změnil soubory, kde provedl 3402 přidání a 363 odebrání
  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
 
+> 2026-09-24 需求裁定完成,后端 BT-5 已从 `needs-triage` 转回 `ready-for-agent`,但当前工作树仍未编译通过、接线验收进度为 0%。本文件的当前实现基线优先于历史目标条目。
+
 ## Problem Statement
 
 城市生命线驾驶舱系统面向指挥中心领导和值班人员,是一个只读展示大屏。UE 需要在进入程序后加载“一张图”,以监测点为首要地图对象,查看关联智能设备的基础档案、当前监测数据、预警、报警和工单处置状态。现有后端主要提供管理型接口,字段、分页和写入能力与驾驶舱展示边界不一致;预警接口仍处于向统一工单模型迁移的阶段,设备状态 WebSocket 也使用旧的驼峰设备字段。
@@ -10,12 +12,14 @@ Status: ready-for-agent
 
 ## 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`、状态原因、统计和坐标校验结果。监测点优先渲染;已关联设备不重复生成独立点位;未关联且坐标有效的设备才作为直接设备点位;无效坐标对象保留在列表和详情中但禁止地图渲染。
 
 设备详情采用基础档案、当前指标、预警、报警、工单、工单时间线和图片等独立只读分区。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
 
 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.
 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.
-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.
 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.
@@ -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.
 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.
-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.
 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.
@@ -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.
 - 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.
-- 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.
 - 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.
 - 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.
@@ -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.
 - 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 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 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.
 
+## 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
 
 - 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 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.
-- 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.

+ 1 - 1
CONTEXT-MAP.md

@@ -11,5 +11,5 @@
 ## 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)。

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

@@ -7,7 +7,7 @@
 - `audit_status`:0 未审核、1 确认报警、2 误报警。`alarm_level` 继续使用 1~4(一级最严重,红/橙/蓝/绿),不新增或更名为 `case_level`。
 - 确认报警原子更新审核状态、记录审核日志并创建工单;任一步失败整个审核事务回滚。不修改设备状态,也不将报警标为已处理。误报警仅记录结论及意见。
 - 审核转工单时,`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)。
 - 审核意见选填、最多 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. 契约总则
 
 - 所有接口均为**只读**:仅 `GET` + `@PreAuthorize("isAuthenticated()")`,不存在任何处置、派发、确认类写入口。
 - 统一响应信封:`{ "code": 200, "msg": "操作成功", "data": {...} }`;错误为 `{ "code": <httpStatus>, "msg": "<message>", "data": { category, message, requestId, serverTime } }`。
 - 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`)。
 - 可选请求头 `X-Request-Id`;未提供时服务端生成,响应与错误体中都会回传。
 
@@ -18,7 +18,7 @@
 | 等级颜色 | `1 RED` / `2 YELLOW` / `3 BLUE` / `4 GREEN` / `UNKNOWN` | 1红2黄3蓝4绿 |
 | 监测点状态 | `ALARM` `WARNING` `WORK_ORDER` `NORMAL` `OFFLINE` `EMPTY` | 优先级即此顺序,后端负责裁决 |
 | 指标状态 | `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` |
 | 错误类别 | `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/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/operating-information` | `industry` `range` `from` `to` |
 | 基础设施 | `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/equipments/{deviceCode}/archive` |
 | 类型指标 | `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/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`(离线自包含,无需后端)。
 
 ## 4. 分页、快照与错误口径
@@ -68,7 +78,12 @@
 - `map-snapshot` 的 `page > 1` 必须携带同一 `snapshotAt`,否则 `SNAPSHOT_EXPIRED`;同一 `snapshotAt` 下分页结果稳定,监测点优先、已关联设备不重复出点。
 - 无效或未验证坐标的对象保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
 - 成功但无数据返回 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 实时增量
 
@@ -79,9 +94,11 @@
 → {"type":"cockpitSubscribe","scope":"all"}
 ← {"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",
    "device_code":"TC-PS-WS-202609020012",
+   "monitoringPointCode":"MP-001",
    "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00",
            "serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
 
@@ -92,6 +109,8 @@
 
 客户端以 `serverUpdatedAt` 判新旧(新者胜),REST 提供首帧权威快照,WebSocket 只推变化设备;断线后重新订阅即可。设备状态校准周期默认 60 秒,可配置延长。
 
+增量帧字段口径:`monitoringPointCode` 为设备关联监测点的编码(`equipment_base.equipment_code` → `equipment_point_rel` → `monitor_point.point_code`);未关联设备该字段显式 `null`,不静默丢失。`data.reason` 无异常时显式 `null`。
+
 ## 6. 启动与联调
 
 后端启动(Java 11+,Maven 3.9):
@@ -124,14 +143,45 @@ mvn -pl zksy-admin -am test -Dtest='Cockpit*Test'
 
 本仓库**当前没有** 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` 统一约束。
 - Docker:可行。建议以 `eclipse-temurin:11-jre`(或信创基础镜像)为基座,外置 `application-prod.yml` 与密钥,数据库走独立容器或既有实例。若目标环境禁止容器,直接以 systemd + `ry.sh` 部署同样可行。
 - 内存基线:`ry.sh` 默认 `-Xms512m -Xmx1024m`,如需在驾驶舱工作站与渲染任务共存,建议按实际并发上调堆上限。
 - 性能测量:本轮未做生产规模压测,属未验证项;建议在取得真实设备规模后,用同一 `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>
 
     <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>
     <div class="empty" id="empty" style="display:none">当前筛选下没有事件(空列表状态)。</div>
     <div class="panel" id="detail"><h3>详情</h3><p style="color:var(--muted)">点击事件行或地图标记查看设备与工单状态。</p></div>
@@ -111,25 +111,25 @@
 
   const warnings = [
     { 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: "燃气浓度处置" } },
     { 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",
-      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: "供水压力处置" } },
     { 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",
-      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: "供水流量处置" } }
   ];
   const alarms = [
     { 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: "燃气浓度处置" } },
     { 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";
@@ -158,16 +158,18 @@
         <td>${e.value} ${e.unit}</td>
         <td>${e.device}</td>
         <td>${woFailed ? "--" : e.wo ? `<span class="badge">${WO_STATES[e.wo.state]}</span>` : "无工单"}</td>
+        <td>${e.validation.renderable ? "可上图" : "不可上图"}</td>
       </tr>`).join("");
 
     const map = document.getElementById("map");
     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 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;
-      filtered.forEach(e => {
+      plottable.forEach(e => {
         const el = document.createElement("div");
         el.className = "marker c-" + levelColor(e.level);
         el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
@@ -181,7 +183,8 @@
       });
     }
     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 =>
       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>数值:${e.value} ${e.unit} 设备:${e.device}(${e.deviceName})</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
         ? `<li class="empty" style="margin-top:6px">工单状态分区不可用(分区失败独立呈现,事件列表不受影响)。</li>`
         : e.wo

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

@@ -58,21 +58,29 @@
 <script>
 const snapshotAt='2026-09-02T10:00:00+08:00';
 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:[]},
-  {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:[
-    {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;

+ 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" }
     ];
     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" } },
-      { 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" } },
-      { 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" } },
       { type: "__disconnect" },
       { 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" } }
     ];
 

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

@@ -188,6 +188,33 @@ _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_:用页面入口、工单标题或报警记录状态推断系统归属
 
 **故障维修工单**:
-用于核实并修复设备故障的工单;由设备维修需求或监测报警转入的处置都属于此类型。工单流程重构后 `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_:维修任务、报警工单
 
 **同类报警工单**:
-同一设备主键针对同一 `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_:同一报警记录、同设备全部工单
 
 **工单派发**:

+ 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-已取消`;终态仅“已结案”和“已取消”,“延期中”是处理旁路。
 - 工单系统归属按关联设备的设备类型树确定,不由页面名称或标题推断;未知或未归属设备不得静默归入供水。
 - 前端继续保留当前菜单名称,不修改菜单结构命名;“设备维修派单”继续作为进入统一工单页的业务入口。

+ 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 = "pageSize", defaultValue = "500") Integer pageSize,
             @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "region", required = false) String region,
+            @RequestParam(value = "deviceType", required = false) String deviceType,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         CockpitMapSnapshotResponse response = mapSnapshotService.load(
-                resolveRequestId(requestId), page, pageSize, snapshotAt);
+                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, region, deviceType);
         return ResponseEntity.ok(AjaxResult.success(response));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
         return badRequest(mapSnapshotService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
     }
 
     @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()")
     public ResponseEntity<AjaxResult> warningSettings(
             @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) {
-        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)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
         return error(HttpStatus.BAD_REQUEST, warningSettingsService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
     }
 
     @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;
 
+import com.alibaba.fastjson.JSON;
 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.mapper.EquipmentPointRelMapper;
+import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentStatusService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -16,6 +22,8 @@ import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
 import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -36,6 +44,12 @@ public class DeviceStatusRedisListener implements MessageListener {
     @Autowired
     private EquipmentStatusService equipmentStatusService;
 
+    @Autowired
+    private EquipmentBaseService equipmentBaseService;
+
+    @Autowired
+    private EquipmentPointRelMapper equipmentPointRelMapper;
+
     @Autowired
     private DeviceStatusWebSocketHandler webSocketHandler;
 
@@ -109,8 +123,10 @@ public class DeviceStatusRedisListener implements MessageListener {
                 }
                 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 failCount = 0;
@@ -168,13 +184,14 @@ public class DeviceStatusRedisListener implements MessageListener {
         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);
         JSONObject delta = new JSONObject();
         delta.put("type", "cockpitDeviceStatusDelta");
         delta.put("deviceCode", deviceCode);
         delta.put("device_code", deviceCode);
+        delta.put("monitoringPointCode", monitoringPointCode);
         JSONObject data = new JSONObject();
         data.put("status", status);
         data.put("statusTime", statusTime);
@@ -184,6 +201,33 @@ public class DeviceStatusRedisListener implements MessageListener {
         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) {
         if (Integer.valueOf(1).equals(status.getAlarmStatus())) {
             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.";
 
     @Test
-    void workOrderDictionaryIsTheSevenStateBaselineWithoutDeprecatedStates() {
+    void workOrderDictionaryMatchesRuntimeZeroToSevenStatesWithoutDeprecatedStates() {
         List<CockpitWorkOrderState> states = CockpitWorkOrderStateDictionary.states();
-        assertEquals(7, states.size());
+        assertEquals(8, states.size());
         assertEquals(
-                Arrays.asList("待派单", "待接单", "已接单", "处理中", "待验收", "已结案", "已取消"),
+                Arrays.asList("待派单", "待接单", "已接单", "处理中", "待验收", "已结案", "延期中", "已取消"),
                 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());
-        // 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(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].levelColor").value("RED"))
                 .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].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.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[1].eventNo").value("W-002"))
                 .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[3].eventNo").value("W-004"))
                 .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].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.workOrders.state").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].levelColor").value("RED"))
                 .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].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.workOrders.state").value("SUCCESS"))
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)));
@@ -255,10 +276,10 @@ class CockpitCurrentEventsControllerTest {
                 .andExpect(status().isBadRequest())
                 .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(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)
                         .param("mode", "WARNING")
@@ -328,45 +349,45 @@ class CockpitCurrentEventsControllerTest {
                 Arrays.asList(
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "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",
                                 "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"),
                         warning("W-003", "供水压力预警", "WATER_PRESSURE", 3, "PENDING",
                                 "2026-09-02T09:40:00+08:00", "2026-09-02T09:41:00+08:00",
-                                "0.18", "MPa", "D-WS-1", "供水压力计-001", "城区B-供水1号点",
+                                "0.18", "MPa", "D-WS-1", "供水压力计-001", "MP-B-001", "城区B-供水1号点", "城区B-供水1号点",
                                 "WATER_SUPPLY_SENSOR", "110.150", "28.460"),
                         warning("W-004", "污水浓度预警", "SEWAGE_CONCENTRATION", 4, "RELEASED",
                                 "2026-09-02T09:35:00+08:00", "2026-09-02T09:36:00+08:00",
-                                "18.2", "mg/L", "D-DR-1", "污水环境监测仪-001", "城区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",
                                 "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"),
                         warning("W-006", "未知类型预警", "LEGACY_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
-                                "1", "x", "D-LEGACY", "旧设备", "旧位置",
+                                "1", "x", "D-LEGACY", "旧设备", null, null, "旧位置",
                                 "LEGACY", "110.100", "28.400"),
                         warning("W-007", "智能井盖预警", "MANHOLE_EVENT", 1, "ACTIVE",
                                 "2026-09-02T09:55:00+08:00", null,
-                                "1", "x", "D-MH", "智能井盖", "井盖位置",
+                                "1", "x", "D-MH", "智能井盖", null, null, "井盖位置",
                                 "SMART_MANHOLE", "110.101", "28.401")),
                 Arrays.asList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
-                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "城区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",
                                 "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")),
                 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-005", "WO-3", 2, "供水流量处置", 6),
+                        workOrder("W-005", "WO-3", 2, "供水流量处置", 5),
                         workOrder("A-001", "WO-4", 1, "燃气浓度处置", 7)));
     }
 
@@ -391,12 +412,12 @@ class CockpitCurrentEventsControllerTest {
                 Collections.singletonList(
                         warning("W-001", "燃气浓度预警", "GAS_CONCENTRATION", 1, "ACTIVE",
                                 "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")),
                 Collections.singletonList(
                         alarm("A-001", "燃气浓度报警", "GAS_CONCENTRATION", 1, "HANDLED",
                                 "2026-09-02T09:55:00+08:00", "2026-09-02T09:56:00+08:00",
-                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "城区A-燃气3号点",
+                                "22.6", "mg/m³", "D-GAS-3", "燃气浓度计-003", "MP-A-003", "城区A-燃气3号点", "城区A-燃气3号点",
                                 "GAS_SENSOR", "110.132", "28.442")),
                 Collections.emptyList());
     }
@@ -414,23 +435,52 @@ class CockpitCurrentEventsControllerTest {
     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 location, String typeCode,
+            String deviceCode, String deviceName, String monitoringPointCode, String monitoringPointName,
+            String location, String typeCode,
             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(
                 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(
             String id, String name, String type, Integer level, String status,
             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) {
+        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(
                 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(

+ 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.apiVersion").value("v1"))
                 .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.objectKinds", hasSize(2)))
                 .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.rainwaterSmartDeviceCount").value(1))
                 .andExpect(jsonPath("$.data.statistics.industries.drainage.sewageSmartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.manhole.smartDeviceCount").value(0))
                 .andReturn();
 
         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
-    void warningFamilyExposesSummaryExplicitBasisSevenStatesTrendAndEvents() throws Exception {
+    void warningFamilyExposesSummaryExplicitBasisEightStatesTrendAndEvents() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
                         .param("startTime", START)
@@ -87,8 +87,8 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.summary.averageDuration.unit").value("HOURS"))
                 .andExpect(jsonPath("$.data.summary.averageDuration.sampleCount").value(2))
                 .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].count").value(1))
                 .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[5].stateName").value("已结案"))
                 .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[0].eventNo").value("EW-007"))
                 .andExpect(jsonPath("$.data.events[0].hasWorkOrder").value(true))
@@ -134,10 +136,11 @@ class CockpitHandlingEfficiencyControllerTest {
                         .param("startTime", START)
                         .param("endTime", END))
                 .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[4].count").value(1))
-                .andExpect(jsonPath("$.data.workOrderStateCounts[6].count").value(1));
+                .andExpect(jsonPath("$.data.workOrderStateCounts[7].count").value(1));
     }
 
     @Test
@@ -182,7 +185,7 @@ class CockpitHandlingEfficiencyControllerTest {
                 .andExpect(jsonPath("$.data.summary.totalEvents").value(2))
                 .andExpect(jsonPath("$.data.summary.missingWorkOrderCount").value(0))
                 .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("已取消"));
     }
 
@@ -204,17 +207,17 @@ class CockpitHandlingEfficiencyControllerTest {
     void workOrderStateFilterNarrowsEventsAndMapFilterButNotStateCounts() throws Exception {
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
-                        .param("workOrderState", "6")
+                        .param("workOrderState", "5")
                         .param("startTime", START)
                         .param("endTime", END))
                 .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.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.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
@@ -243,7 +246,7 @@ class CockpitHandlingEfficiencyControllerTest {
         mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
                 .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());
 
         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"))
                 .andExpect(status().isBadRequest())
                 .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))
                 .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-002", 2, "2026-09-02T11:00:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-9", "供水压力计-009")),
                 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("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() {
@@ -389,10 +392,10 @@ class CockpitHandlingEfficiencyControllerTest {
                         warning("EW-004", 1, "2026-09-02T11:00:00+08:00", "GAS_SENSOR", "D-GAS-4", "燃气浓度计-004")),
                 Collections.emptyList(),
                 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() {

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

@@ -139,7 +139,7 @@ class CockpitHistoryAnalysisControllerTest {
     }
 
     @Test
-    void handledScopeCountsOnlyClosedStateSixOrders() throws Exception {
+    void handledScopeCountsOnlyClosedStateFiveOrders() throws Exception {
         CockpitHistoryAnalysisResponse response = service("WARNING", START, END);
         assertEquals(5, response.getSummary().getEventCount());
         assertEquals(2, response.getSummary().getHandledCount());
@@ -211,7 +211,7 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.message").value(
                         "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());
 
         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-002", 1, "2026-09-02T01:30:00+08:00", "GAS_SENSOR", "D-GAS-3")),
                 Arrays.asList(
-                        workOrder("HW-001", 6),
+                        workOrder("HW-001", 5),
                         workOrder("HW-002", 4),
                         workOrder("HW-003", 1),
-                        workOrder("HW-004", 6),
+                        workOrder("HW-004", 5),
                         workOrder("HW-005", 7),
-                        workOrder("HA-001", 6)));
+                        workOrder("HA-001", 5)));
     }
 
     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.lengthKm").value(8.0))
                 .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].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[1].code").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(3))
                 .andExpect(jsonPath("$.data.industryDistribution[2].code").value("DRAINAGE"))
                 .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[0].code").value("WATER_PLANT"))
                 .andExpect(jsonPath("$.data.facilityTypeDistribution[0].count").value(1))
@@ -167,11 +169,11 @@ class CockpitInfrastructureBaseArchivesControllerTest {
     @Test
     void invalidFiltersReturnTypedBadRequestBeforeAdapterInteraction() throws Exception {
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
-                        .queryParam("industry", "MANHOLE"))
+                        .queryParam("industry", "WHATEVER"))
                 .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"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE, MANHOLE"));
 
         mockMvc.perform(get("/api/cockpit/v1/infrastructure/base-archives")
                         .queryParam("ageBucket", "OVER_5_YEARS"))
@@ -204,10 +206,11 @@ class CockpitInfrastructureBaseArchivesControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.totals.facilityCount").value(0))
                 .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[1].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.ageDistribution[0].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.area").value(nullValue()))
                 .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.totalObjectCount").value(5))
+                .andExpect(jsonPath("$.data.totals.totalObjectCount").value(6))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.state").value("READY"))
                 .andExpect(jsonPath("$.data.sections.emergencyResources.stateReason").value("READY"))
                 .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.alarmCount").value(1))
                 .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.protectionTargets.items", hasSize(1)))
                 .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].count").value(1))
                 .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
                 .andExpect(jsonPath("$.data.industryDistribution[1].count").value(3))
                 .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
                 .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[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].count").value(2))
                 .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].count").value(3))
                 .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].count").value(1))
                 .andExpect(jsonPath("$.data.eventSummary.warningCount").value(3))
@@ -119,9 +121,9 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.category").value(nullValue()))
                 .andExpect(jsonPath("$.data.mapFilter.industry").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.nonFormalIndustryCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.duplicateObjectCount").value(1));
     }
 
@@ -226,11 +228,11 @@ class CockpitInfrastructureEmergencySupportControllerTest {
                 .andExpect(jsonPath("$.data.message")
                         .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(jsonPath("$.code").value(400))
                 .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);
     }

+ 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.selectedFilters.industry").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.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].organizationName").value("供水一公司"))
                 .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.ownershipDistribution", hasSize(4)))
                 .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].total").value(5))
+                .andExpect(jsonPath("$.data.ownershipDistribution[0].total").value(6))
                 .andExpect(jsonPath("$.data.ownershipDistribution[1].ownershipType").value("UNKNOWN"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[1].total").value(2))
                 .andExpect(jsonPath("$.data.ownershipDistribution[2].ownershipType").value("ENTRUSTED"))
                 .andExpect(jsonPath("$.data.ownershipDistribution[3].ownershipType").value("LEASED"))
-                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(4)))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes", hasSize(5)))
                 .andExpect(jsonPath("$.data.mapFilter.organizationCodes[0]").value("ORG-WS-A"))
                 .andExpect(jsonPath("$.data.mapFilter.organizationCodes[3]").value("UNMAINTAINED"))
+                .andExpect(jsonPath("$.data.mapFilter.organizationCodes[4]").value("ORG-MH-A"))
                 .andExpect(jsonPath("$.data.excluded.duplicateRelationCount").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));
     }
 
@@ -133,7 +134,7 @@ class CockpitInfrastructureManagementInformationControllerTest {
 
         mockMvc.perform(get(ENDPOINT))
                 .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.excluded.duplicateRelationCount").value(1));
     }
@@ -146,7 +147,7 @@ class CockpitInfrastructureManagementInformationControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.organizations[0].facilityCount").value(2))
                 .andExpect(jsonPath("$.data.organizations[0].equipmentCount").value(1))
-                .andExpect(jsonPath("$.data.totals.facilityCount").value(5))
+                .andExpect(jsonPath("$.data.totals.facilityCount").value(6))
                 .andExpect(jsonPath("$.data.totals.equipmentCount").value(4));
     }
 
@@ -224,11 +225,11 @@ class CockpitInfrastructureManagementInformationControllerTest {
 
     @Test
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WHATEVER"))
                 .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 or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
         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.updatedAt").value("2026-09-02T09:59:50+08:00"))
                 .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.key").value("gas"))
                 .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].abnormalMetricCount").value(0))
                 .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1));
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
     }
 
     @Test
@@ -160,7 +160,7 @@ class CockpitInfrastructureOperatingInformationControllerTest {
                 .andExpect(jsonPath("$.data.selectedIndustry.code").value("DRAINAGE"))
                 .andExpect(jsonPath("$.data.selectedIndustry.key").value("drainage"))
                 .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].state").value("EMPTY"))
                 .andExpect(jsonPath("$.data.industries[2].metrics", hasSize(1)));
@@ -193,11 +193,11 @@ class CockpitInfrastructureOperatingInformationControllerTest {
 
     @Test
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "WHATEVER"))
                 .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 or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
         verifyNoInteractions(adapter);
     }
@@ -238,12 +238,13 @@ class CockpitInfrastructureOperatingInformationControllerTest {
 
         mockMvc.perform(get(ENDPOINT))
                 .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].stateReason").value("NO_METRICS"))
                 .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
                 .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
                 .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.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 static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -70,8 +71,11 @@ class CockpitMapSnapshotControllerTest {
                 .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
                 .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
                 .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.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].coordinateValidation.renderable").value(false))
                 .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.renderableCount").value(3))
                 .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
     void mapSummaryResolvesPointStatusAcrossAllSixStatesFromDeviceFacts() throws Exception {
         when(adapter.load(null)).thenReturn(new CockpitMapSnapshot(
                 SNAPSHOT_AT,
+                types(),
                 Arrays.asList(
                         sourcePoint("MP-ALARM", Collections.singletonList(
                                 device("EQ-ALARM", true, false, false, true,
@@ -264,10 +373,13 @@ class CockpitMapSnapshotControllerTest {
     private static CockpitMapSnapshot fixture() {
         return new CockpitMapSnapshot(
                 SNAPSHOT_AT,
+                types(),
                 Arrays.asList(
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-002",
                                 "燃气调压站监测点",
+                                "城区A",
+                                "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 true,
@@ -276,6 +388,8 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-001",
                                 "供水干管监测点",
+                                "城区B",
+                                "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("110.141"),
                                 new BigDecimal("28.452"),
                                 true,
@@ -283,6 +397,8 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceMonitoringPoint(
                                 "MP-003",
                                 "污水排口监测点",
+                                null,
+                                "SEWAGE_ENV",
                                 new BigDecimal("110.143"),
                                 new BigDecimal("28.454"),
                                 false,
@@ -291,40 +407,69 @@ class CockpitMapSnapshotControllerTest {
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-ASSOC-001",
                                 "燃气浓度计-001",
+                                "城区A",
+                                "GAS_SENSOR",
                                 new BigDecimal("110.142"),
                                 new BigDecimal("28.453"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-002",
                                 "智能井盖-002",
+                                null,
+                                null,
                                 new BigDecimal("110.145"),
                                 new BigDecimal("28.456"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-001",
                                 "供水压力计-001",
+                                "城区B",
+                                "WATER_SUPPLY_SENSOR",
                                 new BigDecimal("181"),
                                 new BigDecimal("28.455"),
                                 true),
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-003",
                                 "污水环境监测仪-003",
+                                "城区C",
+                                "SEWAGE_ENV",
                                 new BigDecimal("110.146"),
                                 new BigDecimal("28.457"),
                                 false),
                         new CockpitMapSnapshot.SourceSmartDevice(
                                 "EQ-FREE-004",
                                 "零坐标设备-004",
+                                "城区A",
+                                "GAS_SENSOR",
                                 BigDecimal.ZERO,
                                 BigDecimal.ZERO,
                                 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,
             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(
                 pointCode,
                 pointCode + "监测点",
+                region,
+                typeCode,
                 new BigDecimal("110.141"),
                 new BigDecimal("28.452"),
                 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
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "MANHOLE"))
+        mockMvc.perform(get(ENDPOINT).param("industry", "WHATEVER"))
                 .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 or DRAINAGE"));
+                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
         verifyNoInteractions(adapter);
     }
@@ -328,7 +328,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
                 "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 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"));
 
         assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
@@ -337,7 +337,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
                 "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 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"));
 
         assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
@@ -346,7 +346,7 @@ class CockpitMonitoringDistributionControllerTest {
         CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
                 "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
                 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"));
 
         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.timezone").value("Asia/Shanghai"))
                 .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.status.onlineCount").value(2))
+                .andExpect(jsonPath("$.data.status.onlineCount").value(3))
                 .andExpect(jsonPath("$.data.status.offlineCount").value(1))
                 .andExpect(jsonPath("$.data.status.staleCount").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.typeDistribution", hasSize(3)))
+                .andExpect(jsonPath("$.data.typeDistribution", hasSize(4)))
                 .andExpect(jsonPath("$.data.typeDistribution[0].deviceCount").value(2))
                 .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.typeKindMismatchCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1));
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(0));
     }
 
     @Test
@@ -160,19 +160,26 @@ class CockpitMonitoringOverviewControllerTest {
         mockMvc.perform(get(ENDPOINT))
                 .andExpect(status().isOk())
                 .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
-    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+    void manholeIsAFormalIndustryAndUnknownIndustryIsRejected() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
         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(jsonPath("$.code").value(400))
                 .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
@@ -202,21 +209,21 @@ class CockpitMonitoringOverviewControllerTest {
         CockpitMonitoringOverviewResponse first = service.applyDeviceStatusDelta(
                 "delta-1", null, null,
                 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"));
 
-        assertEquals(3, first.getStatus().getOnlineCount());
+        assertEquals(4, first.getStatus().getOnlineCount());
         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(
                 "delta-2", null, null,
                 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"));
 
-        assertEquals(3, second.getStatus().getOnlineCount());
+        assertEquals(4, second.getStatus().getOnlineCount());
         assertEquals(0, second.getStatus().getOfflineCount());
     }
 
@@ -246,7 +253,7 @@ class CockpitMonitoringOverviewControllerTest {
                         type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY"),
                         type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
                         new CockpitMonitoringOverviewSnapshot.SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
-                        type("SMART_MANHOLE", "智能井盖", null)),
+                        type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                         device("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1"),
                         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.metricStatusDistribution", hasSize(6)))
                 .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].count").value(1))
                 .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[1].count").value(1))
                 .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].deviceCount").value(4))
                 .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].deviceCount").value(2))
                 .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[0].deviceCode").value("D-EXCL-GAS"))
                 .andExpect(jsonPath("$.data.abnormalEquipment[0].abnormalType").value("ALARM"))
@@ -130,7 +133,7 @@ class CockpitMonitoringSituationControllerTest {
                 .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(4)))
                 .andExpect(jsonPath("$.data.excluded.unknownTypeCount").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.freshness.status").value("STALE"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("STALE_SAMPLES"));
@@ -240,15 +243,23 @@ class CockpitMonitoringSituationControllerTest {
     }
 
     @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(jsonPath("$.code").value(400))
                 .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
                 .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
@@ -327,37 +338,37 @@ class CockpitMonitoringSituationControllerTest {
         CockpitMonitoringSituationResponse initial = service.load(
                 "delta-baseline", null, null, START, END, 1, 100);
         assertEquals(2, distributionCount(initial, "ALARM"));
-        assertEquals(2, distributionCount(initial, "NORMAL"));
+        assertEquals(3, distributionCount(initial, "NORMAL"));
         assertEquals(5, initial.getAbnormalEquipment().size());
 
         CockpitMonitoringSituationResponse applied = service.applyDeviceStatusDelta(
                 "delta-1", null, null, START, END, 1, 100,
                 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"));
 
         assertEquals(1, distributionCount(applied, "ALARM"));
-        assertEquals(3, distributionCount(applied, "NORMAL"));
+        assertEquals(4, distributionCount(applied, "NORMAL"));
         assertEquals(4, applied.getAbnormalEquipment().size());
         assertEquals(2, applied.getIndustryCoverage().get(0).getAbnormalCount());
 
         CockpitMonitoringSituationResponse ignoredOld = service.applyDeviceStatusDelta(
                 "delta-2", null, null, START, END, 1, 100,
                 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"));
 
         assertEquals(1, distributionCount(ignoredOld, "ALARM"));
-        assertEquals(3, distributionCount(ignoredOld, "NORMAL"));
+        assertEquals(4, distributionCount(ignoredOld, "NORMAL"));
 
         CockpitMonitoringSituationResponse ignoredUnknown = service.applyDeviceStatusDelta(
                 "delta-3", null, null, START, END, 1, 100,
                 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"));
 
         assertEquals(1, distributionCount(ignoredUnknown, "ALARM"));
-        assertEquals(3, distributionCount(ignoredUnknown, "NORMAL"));
+        assertEquals(4, distributionCount(ignoredUnknown, "NORMAL"));
     }
 
     private int distributionCount(CockpitMonitoringSituationResponse response, String status) {
@@ -394,8 +405,7 @@ class CockpitMonitoringSituationControllerTest {
                         type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
                         new CockpitMonitoringSituationSnapshot.SourceType(
                                 "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
-                        new CockpitMonitoringSituationSnapshot.SourceType(
-                                "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
+                        type("SMART_MANHOLE", "智能井盖", "MANHOLE")),
                 Arrays.asList(
                         device("D-GAS-1", "燃气浓度计-001", "城区A", "GAS_SENSOR",
                                 "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"))
                 .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());
 
         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
     void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(get("/api/cockpit/v1/overview/monitoring")
-                        .queryParam("industry", "MANHOLE"))
+                        .queryParam("industry", "WHATEVER"))
                 .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 or DRAINAGE"));
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
 
         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",
                 null,
                 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(0), first.getCounts().getStatus().getOffline());
@@ -134,7 +134,7 @@ class CockpitOverviewMonitoringServiceTest {
                 "request-delta-002",
                 null,
                 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(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.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.verifyNoInteractions;
 import static org.mockito.Mockito.when;
@@ -62,6 +64,10 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.selectedMode").value("WARNING"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
                 .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.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
@@ -106,6 +112,86 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .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
     void invalidModeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
@@ -183,10 +269,18 @@ class CockpitOverviewWarningSettingsControllerTest {
                                 "WARNING", "2026-09-01", 2, 1),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "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(
                                 "ALARM", "2026-09-01", 1, 0),
                         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
-    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(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("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(1), response.getWarning().getUnlinkedEventCount());
         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(2));
         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(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(7, response.getWarning().getWorkOrderStates().size());
+        assertEquals(8, response.getWarning().getWorkOrderStates().size());
         assertTrue(response.getWarning().getWorkOrderStates().keySet().stream()
-                .allMatch(code -> code >= 1 && code <= 7));
+                .allMatch(code -> code >= 0 && code <= 7));
     }
 
     @Test
     void emptyFamilyUsesExplicitEmptyStateAndZeroDenominatorEfficiency() {
         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("NO_EVENTS", response.getAlarm().getStateReason());
@@ -80,7 +83,8 @@ class CockpitOverviewWarningSettingsServiceTest {
     void missingSnapshotUsesExplicitEmptyFreshness() {
         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("NO_SNAPSHOT", response.getFreshness().getReason());
@@ -88,22 +92,22 @@ class CockpitOverviewWarningSettingsServiceTest {
         assertEquals("EMPTY", response.getWarning().getState());
     }
 
-    private static CockpitOverviewWarningSettingsSnapshot sevenStateFixture() {
+    private static CockpitOverviewWarningSettingsSnapshot eightStateFixture() {
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
+                        event("WARNING", "WRN-000", "WO-000", 0),
                         event("WARNING", "WRN-001", "WO-001", 1),
                         event("WARNING", "WRN-002", "WO-002", 2),
                         event("WARNING", "WRN-003", "WO-003", 3),
                         event("WARNING", "WRN-004", "WO-004", 4),
                         event("WARNING", "WRN-005", "WO-005", 5),
                         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(
-                                "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)),
                 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
-    void deviceWorkOrdersReturnSevenStateDictionary() throws Exception {
+    void deviceWorkOrdersReturnEightStateDictionary() throws Exception {
         when(adapter.loadByDevice(DEVICE_CODE)).thenReturn(sevenStateFixture());
 
         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.readOnly").value(true))
                 .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[5].code").value(6))
+                .andExpect(jsonPath("$.data.stateDictionary[5].code").value(5))
                 .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].deprecated").doesNotExist())
                 .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[6].stateCode").value(7))
                 .andExpect(jsonPath("$.data.workOrders[6].stateName").value("已取消"))
@@ -131,12 +133,12 @@ class CockpitWorkOrderReadControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.requestId").value("timeline-001"))
                 .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[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[2].stateCode").value(3))
+                .andExpect(jsonPath("$.data.timelineEntries[2].stateCode").value(2))
                 .andExpect(jsonPath("$.data.timelineEntries[2].stateName").value("已接单"))
                 .andExpect(jsonPath("$.data.timelineEntries[0].legacyState").doesNotExist());
 
@@ -146,7 +148,7 @@ class CockpitWorkOrderReadControllerTest {
                 .andExpect(jsonPath("$.data.workOrderCode").value("WO-2026090200002"))
                 .andExpect(jsonPath("$.data.timelineEntries", hasSize(1)))
                 .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("已结案"));
     }
 

+ 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 WebSocketSession session;
     private EquipmentStatusService equipmentStatusService;
+    private com.zksy.base.service.EquipmentBaseService equipmentBaseService;
+    private com.zksy.base.mapper.EquipmentPointRelMapper equipmentPointRelMapper;
     private SubscriptionManager subscriptionManager;
 
     @BeforeEach
@@ -43,6 +45,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         org.mockito.Mockito.doReturn(attributes).when(session).getAttributes();
 
         equipmentStatusService = mock(EquipmentStatusService.class);
+        equipmentBaseService = mock(com.zksy.base.service.EquipmentBaseService.class);
+        equipmentPointRelMapper = mock(com.zksy.base.mapper.EquipmentPointRelMapper.class);
         subscriptionManager = new SubscriptionManager();
         ReflectionTestUtils.setField(handler, "equipmentStatusService", equipmentStatusService);
         ReflectionTestUtils.setField(handler, "subscriptionManager", subscriptionManager);
@@ -80,6 +84,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
         ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(listener, "equipmentBaseService", equipmentBaseService);
+        ReflectionTestUtils.setField(listener, "equipmentPointRelMapper", equipmentPointRelMapper);
         ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
         ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
         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"));
     }
 
+    @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
     void existingDeviceCodeSubscriptionStillReceivesLegacyPush() throws Exception {
         handler.afterConnectionEstablished(session);
@@ -291,6 +354,8 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
         ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
         ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(listener, "equipmentBaseService", equipmentBaseService);
+        ReflectionTestUtils.setField(listener, "equipmentPointRelMapper", equipmentPointRelMapper);
         ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
         ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
         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);
         }
 
-        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = selectTypeMap(devices);
         Map<String, RadarData> radarMap = selectLatestRadar(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);
         }
 
-        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = selectTypeMap(devices);
         Map<String, FirefightingPressure> pressureMap = new HashMap<>();
         List<String> deviceCodes = deviceCodes(devices);
         if (!deviceCodes.isEmpty()) {
@@ -151,7 +151,7 @@ public class MonitoringServiceImpl implements MonitoringService {
             return emptyPage(pageNum, pageSize);
         }
 
-        Map<String, EquipmentType> typeMap = selectTypeMap(devices);
+        Map<Integer, EquipmentType> typeMap = selectTypeMap(devices);
         Map<String, NoiseInfo> noiseMap = new HashMap<>();
         List<String> deviceCodes = deviceCodes(devices);
         if (!deviceCodes.isEmpty()) {
@@ -291,9 +291,9 @@ public class MonitoringServiceImpl implements MonitoringService {
                 .like(StrUtil.isNotBlank(equipmentCode), EquipmentBase::getEquipmentCode, equipmentCode)
                 .like(StrUtil.isNotBlank(equipmentLocation), EquipmentBase::getEquipmentLocation, equipmentLocation)
                 .orderByDesc(EquipmentBase::getCreateTime);
-        Set<String> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
+        Set<Integer> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
         wrapper.in(EquipmentBase::getEquipmentTypeId,
-                monitoringTypeIds.isEmpty() ? Collections.singleton("__NO_WATER_MONITORING_TYPE__") : monitoringTypeIds);
+                monitoringTypeIds.isEmpty() ? Collections.singleton(-1) : monitoringTypeIds);
         return equipmentBaseMapper.selectList(wrapper);
     }
 
@@ -302,11 +302,11 @@ public class MonitoringServiceImpl implements MonitoringService {
         if (device == null || StrUtil.isBlank(device.getEquipmentCode())) {
             return null;
         }
-        Set<String> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
+        Set<Integer> monitoringTypeIds = selectMonitoringTypeIds(monitorKind);
         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()
                 .map(EquipmentBase::getEquipmentTypeId)
                 .anyMatch(Objects::nonNull);
@@ -314,7 +314,7 @@ public class MonitoringServiceImpl implements MonitoringService {
             return Collections.emptyMap();
         }
         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,
@@ -514,7 +514,7 @@ public class MonitoringServiceImpl implements MonitoringService {
     }
 
     /** 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;
         switch (monitorKind) {
             case "pressure":
@@ -538,8 +538,9 @@ public class MonitoringServiceImpl implements MonitoringService {
             return Collections.emptySet();
         }
         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());
     }
 }

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

+ 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
 public class CockpitDeviceBoundaryService {
     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 =
             Collections.unmodifiableList(Arrays.asList("RAINWATER", "SEWAGE"));
     private static final List<String> OBJECT_KINDS =
@@ -117,6 +117,7 @@ public class CockpitDeviceBoundaryService {
         industries.put("gas", new CockpitIndustryStatistics());
         industries.put("waterSupply", new CockpitIndustryStatistics());
         industries.put("drainage", new CockpitIndustryStatistics());
+        industries.put("manhole", new CockpitIndustryStatistics());
 
         for (CockpitFacilityBoundary facility : facilities) {
             if (facility.isFormalScope()) {
@@ -208,6 +209,9 @@ public class CockpitDeviceBoundaryService {
         if ("DRAINAGE".equals(industry)) {
             return industries.get("drainage");
         }
+        if ("MANHOLE".equals(industry)) {
+            return industries.get("manhole");
+        }
         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;
 
 import com.zksy.cockpit.detail.CockpitSectionResult;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -151,10 +152,15 @@ public class CockpitCurrentEventsResponse {
         private final String unit;
         private final String deviceCode;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String industry;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
+        private final CockpitCoordinateValidation coordinateValidation;
+        private final BigDecimal thresholdMinValue;
+        private final BigDecimal thresholdMaxValue;
         private final WorkOrderSummary workOrderSummary;
 
         public EventItem(
@@ -170,10 +176,15 @@ public class CockpitCurrentEventsResponse {
                 String unit,
                 String deviceCode,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String industry,
                 BigDecimal longitude,
                 BigDecimal latitude,
+                CockpitCoordinateValidation coordinateValidation,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue,
                 WorkOrderSummary workOrderSummary) {
             this.eventNo = eventNo;
             this.eventName = eventName;
@@ -187,10 +198,15 @@ public class CockpitCurrentEventsResponse {
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.industry = industry;
             this.longitude = longitude;
             this.latitude = latitude;
+            this.coordinateValidation = coordinateValidation;
+            this.thresholdMinValue = thresholdMinValue;
+            this.thresholdMaxValue = thresholdMaxValue;
             this.workOrderSummary = workOrderSummary;
         }
 
@@ -206,10 +222,15 @@ public class CockpitCurrentEventsResponse {
         public String getUnit() { return unit; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
+        public String getMonitoringPointCode() { return monitoringPointCode; }
+        public String getMonitoringPointName() { return monitoringPointName; }
         public String getLocation() { return location; }
         public String getIndustry() { return industry; }
         public BigDecimal getLongitude() { return longitude; }
         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; }
     }
 

+ 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;
 
 import com.zksy.cockpit.detail.CockpitSectionResult;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -33,7 +34,7 @@ import java.util.Set;
 @Service
 public class CockpitCurrentEventsService {
     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 int DEFAULT_PAGE_SIZE = 100;
     private static final int MAX_PAGE_SIZE = 1000;
@@ -199,10 +200,15 @@ public class CockpitCurrentEventsService {
                     warning.getUnit(),
                     warning.getDeviceCode(),
                     warning.getDeviceName(),
+                    warning.getMonitoringPointCode(),
+                    warning.getMonitoringPointName(),
                     warning.getLocation(),
                     type.getIndustry(),
                     warning.getLongitude(),
-                    warning.getLatitude()));
+                    warning.getLatitude(),
+                    warning.isCoordinateVerified(),
+                    null,
+                    null));
         }
         return rows;
     }
@@ -243,10 +249,15 @@ public class CockpitCurrentEventsService {
                     alarm.getUnit(),
                     alarm.getDeviceCode(),
                     alarm.getDeviceName(),
+                    alarm.getMonitoringPointCode(),
+                    alarm.getMonitoringPointName(),
                     alarm.getLocation(),
                     type.getIndustry(),
                     alarm.getLongitude(),
-                    alarm.getLatitude()));
+                    alarm.getLatitude(),
+                    alarm.isCoordinateVerified(),
+                    alarm.getThresholdMinValue(),
+                    alarm.getThresholdMaxValue()));
         }
         return rows;
     }
@@ -359,7 +370,7 @@ public class CockpitCurrentEventsService {
         }
         String resolved = industry.trim();
         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;
     }
@@ -426,10 +437,15 @@ public class CockpitCurrentEventsService {
         private final String unit;
         private final String deviceCode;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String industry;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
+        private final boolean coordinateVerified;
+        private final BigDecimal thresholdMinValue;
+        private final BigDecimal thresholdMaxValue;
 
         private EventRow(
                 String eventNo,
@@ -443,10 +459,15 @@ public class CockpitCurrentEventsService {
                 String unit,
                 String deviceCode,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String industry,
                 BigDecimal longitude,
-                BigDecimal latitude) {
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue) {
             this.eventNo = eventNo;
             this.eventName = eventName;
             this.eventType = eventType;
@@ -458,10 +479,15 @@ public class CockpitCurrentEventsService {
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.industry = industry;
             this.longitude = longitude;
             this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
+            this.thresholdMinValue = thresholdMinValue;
+            this.thresholdMaxValue = thresholdMaxValue;
         }
 
         private String getEventNo() {
@@ -484,10 +510,15 @@ public class CockpitCurrentEventsService {
                     unit,
                     deviceCode,
                     deviceName,
+                    monitoringPointCode,
+                    monitoringPointName,
                     location,
                     industry,
                     longitude,
                     latitude,
+                    CockpitCoordinateValidator.validate(longitude, latitude, coordinateVerified),
+                    thresholdMinValue,
+                    thresholdMaxValue,
                     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 deviceCode;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
+        private final boolean coordinateVerified;
 
         public SourceWarning(
                 String warningNo,
@@ -119,10 +122,13 @@ public class CockpitCurrentEventsSnapshot {
                 String unit,
                 String deviceCode,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String typeCode,
                 BigDecimal longitude,
-                BigDecimal latitude) {
+                BigDecimal latitude,
+                boolean coordinateVerified) {
             this.warningNo = warningNo;
             this.warningName = warningName;
             this.warningType = warningType;
@@ -134,10 +140,13 @@ public class CockpitCurrentEventsSnapshot {
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
         }
 
         public String getWarningNo() {
@@ -184,6 +193,14 @@ public class CockpitCurrentEventsSnapshot {
             return deviceName;
         }
 
+        public String getMonitoringPointCode() {
+            return monitoringPointCode;
+        }
+
+        public String getMonitoringPointName() {
+            return monitoringPointName;
+        }
+
         public String getLocation() {
             return location;
         }
@@ -199,6 +216,10 @@ public class CockpitCurrentEventsSnapshot {
         public BigDecimal getLatitude() {
             return latitude;
         }
+
+        public boolean isCoordinateVerified() {
+            return coordinateVerified;
+        }
     }
 
     public static class SourceAlarm {
@@ -213,10 +234,15 @@ public class CockpitCurrentEventsSnapshot {
         private final String unit;
         private final String deviceCode;
         private final String deviceName;
+        private final String monitoringPointCode;
+        private final String monitoringPointName;
         private final String location;
         private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
+        private final boolean coordinateVerified;
+        private final BigDecimal thresholdMinValue;
+        private final BigDecimal thresholdMaxValue;
 
         public SourceAlarm(
                 String alarmId,
@@ -230,10 +256,15 @@ public class CockpitCurrentEventsSnapshot {
                 String unit,
                 String deviceCode,
                 String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
                 String location,
                 String typeCode,
                 BigDecimal longitude,
-                BigDecimal latitude) {
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue) {
             this.alarmId = alarmId;
             this.warningName = warningName;
             this.warningType = warningType;
@@ -245,10 +276,15 @@ public class CockpitCurrentEventsSnapshot {
             this.unit = unit;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
+            this.monitoringPointCode = monitoringPointCode;
+            this.monitoringPointName = monitoringPointName;
             this.location = location;
             this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
+            this.thresholdMinValue = thresholdMinValue;
+            this.thresholdMaxValue = thresholdMaxValue;
         }
 
         public String getAlarmId() {
@@ -295,6 +331,14 @@ public class CockpitCurrentEventsSnapshot {
             return deviceName;
         }
 
+        public String getMonitoringPointCode() {
+            return monitoringPointCode;
+        }
+
+        public String getMonitoringPointName() {
+            return monitoringPointName;
+        }
+
         public String getLocation() {
             return location;
         }
@@ -310,6 +354,18 @@ public class CockpitCurrentEventsSnapshot {
         public BigDecimal getLatitude() {
             return latitude;
         }
+
+        public boolean isCoordinateVerified() {
+            return coordinateVerified;
+        }
+
+        public BigDecimal getThresholdMinValue() {
+            return thresholdMinValue;
+        }
+
+        public BigDecimal getThresholdMaxValue() {
+            return thresholdMaxValue;
+        }
     }
 
     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
 public class CockpitHandlingEfficiencyService {
     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 WARNING = "WARNING";
     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 String EFFICIENCY_BASIS = "CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS";
     private static final long DEFAULT_WINDOW_DAYS = 7;
@@ -179,7 +179,7 @@ public class CockpitHandlingEfficiencyService {
         int cancelledCount = stateCounts.getOrDefault(CANCELLED_STATE_CODE, 0);
         int inProgressCount = 0;
         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();
             }
         }
@@ -496,7 +496,7 @@ public class CockpitHandlingEfficiencyService {
         }
         String resolved = industry.trim();
         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;
     }
@@ -524,12 +524,12 @@ public class CockpitHandlingEfficiencyService {
             int resolved = Integer.parseInt(workOrderState.trim());
             if (CockpitWorkOrderStateDictionary.byCode(resolved) == null) {
                 throw new CockpitInvalidRequestException(
-                        "workOrderState must be an integer between 1 and 7");
+                        "workOrderState must be an integer between 0 and 7");
             }
             return resolved;
         } catch (NumberFormatException exception) {
             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
  * isolated, and resolves the handled disposal scope through the authoritative
  * 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
 public class CockpitHistoryAnalysisService {
     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 WARNING = "WARNING";
     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 MAX_WINDOW_DAYS = 31;
     private static final DateTimeFormatter TIMESTAMP =
@@ -319,7 +319,7 @@ public class CockpitHistoryAnalysisService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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
 public class CockpitMultidimensionalAnalysisService {
     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 WARNING = "WARNING";
     private static final String ALARM = "ALARM";
@@ -444,7 +444,7 @@ public class CockpitMultidimensionalAnalysisService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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 {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     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(
             "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(
             "EMERGENCY_RESOURCE", "HAZARD_SOURCE", "PROTECTION_TARGET");
     private static final List<String> INDUSTRIES = Arrays.asList(
-            "GAS", "WATER_SUPPLY", "DRAINAGE");
+            "GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
 
     private final CockpitInfrastructureEmergencySupportAdapter adapter;
     private final Clock clock;
@@ -227,7 +227,7 @@ public class CockpitInfrastructureEmergencySupportService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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
 public class CockpitInfrastructureManagementInformationService {
     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 SMART_DEVICE = "SMART_DEVICE";
     private static final String UNMAINTAINED_CODE = "UNMAINTAINED";
@@ -197,7 +197,7 @@ public class CockpitInfrastructureManagementInformationService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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
 public class CockpitInfrastructureOperatingInformationService {
     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> CATEGORIES =
             Arrays.asList("SUPPLY", "FLOW", "PRESSURE", "QUALITY", "CAPACITY");
@@ -286,7 +286,7 @@ public class CockpitInfrastructureOperatingInformationService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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 {
     private final String snapshotAt;
+    private final List<SourceType> types;
     private final List<SourceMonitoringPoint> monitoringPoints;
     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.types = types;
         this.monitoringPoints = monitoringPoints;
         this.smartDevices = smartDevices;
     }
 
     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() {
         return snapshotAt;
     }
 
+    public List<SourceType> getTypes() {
+        return types;
+    }
+
     public List<SourceMonitoringPoint> getMonitoringPoints() {
         return monitoringPoints;
     }
@@ -34,18 +41,46 @@ public class CockpitMapSnapshot {
         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 {
         private final String pointCode;
         private final String pointName;
+        private final String region;
+        private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         private final List<CockpitMonitoringPointDeviceSnapshot> devices;
 
-        public SourceMonitoringPoint(String pointCode, String pointName, BigDecimal longitude, BigDecimal latitude,
+        public SourceMonitoringPoint(String pointCode, String pointName, String region, String typeCode,
+                BigDecimal longitude, BigDecimal latitude,
                 boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
             this.pointCode = pointCode;
             this.pointName = pointName;
+            this.region = region;
+            this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
@@ -54,6 +89,8 @@ public class CockpitMapSnapshot {
 
         public String getPointCode() { return pointCode; }
         public String getPointName() { return pointName; }
+        public String getRegion() { return region; }
+        public String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public boolean isCoordinateVerified() { return coordinateVerified; }
@@ -63,14 +100,18 @@ public class CockpitMapSnapshot {
     public static class SourceSmartDevice {
         private final String deviceCode;
         private final String deviceName;
+        private final String region;
+        private final String typeCode;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         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.deviceName = deviceName;
+            this.region = region;
+            this.typeCode = typeCode;
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
@@ -78,6 +119,8 @@ public class CockpitMapSnapshot {
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceName() { return deviceName; }
+        public String getRegion() { return region; }
+        public String getTypeCode() { return typeCode; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         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<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
     private final CockpitCoordinateValidationSummary coordinateValidation;
+    private final MapFilter mapFilter;
 
     public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
             String snapshotAt, int total, String batchUpdatedAt, boolean complete,
             List<CockpitMonitoringPointSummary> monitoringPoints,
             List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
-            CockpitCoordinateValidationSummary coordinateValidation) {
+            CockpitCoordinateValidationSummary coordinateValidation,
+            MapFilter mapFilter) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
         this.readOnly = readOnly;
@@ -33,6 +35,7 @@ public class CockpitMapSnapshotResponse {
         this.monitoringPoints = monitoringPoints;
         this.unassociatedEquipment = unassociatedEquipment;
         this.coordinateValidation = coordinateValidation;
+        this.mapFilter = mapFilter;
     }
 
     public String getRequestId() { return requestId; }
@@ -47,4 +50,32 @@ public class CockpitMapSnapshotResponse {
     public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
     public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
     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.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
+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;
@@ -29,6 +34,9 @@ public class CockpitMapSnapshotService {
     private static final int DEFAULT_PAGE_SIZE = 500;
     private static final int MAX_PAGE_SIZE = 1000;
     private static final String WGS84 = "WGS84";
+    private static final String SMART_DEVICE = "SMART_DEVICE";
+    private static final String UNASSIGNED_REGION = "未分区";
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
 
     private final CockpitMapSnapshotAdapter adapter;
     private final Clock clock;
@@ -38,11 +46,19 @@ public class CockpitMapSnapshotService {
         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 safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
         validatePagination(safePage, safePageSize, 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);
         if (snapshot == null) {
@@ -55,8 +71,11 @@ public class CockpitMapSnapshotService {
             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 fromIndex = Math.min((safePage - 1) * safePageSize, total);
         int toIndex = Math.min(fromIndex + safePageSize, total);
@@ -79,13 +98,14 @@ public class CockpitMapSnapshotService {
                 toIndex >= total,
                 pagePoints,
                 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(
                 CockpitErrorCategory.INVALID_REQUEST,
-                "地图快照分页参数不合法",
+                message,
                 requestId,
                 businessTime());
     }
@@ -108,7 +128,7 @@ public class CockpitMapSnapshotService {
 
     private static void validatePagination(int page, int pageSize, String 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 {
             OffsetDateTime.parse(snapshotAt);
         } 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) {
         return value == null || value.trim().isEmpty();
     }
@@ -131,42 +162,127 @@ public class CockpitMapSnapshotService {
         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) {
             return Collections.emptyList();
         }
         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,
                         Comparator.nullsLast(String::compareTo)))
                 .collect(Collectors.toList());
     }
 
     private static CockpitMonitoringPointSummary monitoringPoint(
-            CockpitMapSnapshot.SourceMonitoringPoint source) {
+            CockpitMapSnapshot.SourceMonitoringPoint source,
+            Map<String, CockpitMapSnapshot.SourceType> types) {
         List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
                 ? Collections.emptyList()
                 : source.getDevices();
         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(
                 source.getPointCode(),
                 source.getPointName(),
                 pointStatus,
                 CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
+                deviceCodes,
+                regionOf(source.getRegion()),
+                industryOf(type),
+                typeCodeOf(source.getTypeCode()),
+                typeNameOf(type),
                 source.getLongitude(),
                 source.getLatitude(),
                 CockpitCoordinateValidator.validate(
                         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) {
             return Collections.emptyList();
         }
         Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
         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,
                         Comparator.nullsLast(String::compareTo)))
                 .collect(Collectors.toList());
@@ -186,16 +302,55 @@ public class CockpitMapSnapshotService {
     }
 
     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(
                 source.getDeviceCode(),
                 source.getDeviceName(),
+                regionOf(source.getRegion()),
+                industryOf(type),
+                typeCodeOf(source.getTypeCode()),
+                typeNameOf(type),
                 source.getLongitude(),
                 source.getLatitude(),
                 CockpitCoordinateValidator.validate(
                         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) {
         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 CockpitMonitoringPointStatus pointStatus;
     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 latitude;
     private final CockpitCoordinateValidation coordinateValidation;
 
     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.pointName = pointName;
         this.pointStatus = pointStatus;
         this.statusReasons = statusReasons;
+        this.deviceCodes = deviceCodes;
+        this.region = region;
+        this.industry = industry;
+        this.typeCode = typeCode;
+        this.typeName = typeName;
         this.longitude = longitude;
         this.latitude = latitude;
         this.coordinateValidation = coordinateValidation;
@@ -31,6 +42,11 @@ public class CockpitMonitoringPointSummary {
     public String getPointName() { return pointName; }
     public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
     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 getLatitude() { return latitude; }
     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 {
     private final String deviceCode;
     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 latitude;
     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.deviceName = deviceName;
+        this.region = region;
+        this.industry = industry;
+        this.typeCode = typeCode;
+        this.typeName = typeName;
         this.longitude = longitude;
         this.latitude = latitude;
         this.coordinateValidation = coordinateValidation;
@@ -22,6 +31,10 @@ public class CockpitUnassociatedEquipmentSummary {
 
     public String getDeviceCode() { return deviceCode; }
     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 getLatitude() { return latitude; }
     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 {
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String monitoringState;
     private final String sampledAt;
     private final String serverUpdatedAt;
 
     public CockpitMonitoringDistributionDeviceStatusDelta(
             String deviceCode,
+            String monitoringPointCode,
             String monitoringState,
             String sampledAt,
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.monitoringState = monitoringState;
         this.sampledAt = sampledAt;
         this.serverUpdatedAt = serverUpdatedAt;
@@ -21,6 +24,10 @@ public class CockpitMonitoringDistributionDeviceStatusDelta {
         return deviceCode;
     }
 
+    public String getMonitoringPointCode() {
+        return monitoringPointCode;
+    }
+
     public String getMonitoringState() {
         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
 public class CockpitMonitoringDistributionService {
     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 int DEFAULT_PAGE_SIZE = 500;
     private static final int MAX_PAGE_SIZE = 1000;
@@ -579,7 +579,7 @@ public class CockpitMonitoringDistributionService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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 {
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String monitoringState;
     private final String sampledAt;
     private final String serverUpdatedAt;
 
     public CockpitMonitoringOverviewDeviceStatusDelta(
             String deviceCode,
+            String monitoringPointCode,
             String monitoringState,
             String sampledAt,
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.monitoringState = monitoringState;
         this.sampledAt = sampledAt;
         this.serverUpdatedAt = serverUpdatedAt;
     }
 
     public String getDeviceCode() { return deviceCode; }
+    public String getMonitoringPointCode() { return monitoringPointCode; }
     public String getMonitoringState() { return monitoringState; }
     public String getSampledAt() { return sampledAt; }
     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
 public class CockpitMonitoringOverviewService {
     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 final CockpitMonitoringOverviewAdapter adapter;
@@ -310,7 +311,8 @@ public class CockpitMonitoringOverviewService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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 {
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String metricStatus;
     private final String sampledAt;
     private final String serverUpdatedAt;
 
     public CockpitMonitoringSituationDeviceStatusDelta(
             String deviceCode,
+            String monitoringPointCode,
             String metricStatus,
             String sampledAt,
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.metricStatus = metricStatus;
         this.sampledAt = sampledAt;
         this.serverUpdatedAt = serverUpdatedAt;
@@ -21,6 +24,10 @@ public class CockpitMonitoringSituationDeviceStatusDelta {
         return deviceCode;
     }
 
+    public String getMonitoringPointCode() {
+        return monitoringPointCode;
+    }
+
     public String getMetricStatus() {
         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
 public class CockpitMonitoringSituationService {
     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 =
             Arrays.asList("NORMAL", "WARNING", "ALARM", "EXPIRED", "OFFLINE", "UNKNOWN");
     private static final Set<String> ABNORMAL_STATUSES =
@@ -401,7 +402,8 @@ public class CockpitMonitoringSituationService {
         }
         String resolved = industry.trim();
         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;
     }

+ 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 {
     private final String deviceCode;
+    private final String monitoringPointCode;
     private final String monitoringState;
     private final String statusTime;
     private final String serverUpdatedAt;
 
     public CockpitOverviewMonitoringDeviceStatusDelta(
             String deviceCode,
+            String monitoringPointCode,
             String monitoringState,
             String statusTime,
             String serverUpdatedAt) {
         this.deviceCode = deviceCode;
+        this.monitoringPointCode = monitoringPointCode;
         this.monitoringState = monitoringState;
         this.statusTime = statusTime;
         this.serverUpdatedAt = serverUpdatedAt;
     }
 
     public String getDeviceCode() { return deviceCode; }
+    public String getMonitoringPointCode() { return monitoringPointCode; }
     public String getMonitoringState() { return monitoringState; }
     public String getStatusTime() { return statusTime; }
     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 {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     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 =
-            Collections.unmodifiableList(Arrays.asList("gas", "waterSupply", "drainage"));
+            Collections.unmodifiableList(Arrays.asList("gas", "waterSupply", "drainage", "manhole"));
 
     private final CockpitOverviewMonitoringAdapter adapter;
     private final Clock clock;
@@ -52,7 +52,7 @@ public class CockpitOverviewMonitoringService {
     public CockpitErrorPayload invalidRequest(String requestId) {
         return new CockpitErrorPayload(
                 CockpitErrorCategory.INVALID_REQUEST,
-                "industry must be GAS, WATER_SUPPLY or DRAINAGE",
+                "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE",
                 requestId,
                 businessTime());
     }
@@ -124,7 +124,7 @@ public class CockpitOverviewMonitoringService {
 
     private void validateIndustry(String 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 ("WATER_SUPPLY".equals(code)) return "供水";
         if ("DRAINAGE".equals(code)) return "排水";
+        if ("MANHOLE".equals(code)) return "窨井";
         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.RoundingMode;
 import java.time.Clock;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -25,11 +28,14 @@ public class CockpitOverviewWarningSettingsService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final List<String> MODES =
             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 =
             CockpitWorkOrderStateDictionary.states().stream()
                     .map(CockpitWorkOrderState::getCode)
                     .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 Clock clock;
@@ -41,8 +47,14 @@ public class CockpitOverviewWarningSettingsService {
         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);
+        Window window = resolveWindow(range, startTime, endTime);
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
         boolean hasSnapshot = snapshot != null && !isBlank(snapshot.getUpdatedAt());
         String updatedAt = hasSnapshot ? snapshot.getUpdatedAt() : null;
@@ -56,22 +68,25 @@ public class CockpitOverviewWarningSettingsService {
                 "预警设置",
                 selectedMode,
                 "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,
                 new CockpitOverviewWarningSettingsResponse.Freshness(
                         freshnessStatus,
                         freshnessReason,
                         updatedAt,
                         businessTime()),
-                familyMetrics(snapshot, "WARNING"),
-                familyMetrics(snapshot, "ALARM"),
+                familyMetrics(snapshot, "WARNING", window),
+                familyMetrics(snapshot, "ALARM", window),
                 mapFilter(selectedMode));
     }
 
-    public CockpitErrorPayload invalidRequest(String requestId) {
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
         return new CockpitErrorPayload(
                 CockpitErrorCategory.INVALID_REQUEST,
-                "mode must be WARNING or ALARM",
+                message,
                 requestId,
                 businessTime());
     }
@@ -94,9 +109,57 @@ public class CockpitOverviewWarningSettingsService {
         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(
             CockpitOverviewWarningSettingsSnapshot snapshot,
-            String eventFamily) {
+            String eventFamily,
+            Window window) {
         List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> sourceEvents =
                 snapshot == null || snapshot.getEvents() == null
                         ? Collections.emptyList()
@@ -130,12 +193,12 @@ public class CockpitOverviewWarningSettingsService {
             }
             total++;
             workOrderStates.merge(state, 1, Integer::sum);
-            if (state < HANDLED_STATE) {
-                pending++;
-            } else if (state == HANDLED_STATE) {
+            if (state == HANDLED_STATE) {
                 handled++;
-            } else {
+            } else if (state == 7) {
                 cancelledCount++;
+            } else {
+                pending++;
             }
         }
 
@@ -164,22 +227,32 @@ public class CockpitOverviewWarningSettingsService {
                 invalidWorkOrderStateCount,
                 efficiency,
                 workOrderStates,
-                trend(snapshot, eventFamily));
+                trend(snapshot, eventFamily, window));
     }
 
+    /**
+     * 趋势桶跟随声明窗口:桶为业务时区日期(适配器契约 `yyyy-MM-dd`),
+     * 保留桶日期落在窗口起止日期之间(含端点)的桶;窗口无界时不筛选;
+     * 桶标签无法解析时保留该桶,不静默丢数据。
+     */
     private List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend(
             CockpitOverviewWarningSettingsSnapshot snapshot,
-            String eventFamily) {
+            String eventFamily,
+            Window window) {
         if (snapshot == null || snapshot.getTrendPoints() == null) {
             return Collections.emptyList();
         }
         List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend = new ArrayList<>();
         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;
     }
@@ -204,12 +277,59 @@ public class CockpitOverviewWarningSettingsService {
     }
 
     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) {
         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) {
-        CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byLegacyCode(order.getOrderStatus());
+        CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byCode(order.getOrderStatus());
         return new CockpitWorkOrderSummary(
                 order.getOrderNo(),
                 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.Map;
 
+/**
+ * 对外公开的工单状态字典,与运行时 {@code WorkOrderStatusEnum}(0–7)对齐。
+ *
+ * <p>0 待派单、1 待接单、2 已接单、3 处理中、4 待验收、5 已结案、6 延期中、7 已取消;
+ * 终态为 5、7,延期中是处理旁路。旧 ADR-0008 的“从 1 开始七态”仅保留为历史决策,
+ * 不再作为对外编码。
+ */
 public final class CockpitWorkOrderStateDictionary {
     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, "已取消")
     ));
 
@@ -36,13 +44,17 @@ public final class CockpitWorkOrderStateDictionary {
         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) {
-        // 迁移期兼容:现网旧编码 1-6 与新模型同值;旧 7 为取消/驳回混用,收敛为“已取消”。
         if (legacyCode == 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);
     }

+ 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+"," ");
         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("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"));
         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("压力");
         query.setStartTime(java.time.LocalDateTime.now()); query.setAscending(true);
         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(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.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.domain.EquipmentPointRel;
 import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentTypeMapper;
 import com.zksy.base.mapper.EquipmentMaintainMapper;
 import com.zksy.base.mapper.EquipmentPointRelMapper;
 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.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -19,6 +25,7 @@ import org.springframework.test.util.ReflectionTestUtils;
 import java.lang.reflect.Method;
 import java.util.List;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
@@ -41,6 +48,9 @@ class EquipmentBaseServiceImplTest {
     @Mock
     private EquipmentBaseMapper baseMapper;
 
+    @Mock
+    private EquipmentTypeMapper equipmentTypeMapper;
+
     @Mock
     private EquipmentStatusMapper equipmentStatusMapper;
 
@@ -56,11 +66,49 @@ class EquipmentBaseServiceImplTest {
     void setUp() {
         service = Mockito.spy(new EquipmentBaseServiceImpl());
         ReflectionTestUtils.setField(service, "baseMapper", baseMapper);
+        ReflectionTestUtils.setField(service, "equipmentTypeMapper", equipmentTypeMapper);
         ReflectionTestUtils.setField(service, "equipmentStatusMapper", equipmentStatusMapper);
         ReflectionTestUtils.setField(service, "equipmentPointRelMapper", equipmentPointRelMapper);
         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
     void deletingOneDevicePhysicallyRemovesChildRowsThatCarryAForeignKey() {
         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");
         BoundSql bound = statement.getBoundSql(params);
         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("a.warning_code = ?"));
         assertTrue(sql.contains("w.order_status IN"));