Просмотр исходного кода

Merge remote-tracking branch 'origin/master'

Kazerin 6 дней назад
Родитель
Сommit
9c9b3117d3
100 измененных файлов с 3493 добавлено и 511 удалено
  1. 10 0
      pipe-network-service/CONTEXT.md
  2. 25 0
      pipe-network-service/docs/adr/0011-mobile-controllers-shared-services.md
  3. 4 3
      pipe-network-service/docs/domain/work-order-lifecycle.md
  4. 8 8
      pipe-network-service/sql/early_warning_system.sql
  5. 2 2
      pipe-network-service/sql/water_supply_alarm_test_data.sql
  6. 9 9
      pipe-network-service/sql/water_supply_monitoring_alarm_rebuild_test_data.sql
  7. 2 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/EquipmentBaseController.java
  8. 42 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/drainage/DrainageVideoController.java
  9. 59 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileAlarmController.java
  10. 27 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileContactsController.java
  11. 37 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEquipmentController.java
  12. 45 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileInspectTaskController.java
  13. 31 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileLogininforController.java
  14. 44 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileNoticeController.java
  15. 43 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileOfficeController.java
  16. 50 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileWorkOrderController.java
  17. 1 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/monitor/SysLogininforController.java
  18. 1 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/InspectTaskController.java
  19. 9 10
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/WorkOrderController.java
  20. 0 22
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysNoticeController.java
  21. 0 8
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysUserController.java
  22. 6 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusWebSocketHandler.java
  23. 6 3
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/WorkOrderWebSocketPushTask.java
  24. 2 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureEmergencySupportAdapterTest.java
  25. 24 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringDistributionAdapterTest.java
  26. 48 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringOverviewAdapterTest.java
  27. 33 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMonitoringSituationAdapterTest.java
  28. 121 22
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitOverviewWarningSettingsAdapterTest.java
  29. 62 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitCreatedAtTest.java
  30. 93 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  31. 82 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringOverviewControllerTest.java
  32. 59 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringSituationControllerTest.java
  33. 70 31
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsControllerTest.java
  34. 210 82
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsServiceTest.java
  35. 71 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileAlarmControllerTest.java
  36. 142 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileControllerMappingTest.java
  37. 35 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/websocket/DeviceStatusCockpitWebSocketHandlerTest.java
  38. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/MobileAlarmQuery.java
  39. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java
  40. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmReviewMapper.java
  41. 40 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmOperationsService.java
  42. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewScopeService.java
  43. 21 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java
  44. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/EquipmentBaseService.java
  45. 87 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java
  46. 49 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/EarlyWarningStatusEnum.java
  47. 13 24
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningWorkflow.java
  48. 20 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java
  49. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningItemServiceImpl.java
  50. 77 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitCreatedAt.java
  51. 7 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  52. 6 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringOverviewAdapter.java
  53. 4 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringSituationAdapter.java
  54. 21 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  55. 22 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  56. 37 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  57. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewResponse.java
  58. 30 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewService.java
  59. 22 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringOverviewSnapshot.java
  60. 14 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationResponse.java
  61. 19 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationService.java
  62. 23 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringSituationSnapshot.java
  63. 133 84
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewWarningSettingsAdapter.java
  64. 73 15
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsResponse.java
  65. 281 42
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsService.java
  66. 47 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsSnapshot.java
  67. 22 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/domain/DrainageVideoResource.java
  68. 11 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/mapper/DrainageBindingLockMapper.java
  69. 46 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/mapper/DrainageVideoMapper.java
  70. 100 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/DrainageVideoService.java
  71. 7 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/impl/DrainageEquipmentBindingServiceImpl.java
  72. 24 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/impl/WaterloggingPointServiceImpl.java
  73. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/InspectTask.java
  74. 0 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/WorkOrder.java
  75. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/MobileAssignableUserVO.java
  76. 5 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderPushVO.java
  77. 0 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderVO.java
  78. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderDetailVO.java
  79. 0 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderPageDTO.java
  80. 4 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/enums/WorkOrderStatusEnum.java
  81. 9 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/InspectTaskService.java
  82. 15 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java
  83. 54 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/InspectTaskServiceImpl.java
  84. 214 54
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java
  85. 65 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/support/OpsAssignScopeSupport.java
  86. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysLogininforMapper.java
  87. 2 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysUserMapper.java
  88. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/ISysLogininforService.java
  89. 2 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/ISysUserService.java
  90. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysLogininforServiceImpl.java
  91. 3 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysUserServiceImpl.java
  92. 43 0
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml
  93. 5 0
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmReviewMapper.xml
  94. 1 2
      pipe-network-service/zksy-system/src/main/resources/mapper/ops/WorkOrderMapper.xml
  95. 8 1
      pipe-network-service/zksy-system/src/main/resources/mapper/system/SysLogininforMapper.xml
  96. 3 5
      pipe-network-service/zksy-system/src/main/resources/mapper/system/SysUserMapper.xml
  97. 49 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/mapper/MobileAlarmMapperTest.java
  98. 111 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmMobileServiceTest.java
  99. 5 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewMapperTest.java
  100. 2 1
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewScopeServiceTest.java

+ 10 - 0
pipe-network-service/CONTEXT.md

@@ -61,6 +61,16 @@ _Avoid_:把供水子系统等同于单一的报警页面,或把监测设备
 
 
 供水首页工作台的报警态势通过统一 `GET /api/alarm/dashboard` 查询,保留报警总数、报警中设备、近 N 天分级趋势和分类占比;使用获授权设备类型树及 `alarm:operations:list` 权限,供水传 `rootTypeId=1`,不恢复已删除的旧台账 dashboard 接口。统计由数据库全量聚合,报警中设备按设备状态去重,只有趋势限制自然日范围(2026-09-24 首页导入错误修复)。
 供水首页工作台的报警态势通过统一 `GET /api/alarm/dashboard` 查询,保留报警总数、报警中设备、近 N 天分级趋势和分类占比;使用获授权设备类型树及 `alarm:operations:list` 权限,供水传 `rootTypeId=1`,不恢复已删除的旧台账 dashboard 接口。统计由数据库全量聚合,报警中设备按设备状态去重,只有趋势限制自然日范围(2026-09-24 首页导入错误修复)。
 
 
+### 移动端接口分层
+
+移动端专用 Controller 统一放在 `zksy-admin` 的 `com.zksy.web.controller.mobile` 包,当前包含工单、巡检、设备、故障通知、公告、本人登录日志和通讯录七类入口。PC Controller 不再承载这些移动端专用方法。URL 统一采用 `/api/mobile/...`,工单与巡检位于 `/api/mobile/ops/...`;移动端调用同步迁移,请求参数和响应结构保持不变。
+
+两端复用现有业务 Service,移动端数据范围也在 Service 校验,不复制 PC 业务流程。报警查询、公司范围和审核分别归入 `AlarmOperationsService`、`AlarmReviewScopeService`、`AlarmReviewService`,不单设 `MobileAlarmService`。登录、验证码、资料、通用上传及工单处置等两端共用入口继续保留原 Controller,避免重复路由。此次需同步更新管理后端和移动端,PC 无需改动,无数据库变更;详见 [ADR-0011](docs/adr/0011-mobile-controllers-shared-services.md)(2026-10-14)。
+
+### 移动端故障通知
+
+移动端通过 `/api/mobile/alarm` 查询故障通知;读取要求登录,公司范围由当前部门自身及 ancestors 中的 101/102/103 映射为设备类型直接 parent_id=1/2/3。范围不含共享类型4、不递归扩展,也不因管理员身份放宽;可选 rootTypeId 仅能进一步收窄。已转工单的事件按 work_order.device_id 取设备,无工单事件按报警 device_code 取设备;损坏的工单关联不回退到设备编码。列表固定未处理事件,详情、日志、审核及附件上传共用公司范围校验。审核与上传保留 alarm:review:audit 权限,复用 PC 原审核事务及附件票据;确认报警转待派单工单,误报只记结论。移动端“处置全流程”沿用报警日志,不复制关联工单日志。无需数据库结构变更(2026-10-14)。
+
 ### 工单视图分工
 ### 工单视图分工
 
 
 统一工单视图集中检索和处置所有供水类型工单,覆盖故障维修、日常巡检和设备保养三类业务;报警审核视图只承载由报警转入的故障维修工单。巡检或保养工单即使由其他系统派发给供水用户,仍可在统一工单视图中继续接单、处理和验收。
 统一工单视图集中检索和处置所有供水类型工单,覆盖故障维修、日常巡检和设备保养三类业务;报警审核视图只承载由报警转入的故障维修工单。巡检或保养工单即使由其他系统派发给供水用户,仍可在统一工单视图中继续接单、处理和验收。

+ 25 - 0
pipe-network-service/docs/adr/0011-mobile-controllers-shared-services.md

@@ -0,0 +1,25 @@
+---
+status: accepted
+---
+
+# 移动端独立 Controller,共用业务 Service
+
+2026-10-14:移动端专用接口原先分散在 PC Controller 中。为集中管理移动端契约,将其统一迁入 `zksy-admin/src/main/java/com/zksy/web/controller/mobile`。用户进一步确认统一 URL 前缀为 `/api/mobile`,移动端调用同步迁移;此决定取代同日初版保留旧 URL 的约定。
+
+| Controller | 统一后的路由前缀或路径 |
+| --- | --- |
+| MobileWorkOrderController | `/api/mobile/ops/workOrder` |
+| MobileInspectTaskController | `/api/mobile/ops/inspectTask` |
+| MobileEquipmentController | `/api/mobile/equipment-base` |
+| MobileAlarmController | `/api/mobile/alarm` |
+| MobileNoticeController | `/api/mobile/notice` |
+| MobileLogininforController | `/api/mobile/monitor/logininfor/list` |
+| MobileContactsController | `/api/mobile/contacts` |
+
+Controller 负责参数绑定、鉴权和响应适配,调用 PC 使用的现有业务 Service。可在业务 Service 内提供移动端查询方法,公司范围、当前用户范围及业务状态校验留在 Service,禁止通过调用 PC Controller 复用流程或复制一套移动端业务实现。
+
+报警专用的 `MobileAlarmService` 撤销,其查询、范围、审核分别归入 `AlarmOperationsService`、`AlarmReviewScopeService`、`AlarmReviewService`。移动端审核的公开 Service 方法保留事务,与 PC 共用审核实现。
+
+登录、验证码、个人资料、通用上传和工单处置等两端共用同一 URL 的接口仍保留原 Controller,不重复注册映射。以后新增移动端专属契约遵循本目录规则和 `/api/mobile` 前缀,旧移动端专用 URL 直接迁移,不保留双路由。
+
+迁移保留原有权限、数据范围、参数及返回结构,通过 PC 与移动端 Controller 同时注册的映射测试和相关业务回归验证。管理后端和移动端必须同步更新,PC 无需改动;无需数据库迁移。

+ 4 - 3
pipe-network-service/docs/domain/work-order-lifecycle.md

@@ -1,7 +1,7 @@
 # 统一工单处置生命周期 · 管理端权威口径
 # 统一工单处置生命周期 · 管理端权威口径
 
 
 状态:accepted
 状态:accepted
-更新日期:2026-09-10
+更新日期:2026-10-14
 决策依据:[ADR-0010 当前工单状态枚举为权威](../adr/0010-current-work-order-status-enum-is-authoritative.md)
 决策依据:[ADR-0010 当前工单状态枚举为权威](../adr/0010-current-work-order-status-enum-is-authoritative.md)
 
 
 > 本文以最新统一工单代码和 `WorkOrderStatusEnum` 为权威。状态名是业务权威;接口编码按所属契约解释,不能跨接口复用裸数字。
 > 本文以最新统一工单代码和 `WorkOrderStatusEnum` 为权威。状态名是业务权威;接口编码按所属契约解释,不能跨接口复用裸数字。
@@ -33,7 +33,7 @@
 | 提交验收 | 责任人 | 处理中 / 延期中 | 待验收 | 处理后描述与附件 |
 | 提交验收 | 责任人 | 处理中 / 延期中 | 待验收 | 处理后描述与附件 |
 | 验收通过 | 管理员 | 待验收 | 已结案 | 验收意见 |
 | 验收通过 | 管理员 | 待验收 | 已结案 | 验收意见 |
 | 验收驳回 | 管理员 | 待验收 | 处理中 | 驳回原因,交同一责任人继续整改 |
 | 验收驳回 | 管理员 | 待验收 | 处理中 | 驳回原因,交同一责任人继续整改 |
-| 取消工单 | 管理员 | 待派单 / 待接单 / 已接单 / 处理中 / 待验收 | 已取消 | 取消原因 |
+| 取消工单 | 管理员 | 待派单 / 待接单 | 已取消 | 取消原因 |
 | 申请延期 | 责任人 | 处理中 | 待延期审核 | 延期原因和截止时间 |
 | 申请延期 | 责任人 | 处理中 | 待延期审核 | 延期原因和截止时间 |
 | 延期审核通过 | 管理员 | 待延期审核 | 延期中 | 审核意见 |
 | 延期审核通过 | 管理员 | 待延期审核 | 延期中 | 审核意见 |
 | 延期审核驳回 | 管理员 | 待延期审核 | 处理中 | 审核意见 |
 | 延期审核驳回 | 管理员 | 待延期审核 | 处理中 | 审核意见 |
@@ -42,6 +42,7 @@
 
 
 - “验收驳回”是动作不是状态;被驳回的工单状态显示为“处理中”,并在时间线展示驳回原因。
 - “验收驳回”是动作不是状态;被驳回的工单状态显示为“处理中”,并在时间线展示驳回原因。
 - “已取消”是终态,不能复活;如需继续处置,应另建新工单。
 - “已取消”是终态,不能复活;如需继续处置,应另建新工单。
+- 取消仅允许待派单(0)、待接单(1);接单后所有状态均不可取消。此规则按 2026-10-14 用户要求收窄,取代旧 ADR-0008 的任意中间态可取消口径,状态编码不变。
 - “延期申请状态”与“工单状态”是两个来源,不得互相替代。
 - “延期申请状态”与“工单状态”是两个来源,不得互相替代。
 - “已结案”不可再取消、驳回或回退。
 - “已结案”不可再取消、驳回或回退。
 
 
@@ -55,4 +56,4 @@
 
 
 ## 4. 与设备报警状态的关系
 ## 4. 与设备报警状态的关系
 
 
-工单状态独立于设备报警状态、报警记录状态和报警展示状态。工单结案只触发清警复核;运行系统确认没有有效报警原因后才允许自动清警。前端或工单服务不得直接把设备报警状态改为正常。
+工单状态独立于设备报警状态、报警记录状态和报警展示状态。工单结案只触发清警复核;运行系统确认没有有效报警原因后才允许自动清警。前端或工单服务不得直接把设备报警状态改为正常。

+ 8 - 8
pipe-network-service/sql/early_warning_system.sql

@@ -7,18 +7,16 @@ DROP TABLE IF EXISTS `early_warning`;
 CREATE TABLE `early_warning` (
 CREATE TABLE `early_warning` (
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID(主键)',
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID(主键)',
   `warning_no` varchar(64) NOT NULL COMMENT '预警编号',
   `warning_no` varchar(64) NOT NULL COMMENT '预警编号',
-  `warning_name` varchar(256) NOT NULL COMMENT '预警名称',
   `warning_type` varchar(64) NOT NULL COMMENT '预警类型',
   `warning_type` varchar(64) NOT NULL COMMENT '预警类型',
   `warning_level` varchar(32) NOT NULL COMMENT '预警级别(1-最高 2-次高 3-一般 4-常态;驾驶舱展示色为1红2黄3蓝4绿)',
   `warning_level` varchar(32) NOT NULL COMMENT '预警级别(1-最高 2-次高 3-一般 4-常态;驾驶舱展示色为1红2黄3蓝4绿)',
   `warning_special` varchar(128) DEFAULT NULL COMMENT '预警专项',
   `warning_special` varchar(128) DEFAULT NULL COMMENT '预警专项',
   `location` varchar(512) DEFAULT NULL COMMENT '预警位置',
   `location` varchar(512) DEFAULT NULL COMMENT '预警位置',
-  `longitude` decimal(15,10) DEFAULT NULL COMMENT '经度',
-  `latitude` decimal(15,10) DEFAULT NULL COMMENT '纬度',
   `ownership_unit` varchar(256) DEFAULT NULL COMMENT '权属单位',
   `ownership_unit` varchar(256) DEFAULT NULL COMMENT '权属单位',
   `publisher` varchar(128) DEFAULT NULL COMMENT '发布人',
   `publisher` varchar(128) DEFAULT NULL COMMENT '发布人',
   `publish_time` datetime DEFAULT NULL COMMENT '发布时间',
   `publish_time` datetime DEFAULT NULL COMMENT '发布时间',
   `handler` varchar(128) DEFAULT NULL COMMENT '处置人',
   `handler` varchar(128) DEFAULT NULL COMMENT '处置人',
-  `status` varchar(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态(DRAFT-草稿 PENDING-待办 PROCESSING-处置中 RELEASED-已发布 HANDLED-已处置 CLOSED-已解除)',
+  `reviewer` varchar(128) DEFAULT NULL COMMENT '核查人(默认发布人)',
+  `status` varchar(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态(DRAFT-草稿 PENDING-待接收 RECEIVED-已接收 PROCESSING-处置中 HANDLED-已处置 CLOSED-已解除)',
   `warning_content` text COMMENT '预警内容描述',
   `warning_content` text COMMENT '预警内容描述',
   `process_instance_id` varchar(128) DEFAULT NULL COMMENT '工作流实例ID',
   `process_instance_id` varchar(128) DEFAULT NULL COMMENT '工作流实例ID',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',
@@ -51,6 +49,8 @@ DROP TABLE IF EXISTS `warning_attachment`;
 CREATE TABLE `warning_attachment` (
 CREATE TABLE `warning_attachment` (
   `attachment_id` varchar(64) NOT NULL COMMENT '附件ID',
   `attachment_id` varchar(64) NOT NULL COMMENT '附件ID',
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
+  `disposal_id` varchar(64) DEFAULT NULL COMMENT '处置记录ID,报告附件为空',
+  `attachment_stage` varchar(32) DEFAULT 'REPORT' COMMENT '附件阶段(REPORT/PROCESS)',
   `attachment_name` varchar(256) NOT NULL COMMENT '附件名称',
   `attachment_name` varchar(256) NOT NULL COMMENT '附件名称',
   `attachment_url` varchar(512) NOT NULL COMMENT '附件地址',
   `attachment_url` varchar(512) NOT NULL COMMENT '附件地址',
   `attachment_type` varchar(255) DEFAULT NULL COMMENT '附件类型(MIME,标准 MIME 可达 70+ 字符)',
   `attachment_type` varchar(255) DEFAULT NULL COMMENT '附件类型(MIME,标准 MIME 可达 70+ 字符)',
@@ -89,6 +89,8 @@ CREATE TABLE `warning_supervision` (
   `supervision_content` text NOT NULL COMMENT '督办意见',
   `supervision_content` text NOT NULL COMMENT '督办意见',
   `notification_type` varchar(32) NOT NULL COMMENT '通知方式',
   `notification_type` varchar(32) NOT NULL COMMENT '通知方式',
   `notification_content` text COMMENT '通知内容',
   `notification_content` text COMMENT '通知内容',
+  `notification_status` varchar(16) DEFAULT NULL COMMENT '通知结果:PENDING/SENT/FAILED',
+  `notification_error` varchar(256) DEFAULT NULL COMMENT '失败原因',
   `attachment_url` varchar(512) DEFAULT NULL COMMENT '附件地址',
   `attachment_url` varchar(512) DEFAULT NULL COMMENT '附件地址',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -116,7 +118,8 @@ CREATE TABLE `warning_todo` (
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `user_id` varchar(64) NOT NULL COMMENT '用户ID',
   `user_id` varchar(64) NOT NULL COMMENT '用户ID',
   `user_name` varchar(128) DEFAULT NULL COMMENT '用户姓名',
   `user_name` varchar(128) DEFAULT NULL COMMENT '用户姓名',
-  `todo_type` varchar(32) NOT NULL COMMENT '待办类型(TODO-待办 DONE-已办)',
+  `todo_type` varchar(32) NOT NULL COMMENT '待办类型(TODO-待办 DONE-已办 NOTICE-系统通知)',
+  `action_type` varchar(32) DEFAULT NULL COMMENT '任务职责(HANDLE-处置 REVIEW-核查)',
   `read_status` varchar(32) DEFAULT 'UNREAD' COMMENT '阅读状态(UNREAD-未读 READ-已读)',
   `read_status` varchar(32) DEFAULT 'UNREAD' COMMENT '阅读状态(UNREAD-未读 READ-已读)',
   `task_name` varchar(256) DEFAULT NULL COMMENT '任务名称',
   `task_name` varchar(256) DEFAULT NULL COMMENT '任务名称',
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -163,12 +166,9 @@ CREATE TABLE `warning_archive` (
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `archive_no` varchar(64) NOT NULL COMMENT '归档编号',
   `archive_no` varchar(64) NOT NULL COMMENT '归档编号',
   `warning_no` varchar(64) NOT NULL COMMENT '预警编号',
   `warning_no` varchar(64) NOT NULL COMMENT '预警编号',
-  `warning_name` varchar(256) NOT NULL COMMENT '预警名称',
   `warning_type` varchar(64) NOT NULL COMMENT '预警类型',
   `warning_type` varchar(64) NOT NULL COMMENT '预警类型',
   `warning_level` varchar(32) NOT NULL COMMENT '预警级别',
   `warning_level` varchar(32) NOT NULL COMMENT '预警级别',
   `location` varchar(512) DEFAULT NULL COMMENT '预警位置',
   `location` varchar(512) DEFAULT NULL COMMENT '预警位置',
-  `longitude` decimal(15,10) DEFAULT NULL COMMENT '经度',
-  `latitude` decimal(15,10) DEFAULT NULL COMMENT '纬度',
   `ownership_unit` varchar(256) DEFAULT NULL COMMENT '权属单位',
   `ownership_unit` varchar(256) DEFAULT NULL COMMENT '权属单位',
   `publisher` varchar(128) DEFAULT NULL COMMENT '发布人',
   `publisher` varchar(128) DEFAULT NULL COMMENT '发布人',
   `publish_time` datetime DEFAULT NULL COMMENT '发布时间',
   `publish_time` datetime DEFAULT NULL COMMENT '发布时间',

+ 2 - 2
pipe-network-service/sql/water_supply_alarm_test_data.sql

@@ -127,13 +127,13 @@ VALUES
 -- ------------------------------------------------------------
 -- ------------------------------------------------------------
 INSERT INTO app_user.work_order
 INSERT INTO app_user.work_order
     (order_no, device_id,
     (order_no, device_id,
-     order_level, order_desc, dept_id, receive_user, order_status,
+     order_level, order_desc, receive_user, order_status,
      delayed_reason, delayed_deadline, plan_finish_time,
      delayed_reason, delayed_deadline, plan_finish_time,
      real_finish_time, repair_result, verify_user, verify_time, create_time, update_time,
      real_finish_time, repair_result, verify_user, verify_time, create_time, update_time,
      maintenance_before_img)
      maintenance_before_img)
 VALUES
 VALUES
     ('TEST-WO-001', 'TEST-EQ-WL-002',
     ('TEST-WO-001', 'TEST-EQ-WL-002',
-     2, '供水监测报警测试工单-水位超限', NULL, NULL, 5,
+     2, '供水监测报警测试工单-水位超限', NULL, 5,
      NULL, NULL, NULL,
      NULL, NULL, NULL,
      NULL, '现场已处理', NULL, NULL, now(), now(),
      NULL, '现场已处理', NULL, NULL, now(), now(),
      NULL);
      NULL);

+ 9 - 9
pipe-network-service/sql/water_supply_monitoring_alarm_rebuild_test_data.sql

@@ -359,25 +359,25 @@ WHERE id LIKE 'wrb-alarm-%';
 -- 10. 工单(0..7 各一张;device_id 必须是供水设备 equipment_id,否则审核计数条取不到)
 -- 10. 工单(0..7 各一张;device_id 必须是供水设备 equipment_id,否则审核计数条取不到)
 -- ------------------------------------------------------------
 -- ------------------------------------------------------------
 INSERT INTO app_user.work_order
 INSERT INTO app_user.work_order
-    (order_no, device_id, order_level, order_desc, dept_id,
+    (order_no, device_id, order_level, order_desc,
      dispatch_user, dispatch_time, receive_user, order_status, fault_time, create_time, update_time)
      dispatch_user, dispatch_time, receive_user, order_status, fault_time, create_time, update_time)
 VALUES
 VALUES
     ('WRB-WO-01', 'WRB-EQ-FL-01', 0,
     ('WRB-WO-01', 'WRB-EQ-FL-01', 0,
-     '#56测试工单-城南主轴流量超限待派单', 100, NULL, NULL, NULL, 0, now() - interval '35 minutes', now(), now()),
+     '#56测试工单-城南主轴流量超限待派单', NULL, NULL, NULL, 0, now() - interval '35 minutes', now(), now()),
     ('WRB-WO-02', 'WRB-EQ-FL-02', 1,
     ('WRB-WO-02', 'WRB-EQ-FL-02', 1,
-     '#56测试工单-城北出厂流量超限待接单', 100, 1, now() - interval '3 hours', NULL, 1, now() - interval '3 hours', now(), now()),
+     '#56测试工单-城北出厂流量超限待接单', 1, now() - interval '3 hours', NULL, 1, now() - interval '3 hours', now(), now()),
     ('WRB-WO-03', 'WRB-EQ-FL-03', 0,
     ('WRB-WO-03', 'WRB-EQ-FL-03', 0,
-     '#56测试工单-太常厂区流量超限已接单', 100, 1, now() - interval '1 day', 2, 2, now() - interval '1 day 2 hours', now(), now()),
+     '#56测试工单-太常厂区流量超限已接单', 1, now() - interval '1 day', 2, 2, now() - interval '1 day 2 hours', now(), now()),
     ('WRB-WO-04', 'WRB-EQ-FL-04', 1,
     ('WRB-WO-04', 'WRB-EQ-FL-04', 1,
-     '#56测试工单-城南支线流量超限处理中', 100, 1, now() - interval '2 days', 2, 3, now() - interval '2 days', now(), now()),
+     '#56测试工单-城南支线流量超限处理中', 1, now() - interval '2 days', 2, 3, now() - interval '2 days', now(), now()),
     ('WRB-WO-05', 'WRB-EQ-PR-01', 1,
     ('WRB-WO-05', 'WRB-EQ-PR-01', 1,
-     '#56测试工单-城南加压站压力超限待验收', 100, 1, now() - interval '3 days', 2, 4, now() - interval '3 days', now(), now()),
+     '#56测试工单-城南加压站压力超限待验收', 1, now() - interval '3 days', 2, 4, now() - interval '3 days', now(), now()),
     ('WRB-WO-06', 'WRB-EQ-PR-02', 0,
     ('WRB-WO-06', 'WRB-EQ-PR-02', 0,
-     '#56测试工单-城北末梢压力偏低已结案', 100, 1, now() - interval '4 days', 2, 5, now() - interval '4 days', now(), now()),
+     '#56测试工单-城北末梢压力偏低已结案', 1, now() - interval '4 days', 2, 5, now() - interval '4 days', now(), now()),
     ('WRB-WO-07', 'WRB-EQ-LK-01', 1,
     ('WRB-WO-07', 'WRB-EQ-LK-01', 1,
-     '#56测试工单-城南漏失延期中', 100, 1, now() - interval '5 days', 2, 6, now() - interval '5 days', now(), now()),
+     '#56测试工单-城南漏失延期中', 1, now() - interval '5 days', 2, 6, now() - interval '5 days', now(), now()),
     ('WRB-WO-08', 'WRB-EQ-LK-02', 0,
     ('WRB-WO-08', 'WRB-EQ-LK-02', 0,
-     '#56测试工单-城北漏失已取消', 100, 1, now() - interval '6 days', NULL, 7, now() - interval '6 days', now(), now());
+     '#56测试工单-城北漏失已取消', 1, now() - interval '6 days', NULL, 7, now() - interval '6 days', now(), now());
 
 
 UPDATE app_user.alarm_data a
 UPDATE app_user.alarm_data a
    SET work_order_id = w.order_id
    SET work_order_id = w.order_id

+ 2 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/EquipmentBaseController.java

@@ -44,6 +44,7 @@ public class EquipmentBaseController {
                 rootTypeId, onlineStatus, alarmStatus);
                 rootTypeId, onlineStatus, alarmStatus);
     }
     }
 
 
+
     @GetMapping("/all")
     @GetMapping("/all")
     @ApiOperation(value = "查询所有设备基础信息")
     @ApiOperation(value = "查询所有设备基础信息")
     @Anonymous
     @Anonymous
@@ -71,6 +72,7 @@ public class EquipmentBaseController {
         return AjaxResult.success(equipmentBase);
         return AjaxResult.success(equipmentBase);
     }
     }
 
 
+
     @PostMapping
     @PostMapping
     @ApiOperation(value = "新增设备基础信息")
     @ApiOperation(value = "新增设备基础信息")
     @Log(title = "新增设备基础信息", businessType = BusinessType.INSERT)
     @Log(title = "新增设备基础信息", businessType = BusinessType.INSERT)

+ 42 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/drainage/DrainageVideoController.java

@@ -0,0 +1,42 @@
+package com.zksy.web.controller.drainage;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.annotation.Log;
+import com.zksy.common.enums.BusinessType;
+import com.zksy.drainage.domain.DrainageVideoResource;
+import com.zksy.drainage.service.DrainageVideoService;
+import lombok.Data;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/drainage/video")
+public class DrainageVideoController {
+    private final DrainageVideoService service;
+    public DrainageVideoController(DrainageVideoService service) {this.service=service;}
+    @PreAuthorize("@ss.hasPermi('drainage:video:list')")
+    @GetMapping("/resources")
+    public AjaxResult list(@RequestParam(defaultValue="1") int pageNum,@RequestParam(defaultValue="10") int pageSize,
+        @RequestParam(required=false) String query,@RequestParam(required=false) Integer district,
+        @RequestParam(required=false) String pointId,@RequestParam(required=false) Boolean unbound,
+        @RequestParam(required=false) Boolean favoriteOnly,@RequestParam(required=false) String sourceType) {
+        Page<DrainageVideoResource> page=service.page(pageNum,pageSize,query,district,pointId,unbound,favoriteOnly,sourceType);
+        AjaxResult result=AjaxResult.success();result.put("rows",page.getRecords());result.put("total",page.getTotal());return result;
+    }
+    @PreAuthorize("@ss.hasPermi('drainage:video:list')") @GetMapping("/tree")
+    public AjaxResult tree() {return AjaxResult.success(service.tree());}
+    @PreAuthorize("@ss.hasPermi('drainage:video:list')") @GetMapping("/map")
+    public AjaxResult map() {return AjaxResult.success(service.all());}
+    @PreAuthorize("@ss.hasPermi('drainage:video:list')") @GetMapping("/points")
+    public AjaxResult points() {return AjaxResult.success(service.points());}
+    @PreAuthorize("@ss.hasPermi('drainage:video:bind')") @PutMapping("/devices/{id}/waterlogging")
+    @Log(title="排水视频点位关联",businessType=BusinessType.UPDATE)
+    public AjaxResult associate(@PathVariable String id,@RequestBody Association input) {service.associate(id,input.getPointId());return AjaxResult.success();}
+    @PreAuthorize("@ss.hasPermi('drainage:video:favorite')") @PutMapping("/devices/{id}/favorite")
+    public AjaxResult favorite(@PathVariable String id,@Valid @RequestBody Favorite input) {service.favorite(id,input.getEnabled());return AjaxResult.success();}
+    @Data public static class Association {private String pointId;}
+    @Data public static class Favorite {@NotNull private Boolean enabled;}
+}

+ 59 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileAlarmController.java

@@ -0,0 +1,59 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.base.alarm.dto.*;
+import com.zksy.base.alarm.enums.AlarmAuditStatusEnum;
+import com.zksy.base.alarm.enums.AlarmRecordStatusEnum;
+import com.zksy.base.alarm.service.AlarmOperationsService;
+import com.zksy.base.alarm.service.AlarmReviewService;
+import com.zksy.base.alarm.service.AlarmReviewAttachmentService;
+import com.zksy.common.annotation.Log;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.enums.BusinessType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import javax.validation.Valid;
+
+@RestController
+@RequestMapping("/api/mobile/alarm")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileAlarmController {
+    private final AlarmOperationsService operations;
+    private final AlarmReviewService reviews;
+    private final AlarmReviewAttachmentService attachments;
+
+    @GetMapping
+    public AjaxResult list(@Valid MobileAlarmQuery query) { return AjaxResult.success(operations.mobilePage(query)); }
+
+    @GetMapping("/{alarmId}")
+    public AjaxResult detail(@PathVariable("alarmId") String id, @Valid MobileAlarmQuery query) {
+        return AjaxResult.success(operations.mobileDetail(id, query));
+    }
+
+    @GetMapping("/{alarmId}/logs")
+    public AjaxResult logs(@PathVariable("alarmId") String id, @Valid MobileAlarmQuery query) {
+        return AjaxResult.success(operations.mobileLogs(id, query));
+    }
+
+    @PostMapping("/{alarmId}/reviews")
+    @PreAuthorize("@ss.hasPermi('alarm:review:audit')")
+    @Log(title = "移动端报警审核", businessType = BusinessType.UPDATE)
+    public AjaxResult review(@PathVariable("alarmId") String id, @Valid MobileAlarmQuery query,
+                             @Valid @RequestBody AlarmReviewRequest request) {
+        return AjaxResult.success(reviews.mobileReview(id, query, request));
+    }
+
+    @PostMapping("/{alarmId}/attachments")
+    @PreAuthorize("@ss.hasPermi('alarm:review:audit')")
+    public AjaxResult upload(@PathVariable("alarmId") String id, @Valid MobileAlarmQuery query,
+                             @RequestPart("file") MultipartFile file) throws Exception {
+        AlarmVO alarm = operations.mobileDetail(id, query);
+        if (!Integer.valueOf(AlarmAuditStatusEnum.UNREVIEWED.getCode()).equals(alarm.getAuditStatus())
+                || !Integer.valueOf(AlarmRecordStatusEnum.UNHANDLED.getCode()).equals(alarm.getAlarmStatus())) {
+            return AjaxResult.error("报警已审核或已处理,请刷新后重试");
+        }
+        return AjaxResult.success(attachments.upload(id, file));
+    }
+}

+ 27 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileContactsController.java

@@ -0,0 +1,27 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.system.service.ISysUserService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/mobile/contacts")
+@Api(tags = "移动端通讯录")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileContactsController extends BaseController {
+    private final ISysUserService userService;
+
+    @GetMapping
+    @ApiOperation("获取有效联系人,排除当前用户和超级管理员")
+    public AjaxResult contacts() {
+        return success(userService.selectContacts(getUserId()));
+    }
+}

+ 37 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEquipmentController.java

@@ -0,0 +1,37 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.common.core.domain.AjaxResult;
+import io.swagger.annotations.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/mobile/equipment-base")
+@Api(tags = "移动端设备信息")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileEquipmentController {
+    private final EquipmentBaseService service;
+
+    @GetMapping
+    @PreAuthorize("isAuthenticated()")
+    @ApiOperation("移动端日常设备信息分页查询(按公司设备类型范围过滤)")
+    public AjaxResult findMobileByPage(
+            @RequestParam(value = "pageNum", defaultValue = "1") long pageNum,
+            @RequestParam(value = "pageSize", defaultValue = "10") long pageSize,
+            @ApiParam("设备编码或名称") @RequestParam(value = "keyword", required = false) String keyword,
+            @ApiParam("设备根类型ID,兼容PC端筛选") @RequestParam(value = "rootTypeId", required = false) Integer rootTypeId,
+            @ApiParam("在线状态(0-离线,1-在线)") @RequestParam(value = "onlineStatus", required = false) Integer onlineStatus,
+            @ApiParam("报警状态(1-报警,仅勾选报警时传)") @RequestParam(value = "alarmStatus", required = false) Integer alarmStatus) {
+        return AjaxResult.success(service.findMobileByPage(pageNum, pageSize, keyword, rootTypeId, onlineStatus, alarmStatus));
+    }
+
+    @GetMapping("/{id}")
+    @PreAuthorize("isAuthenticated()")
+    @ApiOperation("移动端设备详情(按公司设备类型范围校验)")
+    public AjaxResult getMobileById(@PathVariable("id") String id) {
+        return AjaxResult.success(service.getMobileEquipmentDetail(id));
+    }
+}

+ 45 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileInspectTaskController.java

@@ -0,0 +1,45 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.common.annotation.Log;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.enums.BusinessType;
+import com.zksy.ops.dto.InspectTaskCompleteDTO;
+import com.zksy.ops.dto.InspectTaskPageDTO;
+import com.zksy.ops.service.InspectTaskService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/mobile/ops/inspectTask")
+@Api(tags = "移动端巡检")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileInspectTaskController {
+    private final InspectTaskService inspectTaskService;
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/findByPage")
+    @ApiOperation("移动端分页查询当前用户的巡检任务")
+    public AjaxResult findMobileByPage(InspectTaskPageDTO dto) {
+        return AjaxResult.success(inspectTaskService.findMobileByPage(dto));
+    }
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/{id}")
+    @ApiOperation("移动端获取当前用户的巡检任务详情")
+    public AjaxResult getMobileById(@PathVariable("id") Long id) {
+        return AjaxResult.success(inspectTaskService.getMobileDetailById(id));
+    }
+
+    @PreAuthorize("isAuthenticated()")
+    @PutMapping("/complete")
+    @ApiOperation("移动端完成当前用户的巡检任务")
+    @Log(title = "移动端完成巡检", businessType = BusinessType.UPDATE)
+    public AjaxResult completeMobile(@RequestBody InspectTaskCompleteDTO dto) {
+        return inspectTaskService.completeMobileTask(dto)
+                ? AjaxResult.success("完成巡检成功") : AjaxResult.error("完成巡检失败");
+    }
+}

+ 31 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileLogininforController.java

@@ -0,0 +1,31 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.core.page.TableDataInfo;
+import com.zksy.system.domain.SysLogininfor;
+import com.zksy.system.service.ISysLogininforService;
+import java.util.List;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/mobile/monitor/logininfor")
+@Api(tags = "移动端登录日志")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileLogininforController extends BaseController {
+    private final ISysLogininforService logininforService;
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/list")
+    @ApiOperation("当前登录用户的登录日志(移动端)")
+    public TableDataInfo mobileList()
+    {
+        startPage();
+        List<SysLogininfor> list = logininforService.selectCurrentUserLogininforList();
+        return getDataTable(list);
+    }
+}

+ 44 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileNoticeController.java

@@ -0,0 +1,44 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.common.core.controller.BaseController;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.core.page.TableDataInfo;
+import com.zksy.system.domain.SysNotice;
+import com.zksy.system.service.ISysNoticeService;
+import java.util.List;
+import io.swagger.annotations.Api;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/mobile/notice")
+@Api(tags = "移动端通知公告")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileNoticeController extends BaseController {
+    private final ISysNoticeService noticeService;
+
+    /**
+     * 获取移动端通知公告列表(登录后即可访问)。
+     */
+    @GetMapping("/list")
+    public TableDataInfo mobileList(SysNotice notice) {
+        notice.setStatus("0");
+        startPage();
+        List<SysNotice> list = noticeService.selectNoticeList(notice);
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取移动端通知公告详情(登录后即可访问)。
+     */
+    @GetMapping("/{noticeId}")
+    public AjaxResult mobileGetInfo(@PathVariable("noticeId") Long noticeId) {
+        SysNotice notice = noticeService.selectNoticeById(noticeId);
+        if (notice == null || !"0".equals(notice.getStatus())) {
+            return error("通知公告不存在或已关闭");
+        }
+        return success(notice);
+    }
+}

+ 43 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileOfficeController.java

@@ -0,0 +1,43 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.base.alarm.dto.MobileAlarmQuery;
+import com.zksy.base.alarm.service.AlarmOperationsService;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.ops.dto.InspectTaskPageDTO;
+import com.zksy.ops.service.InspectTaskService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/mobile/office")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileOfficeController {
+    private final EquipmentBaseService equipment;
+    private final AlarmOperationsService alarms;
+    private final InspectTaskService inspections;
+
+    @GetMapping("/summary")
+    public AjaxResult summary() {
+        Map<String, Long> result = new LinkedHashMap<>();
+        result.put("equipmentTotal", equipment.findMobileByPage(1, 1, null, null, null, null).getTotal());
+        result.put("equipmentOffline", equipment.findMobileByPage(1, 1, null, null, 0, null).getTotal());
+        result.put("equipmentAlarming", equipment.findMobileByPage(1, 1, null, null, null, 1).getTotal());
+        MobileAlarmQuery alarmQuery = new MobileAlarmQuery();
+        alarmQuery.setPageSize(1);
+        result.put("alarmPending", alarms.mobilePage(alarmQuery).getTotal());
+        result.put("alarmAddedToday", alarms.mobileTodayCount());
+        InspectTaskPageDTO inspectQuery = new InspectTaskPageDTO();
+        inspectQuery.setPageSize(1L);
+        inspectQuery.setStatus(0);
+        result.put("inspectPending", inspections.findMobileByPage(inspectQuery).getTotal());
+        return AjaxResult.success(result);
+    }
+}

+ 50 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileWorkOrderController.java

@@ -0,0 +1,50 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.ops.domain.vo.MobileAssignableUserVO;
+import com.zksy.ops.dto.WorkOrderPageDTO;
+import com.zksy.ops.service.WorkOrderService;
+import java.util.List;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/mobile/ops/workOrder")
+@Api(tags = "移动端工单")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileWorkOrderController {
+    private final WorkOrderService workOrderService;
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping
+    @ApiOperation("移动端我的工单分页(无需 PC 查询权限)")
+    public AjaxResult mobilePage(WorkOrderPageDTO query) {
+        return AjaxResult.success(workOrderService.findMobilePage(query));
+    }
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/pending")
+    @ApiOperation("移动端待办工单分页(无需 PC 查询权限)")
+    public AjaxResult mobilePendingPage(WorkOrderPageDTO query) {
+        return AjaxResult.success(workOrderService.findMobilePendingPage(query));
+    }
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/{orderId}")
+    @ApiOperation("移动端工单详情(含数据范围校验)")
+    public AjaxResult mobileDetail(@PathVariable("orderId") Long orderId) {
+        return AjaxResult.success(workOrderService.getMobileDetail(orderId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:dispatch')")
+    @GetMapping("/assignable/users")
+    @ApiOperation("移动端派单候选人,type=handler/reviewer")
+    public AjaxResult mobileAssignableUsers(@RequestParam("type") String type) {
+        List<MobileAssignableUserVO> users = workOrderService.mobileAssignableUsers(type);
+        return AjaxResult.success(users);
+    }
+}

+ 1 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/monitor/SysLogininforController.java

@@ -49,6 +49,7 @@ public class SysLogininforController extends BaseController
         return getDataTable(list);
         return getDataTable(list);
     }
     }
 
 
+
     @Log(title = "登录日志", businessType = BusinessType.EXPORT)
     @Log(title = "登录日志", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('monitor:logininfor:export')")
     @PreAuthorize("@ss.hasPermi('monitor:logininfor:export')")
     @PostMapping("/export")
     @PostMapping("/export")

+ 1 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/InspectTaskController.java

@@ -90,4 +90,5 @@ public class InspectTaskController {
     public AjaxResult deleteById(@PathVariable("id") Long id) {
     public AjaxResult deleteById(@PathVariable("id") Long id) {
         return inspectTaskService.deleteTask(id) ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
         return inspectTaskService.deleteTask(id) ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
     }
     }
+
 }
 }

+ 9 - 10
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/WorkOrderController.java

@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
-import com.zksy.common.annotation.DataScope;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.core.domain.TreeSelect;
 import com.zksy.common.core.domain.TreeSelect;
@@ -53,7 +52,6 @@ public class WorkOrderController {
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @GetMapping
     @GetMapping
     @ApiOperation("分页查询工单列表")
     @ApiOperation("分页查询工单列表")
-    @DataScope(deptAlias = "app_user.work_order", permission = "ops:workOrder:query")
     public AjaxResult page(WorkOrderPageDTO query) {
     public AjaxResult page(WorkOrderPageDTO query) {
         Page<WorkOrderVO> page = workOrderService.findByPage(query);
         Page<WorkOrderVO> page = workOrderService.findByPage(query);
         return AjaxResult.success(page);
         return AjaxResult.success(page);
@@ -62,7 +60,6 @@ public class WorkOrderController {
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @GetMapping("/statusCount")
     @GetMapping("/statusCount")
     @ApiOperation("按状态统计工单数量(列表状态筛选条使用,忽略入参中的状态筛选)")
     @ApiOperation("按状态统计工单数量(列表状态筛选条使用,忽略入参中的状态筛选)")
-    @DataScope(deptAlias = "app_user.work_order", permission = "ops:workOrder:query")
     public AjaxResult statusCount(WorkOrderPageDTO query) {
     public AjaxResult statusCount(WorkOrderPageDTO query) {
         return AjaxResult.success(workOrderService.countByStatus(query));
         return AjaxResult.success(workOrderService.countByStatus(query));
     }
     }
@@ -115,7 +112,7 @@ public class WorkOrderController {
         return workOrderService.dispatch(orderId, dto) ? AjaxResult.success("派单成功") : AjaxResult.error("派单失败");
         return workOrderService.dispatch(orderId, dto) ? AjaxResult.success("派单成功") : AjaxResult.error("派单失败");
     }
     }
 
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:accept')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/accept")
     @PutMapping("/{orderId}/accept")
     @ApiOperation("核查接单(待接单 → 已接单)")
     @ApiOperation("核查接单(待接单 → 已接单)")
     @Log(title = "工单接单", businessType = BusinessType.UPDATE)
     @Log(title = "工单接单", businessType = BusinessType.UPDATE)
@@ -125,13 +122,13 @@ public class WorkOrderController {
 
 
     @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/process")
     @PutMapping("/{orderId}/process")
-    @ApiOperation("工单处置(已接单/处理中/延期中 → 处理中,可多次处置)")
+    @ApiOperation("工单处置(已接单/延期中 → 处理中)")
     @Log(title = "工单处置", businessType = BusinessType.UPDATE)
     @Log(title = "工单处置", businessType = BusinessType.UPDATE)
     public AjaxResult process(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
     public AjaxResult process(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
         return workOrderService.process(orderId, dto) ? AjaxResult.success("处置成功") : AjaxResult.error("处置失败");
         return workOrderService.process(orderId, dto) ? AjaxResult.success("处置成功") : AjaxResult.error("处置失败");
     }
     }
 
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:submitVerify')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/submitVerify")
     @PutMapping("/{orderId}/submitVerify")
     @ApiOperation("提交验收(处理中/延期中 → 待验收)")
     @ApiOperation("提交验收(处理中/延期中 → 待验收)")
     @Log(title = "工单提交验收", businessType = BusinessType.UPDATE)
     @Log(title = "工单提交验收", businessType = BusinessType.UPDATE)
@@ -139,7 +136,7 @@ public class WorkOrderController {
         return workOrderService.submitVerify(orderId, dto) ? AjaxResult.success("提交验收成功") : AjaxResult.error("提交验收失败");
         return workOrderService.submitVerify(orderId, dto) ? AjaxResult.success("提交验收成功") : AjaxResult.error("提交验收失败");
     }
     }
 
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:verify')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:audit')")
     @PutMapping("/{orderId}/verify")
     @PutMapping("/{orderId}/verify")
     @ApiOperation("工单验收(待验收 → 已结案 / 处理中)")
     @ApiOperation("工单验收(待验收 → 已结案 / 处理中)")
     @Log(title = "工单验收", businessType = BusinessType.UPDATE)
     @Log(title = "工单验收", businessType = BusinessType.UPDATE)
@@ -149,13 +146,13 @@ public class WorkOrderController {
 
 
     @PreAuthorize("@ss.hasPermi('ops:workOrder:cancel')")
     @PreAuthorize("@ss.hasPermi('ops:workOrder:cancel')")
     @PutMapping("/{orderId}/cancel")
     @PutMapping("/{orderId}/cancel")
-    @ApiOperation("取消工单(待派单/待接单/已接单/处理中/待验收 → 已取消)")
+    @ApiOperation("取消工单(仅待派单/待接单 → 已取消)")
     @Log(title = "取消工单", businessType = BusinessType.UPDATE)
     @Log(title = "取消工单", businessType = BusinessType.UPDATE)
     public AjaxResult cancel(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
     public AjaxResult cancel(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
         return workOrderService.cancel(orderId, dto) ? AjaxResult.success("取消成功") : AjaxResult.error("取消失败");
         return workOrderService.cancel(orderId, dto) ? AjaxResult.success("取消成功") : AjaxResult.error("取消失败");
     }
     }
 
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:delay')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/delay/apply")
     @PutMapping("/{orderId}/delay/apply")
     @ApiOperation("申请延期(处理中且无延期申请 → 待延期审核)")
     @ApiOperation("申请延期(处理中且无延期申请 → 待延期审核)")
     @Log(title = "工单申请延期", businessType = BusinessType.UPDATE)
     @Log(title = "工单申请延期", businessType = BusinessType.UPDATE)
@@ -163,7 +160,7 @@ public class WorkOrderController {
         return workOrderService.applyDelay(orderId, dto) ? AjaxResult.success("延期申请已提交") : AjaxResult.error("延期申请失败");
         return workOrderService.applyDelay(orderId, dto) ? AjaxResult.success("延期申请已提交") : AjaxResult.error("延期申请失败");
     }
     }
 
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:delayAudit')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:audit')")
     @PutMapping("/{orderId}/delay/audit")
     @PutMapping("/{orderId}/delay/audit")
     @ApiOperation("延期审核(待延期审核 → 延期审核通过 / 延期审核驳回)")
     @ApiOperation("延期审核(待延期审核 → 延期审核通过 / 延期审核驳回)")
     @Log(title = "工单延期审核", businessType = BusinessType.UPDATE)
     @Log(title = "工单延期审核", businessType = BusinessType.UPDATE)
@@ -179,6 +176,8 @@ public class WorkOrderController {
         return workOrderService.deleteWorkOrder(orderId) ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
         return workOrderService.deleteWorkOrder(orderId) ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
     }
     }
 
 
+
+
     /**
     /**
      * 入参 DTO 转工单实体:只映射创建阶段客户端可决定的字段,
      * 入参 DTO 转工单实体:只映射创建阶段客户端可决定的字段,
      * 工单编号、状态、归属部门、创建时间等一律由 Service 生成。
      * 工单编号、状态、归属部门、创建时间等一律由 Service 生成。

+ 0 - 22
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysNoticeController.java

@@ -85,26 +85,4 @@ public class SysNoticeController extends BaseController {
     }
     }
 
 
 
 
-    /**
-     * 获取移动端通知公告列表(登录后即可访问)。
-     */
-    @GetMapping("/mobile/list")
-    public TableDataInfo mobileList(SysNotice notice) {
-        notice.setStatus("0");
-        startPage();
-        List<SysNotice> list = noticeService.selectNoticeList(notice);
-        return getDataTable(list);
-    }
-
-    /**
-     * 获取移动端通知公告详情(登录后即可访问)。
-     */
-    @GetMapping("/mobile/{noticeId}")
-    public AjaxResult mobileGetInfo(@PathVariable Long noticeId) {
-        SysNotice notice = noticeService.selectNoticeById(noticeId);
-        if (notice == null || !"0".equals(notice.getStatus())) {
-            return error("通知公告不存在或已关闭");
-        }
-        return success(notice);
-    }
 }
 }

+ 0 - 8
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysUserController.java

@@ -263,12 +263,4 @@ public class SysUserController extends BaseController
         return success(userService.selectAllEnabledUser());
         return success(userService.selectAllEnabledUser());
     }
     }
 
 
-    /**
-     * 获取当前用户所在部门及全部下级部门的通讯录。
-     */
-    @GetMapping("/contacts")
-    public AjaxResult contacts() {
-        Long deptId = getLoginUser().getUser().getDeptId();
-        return success(userService.selectContactsByDeptId(deptId, getUserId()));
-    }
 }
 }

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

@@ -8,6 +8,7 @@ import com.zksy.ops.domain.vo.WorkOrderPushVO;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.common.core.domain.model.LoginUser;
 import com.zksy.common.core.domain.model.LoginUser;
 import com.zksy.common.utils.DateUtils;
 import com.zksy.common.utils.DateUtils;
+import com.zksy.common.utils.SecurityUtils;
 import com.zksy.framework.web.service.TokenService;
 import com.zksy.framework.web.service.TokenService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -279,10 +280,11 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
 
 
     static boolean isWorkOrderVisibleTo(WorkOrderPushVO item, LoginUser loginUser) {
     static boolean isWorkOrderVisibleTo(WorkOrderPushVO item, LoginUser loginUser) {
         if (item == null || loginUser == null) return false;
         if (item == null || loginUser == null) return false;
-        boolean sameDepartment = loginUser.getDeptId() != null && loginUser.getDeptId().equals(item.getDeptId());
-        boolean receiver = item.getReceiveUser() != null
-                && item.getReceiveUser().equals(loginUser.getUserId());
-        return sameDepartment || receiver;
+        Long userId = loginUser.getUserId();
+        if (SecurityUtils.isAdmin(userId)) return true;
+        return userId != null && (userId.equals(item.getCreateBy())
+                || userId.equals(item.getReceiveUser())
+                || userId.equals(item.getVerifyUser()));
     }
     }
 
 
     /**
     /**

+ 6 - 3
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/WorkOrderWebSocketPushTask.java

@@ -131,8 +131,9 @@ public class WorkOrderWebSocketPushTask {
                         + item.getOrderStatus() + ':'
                         + item.getOrderStatus() + ':'
                         + time(item.getEffectiveDeadline()) + ':'
                         + time(item.getEffectiveDeadline()) + ':'
                         + time(item.getUpdateTime()) + ':'
                         + time(item.getUpdateTime()) + ':'
-                        + item.getDeptId() + ':'
-                        + item.getReceiveUser())
+                        + item.getCreateBy() + ':'
+                        + item.getReceiveUser() + ':'
+                        + item.getVerifyUser())
                 .collect(Collectors.joining("|"));
                 .collect(Collectors.joining("|"));
     }
     }
 
 
@@ -140,7 +141,9 @@ public class WorkOrderWebSocketPushTask {
         if (loginUser == null) {
         if (loginUser == null) {
             return "ANONYMOUS";
             return "ANONYMOUS";
         }
         }
-        return String.valueOf(loginUser.getUserId()) + ":" + String.valueOf(loginUser.getDeptId());
+        String permissions = loginUser.getPermissions() == null ? "" : loginUser.getPermissions().stream()
+                .sorted().collect(Collectors.joining(","));
+        return String.valueOf(loginUser.getUserId()) + ":" + permissions;
     }
     }
 
 
     private long time(Date value) {
     private long time(Date value) {

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

@@ -11,6 +11,7 @@ import com.zksy.base.service.RiskAssessmentService;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.domain.EarlyWarningStatusEnum;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureEmergencySupportAdapter;
 import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureEmergencySupportAdapter;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportSnapshot;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportSnapshot;
@@ -91,7 +92,7 @@ class BusinessCockpitInfrastructureEmergencySupportAdapterTest {
         alarm.setAlarmStatus(0);
         alarm.setAlarmStatus(0);
         EarlyWarning warning = new EarlyWarning();
         EarlyWarning warning = new EarlyWarning();
         warning.setDeviceCode("GAS-001");
         warning.setDeviceCode("GAS-001");
-        warning.setStatus("PENDING");
+        warning.setStatus(EarlyWarningStatusEnum.PENDING.getCode());
         WorkOrder order = new WorkOrder();
         WorkOrder order = new WorkOrder();
         order.setDeviceId("EQUIP-001");
         order.setDeviceId("EQUIP-001");
         order.setOrderStatus(3);
         order.setOrderStatus(3);

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

@@ -181,6 +181,30 @@ class BusinessCockpitMonitoringDistributionAdapterTest {
 
 
     // ---- fixtures ----
     // ---- fixtures ----
 
 
+    /**
+     * 规格 08:点位 `createdAt` 来自 `monitor_point.create_time`、设备来自 `equipment_base.create_time`;
+     * 两者与 `statusTime`(状态更新时间)分离。
+     */
+    @Test
+    void createdAtComesFromCreateTimeColumnsAndStaysSeparateFromStatusTime() {
+        MonitorPoint point = point("P-1", "P-1", "点位", new BigDecimal("110.4"), new BigDecimal("28.4"));
+        point.setCreateTime(java.time.LocalDateTime.parse("2026-01-05T08:00:00"));
+        stubPoints(Collections.singletonList(point));
+        stubTypes(Collections.singletonList(type(22, "22", "可燃气体检测仪", "3")));
+        EquipmentBase device = device("D-1", "D-1", "设备", "22",
+                new BigDecimal("110.4"), new BigDecimal("28.4"), 0);
+        device.setCreateTime(java.time.LocalDateTime.parse("2026-03-11T09:30:00"));
+        stubDevices(Collections.singletonList(device), Collections.emptyList());
+        when(deviceFactResolver.resolve(any(), anyString())).thenReturn(fact("D-1", true, false, false, false));
+
+        CockpitMonitoringDistributionSnapshot snapshot = adapter.load(null);
+
+        assertEquals("2026-01-05T08:00:00+08:00", snapshot.getMonitoringPoints().get(0).getCreatedAt());
+        assertEquals("2026-03-11T09:30:00+08:00", snapshot.getSmartDevices().get(0).getCreatedAt());
+        assertNull(snapshot.getSmartDevices().get(0).getStatusTime(),
+                "statusTime 仍取设备状态更新时间,不被 createdAt 顶替");
+    }
+
     private static CockpitMonitoringPointDeviceSnapshot fact(
     private static CockpitMonitoringPointDeviceSnapshot fact(
             String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
             String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
         return new CockpitMonitoringPointDeviceSnapshot(
         return new CockpitMonitoringPointDeviceSnapshot(

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

@@ -162,6 +162,54 @@ class BusinessCockpitMonitoringOverviewAdapterTest {
 
 
     // ---- helpers ----
     // ---- helpers ----
 
 
+    /**
+     * 规格 08:设备 `createdAt` 来自 `equipment_base.create_time`、点位来自 `monitor_point.create_time`,
+     * 且与 `sampledAt`(状态更新时间)分离 —— 两者取自不同列,不得互相冒充。
+     */
+    @Test
+    void createdAtComesFromCreateTimeColumnsAndStaysSeparateFromSampledAt() {
+        MonitorPoint point = point("P-1", "Z-RQXLJC01");
+        point.setCreateTime(LocalDateTime.parse("2026-01-05T08:00:00"));
+        stubPoints(Collections.singletonList(point));
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        EquipmentBase device = device("D-1", "ONLINE-1", "22");
+        device.setCreateTime(LocalDateTime.parse("2026-03-11T09:30:00"));
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device));
+        when(equipmentBaseService.getEquipmentByPoint("P-1")).thenReturn(Collections.singletonList(device));
+        when(equipmentStatusService.getByDeviceCode("ONLINE-1")).thenReturn(status(1));
+
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+
+        CockpitMonitoringOverviewSnapshot.SourceDevice source = snapshot.getDevices().get(0);
+        assertEquals("2026-03-11T09:30:00+08:00", source.getCreatedAt());
+        assertEquals("2026-09-02T09:59:50+08:00", source.getSampledAt(),
+                "sampledAt 仍取状态更新时间,语义不变");
+        assertEquals("2026-01-05T08:00:00+08:00", snapshot.getMonitoringPoints().get(0).getCreatedAt());
+
+        CockpitMonitoringOverviewService service = new CockpitMonitoringOverviewService(
+                () -> snapshot, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        assertEquals("2026-03-11T09:30:00+08:00", service.load("req", null, null).getCreatedAt());
+    }
+
+    /** 源表无 `create_time` → 快照带 null,服务层出口也是 null(不拿快照时间顶替)。 */
+    @Test
+    void missingCreateTimeStaysNullThroughTheWholeChain() {
+        stubPoints(Collections.singletonList(point("P-1", "P-1")));
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        EquipmentBase device = device("D-1", "NS-1", "22");
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device));
+        when(equipmentBaseService.getEquipmentByPoint(any())).thenReturn(Collections.emptyList());
+        when(equipmentStatusService.getByDeviceCode("NS-1")).thenReturn(status(1));
+
+        CockpitMonitoringOverviewSnapshot snapshot = adapter.load();
+        assertNull(snapshot.getDevices().get(0).getCreatedAt());
+        assertNull(snapshot.getMonitoringPoints().get(0).getCreatedAt());
+
+        CockpitMonitoringOverviewService service = new CockpitMonitoringOverviewService(
+                () -> snapshot, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        assertNull(service.load("req", null, null).getCreatedAt());
+    }
+
     private static String stateOf(CockpitMonitoringOverviewSnapshot snapshot, String deviceCode) {
     private static String stateOf(CockpitMonitoringOverviewSnapshot snapshot, String deviceCode) {
         return snapshot.getDevices().stream()
         return snapshot.getDevices().stream()
                 .filter(d -> deviceCode.equals(d.getDeviceCode()))
                 .filter(d -> deviceCode.equals(d.getDeviceCode()))

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

@@ -11,6 +11,7 @@ import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.cockpit.monitoring.BusinessCockpitMonitoringSituationAdapter;
 import com.zksy.cockpit.monitoring.BusinessCockpitMonitoringSituationAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationService;
 import com.zksy.cockpit.monitoring.CockpitMonitoringSituationSnapshot;
 import com.zksy.cockpit.monitoring.CockpitMonitoringSituationSnapshot;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
@@ -184,6 +185,38 @@ class BusinessCockpitMonitoringSituationAdapterTest {
 
 
     // ---- helpers ----
     // ---- helpers ----
 
 
+    /**
+     * 规格 08:异常设备 `createdAt` 来自 `equipment_base.create_time`,
+     * 与 `sampledAt`(状态更新时间)取自不同列,不得互相冒充。
+     */
+    @Test
+    void createdAtComesFromCreateTimeAndStaysSeparateFromSampledAt() {
+        stubTypes(Collections.singletonList(type(22, "combustible_gas_detector", "可燃气体检测仪", "3")));
+        EquipmentBase device = device("D-1", "ALARM-1", "22");
+        device.setCreateTime(LocalDateTime.parse("2026-03-11T09:30:00"));
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device));
+        when(equipmentStatusService.getByDeviceCode("ALARM-1")).thenReturn(status(1));
+        // 状态更新时间与 create_time 明确取不同值,才能证明两者没有互相冒充
+        when(deviceFactResolver.resolve(any(), anyString())).thenReturn(
+                new CockpitMonitoringPointDeviceSnapshot(
+                        "ALARM-1", "ALARM-1", "类型名", true, true, false, false,
+                        Collections.emptyList(), "2026-07-07T07:07:00+08:00"));
+        stubEvents(Collections.emptyList(), Collections.emptyList());
+
+        CockpitMonitoringSituationSnapshot snapshot = adapter.load();
+
+        CockpitMonitoringSituationSnapshot.SourceMetricDevice source = snapshot.getDevices().get(0);
+        assertEquals("2026-03-11T09:30:00+08:00", source.getCreatedAt(),
+                "createdAt 取 equipment_base.create_time");
+        assertEquals("2026-07-07T07:07:00+08:00", source.getSampledAt(),
+                "sampledAt 仍取状态更新时间,语义不变");
+
+        CockpitMonitoringSituationService service = new CockpitMonitoringSituationService(
+                () -> snapshot, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        assertEquals("2026-03-11T09:30:00+08:00",
+                service.load("req", null, null, null, null, null, null).getCreatedAt());
+    }
+
     private void stubTypes(List<EquipmentType> types) {
     private void stubTypes(List<EquipmentType> types) {
         when(equipmentTypeService.list()).thenReturn(types);
         when(equipmentTypeService.list()).thenReturn(types);
     }
     }

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

@@ -3,6 +3,10 @@ package com.zksy.web.controller.cockpit;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
@@ -18,6 +22,8 @@ import java.sql.Timestamp;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -31,6 +37,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
     private AlarmDataService alarmDataService;
     private AlarmDataService alarmDataService;
     private AlarmWarningRelMapper alarmWarningRelMapper;
     private AlarmWarningRelMapper alarmWarningRelMapper;
     private WorkOrderService workOrderService;
     private WorkOrderService workOrderService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
     private BusinessCockpitOverviewWarningSettingsAdapter adapter;
     private BusinessCockpitOverviewWarningSettingsAdapter adapter;
 
 
     @BeforeEach
     @BeforeEach
@@ -39,16 +47,28 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         alarmDataService = mock(AlarmDataService.class);
         alarmDataService = mock(AlarmDataService.class);
         alarmWarningRelMapper = mock(AlarmWarningRelMapper.class);
         alarmWarningRelMapper = mock(AlarmWarningRelMapper.class);
         workOrderService = mock(WorkOrderService.class);
         workOrderService = mock(WorkOrderService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
         adapter = new BusinessCockpitOverviewWarningSettingsAdapter(
         adapter = new BusinessCockpitOverviewWarningSettingsAdapter(
-                earlyWarningService, alarmDataService, alarmWarningRelMapper, workOrderService);
+                earlyWarningService, alarmDataService, alarmWarningRelMapper, workOrderService,
+                equipmentBaseService, equipmentTypeService);
+        // 默认无设备/类型台账,各用例按需覆盖。
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(equipmentTypeService.list()).thenReturn(Collections.emptyList());
     }
     }
 
 
+    /**
+     * 预警不再桥接工单(维护者 2026-10-14 裁定):即便库里有工单,预警事件也不带工单事实,
+     * 只带自身生命周期;报警仍保留工单回联。
+     */
     @Test
     @Test
-    void bridgesWarningWorkOrderThroughRelationAndAggregatesTrend() {
+    void warningCarriesItsOwnLifecycleAndNoWorkOrderWhileAlarmKeepsWorkOrderLink() {
         EarlyWarning warning = new EarlyWarning();
         EarlyWarning warning = new EarlyWarning();
         warning.setWarningId("WARN-ID-001");
         warning.setWarningId("WARN-ID-001");
         warning.setWarningNo("W-001");
         warning.setWarningNo("W-001");
         warning.setDeviceCode("GAS-001");
         warning.setDeviceCode("GAS-001");
+        warning.setStatus("CLOSED");
+        warning.setWarningLevel("2");
         warning.setPublishTime(LocalDateTime.parse("2026-09-02T09:00:00"));
         warning.setPublishTime(LocalDateTime.parse("2026-09-02T09:00:00"));
         when(earlyWarningService.list()).thenReturn(Collections.singletonList(warning));
         when(earlyWarningService.list()).thenReturn(Collections.singletonList(warning));
 
 
@@ -56,6 +76,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         alarm.setId("A-001");
         alarm.setId("A-001");
         alarm.setDeviceCode("GAS-001");
         alarm.setDeviceCode("GAS-001");
         alarm.setWorkOrderId(1001L);
         alarm.setWorkOrderId(1001L);
+        alarm.setAlarmStatus(1);
+        alarm.setAlarmLevel(3);
         alarm.setCreateTime(LocalDateTime.parse("2026-09-02T10:00:00"));
         alarm.setCreateTime(LocalDateTime.parse("2026-09-02T10:00:00"));
         when(alarmDataService.list()).thenReturn(Collections.singletonList(alarm));
         when(alarmDataService.list()).thenReturn(Collections.singletonList(alarm));
 
 
@@ -76,30 +98,78 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
 
 
         assertEquals(2, snapshot.getEvents().size());
         assertEquals(2, snapshot.getEvents().size());
-        CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = snapshot.getEvents().get(0);
-        assertEquals("ALARM", alarmEvent.getEventFamily());
+
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = event(snapshot, "ALARM");
         assertEquals("A-001", alarmEvent.getEventCode());
         assertEquals("A-001", alarmEvent.getEventCode());
         assertEquals("WO-001", alarmEvent.getWorkOrderCode());
         assertEquals("WO-001", alarmEvent.getWorkOrderCode());
         assertEquals(Integer.valueOf(5), alarmEvent.getWorkOrderState());
         assertEquals(Integer.valueOf(5), alarmEvent.getWorkOrderState());
+        assertEquals("HANDLED", alarmEvent.getStatus());
+        assertEquals(Integer.valueOf(3), alarmEvent.getLevel());
 
 
-        CockpitOverviewWarningSettingsSnapshot.SourceEvent warningEvent = snapshot.getEvents().get(1);
-        assertEquals("WARNING", warningEvent.getEventFamily());
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent warningEvent = event(snapshot, "WARNING");
         assertEquals("W-001", warningEvent.getEventCode());
         assertEquals("W-001", warningEvent.getEventCode());
-        assertEquals("WO-001", warningEvent.getWorkOrderCode());
-        assertEquals(Integer.valueOf(5), warningEvent.getWorkOrderState());
-
-        CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint warningTrend = snapshot.getTrendPoints().stream()
-                .filter(point -> "WARNING".equals(point.getEventFamily()))
-                .findFirst().orElseThrow(AssertionError::new);
-        assertEquals("2026-09-02", warningTrend.getBucket());
-        assertEquals(1, warningTrend.getTotal());
-        assertEquals(1, warningTrend.getHandled());
-
-        CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint alarmTrend = snapshot.getTrendPoints().stream()
-                .filter(point -> "ALARM".equals(point.getEventFamily()))
-                .findFirst().orElseThrow(AssertionError::new);
-        assertEquals(1, alarmTrend.getTotal());
-        assertEquals(1, alarmTrend.getHandled());
+        assertEquals("CLOSED", warningEvent.getStatus());
+        assertEquals(Integer.valueOf(2), warningEvent.getLevel());
+        // 预警不走工单流程:工单事实一律为空,不因库里有工单就被挂上。
+        assertNull(warningEvent.getWorkOrderCode());
+        assertNull(warningEvent.getWorkOrderState());
+    }
+
+    /** 预警趋势的 handled 按预警自身「已解除」判定,不依赖工单是否结案。 */
+    @Test
+    void warningTrendHandledCountsClosedWarningsNotClosedWorkOrders() {
+        EarlyWarning closed = warning("W-1", "CLOSED", "2026-09-02T09:00:00");
+        EarlyWarning processing = warning("W-2", "PROCESSING", "2026-09-02T11:00:00");
+        when(earlyWarningService.list()).thenReturn(Arrays.asList(closed, processing));
+        when(alarmDataService.list()).thenReturn(Collections.emptyList());
+        when(alarmWarningRelMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list()).thenReturn(Collections.emptyList());
+
+        CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
+
+        CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint trend =
+                snapshot.getTrendPoints().stream()
+                        .filter(point -> "WARNING".equals(point.getEventFamily()))
+                        .findFirst().orElseThrow(AssertionError::new);
+        assertEquals("2026-09-02", trend.getBucket());
+        assertEquals(2, trend.getTotal());
+        assertEquals(1, trend.getHandled());
+    }
+
+    /** 片区/行业沿设备解析带入快照;无设备时如实留空,不冒充。 */
+    @Test
+    void districtAndIndustryAreResolvedThroughTheDeviceAndStayNullWithoutOne() {
+        EquipmentType root = new EquipmentType();
+        root.setId(3);
+        root.setParentId(0);
+        root.setTypeName("燃气");
+        when(equipmentTypeService.list()).thenReturn(Collections.singletonList(root));
+
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentId("DEV-ID-1");
+        device.setEquipmentCode("GAS-001");
+        device.setEquipmentTypeId(3);
+        device.setDistrict(1);
+        when(equipmentBaseService.list()).thenReturn(Collections.singletonList(device));
+
+        EarlyWarning known = warning("W-1", "PENDING", "2026-09-02T09:00:00");
+        known.setDeviceCode("GAS-001");
+        EarlyWarning unknownDevice = warning("W-2", "PENDING", "2026-09-02T09:00:00");
+        unknownDevice.setDeviceCode("NOPE-999");
+        when(earlyWarningService.list()).thenReturn(Arrays.asList(known, unknownDevice));
+        when(alarmDataService.list()).thenReturn(Collections.emptyList());
+        when(alarmWarningRelMapper.selectList(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(workOrderService.list()).thenReturn(Collections.emptyList());
+
+        CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
+
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent resolved = event(snapshot, "WARNING", "W-1");
+        assertEquals(Integer.valueOf(1), resolved.getDistrictCode());
+        assertEquals("GAS", resolved.getIndustryCode());
+
+        CockpitOverviewWarningSettingsSnapshot.SourceEvent unresolved = event(snapshot, "WARNING", "W-2");
+        assertNull(unresolved.getDistrictCode());
+        assertNull(unresolved.getIndustryCode());
     }
     }
 
 
     @Test
     @Test
@@ -119,6 +189,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = snapshot.getEvents().get(0);
         CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = snapshot.getEvents().get(0);
         assertNull(alarmEvent.getWorkOrderCode());
         assertNull(alarmEvent.getWorkOrderCode());
         assertNull(alarmEvent.getWorkOrderState());
         assertNull(alarmEvent.getWorkOrderState());
+        // alarm_status 缺失 → 自身生命周期状态如实为 null(服务层进未知桶)。
+        assertNull(alarmEvent.getStatus());
         CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint alarmTrend = snapshot.getTrendPoints().get(0);
         CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint alarmTrend = snapshot.getTrendPoints().get(0);
         assertEquals(1, alarmTrend.getTotal());
         assertEquals(1, alarmTrend.getTotal());
         assertEquals(0, alarmTrend.getHandled());
         assertEquals(0, alarmTrend.getHandled());
@@ -138,4 +210,31 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         assertEquals(0, snapshot.getTrendPoints().size());
         assertEquals(0, snapshot.getTrendPoints().size());
         assertNull(snapshot.getUpdatedAt());
         assertNull(snapshot.getUpdatedAt());
     }
     }
-}
+
+    private static EarlyWarning warning(String no, String status, String publishTime) {
+        EarlyWarning warning = new EarlyWarning();
+        warning.setWarningId("ID-" + no);
+        warning.setWarningNo(no);
+        warning.setDeviceCode("GAS-001");
+        warning.setStatus(status);
+        warning.setPublishTime(LocalDateTime.parse(publishTime));
+        return warning;
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
+            CockpitOverviewWarningSettingsSnapshot snapshot, String family) {
+        return event(snapshot, family, null);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
+            CockpitOverviewWarningSettingsSnapshot snapshot, String family, String eventCode) {
+        List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> matches = snapshot.getEvents().stream()
+                .filter(item -> family.equals(item.getEventFamily()))
+                .filter(item -> eventCode == null || eventCode.equals(item.getEventCode()))
+                .collect(Collectors.toList());
+        if (matches.size() != 1) {
+            throw new AssertionError("expected exactly one " + family + " event, got " + matches.size());
+        }
+        return matches.get(0);
+    }
+}

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

@@ -0,0 +1,62 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * 数据时间单一口径来源的契约测试(规格 08 / 决策票 03)。
+ *
+ * <p>本类不注入时钟 —— 结构上就**没有**「拿响应时间冒充数据时间」的入口。
+ */
+class CockpitCreatedAtTest {
+
+    @Test
+    void formatsLocalCreateTimeInBusinessZone() {
+        assertEquals("2026-03-11T09:30:00+08:00",
+                CockpitCreatedAt.format(LocalDateTime.parse("2026-03-11T09:30:00")));
+    }
+
+    @Test
+    void missingSourceCreateTimeIsNullNotAFabricatedTime() {
+        assertNull(CockpitCreatedAt.format(null));
+        assertNull(CockpitCreatedAt.max(Collections.emptyList()));
+        assertNull(CockpitCreatedAt.max(Arrays.asList(null, null)));
+        assertNull(CockpitCreatedAt.max((String) null, null));
+    }
+
+    @Test
+    void maxPicksTheLatestRealCreateTimeAcrossHeterogeneousSources() {
+        assertEquals("2026-05-06T11:22:00+08:00", CockpitCreatedAt.max(Arrays.asList(
+                "2026-01-05T08:00:00+08:00",
+                null,
+                "2026-05-06T11:22:00+08:00",
+                "2026-03-11T09:30:00+08:00")));
+    }
+
+    /** 只有一边可用时取那一边:不因为另一来源缺失就把已拿到的真实时间丢掉。 */
+    @Test
+    void maxKeepsTheOnlyAvailableSide() {
+        assertEquals("2026-01-05T08:00:00+08:00",
+                CockpitCreatedAt.max("2026-01-05T08:00:00+08:00", null));
+        assertEquals("2026-01-05T08:00:00+08:00",
+                CockpitCreatedAt.max(null, "2026-01-05T08:00:00+08:00"));
+    }
+
+    /** 时区不同但时刻相同时,按真实先后比较而不是按字符串比较。 */
+    @Test
+    void maxComparesInstantsNotStrings() {
+        // 同一时刻的两种写法:UTC 01:00 == +08:00 09:00
+        assertEquals("2026-01-05T01:00:00Z",
+                CockpitCreatedAt.max("2026-01-05T01:00:00Z", "2026-01-05T09:00:00+08:00"));
+        // 字符串序会把 "2026-01-05T09:00:00+08:00" 判大,真实时刻是 UTC 02:00 更晚
+        assertEquals("2026-01-05T02:00:00Z",
+                CockpitCreatedAt.max("2026-01-05T02:00:00Z", "2026-01-05T09:00:00+08:00"));
+    }
+}

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

@@ -587,6 +587,99 @@ class CockpitMonitoringDistributionControllerTest {
                 Arrays.asList(devices));
                 Arrays.asList(devices));
     }
     }
 
 
+    private static CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint pointAt(
+            String code, String name, Integer district, String typeCode,
+            String longitude, String latitude, String createdAt,
+            CockpitMonitoringPointDeviceSnapshot... devices) {
+        return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
+                code, name, district, typeCode,
+                new BigDecimal(longitude), new BigDecimal(latitude), true,
+                Arrays.asList(devices), createdAt);
+    }
+
+    // ---- 数据时间(createdAt)契约 ----
+
+    /**
+     * 规格 08:`distribution` 的信封级 `createdAt` 取台账逐对象真实 `create_time` 的 max,
+     * 点位用 `monitor_point.create_time`、设备用 `equipment_base.create_time`;
+     * 必须与 `updatedAt`(快照加载时刻)取值不同,证明没拿响应时间冒充。
+     */
+    @Test
+    void distributionCreatedAtIsTheMaxLedgerCreateTimeAndNeverTheSnapshotTime() throws Exception {
+        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT,
+                "2026-09-02T09:59:50+08:00",
+                fixture().getTypes(),
+                Collections.singletonList(pointAt("P-1", "点位", 0, "GAS_SENSOR",
+                        "110.130", "28.440", "2026-02-20T10:00:00+08:00")),
+                Collections.singletonList(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
+                        "D-1", "设备", 0, "GAS_SENSOR", new BigDecimal("110.131"), new BigDecimal("28.441"),
+                        true, true, false, false, false, Collections.emptyList(), null, true,
+                        "2026-04-01T07:15:00+08:00")));
+        when(adapter.load(null)).thenReturn(snapshot);
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value("2026-04-01T07:15:00+08:00"))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                // 逐对象字段齐备(台账卡读它)
+                .andExpect(jsonPath("$.data.monitoringPoints[0].createdAt").value("2026-02-20T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].createdAt").value("2026-04-01T07:15:00+08:00"));
+    }
+
+    /** 空态:无台账对象 → 信封 `createdAt` 显式 null,不用快照时间顶上。 */
+    @Test
+    void distributionCreatedAtIsNullForEmptyLedger() throws Exception {
+        when(adapter.load(null)).thenReturn(new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, "2026-09-02T09:59:50+08:00", Collections.emptyList(),
+                Collections.emptyList(), Collections.emptyList()));
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"));
+    }
+
+    /** 有台账对象但来源表无 `create_time` → 信封与逐对象都显式 null。 */
+    @Test
+    void distributionCreatedAtIsNullWhenLedgerHasNoCreateTime() throws Exception {
+        when(adapter.load(null)).thenReturn(new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, "2026-09-02T09:59:50+08:00", fixture().getTypes(),
+                Collections.singletonList(point("P-1", "点位", 0, "GAS_SENSOR", "110.130", "28.440")),
+                Collections.emptyList()));
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].createdAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.createdAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"));
+    }
+
+    /**
+     * 取值域是分页前的全量台账(与三份分布同口径),故翻页不改变数据时间读数 ——
+     * 否则「数据时间」会随翻页跳动,读者无法据它判断这批数据写到什么时候。
+     */
+    @Test
+    void distributionCreatedAtDoesNotMoveAcrossPages() throws Exception {
+        java.util.List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points =
+                new java.util.ArrayList<>();
+        for (int index = 0; index < 4; index++) {
+            points.add(pointAt("P-" + index, "点位" + index, 0, "GAS_SENSOR",
+                    "110.13" + index, "28.44" + index,
+                    "2026-0" + (index + 1) + "-01T08:00:00+08:00"));
+        }
+        when(adapter.load(null)).thenReturn(new CockpitMonitoringDistributionSnapshot(
+                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(), points, Collections.emptyList()));
+
+        mockMvc.perform(get(ENDPOINT).param("page", "1").param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.total").value(4))
+                .andExpect(jsonPath("$.data.createdAt").value("2026-04-01T08:00:00+08:00"));
+        mockMvc.perform(get(ENDPOINT).param("page", "2").param("pageSize", "2")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value("2026-04-01T08:00:00+08:00"));
+    }
+
     private static CockpitMonitoringPointDeviceSnapshot device(
     private static CockpitMonitoringPointDeviceSnapshot device(
             String code, String name, boolean online, boolean alarm, boolean warning,
             String code, String name, boolean online, boolean alarm, boolean warning,
             boolean workOrderInProgress, List<String> statusReasons) {
             boolean workOrderInProgress, List<String> statusReasons) {

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

@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.ArrayList;
 import java.util.ArrayList;
 
 
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
@@ -330,7 +331,88 @@ class CockpitMonitoringOverviewControllerTest {
                 code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode);
                 code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode);
     }
     }
 
 
+    private static CockpitMonitoringOverviewSnapshot.SourceDevice deviceAt(
+            String code, String typeCode, String state, String pointCode, String createdAt) {
+        return new CockpitMonitoringOverviewSnapshot.SourceDevice(
+                code, typeCode, state, "2026-09-02T09:59:00+08:00", pointCode, createdAt);
+    }
+
     private static CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point(String code, String typeCode) {
     private static CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point(String code, String typeCode) {
         return new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(code, typeCode);
         return new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(code, typeCode);
     }
     }
+
+    // ---- 数据时间(createdAt)契约 ----
+
+    /**
+     * 规格 08:`overview` 的分区级 `createdAt` 取聚合源真实 `create_time` 的跨类型 max;
+     * 且必须与 `updatedAt`(快照加载时刻)取值不同,证明确实没拿响应时间冒充。
+     */
+    @Test
+    void overviewCreatedAtIsTheMaxCreateTimeAcrossSourcesAndNeverTheSnapshotTime() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.singletonList(type("GAS_SENSOR", "燃气浓度计", "GAS")),
+                Arrays.asList(
+                        deviceAt("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1", "2026-01-05T08:00:00+08:00"),
+                        deviceAt("GAS-002", "GAS_SENSOR", "OFFLINE", "P-GAS-2", "2026-03-11T09:30:00+08:00")),
+                Arrays.asList(
+                        new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
+                                "P-GAS-1", "GAS_SENSOR", 1, "2026-02-20T10:00:00+08:00"),
+                        new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
+                                "P-GAS-2", "GAS_SENSOR", 1, null))));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value("2026-03-11T09:30:00+08:00"))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"));
+    }
+
+    /** 空态:没有对象时 `createdAt` 必须显式 null,不得回退到响应时间。 */
+    @Test
+    void overviewCreatedAtIsNullForEmptySnapshot() throws Exception {
+        when(adapter.load()).thenReturn(emptyFixture());
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"));
+    }
+
+    /** 有对象但来源表无 `create_time` → 同样显式 null(不拿快照时间顶替)。 */
+    @Test
+    void overviewCreatedAtIsNullWhenSourcesHaveNoCreateTime() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.singletonList(type("GAS_SENSOR", "燃气浓度计", "GAS")),
+                Collections.singletonList(device("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1")),
+                Collections.emptyList()));
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.counts.equipmentCount").value(1))
+                .andExpect(jsonPath("$.data.createdAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"));
+    }
+
+    /**
+     * 取值域跟随筛选:被筛掉的设备/点位不得把时间带进读数,
+     * 否则「数据时间」会指向本响应根本不包含的对象。
+     */
+    @Test
+    void overviewCreatedAtFollowsTheActiveFilter() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        type("GAS_SENSOR", "燃气浓度计", "GAS"),
+                        type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY")),
+                Arrays.asList(
+                        deviceAt("GAS-001", "GAS_SENSOR", "ONLINE", "P-GAS-1", "2026-01-05T08:00:00+08:00"),
+                        deviceAt("WS-001", "WATER_SUPPLY_SENSOR", "ONLINE", "P-WS-1", "2026-05-05T08:00:00+08:00")),
+                Collections.emptyList()));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value("2026-05-05T08:00:00+08:00"));
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "GAS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value("2026-01-05T08:00:00+08:00"));
+    }
 }
 }

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

@@ -460,10 +460,69 @@ class CockpitMonitoringSituationControllerTest {
                 new BigDecimal(longitude), new BigDecimal(latitude), verified);
                 new BigDecimal(longitude), new BigDecimal(latitude), verified);
     }
     }
 
 
+    private static CockpitMonitoringSituationSnapshot.SourceMetricDevice deviceAt(
+            String code, String name, Integer district, String typeCode, String metricStatus,
+            List<String> reasons, String longitude, String latitude, boolean verified,
+            String createdAt) {
+        return new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
+                code, name, district, typeCode, metricStatus, reasons,
+                "2026-09-02T09:59:00+08:00",
+                new BigDecimal(longitude), new BigDecimal(latitude), verified, createdAt);
+    }
+
     private static CockpitMonitoringSituationSnapshot.SourceEvent event(
     private static CockpitMonitoringSituationSnapshot.SourceEvent event(
             String eventNo, String level, String eventTime, String industry,
             String eventNo, String level, String eventTime, String industry,
             String typeCode, String deviceCode, String deviceName) {
             String typeCode, String deviceCode, String deviceName) {
         return new CockpitMonitoringSituationSnapshot.SourceEvent(
         return new CockpitMonitoringSituationSnapshot.SourceEvent(
                 eventNo, level, eventTime, industry, typeCode, deviceCode, deviceName);
                 eventNo, level, eventTime, industry, typeCode, deviceCode, deviceName);
     }
     }
+
+    // ---- 数据时间(createdAt)契约 ----
+
+    /**
+     * 维护者 2026-10-14 裁定(票 35 Q3):信封级 `createdAt` 取**异常设备来源表** `create_time` 的 max,
+     * 与逐对象字段同源可复算;事件时间仍由 `warningTrend`/`alarmTrend` 的 `bucketStart` 承载,不并入读数。
+     */
+    @Test
+    void situationCreatedAtIsTheMaxAbnormalDeviceCreateTimeNotTheEventWindow() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitMonitoringSituationSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.singletonList(type("GAS_SENSOR", "燃气浓度计", "GAS")),
+                Arrays.asList(
+                        // 异常(ALARM)→ 进异常清单,携带 create_time
+                        deviceAt("D-ABNORMAL", "异常设备", 0, "GAS_SENSOR", "ALARM",
+                                Collections.singletonList("浓度超限"), "110.360", "28.440", true,
+                                "2026-05-06T11:22:00+08:00"),
+                        // 正常设备不进异常清单,其 create_time 更晚也不得影响读数
+                        deviceAt("D-NORMAL", "正常设备", 0, "GAS_SENSOR", "NORMAL",
+                                Collections.emptyList(), "110.361", "28.441", true,
+                                "2026-08-08T08:08:00+08:00")),
+                Collections.emptyList(),
+                Collections.emptyList()));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value("2026-05-06T11:22:00+08:00"))
+                // 事件窗口时间不得冒充数据时间
+                .andExpect(jsonPath("$.data.range.startTime").value("2026-09-01T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                .andExpect(jsonPath("$.data.abnormalEquipment[0].createdAt").value("2026-05-06T11:22:00+08:00"));
+    }
+
+    /** 无异常设备 → 信封 `createdAt` 显式 null,不用窗口时间或响应时间顶上。 */
+    @Test
+    void situationCreatedAtIsExplicitlyNullWhenNoAbnormalDeviceExists() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitMonitoringSituationSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.singletonList(type("GAS_SENSOR", "燃气浓度计", "GAS")),
+                Collections.singletonList(deviceAt("D-NORMAL", "正常设备", 0, "GAS_SENSOR", "NORMAL",
+                        Collections.emptyList(), "110.360", "28.440", true, "2026-08-08T08:08:00+08:00")),
+                Collections.emptyList(),
+                Collections.emptyList()));
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.createdAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.abnormalEquipment", hasSize(0)));
+    }
 }
 }

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

@@ -47,8 +47,12 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .build();
                 .build();
     }
     }
 
 
+    /**
+     * 维护者 2026-10-14 裁定:预警族只给预警自身生命周期,工单口径字段一律 {@code null};
+     * 报警族保留工单口径并并存自身生命周期。
+     */
     @Test
     @Test
-    void warningSettingsUsesEnvelopeCamelCaseAndDefaultWarningMode() throws Exception {
+    void warningSettingsUsesEnvelopeCamelCaseAndSplitsTheTwoFamilyVocabularies() throws Exception {
         when(adapter.load()).thenReturn(fixture());
         when(adapter.load()).thenReturn(fixture());
 
 
         mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
         mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
@@ -70,31 +74,55 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.freshness.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                // ---- 预警族:自身生命周期,工单口径字段全为 null ----
                 .andExpect(jsonPath("$.data.warning.total").value(3))
                 .andExpect(jsonPath("$.data.warning.total").value(3))
-                .andExpect(jsonPath("$.data.warning.handled").value(1))
-                .andExpect(jsonPath("$.data.warning.pending").value(2))
-                // 处置率改为工单口径(决策票 06):分子=状态 5(1),分母=状态 0–6(5)→ 0.2000。
-                .andExpect(jsonPath("$.data.warning.handlingEfficiency.value").value("0.2000"))
+                .andExpect(jsonPath("$.data.warning.handled").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.pending").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.cancelledCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.unlinkedEventCount").value(nullValue()))
+                .andExpect(jsonPath("$.data.warning.workOrderStates").value(nullValue()))
+                // 已解除率=已解除 ÷ 总数 = 1/3(预警自身生命周期,不是工单处置率)。
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.value").value("0.3333"))
                 .andExpect(jsonPath("$.data.warning.handlingEfficiency.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.warning.handlingEfficiency.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.warning.handlingEfficiency.numerator").value(1))
                 .andExpect(jsonPath("$.data.warning.handlingEfficiency.numerator").value(1))
-                .andExpect(jsonPath("$.data.warning.handlingEfficiency.denominator").value(5))
-                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.value").value("0.5000"))
-                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.numerator").value(1))
-                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.denominator").value(2))
-                // 状态分布同为工单口径(逐态补 0)。
-                .andExpect(jsonPath("$.data.warning.workOrderStates['1']").value(1))
-                .andExpect(jsonPath("$.data.warning.workOrderStates['4']").value(2))
-                .andExpect(jsonPath("$.data.warning.workOrderStates['5']").value(1))
-                .andExpect(jsonPath("$.data.warning.workOrderStates['6']").value(1))
-                .andExpect(jsonPath("$.data.warning.unlinkedEventCount").value(0))
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.denominator").value(3))
+                // 状态分布=WarningWorkflow 六态,逐态铺满。
+                .andExpect(jsonPath("$.data.warning.statusDistribution", hasSize(6)))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[0].code").value("DRAFT"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[1].code").value("PENDING"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[3].code").value("PROCESSING"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[3].count").value(1))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[5].code").value("CLOSED"))
+                .andExpect(jsonPath("$.data.warning.statusDistribution[5].count").value(1))
+                // 等级 1–4 全铺 + 一条等级缺失进未知桶。
+                .andExpect(jsonPath("$.data.warning.levelDistribution", hasSize(5)))
+                .andExpect(jsonPath("$.data.warning.levelDistribution[1].count").value(1))
+                .andExpect(jsonPath("$.data.warning.levelDistribution[4].code").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.warning.levelDistribution[4].count").value(1))
+                // 行业四正式桶全铺 + 未知桶;片区含恒有的未分区桶。
+                .andExpect(jsonPath("$.data.warning.industryDistribution", hasSize(5)))
+                .andExpect(jsonPath("$.data.warning.districtDistribution", hasSize(4)))
+                .andExpect(jsonPath("$.data.warning.districtDistribution[3].code").value("UNASSIGNED"))
+                .andExpect(jsonPath("$.data.warning.districtDistribution[3].count").value(1))
+                // ---- 报警族:工单口径保留 ----
                 .andExpect(jsonPath("$.data.alarm.total").value(2))
                 .andExpect(jsonPath("$.data.alarm.total").value(2))
-                .andExpect(jsonPath("$.data.alarm.handled").value(0))
-                .andExpect(jsonPath("$.data.alarm.pending").value(2))
+                .andExpect(jsonPath("$.data.alarm.handled").value(1))
+                .andExpect(jsonPath("$.data.alarm.pending").value(1))
                 .andExpect(jsonPath("$.data.alarm.cancelledCount").value(0))
                 .andExpect(jsonPath("$.data.alarm.cancelledCount").value(0))
-                // 两 family 共用同一份全范围工单读数。
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.value").value("0.2000"))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.value").value("0.2000"))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.numerator").value(1))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.numerator").value(1))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.denominator").value(5))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.denominator").value(5))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['1']").value(1))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['5']").value(1))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['6']").value(1))
+                // 报警自身生命周期并存。
+                .andExpect(jsonPath("$.data.alarm.statusDistribution", hasSize(2)))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[0].code").value("UNHANDLED"))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[0].count").value(1))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[1].code").value("HANDLED"))
+                .andExpect(jsonPath("$.data.alarm.statusDistribution[1].count").value(1))
                 .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("WARNING"))
                 .andExpect(jsonPath("$.data.mapFilter.label").value("预警"));
                 .andExpect(jsonPath("$.data.mapFilter.label").value("预警"));
     }
     }
@@ -110,7 +138,8 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.selectedMode").value("ALARM"))
                 .andExpect(jsonPath("$.data.selectedMode").value("ALARM"))
                 .andExpect(jsonPath("$.data.warning.total").value(3))
                 .andExpect(jsonPath("$.data.warning.total").value(3))
                 .andExpect(jsonPath("$.data.alarm.total").value(2))
                 .andExpect(jsonPath("$.data.alarm.total").value(2))
-                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))                .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("ALARM"))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))
+                .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("ALARM"))
                 .andExpect(jsonPath("$.data.mapFilter.label").value("报警"));
                 .andExpect(jsonPath("$.data.mapFilter.label").value("报警"));
     }
     }
 
 
@@ -251,16 +280,13 @@ class CockpitOverviewWarningSettingsControllerTest {
         return new CockpitOverviewWarningSettingsSnapshot(
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
                 Arrays.asList(
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-001", "DEV-001", "WO-001", 1),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-002", "DEV-002", "WO-002", 5),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "WARNING", "WRN-003", "DEV-003", "WO-003", 6),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "ALARM", "ALM-001", "DEV-004", "WO-004", 4),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "ALARM", "ALM-002", "DEV-005", "WO-005", 4)),
+                        // 预警:自身生命周期 + 维度;工单事实刻意留空(预警不走工单)。
+                        warning("WRN-001", "PENDING", 2, "GAS", 0),
+                        warning("WRN-002", "CLOSED", 3, "WATER_SUPPLY", 1),
+                        warning("WRN-003", "PROCESSING", null, null, null),
+                        // 报警:保留工单回联,并带自身生命周期。
+                        alarm("ALM-001", "UNHANDLED", 4, 3, "GAS", 0),
+                        alarm("ALM-002", "HANDLED", 5, 2, "GAS", 0)),
                 Arrays.asList(
                 Arrays.asList(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "WARNING", "2026-09-01", 2, 1),
                                 "WARNING", "2026-09-01", 2, 1),
@@ -278,14 +304,27 @@ class CockpitOverviewWarningSettingsControllerTest {
                                 "ALARM", "2026-08-20", 2, 1),
                                 "ALARM", "2026-08-20", 2, 1),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "ALARM", "2026-08-01", 1, 1)),
                                 "ALARM", "2026-08-01", 1, 1)),
-                // 全范围工单(分母来源):状态 1/5/6/4/4 → 分母 5(排除状态 7),分子 1(状态 5)。
+                // 报警族工单口径分母来源:状态 1/5/6/4/4 → 分母 5(排除状态 7),分子 1(状态 5)。
                 Arrays.asList(
                 Arrays.asList(
                         scopeOrder(1), scopeOrder(5), scopeOrder(6), scopeOrder(4), scopeOrder(4)));
                         scopeOrder(1), scopeOrder(5), scopeOrder(6), scopeOrder(4), scopeOrder(4)));
     }
     }
 
 
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent warning(
+            String code, String status, Integer level, String industry, Integer district) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                "WARNING", code, "DEV-" + code, null, null, status, level, district, industry);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent alarm(
+            String code, String status, Integer workOrderState, Integer level,
+            String industry, Integer district) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                "ALARM", code, "DEV-" + code, "WO-" + code, workOrderState,
+                status, level, district, industry);
+    }
+
     private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder scopeOrder(Integer state) {
     private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder scopeOrder(Integer state) {
         return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
         return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
                 state, "2026-09-01T10:00:00+08:00");
                 state, "2026-09-01T10:00:00+08:00");
     }
     }
 }
 }
-

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

@@ -12,8 +12,11 @@ import java.time.Instant;
 import java.time.ZoneOffset;
 import java.time.ZoneOffset;
 import java.util.Arrays;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
@@ -31,69 +34,146 @@ class CockpitOverviewWarningSettingsServiceTest {
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
     }
     }
 
 
+    /**
+     * 维护者 2026-10-14 裁定:预警不走工单流程 → 预警族的工单口径字段**必须为 null**(不适用),
+     * 而不是 0 —— 0 会被读成"真的是零"。
+     */
     @Test
     @Test
-    void currentEightStateModelSeparatesHandledPendingAndUnlinkedEvents() {
-        when(adapter.load()).thenReturn(eightStateFixture());
+    void warningFamilyDropsWorkOrderVocabularyEntirely() {
+        when(adapter.load()).thenReturn(warningFixture());
 
 
         CockpitOverviewWarningSettingsResponse response =
         CockpitOverviewWarningSettingsResponse response =
-                service.load("request-eight", "WARNING", null, null, null);
-
-        // 事件计数仍按 family 各自统计。
-        assertEquals(Integer.valueOf(10), response.getWarning().getTotal());
-        assertEquals(Integer.valueOf(1), response.getWarning().getHandled());
-        assertEquals(Integer.valueOf(6), response.getWarning().getPending());
-        assertEquals(Integer.valueOf(1), response.getWarning().getCancelledCount());
-        assertEquals(Integer.valueOf(1), response.getWarning().getUnlinkedEventCount());
-        assertEquals(Integer.valueOf(1), response.getWarning().getInvalidWorkOrderStateCount());
-        // 工单状态分布与处置率改为工单口径(决策票 06):逐态 1 张,共 8 张。
-        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(1), response.getWarning().getWorkOrderStates().get(6));
-        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(7));
-        assertEquals(8, response.getWarning().getWorkOrderStates().size());
-        assertTrue(response.getWarning().getWorkOrderStates().keySet().stream()
-                .allMatch(code -> code >= 0 && code <= 7));
-        // 分母=状态 0–6(7 张,状态 7 排除),分子=状态 5(1 张)→ 1/7。
-        assertEquals(Integer.valueOf(1), response.getWarning().getHandlingEfficiency().getNumerator());
-        assertEquals(Integer.valueOf(7), response.getWarning().getHandlingEfficiency().getDenominator());
-        assertEquals("0.1429", response.getWarning().getHandlingEfficiency().getValue().toPlainString());
+                service.load("request-warning", "WARNING", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics warning = response.getWarning();
+        assertNull(warning.getHandled());
+        assertNull(warning.getPending());
+        assertNull(warning.getCancelledCount());
+        assertNull(warning.getUnlinkedEventCount());
+        assertNull(warning.getInvalidWorkOrderStateCount());
+        assertNull(warning.getWorkOrderStates());
     }
     }
 
 
+    /** 预警族状态分布=预警自身生命周期(六态),逐桶计数自洽(合计=总数)。 */
     @Test
     @Test
-    void bothFamiliesShareTheSameWorkOrderScopedRate() {
-        when(adapter.load()).thenReturn(eightStateFixture());
+    void warningStatusDistributionCoversItsOwnLifecycleAndSumsToTotal() {
+        when(adapter.load()).thenReturn(warningFixture());
 
 
         CockpitOverviewWarningSettingsResponse response =
         CockpitOverviewWarningSettingsResponse response =
-                service.load("request-shared", "WARNING", null, null, null);
+                service.load("request-warning", "WARNING", null, null, null);
 
 
-        // 分母不要求事件回联 → 两 family 共用同一份全范围工单读数。
-        assertEquals(response.getWarning().getHandlingEfficiency().getDenominator(),
-                response.getAlarm().getHandlingEfficiency().getDenominator());
-        assertEquals(response.getWarning().getHandlingEfficiency().getNumerator(),
-                response.getAlarm().getHandlingEfficiency().getNumerator());
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics warning = response.getWarning();
+        assertEquals(6, warning.getTotal());
+        assertEquals(1, count(warning.getStatusDistribution(), "DRAFT"));
+        assertEquals(1, count(warning.getStatusDistribution(), "PENDING"));
+        assertEquals(1, count(warning.getStatusDistribution(), "RECEIVED"));
+        assertEquals(1, count(warning.getStatusDistribution(), "PROCESSING"));
+        assertEquals(1, count(warning.getStatusDistribution(), "HANDLED"));
+        assertEquals(1, count(warning.getStatusDistribution(), "CLOSED"));
+        assertEquals(warning.getTotal(), sum(warning.getStatusDistribution()));
+        // 六态全铺(缺的态是真实 0,不是"没有数据")。
+        assertEquals(6, warning.getStatusDistribution().size());
     }
     }
 
 
+    /** 预警自身生命周期「已解除率」=已解除 ÷ 总数(1/6),与工单口径无关。 */
     @Test
     @Test
-    void cancelledWorkOrdersAreExcludedFromTheRateDenominator() {
-        when(adapter.load()).thenReturn(cancelledHeavyFixture());
+    void warningEfficiencyIsItsOwnClosedOverTotalNotAWorkOrderRate() {
+        when(adapter.load()).thenReturn(warningFixture());
 
 
         CockpitOverviewWarningSettingsResponse response =
         CockpitOverviewWarningSettingsResponse response =
-                service.load("request-cancelled", "WARNING", null, null, null);
+                service.load("request-warning", "WARNING", null, null, null);
 
 
-        // 只有状态 7(2 张)→ 分母 0,不显示 0%,而是不适用。
-        assertEquals(Integer.valueOf(0), response.getWarning().getHandlingEfficiency().getDenominator());
-        assertEquals(Integer.valueOf(0), response.getWarning().getHandlingEfficiency().getNumerator());
-        assertEquals("EMPTY", response.getWarning().getHandlingEfficiency().getState());
-        assertEquals("NO_ELIGIBLE_WORK_ORDERS",
-                response.getWarning().getHandlingEfficiency().getReason());
+        CockpitOverviewWarningSettingsResponse.Efficiency efficiency =
+                response.getWarning().getHandlingEfficiency();
+        assertEquals(1, efficiency.getNumerator());
+        assertEquals(6, efficiency.getDenominator());
+        assertEquals("0.1667", efficiency.getValue().toPlainString());
+        assertEquals("AVAILABLE", efficiency.getState());
+    }
+
+    /** 等级 / 行业 / 片区分布都逐桶自洽,且未分区是显式桶。 */
+    @Test
+    void warningLevelIndustryAndDistrictDistributionsAreSelfConsistent() {
+        when(adapter.load()).thenReturn(warningFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-warning", "WARNING", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics warning = response.getWarning();
+        // 1–4 全铺 + 一条等级缺失进未知桶。
+        assertEquals(5, warning.getLevelDistribution().size());
+        assertEquals(2, count(warning.getLevelDistribution(), "2"));
+        assertEquals(3, count(warning.getLevelDistribution(), "3"));
+        assertEquals(1, count(warning.getLevelDistribution(), "UNKNOWN"));
+        assertEquals(warning.getTotal(), sum(warning.getLevelDistribution()));
+
+        assertEquals(2, count(warning.getIndustryDistribution(), "GAS"));
+        assertEquals(3, count(warning.getIndustryDistribution(), "WATER_SUPPLY"));
+        assertEquals(1, count(warning.getIndustryDistribution(), "UNKNOWN"));
+        assertEquals(warning.getTotal(), sum(warning.getIndustryDistribution()));
+
+        assertEquals(2, count(warning.getDistrictDistribution(), "0"));
+        assertEquals(3, count(warning.getDistrictDistribution(), "1"));
+        // 未分区恒有显式桶(设备缺失是合法结果,不是缺数据)。
+        assertEquals(1, count(warning.getDistrictDistribution(), "UNASSIGNED"));
+        assertEquals(warning.getTotal(), sum(warning.getDistrictDistribution()));
+    }
+
+    /** 报警族保留工单口径(维护者裁定:报警靠工单闭环),并并存报警自身生命周期。 */
+    @Test
+    void alarmFamilyKeepsWorkOrderVocabularyAndAddsItsOwnLifecycle() {
+        when(adapter.load()).thenReturn(alarmFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-alarm", "ALARM", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics alarm = response.getAlarm();
+        // 工单口径仍在:分母=状态 0–6 工单数(7 张,排除状态 7),分子=状态 5(1 张)。
+        assertEquals(1, alarm.getHandlingEfficiency().getNumerator());
+        assertEquals(7, alarm.getHandlingEfficiency().getDenominator());
+        assertEquals("0.1429", alarm.getHandlingEfficiency().getValue().toPlainString());
+        assertEquals(8, alarm.getWorkOrderStates().size());
+        assertEquals(1, alarm.getWorkOrderStates().get(5));
+        // 报警自身生命周期:alarm_status 0/1。
+        assertEquals(1, count(alarm.getStatusDistribution(), "HANDLED"));
+        assertEquals(2, count(alarm.getStatusDistribution(), "UNHANDLED"));
+        assertEquals(alarm.getTotal(), sum(alarm.getStatusDistribution()));
+    }
+
+    /** 报警族工单口径分列:未回联 / 脏码 / 已取消 各自如实计数,不互相冒充。 */
+    @Test
+    void alarmWorkOrderBucketsSeparateUnlinkedInvalidAndCancelled() {
+        when(adapter.load()).thenReturn(alarmFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-alarm", "ALARM", null, null, null);
+
+        CockpitOverviewWarningSettingsResponse.FamilyMetrics alarm = response.getAlarm();
+        assertEquals(1, alarm.getUnlinkedEventCount());
+        assertEquals(1, alarm.getInvalidWorkOrderStateCount());
+        assertEquals(1, alarm.getCancelledCount());
+        assertEquals(1, alarm.getPending());
     }
     }
 
 
+    /** 工单表整体不可用时,预警族仍如实出数(这是本票要消除的故障面)。 */
     @Test
     @Test
-    void emptyFamilyUsesExplicitEmptyStateAndZeroDenominatorEfficiency() {
+    void warningFamilyStaysAvailableWhenNoWorkOrderFactsExistAtAll() {
+        when(adapter.load()).thenReturn(warningFixtureWithoutWorkOrders());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-warning", "WARNING", null, null, null);
+
+        assertEquals(2, response.getWarning().getTotal());
+        assertEquals("READY", response.getWarning().getState());
+        assertEquals(1, count(response.getWarning().getStatusDistribution(), "CLOSED"));
+        // 报警族没有工单事实 → 处置率显式「不适用」,不是 0%。
+        assertEquals("EMPTY", response.getAlarm().getHandlingEfficiency().getState());
+        assertEquals("NO_ELIGIBLE_WORK_ORDERS", response.getAlarm().getHandlingEfficiency().getReason());
+    }
+
+    /** 空态:两族都显式 EMPTY,预警族处置率不适用。 */
+    @Test
+    void emptyFamilyUsesExplicitEmptyState() {
         when(adapter.load()).thenReturn(emptyFixture());
         when(adapter.load()).thenReturn(emptyFixture());
 
 
         CockpitOverviewWarningSettingsResponse response =
         CockpitOverviewWarningSettingsResponse response =
@@ -101,14 +181,13 @@ class CockpitOverviewWarningSettingsServiceTest {
 
 
         assertEquals("EMPTY", response.getAlarm().getState());
         assertEquals("EMPTY", response.getAlarm().getState());
         assertEquals("NO_EVENTS", response.getAlarm().getStateReason());
         assertEquals("NO_EVENTS", response.getAlarm().getStateReason());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getTotal());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getHandled());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getPending());
-        assertEquals("0.0000", response.getAlarm().getHandlingEfficiency().getValue().toPlainString());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getHandlingEfficiency().getNumerator());
-        assertEquals(Integer.valueOf(0), response.getAlarm().getHandlingEfficiency().getDenominator());
-        assertEquals("EMPTY", response.getAlarm().getHandlingEfficiency().getState());
-        assertEquals("NO_ELIGIBLE_WORK_ORDERS", response.getAlarm().getHandlingEfficiency().getReason());
+        assertEquals(0, response.getAlarm().getTotal());
+        assertEquals("EMPTY", response.getWarning().getState());
+        assertEquals("EMPTY", response.getWarning().getHandlingEfficiency().getState());
+        assertEquals("NO_WARNINGS", response.getWarning().getHandlingEfficiency().getReason());
+        // 空态下分布仍全铺已知桶,只是计数为 0。
+        assertEquals(6, response.getWarning().getStatusDistribution().size());
+        assertEquals(0, sum(response.getWarning().getStatusDistribution()));
     }
     }
 
 
     @Test
     @Test
@@ -120,52 +199,101 @@ class CockpitOverviewWarningSettingsServiceTest {
 
 
         assertEquals("EMPTY", response.getFreshness().getStatus());
         assertEquals("EMPTY", response.getFreshness().getStatus());
         assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
         assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
-        assertTrue(response.getUpdatedAt() == null);
+        assertNull(response.getUpdatedAt());
         assertEquals("EMPTY", response.getWarning().getState());
         assertEquals("EMPTY", response.getWarning().getState());
     }
     }
 
 
-    private static CockpitOverviewWarningSettingsSnapshot eightStateFixture() {
+    /** 趋势窗口跟随 `range`:窗口外桶被裁掉,窗口内保留。 */
+    @Test
+    void trendWindowFollowsTheDeclaredRange() {
+        when(adapter.load()).thenReturn(warningFixture());
+
+        CockpitOverviewWarningSettingsResponse response =
+                service.load("request-range", "WARNING", "LAST_7_DAYS", null, null);
+
+        assertEquals("LAST_7_DAYS", response.getRange().getType());
+        List<String> buckets = response.getWarning().getTrend().stream()
+                .map(CockpitOverviewWarningSettingsResponse.TrendPoint::getBucket)
+                .collect(Collectors.toList());
+        // 固定时钟 2026-09-02T10:00+08:00 起算近 7 日 → 只保留 2026-09-01。
+        assertTrue(buckets.contains("2026-09-01"));
+        assertTrue(!buckets.contains("2026-08-01"));
+    }
+
+    private static int count(List<CockpitOverviewWarningSettingsResponse.Bucket> buckets, String code) {
+        return buckets.stream()
+                .filter(bucket -> code.equals(bucket.getCode()))
+                .mapToInt(CockpitOverviewWarningSettingsResponse.Bucket::getCount)
+                .findFirst().orElse(-1);
+    }
+
+    private static int sum(List<CockpitOverviewWarningSettingsResponse.Bucket> buckets) {
+        return buckets.stream().mapToInt(CockpitOverviewWarningSettingsResponse.Bucket::getCount).sum();
+    }
+
+    /** 预警族六态各一条(CLOSED 两条)+ 等级/行业/片区维度齐全。 */
+    private static CockpitOverviewWarningSettingsSnapshot warningFixture() {
         return new CockpitOverviewWarningSettingsSnapshot(
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
                 Arrays.asList(
-                        event("WARNING", "WRN-000", "WO-000", 0),
-                        event("WARNING", "WRN-001", "WO-001", 1),
-                        event("WARNING", "WRN-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", 7),
-                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                                "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(),
-                // 全范围工单:状态 0–7 各一张 → 分母 7(排除状态 7)、分子 1(状态 5)。
+                        warning("W-DRAFT", "DRAFT", 2, "GAS", 0),
+                        warning("W-PENDING", "PENDING", 3, "WATER_SUPPLY", 1),
+                        warning("W-RECEIVED", "RECEIVED", 3, "WATER_SUPPLY", 1),
+                        warning("W-PROCESSING", "PROCESSING", null, null, null),
+                        warning("W-HANDLED", "HANDLED", 3, "WATER_SUPPLY", 1),
+                        warning("W-CLOSED", "CLOSED", 2, "GAS", 0)),
                 Arrays.asList(
                 Arrays.asList(
-                        order(0), order(1), order(2), order(3),
-                        order(4), order(5), order(6), order(7)));
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-09-01", 2, 1),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-08-01", 1, 0)),
+                Collections.emptyList());
     }
     }
 
 
-    /** 只有已取消工单 → 分母 0,用于验证「不适用」而非 0%。 */
-    private static CockpitOverviewWarningSettingsSnapshot cancelledHeavyFixture() {
+    /** 预警族有数据、工单事实为空:验证预警不再依赖工单。 */
+    private static CockpitOverviewWarningSettingsSnapshot warningFixtureWithoutWorkOrders() {
         return new CockpitOverviewWarningSettingsSnapshot(
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 "2026-09-02T09:59:50+08:00",
-                Collections.singletonList(event("WARNING", "WRN-007", "WO-007", 7)),
+                Arrays.asList(
+                        warning("W-1", "CLOSED", 2, "GAS", 0),
+                        warning("W-2", "PROCESSING", 3, "GAS", 0)),
                 Collections.emptyList(),
                 Collections.emptyList(),
-                Arrays.asList(order(7), order(7)));
+                Collections.emptyList());
     }
     }
 
 
-    private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder order(Integer state) {
-        return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
-                state, "2026-09-02T09:00:00+08:00");
+    /** 报警族:工单口径(状态 0–7 各一张)+ 自身生命周期(1 已处理 / 2 未处理 / 1 未回联 / 1 脏码)。 */
+    private static CockpitOverviewWarningSettingsSnapshot alarmFixture() {
+        return new CockpitOverviewWarningSettingsSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        alarm("A-1", "HANDLED", 5, 3, "GAS", 0),
+                        alarm("A-2", "UNHANDLED", 3, 3, "WATER_SUPPLY", 1),
+                        alarm("A-3", "UNHANDLED", 7, 2, "GAS", 0),
+                        alarm("A-4", null, null, 2, "GAS", 0),
+                        alarm("A-5", null, 9, 2, "GAS", 0)),
+                Collections.emptyList(),
+                Arrays.asList(
+                        order(0), order(1), order(2), order(3),
+                        order(4), order(5), order(6), order(7)));
     }
     }
 
 
-    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
-            String family, String eventCode, String workOrderCode, Integer state) {
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent warning(
+            String code, String status, Integer level, String industry, Integer district) {
         return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
         return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
-                family, eventCode, "DEV-" + eventCode, workOrderCode, state);
+                "WARNING", code, "DEV-" + code, null, null, status, level, district, industry);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent alarm(
+            String code, String status, Integer workOrderState, Integer level,
+            String industry, Integer district) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                "ALARM", code, "DEV-" + code, "WO-" + code, workOrderState,
+                status, level, district, industry);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder order(Integer state) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
+                state, "2026-09-02T09:00:00+08:00");
     }
     }
 
 
     private static CockpitOverviewWarningSettingsSnapshot emptyFixture() {
     private static CockpitOverviewWarningSettingsSnapshot emptyFixture() {

+ 71 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileAlarmControllerTest.java

@@ -0,0 +1,71 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.base.alarm.dto.*;
+import com.zksy.base.alarm.service.*;
+import com.zksy.common.exception.ServiceException;
+import org.junit.jupiter.api.*;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class MobileAlarmControllerTest {
+    private final AlarmOperationsService operations = mock(AlarmOperationsService.class);
+    private final AlarmReviewService reviews = mock(AlarmReviewService.class);
+    private final AlarmReviewAttachmentService attachments = mock(AlarmReviewAttachmentService.class);
+    private final MobileAlarmController controller = new MobileAlarmController(operations, reviews, attachments);
+    private MockMvc mvc;
+    @BeforeEach void setup() { mvc = MockMvcBuilders.standaloneSetup(controller).build(); }
+    @Test void readsRequireLoginAndWritesRetainAuditPermission() throws Exception {
+        assertEquals("isAuthenticated()", MobileAlarmController.class.getAnnotation(PreAuthorize.class).value());
+        for (String method : new String[] {"review", "upload"}) {
+            java.lang.reflect.Method found = java.util.Arrays.stream(MobileAlarmController.class.getDeclaredMethods())
+                    .filter(m -> m.getName().equals(method)).findFirst().orElseThrow();
+            assertEquals("@ss.hasPermi('alarm:review:audit')", found.getAnnotation(PreAuthorize.class).value());
+        }
+    }
+    @Test void rootIsOptionalAndPaginationIsBounded() throws Exception {
+        mvc.perform(get("/api/mobile/alarm")).andExpect(status().isOk());
+        verify(operations).mobilePage(any(MobileAlarmQuery.class));
+        clearInvocations(operations);
+        mvc.perform(get("/api/mobile/alarm").param("pageNum", "0")).andExpect(status().isBadRequest());
+        mvc.perform(get("/api/mobile/alarm").param("pageSize", "101")).andExpect(status().isBadRequest());
+        verifyNoInteractions(operations, reviews);
+    }
+    @Test void requiredResultAndOpinionLimitMatchPc() throws Exception {
+        mvc.perform(post("/api/mobile/alarm/a1/reviews").contentType("application/json").content("{}"))
+                .andExpect(status().isBadRequest());
+        mvc.perform(post("/api/mobile/alarm/a1/reviews").contentType("application/json")
+                .content("{\"auditStatus\":1,\"opinion\":\"" + "核".repeat(501) + "\"}"))
+                .andExpect(status().isBadRequest());
+        verifyNoInteractions(operations, reviews);
+        for (int result : new int[] {1, 2}) {
+            mvc.perform(post("/api/mobile/alarm/a1/reviews").contentType("application/json")
+                    .content("{\"auditStatus\":" + result + "}"))
+                    .andExpect(status().isOk());
+        }
+        verify(reviews, times(2)).mobileReview(eq("a1"), any(MobileAlarmQuery.class), any(AlarmReviewRequest.class));
+    }
+    @Test void uploadChecksCompanyBeforeInvokingStorage() throws Exception {
+        MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1});
+        when(operations.mobileDetail(eq("a1"), any())).thenThrow(new ServiceException("无权访问"));
+        assertThrows(ServiceException.class, () -> controller.upload("a1", new MobileAlarmQuery(), file));
+        verifyNoInteractions(attachments);
+    }
+    @Test void reviewedOrHandledAlarmCannotUpload() throws Exception {
+        MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1});
+        AlarmVO alarm = new AlarmVO(); when(operations.mobileDetail(eq("a1"), any())).thenReturn(alarm);
+        alarm.setAuditStatus(1); alarm.setAlarmStatus(0);
+        assertEquals(500, controller.upload("a1", new MobileAlarmQuery(), file).get("code"));
+        alarm.setAuditStatus(0); alarm.setAlarmStatus(1);
+        assertEquals(500, controller.upload("a1", new MobileAlarmQuery(), file).get("code"));
+        verifyNoInteractions(attachments);
+        alarm.setAlarmStatus(0);
+        controller.upload("a1", new MobileAlarmQuery(), file);
+        verify(attachments).upload("a1", file);
+    }
+}

+ 142 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileControllerMappingTest.java

@@ -0,0 +1,142 @@
+package com.zksy.web.controller.mobile;
+
+import com.zksy.base.alarm.service.*;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.ops.dto.InspectTaskCompleteDTO;
+import com.zksy.ops.dto.InspectTaskPageDTO;
+import com.zksy.ops.dto.WorkOrderPageDTO;
+import com.zksy.ops.service.InspectTaskService;
+import com.zksy.ops.service.WorkOrderService;
+import com.zksy.system.domain.SysNotice;
+import com.zksy.system.service.*;
+import com.zksy.web.controller.alarm.AlarmController;
+import com.zksy.web.controller.base.EquipmentBaseController;
+import com.zksy.web.controller.monitor.SysLogininforController;
+import com.zksy.web.controller.ops.InspectTaskController;
+import com.zksy.web.controller.ops.WorkOrderController;
+import com.zksy.web.controller.system.SysNoticeController;
+import com.zksy.web.controller.system.SysUserController;
+import org.junit.jupiter.api.*;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class MobileControllerMappingTest {
+    private final WorkOrderService orders = mock(WorkOrderService.class);
+    private final InspectTaskService inspections = mock(InspectTaskService.class);
+    private final EquipmentBaseService equipment = mock(EquipmentBaseService.class);
+    private final ISysNoticeService notices = mock(ISysNoticeService.class);
+    private final ISysLogininforService loginLogs = mock(ISysLogininforService.class);
+    private final Object[] mobile = {
+            new MobileAlarmController(mock(AlarmOperationsService.class), mock(AlarmReviewService.class), mock(AlarmReviewAttachmentService.class)),
+            new MobileWorkOrderController(orders), new MobileInspectTaskController(inspections),
+            new MobileEquipmentController(equipment), new MobileNoticeController(notices),
+            new MobileLogininforController(loginLogs), new MobileContactsController(mock(ISysUserService.class)),
+            new MobileOfficeController(equipment, mock(AlarmOperationsService.class), inspections)
+    };
+    private MockMvc mvc;
+    private RequestMappingHandlerMapping mapping;
+
+    @BeforeEach void setup() {
+        List<Object> controllers = new ArrayList<>(Arrays.asList(mobile));
+        controllers.addAll(Arrays.asList(new AlarmController(), new WorkOrderController(), new InspectTaskController(),
+                new EquipmentBaseController(), new SysNoticeController(), new SysLogininforController(), new SysUserController()));
+        // Register PC and mobile together: duplicate mappings must fail at startup.
+        mvc = MockMvcBuilders.standaloneSetup(controllers.toArray()).build();
+        mapping = mvc.getDispatcherServlet().getWebApplicationContext().getBean(RequestMappingHandlerMapping.class);
+    }
+
+    private void route(String method, String url, Class<?> owner) throws Exception {
+        var chain = mapping.getHandler(new MockHttpServletRequest(method, url));
+        assertNotNull(chain, method + " " + url);
+        assertEquals(owner, ((HandlerMethod) chain.getHandler()).getBeanType(), method + " " + url);
+    }
+
+    @Test void mobileUrlsUseUnifiedPrefixAndResolveToDedicatedControllers() throws Exception {
+        for (String path : new String[] {"", "/pending", "/42", "/assignable/users"})
+            route("GET", "/api/mobile/ops/workOrder" + path, MobileWorkOrderController.class);
+        for (String path : new String[] {"/findByPage", "/42"})
+            route("GET", "/api/mobile/ops/inspectTask" + path, MobileInspectTaskController.class);
+        route("PUT", "/api/mobile/ops/inspectTask/complete", MobileInspectTaskController.class);
+        for (String path : new String[] {"", "/device-1"})
+            route("GET", "/api/mobile/equipment-base" + path, MobileEquipmentController.class);
+        for (String path : new String[] {"/list", "/42"})
+            route("GET", "/api/mobile/notice" + path, MobileNoticeController.class);
+        route("GET", "/api/mobile/monitor/logininfor/list", MobileLogininforController.class);
+        route("GET", "/api/mobile/contacts", MobileContactsController.class);
+        route("GET", "/api/mobile/office/summary", MobileOfficeController.class);
+        for (String path : new String[] {"", "/a1", "/a1/logs"})
+            route("GET", "/api/mobile/alarm" + path, MobileAlarmController.class);
+        for (String path : new String[] {"/a1/reviews", "/a1/attachments"})
+            route("POST", "/api/mobile/alarm" + path, MobileAlarmController.class);
+    }
+
+    @Test void pcAndSharedOperationUrlsKeepTheirOwners() throws Exception {
+        route("GET", "/api/ops/workOrder", WorkOrderController.class);
+        route("GET", "/api/ops/workOrder/42", WorkOrderController.class);
+        for (String action : new String[] {"dispatch", "accept", "process", "submitVerify", "verify", "cancel", "delay/apply", "delay/audit"})
+            route("PUT", "/api/ops/workOrder/42/" + action, WorkOrderController.class);
+        route("GET", "/api/ops/inspectTask/findByPage", InspectTaskController.class);
+        route("PUT", "/api/ops/inspectTask/complete", InspectTaskController.class);
+        route("GET", "/equipment-base", EquipmentBaseController.class);
+        route("GET", "/system/notice/list", SysNoticeController.class);
+        route("GET", "/monitor/logininfor/list", SysLogininforController.class);
+        route("GET", "/system/user/list", SysUserController.class);
+        route("POST", "/api/alarm/a1/reviews", AlarmController.class);
+    }
+
+    @Test void allDedicatedControllersRequireLoginAndDispatchKeepsItsPermission() throws Exception {
+        for (Object controller : mobile)
+            assertEquals("isAuthenticated()", controller.getClass().getAnnotation(PreAuthorize.class).value());
+        assertEquals("@ss.hasPermi('ops:workOrder:dispatch')", MobileWorkOrderController.class
+                .getMethod("mobileAssignableUsers", String.class).getAnnotation(PreAuthorize.class).value());
+    }
+
+    @Test void equipmentParametersAndPathBindingRemainCompatible() throws Exception {
+        mvc.perform(get("/api/mobile/equipment-base").param("pageNum", "2").param("pageSize", "20")
+                .param("keyword", "设备").param("rootTypeId", "2").param("onlineStatus", "1").param("alarmStatus", "1"))
+                .andExpect(status().isOk());
+        verify(equipment).findMobileByPage(2, 20, "设备", 2, 1, 1);
+        mvc.perform(get("/api/mobile/equipment-base/device-1")).andExpect(status().isOk());
+        verify(equipment).getMobileEquipmentDetail("device-1");
+    }
+
+    @Test void workOrderAndInspectionQueriesReachTheSharedServices() throws Exception {
+        mvc.perform(get("/api/mobile/ops/workOrder").param("pageNum", "3").param("keyword", "工单"))
+                .andExpect(status().isOk());
+        verify(orders).findMobilePage(argThat(query -> query.getPageNum() == 3 && "工单".equals(query.getKeyword())));
+        mvc.perform(get("/api/mobile/ops/workOrder/pending")).andExpect(status().isOk());
+        verify(orders).findMobilePendingPage(any(WorkOrderPageDTO.class));
+        mvc.perform(get("/api/mobile/ops/workOrder/42")).andExpect(status().isOk());
+        verify(orders).getMobileDetail(42L);
+        mvc.perform(get("/api/mobile/ops/inspectTask/findByPage")).andExpect(status().isOk());
+        verify(inspections).findMobileByPage(any(InspectTaskPageDTO.class));
+        mvc.perform(get("/api/mobile/ops/inspectTask/42")).andExpect(status().isOk());
+        verify(inspections).getMobileDetailById(42L);
+        mvc.perform(put("/api/mobile/ops/inspectTask/complete").contentType("application/json")
+                .content("{\"inspectTaskId\":42,\"realTime\":\"2026-10-14 10:00:00\"}"))
+                .andExpect(status().isOk());
+        verify(inspections).completeMobileTask(any(InspectTaskCompleteDTO.class));
+    }
+
+    @Test void closedNoticeIsStillHiddenAndLoginLogUsesCurrentUserService() throws Exception {
+        SysNotice notice = new SysNotice(); notice.setNoticeId(42L); notice.setStatus("1");
+        when(notices.selectNoticeById(42L)).thenReturn(notice);
+        mvc.perform(get("/api/mobile/notice/42")).andExpect(status().isOk()).andExpect(jsonPath("$.code").value(500));
+        notice.setStatus("0");
+        mvc.perform(get("/api/mobile/notice/42")).andExpect(status().isOk()).andExpect(jsonPath("$.data.noticeId").value(42));
+        when(loginLogs.selectCurrentUserLogininforList()).thenReturn(Collections.emptyList());
+        mvc.perform(get("/api/mobile/monitor/logininfor/list").param("userName", "ignored"))
+                .andExpect(status().isOk()).andExpect(jsonPath("$.rows").isArray());
+        verify(loginLogs).selectCurrentUserLogininforList();
+        verify(loginLogs, never()).selectLogininforList(any());
+    }
+}

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

@@ -3,6 +3,8 @@ package com.zksy.web.websocket;
 import com.alibaba.fastjson.JSONObject;
 import com.alibaba.fastjson.JSONObject;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.ops.domain.vo.WorkOrderPushVO;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.framework.web.service.TokenService;
 import com.zksy.framework.web.service.TokenService;
 import org.springframework.data.redis.connection.Message;
 import org.springframework.data.redis.connection.Message;
@@ -19,6 +21,7 @@ import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
@@ -55,6 +58,38 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         ReflectionTestUtils.setField(handler, "workOrderMaxItems", 100);
         ReflectionTestUtils.setField(handler, "workOrderMaxItems", 100);
     }
     }
 
 
+    @Test
+    void workOrderReminderUsesParticipantVisibility() {
+        WorkOrderPushVO item = new WorkOrderPushVO();
+        item.setCreateBy(10L);
+        item.setReceiveUser(11L);
+        item.setVerifyUser(12L);
+        LoginUser creator = new LoginUser();
+        creator.setUserId(10L);
+        creator.setPermissions(java.util.Collections.emptySet());
+        LoginUser unrelated = new LoginUser();
+        unrelated.setUserId(20L);
+        unrelated.setPermissions(java.util.Collections.emptySet());
+
+        assertTrue(DeviceStatusWebSocketHandler.isWorkOrderVisibleTo(item, creator));
+        assertFalse(DeviceStatusWebSocketHandler.isWorkOrderVisibleTo(item, unrelated));
+    }
+
+    @Test
+    void workOrderReminderAllowsAdminButNotUnassignedDispatcher() {
+        WorkOrderPushVO item = new WorkOrderPushVO();
+        item.setOrderStatus(0);
+        LoginUser admin = new LoginUser();
+        admin.setUserId(1L);
+        admin.setPermissions(java.util.Collections.emptySet());
+        LoginUser dispatcher = new LoginUser();
+        dispatcher.setUserId(20L);
+        dispatcher.setPermissions(java.util.Collections.singleton("ops:workOrder:dispatch"));
+
+        assertTrue(DeviceStatusWebSocketHandler.isWorkOrderVisibleTo(item, admin));
+        assertFalse(DeviceStatusWebSocketHandler.isWorkOrderVisibleTo(item, dispatcher));
+    }
+
     @Test
     @Test
     void cockpitSubscribeAllReturnsExplicitScopeSubscription() throws Exception {
     void cockpitSubscribeAllReturnsExplicitScopeSubscription() throws Exception {
         handler.handleTextMessage(session, new TextMessage(
         handler.handleTextMessage(session, new TextMessage(

+ 29 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/MobileAlarmQuery.java

@@ -0,0 +1,29 @@
+package com.zksy.base.alarm.dto;
+
+import lombok.Data;
+import javax.validation.constraints.*;
+
+/** 移动端根类型可选,实际可见范围始终由登录用户部门确定。 */
+@Data
+public class MobileAlarmQuery {
+    private Integer rootTypeId;
+    @Size(max = 100)
+    private String keyword;
+    private Integer alarmLevel;
+    private Integer auditStatus;
+    @Min(1)
+    private long pageNum = 1;
+    @Min(1) @Max(100)
+    private long pageSize = 10;
+
+    public AlarmQuery toAlarmQuery() {
+        AlarmQuery query = new AlarmQuery();
+        query.setRootTypeId(rootTypeId);
+        query.setKeyword(keyword == null ? null : keyword.trim());
+        query.setAlarmLevel(alarmLevel);
+        query.setAuditStatus(auditStatus);
+        query.setPageNum(pageNum);
+        query.setPageSize(pageSize);
+        return query;
+    }
+}

+ 2 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java

@@ -11,6 +11,8 @@ import java.util.Map;
 
 
 @Mapper
 @Mapper
 public interface AlarmOperationsMapper {
 public interface AlarmOperationsMapper {
+    Page<AlarmVO> selectMobilePage(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
+    AlarmVO selectMobileDetail(@Param("id") String id, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     Page<AlarmVO> selectPage(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     Page<AlarmVO> selectPage(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     Page<AlarmVO> selectReminders(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     Page<AlarmVO> selectReminders(Page<AlarmVO> page, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     AlarmVO selectDetail(@Param("id") String id, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
     AlarmVO selectDetail(@Param("id") String id, @Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);

+ 2 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmReviewMapper.java

@@ -8,6 +8,8 @@ import java.util.Map;
 
 
 @Mapper
 @Mapper
 public interface AlarmReviewMapper {
 public interface AlarmReviewMapper {
+    AlarmReviewVO selectMobileDetail(@Param("id") String id, @Param("query") AlarmReviewQuery query,
+                                    @Param("typeIds") List<Integer> typeIds);
     Page<AlarmReviewVO> selectPage(Page<AlarmReviewVO> page, @Param("query") AlarmReviewQuery query,
     Page<AlarmReviewVO> selectPage(Page<AlarmReviewVO> page, @Param("query") AlarmReviewQuery query,
                                   @Param("typeIds") List<Integer> typeIds, @Param("unhandled") int unhandled);
                                   @Param("typeIds") List<Integer> typeIds, @Param("unhandled") int unhandled);
     AlarmReviewVO selectDetail(@Param("id") String id, @Param("query") AlarmReviewQuery query,
     AlarmReviewVO selectDetail(@Param("id") String id, @Param("query") AlarmReviewQuery query,

+ 40 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmOperationsService.java

@@ -34,12 +34,49 @@ public class AlarmOperationsService {
         labels(page.getRecords());
         labels(page.getRecords());
         return page;
         return page;
     }
     }
+    public Page<AlarmVO> mobilePage(MobileAlarmQuery query) {
+        AlarmQuery filters = query.toAlarmQuery();
+        // 与 PC 审核页一致,只查未处理事件,包含所有审核结果。
+        filters.setAlarmStatus(AlarmRecordStatusEnum.UNHANDLED.getCode());
+        List<Integer> typeIds = scope.resolveMobile(query);
+        return pageInScope(filters, typeIds);
+    }
+    /** 今日新增仅统计移动端故障列表中仍可见的未处理事件。 */
+    public long mobileTodayCount() {
+        MobileAlarmQuery query = new MobileAlarmQuery();
+        query.setPageSize(1);
+        AlarmQuery filters = query.toAlarmQuery();
+        filters.setAlarmStatus(AlarmRecordStatusEnum.UNHANDLED.getCode());
+        LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
+        filters.setStartTime(today.atStartOfDay());
+        filters.setEndTime(today.plusDays(1).atStartOfDay());
+        return pageInScope(filters, scope.resolveMobile(query)).getTotal();
+    }
+    public AlarmVO mobileDetail(String id, MobileAlarmQuery query) {
+        return detailInScope(id, query.toAlarmQuery(), scope.resolveMobile(query));
+    }
+    public List<AlarmLog> mobileLogs(String id, MobileAlarmQuery query) {
+        mobileDetail(id, query);
+        return history(id);
+    }
+    private Page<AlarmVO> pageInScope(AlarmQuery query, List<Integer> typeIds) {
+        validate(query);
+        Page<AlarmVO> page = mapper.selectMobilePage(new Page<>(query.getPageNum(), query.getPageSize()), query, typeIds);
+        labels(page.getRecords());
+        return page;
+    }
     public AlarmVO detail(String id, AlarmQuery query) {
     public AlarmVO detail(String id, AlarmQuery query) {
         AlarmVO alarm = mapper.selectDetail(id, query, scope.resolveOperations(query));
         AlarmVO alarm = mapper.selectDetail(id, query, scope.resolveOperations(query));
         if (alarm == null) throw new ServiceException("报警不存在或不属于当前管理范围");
         if (alarm == null) throw new ServiceException("报警不存在或不属于当前管理范围");
         labels(alarm);
         labels(alarm);
         return alarm;
         return alarm;
     }
     }
+    private AlarmVO detailInScope(String id, AlarmQuery query, List<Integer> typeIds) {
+        AlarmVO alarm = mapper.selectMobileDetail(id, query, typeIds);
+        if (alarm == null) throw new ServiceException("报警不存在或不属于当前管理范围");
+        labels(alarm);
+        return alarm;
+    }
     public Page<AlarmVO> reminders(AlarmQuery query) {
     public Page<AlarmVO> reminders(AlarmQuery query) {
         // 提醒只展示未处理报警,不能通过请求参数切换为历史已恢复事件。
         // 提醒只展示未处理报警,不能通过请求参数切换为历史已恢复事件。
         query.setAlarmStatus(AlarmRecordStatusEnum.UNHANDLED.getCode());
         query.setAlarmStatus(AlarmRecordStatusEnum.UNHANDLED.getCode());
@@ -50,6 +87,9 @@ public class AlarmOperationsService {
     }
     }
     public List<AlarmLog> logs(String id, AlarmQuery query) {
     public List<AlarmLog> logs(String id, AlarmQuery query) {
         detail(id, query);
         detail(id, query);
+        return history(id);
+    }
+    private List<AlarmLog> history(String id) {
         List<AlarmLog> result = logs.selectHistory(id);
         List<AlarmLog> result = logs.selectHistory(id);
         result.forEach(log -> { AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
         result.forEach(log -> { AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
             log.setOperTypeName(type == null ? "历史操作" : type.getLabel()); });
             log.setOperTypeName(type == null ? "历史操作" : type.getLabel()); });

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewScopeService.java

@@ -3,6 +3,7 @@ package com.zksy.base.alarm.service;
 import cn.hutool.json.JSONUtil;
 import cn.hutool.json.JSONUtil;
 import cn.hutool.json.JSONObject;
 import cn.hutool.json.JSONObject;
 import com.zksy.base.alarm.dto.AlarmReviewQuery;
 import com.zksy.base.alarm.dto.AlarmReviewQuery;
+import com.zksy.base.alarm.dto.MobileAlarmQuery;
 import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.enums.EquipmentTypeEnum;
 import com.zksy.base.enums.EquipmentTypeEnum;
 import com.zksy.base.mapper.EquipmentTypeMapper;
 import com.zksy.base.mapper.EquipmentTypeMapper;
@@ -10,10 +11,12 @@ import com.zksy.common.core.domain.entity.SysMenu;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.common.utils.SecurityUtils;
 import com.zksy.common.utils.SecurityUtils;
 import com.zksy.system.service.ISysMenuService;
 import com.zksy.system.service.ISysMenuService;
+import com.zksy.ops.support.OpsAssignScopeSupport;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.access.AccessDeniedException;
 import java.util.*;
 import java.util.*;
+import java.util.stream.Collectors;
 
 
 /** 路由 query 不是授权依据,服务端同时校验用户获授权菜单及数据库设备类型树。 */
 /** 路由 query 不是授权依据,服务端同时校验用户获授权菜单及数据库设备类型树。 */
 @Service
 @Service
@@ -21,6 +24,21 @@ import java.util.*;
 public class AlarmReviewScopeService {
 public class AlarmReviewScopeService {
     private final EquipmentTypeMapper typeMapper;
     private final EquipmentTypeMapper typeMapper;
     private final ISysMenuService menuService;
     private final ISysMenuService menuService;
+    private final OpsAssignScopeSupport companyScope;
+
+    /** 移动端仅匹配公司设备类型的直接子级,管理员也不扩大范围。 */
+    public List<Integer> resolveMobile(MobileAlarmQuery query) {
+        Set<Integer> roots = companyScope.currentCompanyEquipmentRootTypeIds();
+        if (query.getRootTypeId() != null) {
+            if (!roots.contains(query.getRootTypeId())) throw new ServiceException("无权访问该公司的故障数据");
+            roots = Collections.singleton(query.getRootTypeId());
+        }
+        if (roots.isEmpty()) return Collections.emptyList();
+        final Set<Integer> allowed = roots;
+        return typeMapper.selectList(null).stream()
+                .filter(type -> allowed.contains(type.getParentId()) && type.getId() != null)
+                .map(EquipmentType::getId).distinct().collect(Collectors.toList());
+    }
 
 
     public List<Integer> resolve(AlarmReviewQuery query) {
     public List<Integer> resolve(AlarmReviewQuery query) {
         return resolve(query, Collections.singleton("subSystem/alarm/common/audit"));
         return resolve(query, Collections.singleton("subSystem/alarm/common/audit"));

+ 21 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java

@@ -58,6 +58,11 @@ public class AlarmReviewService {
 
 
     public List<AlarmLog> logs(String id, AlarmReviewQuery query) {
     public List<AlarmLog> logs(String id, AlarmReviewQuery query) {
         detail(id, query);
         detail(id, query);
+        return history(id);
+    }
+
+    /** 调用方须先校验报警读取范围。 */
+    List<AlarmLog> history(String id) {
         List<AlarmLog> logs = logMapper.selectHistory(id);
         List<AlarmLog> logs = logMapper.selectHistory(id);
         logs.forEach(log -> {
         logs.forEach(log -> {
             AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
             AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
@@ -69,6 +74,22 @@ public class AlarmReviewService {
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public AlarmReviewVO review(String id, AlarmReviewQuery query, AlarmReviewRequest request) {
     public AlarmReviewVO review(String id, AlarmReviewQuery query, AlarmReviewRequest request) {
         AlarmReviewVO alarm = detail(id, query);
         AlarmReviewVO alarm = detail(id, query);
+        return performReview(id, alarm, request);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AlarmReviewVO mobileReview(String id, MobileAlarmQuery query, AlarmReviewRequest request) {
+        return reviewInScope(id, query.toAlarmQuery(), scopeService.resolveMobile(query), request);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public AlarmReviewVO reviewInScope(String id, AlarmReviewQuery query, List<Integer> typeIds, AlarmReviewRequest request) {
+        AlarmReviewVO alarm = reviewMapper.selectMobileDetail(id, query, typeIds);
+        if (alarm == null) throw new ServiceException("报警不存在或不属于当前管理范围");
+        return performReview(id, alarm, request);
+    }
+
+    private AlarmReviewVO performReview(String id, AlarmReviewVO alarm, AlarmReviewRequest request) {
         AlarmAuditStatusEnum result = AlarmAuditStatusEnum.fromCode(request.getAuditStatus());
         AlarmAuditStatusEnum result = AlarmAuditStatusEnum.fromCode(request.getAuditStatus());
         if (result == null || result == AlarmAuditStatusEnum.UNREVIEWED) throw new ServiceException("审核结果无效");
         if (result == null || result == AlarmAuditStatusEnum.UNREVIEWED) throw new ServiceException("审核结果无效");
         String opinion = request.getOpinion() == null ? "" : request.getOpinion().trim();
         String opinion = request.getOpinion() == null ? "" : request.getOpinion().trim();

+ 8 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/EquipmentBaseService.java

@@ -31,6 +31,14 @@ public interface EquipmentBaseService extends IService<EquipmentBase> {
                                    Integer equipmentTypeId, Integer rootTypeId,
                                    Integer equipmentTypeId, Integer rootTypeId,
                 Integer onlineStatus, Integer alarmStatus);
                 Integer onlineStatus, Integer alarmStatus);
 
 
+    /** 移动端日常设备查询:按当前用户公司范围过滤设备类型。 */
+    Page<EquipmentBaseVO> findMobileByPage(long pageNum, long pageSize,
+                                           String keyword, Integer rootTypeId, Integer onlineStatus,
+                                           Integer alarmStatus);
+
+    /** 移动端设备详情:复用公司范围校验后的设备详情数据。 */
+    Map<String, Object> getMobileEquipmentDetail(String equipmentId);
+
     Map<String, Object> getEquipmentStatusStatistics(Integer rootTypeId);
     Map<String, Object> getEquipmentStatusStatistics(Integer rootTypeId);
 
 
     boolean saveWithCheck(EquipmentBase entity);
     boolean saveWithCheck(EquipmentBase entity);

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

@@ -33,6 +33,7 @@ import com.zksy.manhole.dto.in.ManholeDeviceStatisticsInDTO;
 import com.zksy.manhole.dto.out.*;
 import com.zksy.manhole.dto.out.*;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.mapper.WorkOrderMapper;
 import com.zksy.ops.mapper.WorkOrderMapper;
+import com.zksy.ops.support.OpsAssignScopeSupport;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -84,6 +85,9 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
     @Autowired
     @Autowired
     private WorkOrderMapper workOrderMapper;
     private WorkOrderMapper workOrderMapper;
 
 
+    @Autowired
+    private OpsAssignScopeSupport assignScopeSupport;
+
     @Override
     @Override
     public Page<EquipmentBaseVO> findByPage(long pageNum, long pageSize,
     public Page<EquipmentBaseVO> findByPage(long pageNum, long pageSize,
                                           String equipmentCode, String equipmentName,
                                           String equipmentCode, String equipmentName,
@@ -128,6 +132,89 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
         }
         }
         queryWrapper.orderByDesc(EquipmentBase::getCreateTime);
         queryWrapper.orderByDesc(EquipmentBase::getCreateTime);
         Page<EquipmentBase> basePage = this.page(page, queryWrapper);
         Page<EquipmentBase> basePage = this.page(page, queryWrapper);
+        return toEquipmentBaseVOPage(basePage, pageNum, pageSize);
+    }
+
+    @Override
+    public Page<EquipmentBaseVO> findMobileByPage(long pageNum, long pageSize,
+                                                  String keyword, Integer rootTypeId,
+                                                  Integer onlineStatus,
+                                                  Integer alarmStatus) {
+        Set<Integer> companyRootTypeIds = assignScopeSupport.currentCompanyEquipmentRootTypeIds();
+        if (rootTypeId != null && !companyRootTypeIds.contains(rootTypeId)) {
+            return new Page<>(pageNum, pageSize, 0);
+        }
+        Set<Integer> allowedTypeIds = resolveMobileTypeIds(companyRootTypeIds, rootTypeId);
+        if (allowedTypeIds.isEmpty()) return new Page<>(pageNum, pageSize, 0);
+
+        LambdaQueryWrapper<EquipmentBase> queryWrapper = new LambdaQueryWrapper<>();
+        String normalizedKeyword = StringUtils.trimToNull(keyword);
+        if (normalizedKeyword != null) {
+            queryWrapper.and(w -> w.like(EquipmentBase::getEquipmentCode, normalizedKeyword)
+                    .or().like(EquipmentBase::getEquipmentName, normalizedKeyword));
+        }
+        queryWrapper.in(EquipmentBase::getEquipmentTypeId, allowedTypeIds);
+        if (onlineStatus != null || (alarmStatus != null && alarmStatus == 1)) {
+            LambdaQueryWrapper<EquipmentStatus> statusWrapper = new LambdaQueryWrapper<>();
+            if (onlineStatus != null) {
+                statusWrapper.eq(EquipmentStatus::getOnlineStatus, onlineStatus);
+            }
+            if (alarmStatus != null && alarmStatus == 1) {
+                statusWrapper.eq(EquipmentStatus::getAlarmStatus, 1);
+            }
+            List<String> matchedIds = equipmentStatusMapper.selectList(statusWrapper).stream()
+                    .map(EquipmentStatus::getEquipmentId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+            if (matchedIds.isEmpty()) {
+                return new Page<>(pageNum, pageSize, 0);
+            }
+            queryWrapper.in(EquipmentBase::getEquipmentId, matchedIds);
+        }
+        queryWrapper.orderByDesc(EquipmentBase::getCreateTime);
+        return toEquipmentBaseVOPage(this.page(new Page<>(pageNum, pageSize), queryWrapper), pageNum, pageSize);
+    }
+
+    @Override
+    public Map<String, Object> getMobileEquipmentDetail(String equipmentId) {
+        EquipmentBase equipment = this.getById(equipmentId);
+        if (equipment == null || equipment.getEquipmentTypeId() == null) {
+            throw new ServiceException("设备不存在或无权查看");
+        }
+        Set<Integer> companyRootTypeIds = assignScopeSupport.currentCompanyEquipmentRootTypeIds();
+        EquipmentType type = equipmentTypeMapper.selectById(equipment.getEquipmentTypeId());
+        if (type == null || !isTypeInAuthorizedRoots(type, companyRootTypeIds)) {
+            throw new ServiceException("无权查看其他公司范围的设备");
+        }
+        return getEquipmentDetail(equipmentId);
+    }
+
+    private Set<Integer> resolveMobileTypeIds(Set<Integer> authorizedRoots, Integer rootTypeId) {
+        if (authorizedRoots == null || authorizedRoots.isEmpty()) return Collections.emptySet();
+        Set<Integer> roots = rootTypeId == null
+                ? new LinkedHashSet<>(authorizedRoots)
+                : new LinkedHashSet<>(Collections.singleton(rootTypeId));
+        if (rootTypeId != null) {
+            EquipmentTypeEnum root = EquipmentTypeEnum.getById(rootTypeId);
+            if (root == null || !root.isRoot() || !authorizedRoots.contains(rootTypeId)) {
+                return Collections.emptySet();
+            }
+        }
+
+        return equipmentTypeMapper.selectList(
+                        new LambdaQueryWrapper<EquipmentType>().in(EquipmentType::getParentId, roots)).stream()
+                .map(EquipmentType::getId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+    }
+
+    private boolean isTypeInAuthorizedRoots(EquipmentType type, Set<Integer> authorizedRoots) {
+        return type != null && authorizedRoots != null && authorizedRoots.contains(type.getParentId());
+    }
+
+    private Page<EquipmentBaseVO> toEquipmentBaseVOPage(Page<EquipmentBase> basePage,
+                                                         long pageNum, long pageSize) {
         List<String> equipmentIds = basePage.getRecords().stream().map(EquipmentBase::getEquipmentId)
         List<String> equipmentIds = basePage.getRecords().stream().map(EquipmentBase::getEquipmentId)
                 .filter(Objects::nonNull).collect(Collectors.toList());
                 .filter(Objects::nonNull).collect(Collectors.toList());
         Map<String, EquipmentStatus> statusMap = equipmentIds.isEmpty() ? Collections.emptyMap() :
         Map<String, EquipmentStatus> statusMap = equipmentIds.isEmpty() ? Collections.emptyMap() :

+ 49 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/EarlyWarningStatusEnum.java

@@ -0,0 +1,49 @@
+package com.zksy.base.warning.domain;
+
+import java.util.Arrays;
+
+/**
+ * 预警业务状态枚举(与 early_warning.status 存储值一致)。
+ * 状态主线:草稿 → 待接收 → 已接收 → 处置中 → 已处置 → 已解除(终态)。
+ * 已处置仍需核查,只有「已解除」才是终态。
+ */
+public enum EarlyWarningStatusEnum {
+    DRAFT("DRAFT", "草稿"),
+    PENDING("PENDING", "待接收"),
+    RECEIVED("RECEIVED", "已接收"),
+    PROCESSING("PROCESSING", "处置中"),
+    HANDLED("HANDLED", "已处置"),
+    CLOSED("CLOSED", "已解除");
+
+    private final String code;
+    private final String label;
+
+    EarlyWarningStatusEnum(String code, String label) {
+        this.code = code;
+        this.label = label;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public static EarlyWarningStatusEnum of(String code) {
+        if (code == null) return null;
+        for (EarlyWarningStatusEnum value : values()) {
+            if (value.code.equalsIgnoreCase(code)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    public static String labelOf(String code) {
+        EarlyWarningStatusEnum value = of(code);
+        if (value != null) return value.label;
+        return code == null ? "未知状态" : code;
+    }
+}

+ 13 - 24
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningWorkflow.java

@@ -7,26 +7,17 @@ import java.util.List;
 
 
 /** 预警业务状态的唯一主线;已处置仍需核查,只有已解除才是终态。 */
 /** 预警业务状态的唯一主线;已处置仍需核查,只有已解除才是终态。 */
 public final class WarningWorkflow {
 public final class WarningWorkflow {
-    public static final String DRAFT = "DRAFT";
-    public static final String PENDING = "PENDING";
-    public static final String RECEIVED = "RECEIVED";
-    public static final String PROCESSING = "PROCESSING";
-    public static final String HANDLED = "HANDLED";
-    public static final String CLOSED = "CLOSED";
+    public static final String DRAFT = EarlyWarningStatusEnum.DRAFT.getCode();
+    public static final String PENDING = EarlyWarningStatusEnum.PENDING.getCode();
+    public static final String RECEIVED = EarlyWarningStatusEnum.RECEIVED.getCode();
+    public static final String PROCESSING = EarlyWarningStatusEnum.PROCESSING.getCode();
+    public static final String HANDLED = EarlyWarningStatusEnum.HANDLED.getCode();
+    public static final String CLOSED = EarlyWarningStatusEnum.CLOSED.getCode();
 
 
     private WarningWorkflow() { }
     private WarningWorkflow() { }
 
 
     public static String label(String state) {
     public static String label(String state) {
-        if (state == null) return "未知状态";
-        switch (state) {
-            case DRAFT: return "草稿";
-            case PENDING: return "待办";
-            case RECEIVED: return "已接收";
-            case PROCESSING: return "处置中";
-            case HANDLED: return "已处置";
-            case CLOSED: return "已解除";
-            default: return state;
-        }
+        return EarlyWarningStatusEnum.labelOf(state);
     }
     }
 
 
     public static void requireState(EarlyWarning warning, String operation, String... allowed) {
     public static void requireState(EarlyWarning warning, String operation, String... allowed) {
@@ -38,13 +29,11 @@ public final class WarningWorkflow {
 
 
     public static List<String> actions(String state) {
     public static List<String> actions(String state) {
         if (state == null) return Collections.emptyList();
         if (state == null) return Collections.emptyList();
-        switch (state) {
-            case DRAFT: return Arrays.asList("EDIT", "DELETE", "PUBLISH");
-            case PENDING: return Arrays.asList("RECEIVE", "UPGRADE", "SUPERVISION", "INSTRUCTION");
-            case RECEIVED: return Arrays.asList("START", "UPGRADE", "SUPERVISION", "INSTRUCTION");
-            case PROCESSING: return Arrays.asList("HANDLE", "UPGRADE", "SUPERVISION", "INSTRUCTION");
-            case HANDLED: return Arrays.asList("CLEAR", "RETURN", "UPGRADE", "SUPERVISION", "INSTRUCTION");
-            default: return Collections.emptyList();
-        }
+        if (DRAFT.equals(state)) return Arrays.asList("EDIT", "DELETE", "PUBLISH");
+        if (PENDING.equals(state)) return Arrays.asList("RECEIVE", "UPGRADE", "SUPERVISION", "INSTRUCTION");
+        if (RECEIVED.equals(state)) return Arrays.asList("START", "UPGRADE", "SUPERVISION", "INSTRUCTION");
+        if (PROCESSING.equals(state)) return Arrays.asList("HANDLE", "UPGRADE", "SUPERVISION", "INSTRUCTION");
+        if (HANDLED.equals(state)) return Arrays.asList("CLEAR", "RETURN", "UPGRADE", "SUPERVISION", "INSTRUCTION");
+        return Collections.emptyList();
     }
     }
 }
 }

+ 20 - 10
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java

@@ -29,8 +29,10 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
 
 
 @Service
 @Service
 @Slf4j
 @Slf4j
@@ -203,10 +205,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         // 草稿可预选核查人;未选择时在发布时使用当前发布人。
         // 草稿可预选核查人;未选择时在发布时使用当前发布人。
         warning.setCreateBy(currentUser());
         warning.setCreateBy(currentUser());
         warning.setUpdateBy(currentUser());
         warning.setUpdateBy(currentUser());
-        String warningNo = "YJ" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"))
-                + UUID.randomUUID().toString().substring(0, 6).toUpperCase(Locale.ROOT);
-        warning.setWarningNo(warningNo);
-        warning.setStatus("DRAFT");
+        warning.setWarningNo(generateWarningNo());
+        warning.setStatus(EarlyWarningStatusEnum.DRAFT.getCode());
         warning.setCreateTime(LocalDateTime.now());
         warning.setCreateTime(LocalDateTime.now());
         warning.setUpdateTime(LocalDateTime.now());
         warning.setUpdateTime(LocalDateTime.now());
 
 
@@ -225,6 +225,16 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 预警单号:规则与报警单号一致(yyyyMMddHHmmss + 4 位随机),仅前缀为 YJ。
+     * 与 zk-api-service 的 AlarmLifecycleSupport.nextWarningNo() 保持同一种子公式。
+     */
+    private static String generateWarningNo() {
+        return "YJ"
+                + LocalDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+                + String.format(Locale.ROOT, "%04d", ThreadLocalRandom.current().nextInt(10000));
+    }
+
     /**
     /**
      * 保存附件
      * 保存附件
      */
      */
@@ -405,7 +415,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         warning.setReviewer(reviewer);
         warning.setReviewer(reviewer);
 
 
         itemBinding.bind(warning);
         itemBinding.bind(warning);
-        warning.setStatus("PENDING");
+        warning.setStatus(EarlyWarningStatusEnum.PENDING.getCode());
         warning.setPublisher(currentUser());
         warning.setPublisher(currentUser());
         warning.setUpdateBy(currentUser());
         warning.setUpdateBy(currentUser());
         warning.setPublishTime(LocalDateTime.now());
         warning.setPublishTime(LocalDateTime.now());
@@ -437,7 +447,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         String operator = currentUser();
         String operator = currentUser();
         String assignee = warning.getHandler();
         String assignee = warning.getHandler();
         // 接收预警:待办(PENDING) → 已接收(RECEIVED)
         // 接收预警:待办(PENDING) → 已接收(RECEIVED)
-        warning.setStatus("RECEIVED");
+        warning.setStatus(EarlyWarningStatusEnum.RECEIVED.getCode());
         itemBinding.startStage(warning, "HANDLE", now);
         itemBinding.startStage(warning, "HANDLE", now);
         warning.setHandler(assignee);
         warning.setHandler(assignee);
         warning.setUpdateTime(now);
         warning.setUpdateTime(now);
@@ -455,7 +465,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         LocalDateTime now = LocalDateTime.now();
         LocalDateTime now = LocalDateTime.now();
         String operator = currentUser();
         String operator = currentUser();
         // 开始处置:已接收(RECEIVED) → 处置中(PROCESSING)
         // 开始处置:已接收(RECEIVED) → 处置中(PROCESSING)
-        warning.setStatus("PROCESSING");
+        warning.setStatus(EarlyWarningStatusEnum.PROCESSING.getCode());
         warning.setUpdateTime(now);
         warning.setUpdateTime(now);
         if (!updateById(warning)) return false;
         if (!updateById(warning)) return false;
         addDisposalRecord(warningId, "START", null, null, operator, remark);
         addDisposalRecord(warningId, "START", null, null, operator, remark);
@@ -484,7 +494,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (isBlank(processContent)) throw new ServiceException("请填写现场处置结果");
         if (isBlank(processContent)) throw new ServiceException("请填写现场处置结果");
         LocalDateTime now = LocalDateTime.now();
         LocalDateTime now = LocalDateTime.now();
         String operator = currentUser();
         String operator = currentUser();
-        warning.setStatus("HANDLED");
+        warning.setStatus(EarlyWarningStatusEnum.HANDLED.getCode());
         itemBinding.startStage(warning, "REVIEW", now);
         itemBinding.startStage(warning, "REVIEW", now);
         warning.setRemark(processContent);
         warning.setRemark(processContent);
         warning.setUpdateTime(now);
         warning.setUpdateTime(now);
@@ -534,7 +544,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (isBlank(clearRemark)) throw new ServiceException("请填写核查通过和解除依据");
         if (isBlank(clearRemark)) throw new ServiceException("请填写核查通过和解除依据");
         LocalDateTime now = LocalDateTime.now();
         LocalDateTime now = LocalDateTime.now();
         String operator = currentUser();
         String operator = currentUser();
-        warning.setStatus("CLOSED");
+        warning.setStatus(EarlyWarningStatusEnum.CLOSED.getCode());
         warning.setStageDeadline(null);
         warning.setStageDeadline(null);
         warning.setRemark(clearRemark);
         warning.setRemark(clearRemark);
         warning.setUpdateTime(now);
         warning.setUpdateTime(now);
@@ -651,7 +661,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         requireReviewer(warning);
         requireReviewer(warning);
         if (isBlank(disposalContent)) throw new ServiceException("请填写退回原因和重新处置要求");
         if (isBlank(disposalContent)) throw new ServiceException("请填写退回原因和重新处置要求");
         requireActiveUser(warning.getHandler(), "原处置人");
         requireActiveUser(warning.getHandler(), "原处置人");
-        warning.setStatus("PROCESSING");
+        warning.setStatus(EarlyWarningStatusEnum.PROCESSING.getCode());
         itemBinding.startStage(warning, "HANDLE", LocalDateTime.now());
         itemBinding.startStage(warning, "HANDLE", LocalDateTime.now());
         warning.setUpdateBy(currentUser());
         warning.setUpdateBy(currentUser());
         warning.setUpdateTime(LocalDateTime.now());
         warning.setUpdateTime(LocalDateTime.now());

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningItemServiceImpl.java

@@ -131,7 +131,7 @@ public class WarningItemServiceImpl extends ServiceImpl<WarningItemMapper, Warni
     @Override
     @Override
     public String generateItemCode(String warningType, String warningLevel) {
     public String generateItemCode(String warningType, String warningLevel) {
         catalogRules.validateTypeAndLevel(warningType, warningLevel);
         catalogRules.validateTypeAndLevel(warningType, warningLevel);
-        if (!warningType.matches("[A-Za-z0-9_]{1,32}")) throw new ServiceException("预警类型编码仅支持字母、数字和下划线");
+        //if (!warningType.matches("[A-Za-z0-9_]{1,32}")) throw new ServiceException("预警类型编码仅支持字母、数字和下划线");
         String dateStr = new SimpleDateFormat("yyyyMMdd").format(new Date());
         String dateStr = new SimpleDateFormat("yyyyMMdd").format(new Date());
         String prefix = "WI-" + warningType.toUpperCase(Locale.ROOT) + "-L" + warningLevel + "-";
         String prefix = "WI-" + warningType.toUpperCase(Locale.ROOT) + "-L" + warningLevel + "-";
         
         

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

@@ -0,0 +1,77 @@
+package com.zksy.cockpit.foundation;
+
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collection;
+
+/**
+ * 数据时间({@code createdAt})的单一口径来源:库列 {@code create_time} → API {@code createdAt}。
+ *
+ * <p>语义由决策票 03 与规格 08 固化:{@code createdAt}=<b>记录创建时间</b>,
+ * 既不是采样/上报时刻({@code sampledAt}),也不是状态更新时刻
+ * ({@code status_update_time})或响应生成时刻({@code serverUpdatedAt})。
+ *
+ * <p>空值一律显式 {@code null}:本类不读时钟,因此**结构上无法**用响应时间冒充数据时间。
+ */
+public final class CockpitCreatedAt {
+
+    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+
+    private CockpitCreatedAt() {
+    }
+
+    /** 来源表 {@code create_time} → 响应 {@code createdAt};缺值为 {@code null}。 */
+    public static String format(LocalDateTime value) {
+        return value == null
+                ? null
+                : FORMATTER.format(value.atZone(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    /**
+     * 跨来源取 max:异构来源(多张表、多种设备类型)统一成一个读数。
+     * 两边都缺为 {@code null};只有一边可解析时取那一边,不丢已拿到的真实时间。
+     */
+    public static String max(String left, String right) {
+        if (left == null) {
+            return right;
+        }
+        if (right == null) {
+            return left;
+        }
+        OffsetDateTime leftTime = parse(left);
+        OffsetDateTime rightTime = parse(right);
+        if (leftTime == null && rightTime == null) {
+            return left;
+        }
+        if (leftTime == null) {
+            return right;
+        }
+        if (rightTime == null) {
+            return left;
+        }
+        return rightTime.isAfter(leftTime) ? right : left;
+    }
+
+    /** 集合上的 {@link #max(String, String)};空集合为 {@code null}。 */
+    public static String max(Collection<String> values) {
+        String result = null;
+        if (values != null) {
+            for (String value : values) {
+                result = max(result, value);
+            }
+        }
+        return result;
+    }
+
+    private static OffsetDateTime parse(String value) {
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            return null;
+        }
+    }
+}

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

@@ -6,6 +6,7 @@ import com.zksy.base.domain.MonitorPoint;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
 import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
@@ -113,7 +114,9 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     point.getLongitude(),
                     point.getLongitude(),
                     point.getLatitude(),
                     point.getLatitude(),
                     false,
                     false,
-                    devices));
+                    devices,
+                    // 数据时间=点位自身记录创建时间(monitor_point.create_time)
+                    CockpitCreatedAt.format(point.getCreateTime())));
         }
         }
 
 
         List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
         List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
@@ -139,7 +142,9 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     fact.isWorkOrderInProgress(),
                     fact.isWorkOrderInProgress(),
                     fact.getStatusReasons(),
                     fact.getStatusReasons(),
                     fact.getStatusTime(),
                     fact.getStatusTime(),
-                    fact.isStatusPublished()));
+                    fact.isStatusPublished(),
+                    // 数据时间=设备自身记录创建时间(equipment_base.create_time)
+                    CockpitCreatedAt.format(device.getCreateTime())));
         }
         }
 
 
         List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
         List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()

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

@@ -8,6 +8,7 @@ import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
 import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
@@ -100,7 +101,8 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
             sourcePoints.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
             sourcePoints.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
                     pointCode,
                     pointCode,
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
-                    boundDeviceCodes.size()));
+                    boundDeviceCodes.size(),
+                    CockpitCreatedAt.format(point.getCreateTime())));
         }
         }
 
 
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();
@@ -127,7 +129,9 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
                             ? null
                             ? null
                             : TIME_FORMATTER.format(status.getStatusUpdateTime()
                             : TIME_FORMATTER.format(status.getStatusUpdateTime()
                                     .atZone(BUSINESS_ZONE).toOffsetDateTime()),
                                     .atZone(BUSINESS_ZONE).toOffsetDateTime()),
-                    pointCodeByDevice.get(deviceCode)));
+                    pointCodeByDevice.get(deviceCode),
+                    // 数据时间=记录创建时间(equipment_base.create_time),与 sampledAt 分离
+                    CockpitCreatedAt.format(device.getCreateTime())));
         }
         }
 
 
         List<CockpitMonitoringOverviewSnapshot.SourceType> sourceTypes = types.values().stream()
         List<CockpitMonitoringOverviewSnapshot.SourceType> sourceTypes = types.values().stream()

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

@@ -10,6 +10,7 @@ import com.zksy.base.service.EquipmentStatusService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
@@ -117,7 +118,9 @@ public class BusinessCockpitMonitoringSituationAdapter implements CockpitMonitor
                     fact.getStatusTime(),
                     fact.getStatusTime(),
                     device.getLongitude(),
                     device.getLongitude(),
                     device.getLatitude(),
                     device.getLatitude(),
-                    false));
+                    false,
+                    // 数据时间=记录创建时间(equipment_base.create_time),与 sampledAt 分离
+                    CockpitCreatedAt.format(device.getCreateTime())));
         }
         }
 
 
         List<CockpitMonitoringSituationSnapshot.SourceEvent> warnings = new ArrayList<>();
         List<CockpitMonitoringSituationSnapshot.SourceEvent> warnings = new ArrayList<>();

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

@@ -16,6 +16,11 @@ public class CockpitMonitoringDistributionResponse {
     private final String timezone;
     private final String timezone;
     private final Range range;
     private final Range range;
     private final String updatedAt;
     private final String updatedAt;
+    /**
+     * 数据时间=记录创建时间({@code create_time})跨台账对象取 max;
+     * 空态显式 {@code null}。与 {@code updatedAt}(快照加载时刻)语义不同,不互相冒充。
+     */
+    private final String createdAt;
     private final Freshness freshness;
     private final Freshness freshness;
     private final List<DistrictDistribution> districtDistribution;
     private final List<DistrictDistribution> districtDistribution;
     private final List<IndustryDistribution> industryDistribution;
     private final List<IndustryDistribution> industryDistribution;
@@ -35,6 +40,7 @@ public class CockpitMonitoringDistributionResponse {
             String timezone,
             String timezone,
             Range range,
             Range range,
             String updatedAt,
             String updatedAt,
+            String createdAt,
             Freshness freshness,
             Freshness freshness,
             List<DistrictDistribution> districtDistribution,
             List<DistrictDistribution> districtDistribution,
             List<IndustryDistribution> industryDistribution,
             List<IndustryDistribution> industryDistribution,
@@ -52,6 +58,7 @@ public class CockpitMonitoringDistributionResponse {
         this.timezone = timezone;
         this.timezone = timezone;
         this.range = range;
         this.range = range;
         this.updatedAt = updatedAt;
         this.updatedAt = updatedAt;
+        this.createdAt = createdAt;
         this.freshness = freshness;
         this.freshness = freshness;
         this.districtDistribution = districtDistribution;
         this.districtDistribution = districtDistribution;
         this.industryDistribution = industryDistribution;
         this.industryDistribution = industryDistribution;
@@ -71,6 +78,7 @@ public class CockpitMonitoringDistributionResponse {
     public String getTimezone() { return timezone; }
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
     public String getUpdatedAt() { return updatedAt; }
+    public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }
     public Freshness getFreshness() { return freshness; }
     public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
@@ -296,10 +304,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
         private final CockpitCoordinateValidation coordinateValidation;
+        /** 记录创建时间(点位={@code monitor_point.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public MonitoringPointMarker(String pointCode, String pointName, Integer district, String industry,
         public MonitoringPointMarker(String pointCode, String pointName, Integer district, String industry,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
-                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
+                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
+                String createdAt) {
             this.pointCode = pointCode;
             this.pointCode = pointCode;
             this.pointName = pointName;
             this.pointName = pointName;
             this.district = CockpitDistrict.codeOf(district);
             this.district = CockpitDistrict.codeOf(district);
@@ -311,6 +322,7 @@ public class CockpitMonitoringDistributionResponse {
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getPointCode() { return pointCode; }
         public String getPointCode() { return pointCode; }
@@ -324,6 +336,7 @@ public class CockpitMonitoringDistributionResponse {
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public String getCreatedAt() { return createdAt; }
     }
     }
 
 
     public static class UnassociatedEquipmentMarker {
     public static class UnassociatedEquipmentMarker {
@@ -338,10 +351,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
         private final CockpitCoordinateValidation coordinateValidation;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public UnassociatedEquipmentMarker(String deviceCode, String deviceName, Integer district, String industry,
         public UnassociatedEquipmentMarker(String deviceCode, String deviceName, Integer district, String industry,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
                 DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
-                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
+                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.district = CockpitDistrict.codeOf(district);
             this.district = CockpitDistrict.codeOf(district);
@@ -353,13 +369,13 @@ public class CockpitMonitoringDistributionResponse {
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceStatus() {
         public String getDeviceStatus() {
             return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
             return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
-        }
-        public String getDeviceName() { return deviceName; }
+        }        public String getDeviceName() { return deviceName; }
         public Integer getDistrict() { return district; }
         public Integer getDistrict() { return district; }
         public String getDistrictName() { return districtName; }
         public String getDistrictName() { return districtName; }
         public String getIndustry() { return industry; }
         public String getIndustry() { return industry; }
@@ -369,6 +385,7 @@ public class CockpitMonitoringDistributionResponse {
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public String getCreatedAt() { return createdAt; }
     }
     }
 
 
     public static class Excluded {
     public static class Excluded {

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

@@ -2,6 +2,7 @@ package com.zksy.cockpit.monitoring;
 
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
@@ -295,7 +296,8 @@ public class CockpitMonitoringDistributionService {
                         point.getLatitude(),
                         point.getLatitude(),
                         validation,
                         validation,
                         true,
                         true,
-                        codes(effective)));
+                        codes(effective),
+                        point.getCreatedAt()));
             }
             }
         }
         }
 
 
@@ -355,7 +357,8 @@ public class CockpitMonitoringDistributionService {
                         device.getLatitude(),
                         device.getLatitude(),
                         validation,
                         validation,
                         false,
                         false,
-                        Collections.emptyList()));
+                        Collections.emptyList(),
+                        device.getCreatedAt()));
             }
             }
         }
         }
 
 
@@ -407,6 +410,16 @@ public class CockpitMonitoringDistributionService {
 
 
         boolean hasResolved = total > 0;
         boolean hasResolved = total > 0;
         String updatedAt = isBlank(snapshot.getUpdatedAt()) ? businessTime() : snapshot.getUpdatedAt();
         String updatedAt = isBlank(snapshot.getUpdatedAt()) ? businessTime() : snapshot.getUpdatedAt();
+        // 数据时间=筛选后台账对象(点位 + 未关联设备)真实 create_time 的 max。
+        // 取分页前的全量台账(与三份分布同口径),故不随翻页跳动;空态显式 null。
+        List<String> createdAtValues = new ArrayList<>();
+        for (Marker marker : pointMarkers) {
+            createdAtValues.add(marker.createdAt);
+        }
+        for (Marker marker : deviceMarkers) {
+            createdAtValues.add(marker.createdAt);
+        }
+        String createdAt = CockpitCreatedAt.max(createdAtValues);
 
 
         return new CockpitMonitoringDistributionResponse(
         return new CockpitMonitoringDistributionResponse(
                 requestId,
                 requestId,
@@ -416,6 +429,7 @@ public class CockpitMonitoringDistributionService {
                 "Asia/Shanghai",
                 "Asia/Shanghai",
                 new CockpitMonitoringDistributionResponse.Range("CURRENT_BASELINE"),
                 new CockpitMonitoringDistributionResponse.Range("CURRENT_BASELINE"),
                 updatedAt,
                 updatedAt,
+                createdAt,
                 new CockpitMonitoringDistributionResponse.Freshness(
                 new CockpitMonitoringDistributionResponse.Freshness(
                         hasResolved ? CockpitFreshnessStatus.CURRENT.name()
                         hasResolved ? CockpitFreshnessStatus.CURRENT.name()
                                 : CockpitFreshnessStatus.EMPTY.name(),
                                 : CockpitFreshnessStatus.EMPTY.name(),
@@ -735,11 +749,13 @@ public class CockpitMonitoringDistributionService {
         private final CockpitCoordinateValidation coordinateValidation;
         private final CockpitCoordinateValidation coordinateValidation;
         private final boolean point;
         private final boolean point;
         private final List<String> deviceCodes;
         private final List<String> deviceCodes;
+        /** 记录创建时间(点位=monitor_point.create_time,设备=equipment_base.create_time)。 */
+        private final String createdAt;
 
 
         private Marker(String code, String name, Integer district, String industry, String typeCode, String typeName,
         private Marker(String code, String name, Integer district, String industry, String typeCode, String typeName,
                 CockpitMonitoringPointStatus status, List<String> statusReasons, BigDecimal longitude,
                 CockpitMonitoringPointStatus status, List<String> statusReasons, BigDecimal longitude,
                 BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
                 BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
-                List<String> deviceCodes) {
+                List<String> deviceCodes, String createdAt) {
             this.code = code;
             this.code = code;
             this.name = name;
             this.name = name;
             this.district = district;
             this.district = district;
@@ -753,6 +769,7 @@ public class CockpitMonitoringDistributionService {
             this.coordinateValidation = coordinateValidation;
             this.coordinateValidation = coordinateValidation;
             this.point = point;
             this.point = point;
             this.deviceCodes = deviceCodes;
             this.deviceCodes = deviceCodes;
+            this.createdAt = createdAt;
         }
         }
 
 
         private String getCode() {
         private String getCode() {
@@ -763,14 +780,14 @@ public class CockpitMonitoringDistributionService {
             return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
             return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
                     code, name, district, industry,
                     code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
-                    status, statusReasons, longitude, latitude, coordinateValidation);
+                    status, statusReasons, longitude, latitude, coordinateValidation, createdAt);
         }
         }
 
 
         private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
         private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
             return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
             return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
                     code, name, district, industry,
                     code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
-                    status, statusReasons, longitude, latitude, coordinateValidation);
+                    status, statusReasons, longitude, latitude, coordinateValidation, createdAt);
         }
         }
     }
     }
 
 

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

@@ -105,6 +105,8 @@ public class CockpitMonitoringDistributionSnapshot {
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         private final boolean coordinateVerified;
         private final List<CockpitMonitoringPointDeviceSnapshot> devices;
         private final List<CockpitMonitoringPointDeviceSnapshot> devices;
+        /** 记录创建时间({@code monitor_point.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public SourceMonitoringPoint(
         public SourceMonitoringPoint(
                 String pointCode,
                 String pointCode,
@@ -115,6 +117,20 @@ public class CockpitMonitoringDistributionSnapshot {
                 BigDecimal latitude,
                 BigDecimal latitude,
                 boolean coordinateVerified,
                 boolean coordinateVerified,
                 List<CockpitMonitoringPointDeviceSnapshot> devices) {
                 List<CockpitMonitoringPointDeviceSnapshot> devices) {
+            this(pointCode, pointName, district, typeCode, longitude, latitude,
+                    coordinateVerified, devices, null);
+        }
+
+        public SourceMonitoringPoint(
+                String pointCode,
+                String pointName,
+                Integer district,
+                String typeCode,
+                BigDecimal longitude,
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                List<CockpitMonitoringPointDeviceSnapshot> devices,
+                String createdAt) {
             this.pointCode = pointCode;
             this.pointCode = pointCode;
             this.pointName = pointName;
             this.pointName = pointName;
             this.district = district;
             this.district = district;
@@ -123,6 +139,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
             this.coordinateVerified = coordinateVerified;
             this.devices = devices;
             this.devices = devices;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getPointCode() {
         public String getPointCode() {
@@ -157,6 +174,10 @@ public class CockpitMonitoringDistributionSnapshot {
         public List<CockpitMonitoringPointDeviceSnapshot> getDevices() {
         public List<CockpitMonitoringPointDeviceSnapshot> getDevices() {
             return devices;
             return devices;
         }
         }
+
+        public String getCreatedAt() {
+            return createdAt;
+        }
     }
     }
 
 
     public static class SourceSmartDevice {
     public static class SourceSmartDevice {
@@ -174,6 +195,8 @@ public class CockpitMonitoringDistributionSnapshot {
         private final List<String> statusReasons;
         private final List<String> statusReasons;
         private final String statusTime;
         private final String statusTime;
         private final boolean statusPublished;
         private final boolean statusPublished;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public SourceSmartDevice(
         public SourceSmartDevice(
                 String deviceCode,
                 String deviceCode,
@@ -197,6 +220,15 @@ public class CockpitMonitoringDistributionSnapshot {
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
                 boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
                 boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
                 List<String> statusReasons, String statusTime, boolean statusPublished) {
                 List<String> statusReasons, String statusTime, boolean statusPublished) {
+            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified,
+                    online, alarm, warning, workOrderInProgress, statusReasons, statusTime,
+                    statusPublished, null);
+        }
+
+        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
+                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
+                boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
+                List<String> statusReasons, String statusTime, boolean statusPublished, String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.district = district;
             this.district = district;
@@ -211,6 +243,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.statusReasons = statusReasons;
             this.statusReasons = statusReasons;
             this.statusTime = statusTime;
             this.statusTime = statusTime;
             this.statusPublished = statusPublished;
             this.statusPublished = statusPublished;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getDeviceCode() {
         public String getDeviceCode() {
@@ -267,5 +300,9 @@ public class CockpitMonitoringDistributionSnapshot {
         public String getStatusTime() {
         public String getStatusTime() {
             return statusTime;
             return statusTime;
         }
         }
+
+        public String getCreatedAt() {
+            return createdAt;
+        }
     }
     }
 }
 }

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

@@ -12,6 +12,11 @@ public class CockpitMonitoringOverviewResponse {
     private final String timezone;
     private final String timezone;
     private final Range range;
     private final Range range;
     private final String updatedAt;
     private final String updatedAt;
+    /**
+     * 数据时间=记录创建时间({@code create_time}),跨聚合源类型取 max;
+     * 空态显式 {@code null}。与 {@code updatedAt}(快照加载时刻)语义不同,不互相冒充。
+     */
+    private final String createdAt;
     private final Freshness freshness;
     private final Freshness freshness;
     private final Counts counts;
     private final Counts counts;
     private final Status status;
     private final Status status;
@@ -28,6 +33,7 @@ public class CockpitMonitoringOverviewResponse {
             String timezone,
             String timezone,
             Range range,
             Range range,
             String updatedAt,
             String updatedAt,
+            String createdAt,
             Freshness freshness,
             Freshness freshness,
             Counts counts,
             Counts counts,
             Status status,
             Status status,
@@ -42,6 +48,7 @@ public class CockpitMonitoringOverviewResponse {
         this.timezone = timezone;
         this.timezone = timezone;
         this.range = range;
         this.range = range;
         this.updatedAt = updatedAt;
         this.updatedAt = updatedAt;
+        this.createdAt = createdAt;
         this.freshness = freshness;
         this.freshness = freshness;
         this.counts = counts;
         this.counts = counts;
         this.status = status;
         this.status = status;
@@ -58,6 +65,7 @@ public class CockpitMonitoringOverviewResponse {
     public String getTimezone() { return timezone; }
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
     public String getUpdatedAt() { return updatedAt; }
+    public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }
     public Freshness getFreshness() { return freshness; }
     public Counts getCounts() { return counts; }
     public Counts getCounts() { return counts; }
     public Status getStatus() { return status; }
     public Status getStatus() { return status; }

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

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.monitoring;
 package com.zksy.cockpit.monitoring;
 
 
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -199,6 +200,9 @@ public class CockpitMonitoringOverviewService {
         String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
         String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
                 ? businessTime()
                 ? businessTime()
                 : snapshot.getUpdatedAt();
                 : snapshot.getUpdatedAt();
+        // 数据时间=范围内真实记录 create_time 跨类型 max(设备表 + 点位表);
+        // 与 updatedAt(快照加载时刻)、freshness.serverUpdatedAt(响应生成时刻)语义分离。
+        String createdAt = CockpitCreatedAt.max(selectedCreatedAtValues(snapshot, selectedDevices, selectedPointCodes));
 
 
         List<CockpitMonitoringOverviewResponse.TypeDistribution> typeDistribution = new ArrayList<>();
         List<CockpitMonitoringOverviewResponse.TypeDistribution> typeDistribution = new ArrayList<>();
         for (TypeAccumulator accumulator : typeAccumulators.values()) {
         for (TypeAccumulator accumulator : typeAccumulators.values()) {
@@ -220,6 +224,7 @@ public class CockpitMonitoringOverviewService {
                 "Asia/Shanghai",
                 "Asia/Shanghai",
                 new CockpitMonitoringOverviewResponse.Range("CURRENT_BASELINE"),
                 new CockpitMonitoringOverviewResponse.Range("CURRENT_BASELINE"),
                 updatedAt,
                 updatedAt,
+                createdAt,
                 new CockpitMonitoringOverviewResponse.Freshness(
                 new CockpitMonitoringOverviewResponse.Freshness(
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                 new CockpitMonitoringOverviewResponse.Counts(
                 new CockpitMonitoringOverviewResponse.Counts(
@@ -261,6 +266,31 @@ public class CockpitMonitoringOverviewService {
         return null;
         return null;
     }
     }
 
 
+    /**
+     * 数据时间的取值域=**筛选后**参与本响应的对象(设备 + 点位)各自来源表的 {@code create_time}。
+     * 空态(无对象或均无 create_time)返回空集合 → {@code createdAt} 显式 {@code null}。
+     */
+    private static List<String> selectedCreatedAtValues(
+            CockpitMonitoringOverviewSnapshot snapshot,
+            List<CockpitMonitoringOverviewSnapshot.SourceDevice> selectedDevices,
+            Set<String> selectedPointCodes) {
+        List<String> values = new ArrayList<>();
+        for (CockpitMonitoringOverviewSnapshot.SourceDevice device : selectedDevices) {
+            if (device != null) {
+                values.add(device.getCreatedAt());
+            }
+        }
+        if (snapshot != null && snapshot.getMonitoringPoints() != null) {
+            for (CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
+                if (point != null && !isBlank(point.getPointCode())
+                        && selectedPointCodes.contains(point.getPointCode().trim())) {
+                    values.add(point.getCreatedAt());
+                }
+            }
+        }
+        return values;
+    }
+
     private static Set<String> selectMonitoringPointCodes(
     private static Set<String> selectMonitoringPointCodes(
             CockpitMonitoringOverviewSnapshot snapshot,
             CockpitMonitoringOverviewSnapshot snapshot,
             String selectedIndustry,
             String selectedIndustry,

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

@@ -49,6 +49,8 @@ public class CockpitMonitoringOverviewSnapshot {
         private final String monitoringState;
         private final String monitoringState;
         private final String sampledAt;
         private final String sampledAt;
         private final String pointCode;
         private final String pointCode;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public SourceDevice(
         public SourceDevice(
                 String deviceCode,
                 String deviceCode,
@@ -56,11 +58,22 @@ public class CockpitMonitoringOverviewSnapshot {
                 String monitoringState,
                 String monitoringState,
                 String sampledAt,
                 String sampledAt,
                 String pointCode) {
                 String pointCode) {
+            this(deviceCode, typeCode, monitoringState, sampledAt, pointCode, null);
+        }
+
+        public SourceDevice(
+                String deviceCode,
+                String typeCode,
+                String monitoringState,
+                String sampledAt,
+                String pointCode,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.monitoringState = monitoringState;
             this.monitoringState = monitoringState;
             this.sampledAt = sampledAt;
             this.sampledAt = sampledAt;
             this.pointCode = pointCode;
             this.pointCode = pointCode;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
@@ -68,6 +81,7 @@ public class CockpitMonitoringOverviewSnapshot {
         public String getMonitoringState() { return monitoringState; }
         public String getMonitoringState() { return monitoringState; }
         public String getSampledAt() { return sampledAt; }
         public String getSampledAt() { return sampledAt; }
         public String getPointCode() { return pointCode; }
         public String getPointCode() { return pointCode; }
+        public String getCreatedAt() { return createdAt; }
     }
     }
 
 
     public static class SourceMonitoringPoint {
     public static class SourceMonitoringPoint {
@@ -75,19 +89,27 @@ public class CockpitMonitoringOverviewSnapshot {
         private final String typeCode;
         private final String typeCode;
         /** -1 means an older adapter did not publish the association fact. */
         /** -1 means an older adapter did not publish the association fact. */
         private final int associatedDeviceCount;
         private final int associatedDeviceCount;
+        /** 记录创建时间({@code monitor_point.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public SourceMonitoringPoint(String pointCode, String typeCode) {
         public SourceMonitoringPoint(String pointCode, String typeCode) {
             this(pointCode, typeCode, -1);
             this(pointCode, typeCode, -1);
         }
         }
 
 
         public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount) {
         public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount) {
+            this(pointCode, typeCode, associatedDeviceCount, null);
+        }
+
+        public SourceMonitoringPoint(String pointCode, String typeCode, int associatedDeviceCount, String createdAt) {
             this.pointCode = pointCode;
             this.pointCode = pointCode;
             this.typeCode = typeCode;
             this.typeCode = typeCode;
             this.associatedDeviceCount = associatedDeviceCount;
             this.associatedDeviceCount = associatedDeviceCount;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getPointCode() { return pointCode; }
         public String getPointCode() { return pointCode; }
         public String getTypeCode() { return typeCode; }
         public String getTypeCode() { return typeCode; }
         public int getAssociatedDeviceCount() { return associatedDeviceCount; }
         public int getAssociatedDeviceCount() { return associatedDeviceCount; }
+        public String getCreatedAt() { return createdAt; }
     }
     }
 }
 }

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

@@ -14,6 +14,11 @@ public class CockpitMonitoringSituationResponse {
     private final String timezone;
     private final String timezone;
     private final Range range;
     private final Range range;
     private final String updatedAt;
     private final String updatedAt;
+    /**
+     * 数据时间=异常设备来源表 {@code create_time} 的 max(与逐对象 {@code createdAt} 同源可复算);
+     * 空态显式 {@code null}。事件时间由趋势桶 {@code bucketStart} 承载,不并入本读数。
+     */
+    private final String createdAt;
     private final Freshness freshness;
     private final Freshness freshness;
     private final List<MetricStatusCount> metricStatusDistribution;
     private final List<MetricStatusCount> metricStatusDistribution;
     private final List<TrendPoint> warningTrend;
     private final List<TrendPoint> warningTrend;
@@ -32,6 +37,7 @@ public class CockpitMonitoringSituationResponse {
             String timezone,
             String timezone,
             Range range,
             Range range,
             String updatedAt,
             String updatedAt,
+            String createdAt,
             Freshness freshness,
             Freshness freshness,
             List<MetricStatusCount> metricStatusDistribution,
             List<MetricStatusCount> metricStatusDistribution,
             List<TrendPoint> warningTrend,
             List<TrendPoint> warningTrend,
@@ -48,6 +54,7 @@ public class CockpitMonitoringSituationResponse {
         this.timezone = timezone;
         this.timezone = timezone;
         this.range = range;
         this.range = range;
         this.updatedAt = updatedAt;
         this.updatedAt = updatedAt;
+        this.createdAt = createdAt;
         this.freshness = freshness;
         this.freshness = freshness;
         this.metricStatusDistribution = metricStatusDistribution;
         this.metricStatusDistribution = metricStatusDistribution;
         this.warningTrend = warningTrend;
         this.warningTrend = warningTrend;
@@ -66,6 +73,7 @@ public class CockpitMonitoringSituationResponse {
     public String getTimezone() { return timezone; }
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
     public String getUpdatedAt() { return updatedAt; }
+    public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }
     public Freshness getFreshness() { return freshness; }
     public List<MetricStatusCount> getMetricStatusDistribution() { return metricStatusDistribution; }
     public List<MetricStatusCount> getMetricStatusDistribution() { return metricStatusDistribution; }
     public List<TrendPoint> getWarningTrend() { return warningTrend; }
     public List<TrendPoint> getWarningTrend() { return warningTrend; }
@@ -194,6 +202,8 @@ public class CockpitMonitoringSituationResponse {
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
         private final CockpitCoordinateValidation coordinateValidation;
+        /** 记录创建时间(来源表 {@code create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public AbnormalEquipment(
         public AbnormalEquipment(
                 String deviceCode,
                 String deviceCode,
@@ -206,7 +216,8 @@ public class CockpitMonitoringSituationResponse {
                 List<String> statusReasons,
                 List<String> statusReasons,
                 BigDecimal longitude,
                 BigDecimal longitude,
                 BigDecimal latitude,
                 BigDecimal latitude,
-                CockpitCoordinateValidation coordinateValidation) {
+                CockpitCoordinateValidation coordinateValidation,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.deviceType = deviceType;
             this.deviceType = deviceType;
@@ -220,6 +231,7 @@ public class CockpitMonitoringSituationResponse {
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
@@ -235,6 +247,7 @@ public class CockpitMonitoringSituationResponse {
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public BigDecimal getLatitude() { return latitude; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public String getCreatedAt() { return createdAt; }
     }
     }
 
 
     public static class DeviceType {
     public static class DeviceType {

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

@@ -2,6 +2,7 @@ package com.zksy.cockpit.monitoring;
 
 
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
 import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
@@ -228,7 +229,8 @@ public class CockpitMonitoringSituationService {
                             device.getStatusReasons(),
                             device.getStatusReasons(),
                             device.getLongitude(),
                             device.getLongitude(),
                             device.getLatitude(),
                             device.getLatitude(),
-                            validation));
+                            validation,
+                            device.getCreatedAt()));
                     if (validation.isRenderable()) {
                     if (validation.isRenderable()) {
                         mapDeviceCodes.add(device.getDeviceCode().trim());
                         mapDeviceCodes.add(device.getDeviceCode().trim());
                     } else {
                     } else {
@@ -286,6 +288,14 @@ public class CockpitMonitoringSituationService {
         String updatedAt = isBlank(snapshot.getUpdatedAt())
         String updatedAt = isBlank(snapshot.getUpdatedAt())
                 ? businessTime()
                 ? businessTime()
                 : snapshot.getUpdatedAt();
                 : snapshot.getUpdatedAt();
+        // 数据时间=异常设备来源表 create_time 的 max(与逐对象 createdAt 同源可复算)。
+        // 取分页前的异常全集,故不随翻页跳动;无异常设备时显式 null。
+        // 事件时间不并入本读数:预警/报警趋势仍由各自 bucketStart 承载。
+        List<String> createdAtValues = new ArrayList<>();
+        for (AbnormalRow row : abnormal) {
+            createdAtValues.add(row.createdAt);
+        }
+        String createdAt = CockpitCreatedAt.max(createdAtValues);
 
 
         return new CockpitMonitoringSituationResponse(
         return new CockpitMonitoringSituationResponse(
                 requestId,
                 requestId,
@@ -298,6 +308,7 @@ public class CockpitMonitoringSituationService {
                         timestamp(window.start),
                         timestamp(window.start),
                         timestamp(window.end)),
                         timestamp(window.end)),
                 updatedAt,
                 updatedAt,
+                createdAt,
                 new CockpitMonitoringSituationResponse.Freshness(
                 new CockpitMonitoringSituationResponse.Freshness(
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                 metricStatusDistribution,
                 metricStatusDistribution,
@@ -542,6 +553,8 @@ public class CockpitMonitoringSituationService {
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
         private final CockpitCoordinateValidation coordinateValidation;
+        /** 记录创建时间(equipment_base.create_time);缺值为 null。 */
+        private final String createdAt;
 
 
         private AbnormalRow(
         private AbnormalRow(
                 String deviceCode,
                 String deviceCode,
@@ -552,7 +565,8 @@ public class CockpitMonitoringSituationService {
                 List<String> statusReasons,
                 List<String> statusReasons,
                 BigDecimal longitude,
                 BigDecimal longitude,
                 BigDecimal latitude,
                 BigDecimal latitude,
-                CockpitCoordinateValidation coordinateValidation) {
+                CockpitCoordinateValidation coordinateValidation,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.type = type;
             this.type = type;
@@ -562,6 +576,7 @@ public class CockpitMonitoringSituationService {
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
         }
 
 
         private String getDeviceCode() {
         private String getDeviceCode() {
@@ -581,7 +596,8 @@ public class CockpitMonitoringSituationService {
                     statusReasons == null ? Collections.emptyList() : statusReasons,
                     statusReasons == null ? Collections.emptyList() : statusReasons,
                     longitude,
                     longitude,
                     latitude,
                     latitude,
-                    coordinateValidation);
+                    coordinateValidation,
+                    createdAt);
         }
         }
     }
     }
 
 

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

@@ -101,6 +101,8 @@ public class CockpitMonitoringSituationSnapshot {
         private final BigDecimal longitude;
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final BigDecimal latitude;
         private final boolean coordinateVerified;
         private final boolean coordinateVerified;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
 
         public SourceMetricDevice(
         public SourceMetricDevice(
                 String deviceCode,
                 String deviceCode,
@@ -113,6 +115,22 @@ public class CockpitMonitoringSituationSnapshot {
                 BigDecimal longitude,
                 BigDecimal longitude,
                 BigDecimal latitude,
                 BigDecimal latitude,
                 boolean coordinateVerified) {
                 boolean coordinateVerified) {
+            this(deviceCode, deviceName, district, typeCode, metricStatus, statusReasons, sampledAt,
+                    longitude, latitude, coordinateVerified, null);
+        }
+
+        public SourceMetricDevice(
+                String deviceCode,
+                String deviceName,
+                Integer district,
+                String typeCode,
+                String metricStatus,
+                List<String> statusReasons,
+                String sampledAt,
+                BigDecimal longitude,
+                BigDecimal latitude,
+                boolean coordinateVerified,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceName = deviceName;
             this.district = district;
             this.district = district;
@@ -123,6 +141,7 @@ public class CockpitMonitoringSituationSnapshot {
             this.longitude = longitude;
             this.longitude = longitude;
             this.latitude = latitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
             this.coordinateVerified = coordinateVerified;
+            this.createdAt = createdAt;
         }
         }
 
 
         public String getDeviceCode() {
         public String getDeviceCode() {
@@ -165,6 +184,10 @@ public class CockpitMonitoringSituationSnapshot {
         public boolean isCoordinateVerified() {
         public boolean isCoordinateVerified() {
             return coordinateVerified;
             return coordinateVerified;
         }
         }
+
+        public String getCreatedAt() {
+            return createdAt;
+        }
     }
     }
 
 
     public static class SourceEvent {
     public static class SourceEvent {

+ 133 - 84
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/BusinessCockpitOverviewWarningSettingsAdapter.java

@@ -3,10 +3,16 @@ package com.zksy.cockpit.overview;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentTypeService;
 import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.AlarmWarningRel;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.ops.service.WorkOrderService;
 import org.springframework.context.annotation.Primary;
 import org.springframework.context.annotation.Primary;
@@ -28,10 +34,16 @@ import java.util.stream.Collectors;
 /**
 /**
  * 总体概览-预警设置 真实数据适配器(BT-5)。
  * 总体概览-预警设置 真实数据适配器(BT-5)。
  *
  *
- * <p>报警事件来自 {@code alarm_data}(经 {@code work_order_id → work_order.order_id} 关联工单);
- * 预警事件来自 {@code early_warning},工单经
- * {@code alarm_warning_rel.warning_id → alarm_data.work_order_id → work_order} 桥接(README 冻结关联)。
- * 趋势桶按业务时区日期({@code yyyy-MM-dd})聚合,已处理 = 关联工单已结案(状态 5)。
+ * <p><b>口径(维护者 2026-10-14 裁定)</b>:预警不走工单流程,故预警事件**不再经工单桥接**,
+ * 只带 {@code early_warning} 自身生命周期({@code status} / {@code warning_level});
+ * 报警保留工单回联(报警靠工单闭环),并额外带 {@code alarm_status} / {@code alarm_level}。
+ *
+ * <p>两族都沿设备解析**片区**({@code equipment_base.district})与**行业**
+ * ({@code equipment_type} 树上溯根),解析不出时如实留空、不冒充 ——
+ * 这两项服务层拿不到设备台账,必须在此带入快照。
+ *
+ * <p>趋势桶按业务时区日期({@code yyyy-MM-dd})聚合。{@code handled} 的含义**按族不同**:
+ * 预警=该桶内已解除({@code CLOSED});报警=该桶内关联工单已结案(状态 5)。
  * {@code updatedAt} 取事件与工单的最新时间。
  * {@code updatedAt} 取事件与工单的最新时间。
  */
  */
 @Component
 @Component
@@ -43,21 +55,28 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
     private static final DateTimeFormatter BUCKET_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final DateTimeFormatter BUCKET_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final int WORK_ORDER_HANDLED = 5;
     private static final int WORK_ORDER_HANDLED = 5;
+    private static final String WARNING_CLOSED = "CLOSED";
 
 
     private final EarlyWarningService earlyWarningService;
     private final EarlyWarningService earlyWarningService;
     private final AlarmDataService alarmDataService;
     private final AlarmDataService alarmDataService;
     private final AlarmWarningRelMapper alarmWarningRelMapper;
     private final AlarmWarningRelMapper alarmWarningRelMapper;
     private final WorkOrderService workOrderService;
     private final WorkOrderService workOrderService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
 
 
     public BusinessCockpitOverviewWarningSettingsAdapter(
     public BusinessCockpitOverviewWarningSettingsAdapter(
             EarlyWarningService earlyWarningService,
             EarlyWarningService earlyWarningService,
             AlarmDataService alarmDataService,
             AlarmDataService alarmDataService,
             AlarmWarningRelMapper alarmWarningRelMapper,
             AlarmWarningRelMapper alarmWarningRelMapper,
-            WorkOrderService workOrderService) {
+            WorkOrderService workOrderService,
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService) {
         this.earlyWarningService = earlyWarningService;
         this.earlyWarningService = earlyWarningService;
         this.alarmDataService = alarmDataService;
         this.alarmDataService = alarmDataService;
         this.alarmWarningRelMapper = alarmWarningRelMapper;
         this.alarmWarningRelMapper = alarmWarningRelMapper;
         this.workOrderService = workOrderService;
         this.workOrderService = workOrderService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
     }
     }
 
 
     @Override
     @Override
@@ -74,23 +93,9 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             }
         }
         }
 
 
-        Map<String, List<String>> warningNosByAlarmId = new HashMap<>();
-        Map<String, String> warningNoById = new HashMap<>();
-        for (EarlyWarning warning : warnings) {
-            if (warning == null || isBlank(warning.getWarningId()) || isBlank(warning.getWarningNo())) {
-                continue;
-            }
-            warningNoById.put(warning.getWarningId(), warning.getWarningNo().trim());
-        }
-        for (AlarmWarningRel relation : relations) {
-            if (relation == null || isBlank(relation.getAlarmId())) {
-                continue;
-            }
-            String warningNo = warningNoById.get(relation.getWarningId());
-            if (warningNo != null) {
-                warningNosByAlarmId.computeIfAbsent(relation.getAlarmId(), key -> new ArrayList<>()).add(warningNo);
-            }
-        }
+        // 设备与类型索引:片区/行业只能沿设备解析,服务层拿不到台账。
+        Map<String, EquipmentBase> deviceByCode = deviceIndex();
+        Map<Integer, EquipmentType> typesById = typeIndex();
 
 
         List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events = new ArrayList<>();
         List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events = new ArrayList<>();
         LocalDateTime updatedAt = null;
         LocalDateTime updatedAt = null;
@@ -111,7 +116,11 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
                     alarm.getId().trim(),
                     alarm.getId().trim(),
                     trimToNull(alarm.getDeviceCode()),
                     trimToNull(alarm.getDeviceCode()),
                     order == null ? null : order.getOrderNo(),
                     order == null ? null : order.getOrderNo(),
-                    order == null || order.getOrderStatus() == null ? null : order.getOrderStatus()));
+                    order == null || order.getOrderStatus() == null ? null : order.getOrderStatus(),
+                    alarmStatus(alarm.getAlarmStatus()),
+                    alarm.getAlarmLevel(),
+                    districtCode(alarm.getDeviceCode(), deviceByCode),
+                    industryCode(alarm.getDeviceCode(), deviceByCode, typesById)));
         }
         }
 
 
         for (EarlyWarning warning : warnings) {
         for (EarlyWarning warning : warnings) {
@@ -120,48 +129,33 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             }
             updatedAt = latest(updatedAt, warning.getUpdateTime());
             updatedAt = latest(updatedAt, warning.getUpdateTime());
             updatedAt = latest(updatedAt, warning.getPublishTime());
             updatedAt = latest(updatedAt, warning.getPublishTime());
-            List<String> linkedAlarmIds = new ArrayList<>();
-            for (AlarmWarningRel relation : relations) {
-                if (relation != null && warning.getWarningId() != null
-                        && warning.getWarningId().equals(relation.getWarningId())) {
-                    linkedAlarmIds.add(relation.getAlarmId());
-                }
-            }
-            WorkOrder order = null;
-            for (String alarmId : linkedAlarmIds) {
-                AlarmData alarm = alarms.stream()
-                        .filter(candidate -> candidate != null && alarmId != null && alarmId.equals(candidate.getId()))
-                        .findFirst().orElse(null);
-                if (alarm != null && alarm.getWorkOrderId() != null) {
-                    order = orderByOrderId.get(alarm.getWorkOrderId());
-                    if (order != null) {
-                        break;
-                    }
-                }
-            }
-            if (order != null) {
-                updatedAt = latest(updatedAt, toLocal(order.getUpdateTime()));
-                updatedAt = latest(updatedAt, toLocal(order.getCreateTime()));
-            }
+            // 预警不走工单流程(维护者 2026-10-14 裁定):不再桥接工单,
+            // 只带自身生命周期与维度;趋势 handled 也改按预警自身 CLOSED 判定。
             events.add(new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
             events.add(new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
                     "WARNING",
                     "WARNING",
                     warning.getWarningNo().trim(),
                     warning.getWarningNo().trim(),
                     trimToNull(warning.getDeviceCode()),
                     trimToNull(warning.getDeviceCode()),
-                    order == null ? null : order.getOrderNo(),
-                    order == null || order.getOrderStatus() == null ? null : order.getOrderStatus()));
+                    null,
+                    null,
+                    trimToNull(warning.getStatus()),
+                    warningLevel(warning.getWarningLevel()),
+                    districtCode(warning.getDeviceCode(), deviceByCode),
+                    industryCode(warning.getDeviceCode(), deviceByCode, typesById)));
         }
         }
 
 
         List<CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint> trendPoints = new ArrayList<>();
         List<CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint> trendPoints = new ArrayList<>();
-        addBucket(trendPoints, "WARNING", bucketFor(warnings, alarms, warningNosByAlarmId, orderByOrderId));
-        addBucket(trendPoints, "ALARM", bucketFor(alarms, orderByOrderId));
+        addBuckets(trendPoints, "WARNING", warningBuckets(warnings));
+        addBuckets(trendPoints, "ALARM", alarmBuckets(alarms, orderByOrderId));
 
 
         return new CockpitOverviewWarningSettingsSnapshot(
         return new CockpitOverviewWarningSettingsSnapshot(
                 offsetTime(updatedAt), events, trendPoints, scopeWorkOrders(orders));
                 offsetTime(updatedAt), events, trendPoints, scopeWorkOrders(orders));
     }
     }
 
 
     /**
     /**
-     * 范围内**全部工单**(决策票 06):处置率分母要的是工单事实,不要求事件回联。
+     * 范围内**全部工单**(决策票 06):**报警族**处置率分母要的是工单事实,不要求事件回联。
      * 状态码原样透出(是否计入分母由服务层的 0–6 口径判定)。
      * 状态码原样透出(是否计入分母由服务层的 0–6 口径判定)。
+     *
+     * <p>维护者 2026-10-14 裁定后,本读数只服务报警族。
      */
      */
     private static List<CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder> scopeWorkOrders(
     private static List<CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder> scopeWorkOrders(
             List<WorkOrder> orders) {
             List<WorkOrder> orders) {
@@ -177,11 +171,11 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         return result;
         return result;
     }
     }
 
 
-    private Map<String, Counter> bucketFor(
-            List<EarlyWarning> warnings,
-            List<AlarmData> alarms,
-            Map<String, List<String>> warningNosByAlarmId,
-            Map<Long, WorkOrder> orderByOrderId) {
+    /**
+     * 预警趋势桶:按发布时间分桶,{@code handled}=该桶内**已解除**的预警数
+     * (预警自身生命周期,不依赖工单)。
+     */
+    private Map<String, Counter> warningBuckets(List<EarlyWarning> warnings) {
         Map<String, Counter> buckets = new LinkedHashMap<>();
         Map<String, Counter> buckets = new LinkedHashMap<>();
         for (EarlyWarning warning : warnings) {
         for (EarlyWarning warning : warnings) {
             if (warning == null || isBlank(warning.getWarningNo())) {
             if (warning == null || isBlank(warning.getWarningNo())) {
@@ -193,14 +187,15 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             }
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             counter.total++;
             counter.total++;
-            if (isHandledWarning(warning, warningNosByAlarmId, alarms, orderByOrderId)) {
+            if (WARNING_CLOSED.equals(normalize(warning.getStatus()))) {
                 counter.handled++;
                 counter.handled++;
             }
             }
         }
         }
         return buckets;
         return buckets;
     }
     }
 
 
-    private Map<String, Counter> bucketFor(List<AlarmData> alarms, Map<Long, WorkOrder> orderByOrderId) {
+    /** 报警趋势桶:按更新时间分桶,{@code handled}=该桶内关联工单已结案的报警数(工单口径)。 */
+    private Map<String, Counter> alarmBuckets(List<AlarmData> alarms, Map<Long, WorkOrder> orderByOrderId) {
         Map<String, Counter> buckets = new LinkedHashMap<>();
         Map<String, Counter> buckets = new LinkedHashMap<>();
         for (AlarmData alarm : alarms) {
         for (AlarmData alarm : alarms) {
             if (alarm == null || isBlank(alarm.getId())) {
             if (alarm == null || isBlank(alarm.getId())) {
@@ -213,37 +208,14 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             counter.total++;
             counter.total++;
             WorkOrder order = alarm.getWorkOrderId() == null ? null : orderByOrderId.get(alarm.getWorkOrderId());
             WorkOrder order = alarm.getWorkOrderId() == null ? null : orderByOrderId.get(alarm.getWorkOrderId());
-            if (order != null && WORK_ORDER_HANDLED == order.getOrderStatus()) {
+            if (order != null && Integer.valueOf(WORK_ORDER_HANDLED).equals(order.getOrderStatus())) {
                 counter.handled++;
                 counter.handled++;
             }
             }
         }
         }
         return buckets;
         return buckets;
     }
     }
 
 
-    private boolean isHandledWarning(
-            EarlyWarning warning,
-            Map<String, List<String>> warningNosByAlarmId,
-            List<AlarmData> alarms,
-            Map<Long, WorkOrder> orderByOrderId) {
-        if (warning.getWarningId() == null) {
-            return false;
-        }
-        for (AlarmData alarm : alarms) {
-            if (alarm == null || alarm.getWorkOrderId() == null) {
-                continue;
-            }
-            List<String> linked = warningNosByAlarmId.get(alarm.getId());
-            if (linked != null && linked.contains(warning.getWarningNo().trim())) {
-                WorkOrder order = orderByOrderId.get(alarm.getWorkOrderId());
-                if (order != null && WORK_ORDER_HANDLED == order.getOrderStatus()) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    private void addBucket(
+    private void addBuckets(
             List<CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint> trendPoints,
             List<CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint> trendPoints,
             String family,
             String family,
             Map<String, Counter> buckets) {
             Map<String, Counter> buckets) {
@@ -253,6 +225,83 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         }
         }
     }
     }
 
 
+    /** 报警自身生命周期:{@code alarm_status} 0 未处理 / 1 已处理;其余回 {@code null}(服务层进未知桶)。 */
+    private static String alarmStatus(Integer status) {
+        if (Integer.valueOf(0).equals(status)) {
+            return "UNHANDLED";
+        }
+        if (Integer.valueOf(1).equals(status)) {
+            return "HANDLED";
+        }
+        return null;
+    }
+
+    /** 预警等级:{@code warning_level} 是字符串,仅 1–4 计算编码有效;其余回 {@code null}。 */
+    private static Integer warningLevel(String level) {
+        if (level == null) {
+            return null;
+        }
+        try {
+            int resolved = Integer.parseInt(level.trim());
+            return resolved >= 1 && resolved <= 4 ? resolved : null;
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+
+    /**
+     * 事件片区:沿事件设备解析到 {@code equipment_base.district}(片区权威来源),
+     * 再经 {@link CockpitDistrict#codeOf} 归一;设备缺失或片区未识别时如实回 {@code null}(=未分区)。
+     */
+    private static Integer districtCode(String deviceCode, Map<String, EquipmentBase> deviceByCode) {
+        EquipmentBase device = device(deviceCode, deviceByCode);
+        return device == null ? null : CockpitDistrict.codeOf(device.getDistrict());
+    }
+
+    /**
+     * 事件行业:沿设备类型树上溯到根再映射({@link CockpitTypeIndustry})。
+     * 设备缺失、类型缺失或根不在取值域内时如实回 {@code null}(服务层进未知桶),不按片区或名称冒充。
+     */
+    private static String industryCode(
+            String deviceCode,
+            Map<String, EquipmentBase> deviceByCode,
+            Map<Integer, EquipmentType> typesById) {
+        EquipmentBase device = device(deviceCode, deviceByCode);
+        if (device == null || device.getEquipmentTypeId() == null) {
+            return null;
+        }
+        return CockpitTypeIndustry.industryOf(typesById.get(device.getEquipmentTypeId()), typesById);
+    }
+
+    private static EquipmentBase device(String deviceCode, Map<String, EquipmentBase> deviceByCode) {
+        String normalized = trimToNull(deviceCode);
+        return normalized == null ? null : deviceByCode.get(normalized);
+    }
+
+    private Map<String, EquipmentBase> deviceIndex() {
+        Map<String, EquipmentBase> result = new LinkedHashMap<>();
+        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
+            if (device != null && !isBlank(device.getEquipmentCode())) {
+                result.put(device.getEquipmentCode().trim(), device);
+            }
+        }
+        return result;
+    }
+
+    private Map<Integer, EquipmentType> typeIndex() {
+        Map<Integer, EquipmentType> result = new LinkedHashMap<>();
+        for (EquipmentType type : safeList(equipmentTypeService.list())) {
+            if (type != null && type.getId() != null) {
+                result.put(type.getId(), type);
+            }
+        }
+        return result;
+    }
+
+    private static String normalize(String value) {
+        return value == null ? null : value.trim().toUpperCase();
+    }
+
     private static String bucket(LocalDateTime time) {
     private static String bucket(LocalDateTime time) {
         return BUCKET_FORMATTER.format(time.atZone(BUSINESS_ZONE).toLocalDate());
         return BUCKET_FORMATTER.format(time.atZone(BUSINESS_ZONE).toLocalDate());
     }
     }
@@ -299,4 +348,4 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         private int total;
         private int total;
         private int handled;
         private int handled;
     }
     }
-}
+}

+ 73 - 15
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsResponse.java

@@ -99,18 +99,42 @@ public class CockpitOverviewWarningSettingsResponse {
         public String getServerUpdatedAt() { return serverUpdatedAt; }
         public String getServerUpdatedAt() { return serverUpdatedAt; }
     }
     }
 
 
+    /**
+     * 一族(预警或报警)的统计。
+     *
+     * <p><b>预警族</b>(维护者 2026-10-14 裁定:预警不走工单流程)只给**预警自身生命周期**读数:
+     * {@code total}、{@code statusDistribution}({@code WarningWorkflow} 六态)、{@code levelDistribution}、
+     * {@code industryDistribution}、{@code districtDistribution}、{@code handlingEfficiency}
+     * (已解除 ÷ 预警总数,非工单口径)、{@code trend}(按发布时间分桶)。
+     * 工单口径字段({@code handled}/{@code pending}/{@code cancelledCount}/{@code unlinkedEventCount}/
+     * {@code invalidWorkOrderStateCount}/{@code workOrderStates})一律回 {@code null} ——
+     * 「不适用」与「真的是 0」必须分得开,故用包装类型而非 0 冒充。
+     *
+     * <p><b>报警族</b>(维护者裁定:报警生命周期与工单关联性强,报警靠工单闭环)**保留工单口径**
+     * 全部字段与语义({@code handlingEfficiency} 仍是工单口径处置率),同时**并存**报警自身生命周期的
+     * {@code statusDistribution}({@code alarm_status})、{@code levelDistribution}、
+     * {@code industryDistribution}、{@code districtDistribution}。两者并存,不互相冒充。
+     *
+     * <p>除 {@code trend} 外,全部读数取**整个快照**(不随 {@code range} 收缩),
+     * 故 {@code sum(statusDistribution)} == {@code total} 恒成立,与既有 {@code total} 的
+     * 「当前」语义一致;只有趋势桶跟随声明窗口。
+     */
     public static class FamilyMetrics {
     public static class FamilyMetrics {
         private final String eventFamily;
         private final String eventFamily;
         private final String state;
         private final String state;
         private final String stateReason;
         private final String stateReason;
         private final int total;
         private final int total;
-        private final int handled;
-        private final int pending;
-        private final int cancelledCount;
-        private final int unlinkedEventCount;
-        private final int invalidWorkOrderStateCount;
+        private final Integer handled;
+        private final Integer pending;
+        private final Integer cancelledCount;
+        private final Integer unlinkedEventCount;
+        private final Integer invalidWorkOrderStateCount;
         private final Efficiency handlingEfficiency;
         private final Efficiency handlingEfficiency;
         private final Map<Integer, Integer> workOrderStates;
         private final Map<Integer, Integer> workOrderStates;
+        private final List<Bucket> statusDistribution;
+        private final List<Bucket> levelDistribution;
+        private final List<Bucket> industryDistribution;
+        private final List<Bucket> districtDistribution;
         private final List<TrendPoint> trend;
         private final List<TrendPoint> trend;
 
 
         public FamilyMetrics(
         public FamilyMetrics(
@@ -118,13 +142,17 @@ public class CockpitOverviewWarningSettingsResponse {
                 String state,
                 String state,
                 String stateReason,
                 String stateReason,
                 int total,
                 int total,
-                int handled,
-                int pending,
-                int cancelledCount,
-                int unlinkedEventCount,
-                int invalidWorkOrderStateCount,
+                Integer handled,
+                Integer pending,
+                Integer cancelledCount,
+                Integer unlinkedEventCount,
+                Integer invalidWorkOrderStateCount,
                 Efficiency handlingEfficiency,
                 Efficiency handlingEfficiency,
                 Map<Integer, Integer> workOrderStates,
                 Map<Integer, Integer> workOrderStates,
+                List<Bucket> statusDistribution,
+                List<Bucket> levelDistribution,
+                List<Bucket> industryDistribution,
+                List<Bucket> districtDistribution,
                 List<TrendPoint> trend) {
                 List<TrendPoint> trend) {
             this.eventFamily = eventFamily;
             this.eventFamily = eventFamily;
             this.state = state;
             this.state = state;
@@ -137,6 +165,10 @@ public class CockpitOverviewWarningSettingsResponse {
             this.invalidWorkOrderStateCount = invalidWorkOrderStateCount;
             this.invalidWorkOrderStateCount = invalidWorkOrderStateCount;
             this.handlingEfficiency = handlingEfficiency;
             this.handlingEfficiency = handlingEfficiency;
             this.workOrderStates = workOrderStates;
             this.workOrderStates = workOrderStates;
+            this.statusDistribution = statusDistribution;
+            this.levelDistribution = levelDistribution;
+            this.industryDistribution = industryDistribution;
+            this.districtDistribution = districtDistribution;
             this.trend = trend;
             this.trend = trend;
         }
         }
 
 
@@ -144,16 +176,42 @@ public class CockpitOverviewWarningSettingsResponse {
         public String getState() { return state; }
         public String getState() { return state; }
         public String getStateReason() { return stateReason; }
         public String getStateReason() { return stateReason; }
         public int getTotal() { return total; }
         public int getTotal() { return total; }
-        public int getHandled() { return handled; }
-        public int getPending() { return pending; }
-        public int getCancelledCount() { return cancelledCount; }
-        public int getUnlinkedEventCount() { return unlinkedEventCount; }
-        public int getInvalidWorkOrderStateCount() { return invalidWorkOrderStateCount; }
+        public Integer getHandled() { return handled; }
+        public Integer getPending() { return pending; }
+        public Integer getCancelledCount() { return cancelledCount; }
+        public Integer getUnlinkedEventCount() { return unlinkedEventCount; }
+        public Integer getInvalidWorkOrderStateCount() { return invalidWorkOrderStateCount; }
         public Efficiency getHandlingEfficiency() { return handlingEfficiency; }
         public Efficiency getHandlingEfficiency() { return handlingEfficiency; }
         public Map<Integer, Integer> getWorkOrderStates() { return workOrderStates; }
         public Map<Integer, Integer> getWorkOrderStates() { return workOrderStates; }
+        public List<Bucket> getStatusDistribution() { return statusDistribution; }
+        public List<Bucket> getLevelDistribution() { return levelDistribution; }
+        public List<Bucket> getIndustryDistribution() { return industryDistribution; }
+        public List<Bucket> getDistrictDistribution() { return districtDistribution; }
         public List<TrendPoint> getTrend() { return trend; }
         public List<TrendPoint> getTrend() { return trend; }
     }
     }
 
 
+    /**
+     * 一个分布桶。{@code code} 是机器键;{@code label} 是展示词。
+     *
+     * <p>「未知」类桶(状态/等级无法识别、行业推不出、片区未分区)也如实入桶,
+     * 因此 {@code sum(桶计数)} 恒等于该族 {@code total},不静默丢数据。
+     */
+    public static class Bucket {
+        private final String code;
+        private final String label;
+        private final int count;
+
+        public Bucket(String code, String label, int count) {
+            this.code = code;
+            this.label = label;
+            this.count = count;
+        }
+
+        public String getCode() { return code; }
+        public String getLabel() { return label; }
+        public int getCount() { return count; }
+    }
+
     public static class Efficiency {
     public static class Efficiency {
         private final int numerator;
         private final int numerator;
         private final int denominator;
         private final int denominator;

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

@@ -1,7 +1,9 @@
 package com.zksy.cockpit.overview;
 package com.zksy.cockpit.overview;
 
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.workorder.CockpitWorkOrderState;
 import com.zksy.cockpit.workorder.CockpitWorkOrderState;
 import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
 import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -21,8 +23,25 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
+/**
+ * 总体概览-预警设置。
+ *
+ * <p><b>口径(维护者 2026-10-14 裁定)</b>:
+ * <ul>
+ *   <li><b>预警不走工单流程</b>:预警族只给预警自身生命周期读数
+ *       (状态分布取 {@link WarningWorkflow} 六态、等级分布、行业/片区分布、
+ *       已解除÷总数、按发布时间分桶的趋势)。其工单口径字段一律 {@code null}。</li>
+ *   <li><b>报警靠工单闭环</b>:报警族保留工单口径({@code handled}/{@code pending}/
+ *       {@code cancelledCount}/{@code workOrderStates}/工单口径处置率),**同时**并存
+ *       报警自身生命周期分布。</li>
+ * </ul>
+ *
+ * <p>本端点因此**不再要求工单事实对预警族可用**:预警族读的是 {@code early_warning} 自身,
+ * 工单表不可用时预警族仍如实出数。
+ */
 @Service
 @Service
 public class CockpitOverviewWarningSettingsService {
 public class CockpitOverviewWarningSettingsService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
@@ -35,8 +54,33 @@ public class CockpitOverviewWarningSettingsService {
                     .map(CockpitWorkOrderState::getCode)
                     .map(CockpitWorkOrderState::getCode)
                     .collect(Collectors.toList());
                     .collect(Collectors.toList());
     private static final int HANDLED_STATE = CockpitWorkOrderStateDictionary.CLOSED_RATE_CODE;
     private static final int HANDLED_STATE = CockpitWorkOrderStateDictionary.CLOSED_RATE_CODE;
+    private static final int CANCELLED_STATE = CockpitWorkOrderStateDictionary.CANCELLED_RATE_CODE;
     private static final DateTimeFormatter BUCKET_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final DateTimeFormatter BUCKET_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
 
 
+    /** 预警自身生命周期的展示顺序({@link WarningWorkflow} 主线),不重排、不合并。 */
+    private static final List<String> WARNING_STATUSES = Collections.unmodifiableList(Arrays.asList(
+            WarningWorkflow.DRAFT,
+            WarningWorkflow.PENDING,
+            WarningWorkflow.RECEIVED,
+            WarningWorkflow.PROCESSING,
+            WarningWorkflow.HANDLED,
+            WarningWorkflow.CLOSED));
+
+    /** 报警自身生命周期:{@code alarm_data.alarm_status} 0/1。 */
+    private static final String ALARM_UNHANDLED = "UNHANDLED";
+    private static final String ALARM_HANDLED = "HANDLED";
+    private static final List<String> ALARM_STATUSES =
+            Collections.unmodifiableList(Arrays.asList(ALARM_UNHANDLED, ALARM_HANDLED));
+
+    private static final List<Integer> LEVELS = Collections.unmodifiableList(Arrays.asList(1, 2, 3, 4));
+
+    private static final List<String> INDUSTRIES = Collections.unmodifiableList(
+            Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE"));
+
+    /** 未知类桶的机器键与展示词:无法识别时如实入桶,不静默丢数据。 */
+    private static final String UNKNOWN_CODE = "UNKNOWN";
+    private static final String UNKNOWN_LABEL = "未知";
+
     private final CockpitOverviewWarningSettingsAdapter adapter;
     private final CockpitOverviewWarningSettingsAdapter adapter;
     private final Clock clock;
     private final Clock clock;
 
 
@@ -61,7 +105,6 @@ public class CockpitOverviewWarningSettingsService {
         String freshnessStatus = hasSnapshot ? "CURRENT" : "EMPTY";
         String freshnessStatus = hasSnapshot ? "CURRENT" : "EMPTY";
         String freshnessReason = hasSnapshot ? "READ_ONLY_AGGREGATE" : "NO_SNAPSHOT";
         String freshnessReason = hasSnapshot ? "READ_ONLY_AGGREGATE" : "NO_SNAPSHOT";
 
 
-        ScopeRate scopeRate = scopeRate(snapshot, window);
         return new CockpitOverviewWarningSettingsResponse(
         return new CockpitOverviewWarningSettingsResponse(
                 requestId,
                 requestId,
                 "v1",
                 "v1",
@@ -79,8 +122,8 @@ public class CockpitOverviewWarningSettingsService {
                         freshnessReason,
                         freshnessReason,
                         updatedAt,
                         updatedAt,
                         businessTime()),
                         businessTime()),
-                familyMetrics(snapshot, "WARNING", window, scopeRate),
-                familyMetrics(snapshot, "ALARM", window, scopeRate),
+                warningFamily(events(snapshot, "WARNING"), snapshot, window),
+                alarmFamily(events(snapshot, "ALARM"), snapshot, window),
                 mapFilter(selectedMode));
                 mapFilter(selectedMode));
     }
     }
 
 
@@ -157,59 +200,116 @@ public class CockpitOverviewWarningSettingsService {
         }
         }
     }
     }
 
 
-    private CockpitOverviewWarningSettingsResponse.FamilyMetrics familyMetrics(
-            CockpitOverviewWarningSettingsSnapshot snapshot,
-            String eventFamily,
-            Window window,
-            ScopeRate scopeRate) {
-        List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> sourceEvents =
-                snapshot == null || snapshot.getEvents() == null
-                        ? Collections.emptyList()
-                        : snapshot.getEvents();
+    private static List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events(
+            CockpitOverviewWarningSettingsSnapshot snapshot, String eventFamily) {
+        if (snapshot == null || snapshot.getEvents() == null) {
+            return Collections.emptyList();
+        }
+        return snapshot.getEvents().stream()
+                .filter(event -> event != null && eventFamily.equals(event.getEventFamily()))
+                .collect(Collectors.toList());
+    }
 
 
-        Map<Integer, Integer> workOrderStates = new LinkedHashMap<>();
-        for (Integer state : WORK_ORDER_STATES) {
-            workOrderStates.put(state, 0);
+    /**
+     * 预警族:**只**给预警自身生命周期读数。工单口径字段全部 {@code null}。
+     *
+     * <p>分布取**整个快照**(不随 `range` 收缩),与既有 {@code total} 的「当前」语义一致,
+     * 故 `sum(statusDistribution) == total` 恒成立;只有趋势桶跟随声明窗口。
+     */
+    private CockpitOverviewWarningSettingsResponse.FamilyMetrics warningFamily(
+            List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events,
+            CockpitOverviewWarningSettingsSnapshot snapshot,
+            Window window) {
+        int total = events.size();
+        int closed = 0;
+        Map<String, Integer> statusCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> levelCounts = new LinkedHashMap<>();
+        Map<String, Integer> industryCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
+
+        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : events) {
+            String status = normalizeWarningStatus(event.getStatus());
+            statusCounts.merge(status == null ? UNKNOWN_CODE : status, 1, Integer::sum);
+            if (WarningWorkflow.CLOSED.equals(status)) {
+                closed++;
+            }
+            levelCounts.merge(normalizeLevel(event.getLevel()), 1, Integer::sum);
+            industryCounts.merge(
+                    event.getIndustryCode() == null ? UNKNOWN_CODE : event.getIndustryCode(), 1, Integer::sum);
+            districtCounts.merge(event.getDistrictCode(), 1, Integer::sum);
         }
         }
 
 
-        int total = 0;
+        return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
+                "WARNING",
+                total == 0 ? "EMPTY" : "READY",
+                total == 0 ? "NO_EVENTS" : "WARNINGS_RESOLVED",
+                total,
+                // 工单口径字段:预警不走工单流程 → 显式 null(不适用),不用 0 冒充。
+                null,
+                null,
+                null,
+                null,
+                null,
+                selfLifecycleEfficiency(closed, total),
+                null,
+                statusBuckets(statusCounts, WARNING_STATUSES,
+                        CockpitOverviewWarningSettingsService::warningStatusLabel),
+                levelBuckets(levelCounts),
+                industryBuckets(industryCounts),
+                districtBuckets(districtCounts),
+                trend(snapshot, "WARNING", window));
+    }
+
+    /**
+     * 报警族:保留工单口径(维护者裁定:报警靠工单闭环),并**并存**报警自身生命周期分布。
+     *
+     * <p>{@code handled} 是报警自身生命周期({@code alarm_status=1}),
+     * {@code handlingEfficiency} 仍是**工单口径**处置率(状态 5 ÷ 状态 0–6)——
+     * 两者刻意不同源,各自命名清楚,不互相冒充。
+     */
+    private CockpitOverviewWarningSettingsResponse.FamilyMetrics alarmFamily(
+            List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> events,
+            CockpitOverviewWarningSettingsSnapshot snapshot,
+            Window window) {
+        ScopeRate scopeRate = scopeRate(snapshot, window);
+
+        int total = events.size();
         int handled = 0;
         int handled = 0;
         int pending = 0;
         int pending = 0;
         int cancelledCount = 0;
         int cancelledCount = 0;
         int unlinkedEventCount = 0;
         int unlinkedEventCount = 0;
         int invalidWorkOrderStateCount = 0;
         int invalidWorkOrderStateCount = 0;
-        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : sourceEvents) {
-            if (event == null || !eventFamily.equals(event.getEventFamily())) {
-                continue;
+        Map<String, Integer> statusCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> levelCounts = new LinkedHashMap<>();
+        Map<String, Integer> industryCounts = new LinkedHashMap<>();
+        Map<Integer, Integer> districtCounts = new LinkedHashMap<>();
+
+        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : events) {
+            String status = normalizeAlarmStatus(event.getStatus());
+            statusCounts.merge(status == null ? UNKNOWN_CODE : status, 1, Integer::sum);
+            if (ALARM_HANDLED.equals(status)) {
+                handled++;
             }
             }
+            levelCounts.merge(normalizeLevel(event.getLevel()), 1, Integer::sum);
+            industryCounts.merge(
+                    event.getIndustryCode() == null ? UNKNOWN_CODE : event.getIndustryCode(), 1, Integer::sum);
+            districtCounts.merge(event.getDistrictCode(), 1, Integer::sum);
+
+            // 工单口径(与改造前一致):报警靠工单闭环,未回联/脏码如实分列。
             Integer state = event.getWorkOrderState();
             Integer state = event.getWorkOrderState();
             if (state == null) {
             if (state == null) {
-                total++;
                 unlinkedEventCount++;
                 unlinkedEventCount++;
-                continue;
-            }
-            if (!WORK_ORDER_STATES.contains(state)) {
-                total++;
+            } else if (!WORK_ORDER_STATES.contains(state)) {
                 invalidWorkOrderStateCount++;
                 invalidWorkOrderStateCount++;
-                continue;
-            }
-            total++;
-            if (state == HANDLED_STATE) {
-                handled++;
-            } else if (state == 7) {
+            } else if (state == CANCELLED_STATE) {
                 cancelledCount++;
                 cancelledCount++;
-            } else {
+            } else if (state != HANDLED_STATE) {
                 pending++;
                 pending++;
             }
             }
         }
         }
 
 
-        // 处置率=范围内全部工单(状态 0–6)口径,两个 family 共用同一份范围读数
-        // (决策票 06:分母不要求关联报警/事件)。事件计数仍按 family 各自统计。
-        int rateNumerator = scopeRate.handled;
-        int rateDenominator = scopeRate.eligible;
-
         return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
         return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
-                eventFamily,
+                "ALARM",
                 total == 0 ? "EMPTY" : "READY",
                 total == 0 ? "EMPTY" : "READY",
                 total == 0 ? "NO_EVENTS" : "EVENTS_RESOLVED",
                 total == 0 ? "NO_EVENTS" : "EVENTS_RESOLVED",
                 total,
                 total,
@@ -218,9 +318,15 @@ public class CockpitOverviewWarningSettingsService {
                 cancelledCount,
                 cancelledCount,
                 unlinkedEventCount,
                 unlinkedEventCount,
                 invalidWorkOrderStateCount,
                 invalidWorkOrderStateCount,
-                scopeEfficiency(rateNumerator, rateDenominator),
+                // 报警族处置率仍是**工单口径**(决策票 06 / 维护者 2026-10-14 裁定)。
+                scopeEfficiency(scopeRate.handled, scopeRate.eligible),
                 scopeRate.stateCounts,
                 scopeRate.stateCounts,
-                trend(snapshot, eventFamily, window));
+                statusBuckets(statusCounts, ALARM_STATUSES,
+                        CockpitOverviewWarningSettingsService::alarmStatusLabel),
+                levelBuckets(levelCounts),
+                industryBuckets(industryCounts),
+                districtBuckets(districtCounts),
+                trend(snapshot, "ALARM", window));
     }
     }
 
 
     /**
     /**
@@ -251,8 +357,10 @@ public class CockpitOverviewWarningSettingsService {
     }
     }
 
 
     /**
     /**
-     * 范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
-     * 按 {@code create_time} 界定窗口。两 family 共用同一份读数。
+     * 报警族范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
+     * 按 {@code create_time} 界定窗口。
+     *
+     * <p>只有报警族消费本读数 —— 预警族已按维护者裁定脱离工单。
      */
      */
     private ScopeRate scopeRate(CockpitOverviewWarningSettingsSnapshot snapshot, Window window) {
     private ScopeRate scopeRate(CockpitOverviewWarningSettingsSnapshot snapshot, Window window) {
         Map<Integer, Integer> stateCounts = new LinkedHashMap<>();
         Map<Integer, Integer> stateCounts = new LinkedHashMap<>();
@@ -298,6 +406,137 @@ public class CockpitOverviewWarningSettingsService {
         }
         }
     }
     }
 
 
+    /** 预警自身生命周期状态归一:只认 {@link WarningWorkflow} 六态,其余回 {@code null}(进未知桶)。 */
+    private static String normalizeWarningStatus(String status) {
+        if (status == null) {
+            return null;
+        }
+        String value = status.trim().toUpperCase();
+        return WARNING_STATUSES.contains(value) ? value : null;
+    }
+
+    /** 报警自身生命周期状态归一:{@code alarm_status} 0/1,其余回 {@code null}(进未知桶)。 */
+    private static String normalizeAlarmStatus(String status) {
+        if (status == null) {
+            return null;
+        }
+        String value = status.trim().toUpperCase();
+        return ALARM_STATUSES.contains(value) ? value : null;
+    }
+
+    /** 等级归一:{@code warning_level}/{@code alarm_level} 的 1–4 计算编码,其余回 {@code null}(进未知桶)。 */
+    private static Integer normalizeLevel(Integer level) {
+        return level != null && LEVELS.contains(level) ? level : null;
+    }
+
+    private static String warningStatusLabel(String code) {
+        return WarningWorkflow.label(code);
+    }
+
+    private static String alarmStatusLabel(String code) {
+        if (ALARM_HANDLED.equals(code)) {
+            return "已处理";
+        }
+        if (ALARM_UNHANDLED.equals(code)) {
+            return "未处理";
+        }
+        return UNKNOWN_LABEL;
+    }
+
+    private static String industryLabel(String code) {
+        if ("GAS".equals(code)) return "燃气";
+        if ("WATER_SUPPLY".equals(code)) return "供水";
+        if ("DRAINAGE".equals(code)) return "排水";
+        if ("MANHOLE".equals(code)) return "窨井";
+        return UNKNOWN_LABEL;
+    }
+
+    /**
+     * 状态分布:先按族的主线顺序铺全部已知状态(缺的态是真实计数 0,不是"没有数据"),
+     * 再在确有无法识别的状态时补一个未知桶 —— 故 `sum(桶计数) == total` 恒成立。
+     */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> statusBuckets(
+            Map<String, Integer> counts,
+            List<String> known,
+            Function<String, String> label) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (String code : known) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    code, label.apply(code), counts.getOrDefault(code, 0)));
+        }
+        int unknown = counts.getOrDefault(UNKNOWN_CODE, 0);
+        if (unknown > 0) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    UNKNOWN_CODE, UNKNOWN_LABEL, unknown));
+        }
+        return buckets;
+    }
+
+    /** 等级分布:1–4 全铺(缺的是真实 0),有脏等级时补未知桶。 */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> levelBuckets(
+            Map<Integer, Integer> counts) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (Integer level : LEVELS) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    String.valueOf(level), level + "级", counts.getOrDefault(level, 0)));
+        }
+        int unknown = counts.getOrDefault(null, 0);
+        if (unknown > 0) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    UNKNOWN_CODE, UNKNOWN_LABEL, unknown));
+        }
+        return buckets;
+    }
+
+    /** 行业分布:四个正式行业全铺,推不出行业的进未知桶(不冒充)。 */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> industryBuckets(
+            Map<String, Integer> counts) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (String industry : INDUSTRIES) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    industry, industryLabel(industry), counts.getOrDefault(industry, 0)));
+        }
+        int unknown = counts.getOrDefault(UNKNOWN_CODE, 0);
+        if (unknown > 0) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    UNKNOWN_CODE, UNKNOWN_LABEL, unknown));
+        }
+        return buckets;
+    }
+
+    /**
+     * 片区分布:0/1/2 三个正式片区全铺,另**恒有**「未分区」桶 ——
+     * 未分区是合法结果(设备缺失或片区未识别),不是缺数据。
+     */
+    private static List<CockpitOverviewWarningSettingsResponse.Bucket> districtBuckets(
+            Map<Integer, Integer> counts) {
+        List<CockpitOverviewWarningSettingsResponse.Bucket> buckets = new ArrayList<>();
+        for (Integer code : Arrays.asList(0, 1, 2)) {
+            buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                    String.valueOf(code), CockpitDistrict.nameOf(code), counts.getOrDefault(code, 0)));
+        }
+        buckets.add(new CockpitOverviewWarningSettingsResponse.Bucket(
+                CockpitDistrict.UNASSIGNED_KEY,
+                CockpitDistrict.UNASSIGNED_NAME,
+                counts.getOrDefault(null, 0)));
+        return buckets;
+    }
+
+    /** 预警自身生命周期「已解除率」:分子=已解除,分母=预警总数;分母 0 → 不适用。 */
+    private static CockpitOverviewWarningSettingsResponse.Efficiency selfLifecycleEfficiency(
+            int closed, int total) {
+        BigDecimal value = total == 0
+                ? BigDecimal.ZERO.setScale(4)
+                : BigDecimal.valueOf(closed)
+                        .divide(BigDecimal.valueOf(total), 4, RoundingMode.HALF_UP);
+        return new CockpitOverviewWarningSettingsResponse.Efficiency(
+                closed,
+                total,
+                value,
+                total == 0 ? "EMPTY" : "AVAILABLE",
+                total == 0 ? "NO_WARNINGS" : null);
+    }
+
     private static CockpitOverviewWarningSettingsResponse.Efficiency scopeEfficiency(int handled, int eligible) {
     private static CockpitOverviewWarningSettingsResponse.Efficiency scopeEfficiency(int handled, int eligible) {
         BigDecimal value = eligible == 0
         BigDecimal value = eligible == 0
                 ? BigDecimal.ZERO.setScale(4)
                 ? BigDecimal.ZERO.setScale(4)
@@ -311,7 +550,7 @@ public class CockpitOverviewWarningSettingsService {
                 eligible == 0 ? "NO_ELIGIBLE_WORK_ORDERS" : null);
                 eligible == 0 ? "NO_ELIGIBLE_WORK_ORDERS" : null);
     }
     }
 
 
-    /** 范围处置率读数:分子(状态 5)、分母(状态 0–6)、逐态分布。 */
+    /** 报警族范围处置率读数:分子(状态 5)、分母(状态 0–6)、逐态分布。 */
     private static class ScopeRate {
     private static class ScopeRate {
         private final int handled;
         private final int handled;
         private final int eligible;
         private final int eligible;

+ 47 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsSnapshot.java

@@ -31,8 +31,11 @@ public class CockpitOverviewWarningSettingsSnapshot {
     public List<SourceTrendPoint> getTrendPoints() { return trendPoints; }
     public List<SourceTrendPoint> getTrendPoints() { return trendPoints; }
 
 
     /**
     /**
-     * 范围内**全部工单**(决策票 06):处置率分母=状态 0–6 工单数,不要求事件回联;
-     * 按 {@code create_time} 界定窗口。事件趋势仍走 {@link SourceTrendPoint}。
+     * 范围内**全部工单**(决策票 06):**报警族**处置率分母=状态 0–6 工单数,
+     * 不要求事件回联;按 {@code create_time} 界定窗口。
+     *
+     * <p>维护者 2026-10-14 裁定:预警不走工单流程,故该读数**只服务报警族**,
+     * 预警族不再消费它。事件趋势仍走 {@link SourceTrendPoint}。
      */
      */
     public List<SourceWorkOrder> getScopeWorkOrders() { return scopeWorkOrders; }
     public List<SourceWorkOrder> getScopeWorkOrders() { return scopeWorkOrders; }
 
 
@@ -49,24 +52,57 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public String getCreateTime() { return createTime; }
         public String getCreateTime() { return createTime; }
     }
     }
 
 
+    /**
+     * 一条事件事实(预警或报警),携带**该对象自身生命周期**与**沿设备解析的维度**。
+     *
+     * <p>{@code status} 是对象自身状态原样词:预警取 {@code WarningWorkflow} 六态
+     * ({@code DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED});报警取
+     * {@code alarm_status} 归一后的 {@code UNHANDLED}/{@code HANDLED}。两者都是「自身生命周期」,
+     * 不是工单状态 —— 工单状态另由 {@code workOrderState} 承载(只有报警族消费)。
+     *
+     * <p>{@code districtCode}/{@code industryCode} 由适配器沿设备解析后带入:服务层拿不到
+     * 设备台账,不能在服务层补算。推不出时如实为 {@code null}(未分区/未知行业),不冒充。
+     */
     public static class SourceEvent {
     public static class SourceEvent {
         private final String eventFamily;
         private final String eventFamily;
         private final String eventCode;
         private final String eventCode;
         private final String deviceCode;
         private final String deviceCode;
         private final String workOrderCode;
         private final String workOrderCode;
         private final Integer workOrderState;
         private final Integer workOrderState;
+        private final String status;
+        private final Integer level;
+        private final Integer districtCode;
+        private final String industryCode;
 
 
+        /** 兼容旧调用点:只给工单回联事实,自身生命周期与维度留空。 */
         public SourceEvent(
         public SourceEvent(
                 String eventFamily,
                 String eventFamily,
                 String eventCode,
                 String eventCode,
                 String deviceCode,
                 String deviceCode,
                 String workOrderCode,
                 String workOrderCode,
                 Integer workOrderState) {
                 Integer workOrderState) {
+            this(eventFamily, eventCode, deviceCode, workOrderCode, workOrderState, null, null, null, null);
+        }
+
+        public SourceEvent(
+                String eventFamily,
+                String eventCode,
+                String deviceCode,
+                String workOrderCode,
+                Integer workOrderState,
+                String status,
+                Integer level,
+                Integer districtCode,
+                String industryCode) {
             this.eventFamily = eventFamily;
             this.eventFamily = eventFamily;
             this.eventCode = eventCode;
             this.eventCode = eventCode;
             this.deviceCode = deviceCode;
             this.deviceCode = deviceCode;
             this.workOrderCode = workOrderCode;
             this.workOrderCode = workOrderCode;
             this.workOrderState = workOrderState;
             this.workOrderState = workOrderState;
+            this.status = status;
+            this.level = level;
+            this.districtCode = districtCode;
+            this.industryCode = industryCode;
         }
         }
 
 
         public String getEventFamily() { return eventFamily; }
         public String getEventFamily() { return eventFamily; }
@@ -74,8 +110,17 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceCode() { return deviceCode; }
         public String getWorkOrderCode() { return workOrderCode; }
         public String getWorkOrderCode() { return workOrderCode; }
         public Integer getWorkOrderState() { return workOrderState; }
         public Integer getWorkOrderState() { return workOrderState; }
+        public String getStatus() { return status; }
+        public Integer getLevel() { return level; }
+        public Integer getDistrictCode() { return districtCode; }
+        public String getIndustryCode() { return industryCode; }
     }
     }
 
 
+    /**
+     * 趋势桶。{@code handled} 的含义**按族不同**,不共用一套判据:
+     * 预警族=该桶内**已解除({@code CLOSED})**的预警数(自身生命周期);
+     * 报警族=该桶内**关联工单已结案**的报警数(工单口径,维护者裁定报警靠工单闭环)。
+     */
     public static class SourceTrendPoint {
     public static class SourceTrendPoint {
         private final String eventFamily;
         private final String eventFamily;
         private final String bucket;
         private final String bucket;
@@ -95,4 +140,3 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public int getHandled() { return handled; }
         public int getHandled() { return handled; }
     }
     }
 }
 }
-

+ 22 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/domain/DrainageVideoResource.java

@@ -0,0 +1,22 @@
+package com.zksy.drainage.domain;
+
+import java.math.BigDecimal;
+import lombok.Data;
+
+/** 排水视频展示视图。不返回源地址、人员联系方式或接入凭据。 */
+@Data
+public class DrainageVideoResource {
+    private String videoId;
+    private String deviceCode;
+    private String deviceName;
+    private Integer district;
+    private String districtName;
+    private String sourceType;
+    private String typeName;
+    private String location;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+    private String pointId;
+    private String pointName;
+    private Boolean favorite;
+}

+ 11 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/mapper/DrainageBindingLockMapper.java

@@ -0,0 +1,11 @@
+package com.zksy.drainage.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+/** 所有排水设备关联入口共用事务锁,避免旧接口与视频接口并发覆盖。 */
+@Mapper
+public interface DrainageBindingLockMapper {
+    @Select("SELECT id FROM app_user.drainage_equipment_binding_lock WHERE id=1 FOR UPDATE")
+    Integer lock();
+}

+ 46 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/mapper/DrainageVideoMapper.java

@@ -0,0 +1,46 @@
+package com.zksy.drainage.mapper;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.drainage.domain.DrainageVideoResource;
+import java.util.List;
+import java.util.Map;
+import org.apache.ibatis.annotations.*;
+
+@Mapper
+public interface DrainageVideoMapper {
+    String VIEW = "SELECT e.equipment_id AS video_id,e.equipment_code AS device_code,e.equipment_name AS device_name," +
+        "e.district,e.equipment_location AS location,e.longitude,e.latitude,v.source_type," +
+        "w.id AS point_id,w.name AS point_name,(f.user_id IS NOT NULL) AS favorite " +
+        "FROM app_user.equipment_base e JOIN app_user.equipment_video_binding v ON v.equipment_id=e.equipment_id " +
+        "LEFT JOIN app_user.psgw_waterlogging_point w ON STRPOS(CONCAT(',',REPLACE(COALESCE(w.equipment_id,''),' ',''),','),CONCAT(',',e.equipment_id,','))>0 " +
+        "LEFT JOIN app_user.drainage_video_favorite f ON f.equipment_id=e.equipment_id AND f.user_id=#{userId} ";
+    String FILTER = "WHERE e.equipment_type_id=20 AND e.del_flag='0' " +
+        "<if test='query != null and query != &quot;&quot;'>AND (e.equipment_name LIKE CONCAT('%',#{query},'%') OR e.equipment_code LIKE CONCAT('%',#{query},'%'))</if> " +
+        "<if test='district != null'>AND e.district=#{district}</if> " +
+        "<if test='pointId != null and pointId != &quot;&quot;'>AND w.id=#{pointId}</if> " +
+        "<if test='unbound == true'>AND w.id IS NULL</if> " +
+        "<if test='unbound == false'>AND w.id IS NOT NULL</if> " +
+        "<if test='favoriteOnly == true'>AND f.user_id IS NOT NULL</if> " +
+        "<if test='sourceType != null and sourceType != &quot;&quot;'>AND v.source_type=#{sourceType}</if> " +
+        "ORDER BY e.create_time DESC,e.equipment_id";
+
+    @Select("<script>" + VIEW + FILTER + "</script>")
+    Page<DrainageVideoResource> page(Page<DrainageVideoResource> page, @Param("userId") Long userId,
+        @Param("query") String query,@Param("district") Integer district,@Param("pointId") String pointId,
+        @Param("unbound") Boolean unbound,@Param("favoriteOnly") Boolean favoriteOnly,@Param("sourceType") String sourceType);
+
+    @Select(VIEW + "WHERE e.equipment_type_id=20 AND e.del_flag='0' ORDER BY e.district,e.equipment_id")
+    List<DrainageVideoResource> all(@Param("userId") Long userId);
+
+    @Select("SELECT id,name,area FROM app_user.psgw_waterlogging_point ORDER BY name,id")
+    List<Map<String,Object>> points();
+
+    @Insert("INSERT INTO app_user.drainage_video_favorite(user_id,equipment_id) VALUES(#{userId},#{videoId}) ON CONFLICT(user_id,equipment_id) DO NOTHING")
+    int favorite(@Param("userId") Long userId,@Param("videoId") String videoId);
+
+    @Delete("DELETE FROM app_user.drainage_video_favorite WHERE user_id=#{userId} AND equipment_id=#{videoId}")
+    int unfavorite(@Param("userId") Long userId,@Param("videoId") String videoId);
+
+    @Update("UPDATE app_user.psgw_waterlogging_point SET equipment_id=#{equipmentIds},update_time=CURRENT_TIMESTAMP WHERE id=#{pointId}")
+    int updatePoint(@Param("pointId") String pointId,@Param("equipmentIds") String equipmentIds);
+}

+ 100 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/DrainageVideoService.java

@@ -0,0 +1,100 @@
+package com.zksy.drainage.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.enums.DistrictEnum;
+import com.zksy.base.video.VideoDeviceService;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.common.utils.SecurityUtils;
+import com.zksy.drainage.domain.*;
+import com.zksy.drainage.mapper.*;
+import java.util.*;
+import java.util.stream.Collectors;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class DrainageVideoService {
+    private final DrainageVideoMapper videos;
+    private final WaterloggingPointMapper points;
+    private final DrainageBindingLockMapper guard;
+    private final DrainageEquipmentBindingService binding;
+    private final VideoDeviceService devices;
+    public DrainageVideoService(DrainageVideoMapper videos,WaterloggingPointMapper points,DrainageBindingLockMapper guard,
+                               DrainageEquipmentBindingService binding,VideoDeviceService devices) {
+        this.videos=videos;this.points=points;this.guard=guard;this.binding=binding;this.devices=devices;
+    }
+    public Page<DrainageVideoResource> page(int pageNum,int pageSize,String query,Integer district,String pointId,
+                                          Boolean unbound,Boolean favoriteOnly,String sourceType) {
+        if(district!=null&&!DistrictEnum.isValid(district))throw new ServiceException("无效设备片区");
+        Page<DrainageVideoResource> page=videos.page(new Page<>(Math.max(1,pageNum),Math.max(1,Math.min(100,pageSize))),
+            SecurityUtils.getUserId(),query,district,pointId,unbound,favoriteOnly,sourceType);
+        page.getRecords().forEach(this::describe);return page;
+    }
+    public List<DrainageVideoResource> all() {
+        List<DrainageVideoResource> rows=videos.all(SecurityUtils.getUserId());rows.forEach(this::describe);return rows;
+    }
+    private void describe(DrainageVideoResource row) {
+        row.setDistrictName(Optional.ofNullable(DistrictEnum.getDescByCode(row.getDistrict())).orElse("未设置片区"));
+        row.setTypeName("GB28181".equals(row.getSourceType())?"国标视频":"地址视频");
+    }
+    public List<Map<String,Object>> points() {return videos.points();}
+    public List<Map<String,Object>> tree() {
+        List<DrainageVideoResource> rows=all();
+        List<Map<String,Object>> roots=new ArrayList<>();
+        Map<String,Object> favorites=node("favorites","我的收藏",rows.stream().filter(r->Boolean.TRUE.equals(r.getFavorite())).collect(Collectors.toList()));
+        favorites.put("children",leaves("favorites",rows.stream().filter(r->Boolean.TRUE.equals(r.getFavorite())).collect(Collectors.toList())));
+        favorites.put("favoriteOnly",true);roots.add(favorites);
+        Map<String,List<DrainageVideoResource>> districts=rows.stream().collect(Collectors.groupingBy(
+            r->r.getDistrict()==null?"unknown":r.getDistrict().toString(),LinkedHashMap::new,Collectors.toList()));
+        districts.forEach((key,list)->{
+            Map<String,Object> root=node("district-"+key,list.get(0).getDistrictName(),list);root.put("district",list.get(0).getDistrict());
+            Map<String,List<DrainageVideoResource>> groups=list.stream().collect(Collectors.groupingBy(
+                r->r.getPointId()==null?"unbound":r.getPointId(),LinkedHashMap::new,Collectors.toList()));
+            List<Map<String,Object>> children=new ArrayList<>();
+            groups.forEach((pointId,items)->{
+                Map<String,Object> child=node("point-"+key+"-"+pointId,items.get(0).getPointName()==null?"未关联易积水点":items.get(0).getPointName(),items);
+                child.put("pointId",items.get(0).getPointId());child.put("district",list.get(0).getDistrict());child.put("unbound","unbound".equals(pointId));
+                child.put("children",leaves(child.get("id").toString(),items));
+                children.add(child);
+            });root.put("children",children);roots.add(root);
+        });return roots;
+    }
+    private Map<String,Object> node(String id,String label,List<DrainageVideoResource> rows) {
+        Map<String,Object> result=new LinkedHashMap<>();result.put("id",id);result.put("label",label);result.put("count",rows.size());
+        result.put("videoIds",rows.stream().map(DrainageVideoResource::getVideoId).distinct().collect(Collectors.toList()));return result;
+    }
+    private List<Map<String,Object>> leaves(String parent,List<DrainageVideoResource> rows) {
+        return rows.stream().map(row->node("video-"+parent+"-"+row.getVideoId(),row.getDeviceName(),Collections.singletonList(row))).collect(Collectors.toList());
+    }
+    @Transactional
+    public void associate(String videoId,String pointId) {
+        if(guard.lock()==null)throw new ServiceException("设备关联事务锁未初始化,请执行迁移");
+        devices.one(videoId);
+        List<WaterloggingPoint> all=points.selectList(null);
+        WaterloggingPoint target=null;
+        if(pointId!=null&&!pointId.isBlank()) {
+            target=all.stream().filter(p->pointId.equals(p.getId())).findFirst().orElseThrow(()->new ServiceException("易积水点不存在"));
+            for(WaterloggingPoint point:all) {
+                if(!pointId.equals(point.getId())&&ids(point.getEquipmentId()).contains(videoId))
+                    throw new ServiceException("视频已关联其他易积水点,请先解除关联");
+            }
+            Map<String,String> occupied=binding.findBoundEquipment(DrainageEquipmentBindingService.TYPE_WATERLOGGING,pointId);
+            if(occupied.containsKey(videoId))throw new ServiceException("视频已被其他位置关联:"+occupied.get(videoId));
+            Set<String> merged=ids(target.getEquipmentId());merged.add(videoId);
+            videos.updatePoint(pointId,String.join(",",merged));
+        } else {
+            for(WaterloggingPoint point:all) {
+                Set<String> existing=ids(point.getEquipmentId());
+                if(existing.remove(videoId))videos.updatePoint(point.getId(),String.join(",",existing));
+            }
+        }
+    }
+    @Transactional
+    public void favorite(String videoId,boolean enabled) {
+        devices.one(videoId);
+        if(enabled)videos.favorite(SecurityUtils.getUserId(),videoId);else videos.unfavorite(SecurityUtils.getUserId(),videoId);
+    }
+    static Set<String> ids(String value) {
+        return value==null?new LinkedHashSet<>():Arrays.stream(value.split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toCollection(LinkedHashSet::new));
+    }
+}

+ 7 - 6
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/impl/DrainageEquipmentBindingServiceImpl.java

@@ -6,10 +6,7 @@ import com.zksy.drainage.domain.DrainagePartition;
 import com.zksy.drainage.domain.EngineeringFacility;
 import com.zksy.drainage.domain.EngineeringFacility;
 import com.zksy.drainage.domain.PipeNetworkInfo;
 import com.zksy.drainage.domain.PipeNetworkInfo;
 import com.zksy.drainage.domain.WaterloggingPoint;
 import com.zksy.drainage.domain.WaterloggingPoint;
-import com.zksy.drainage.mapper.DrainagePartitionMapper;
-import com.zksy.drainage.mapper.EngineeringFacilityMapper;
-import com.zksy.drainage.mapper.PipeNetworkInfoMapper;
-import com.zksy.drainage.mapper.WaterloggingPointMapper;
+import com.zksy.drainage.mapper.*;
 import com.zksy.drainage.service.DrainageEquipmentBindingService;
 import com.zksy.drainage.service.DrainageEquipmentBindingService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
@@ -26,6 +23,9 @@ import java.util.Set;
 @Service
 @Service
 public class DrainageEquipmentBindingServiceImpl implements DrainageEquipmentBindingService {
 public class DrainageEquipmentBindingServiceImpl implements DrainageEquipmentBindingService {
 
 
+    @Autowired
+    private DrainageBindingLockMapper bindingLock;
+
     @Autowired
     @Autowired
     private DrainagePartitionMapper drainagePartitionMapper;
     private DrainagePartitionMapper drainagePartitionMapper;
 
 
@@ -75,10 +75,11 @@ public class DrainageEquipmentBindingServiceImpl implements DrainageEquipmentBin
         return result;
         return result;
     }
     }
 
 
-    /** 同一个服务实例串行完成检查和保存,防止单实例内并发抢占同一设备。 */
+    /** 数据库行锁覆盖检查、保存和事务提交,所有实例及点位编辑入口共用。 */
     @Override
     @Override
     @Transactional
     @Transactional
-    public synchronized boolean bindEquipment(String sourceType, String sourceId, List<String> equipmentIds) {
+    public boolean bindEquipment(String sourceType, String sourceId, List<String> equipmentIds) {
+        if (bindingLock.lock() == null) throw new ServiceException("设备关联事务锁未初始化,请执行迁移");
         validateSourceType(sourceType, false);
         validateSourceType(sourceType, false);
         if (sourceId == null || sourceId.trim().isEmpty()) {
         if (sourceId == null || sourceId.trim().isEmpty()) {
             throw new ServiceException("关联对象ID不能为空");
             throw new ServiceException("关联对象ID不能为空");

+ 24 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/impl/WaterloggingPointServiceImpl.java

@@ -27,6 +27,9 @@ public class WaterloggingPointServiceImpl extends ServiceImpl<WaterloggingPointM
     @org.springframework.beans.factory.annotation.Autowired
     @org.springframework.beans.factory.annotation.Autowired
     private DrainageEquipmentBindingService equipmentBindingService;
     private DrainageEquipmentBindingService equipmentBindingService;
 
 
+    @org.springframework.beans.factory.annotation.Autowired
+    private com.zksy.drainage.mapper.DrainageBindingLockMapper bindingLock;
+
     @Override
     @Override
     public Page<WaterloggingPoint> findByPage(long pageNum, long pageSize, String name, String area) {
     public Page<WaterloggingPoint> findByPage(long pageNum, long pageSize, String name, String area) {
         Page<WaterloggingPoint> page = new Page<>(pageNum, pageSize);
         Page<WaterloggingPoint> page = new Page<>(pageNum, pageSize);
@@ -44,22 +47,40 @@ public class WaterloggingPointServiceImpl extends ServiceImpl<WaterloggingPointM
     @Override
     @Override
     @Transactional
     @Transactional
     public boolean saveWithCheck(WaterloggingPoint entity) {
     public boolean saveWithCheck(WaterloggingPoint entity) {
-        return this.save(entity);
+        bindingLock.lock();
+        String equipmentIds = entity.getEquipmentId();
+        entity.setEquipmentId("");
+        if (!this.save(entity)) return false;
+        if (equipmentIds != null && !equipmentIds.isBlank())
+            equipmentBindingService.bindEquipment(DrainageEquipmentBindingService.TYPE_WATERLOGGING,
+                    entity.getId(), java.util.Arrays.asList(equipmentIds.split(",")));
+        return true;
     }
     }
 
 
     @Override
     @Override
     @Transactional
     @Transactional
     public boolean updateWithCheck(WaterloggingPoint entity) {
     public boolean updateWithCheck(WaterloggingPoint entity) {
+        bindingLock.lock();
         String id = entity.getId();
         String id = entity.getId();
         if (this.getById(id) == null) {
         if (this.getById(id) == null) {
             throw new ServiceException("易积水点不存在,无法修改:" + id);
             throw new ServiceException("易积水点不存在,无法修改:" + id);
         }
         }
-        return this.updateById(entity);
+        String equipmentIds = entity.getEquipmentId();
+        entity.setEquipmentId(null);
+        if (!this.updateById(entity)) return false;
+        if (equipmentIds != null && !equipmentIds.isBlank())
+            equipmentBindingService.bindEquipment(DrainageEquipmentBindingService.TYPE_WATERLOGGING,
+                    id, java.util.Arrays.asList(equipmentIds.split(",")));
+        else if (equipmentIds != null)
+            this.baseMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<WaterloggingPoint>()
+                    .eq(WaterloggingPoint::getId, id).set(WaterloggingPoint::getEquipmentId, ""));
+        return true;
     }
     }
 
 
     @Override
     @Override
     @Transactional
     @Transactional
     public boolean removeWithCheck(String id) {
     public boolean removeWithCheck(String id) {
+        bindingLock.lock();
         if (this.getById(id) == null) {
         if (this.getById(id) == null) {
             throw new ServiceException("易积水点不存在:" + id);
             throw new ServiceException("易积水点不存在:" + id);
         }
         }
@@ -69,6 +90,7 @@ public class WaterloggingPointServiceImpl extends ServiceImpl<WaterloggingPointM
     @Override
     @Override
     @Transactional
     @Transactional
     public boolean removeBatchWithCheck(List<String> ids) {
     public boolean removeBatchWithCheck(List<String> ids) {
+        bindingLock.lock();
         if (CollectionUtils.isEmpty(ids)) {
         if (CollectionUtils.isEmpty(ids)) {
             throw new ServiceException("批量删除失败:ID列表不能为空");
             throw new ServiceException("批量删除失败:ID列表不能为空");
         }
         }

+ 3 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/InspectTask.java

@@ -70,4 +70,7 @@ public class InspectTask implements Serializable {
     @ApiModelProperty(value = "现场照片地址数组")
     @ApiModelProperty(value = "现场照片地址数组")
     @TableField(exist = false)
     @TableField(exist = false)
     private List<String> photoList;
     private List<String> photoList;
+    @ApiModelProperty(value = "现场照片访问基础地址")
+    @TableField(exist = false)
+    private String attachmentBaseUrl;
 }
 }

+ 0 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/WorkOrder.java

@@ -43,9 +43,6 @@ public class WorkOrder implements Serializable {
     @ApiModelProperty(value = "工单问题描述")
     @ApiModelProperty(value = "工单问题描述")
     private String orderDesc;
     private String orderDesc;
 
 
-    @ApiModelProperty(value = "归属部门ID,取派单时指定责任人所属部门;待派单阶段为创建人部门。数据权限按此字段做「本部门及子部门」过滤")
-    private Long deptId;
-
     @ApiModelProperty(value = "责任人用户ID")
     @ApiModelProperty(value = "责任人用户ID")
     private Long receiveUser;
     private Long receiveUser;
 
 

+ 29 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/MobileAssignableUserVO.java

@@ -0,0 +1,29 @@
+package com.zksy.ops.domain.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/** 移动端工单派单候选人。 */
+@Data
+@AllArgsConstructor
+@ApiModel(value = "MobileAssignableUserVO", description = "移动端工单派单候选人")
+public class MobileAssignableUserVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("用户ID")
+    private Long userId;
+
+    @ApiModelProperty("用户姓名")
+    private String nickName;
+
+    @ApiModelProperty("部门ID")
+    private Long deptId;
+
+    @ApiModelProperty("部门名称")
+    private String deptName;
+}

+ 5 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderPushVO.java

@@ -33,12 +33,15 @@ public class WorkOrderPushVO implements Serializable {
     @ApiModelProperty("工单优先级:work_order_level 字典整数值,初始0紧急/1一般/2低")
     @ApiModelProperty("工单优先级:work_order_level 字典整数值,初始0紧急/1一般/2低")
     private Integer orderLevel;
     private Integer orderLevel;
 
 
-    @ApiModelProperty("所属部门ID")
-    private Long deptId;
+    @ApiModelProperty("创建人用户ID")
+    private Long createBy;
 
 
     @ApiModelProperty("责任人用户ID")
     @ApiModelProperty("责任人用户ID")
     private Long receiveUser;
     private Long receiveUser;
 
 
+    @ApiModelProperty("验收人用户ID")
+    private Long verifyUser;
+
     @ApiModelProperty("设备ID")
     @ApiModelProperty("设备ID")
     private String deviceId;
     private String deviceId;
 
 

+ 0 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderVO.java

@@ -49,9 +49,6 @@ public class WorkOrderVO implements Serializable {
     @ApiModelProperty("工单问题描述")
     @ApiModelProperty("工单问题描述")
     private String orderDesc;
     private String orderDesc;
 
 
-    @ApiModelProperty("归属部门ID")
-    private Long deptId;
-
     @ApiModelProperty("责任人用户ID")
     @ApiModelProperty("责任人用户ID")
     private Long receiveUser;
     private Long receiveUser;
 
 

+ 3 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderDetailVO.java

@@ -26,4 +26,7 @@ public class WorkOrderDetailVO implements Serializable {
 
 
     @ApiModelProperty(value = "操作日志")
     @ApiModelProperty(value = "操作日志")
     private List<WorkOrderLogVO> logs = new ArrayList<>();
     private List<WorkOrderLogVO> logs = new ArrayList<>();
+
+    @ApiModelProperty(value = "附件访问基础地址,移动端用于拼接相对路径")
+    private String attachmentBaseUrl;
 }
 }

+ 0 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderPageDTO.java

@@ -9,10 +9,6 @@ import lombok.EqualsAndHashCode;
 /**
 /**
  * 工单分页查询入参
  * 工单分页查询入参
  *
  *
- * <p>继承 {@link BaseEntity} 以接入框架 {@code DataScopeAspect} 数据权限:切面会把生成的数据范围 SQL
- * 写入 {@code params["dataScope"]},由 Service 通过 {@code QueryWrapper#apply} 注入查询条件。
- * 若不继承 BaseEntity,切面无法写入该参数,数据权限会静默失效。
- *
  * @author zksy
  * @author zksy
  */
  */
 @Data
 @Data

+ 4 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/enums/WorkOrderStatusEnum.java

@@ -42,16 +42,15 @@ public enum WorkOrderStatusEnum {
 
 
     private final String desc;
     private final String desc;
 
 
-    /** 允许取消的状态:待派单/待接单/已接单/处理中/待验收 */
+    /** 允许取消的状态:仅待派单/待接单 */
     private static final List<Integer> CANCELABLE_CODES =
     private static final List<Integer> CANCELABLE_CODES =
             Collections.unmodifiableList(Arrays.asList(
             Collections.unmodifiableList(Arrays.asList(
-                    PENDING_DISPATCH.code, PENDING_ACCEPT.code, ACCEPTED.code,
-                    PROCESSING.code, PENDING_VERIFY.code));
+                    PENDING_DISPATCH.code, PENDING_ACCEPT.code));
 
 
-    /** 允许处置(处理)的状态:已接单/处理中/延期中 */
+    /** 允许处置(处理)的状态:已接单/延期中 */
     private static final List<Integer> PROCESSABLE_CODES =
     private static final List<Integer> PROCESSABLE_CODES =
             Collections.unmodifiableList(Arrays.asList(
             Collections.unmodifiableList(Arrays.asList(
-                    ACCEPTED.code, PROCESSING.code, DELAYING.code));
+                    ACCEPTED.code, DELAYING.code));
 
 
     /** 允许提交验收的状态:处理中/延期中 */
     /** 允许提交验收的状态:处理中/延期中 */
     private static final List<Integer> SUBMIT_VERIFY_CODES =
     private static final List<Integer> SUBMIT_VERIFY_CODES =

+ 9 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/InspectTaskService.java

@@ -25,6 +25,9 @@ public interface InspectTaskService extends IService<InspectTask> {
      */
      */
     Page<InspectTask> findByPage(InspectTaskPageDTO dto);
     Page<InspectTask> findByPage(InspectTaskPageDTO dto);
 
 
+    /** 移动端分页查询:仅返回指派给当前登录用户的任务。 */
+    Page<InspectTask> findMobileByPage(InspectTaskPageDTO dto);
+
     /**
     /**
      * 获取巡检任务详情(含指派巡检人名称、照片数组)
      * 获取巡检任务详情(含指派巡检人名称、照片数组)
      *
      *
@@ -33,6 +36,9 @@ public interface InspectTaskService extends IService<InspectTask> {
      */
      */
     InspectTask getDetailById(Long inspectTaskId);
     InspectTask getDetailById(Long inspectTaskId);
 
 
+    /** 移动端详情:仅允许当前被指派人查看。 */
+    InspectTask getMobileDetailById(Long inspectTaskId);
+
     /**
     /**
      * 新增巡检任务(后端生成巡检编号,状态置为待巡检)
      * 新增巡检任务(后端生成巡检编号,状态置为待巡检)
      *
      *
@@ -57,6 +63,9 @@ public interface InspectTaskService extends IService<InspectTask> {
      */
      */
     boolean completeTask(InspectTaskCompleteDTO dto);
     boolean completeTask(InspectTaskCompleteDTO dto);
 
 
+    /** 移动端完成巡检:仅允许当前被指派人操作。 */
+    boolean completeMobileTask(InspectTaskCompleteDTO dto);
+
     /**
     /**
      * 删除巡检任务(仅待巡检可逻辑删除)
      * 删除巡检任务(仅待巡检可逻辑删除)
      *
      *

+ 15 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java

@@ -6,6 +6,7 @@ import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderPushVO;
 import com.zksy.ops.domain.vo.WorkOrderPushVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
+import com.zksy.ops.domain.vo.MobileAssignableUserVO;
 import com.zksy.common.core.domain.TreeSelect;
 import com.zksy.common.core.domain.TreeSelect;
 import com.zksy.common.core.domain.entity.SysUser;
 import com.zksy.common.core.domain.entity.SysUser;
 import com.zksy.ops.dto.WorkOrderDelayApplyDTO;
 import com.zksy.ops.dto.WorkOrderDelayApplyDTO;
@@ -44,6 +45,12 @@ public interface WorkOrderService extends IService<WorkOrder> {
      */
      */
     Page<WorkOrderVO> findByPage(WorkOrderPageDTO query);
     Page<WorkOrderVO> findByPage(WorkOrderPageDTO query);
 
 
+    /** 移动端“我的工单”分页,仅按参与关系和设备所属公司过滤,不要求 PC 查询权限。 */
+    Page<WorkOrderVO> findMobilePage(WorkOrderPageDTO query);
+
+    /** 移动端待办工单分页。 */
+    Page<WorkOrderVO> findMobilePendingPage(WorkOrderPageDTO query);
+
     /**
     /**
      * 按状态统计工单数量(列表页状态筛选条使用)。
      * 按状态统计工单数量(列表页状态筛选条使用)。
      *
      *
@@ -61,6 +68,9 @@ public interface WorkOrderService extends IService<WorkOrder> {
      */
      */
     WorkOrderDetailVO getDetail(Long orderId);
     WorkOrderDetailVO getDetail(Long orderId);
 
 
+    /** 移动端详情,查询前校验当前用户是否处于该工单可见范围。 */
+    WorkOrderDetailVO getMobileDetail(Long orderId);
+
     /**
     /**
      * 查询工单操作日志列表(按操作时间倒序)
      * 查询工单操作日志列表(按操作时间倒序)
      *
      *
@@ -115,7 +125,7 @@ public interface WorkOrderService extends IService<WorkOrder> {
     boolean accept(Long orderId, WorkOrderOperateDTO dto);
     boolean accept(Long orderId, WorkOrderOperateDTO dto);
 
 
     /**
     /**
-     * 处置:已接单/处理中/延期中 → 处理中(支持多次处置)
+     * 处置:已接单/延期中 → 处理中;处理中状态不允许重复处置
      *
      *
      * @param orderId 工单ID
      * @param orderId 工单ID
      * @param dto     操作入参(处置内容、附件)
      * @param dto     操作入参(处置内容、附件)
@@ -139,7 +149,7 @@ public interface WorkOrderService extends IService<WorkOrder> {
     boolean verify(Long orderId, WorkOrderVerifyDTO dto);
     boolean verify(Long orderId, WorkOrderVerifyDTO dto);
 
 
     /**
     /**
-     * 取消工单:待派单/待接单/已接单/处理中/待验收 → 已取消
+     * 取消工单:仅待派单/待接单 → 已取消
      *
      *
      * @param orderId 工单ID
      * @param orderId 工单ID
      * @param dto     操作入参(取消原因)
      * @param dto     操作入参(取消原因)
@@ -182,6 +192,9 @@ public interface WorkOrderService extends IService<WorkOrder> {
      */
      */
     List<SysUser> assignableUsers(Long deptId);
     List<SysUser> assignableUsers(Long deptId);
 
 
+    /** 移动端派单候选人,type 仅支持 handler/reviewer。 */
+    List<MobileAssignableUserVO> mobileAssignableUsers(String type);
+
     /**
     /**
      * 查询需要通过 WebSocket 提醒的工单(待派单/待接单 或 已超时)
      * 查询需要通过 WebSocket 提醒的工单(待派单/待接单 或 已超时)
      *
      *

+ 54 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/InspectTaskServiceImpl.java

@@ -19,6 +19,7 @@ import com.zksy.system.service.ISysDeptService;
 import com.zksy.system.service.ISysUserService;
 import com.zksy.system.service.ISysUserService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
@@ -82,11 +83,26 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
     @Autowired
     @Autowired
     private ISysDeptService deptService;
     private ISysDeptService deptService;
 
 
+    @Value("${minio.endpoint}")
+    private String minioEndpoint;
+
+    @Value("${minio.bucket}")
+    private String minioBucket;
+
     /**
     /**
      * 分页查询巡检任务列表
      * 分页查询巡检任务列表
      */
      */
     @Override
     @Override
     public Page<InspectTask> findByPage(InspectTaskPageDTO dto) {
     public Page<InspectTask> findByPage(InspectTaskPageDTO dto) {
+        return findByPage(dto, false);
+    }
+
+    @Override
+    public Page<InspectTask> findMobileByPage(InspectTaskPageDTO dto) {
+        return findByPage(dto, true);
+    }
+
+    private Page<InspectTask> findByPage(InspectTaskPageDTO dto, boolean assignedToCurrentUserOnly) {
         log.info("分页查询巡检任务列表-入参:{}", dto);
         log.info("分页查询巡检任务列表-入参:{}", dto);
         if (dto.getPageNum() == null || dto.getPageNum() < 1) {
         if (dto.getPageNum() == null || dto.getPageNum() < 1) {
             dto.setPageNum(1L);
             dto.setPageNum(1L);
@@ -109,14 +125,15 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         LocalDateTime planTimeEnd = parseDateTime(dto.getPlanTimeEnd(), true);
         LocalDateTime planTimeEnd = parseDateTime(dto.getPlanTimeEnd(), true);
         wrapper.ge(planTimeStart != null, InspectTask::getPlanTime, planTimeStart);
         wrapper.ge(planTimeStart != null, InspectTask::getPlanTime, planTimeStart);
         wrapper.le(planTimeEnd != null, InspectTask::getPlanTime, planTimeEnd);
         wrapper.le(planTimeEnd != null, InspectTask::getPlanTime, planTimeEnd);
-        // 数据权限:仅查询指派给当前用户或当前用户创建的记录
+        // 移动端只展示指派给当前用户的任务;PC 端保留指派给本人或本人创建的数据权限。
         Long dataAuthUserId = currentUserId();
         Long dataAuthUserId = currentUserId();
         if (dataAuthUserId == null) {
         if (dataAuthUserId == null) {
             // 未获取到登录用户时返回空结果,避免越权查看
             // 未获取到登录用户时返回空结果,避免越权查看
             return new Page<>(dto.getPageNum(), dto.getPageSize());
             return new Page<>(dto.getPageNum(), dto.getPageSize());
         }
         }
-        // 超级管理员(userId=1)可查看全部记录
-        if (!SecurityUtils.isAdmin(dataAuthUserId)) {
+        if (assignedToCurrentUserOnly) {
+            wrapper.eq(InspectTask::getAssignUserId, dataAuthUserId);
+        } else if (!SecurityUtils.isAdmin(dataAuthUserId)) {
             wrapper.and(w -> w.eq(InspectTask::getAssignUserId, dataAuthUserId).or().eq(InspectTask::getCreateBy, dataAuthUserId));
             wrapper.and(w -> w.eq(InspectTask::getAssignUserId, dataAuthUserId).or().eq(InspectTask::getCreateBy, dataAuthUserId));
         }
         }
         wrapper.orderByDesc(InspectTask::getPlanTime).orderByDesc(InspectTask::getCreateTime);
         wrapper.orderByDesc(InspectTask::getPlanTime).orderByDesc(InspectTask::getCreateTime);
@@ -145,6 +162,14 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         return task;
         return task;
     }
     }
 
 
+    @Override
+    public InspectTask getMobileDetailById(Long inspectTaskId) {
+        InspectTask task = getDetailById(inspectTaskId);
+        validateMobileAssignee(task);
+        task.setAttachmentBaseUrl(minioEndpoint.replaceAll("/+$", "") + "/" + minioBucket + "/");
+        return task;
+    }
+
     /**
     /**
      * 新增巡检任务
      * 新增巡检任务
      */
      */
@@ -220,6 +245,16 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
     @Override
     @Override
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public boolean completeTask(InspectTaskCompleteDTO dto) {
     public boolean completeTask(InspectTaskCompleteDTO dto) {
+        return completeTask(dto, false);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean completeMobileTask(InspectTaskCompleteDTO dto) {
+        return completeTask(dto, true);
+    }
+
+    private boolean completeTask(InspectTaskCompleteDTO dto, boolean validateAssignee) {
         log.info("完成巡检-入参:{}", dto.getInspectTaskId());
         log.info("完成巡检-入参:{}", dto.getInspectTaskId());
         if (dto.getInspectTaskId() == null) {
         if (dto.getInspectTaskId() == null) {
             throw new ServiceException("巡检任务ID不能为空");
             throw new ServiceException("巡检任务ID不能为空");
@@ -237,6 +272,9 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         if (realTime == null) {
         if (realTime == null) {
             throw new ServiceException("实际巡检时间格式不正确");
             throw new ServiceException("实际巡检时间格式不正确");
         }
         }
+        if (realTime.isAfter(LocalDateTime.now())) {
+            throw new ServiceException("实际巡检时间不能晚于当前时间");
+        }
         InspectTask exist = this.getById(dto.getInspectTaskId());
         InspectTask exist = this.getById(dto.getInspectTaskId());
         if (exist == null) {
         if (exist == null) {
             throw new ServiceException("巡检任务不存在或已被删除");
             throw new ServiceException("巡检任务不存在或已被删除");
@@ -244,10 +282,16 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         if (!Integer.valueOf(STATUS_PENDING).equals(exist.getStatus())) {
         if (!Integer.valueOf(STATUS_PENDING).equals(exist.getStatus())) {
             throw new ServiceException("该巡检任务已完成,不可重复提交");
             throw new ServiceException("该巡检任务已完成,不可重复提交");
         }
         }
+        if (validateAssignee) {
+            validateMobileAssignee(exist);
+        }
         LambdaUpdateWrapper<InspectTask> updateWrapper = new LambdaUpdateWrapper<>();
         LambdaUpdateWrapper<InspectTask> updateWrapper = new LambdaUpdateWrapper<>();
         updateWrapper.set(InspectTask::getRealTime, realTime).set(InspectTask::getProblem, dto.getProblem()).set(InspectTask::getProcess, dto.getProcess()).set(InspectTask::getPhotoUrl, dto.getPhotoUrl()).set(InspectTask::getStatus, STATUS_DONE).set(InspectTask::getUpdateBy, currentUserId()).set(InspectTask::getUpdateTime, LocalDateTime.now()).eq(InspectTask::getInspectTaskId, dto.getInspectTaskId())
         updateWrapper.set(InspectTask::getRealTime, realTime).set(InspectTask::getProblem, dto.getProblem()).set(InspectTask::getProcess, dto.getProcess()).set(InspectTask::getPhotoUrl, dto.getPhotoUrl()).set(InspectTask::getStatus, STATUS_DONE).set(InspectTask::getUpdateBy, currentUserId()).set(InspectTask::getUpdateTime, LocalDateTime.now()).eq(InspectTask::getInspectTaskId, dto.getInspectTaskId())
                 // 状态门禁:仅待巡检且未删除的记录可被完成,防止并发重复提交
                 // 状态门禁:仅待巡检且未删除的记录可被完成,防止并发重复提交
                 .eq(InspectTask::getStatus, STATUS_PENDING).eq(InspectTask::getDelFlag, DEL_FLAG_NORMAL);
                 .eq(InspectTask::getStatus, STATUS_PENDING).eq(InspectTask::getDelFlag, DEL_FLAG_NORMAL);
+        if (validateAssignee) {
+            updateWrapper.eq(InspectTask::getAssignUserId, currentUserId());
+        }
         boolean updated = this.update(updateWrapper);
         boolean updated = this.update(updateWrapper);
         if (!updated) {
         if (!updated) {
             throw new ServiceException("该巡检任务已完成或已被删除,无法重复完成巡检");
             throw new ServiceException("该巡检任务已完成或已被删除,无法重复完成巡检");
@@ -255,6 +299,13 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         return true;
         return true;
     }
     }
 
 
+    private void validateMobileAssignee(InspectTask task) {
+        Long userId = currentUserId();
+        if (userId == null || task == null || !userId.equals(task.getAssignUserId())) {
+            throw new ServiceException("无权查看或操作该巡检任务");
+        }
+    }
+
     /**
     /**
      * 删除巡检任务(逻辑删除)
      * 删除巡检任务(逻辑删除)
      */
      */

+ 214 - 54
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java

@@ -15,6 +15,7 @@ import com.zksy.ops.domain.WorkOrderLog;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderPushVO;
 import com.zksy.ops.domain.vo.WorkOrderPushVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
+import com.zksy.ops.domain.vo.MobileAssignableUserVO;
 import com.zksy.ops.enums.WorkOrderDelayApplyStatusEnum;
 import com.zksy.ops.enums.WorkOrderDelayApplyStatusEnum;
 import com.zksy.common.core.domain.entity.SysDictData;
 import com.zksy.common.core.domain.entity.SysDictData;
 import com.zksy.system.service.ISysDictDataService;
 import com.zksy.system.service.ISysDictDataService;
@@ -46,6 +47,7 @@ import com.zksy.ops.dto.WorkOrderVerifyDTO;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.ops.service.WorkOrderService;
 import com.zksy.ops.support.OpsAssignScopeSupport;
 import com.zksy.ops.support.OpsAssignScopeSupport;
 import com.zksy.system.service.ISysUserService;
 import com.zksy.system.service.ISysUserService;
+import com.zksy.system.service.ISysMenuService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
@@ -62,7 +64,6 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Objects;
 import java.util.Set;
 import java.util.Set;
@@ -105,11 +106,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
      */
      */
     private static final long DEFAULT_PAGE_SIZE = 10L;
     private static final long DEFAULT_PAGE_SIZE = 10L;
 
 
-    /**
-     * 框架数据范围参数名,与 DataScopeAspect.DATA_SCOPE 保持一致。
-     * zksy-system 模块不依赖 zksy-framework(DataScopeAspect 所在模块),故此处使用字面量。
-     */
-    private static final String DATA_SCOPE = "dataScope";
+    private static final String PERM_DISPATCH = "ops:workOrder:dispatch";
+    private static final String PERM_PROCESS = "ops:workOrder:process";
+    private static final String PERM_AUDIT = "ops:workOrder:audit";
 
 
     @Value("${minio.endpoint}")
     @Value("${minio.endpoint}")
     private String minioEndpoint;
     private String minioEndpoint;
@@ -142,6 +141,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Autowired
     @Autowired
     private ISysUserService sysUserService;
     private ISysUserService sysUserService;
 
 
+    @Autowired
+    private ISysMenuService sysMenuService;
+
     @Autowired
     @Autowired
     private ISysDictDataService sysDictDataService;
     private ISysDictDataService sysDictDataService;
 
 
@@ -170,6 +172,37 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return voPage;
         return voPage;
     }
     }
 
 
+    @Override
+    public Page<WorkOrderVO> findMobilePage(WorkOrderPageDTO query) {
+        return findMobilePage(query, false);
+    }
+
+    @Override
+    public Page<WorkOrderVO> findMobilePendingPage(WorkOrderPageDTO query) {
+        return findMobilePage(query, true);
+    }
+
+    private Page<WorkOrderVO> findMobilePage(WorkOrderPageDTO query, boolean pending) {
+        WorkOrderPageDTO condition = query == null ? new WorkOrderPageDTO() : query;
+        long pageNum = condition.getPageNum() == null || condition.getPageNum() < 1 ? 1L : condition.getPageNum();
+        long pageSize = condition.getPageSize() == null || condition.getPageSize() < 1 ? DEFAULT_PAGE_SIZE : Math.min(condition.getPageSize(), 100L);
+        Long userId = currentUserId();
+        if (userId == null) {
+            return new Page<>(pageNum, pageSize);
+        }
+        LambdaQueryWrapper<WorkOrder> wrapper = buildFilterWrapper(condition, true);
+        applyMobileScope(wrapper, userId, pending);
+        wrapper.orderByAsc(WorkOrder::getOrderLevel)
+                .orderByAsc(WorkOrder::getOrderStatus)
+                .orderByDesc(WorkOrder::getFaultTime)
+                .orderByDesc(WorkOrder::getCreateTime);
+        Page<WorkOrderVO> result = toVOPage(this.page(new Page<>(pageNum, pageSize), wrapper));
+        fillUserName(result.getRecords());
+        fillDeviceInfo(result.getRecords());
+        fillSourceNumbers(result.getRecords());
+        return result;
+    }
+
     /**
     /**
      * 实体分页转出参分页:分页元信息(总数/页码/每页条数)原样保留,records 逐条转出参。
      * 实体分页转出参分页:分页元信息(总数/页码/每页条数)原样保留,records 逐条转出参。
      *
      *
@@ -201,7 +234,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         }
         }
 
 
         // 不叠加状态筛选,否则点击某个状态后其它状态的计数会全部变成 0
         // 不叠加状态筛选,否则点击某个状态后其它状态的计数会全部变成 0
-        List<Map<String, Object>> rows = baseMapper.countByStatus(buildFilterWrapper(condition, false));
+        LambdaQueryWrapper<WorkOrder> wrapper = buildFilterWrapper(condition, false);
+        applyPcScope(wrapper);
+        List<Map<String, Object>> rows = baseMapper.countByStatus(wrapper);
         long total = 0L;
         long total = 0L;
         if (!CollectionUtils.isEmpty(rows)) {
         if (!CollectionUtils.isEmpty(rows)) {
             for (Map<String, Object> row : rows) {
             for (Map<String, Object> row : rows) {
@@ -231,9 +266,26 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         WorkOrderDetailVO detail = new WorkOrderDetailVO();
         WorkOrderDetailVO detail = new WorkOrderDetailVO();
         detail.setOrder(vo);
         detail.setOrder(vo);
         detail.setLogs(listLogs(orderId));
         detail.setLogs(listLogs(orderId));
+        detail.setAttachmentBaseUrl(attachmentBaseUrl());
         return detail;
         return detail;
     }
     }
 
 
+    private String attachmentBaseUrl() {
+        if (StringUtils.isBlank(minioEndpoint) || StringUtils.isBlank(minioBucket)) {
+            return null;
+        }
+        return minioEndpoint.replaceAll("/+$", "") + "/" + minioBucket + "/";
+    }
+
+    @Override
+    public WorkOrderDetailVO getMobileDetail(Long orderId) {
+        WorkOrder order = getOrderById(orderId);
+        if (!isMobileManagementVisible(order, currentUserId())) {
+            throw new ServiceException("无权查看该工单");
+        }
+        return getDetail(orderId);
+    }
+
     @Override
     @Override
     public List<WorkOrderLogVO> listLogs(Long orderId) {
     public List<WorkOrderLogVO> listLogs(Long orderId) {
         if (orderId == null) {
         if (orderId == null) {
@@ -261,6 +313,24 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return assignScopeSupport.assignableUsers(deptId);
         return assignScopeSupport.assignableUsers(deptId);
     }
     }
 
 
+    @Override
+    public List<MobileAssignableUserVO> mobileAssignableUsers(String type) {
+        String permission;
+        if ("handler".equals(type)) {
+            permission = PERM_PROCESS;
+        } else if ("reviewer".equals(type)) {
+            permission = PERM_AUDIT;
+        } else {
+            throw new ServiceException("候选人类型不正确");
+        }
+        return assignScopeSupport.assignableUsersInScope().stream()
+                .filter(this::isNormalUser)
+                .filter(user -> userHasPermission(user.getUserId(), permission))
+                .map(user -> new MobileAssignableUserVO(user.getUserId(), userLabel(user), user.getDeptId(),
+                        user.getDept() == null ? null : user.getDept().getDeptName()))
+                .collect(Collectors.toList());
+    }
+
     @Override
     @Override
     public List<WorkOrderPushVO> listPendingOrOverdue(Date now) {
     public List<WorkOrderPushVO> listPendingOrOverdue(Date now) {
         Date currentTime = now == null ? new Date() : now;
         Date currentTime = now == null ? new Date() : now;
@@ -295,10 +365,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         }
         }
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
-        // 数据按部门隔离:默认归属创建人所在部门(框架 DataScopeAspect 依据该字段过滤)
-        if (workOrder.getDeptId() == null) {
-            workOrder.setDeptId(assignScopeSupport.currentDeptId());
-        }
         workOrder.setCreateTime(now);
         workOrder.setCreateTime(now);
         workOrder.setCreateBy(currentUserId());
         workOrder.setCreateBy(currentUserId());
         workOrder.setUpdateTime(now);
         workOrder.setUpdateTime(now);
@@ -360,7 +426,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         workOrder.setFaultTime(alarm.getCreateTime() == null
         workOrder.setFaultTime(alarm.getCreateTime() == null
                 ? now
                 ? now
                 : java.sql.Timestamp.valueOf(alarm.getCreateTime()));
                 : java.sql.Timestamp.valueOf(alarm.getCreateTime()));
-        workOrder.setDeptId(assignScopeSupport.currentDeptId());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setCreateTime(now);
         workOrder.setCreateTime(now);
@@ -408,8 +473,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         workOrder.setDeviceId(equipmentId);
         workOrder.setDeviceId(equipmentId);
         workOrder.setOrderLevel(normalizeWorkOrderLevel(mapHazardLevelToOrderLevel(hazardLevel)));
         workOrder.setOrderLevel(normalizeWorkOrderLevel(mapHazardLevelToOrderLevel(hazardLevel)));
         workOrder.setOrderDesc(hazardDesc);
         workOrder.setOrderDesc(hazardDesc);
-        // 数据按部门隔离:默认归属创建人所在部门(框架 DataScopeAspect 依据该字段过滤)
-        workOrder.setDeptId(assignScopeSupport.currentDeptId());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setPlanFinishTime(planFinishTime);
         workOrder.setPlanFinishTime(planFinishTime);
@@ -448,21 +511,22 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         if (!Objects.equals(WorkOrderStatusEnum.PENDING_DISPATCH.getCode(), order.getOrderStatus())) {
         if (!Objects.equals(WorkOrderStatusEnum.PENDING_DISPATCH.getCode(), order.getOrderStatus())) {
             throw new ServiceException("仅待派单工单允许派单");
             throw new ServiceException("仅待派单工单允许派单");
         }
         }
+        requireDispatchScope(order);
         assignScopeSupport.validateAssignUserInScope(handlerId);
         assignScopeSupport.validateAssignUserInScope(handlerId);
         SysUser handler = sysUserService.selectUserById(handlerId);
         SysUser handler = sysUserService.selectUserById(handlerId);
         if (handler == null || !UserConstants.NORMAL.equals(String.valueOf(handler.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(handler.getDelFlag()))) {
         if (handler == null || !UserConstants.NORMAL.equals(String.valueOf(handler.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(handler.getDelFlag()))) {
             throw new ServiceException("责任人不存在或已停用");
             throw new ServiceException("责任人不存在或已停用");
         }
         }
+        requireUserPermission(handlerId, PERM_PROCESS, "责任人");
         assignScopeSupport.validateAssignUserInScope(verifyUserId);
         assignScopeSupport.validateAssignUserInScope(verifyUserId);
         SysUser verifier = sysUserService.selectUserById(verifyUserId);
         SysUser verifier = sysUserService.selectUserById(verifyUserId);
         if (verifier == null || !UserConstants.NORMAL.equals(String.valueOf(verifier.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(verifier.getDelFlag()))) {
         if (verifier == null || !UserConstants.NORMAL.equals(String.valueOf(verifier.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(verifier.getDelFlag()))) {
             throw new ServiceException("核验人不存在或已停用");
             throw new ServiceException("核验人不存在或已停用");
         }
         }
+        requireUserPermission(verifyUserId, PERM_AUDIT, "核验人");
 
 
         Date now = new Date();
         Date now = new Date();
         this.update(updateWrapper(id).set(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_ACCEPT.getCode()).set(WorkOrder::getReceiveUser, handlerId).set(WorkOrder::getVerifyUser, verifyUserId)
         this.update(updateWrapper(id).set(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_ACCEPT.getCode()).set(WorkOrder::getReceiveUser, handlerId).set(WorkOrder::getVerifyUser, verifyUserId)
-                // 数据归属部门跟随派单指定的责任人
-                .set(handler.getDeptId() != null, WorkOrder::getDeptId, handler.getDeptId())
                 // 计划完成时间:派单时可设定/修改,为空则保留原值
                 // 计划完成时间:派单时可设定/修改,为空则保留原值
                 .set(dto.getPlanFinishTime() != null, WorkOrder::getPlanFinishTime, dto.getPlanFinishTime())
                 .set(dto.getPlanFinishTime() != null, WorkOrder::getPlanFinishTime, dto.getPlanFinishTime())
                 .eq(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_DISPATCH.getCode()));
                 .eq(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_DISPATCH.getCode()));
@@ -477,6 +541,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public boolean accept(Long orderId, WorkOrderOperateDTO dto) {
     public boolean accept(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
         WorkOrder order = getOrderById(orderId);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "核查接单");
         requireResponsible(order, currentUserId(), "核查接单");
         requireStatus(order, WorkOrderStatusEnum.PENDING_ACCEPT, "仅待接单工单允许核查接单");
         requireStatus(order, WorkOrderStatusEnum.PENDING_ACCEPT, "仅待接单工单允许核查接单");
         // 核查接单必须上传现场情况附件
         // 核查接单必须上传现场情况附件
@@ -496,13 +561,14 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public boolean process(Long orderId, WorkOrderOperateDTO dto) {
     public boolean process(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
         WorkOrder order = getOrderById(orderId);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "处置");
         requireResponsible(order, currentUserId(), "处置");
         Integer status = order.getOrderStatus();
         Integer status = order.getOrderStatus();
         if (!WorkOrderStatusEnum.canProcess(status)) {
         if (!WorkOrderStatusEnum.canProcess(status)) {
             throw new ServiceException("当前工单状态不允许处置");
             throw new ServiceException("当前工单状态不允许处置");
         }
         }
 
 
-        // 已接单/延期中 → 处理中;处理中保持原状态,支持多次处置
+        // 已接单/延期中处置后进入处理中;处理中不允许重复提交处置
         Integer targetStatus = (Objects.equals(WorkOrderStatusEnum.ACCEPTED.getCode(), status)
         Integer targetStatus = (Objects.equals(WorkOrderStatusEnum.ACCEPTED.getCode(), status)
                 || Objects.equals(WorkOrderStatusEnum.DELAYING.getCode(), status))
                 || Objects.equals(WorkOrderStatusEnum.DELAYING.getCode(), status))
                 ? WorkOrderStatusEnum.PROCESSING.getCode()
                 ? WorkOrderStatusEnum.PROCESSING.getCode()
@@ -518,6 +584,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public boolean submitVerify(Long orderId, WorkOrderOperateDTO dto) {
     public boolean submitVerify(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
         WorkOrder order = getOrderById(orderId);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "提交验收");
         requireResponsible(order, currentUserId(), "提交验收");
         Integer status = order.getOrderStatus();
         Integer status = order.getOrderStatus();
         if (!WorkOrderStatusEnum.canSubmitVerify(status)) {
         if (!WorkOrderStatusEnum.canSubmitVerify(status)) {
@@ -544,6 +611,8 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("验收结果不能为空");
             throw new ServiceException("验收结果不能为空");
         }
         }
         WorkOrder order = getOrderById(id);
         WorkOrder order = getOrderById(id);
+        requireDeviceCompanyScope(order);
+        requireVerifier(order, currentUserId(), "验收");
         requireStatus(order, WorkOrderStatusEnum.PENDING_VERIFY, "仅待验收工单允许验收");
         requireStatus(order, WorkOrderStatusEnum.PENDING_VERIFY, "仅待验收工单允许验收");
 
 
         Date now = new Date();
         Date now = new Date();
@@ -576,14 +645,20 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     public boolean cancel(Long orderId, WorkOrderOperateDTO dto) {
     public boolean cancel(Long orderId, WorkOrderOperateDTO dto) {
         Long id = requireOrderId(orderId);
         Long id = requireOrderId(orderId);
         WorkOrder order = getOrderById(id);
         WorkOrder order = getOrderById(id);
+        if (!isMobileManagementVisible(order, currentUserId())) {
+            throw new ServiceException("无权取消该工单");
+        }
         if (!WorkOrderStatusEnum.canCancel(order.getOrderStatus())) {
         if (!WorkOrderStatusEnum.canCancel(order.getOrderStatus())) {
-            throw new ServiceException("当前工单状态不允许取消");
+            throw new ServiceException("仅待派单、待接单工单允许取消");
         }
         }
         if (StringUtils.isEmpty(dto.getContent())) {
         if (StringUtils.isEmpty(dto.getContent())) {
             throw new ServiceException("请填写取消原因");
             throw new ServiceException("请填写取消原因");
         }
         }
 
 
         boolean updated = transition(id, order.getOrderStatus(), WorkOrderStatusEnum.CANCELLED.getCode(), null);
         boolean updated = transition(id, order.getOrderStatus(), WorkOrderStatusEnum.CANCELLED.getCode(), null);
+        if (!updated) {
+            throw new ServiceException("工单状态已变更,请刷新后重试");
+        }
         writeLog(id, WorkOrderLogOperTypeEnum.CANCEL, buildDesc("原因", dto.getContent()), dto.getAttachUrl());
         writeLog(id, WorkOrderLogOperTypeEnum.CANCEL, buildDesc("原因", dto.getContent()), dto.getAttachUrl());
         return updated;
         return updated;
     }
     }
@@ -593,6 +668,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     public boolean applyDelay(Long orderId, WorkOrderDelayApplyDTO dto) {
     public boolean applyDelay(Long orderId, WorkOrderDelayApplyDTO dto) {
         Long id = requireOrderId(orderId);
         Long id = requireOrderId(orderId);
         WorkOrder order = getOrderById(id);
         WorkOrder order = getOrderById(id);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "申请延期");
         requireResponsible(order, currentUserId(), "申请延期");
         requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许申请延期");
         requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许申请延期");
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.NONE.getCode(), order.getDelayApplyStatus())) {
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.NONE.getCode(), order.getDelayApplyStatus())) {
@@ -621,6 +697,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("延期审核结果不能为空");
             throw new ServiceException("延期审核结果不能为空");
         }
         }
         WorkOrder order = getOrderById(id);
         WorkOrder order = getOrderById(id);
+        requireDeviceCompanyScope(order);
+        requireVerifier(order, currentUserId(), "延期审核");
+        requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许延期审核");
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.PENDING_AUDIT.getCode(), order.getDelayApplyStatus())) {
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.PENDING_AUDIT.getCode(), order.getDelayApplyStatus())) {
             throw new ServiceException("该工单没有待审核的延期申请");
             throw new ServiceException("该工单没有待审核的延期申请");
         }
         }
@@ -656,17 +735,16 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return true;
         return true;
     }
     }
 
 
-    /**
-     * 构建工单分页查询条件(含数据权限片段与设备类别收窄)
-     */
+    /** 构建 PC 端工单分页查询条件(含参与人数据权限与设备类别收窄)。 */
     private LambdaQueryWrapper<WorkOrder> buildQueryWrapper(WorkOrderPageDTO condition) {
     private LambdaQueryWrapper<WorkOrder> buildQueryWrapper(WorkOrderPageDTO condition) {
         LambdaQueryWrapper<WorkOrder> wrapper = buildFilterWrapper(condition, true);
         LambdaQueryWrapper<WorkOrder> wrapper = buildFilterWrapper(condition, true);
+        applyPcScope(wrapper);
         wrapper.orderByAsc(WorkOrder::getOrderLevel).orderByAsc(WorkOrder::getOrderStatus).orderByDesc(WorkOrder::getFaultTime).orderByDesc(WorkOrder::getCreateTime);
         wrapper.orderByAsc(WorkOrder::getOrderLevel).orderByAsc(WorkOrder::getOrderStatus).orderByDesc(WorkOrder::getFaultTime).orderByDesc(WorkOrder::getCreateTime);
         return wrapper;
         return wrapper;
     }
     }
 
 
     /**
     /**
-     * 构建工单筛选条件(数据权限片段 + 业务条件 + 设备类别收窄),列表查询与状态统计共用。
+     * 构建工单业务筛选条件,PC 与移动端查询共用。
      *
      *
      * <p>返回值刻意不含 orderBy:状态统计要按状态分组,带排序在 MySQL ONLY_FULL_GROUP_BY 下会报错。</p>
      * <p>返回值刻意不含 orderBy:状态统计要按状态分组,带排序在 MySQL ONLY_FULL_GROUP_BY 下会报错。</p>
      *
      *
@@ -677,11 +755,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     private LambdaQueryWrapper<WorkOrder> buildFilterWrapper(WorkOrderPageDTO condition, boolean withStatus) {
     private LambdaQueryWrapper<WorkOrder> buildFilterWrapper(WorkOrderPageDTO condition, boolean withStatus) {
         LambdaQueryWrapper<WorkOrder> wrapper = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<WorkOrder> wrapper = new LambdaQueryWrapper<>();
 
 
-        String dataScope = dataScopeSql(condition);
-        if (StringUtils.isNotEmpty(dataScope)) {
-            wrapper.apply(dataScope);
-        }
-
         wrapper.eq(condition.getOrderLevel() != null, WorkOrder::getOrderLevel, condition.getOrderLevel())
         wrapper.eq(condition.getOrderLevel() != null, WorkOrder::getOrderLevel, condition.getOrderLevel())
                 .eq(condition.getDelayApplyStatus() != null, WorkOrder::getDelayApplyStatus, condition.getDelayApplyStatus())
                 .eq(condition.getDelayApplyStatus() != null, WorkOrder::getDelayApplyStatus, condition.getDelayApplyStatus())
                 .eq(StringUtils.isNotEmpty(condition.getDeviceId()), WorkOrder::getDeviceId, condition.getDeviceId())
                 .eq(StringUtils.isNotEmpty(condition.getDeviceId()), WorkOrder::getDeviceId, condition.getDeviceId())
@@ -720,6 +793,117 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return wrapper;
         return wrapper;
     }
     }
 
 
+    /** PC 端数据范围:普通用户仅可见自己创建、接单或验收的工单,超级管理员不受限制。 */
+    private void applyPcScope(LambdaQueryWrapper<WorkOrder> wrapper) {
+        if (assignScopeSupport.isAdmin()) {
+            return;
+        }
+        Long userId = currentUserId();
+        if (userId == null) {
+            wrapper.eq(WorkOrder::getOrderId, -1L);
+            return;
+        }
+        wrapper.and(w -> w.eq(WorkOrder::getCreateBy, userId)
+                .or().eq(WorkOrder::getReceiveUser, userId)
+                .or().eq(WorkOrder::getVerifyUser, userId));
+    }
+
+    /** 移动端“我的工单/待办工单”最终数据范围。 */
+    private void applyMobileScope(LambdaQueryWrapper<WorkOrder> wrapper, Long userId, boolean pending) {
+        String companySql = currentCompanyEquipmentSql();
+        int hasDispatch = SecurityUtils.hasPermi(PERM_DISPATCH) ? 1 : 0;
+        if (pending) {
+            wrapper.apply("((device_id IS NOT NULL AND " + companySql + " AND (" +
+                            "({1}=1 AND order_status=0) OR " +
+                            "(receive_user={0} AND order_status IN (1,2,3,6)) OR " +
+                            "(verify_user={0} AND (order_status=4 OR (order_status=3 AND delay_apply_status=1))))) OR " +
+                            "(device_id IS NULL AND (((create_by={0} AND {1}=1 AND order_status=0)) OR " +
+                            "(receive_user={0} AND order_status IN (1,2,3,6)) OR " +
+                            "(verify_user={0} AND (order_status=4 OR (order_status=3 AND delay_apply_status=1))))))",
+                    userId, hasDispatch);
+        } else {
+            wrapper.apply("((device_id IS NOT NULL AND " + companySql + " AND " +
+                            "(create_by={0} OR receive_user={0} OR verify_user={0} OR ({1}=1 AND order_status=0))) OR " +
+                            "(device_id IS NULL AND (create_by={0} OR receive_user={0} OR verify_user={0})))",
+                    userId, hasDispatch);
+        }
+    }
+
+    private String currentCompanyEquipmentSql() {
+        Set<Integer> parentIds = currentCompanyParentTypeIds();
+        if (parentIds.isEmpty()) {
+            return "1=0";
+        }
+        String ids = parentIds.stream().sorted().map(String::valueOf).collect(Collectors.joining(","));
+        return "EXISTS (SELECT 1 FROM app_user.equipment_base meb " +
+                "INNER JOIN app_user.equipment_type met ON met.id=meb.equipment_type_id " +
+                "WHERE meb.equipment_id=app_user.work_order.device_id AND met.parent_id IN (" + ids + "))";
+    }
+
+    private Set<Integer> currentCompanyParentTypeIds() {
+        return assignScopeSupport.currentCompanyEquipmentParentTypeIds();
+    }
+
+    private boolean isMobileManagementVisible(WorkOrder order, Long userId) {
+        if (order == null || userId == null) return false;
+        boolean participant = Objects.equals(order.getCreateBy(), userId)
+                || Objects.equals(order.getReceiveUser(), userId)
+                || Objects.equals(order.getVerifyUser(), userId);
+        if (StringUtils.isEmpty(order.getDeviceId())) return participant;
+        return isDeviceInCurrentCompany(order.getDeviceId())
+                && (participant || (SecurityUtils.hasPermi(PERM_DISPATCH)
+                && Objects.equals(order.getOrderStatus(), WorkOrderStatusEnum.PENDING_DISPATCH.getCode())));
+    }
+
+    private boolean isDeviceInCurrentCompany(String deviceId) {
+        if (StringUtils.isEmpty(deviceId)) return true;
+        EquipmentBase equipment = equipmentBaseMapper.selectById(deviceId);
+        if (equipment == null || equipment.getEquipmentTypeId() == null) return false;
+        EquipmentType type = equipmentTypeMapper.selectById(equipment.getEquipmentTypeId());
+        return type != null && type.getParentId() != null && currentCompanyParentTypeIds().contains(type.getParentId());
+    }
+
+    private void requireDeviceCompanyScope(WorkOrder order) {
+        if (order != null && StringUtils.isNotEmpty(order.getDeviceId()) && !isDeviceInCurrentCompany(order.getDeviceId())) {
+            throw new ServiceException("无权操作其他公司范围的工单");
+        }
+    }
+
+    private void requireDispatchScope(WorkOrder order) {
+        Long userId = currentUserId();
+        if (StringUtils.isEmpty(order.getDeviceId())) {
+            if (!Objects.equals(order.getCreateBy(), userId)) {
+                throw new ServiceException("无设备工单仅创建人可以派单");
+            }
+            return;
+        }
+        requireDeviceCompanyScope(order);
+    }
+
+    private void requireVerifier(WorkOrder order, Long userId, String operation) {
+        if (!Objects.equals(order.getVerifyUser(), userId)) {
+            throw new ServiceException("仅指定核验人可以" + operation);
+        }
+    }
+
+    private boolean isNormalUser(SysUser user) {
+        return user != null && UserConstants.NORMAL.equals(String.valueOf(user.getStatus()))
+                && UserConstants.NORMAL.equals(String.valueOf(user.getDelFlag()));
+    }
+
+    private boolean userHasPermission(Long userId, String permission) {
+        if (userId == null) return false;
+        if (SecurityUtils.isAdmin(userId)) return true;
+        Set<String> permissions = sysMenuService.selectMenuPermsByUserId(userId);
+        return permissions != null && SecurityUtils.hasPermi(permissions, permission);
+    }
+
+    private void requireUserPermission(Long userId, String permission, String label) {
+        if (!userHasPermission(userId, permission)) {
+            throw new ServiceException(label + "未配置所需工单操作权限");
+        }
+    }
+
     /**
     /**
      * Map 值转 Long:兼容不同驱动下 COUNT(*) 返回 Integer / Long / BigInteger / String 的差异
      * Map 值转 Long:兼容不同驱动下 COUNT(*) 返回 Integer / Long / BigInteger / String 的差异
      */
      */
@@ -734,38 +918,13 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return StringUtils.isNumeric(text) ? Long.valueOf(text) : null;
         return StringUtils.isNumeric(text) ? Long.valueOf(text) : null;
     }
     }
 
 
-    /**
-     * 解析并剥离数据权限片段中多余的 AND / OR 前缀。
-     *
-     * <p>DataScopeAspect 注入的片段形如 {@code AND (...)},而 wrapper.apply 会自动追加 AND,
-     * 故此处仅保留条件本体。</p>
-     */
-    private String dataScopeSql(WorkOrderPageDTO condition) {
-        Map<String, Object> params = condition.getParams();
-        if (params == null || params.isEmpty()) {
-            return null;
-        }
-        Object dataScope = params.get(DATA_SCOPE);
-        if (dataScope == null) {
-            return null;
-        }
-        String sql = String.valueOf(dataScope).trim();
-        String upper = sql.toUpperCase(Locale.ROOT);
-        if (upper.startsWith("AND ")) {
-            sql = sql.substring(4).trim();
-        } else if (upper.startsWith("OR ")) {
-            sql = sql.substring(3).trim();
-        }
-        return sql.length() == 0 ? null : sql;
-    }
-
     /**
     /**
      * 按设备父类别收窄工单查询范围(业务口径过滤)。
      * 按设备父类别收窄工单查询范围(业务口径过滤)。
      *
      *
      * <p>过滤链路:work_order.device_id → equipment_base.equipment_id
      * <p>过滤链路:work_order.device_id → equipment_base.equipment_id
      * → equipment_base.equipment_type_id → equipment_type.id → equipment_type.parent_id。
      * → equipment_base.equipment_type_id → equipment_type.id → equipment_type.parent_id。
      * 采用 EXISTS 子查询而非 JOIN,避免设备表一对多时放大分页结果集,也不影响已有的
      * 采用 EXISTS 子查询而非 JOIN,避免设备表一对多时放大分页结果集,也不影响已有的
-     * 数据权限片段(dataScope)与 MyBatis-Plus 分页计数。</p>
+     * 参与人数据权限与 MyBatis-Plus 分页计数。</p>
      *
      *
      * <p>parentTypeId 通过 MyBatis-Plus 的 apply 占位符 {0} 注入,由框架做 SQL 注入检查,不拼接原始入参。</p>
      * <p>parentTypeId 通过 MyBatis-Plus 的 apply 占位符 {0} 注入,由框架做 SQL 注入检查,不拼接原始入参。</p>
      *
      *
@@ -1022,8 +1181,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         item.setOrderStatus(status);
         item.setOrderStatus(status);
         item.setOrderStatusDesc(WorkOrderStatusEnum.getDescByCode(status));
         item.setOrderStatusDesc(WorkOrderStatusEnum.getDescByCode(status));
         item.setOrderLevel(order.getOrderLevel());
         item.setOrderLevel(order.getOrderLevel());
-        item.setDeptId(order.getDeptId());
+        item.setCreateBy(order.getCreateBy());
         item.setReceiveUser(order.getReceiveUser());
         item.setReceiveUser(order.getReceiveUser());
+        item.setVerifyUser(order.getVerifyUser());
         item.setDeviceId(order.getDeviceId());
         item.setDeviceId(order.getDeviceId());
         item.setDeviceCode(resolveDeviceCode(order.getDeviceId()));
         item.setDeviceCode(resolveDeviceCode(order.getDeviceId()));
         item.setOrderDesc(order.getOrderDesc());
         item.setOrderDesc(order.getOrderDesc());

+ 65 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/support/OpsAssignScopeSupport.java

@@ -155,6 +155,71 @@ public class OpsAssignScopeSupport {
         return userService.selectUserList(query);
         return userService.selectUserList(query);
     }
     }
 
 
+    /**
+     * 查询当前用户可指派范围内的全部人员。
+     *
+     * <p>移动端派单不展示部门树,因此一次返回范围内人员,再由工单服务按操作权限过滤。</p>
+     */
+    public List<SysUser> assignableUsersInScope() {
+        Set<Long> scope = scopeDeptIds();
+        List<SysUser> users = userService.selectUserList(new SysUser());
+        if (users == null || users.isEmpty() || scope == null) {
+            return users == null ? Collections.emptyList() : users;
+        }
+        if (scope.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return users.stream()
+                .filter(user -> user.getDeptId() != null && scope.contains(user.getDeptId()))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 当前用户部门路径(部门自身 + ancestors 中的祖先部门)。
+     */
+    public Set<Long> currentDeptPathIds() {
+        Long deptId = currentDeptId();
+        if (deptId == null) {
+            return Collections.emptySet();
+        }
+        Set<Long> result = new HashSet<>();
+        result.add(deptId);
+        SysDept dept = deptService.selectDeptById(deptId);
+        if (dept == null || StringUtils.isEmpty(dept.getAncestors())) {
+            return result;
+        }
+        for (String part : dept.getAncestors().split(",")) {
+            String value = part.trim();
+            if (StringUtils.isNotEmpty(value) && StringUtils.isNumeric(value)) {
+                result.add(Long.valueOf(value));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 当前用户所属公司的设备父类型范围(含共享窨井类型)。
+     *
+     * <p>部门路径包含 101/102/103 时分别代表供水/排水/燃气公司;窨井类型(4)
+     * 是三家公司共享的设备范围。
+     * 空集合表示当前部门不属于上述任一公司。</p>
+     */
+    public Set<Integer> currentCompanyEquipmentParentTypeIds() {
+        Set<Integer> result = currentCompanyEquipmentRootTypeIds();
+        if (!result.isEmpty()) result.add(4);
+        return result;
+    }
+
+    /** 当前用户所属公司的根设备类型范围,不包含共享窨井类型。 */
+    public Set<Integer> currentCompanyEquipmentRootTypeIds() {
+        Set<Long> deptPath = currentDeptPathIds();
+        Set<Integer> result = new HashSet<>();
+        if (deptPath.contains(101L)) result.add(1);
+        if (deptPath.contains(102L)) result.add(2);
+        if (deptPath.contains(103L)) result.add(3);
+        return result;
+    }
+
     /**
     /**
      * 校验被指派人是否处于当前登录用户可指派的部门范围内,否则抛出异常
      * 校验被指派人是否处于当前登录用户可指派的部门范围内,否则抛出异常
      */
      */

+ 4 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysLogininforMapper.java

@@ -2,6 +2,7 @@ package com.zksy.system.mapper;
 
 
 import java.util.List;
 import java.util.List;
 import com.zksy.system.domain.SysLogininfor;
 import com.zksy.system.domain.SysLogininfor;
+import org.apache.ibatis.annotations.Param;
 
 
 /**
 /**
  * 系统访问日志情况信息 数据层
  * 系统访问日志情况信息 数据层
@@ -25,6 +26,9 @@ public interface SysLogininforMapper
      */
      */
     public List<SysLogininfor> selectLogininforList(SysLogininfor logininfor);
     public List<SysLogininfor> selectLogininforList(SysLogininfor logininfor);
 
 
+    /** 按账号精确查询本人登录日志。 */
+    public List<SysLogininfor> selectLogininforListByUserName(@Param("userName") String userName);
+
     /**
     /**
      * 批量删除系统登录日志
      * 批量删除系统登录日志
      * 
      * 

+ 2 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysUserMapper.java

@@ -28,14 +28,12 @@ public interface SysUserMapper
     public List<SysUser> selectAllEnabledUser();
     public List<SysUser> selectAllEnabledUser();
 
 
     /**
     /**
-     * 查询指定部门及其全部下级部门的有效联系人。
+     * 查询全部有效联系人,排除当前登录用户和超级管理员。
      *
      *
-     * @param deptId 当前用户部门ID
      * @param excludeUserId 需要排除的当前用户ID
      * @param excludeUserId 需要排除的当前用户ID
      * @return 通讯录联系人
      * @return 通讯录联系人
      */
      */
-    public List<ContactUserVo> selectContactsByDeptId(@Param("deptId") Long deptId,
-            @Param("excludeUserId") Long excludeUserId);
+    public List<ContactUserVo> selectContacts(@Param("excludeUserId") Long excludeUserId);
 
 
     /**
     /**
      * 按用户ID批量查询有效用户(未删除且状态正常),用于按需回填姓名,避免全量拉取用户表
      * 按用户ID批量查询有效用户(未删除且状态正常),用于按需回填姓名,避免全量拉取用户表

+ 3 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/ISysLogininforService.java

@@ -25,6 +25,9 @@ public interface ISysLogininforService
      */
      */
     public List<SysLogininfor> selectLogininforList(SysLogininfor logininfor);
     public List<SysLogininfor> selectLogininforList(SysLogininfor logininfor);
 
 
+    /** 查询当前登录用户的日志,账号从登录态获取,管理员同样仅查询本人。 */
+    public List<SysLogininfor> selectCurrentUserLogininforList();
+
     /**
     /**
      * 批量删除系统登录日志
      * 批量删除系统登录日志
      * 
      * 

+ 2 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/ISysUserService.java

@@ -27,13 +27,12 @@ public interface ISysUserService
     public List<SysUser> selectAllEnabledUser();
     public List<SysUser> selectAllEnabledUser();
 
 
     /**
     /**
-     * 查询当前部门及其全部下级部门的有效联系人。
+     * 查询全部有效联系人,排除当前登录用户和超级管理员。
      *
      *
-     * @param deptId 当前用户部门ID
      * @param excludeUserId 需要排除的当前用户ID
      * @param excludeUserId 需要排除的当前用户ID
      * @return 通讯录联系人
      * @return 通讯录联系人
      */
      */
-    public List<ContactUserVo> selectContactsByDeptId(Long deptId, Long excludeUserId);
+    public List<ContactUserVo> selectContacts(Long excludeUserId);
 
 
     /**
     /**
      * 按用户ID批量查询有效用户(未删除且状态正常)
      * 按用户ID批量查询有效用户(未删除且状态正常)

+ 14 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysLogininforServiceImpl.java

@@ -7,6 +7,9 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
 import com.zksy.system.domain.SysLogininfor;
 import com.zksy.system.domain.SysLogininfor;
 import com.zksy.system.mapper.SysLogininforMapper;
 import com.zksy.system.mapper.SysLogininforMapper;
 import com.zksy.system.service.ISysLogininforService;
 import com.zksy.system.service.ISysLogininforService;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.common.utils.SecurityUtils;
+import com.zksy.common.utils.StringUtils;
 
 
 /**
 /**
  * 系统访问日志情况信息 服务层处理
  * 系统访问日志情况信息 服务层处理
@@ -44,6 +47,17 @@ public class SysLogininforServiceImpl implements ISysLogininforService
         return logininforMapper.selectLogininforList(logininfor);
         return logininforMapper.selectLogininforList(logininfor);
     }
     }
 
 
+    @Override
+    public List<SysLogininfor> selectCurrentUserLogininforList()
+    {
+        String userName = SecurityUtils.getUsername();
+        if (StringUtils.isEmpty(userName))
+        {
+            throw new ServiceException("未获取到当前登录账号");
+        }
+        return logininforMapper.selectLogininforListByUserName(userName);
+    }
+
     /**
     /**
      * 批量删除系统登录日志
      * 批量删除系统登录日志
      * 
      * 

+ 3 - 6
pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysUserServiceImpl.java

@@ -91,14 +91,11 @@ public class SysUserServiceImpl implements ISysUserService
     }
     }
 
 
     /**
     /**
-     * 查询当前部门及其全部下级部门的有效联系人。
+     * 查询全部有效联系人,排除当前登录用户和超级管理员。
      */
      */
     @Override
     @Override
-    public List<ContactUserVo> selectContactsByDeptId(Long deptId, Long excludeUserId) {
-        if (deptId == null) {
-            return new ArrayList<>();
-        }
-        return userMapper.selectContactsByDeptId(deptId, excludeUserId);
+    public List<ContactUserVo> selectContacts(Long excludeUserId) {
+        return userMapper.selectContacts(excludeUserId);
     }
     }
 
 
     /**
     /**

+ 43 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml

@@ -1,6 +1,49 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.zksy.base.alarm.mapper.AlarmOperationsMapper">
 <mapper namespace="com.zksy.base.alarm.mapper.AlarmOperationsMapper">
+    <!-- 已转工单按 device_id 取设备;待审核事件尚无工单,按报警设备编码取设备。 -->
+    <sql id="mobileJoins">
+        FROM app_user.alarm_data a
+        LEFT JOIN app_user.work_order w ON w.order_id = a.work_order_id
+        LEFT JOIN LATERAL (SELECT b.* FROM app_user.equipment_base b
+            WHERE (a.work_order_id IS NOT NULL AND b.equipment_id = w.device_id)
+               OR (a.work_order_id IS NULL AND b.equipment_code = a.device_code)
+            ORDER BY b.equipment_id LIMIT 1) e ON true
+        LEFT JOIN LATERAL (SELECT s.alarm_status FROM app_user.equipment_status s
+            WHERE s.equipment_id = e.equipment_id AND s.del_flag = '0'
+            ORDER BY s.status_update_time DESC NULLS LAST, s.status_id DESC LIMIT 1) st ON true
+        LEFT JOIN app_user.sys_user u ON u.user_id = w.receive_user
+    </sql>
+    <sql id="mobileScope">
+        <choose>
+            <when test="typeIds != null and typeIds.size() > 0">
+                AND e.equipment_type_id IN
+                <foreach collection="typeIds" item="typeId" open="(" separator="," close=")">#{typeId}</foreach>
+            </when>
+            <otherwise>AND 1 = 0</otherwise>
+        </choose>
+    </sql>
+    <select id="selectMobilePage" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="mobileJoins"/>
+        <where>
+            <include refid="mobileScope"/>
+            AND a.alarm_status = #{query.alarmStatus}
+            <if test="query.keyword != null and query.keyword != ''">
+                AND (a.device_code ILIKE CONCAT('%',#{query.keyword},'%')
+                    OR e.equipment_name ILIKE CONCAT('%',#{query.keyword},'%')
+                    OR a.alarm_no ILIKE CONCAT('%',#{query.keyword},'%'))
+            </if>
+            <if test="query.alarmLevel != null">AND a.alarm_level = #{query.alarmLevel}</if>
+            <if test="query.auditStatus != null">AND a.audit_status = #{query.auditStatus}</if>
+            <if test="query.startTime != null">AND a.create_time &gt;= #{query.startTime}</if>
+            <if test="query.endTime != null">AND a.create_time &lt; #{query.endTime}</if>
+        </where>
+        ORDER BY a.create_time DESC NULLS LAST, a.id DESC
+    </select>
+    <select id="selectMobileDetail" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="mobileJoins"/>
+        WHERE a.id = #{id} <include refid="mobileScope"/>
+    </select>
     <sql id="joins">
     <sql id="joins">
         FROM app_user.alarm_data a
         FROM app_user.alarm_data a
         LEFT JOIN LATERAL (SELECT b.* FROM app_user.equipment_base b WHERE b.equipment_code = a.device_code
         LEFT JOIN LATERAL (SELECT b.* FROM app_user.equipment_base b WHERE b.equipment_code = a.device_code

+ 5 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmReviewMapper.xml

@@ -1,6 +1,11 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.zksy.base.alarm.mapper.AlarmReviewMapper">
 <mapper namespace="com.zksy.base.alarm.mapper.AlarmReviewMapper">
+    <select id="selectMobileDetail" resultMap="review">
+        SELECT <include refid="com.zksy.base.alarm.mapper.AlarmOperationsMapper.columns"/>
+        <include refid="com.zksy.base.alarm.mapper.AlarmOperationsMapper.mobileJoins"/>
+        WHERE a.id = #{id} <include refid="com.zksy.base.alarm.mapper.AlarmOperationsMapper.mobileScope"/>
+    </select>
     <!-- EXISTS 保证一条报警一行;不按设备去重,否则会丢失待审核事件。 -->
     <!-- EXISTS 保证一条报警一行;不按设备去重,否则会丢失待审核事件。 -->
     <sql id="scope">
     <sql id="scope">
         <choose>
         <choose>

+ 1 - 2
pipe-network-service/zksy-system/src/main/resources/mapper/ops/WorkOrderMapper.xml

@@ -10,7 +10,6 @@
             <result property="deviceId" column="device_id" jdbcType="VARCHAR"/>
             <result property="deviceId" column="device_id" jdbcType="VARCHAR"/>
             <result property="orderLevel" column="order_level" jdbcType="TINYINT"/>
             <result property="orderLevel" column="order_level" jdbcType="TINYINT"/>
             <result property="orderDesc" column="order_desc" jdbcType="VARCHAR"/>
             <result property="orderDesc" column="order_desc" jdbcType="VARCHAR"/>
-            <result property="deptId" column="dept_id" jdbcType="BIGINT"/>
             <result property="receiveUser" column="receive_user" jdbcType="BIGINT"/>
             <result property="receiveUser" column="receive_user" jdbcType="BIGINT"/>
             <result property="orderStatus" column="order_status" jdbcType="TINYINT"/>
             <result property="orderStatus" column="order_status" jdbcType="TINYINT"/>
             <result property="delayApplyStatus" column="delay_apply_status" jdbcType="TINYINT"/>
             <result property="delayApplyStatus" column="delay_apply_status" jdbcType="TINYINT"/>
@@ -32,7 +31,7 @@
     <sql id="Base_Column_List">
     <sql id="Base_Column_List">
         order_id,order_no,device_id,
         order_id,order_no,device_id,
         order_level,fault_time,
         order_level,fault_time,
-        order_desc,dept_id,receive_user,order_status,
+        order_desc,receive_user,order_status,
         delay_apply_status,delayed_reason,delayed_deadline,plan_finish_time,
         delay_apply_status,delayed_reason,delayed_deadline,plan_finish_time,
         real_finish_time,repair_result,maintenance_before_img,maintenance_after_img,verify_user,
         real_finish_time,repair_result,maintenance_before_img,maintenance_after_img,verify_user,
         verify_time,create_time,create_by,update_time
         verify_time,create_time,create_by,update_time

+ 8 - 1
pipe-network-service/zksy-system/src/main/resources/mapper/system/SysLogininforMapper.xml

@@ -43,6 +43,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 		order by info_id desc
 		order by info_id desc
 	</select>
 	</select>
 	
 	
+	<select id="selectLogininforListByUserName" resultMap="SysLogininforResult">
+		select info_id, user_name, ipaddr, login_location, browser, os, status, msg, login_time
+		from app_user.sys_logininfor
+		where user_name = #{userName}
+		order by login_time desc, info_id desc
+	</select>
+
 	<delete id="deleteLogininforByIds" parameterType="Long">
 	<delete id="deleteLogininforByIds" parameterType="Long">
  		delete from app_user.sys_logininfor where info_id in
  		delete from app_user.sys_logininfor where info_id in
  		<foreach collection="array" item="infoId" open="(" separator="," close=")">
  		<foreach collection="array" item="infoId" open="(" separator="," close=")">
@@ -54,4 +61,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         truncate table app_user.sys_logininfor
         truncate table app_user.sys_logininfor
     </update>
     </update>
     
     
-</mapper> 
+</mapper>

+ 3 - 5
pipe-network-service/zksy-system/src/main/resources/mapper/system/SysUserMapper.xml

@@ -111,7 +111,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by u.nick_name
         order by u.nick_name
     </select>
     </select>
 
 
-    <select id="selectContactsByDeptId" resultMap="ContactUserResult">
+    <select id="selectContacts" resultMap="ContactUserResult">
         select u.user_id,
         select u.user_id,
                u.nick_name,
                u.nick_name,
                u.phonenumber,
                u.phonenumber,
@@ -120,15 +120,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                up.post_id,
                up.post_id,
                p.post_name
                p.post_name
         from app_user.sys_user u
         from app_user.sys_user u
-        inner join app_user.sys_dept d on d.dept_id = u.dept_id
+        left join app_user.sys_dept d on d.dept_id = u.dept_id
         left join app_user.sys_user_post up on up.user_id = u.user_id
         left join app_user.sys_user_post up on up.user_id = u.user_id
         left join app_user.sys_post p on p.post_id = up.post_id and p.status = '0'
         left join app_user.sys_post p on p.post_id = up.post_id and p.status = '0'
         where u.del_flag = '0'
         where u.del_flag = '0'
           and u.status = '0'
           and u.status = '0'
           and u.user_id &lt;&gt; #{excludeUserId}
           and u.user_id &lt;&gt; #{excludeUserId}
-          and d.del_flag = '0'
-          and (u.dept_id = #{deptId}
-               or POSITION(CONCAT(',', #{deptId}, ',') IN CONCAT(',', d.ancestors, ',')) &gt; 0)
+          and u.user_id &lt;&gt; 1
         order by d.order_num, u.nick_name, p.post_sort
         order by d.order_num, u.nick_name, p.post_sort
     </select>
     </select>
 
 

+ 49 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/mapper/MobileAlarmMapperTest.java

@@ -0,0 +1,49 @@
+package com.zksy.base.alarm.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.zksy.base.alarm.dto.AlarmQuery;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.junit.jupiter.api.Test;
+import java.io.InputStream;
+import java.util.*;
+import java.time.LocalDateTime;
+import static org.junit.jupiter.api.Assertions.*;
+
+class MobileAlarmMapperTest {
+    @Test void companyScopeUsesOrderDeviceAndPreservesEventsWithoutOrders() throws Exception {
+        MybatisConfiguration config = new MybatisConfiguration();
+        for (String name : Arrays.asList("AlarmReviewMapper", "AlarmOperationsMapper")) {
+            try (InputStream xml = getClass().getResourceAsStream("/mapper/base/" + name + ".xml")) {
+                new XMLMapperBuilder(xml, config, name, config.getSqlFragments()).parse();
+            }
+        }
+        AlarmQuery query = new AlarmQuery(); query.setAlarmStatus(0); query.setKeyword("BJ001");
+        query.setAuditStatus(2); query.setAlarmLevel(1);
+        query.setStartTime(LocalDateTime.of(2026, 10, 14, 0, 0));
+        query.setEndTime(LocalDateTime.of(2026, 10, 15, 0, 0));
+        Map<String,Object> params = new HashMap<>(); params.put("query",query);
+        params.put("typeIds",Collections.singletonList(11)); params.put("id","a1");
+        for (String statement : Arrays.asList(AlarmOperationsMapper.class.getName()+".selectMobilePage",
+                AlarmOperationsMapper.class.getName()+".selectMobileDetail", AlarmReviewMapper.class.getName()+".selectMobileDetail")) {
+            String sql = config.getMappedStatement(statement).getBoundSql(params).getSql().replaceAll("\\s+", " ");
+            assertTrue(sql.contains("a.work_order_id IS NOT NULL AND b.equipment_id = w.device_id"));
+            assertTrue(sql.contains("a.work_order_id IS NULL AND b.equipment_code = a.device_code"));
+            assertTrue(sql.contains("e.equipment_type_id IN"));
+            if (statement.endsWith("selectMobilePage")) {
+                assertTrue(sql.contains("AND a.alarm_status = ?"));
+                assertTrue(sql.contains("OR a.alarm_no ILIKE"));
+                assertTrue(sql.contains("AND a.audit_status = ?"));
+                assertTrue(sql.contains("AND a.alarm_level = ?"));
+                assertTrue(sql.contains("AND a.create_time >= ?"));
+                assertTrue(sql.contains("AND a.create_time < ?"));
+                assertTrue(sql.contains("ORDER BY a.create_time DESC NULLS LAST, a.id DESC"));
+            } else {
+                assertTrue(sql.contains("WHERE a.id = ?"));
+                assertFalse(sql.contains("AND a.alarm_status ="));
+            }
+            params.put("typeIds",Collections.emptyList());
+            assertTrue(config.getMappedStatement(statement).getBoundSql(params).getSql().contains("1 = 0"));
+            params.put("typeIds",Collections.singletonList(11));
+        }
+    }
+}

+ 111 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmMobileServiceTest.java

@@ -0,0 +1,111 @@
+package com.zksy.base.alarm.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.alarm.dto.*;
+import com.zksy.base.alarm.mapper.*;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.mapper.EquipmentTypeMapper;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.ops.support.OpsAssignScopeSupport;
+import com.zksy.ops.service.WorkOrderService;
+import com.zksy.system.mapper.SysUserMapper;
+import com.zksy.system.service.ISysDictDataService;
+import com.zksy.system.service.ISysMenuService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.*;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class AlarmMobileServiceTest {
+    private final OpsAssignScopeSupport company = mock(OpsAssignScopeSupport.class);
+    private final EquipmentTypeMapper types = mock(EquipmentTypeMapper.class);
+    private final AlarmReviewScopeService scope = new AlarmReviewScopeService(types, mock(ISysMenuService.class), company);
+    private final AlarmOperationsMapper queries = mock(AlarmOperationsMapper.class);
+    private final AlarmReviewMapper reviewQueries = mock(AlarmReviewMapper.class);
+    private final AlarmLogMapper logs = mock(AlarmLogMapper.class);
+    private final ISysDictDataService dictionaries = mock(ISysDictDataService.class);
+    private final AlarmOperationsService operations = new AlarmOperationsService(queries, scope, logs, dictionaries);
+    private final AlarmReviewAttachmentService attachments = mock(AlarmReviewAttachmentService.class);
+    private final AlarmReviewService reviews = new AlarmReviewService(reviewQueries, mock(AlarmDataMapper.class), logs,
+            scope, attachments, mock(WorkOrderService.class), mock(SysUserMapper.class), dictionaries);
+
+    private EquipmentType type(int id, int parent) {
+        EquipmentType type = new EquipmentType(); type.setId(id); type.setParentId(parent); return type;
+    }
+
+    @ParameterizedTest @ValueSource(ints = {1, 2, 3})
+    void scopeIncludesOnlyDirectCompanyChildren(int root) {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.singleton(root));
+        when(types.selectList(null)).thenReturn(Arrays.asList(type(1,0), type(2,0), type(3,0), type(4,0),
+                type(11,1), type(21,2), type(31,3), type(41,4), type(111,11)));
+        assertEquals(Collections.singletonList(root * 10 + 1), scope.resolveMobile(new MobileAlarmQuery()));
+    }
+    @Test void forgedRootBlocksEveryEntryBeforeReadingOrWriting() {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.singleton(1));
+        MobileAlarmQuery query = new MobileAlarmQuery(); query.setRootTypeId(2);
+        assertThrows(ServiceException.class, () -> operations.mobilePage(query));
+        assertThrows(ServiceException.class, () -> operations.mobileDetail("foreign", query));
+        assertThrows(ServiceException.class, () -> operations.mobileLogs("foreign", query));
+        assertThrows(ServiceException.class, () -> reviews.mobileReview("foreign", query, new AlarmReviewRequest()));
+        verifyNoInteractions(queries, reviewQueries, types, attachments, logs);
+    }
+    @Test void unknownCompanyFailsClosed() {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.emptySet());
+        assertTrue(scope.resolveMobile(new MobileAlarmQuery()).isEmpty());
+        verifyNoInteractions(types);
+        when(queries.selectMobilePage(any(), any(), anyList())).thenReturn(new Page<>());
+        operations.mobilePage(new MobileAlarmQuery());
+        verify(queries).selectMobilePage(any(), any(), eq(Collections.emptyList()));
+    }
+    @Test void pageFixesUnhandledScopeAndForwardsFiltersAndPagination() {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.singleton(2));
+        when(types.selectList(null)).thenReturn(Collections.singletonList(type(21,2)));
+        when(queries.selectMobilePage(any(), any(), anyList())).thenReturn(new Page<>());
+        MobileAlarmQuery query = new MobileAlarmQuery(); query.setPageNum(3); query.setPageSize(20);
+        query.setKeyword("  BJ123  "); query.setAuditStatus(2);
+        operations.mobilePage(query);
+        org.mockito.ArgumentCaptor<AlarmQuery> capture = org.mockito.ArgumentCaptor.forClass(AlarmQuery.class);
+        verify(queries).selectMobilePage(argThat(page -> page.getCurrent() == 3 && page.getSize() == 20),
+                capture.capture(), eq(Collections.singletonList(21)));
+        AlarmQuery actual = capture.getValue();
+        assertEquals(0, actual.getAlarmStatus()); assertEquals(2, actual.getAuditStatus());
+        assertEquals("BJ123", actual.getKeyword());
+        assertEquals(3, actual.getPageNum()); assertEquals(20, actual.getPageSize());
+    }
+    @Test void historyCannotBeReadBeforeDetailScopeCheck() {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.emptySet());
+        assertThrows(ServiceException.class, () -> operations.mobileLogs("foreign", new MobileAlarmQuery()));
+        verifyNoInteractions(logs);
+    }
+    @Test void todayCountKeepsMobileScopeAndUsesBeijingDayBounds() {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.singleton(1));
+        when(types.selectList(null)).thenReturn(Collections.singletonList(type(11, 1)));
+        when(queries.selectMobilePage(any(), any(), anyList())).thenReturn(new Page<AlarmVO>(1, 1, 4));
+        LocalDate before = LocalDate.now(ZoneId.of("Asia/Shanghai"));
+        assertEquals(4L, operations.mobileTodayCount());
+        LocalDate after = LocalDate.now(ZoneId.of("Asia/Shanghai"));
+        org.mockito.ArgumentCaptor<AlarmQuery> capture = org.mockito.ArgumentCaptor.forClass(AlarmQuery.class);
+        verify(queries).selectMobilePage(argThat(page -> page.getCurrent() == 1 && page.getSize() == 1),
+                capture.capture(), eq(Collections.singletonList(11)));
+        AlarmQuery actual = capture.getValue();
+        assertEquals(0, actual.getAlarmStatus());
+        assertTrue(actual.getStartTime().toLocalDate().equals(before) || actual.getStartTime().toLocalDate().equals(after));
+        assertEquals(actual.getStartTime().plusDays(1), actual.getEndTime());
+    }
+    @Test void reviewUsesSharedTransactionalAuditWithCompanyTypes() throws Exception {
+        when(company.currentCompanyEquipmentRootTypeIds()).thenReturn(Collections.singleton(1));
+        when(types.selectList(null)).thenReturn(Collections.singletonList(type(11,1)));
+        AlarmReviewRequest request = new AlarmReviewRequest(); request.setAuditStatus(1);
+        assertThrows(ServiceException.class, () -> reviews.mobileReview("foreign", new MobileAlarmQuery(), request));
+        verify(reviewQueries).selectMobileDetail(eq("foreign"), any(), eq(Collections.singletonList(11)));
+        verifyNoInteractions(attachments);
+        assertNotNull(AlarmReviewService.class.getMethod("mobileReview", String.class, MobileAlarmQuery.class,
+                AlarmReviewRequest.class).getAnnotation(Transactional.class));
+    }
+}

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

@@ -12,9 +12,11 @@ import static org.junit.jupiter.api.Assertions.*;
 class AlarmReviewMapperTest {
 class AlarmReviewMapperTest {
     private final MybatisConfiguration configuration = new MybatisConfiguration();
     private final MybatisConfiguration configuration = new MybatisConfiguration();
     @BeforeEach void parseMapper() throws Exception {
     @BeforeEach void parseMapper() throws Exception {
-        try (InputStream xml = getClass().getResourceAsStream("/mapper/base/AlarmReviewMapper.xml")) {
-            assertNotNull(xml);
-            new XMLMapperBuilder(xml, configuration, "AlarmReviewMapper.xml", configuration.getSqlFragments()).parse();
+        for (String name : Arrays.asList("AlarmReviewMapper", "AlarmOperationsMapper")) {
+            try (InputStream xml = getClass().getResourceAsStream("/mapper/base/" + name + ".xml")) {
+                assertNotNull(xml);
+                new XMLMapperBuilder(xml, configuration, name + ".xml", configuration.getSqlFragments()).parse();
+            }
         }
         }
     }
     }
     private BoundSql sql(String method, List<Integer> types, AlarmReviewQuery query) {
     private BoundSql sql(String method, List<Integer> types, AlarmReviewQuery query) {

+ 2 - 1
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewScopeServiceTest.java

@@ -18,7 +18,8 @@ import static org.mockito.Mockito.*;
 class AlarmReviewScopeServiceTest {
 class AlarmReviewScopeServiceTest {
     private final EquipmentTypeMapper types = mock(EquipmentTypeMapper.class);
     private final EquipmentTypeMapper types = mock(EquipmentTypeMapper.class);
     private final ISysMenuService menus = mock(ISysMenuService.class);
     private final ISysMenuService menus = mock(ISysMenuService.class);
-    private final AlarmReviewScopeService service = new AlarmReviewScopeService(types, menus);
+    private final com.zksy.ops.support.OpsAssignScopeSupport company = mock(com.zksy.ops.support.OpsAssignScopeSupport.class);
+    private final AlarmReviewScopeService service = new AlarmReviewScopeService(types, menus, company);
     private final AlarmReviewQuery query = new AlarmReviewQuery();
     private final AlarmReviewQuery query = new AlarmReviewQuery();
     @BeforeEach void login() {
     @BeforeEach void login() {
         SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
         SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(

Некоторые файлы не были показаны из-за большого количества измененных файлов