Эх сурвалжийг харах

Merge remote-tracking branch 'origin/master'

Kazerin 6 өдөр өмнө
parent
commit
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 首页导入错误修复)。
 
+### 移动端接口分层
+
+移动端专用 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
-更新日期:2026-09-10
+更新日期:2026-10-14
 决策依据:[ADR-0010 当前工单状态枚举为权威](../adr/0010-current-work-order-status-enum-is-authoritative.md)
 
 > 本文以最新统一工单代码和 `WorkOrderStatusEnum` 为权威。状态名是业务权威;接口编码按所属契约解释,不能跨接口复用裸数字。
@@ -33,7 +33,7 @@
 | 提交验收 | 责任人 | 处理中 / 延期中 | 待验收 | 处理后描述与附件 |
 | 验收通过 | 管理员 | 待验收 | 已结案 | 验收意见 |
 | 验收驳回 | 管理员 | 待验收 | 处理中 | 驳回原因,交同一责任人继续整改 |
-| 取消工单 | 管理员 | 待派单 / 待接单 / 已接单 / 处理中 / 待验收 | 已取消 | 取消原因 |
+| 取消工单 | 管理员 | 待派单 / 待接单 | 已取消 | 取消原因 |
 | 申请延期 | 责任人 | 处理中 | 待延期审核 | 延期原因和截止时间 |
 | 延期审核通过 | 管理员 | 待延期审核 | 延期中 | 审核意见 |
 | 延期审核驳回 | 管理员 | 待延期审核 | 处理中 | 审核意见 |
@@ -42,6 +42,7 @@
 
 - “验收驳回”是动作不是状态;被驳回的工单状态显示为“处理中”,并在时间线展示驳回原因。
 - “已取消”是终态,不能复活;如需继续处置,应另建新工单。
+- 取消仅允许待派单(0)、待接单(1);接单后所有状态均不可取消。此规则按 2026-10-14 用户要求收窄,取代旧 ADR-0008 的任意中间态可取消口径,状态编码不变。
 - “延期申请状态”与“工单状态”是两个来源,不得互相替代。
 - “已结案”不可再取消、驳回或回退。
 
@@ -55,4 +56,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` (
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID(主键)',
   `warning_no` varchar(64) NOT NULL COMMENT '预警编号',
-  `warning_name` varchar(256) 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_special` varchar(128) 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 '权属单位',
   `publisher` varchar(128) DEFAULT NULL COMMENT '发布人',
   `publish_time` datetime 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 '预警内容描述',
   `process_instance_id` varchar(128) DEFAULT NULL COMMENT '工作流实例ID',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',
@@ -51,6 +49,8 @@ DROP TABLE IF EXISTS `warning_attachment`;
 CREATE TABLE `warning_attachment` (
   `attachment_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_url` varchar(512) NOT NULL COMMENT '附件地址',
   `attachment_type` varchar(255) DEFAULT NULL COMMENT '附件类型(MIME,标准 MIME 可达 70+ 字符)',
@@ -89,6 +89,8 @@ CREATE TABLE `warning_supervision` (
   `supervision_content` text NOT NULL COMMENT '督办意见',
   `notification_type` varchar(32) NOT NULL 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 '附件地址',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -116,7 +118,8 @@ CREATE TABLE `warning_todo` (
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `user_id` varchar(64) NOT NULL COMMENT '用户ID',
   `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-已读)',
   `task_name` varchar(256) DEFAULT NULL COMMENT '任务名称',
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -163,12 +166,9 @@ CREATE TABLE `warning_archive` (
   `warning_id` varchar(64) NOT NULL COMMENT '预警ID',
   `archive_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_level` varchar(32) NOT 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 '权属单位',
   `publisher` varchar(128) 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
     (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,
      real_finish_time, repair_result, verify_user, verify_time, create_time, update_time,
      maintenance_before_img)
 VALUES
     ('TEST-WO-001', 'TEST-EQ-WL-002',
-     2, '供水监测报警测试工单-水位超限', NULL, NULL, 5,
+     2, '供水监测报警测试工单-水位超限', NULL, 5,
      NULL, NULL, NULL,
      NULL, '现场已处理', NULL, NULL, now(), now(),
      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,否则审核计数条取不到)
 -- ------------------------------------------------------------
 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)
 VALUES
     ('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,
-     '#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,
-     '#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,
-     '#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,
-     '#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,
-     '#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,
-     '#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,
-     '#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
    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);
     }
 
+
     @GetMapping("/all")
     @ApiOperation(value = "查询所有设备基础信息")
     @Anonymous
@@ -71,6 +72,7 @@ public class EquipmentBaseController {
         return AjaxResult.success(equipmentBase);
     }
 
+
     @PostMapping
     @ApiOperation(value = "新增设备基础信息")
     @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);
     }
 
+
     @Log(title = "登录日志", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('monitor:logininfor: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) {
         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.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
-import com.zksy.common.annotation.DataScope;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.core.domain.TreeSelect;
@@ -53,7 +52,6 @@ public class WorkOrderController {
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @GetMapping
     @ApiOperation("分页查询工单列表")
-    @DataScope(deptAlias = "app_user.work_order", permission = "ops:workOrder:query")
     public AjaxResult page(WorkOrderPageDTO query) {
         Page<WorkOrderVO> page = workOrderService.findByPage(query);
         return AjaxResult.success(page);
@@ -62,7 +60,6 @@ public class WorkOrderController {
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @GetMapping("/statusCount")
     @ApiOperation("按状态统计工单数量(列表状态筛选条使用,忽略入参中的状态筛选)")
-    @DataScope(deptAlias = "app_user.work_order", permission = "ops:workOrder:query")
     public AjaxResult statusCount(WorkOrderPageDTO query) {
         return AjaxResult.success(workOrderService.countByStatus(query));
     }
@@ -115,7 +112,7 @@ public class WorkOrderController {
         return workOrderService.dispatch(orderId, dto) ? AjaxResult.success("派单成功") : AjaxResult.error("派单失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:accept')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/accept")
     @ApiOperation("核查接单(待接单 → 已接单)")
     @Log(title = "工单接单", businessType = BusinessType.UPDATE)
@@ -125,13 +122,13 @@ public class WorkOrderController {
 
     @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/process")
-    @ApiOperation("工单处置(已接单/处理中/延期中 → 处理中,可多次处置)")
+    @ApiOperation("工单处置(已接单/延期中 → 处理中)")
     @Log(title = "工单处置", businessType = BusinessType.UPDATE)
     public AjaxResult process(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
         return workOrderService.process(orderId, dto) ? AjaxResult.success("处置成功") : AjaxResult.error("处置失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:submitVerify')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/submitVerify")
     @ApiOperation("提交验收(处理中/延期中 → 待验收)")
     @Log(title = "工单提交验收", businessType = BusinessType.UPDATE)
@@ -139,7 +136,7 @@ public class WorkOrderController {
         return workOrderService.submitVerify(orderId, dto) ? AjaxResult.success("提交验收成功") : AjaxResult.error("提交验收失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:verify')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:audit')")
     @PutMapping("/{orderId}/verify")
     @ApiOperation("工单验收(待验收 → 已结案 / 处理中)")
     @Log(title = "工单验收", businessType = BusinessType.UPDATE)
@@ -149,13 +146,13 @@ public class WorkOrderController {
 
     @PreAuthorize("@ss.hasPermi('ops:workOrder:cancel')")
     @PutMapping("/{orderId}/cancel")
-    @ApiOperation("取消工单(待派单/待接单/已接单/处理中/待验收 → 已取消)")
+    @ApiOperation("取消工单(仅待派单/待接单 → 已取消)")
     @Log(title = "取消工单", businessType = BusinessType.UPDATE)
     public AjaxResult cancel(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
         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")
     @ApiOperation("申请延期(处理中且无延期申请 → 待延期审核)")
     @Log(title = "工单申请延期", businessType = BusinessType.UPDATE)
@@ -163,7 +160,7 @@ public class WorkOrderController {
         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")
     @ApiOperation("延期审核(待延期审核 → 延期审核通过 / 延期审核驳回)")
     @Log(title = "工单延期审核", businessType = BusinessType.UPDATE)
@@ -179,6 +176,8 @@ public class WorkOrderController {
         return workOrderService.deleteWorkOrder(orderId) ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
     }
 
+
+
     /**
      * 入参 DTO 转工单实体:只映射创建阶段客户端可决定的字段,
      * 工单编号、状态、归属部门、创建时间等一律由 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());
     }
 
-    /**
-     * 获取当前用户所在部门及全部下级部门的通讯录。
-     */
-    @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.common.core.domain.model.LoginUser;
 import com.zksy.common.utils.DateUtils;
+import com.zksy.common.utils.SecurityUtils;
 import com.zksy.framework.web.service.TokenService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -279,10 +280,11 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
 
     static boolean isWorkOrderVisibleTo(WorkOrderPushVO item, LoginUser loginUser) {
         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() + ':'
                         + time(item.getEffectiveDeadline()) + ':'
                         + time(item.getUpdateTime()) + ':'
-                        + item.getDeptId() + ':'
-                        + item.getReceiveUser())
+                        + item.getCreateBy() + ':'
+                        + item.getReceiveUser() + ':'
+                        + item.getVerifyUser())
                 .collect(Collectors.joining("|"));
     }
 
@@ -140,7 +141,9 @@ public class WorkOrderWebSocketPushTask {
         if (loginUser == null) {
             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) {

+ 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.service.AlarmDataService;
 import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.domain.EarlyWarningStatusEnum;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.cockpit.infrastructure.BusinessCockpitInfrastructureEmergencySupportAdapter;
 import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportSnapshot;
@@ -91,7 +92,7 @@ class BusinessCockpitInfrastructureEmergencySupportAdapterTest {
         alarm.setAlarmStatus(0);
         EarlyWarning warning = new EarlyWarning();
         warning.setDeviceCode("GAS-001");
-        warning.setStatus("PENDING");
+        warning.setStatus(EarlyWarningStatusEnum.PENDING.getCode());
         WorkOrder order = new WorkOrder();
         order.setDeviceId("EQUIP-001");
         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 ----
 
+    /**
+     * 规格 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(
             String code, boolean online, boolean alarm, boolean warning, boolean workOrder) {
         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 ----
 
+    /**
+     * 规格 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) {
         return snapshot.getDevices().stream()
                 .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.service.EarlyWarningService;
 import com.zksy.cockpit.monitoring.BusinessCockpitMonitoringSituationAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationService;
 import com.zksy.cockpit.monitoring.CockpitMonitoringSituationSnapshot;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
 import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
@@ -184,6 +185,38 @@ class BusinessCockpitMonitoringSituationAdapterTest {
 
     // ---- 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) {
         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.zksy.base.alarm.domain.AlarmData;
 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.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
@@ -18,6 +22,8 @@ import java.sql.Timestamp;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 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.assertNull;
@@ -31,6 +37,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
     private AlarmDataService alarmDataService;
     private AlarmWarningRelMapper alarmWarningRelMapper;
     private WorkOrderService workOrderService;
+    private EquipmentBaseService equipmentBaseService;
+    private EquipmentTypeService equipmentTypeService;
     private BusinessCockpitOverviewWarningSettingsAdapter adapter;
 
     @BeforeEach
@@ -39,16 +47,28 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         alarmDataService = mock(AlarmDataService.class);
         alarmWarningRelMapper = mock(AlarmWarningRelMapper.class);
         workOrderService = mock(WorkOrderService.class);
+        equipmentBaseService = mock(EquipmentBaseService.class);
+        equipmentTypeService = mock(EquipmentTypeService.class);
         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
-    void bridgesWarningWorkOrderThroughRelationAndAggregatesTrend() {
+    void warningCarriesItsOwnLifecycleAndNoWorkOrderWhileAlarmKeepsWorkOrderLink() {
         EarlyWarning warning = new EarlyWarning();
         warning.setWarningId("WARN-ID-001");
         warning.setWarningNo("W-001");
         warning.setDeviceCode("GAS-001");
+        warning.setStatus("CLOSED");
+        warning.setWarningLevel("2");
         warning.setPublishTime(LocalDateTime.parse("2026-09-02T09:00:00"));
         when(earlyWarningService.list()).thenReturn(Collections.singletonList(warning));
 
@@ -56,6 +76,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         alarm.setId("A-001");
         alarm.setDeviceCode("GAS-001");
         alarm.setWorkOrderId(1001L);
+        alarm.setAlarmStatus(1);
+        alarm.setAlarmLevel(3);
         alarm.setCreateTime(LocalDateTime.parse("2026-09-02T10:00:00"));
         when(alarmDataService.list()).thenReturn(Collections.singletonList(alarm));
 
@@ -76,30 +98,78 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
 
         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("WO-001", alarmEvent.getWorkOrderCode());
         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("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
@@ -119,6 +189,8 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         CockpitOverviewWarningSettingsSnapshot.SourceEvent alarmEvent = snapshot.getEvents().get(0);
         assertNull(alarmEvent.getWorkOrderCode());
         assertNull(alarmEvent.getWorkOrderState());
+        // alarm_status 缺失 → 自身生命周期状态如实为 null(服务层进未知桶)。
+        assertNull(alarmEvent.getStatus());
         CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint alarmTrend = snapshot.getTrendPoints().get(0);
         assertEquals(1, alarmTrend.getTotal());
         assertEquals(0, alarmTrend.getHandled());
@@ -138,4 +210,31 @@ class BusinessCockpitOverviewWarningSettingsAdapterTest {
         assertEquals(0, snapshot.getTrendPoints().size());
         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));
     }
 
+    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(
             String code, String name, boolean online, boolean alarm, boolean warning,
             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 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.assertTrue;
 import static org.mockito.Mockito.mock;
@@ -330,7 +331,88 @@ class CockpitMonitoringOverviewControllerTest {
                 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) {
         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);
     }
 
+    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(
             String eventNo, String level, String eventTime, String industry,
             String typeCode, String deviceCode, String deviceName) {
         return new CockpitMonitoringSituationSnapshot.SourceEvent(
                 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();
     }
 
+    /**
+     * 维护者 2026-10-14 裁定:预警族只给预警自身生命周期,工单口径字段一律 {@code null};
+     * 报警族保留工单口径并并存自身生命周期。
+     */
     @Test
-    void warningSettingsUsesEnvelopeCamelCaseAndDefaultWarningMode() throws Exception {
+    void warningSettingsUsesEnvelopeCamelCaseAndSplitsTheTwoFamilyVocabularies() throws Exception {
         when(adapter.load()).thenReturn(fixture());
 
         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.reason").value("READ_ONLY_AGGREGATE"))
                 .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                // ---- 预警族:自身生命周期,工单口径字段全为 null ----
                 .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.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.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))
-                // 两 family 共用同一份全范围工单读数。
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.value").value("0.2000"))
                 .andExpect(jsonPath("$.data.alarm.handlingEfficiency.numerator").value(1))
                 .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.label").value("预警"));
     }
@@ -110,7 +138,8 @@ class CockpitOverviewWarningSettingsControllerTest {
                 .andExpect(jsonPath("$.data.selectedMode").value("ALARM"))
                 .andExpect(jsonPath("$.data.warning.total").value(3))
                 .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("报警"));
     }
 
@@ -251,16 +280,13 @@ class CockpitOverviewWarningSettingsControllerTest {
         return new CockpitOverviewWarningSettingsSnapshot(
                 "2026-09-02T09:59:50+08:00",
                 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(
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "WARNING", "2026-09-01", 2, 1),
@@ -278,14 +304,27 @@ class CockpitOverviewWarningSettingsControllerTest {
                                 "ALARM", "2026-08-20", 2, 1),
                         new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
                                 "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(
                         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) {
         return new CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder(
                 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.util.Arrays;
 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.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -31,69 +34,146 @@ class CockpitOverviewWarningSettingsServiceTest {
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
     }
 
+    /**
+     * 维护者 2026-10-14 裁定:预警不走工单流程 → 预警族的工单口径字段**必须为 null**(不适用),
+     * 而不是 0 —— 0 会被读成"真的是零"。
+     */
     @Test
-    void currentEightStateModelSeparatesHandledPendingAndUnlinkedEvents() {
-        when(adapter.load()).thenReturn(eightStateFixture());
+    void warningFamilyDropsWorkOrderVocabularyEntirely() {
+        when(adapter.load()).thenReturn(warningFixture());
 
         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
-    void bothFamiliesShareTheSameWorkOrderScopedRate() {
-        when(adapter.load()).thenReturn(eightStateFixture());
+    void warningStatusDistributionCoversItsOwnLifecycleAndSumsToTotal() {
+        when(adapter.load()).thenReturn(warningFixture());
 
         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
-    void cancelledWorkOrdersAreExcludedFromTheRateDenominator() {
-        when(adapter.load()).thenReturn(cancelledHeavyFixture());
+    void warningEfficiencyIsItsOwnClosedOverTotalNotAWorkOrderRate() {
+        when(adapter.load()).thenReturn(warningFixture());
 
         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
-    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());
 
         CockpitOverviewWarningSettingsResponse response =
@@ -101,14 +181,13 @@ class CockpitOverviewWarningSettingsServiceTest {
 
         assertEquals("EMPTY", response.getAlarm().getState());
         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
@@ -120,52 +199,101 @@ class CockpitOverviewWarningSettingsServiceTest {
 
         assertEquals("EMPTY", response.getFreshness().getStatus());
         assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
-        assertTrue(response.getUpdatedAt() == null);
+        assertNull(response.getUpdatedAt());
         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(
                 "2026-09-02T09:59:50+08:00",
                 Arrays.asList(
-                        event("WARNING", "WRN-000", "WO-000", 0),
-                        event("WARNING", "WRN-001", "WO-001", 1),
-                        event("WARNING", "WRN-002", "WO-002", 2),
-                        event("WARNING", "WRN-003", "WO-003", 3),
-                        event("WARNING", "WRN-004", "WO-004", 4),
-                        event("WARNING", "WRN-005", "WO-005", 5),
-                        event("WARNING", "WRN-006", "WO-006", 6),
-                        event("WARNING", "WRN-007", "WO-007", 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(
-                        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(
                 "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(),
-                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(
-                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() {

+ 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.zksy.base.domain.EquipmentStatus;
 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.framework.web.service.TokenService;
 import org.springframework.data.redis.connection.Message;
@@ -19,6 +21,7 @@ import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 
 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.assertTrue;
 import static org.mockito.Mockito.mock;
@@ -55,6 +58,38 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         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
     void cockpitSubscribeAllReturnsExplicitScopeSubscription() throws Exception {
         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
 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> 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);

+ 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
 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,
                                   @Param("typeIds") List<Integer> typeIds, @Param("unhandled") int unhandled);
     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());
         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) {
         AlarmVO alarm = mapper.selectDetail(id, query, scope.resolveOperations(query));
         if (alarm == null) throw new ServiceException("报警不存在或不属于当前管理范围");
         labels(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) {
         // 提醒只展示未处理报警,不能通过请求参数切换为历史已恢复事件。
         query.setAlarmStatus(AlarmRecordStatusEnum.UNHANDLED.getCode());
@@ -50,6 +87,9 @@ public class AlarmOperationsService {
     }
     public List<AlarmLog> logs(String id, AlarmQuery query) {
         detail(id, query);
+        return history(id);
+    }
+    private List<AlarmLog> history(String id) {
         List<AlarmLog> result = logs.selectHistory(id);
         result.forEach(log -> { AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
             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.JSONObject;
 import com.zksy.base.alarm.dto.AlarmReviewQuery;
+import com.zksy.base.alarm.dto.MobileAlarmQuery;
 import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.enums.EquipmentTypeEnum;
 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.utils.SecurityUtils;
 import com.zksy.system.service.ISysMenuService;
+import com.zksy.ops.support.OpsAssignScopeSupport;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.security.access.AccessDeniedException;
 import java.util.*;
+import java.util.stream.Collectors;
 
 /** 路由 query 不是授权依据,服务端同时校验用户获授权菜单及数据库设备类型树。 */
 @Service
@@ -21,6 +24,21 @@ import java.util.*;
 public class AlarmReviewScopeService {
     private final EquipmentTypeMapper typeMapper;
     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) {
         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) {
         detail(id, query);
+        return history(id);
+    }
+
+    /** 调用方须先校验报警读取范围。 */
+    List<AlarmLog> history(String id) {
         List<AlarmLog> logs = logMapper.selectHistory(id);
         logs.forEach(log -> {
             AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
@@ -69,6 +74,22 @@ public class AlarmReviewService {
     @Transactional(rollbackFor = Exception.class)
     public AlarmReviewVO review(String id, AlarmReviewQuery query, AlarmReviewRequest request) {
         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());
         if (result == null || result == AlarmAuditStatusEnum.UNREVIEWED) throw new ServiceException("审核结果无效");
         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 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);
 
     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.ops.domain.WorkOrder;
 import com.zksy.ops.mapper.WorkOrderMapper;
+import com.zksy.ops.support.OpsAssignScopeSupport;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -84,6 +85,9 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
     @Autowired
     private WorkOrderMapper workOrderMapper;
 
+    @Autowired
+    private OpsAssignScopeSupport assignScopeSupport;
+
     @Override
     public Page<EquipmentBaseVO> findByPage(long pageNum, long pageSize,
                                           String equipmentCode, String equipmentName,
@@ -128,6 +132,89 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
         }
         queryWrapper.orderByDesc(EquipmentBase::getCreateTime);
         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)
                 .filter(Objects::nonNull).collect(Collectors.toList());
         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 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() { }
 
     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) {
@@ -38,13 +29,11 @@ public final class WarningWorkflow {
 
     public static List<String> actions(String state) {
         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 java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
 
 @Service
 @Slf4j
@@ -203,10 +205,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         // 草稿可预选核查人;未选择时在发布时使用当前发布人。
         warning.setCreateBy(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.setUpdateTime(LocalDateTime.now());
 
@@ -225,6 +225,16 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         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);
 
         itemBinding.bind(warning);
-        warning.setStatus("PENDING");
+        warning.setStatus(EarlyWarningStatusEnum.PENDING.getCode());
         warning.setPublisher(currentUser());
         warning.setUpdateBy(currentUser());
         warning.setPublishTime(LocalDateTime.now());
@@ -437,7 +447,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         String operator = currentUser();
         String assignee = warning.getHandler();
         // 接收预警:待办(PENDING) → 已接收(RECEIVED)
-        warning.setStatus("RECEIVED");
+        warning.setStatus(EarlyWarningStatusEnum.RECEIVED.getCode());
         itemBinding.startStage(warning, "HANDLE", now);
         warning.setHandler(assignee);
         warning.setUpdateTime(now);
@@ -455,7 +465,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         LocalDateTime now = LocalDateTime.now();
         String operator = currentUser();
         // 开始处置:已接收(RECEIVED) → 处置中(PROCESSING)
-        warning.setStatus("PROCESSING");
+        warning.setStatus(EarlyWarningStatusEnum.PROCESSING.getCode());
         warning.setUpdateTime(now);
         if (!updateById(warning)) return false;
         addDisposalRecord(warningId, "START", null, null, operator, remark);
@@ -484,7 +494,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (isBlank(processContent)) throw new ServiceException("请填写现场处置结果");
         LocalDateTime now = LocalDateTime.now();
         String operator = currentUser();
-        warning.setStatus("HANDLED");
+        warning.setStatus(EarlyWarningStatusEnum.HANDLED.getCode());
         itemBinding.startStage(warning, "REVIEW", now);
         warning.setRemark(processContent);
         warning.setUpdateTime(now);
@@ -534,7 +544,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (isBlank(clearRemark)) throw new ServiceException("请填写核查通过和解除依据");
         LocalDateTime now = LocalDateTime.now();
         String operator = currentUser();
-        warning.setStatus("CLOSED");
+        warning.setStatus(EarlyWarningStatusEnum.CLOSED.getCode());
         warning.setStageDeadline(null);
         warning.setRemark(clearRemark);
         warning.setUpdateTime(now);
@@ -651,7 +661,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         requireReviewer(warning);
         if (isBlank(disposalContent)) throw new ServiceException("请填写退回原因和重新处置要求");
         requireActiveUser(warning.getHandler(), "原处置人");
-        warning.setStatus("PROCESSING");
+        warning.setStatus(EarlyWarningStatusEnum.PROCESSING.getCode());
         itemBinding.startStage(warning, "HANDLE", LocalDateTime.now());
         warning.setUpdateBy(currentUser());
         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
     public String generateItemCode(String warningType, String 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 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.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
@@ -113,7 +114,9 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     point.getLongitude(),
                     point.getLatitude(),
                     false,
-                    devices));
+                    devices,
+                    // 数据时间=点位自身记录创建时间(monitor_point.create_time)
+                    CockpitCreatedAt.format(point.getCreateTime())));
         }
 
         List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
@@ -139,7 +142,9 @@ public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMoni
                     fact.isWorkOrderInProgress(),
                     fact.getStatusReasons(),
                     fact.getStatusTime(),
-                    fact.isStatusPublished()));
+                    fact.isStatusPublished(),
+                    // 数据时间=设备自身记录创建时间(equipment_base.create_time)
+                    CockpitCreatedAt.format(device.getCreateTime())));
         }
 
         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.EquipmentTypeService;
 import com.zksy.base.service.MonitorPointService;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import org.springframework.context.annotation.Primary;
 import org.springframework.stereotype.Component;
@@ -100,7 +101,8 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
             sourcePoints.add(new CockpitMonitoringOverviewSnapshot.SourceMonitoringPoint(
                     pointCode,
                     deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
-                    boundDeviceCodes.size()));
+                    boundDeviceCodes.size(),
+                    CockpitCreatedAt.format(point.getCreateTime())));
         }
 
         List<CockpitMonitoringOverviewSnapshot.SourceDevice> devices = new ArrayList<>();
@@ -127,7 +129,9 @@ public class BusinessCockpitMonitoringOverviewAdapter implements CockpitMonitori
                             ? null
                             : TIME_FORMATTER.format(status.getStatusUpdateTime()
                                     .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()

+ 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.warning.domain.EarlyWarning;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
@@ -117,7 +118,9 @@ public class BusinessCockpitMonitoringSituationAdapter implements CockpitMonitor
                     fact.getStatusTime(),
                     device.getLongitude(),
                     device.getLatitude(),
-                    false));
+                    false,
+                    // 数据时间=记录创建时间(equipment_base.create_time),与 sampledAt 分离
+                    CockpitCreatedAt.format(device.getCreateTime())));
         }
 
         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 Range range;
     private final String updatedAt;
+    /**
+     * 数据时间=记录创建时间({@code create_time})跨台账对象取 max;
+     * 空态显式 {@code null}。与 {@code updatedAt}(快照加载时刻)语义不同,不互相冒充。
+     */
+    private final String createdAt;
     private final Freshness freshness;
     private final List<DistrictDistribution> districtDistribution;
     private final List<IndustryDistribution> industryDistribution;
@@ -35,6 +40,7 @@ public class CockpitMonitoringDistributionResponse {
             String timezone,
             Range range,
             String updatedAt,
+            String createdAt,
             Freshness freshness,
             List<DistrictDistribution> districtDistribution,
             List<IndustryDistribution> industryDistribution,
@@ -52,6 +58,7 @@ public class CockpitMonitoringDistributionResponse {
         this.timezone = timezone;
         this.range = range;
         this.updatedAt = updatedAt;
+        this.createdAt = createdAt;
         this.freshness = freshness;
         this.districtDistribution = districtDistribution;
         this.industryDistribution = industryDistribution;
@@ -71,6 +78,7 @@ public class CockpitMonitoringDistributionResponse {
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
+    public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }
     public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
     public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
@@ -296,10 +304,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         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,
                 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.pointName = pointName;
             this.district = CockpitDistrict.codeOf(district);
@@ -311,6 +322,7 @@ public class CockpitMonitoringDistributionResponse {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
 
         public String getPointCode() { return pointCode; }
@@ -324,6 +336,7 @@ public class CockpitMonitoringDistributionResponse {
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public String getCreatedAt() { return createdAt; }
     }
 
     public static class UnassociatedEquipmentMarker {
@@ -338,10 +351,13 @@ public class CockpitMonitoringDistributionResponse {
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         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,
                 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.deviceName = deviceName;
             this.district = CockpitDistrict.codeOf(district);
@@ -353,13 +369,13 @@ public class CockpitMonitoringDistributionResponse {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
 
         public String getDeviceCode() { return deviceCode; }
         public String getDeviceStatus() {
             return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
-        }
-        public String getDeviceName() { return deviceName; }
+        }        public String getDeviceName() { return deviceName; }
         public Integer getDistrict() { return district; }
         public String getDistrictName() { return districtName; }
         public String getIndustry() { return industry; }
@@ -369,6 +385,7 @@ public class CockpitMonitoringDistributionResponse {
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public String getCreatedAt() { return createdAt; }
     }
 
     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.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
@@ -295,7 +296,8 @@ public class CockpitMonitoringDistributionService {
                         point.getLatitude(),
                         validation,
                         true,
-                        codes(effective)));
+                        codes(effective),
+                        point.getCreatedAt()));
             }
         }
 
@@ -355,7 +357,8 @@ public class CockpitMonitoringDistributionService {
                         device.getLatitude(),
                         validation,
                         false,
-                        Collections.emptyList()));
+                        Collections.emptyList(),
+                        device.getCreatedAt()));
             }
         }
 
@@ -407,6 +410,16 @@ public class CockpitMonitoringDistributionService {
 
         boolean hasResolved = total > 0;
         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(
                 requestId,
@@ -416,6 +429,7 @@ public class CockpitMonitoringDistributionService {
                 "Asia/Shanghai",
                 new CockpitMonitoringDistributionResponse.Range("CURRENT_BASELINE"),
                 updatedAt,
+                createdAt,
                 new CockpitMonitoringDistributionResponse.Freshness(
                         hasResolved ? CockpitFreshnessStatus.CURRENT.name()
                                 : CockpitFreshnessStatus.EMPTY.name(),
@@ -735,11 +749,13 @@ public class CockpitMonitoringDistributionService {
         private final CockpitCoordinateValidation coordinateValidation;
         private final boolean point;
         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,
                 CockpitMonitoringPointStatus status, List<String> statusReasons, BigDecimal longitude,
                 BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
-                List<String> deviceCodes) {
+                List<String> deviceCodes, String createdAt) {
             this.code = code;
             this.name = name;
             this.district = district;
@@ -753,6 +769,7 @@ public class CockpitMonitoringDistributionService {
             this.coordinateValidation = coordinateValidation;
             this.point = point;
             this.deviceCodes = deviceCodes;
+            this.createdAt = createdAt;
         }
 
         private String getCode() {
@@ -763,14 +780,14 @@ public class CockpitMonitoringDistributionService {
             return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
                     code, name, district, industry,
                     new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
-                    status, statusReasons, longitude, latitude, coordinateValidation);
+                    status, statusReasons, longitude, latitude, coordinateValidation, createdAt);
         }
 
         private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
             return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
                     code, name, district, industry,
                     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 boolean coordinateVerified;
         private final List<CockpitMonitoringPointDeviceSnapshot> devices;
+        /** 记录创建时间({@code monitor_point.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
         public SourceMonitoringPoint(
                 String pointCode,
@@ -115,6 +117,20 @@ public class CockpitMonitoringDistributionSnapshot {
                 BigDecimal latitude,
                 boolean coordinateVerified,
                 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.pointName = pointName;
             this.district = district;
@@ -123,6 +139,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
             this.devices = devices;
+            this.createdAt = createdAt;
         }
 
         public String getPointCode() {
@@ -157,6 +174,10 @@ public class CockpitMonitoringDistributionSnapshot {
         public List<CockpitMonitoringPointDeviceSnapshot> getDevices() {
             return devices;
         }
+
+        public String getCreatedAt() {
+            return createdAt;
+        }
     }
 
     public static class SourceSmartDevice {
@@ -174,6 +195,8 @@ public class CockpitMonitoringDistributionSnapshot {
         private final List<String> statusReasons;
         private final String statusTime;
         private final boolean statusPublished;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
         public SourceSmartDevice(
                 String deviceCode,
@@ -197,6 +220,15 @@ public class CockpitMonitoringDistributionSnapshot {
                 BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
                 boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
                 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.deviceName = deviceName;
             this.district = district;
@@ -211,6 +243,7 @@ public class CockpitMonitoringDistributionSnapshot {
             this.statusReasons = statusReasons;
             this.statusTime = statusTime;
             this.statusPublished = statusPublished;
+            this.createdAt = createdAt;
         }
 
         public String getDeviceCode() {
@@ -267,5 +300,9 @@ public class CockpitMonitoringDistributionSnapshot {
         public String getStatusTime() {
             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 Range range;
     private final String updatedAt;
+    /**
+     * 数据时间=记录创建时间({@code create_time}),跨聚合源类型取 max;
+     * 空态显式 {@code null}。与 {@code updatedAt}(快照加载时刻)语义不同,不互相冒充。
+     */
+    private final String createdAt;
     private final Freshness freshness;
     private final Counts counts;
     private final Status status;
@@ -28,6 +33,7 @@ public class CockpitMonitoringOverviewResponse {
             String timezone,
             Range range,
             String updatedAt,
+            String createdAt,
             Freshness freshness,
             Counts counts,
             Status status,
@@ -42,6 +48,7 @@ public class CockpitMonitoringOverviewResponse {
         this.timezone = timezone;
         this.range = range;
         this.updatedAt = updatedAt;
+        this.createdAt = createdAt;
         this.freshness = freshness;
         this.counts = counts;
         this.status = status;
@@ -58,6 +65,7 @@ public class CockpitMonitoringOverviewResponse {
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
+    public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }
     public Counts getCounts() { return counts; }
     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;
 
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
@@ -199,6 +200,9 @@ public class CockpitMonitoringOverviewService {
         String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
                 ? businessTime()
                 : snapshot.getUpdatedAt();
+        // 数据时间=范围内真实记录 create_time 跨类型 max(设备表 + 点位表);
+        // 与 updatedAt(快照加载时刻)、freshness.serverUpdatedAt(响应生成时刻)语义分离。
+        String createdAt = CockpitCreatedAt.max(selectedCreatedAtValues(snapshot, selectedDevices, selectedPointCodes));
 
         List<CockpitMonitoringOverviewResponse.TypeDistribution> typeDistribution = new ArrayList<>();
         for (TypeAccumulator accumulator : typeAccumulators.values()) {
@@ -220,6 +224,7 @@ public class CockpitMonitoringOverviewService {
                 "Asia/Shanghai",
                 new CockpitMonitoringOverviewResponse.Range("CURRENT_BASELINE"),
                 updatedAt,
+                createdAt,
                 new CockpitMonitoringOverviewResponse.Freshness(
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                 new CockpitMonitoringOverviewResponse.Counts(
@@ -261,6 +266,31 @@ public class CockpitMonitoringOverviewService {
         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(
             CockpitMonitoringOverviewSnapshot snapshot,
             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 sampledAt;
         private final String pointCode;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
         public SourceDevice(
                 String deviceCode,
@@ -56,11 +58,22 @@ public class CockpitMonitoringOverviewSnapshot {
                 String monitoringState,
                 String sampledAt,
                 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.typeCode = typeCode;
             this.monitoringState = monitoringState;
             this.sampledAt = sampledAt;
             this.pointCode = pointCode;
+            this.createdAt = createdAt;
         }
 
         public String getDeviceCode() { return deviceCode; }
@@ -68,6 +81,7 @@ public class CockpitMonitoringOverviewSnapshot {
         public String getMonitoringState() { return monitoringState; }
         public String getSampledAt() { return sampledAt; }
         public String getPointCode() { return pointCode; }
+        public String getCreatedAt() { return createdAt; }
     }
 
     public static class SourceMonitoringPoint {
@@ -75,19 +89,27 @@ public class CockpitMonitoringOverviewSnapshot {
         private final String typeCode;
         /** -1 means an older adapter did not publish the association fact. */
         private final int associatedDeviceCount;
+        /** 记录创建时间({@code monitor_point.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
         public SourceMonitoringPoint(String pointCode, String typeCode) {
             this(pointCode, typeCode, -1);
         }
 
         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.typeCode = typeCode;
             this.associatedDeviceCount = associatedDeviceCount;
+            this.createdAt = createdAt;
         }
 
         public String getPointCode() { return pointCode; }
         public String getTypeCode() { return typeCode; }
         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 Range range;
     private final String updatedAt;
+    /**
+     * 数据时间=异常设备来源表 {@code create_time} 的 max(与逐对象 {@code createdAt} 同源可复算);
+     * 空态显式 {@code null}。事件时间由趋势桶 {@code bucketStart} 承载,不并入本读数。
+     */
+    private final String createdAt;
     private final Freshness freshness;
     private final List<MetricStatusCount> metricStatusDistribution;
     private final List<TrendPoint> warningTrend;
@@ -32,6 +37,7 @@ public class CockpitMonitoringSituationResponse {
             String timezone,
             Range range,
             String updatedAt,
+            String createdAt,
             Freshness freshness,
             List<MetricStatusCount> metricStatusDistribution,
             List<TrendPoint> warningTrend,
@@ -48,6 +54,7 @@ public class CockpitMonitoringSituationResponse {
         this.timezone = timezone;
         this.range = range;
         this.updatedAt = updatedAt;
+        this.createdAt = createdAt;
         this.freshness = freshness;
         this.metricStatusDistribution = metricStatusDistribution;
         this.warningTrend = warningTrend;
@@ -66,6 +73,7 @@ public class CockpitMonitoringSituationResponse {
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
     public String getUpdatedAt() { return updatedAt; }
+    public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }
     public List<MetricStatusCount> getMetricStatusDistribution() { return metricStatusDistribution; }
     public List<TrendPoint> getWarningTrend() { return warningTrend; }
@@ -194,6 +202,8 @@ public class CockpitMonitoringSituationResponse {
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
+        /** 记录创建时间(来源表 {@code create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
         public AbnormalEquipment(
                 String deviceCode,
@@ -206,7 +216,8 @@ public class CockpitMonitoringSituationResponse {
                 List<String> statusReasons,
                 BigDecimal longitude,
                 BigDecimal latitude,
-                CockpitCoordinateValidation coordinateValidation) {
+                CockpitCoordinateValidation coordinateValidation,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.deviceType = deviceType;
@@ -220,6 +231,7 @@ public class CockpitMonitoringSituationResponse {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
 
         public String getDeviceCode() { return deviceCode; }
@@ -235,6 +247,7 @@ public class CockpitMonitoringSituationResponse {
         public BigDecimal getLongitude() { return longitude; }
         public BigDecimal getLatitude() { return latitude; }
         public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+        public String getCreatedAt() { return createdAt; }
     }
 
     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.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitCreatedAt;
 import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
@@ -228,7 +229,8 @@ public class CockpitMonitoringSituationService {
                             device.getStatusReasons(),
                             device.getLongitude(),
                             device.getLatitude(),
-                            validation));
+                            validation,
+                            device.getCreatedAt()));
                     if (validation.isRenderable()) {
                         mapDeviceCodes.add(device.getDeviceCode().trim());
                     } else {
@@ -286,6 +288,14 @@ public class CockpitMonitoringSituationService {
         String updatedAt = isBlank(snapshot.getUpdatedAt())
                 ? businessTime()
                 : 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(
                 requestId,
@@ -298,6 +308,7 @@ public class CockpitMonitoringSituationService {
                         timestamp(window.start),
                         timestamp(window.end)),
                 updatedAt,
+                createdAt,
                 new CockpitMonitoringSituationResponse.Freshness(
                         freshnessStatus, freshnessReason, updatedAt, businessTime()),
                 metricStatusDistribution,
@@ -542,6 +553,8 @@ public class CockpitMonitoringSituationService {
         private final BigDecimal longitude;
         private final BigDecimal latitude;
         private final CockpitCoordinateValidation coordinateValidation;
+        /** 记录创建时间(equipment_base.create_time);缺值为 null。 */
+        private final String createdAt;
 
         private AbnormalRow(
                 String deviceCode,
@@ -552,7 +565,8 @@ public class CockpitMonitoringSituationService {
                 List<String> statusReasons,
                 BigDecimal longitude,
                 BigDecimal latitude,
-                CockpitCoordinateValidation coordinateValidation) {
+                CockpitCoordinateValidation coordinateValidation,
+                String createdAt) {
             this.deviceCode = deviceCode;
             this.deviceName = deviceName;
             this.type = type;
@@ -562,6 +576,7 @@ public class CockpitMonitoringSituationService {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateValidation = coordinateValidation;
+            this.createdAt = createdAt;
         }
 
         private String getDeviceCode() {
@@ -581,7 +596,8 @@ public class CockpitMonitoringSituationService {
                     statusReasons == null ? Collections.emptyList() : statusReasons,
                     longitude,
                     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 latitude;
         private final boolean coordinateVerified;
+        /** 记录创建时间({@code equipment_base.create_time});缺值为 {@code null}。 */
+        private final String createdAt;
 
         public SourceMetricDevice(
                 String deviceCode,
@@ -113,6 +115,22 @@ public class CockpitMonitoringSituationSnapshot {
                 BigDecimal longitude,
                 BigDecimal latitude,
                 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.deviceName = deviceName;
             this.district = district;
@@ -123,6 +141,7 @@ public class CockpitMonitoringSituationSnapshot {
             this.longitude = longitude;
             this.latitude = latitude;
             this.coordinateVerified = coordinateVerified;
+            this.createdAt = createdAt;
         }
 
         public String getDeviceCode() {
@@ -165,6 +184,10 @@ public class CockpitMonitoringSituationSnapshot {
         public boolean isCoordinateVerified() {
             return coordinateVerified;
         }
+
+        public String getCreatedAt() {
+            return createdAt;
+        }
     }
 
     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.zksy.base.alarm.domain.AlarmData;
 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.EarlyWarning;
 import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
 import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitTypeIndustry;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.service.WorkOrderService;
 import org.springframework.context.annotation.Primary;
@@ -28,10 +34,16 @@ import java.util.stream.Collectors;
 /**
  * 总体概览-预警设置 真实数据适配器(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} 取事件与工单的最新时间。
  */
 @Component
@@ -43,21 +55,28 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
     private static final DateTimeFormatter BUCKET_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
     private static final int WORK_ORDER_HANDLED = 5;
+    private static final String WARNING_CLOSED = "CLOSED";
 
     private final EarlyWarningService earlyWarningService;
     private final AlarmDataService alarmDataService;
     private final AlarmWarningRelMapper alarmWarningRelMapper;
     private final WorkOrderService workOrderService;
+    private final EquipmentBaseService equipmentBaseService;
+    private final EquipmentTypeService equipmentTypeService;
 
     public BusinessCockpitOverviewWarningSettingsAdapter(
             EarlyWarningService earlyWarningService,
             AlarmDataService alarmDataService,
             AlarmWarningRelMapper alarmWarningRelMapper,
-            WorkOrderService workOrderService) {
+            WorkOrderService workOrderService,
+            EquipmentBaseService equipmentBaseService,
+            EquipmentTypeService equipmentTypeService) {
         this.earlyWarningService = earlyWarningService;
         this.alarmDataService = alarmDataService;
         this.alarmWarningRelMapper = alarmWarningRelMapper;
         this.workOrderService = workOrderService;
+        this.equipmentBaseService = equipmentBaseService;
+        this.equipmentTypeService = equipmentTypeService;
     }
 
     @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<>();
         LocalDateTime updatedAt = null;
@@ -111,7 +116,11 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
                     alarm.getId().trim(),
                     trimToNull(alarm.getDeviceCode()),
                     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) {
@@ -120,48 +129,33 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             updatedAt = latest(updatedAt, warning.getUpdateTime());
             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(
                     "WARNING",
                     warning.getWarningNo().trim(),
                     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<>();
-        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(
                 offsetTime(updatedAt), events, trendPoints, scopeWorkOrders(orders));
     }
 
     /**
-     * 范围内**全部工单**(决策票 06):处置率分母要的是工单事实,不要求事件回联。
+     * 范围内**全部工单**(决策票 06):**报警族**处置率分母要的是工单事实,不要求事件回联。
      * 状态码原样透出(是否计入分母由服务层的 0–6 口径判定)。
+     *
+     * <p>维护者 2026-10-14 裁定后,本读数只服务报警族。
      */
     private static List<CockpitOverviewWarningSettingsSnapshot.SourceWorkOrder> scopeWorkOrders(
             List<WorkOrder> orders) {
@@ -177,11 +171,11 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         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<>();
         for (EarlyWarning warning : warnings) {
             if (warning == null || isBlank(warning.getWarningNo())) {
@@ -193,14 +187,15 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             }
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             counter.total++;
-            if (isHandledWarning(warning, warningNosByAlarmId, alarms, orderByOrderId)) {
+            if (WARNING_CLOSED.equals(normalize(warning.getStatus()))) {
                 counter.handled++;
             }
         }
         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<>();
         for (AlarmData alarm : alarms) {
             if (alarm == null || isBlank(alarm.getId())) {
@@ -213,37 +208,14 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
             Counter counter = buckets.computeIfAbsent(bucket(time), key -> new Counter());
             counter.total++;
             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++;
             }
         }
         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,
             String family,
             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) {
         return BUCKET_FORMATTER.format(time.atZone(BUSINESS_ZONE).toLocalDate());
     }
@@ -299,4 +348,4 @@ public class BusinessCockpitOverviewWarningSettingsAdapter
         private int total;
         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; }
     }
 
+    /**
+     * 一族(预警或报警)的统计。
+     *
+     * <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 {
         private final String eventFamily;
         private final String state;
         private final String stateReason;
         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 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;
 
         public FamilyMetrics(
@@ -118,13 +142,17 @@ public class CockpitOverviewWarningSettingsResponse {
                 String state,
                 String stateReason,
                 int total,
-                int handled,
-                int pending,
-                int cancelledCount,
-                int unlinkedEventCount,
-                int invalidWorkOrderStateCount,
+                Integer handled,
+                Integer pending,
+                Integer cancelledCount,
+                Integer unlinkedEventCount,
+                Integer invalidWorkOrderStateCount,
                 Efficiency handlingEfficiency,
                 Map<Integer, Integer> workOrderStates,
+                List<Bucket> statusDistribution,
+                List<Bucket> levelDistribution,
+                List<Bucket> industryDistribution,
+                List<Bucket> districtDistribution,
                 List<TrendPoint> trend) {
             this.eventFamily = eventFamily;
             this.state = state;
@@ -137,6 +165,10 @@ public class CockpitOverviewWarningSettingsResponse {
             this.invalidWorkOrderStateCount = invalidWorkOrderStateCount;
             this.handlingEfficiency = handlingEfficiency;
             this.workOrderStates = workOrderStates;
+            this.statusDistribution = statusDistribution;
+            this.levelDistribution = levelDistribution;
+            this.industryDistribution = industryDistribution;
+            this.districtDistribution = districtDistribution;
             this.trend = trend;
         }
 
@@ -144,16 +176,42 @@ public class CockpitOverviewWarningSettingsResponse {
         public String getState() { return state; }
         public String getStateReason() { return stateReason; }
         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 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; }
     }
 
+    /**
+     * 一个分布桶。{@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 {
         private final int numerator;
         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;
 
+import com.zksy.base.warning.domain.WarningWorkflow;
 import com.zksy.cockpit.workorder.CockpitWorkOrderState;
 import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import com.zksy.cockpit.foundation.CockpitDistrict;
 import com.zksy.cockpit.foundation.CockpitErrorCategory;
 import com.zksy.cockpit.foundation.CockpitErrorPayload;
 import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
@@ -21,8 +23,25 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 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
 public class CockpitOverviewWarningSettingsService {
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
@@ -35,8 +54,33 @@ public class CockpitOverviewWarningSettingsService {
                     .map(CockpitWorkOrderState::getCode)
                     .collect(Collectors.toList());
     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");
 
+    /** 预警自身生命周期的展示顺序({@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 Clock clock;
 
@@ -61,7 +105,6 @@ public class CockpitOverviewWarningSettingsService {
         String freshnessStatus = hasSnapshot ? "CURRENT" : "EMPTY";
         String freshnessReason = hasSnapshot ? "READ_ONLY_AGGREGATE" : "NO_SNAPSHOT";
 
-        ScopeRate scopeRate = scopeRate(snapshot, window);
         return new CockpitOverviewWarningSettingsResponse(
                 requestId,
                 "v1",
@@ -79,8 +122,8 @@ public class CockpitOverviewWarningSettingsService {
                         freshnessReason,
                         updatedAt,
                         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));
     }
 
@@ -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 pending = 0;
         int cancelledCount = 0;
         int unlinkedEventCount = 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();
             if (state == null) {
-                total++;
                 unlinkedEventCount++;
-                continue;
-            }
-            if (!WORK_ORDER_STATES.contains(state)) {
-                total++;
+            } else if (!WORK_ORDER_STATES.contains(state)) {
                 invalidWorkOrderStateCount++;
-                continue;
-            }
-            total++;
-            if (state == HANDLED_STATE) {
-                handled++;
-            } else if (state == 7) {
+            } else if (state == CANCELLED_STATE) {
                 cancelledCount++;
-            } else {
+            } else if (state != HANDLED_STATE) {
                 pending++;
             }
         }
 
-        // 处置率=范围内全部工单(状态 0–6)口径,两个 family 共用同一份范围读数
-        // (决策票 06:分母不要求关联报警/事件)。事件计数仍按 family 各自统计。
-        int rateNumerator = scopeRate.handled;
-        int rateDenominator = scopeRate.eligible;
-
         return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
-                eventFamily,
+                "ALARM",
                 total == 0 ? "EMPTY" : "READY",
                 total == 0 ? "NO_EVENTS" : "EVENTS_RESOLVED",
                 total,
@@ -218,9 +318,15 @@ public class CockpitOverviewWarningSettingsService {
                 cancelledCount,
                 unlinkedEventCount,
                 invalidWorkOrderStateCount,
-                scopeEfficiency(rateNumerator, rateDenominator),
+                // 报警族处置率仍是**工单口径**(决策票 06 / 维护者 2026-10-14 裁定)。
+                scopeEfficiency(scopeRate.handled, scopeRate.eligible),
                 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) {
         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) {
         BigDecimal value = eligible == 0
                 ? BigDecimal.ZERO.setScale(4)
@@ -311,7 +550,7 @@ public class CockpitOverviewWarningSettingsService {
                 eligible == 0 ? "NO_ELIGIBLE_WORK_ORDERS" : null);
     }
 
-    /** 范围处置率读数:分子(状态 5)、分母(状态 0–6)、逐态分布。 */
+    /** 报警族范围处置率读数:分子(状态 5)、分母(状态 0–6)、逐态分布。 */
     private static class ScopeRate {
         private final int handled;
         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; }
 
     /**
-     * 范围内**全部工单**(决策票 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; }
 
@@ -49,24 +52,57 @@ public class CockpitOverviewWarningSettingsSnapshot {
         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 {
         private final String eventFamily;
         private final String eventCode;
         private final String deviceCode;
         private final String workOrderCode;
         private final Integer workOrderState;
+        private final String status;
+        private final Integer level;
+        private final Integer districtCode;
+        private final String industryCode;
 
+        /** 兼容旧调用点:只给工单回联事实,自身生命周期与维度留空。 */
         public SourceEvent(
                 String eventFamily,
                 String eventCode,
                 String deviceCode,
                 String workOrderCode,
                 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.eventCode = eventCode;
             this.deviceCode = deviceCode;
             this.workOrderCode = workOrderCode;
             this.workOrderState = workOrderState;
+            this.status = status;
+            this.level = level;
+            this.districtCode = districtCode;
+            this.industryCode = industryCode;
         }
 
         public String getEventFamily() { return eventFamily; }
@@ -74,8 +110,17 @@ public class CockpitOverviewWarningSettingsSnapshot {
         public String getDeviceCode() { return deviceCode; }
         public String getWorkOrderCode() { return workOrderCode; }
         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 {
         private final String eventFamily;
         private final String bucket;
@@ -95,4 +140,3 @@ public class CockpitOverviewWarningSettingsSnapshot {
         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.PipeNetworkInfo;
 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 org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -26,6 +23,9 @@ import java.util.Set;
 @Service
 public class DrainageEquipmentBindingServiceImpl implements DrainageEquipmentBindingService {
 
+    @Autowired
+    private DrainageBindingLockMapper bindingLock;
+
     @Autowired
     private DrainagePartitionMapper drainagePartitionMapper;
 
@@ -75,10 +75,11 @@ public class DrainageEquipmentBindingServiceImpl implements DrainageEquipmentBin
         return result;
     }
 
-    /** 同一个服务实例串行完成检查和保存,防止单实例内并发抢占同一设备。 */
+    /** 数据库行锁覆盖检查、保存和事务提交,所有实例及点位编辑入口共用。 */
     @Override
     @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);
         if (sourceId == null || sourceId.trim().isEmpty()) {
             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
     private DrainageEquipmentBindingService equipmentBindingService;
 
+    @org.springframework.beans.factory.annotation.Autowired
+    private com.zksy.drainage.mapper.DrainageBindingLockMapper bindingLock;
+
     @Override
     public Page<WaterloggingPoint> findByPage(long pageNum, long pageSize, String name, String area) {
         Page<WaterloggingPoint> page = new Page<>(pageNum, pageSize);
@@ -44,22 +47,40 @@ public class WaterloggingPointServiceImpl extends ServiceImpl<WaterloggingPointM
     @Override
     @Transactional
     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
     @Transactional
     public boolean updateWithCheck(WaterloggingPoint entity) {
+        bindingLock.lock();
         String id = entity.getId();
         if (this.getById(id) == null) {
             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
     @Transactional
     public boolean removeWithCheck(String id) {
+        bindingLock.lock();
         if (this.getById(id) == null) {
             throw new ServiceException("易积水点不存在:" + id);
         }
@@ -69,6 +90,7 @@ public class WaterloggingPointServiceImpl extends ServiceImpl<WaterloggingPointM
     @Override
     @Transactional
     public boolean removeBatchWithCheck(List<String> ids) {
+        bindingLock.lock();
         if (CollectionUtils.isEmpty(ids)) {
             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 = "现场照片地址数组")
     @TableField(exist = false)
     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 = "工单问题描述")
     private String orderDesc;
 
-    @ApiModelProperty(value = "归属部门ID,取派单时指定责任人所属部门;待派单阶段为创建人部门。数据权限按此字段做「本部门及子部门」过滤")
-    private Long deptId;
-
     @ApiModelProperty(value = "责任人用户ID")
     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低")
     private Integer orderLevel;
 
-    @ApiModelProperty("所属部门ID")
-    private Long deptId;
+    @ApiModelProperty("创建人用户ID")
+    private Long createBy;
 
     @ApiModelProperty("责任人用户ID")
     private Long receiveUser;
 
+    @ApiModelProperty("验收人用户ID")
+    private Long verifyUser;
+
     @ApiModelProperty("设备ID")
     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("工单问题描述")
     private String orderDesc;
 
-    @ApiModelProperty("归属部门ID")
-    private Long deptId;
-
     @ApiModelProperty("责任人用户ID")
     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 = "操作日志")
     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
  */
 @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 static final List<Integer> CANCELABLE_CODES =
             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 =
             Collections.unmodifiableList(Arrays.asList(
-                    ACCEPTED.code, PROCESSING.code, DELAYING.code));
+                    ACCEPTED.code, DELAYING.code));
 
     /** 允许提交验收的状态:处理中/延期中 */
     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> findMobileByPage(InspectTaskPageDTO dto);
+
     /**
      * 获取巡检任务详情(含指派巡检人名称、照片数组)
      *
@@ -33,6 +36,9 @@ public interface InspectTaskService extends IService<InspectTask> {
      */
     InspectTask getDetailById(Long inspectTaskId);
 
+    /** 移动端详情:仅允许当前被指派人查看。 */
+    InspectTask getMobileDetailById(Long inspectTaskId);
+
     /**
      * 新增巡检任务(后端生成巡检编号,状态置为待巡检)
      *
@@ -57,6 +63,9 @@ public interface InspectTaskService extends IService<InspectTask> {
      */
     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.WorkOrderPushVO;
 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.entity.SysUser;
 import com.zksy.ops.dto.WorkOrderDelayApplyDTO;
@@ -44,6 +45,12 @@ public interface WorkOrderService extends IService<WorkOrder> {
      */
     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 getMobileDetail(Long orderId);
+
     /**
      * 查询工单操作日志列表(按操作时间倒序)
      *
@@ -115,7 +125,7 @@ public interface WorkOrderService extends IService<WorkOrder> {
     boolean accept(Long orderId, WorkOrderOperateDTO dto);
 
     /**
-     * 处置:已接单/处理中/延期中 → 处理中(支持多次处置)
+     * 处置:已接单/延期中 → 处理中;处理中状态不允许重复处置
      *
      * @param orderId 工单ID
      * @param dto     操作入参(处置内容、附件)
@@ -139,7 +149,7 @@ public interface WorkOrderService extends IService<WorkOrder> {
     boolean verify(Long orderId, WorkOrderVerifyDTO dto);
 
     /**
-     * 取消工单:待派单/待接单/已接单/处理中/待验收 → 已取消
+     * 取消工单:仅待派单/待接单 → 已取消
      *
      * @param orderId 工单ID
      * @param dto     操作入参(取消原因)
@@ -182,6 +192,9 @@ public interface WorkOrderService extends IService<WorkOrder> {
      */
     List<SysUser> assignableUsers(Long deptId);
 
+    /** 移动端派单候选人,type 仅支持 handler/reviewer。 */
+    List<MobileAssignableUserVO> mobileAssignableUsers(String type);
+
     /**
      * 查询需要通过 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 lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -82,11 +83,26 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
     @Autowired
     private ISysDeptService deptService;
 
+    @Value("${minio.endpoint}")
+    private String minioEndpoint;
+
+    @Value("${minio.bucket}")
+    private String minioBucket;
+
     /**
      * 分页查询巡检任务列表
      */
     @Override
     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);
         if (dto.getPageNum() == null || dto.getPageNum() < 1) {
             dto.setPageNum(1L);
@@ -109,14 +125,15 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         LocalDateTime planTimeEnd = parseDateTime(dto.getPlanTimeEnd(), true);
         wrapper.ge(planTimeStart != null, InspectTask::getPlanTime, planTimeStart);
         wrapper.le(planTimeEnd != null, InspectTask::getPlanTime, planTimeEnd);
-        // 数据权限:仅查询指派给当前用户或当前用户创建的记录
+        // 移动端只展示指派给当前用户的任务;PC 端保留指派给本人或本人创建的数据权限。
         Long dataAuthUserId = currentUserId();
         if (dataAuthUserId == null) {
             // 未获取到登录用户时返回空结果,避免越权查看
             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.orderByDesc(InspectTask::getPlanTime).orderByDesc(InspectTask::getCreateTime);
@@ -145,6 +162,14 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         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
     @Transactional(rollbackFor = Exception.class)
     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());
         if (dto.getInspectTaskId() == null) {
             throw new ServiceException("巡检任务ID不能为空");
@@ -237,6 +272,9 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         if (realTime == null) {
             throw new ServiceException("实际巡检时间格式不正确");
         }
+        if (realTime.isAfter(LocalDateTime.now())) {
+            throw new ServiceException("实际巡检时间不能晚于当前时间");
+        }
         InspectTask exist = this.getById(dto.getInspectTaskId());
         if (exist == null) {
             throw new ServiceException("巡检任务不存在或已被删除");
@@ -244,10 +282,16 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         if (!Integer.valueOf(STATUS_PENDING).equals(exist.getStatus())) {
             throw new ServiceException("该巡检任务已完成,不可重复提交");
         }
+        if (validateAssignee) {
+            validateMobileAssignee(exist);
+        }
         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())
                 // 状态门禁:仅待巡检且未删除的记录可被完成,防止并发重复提交
                 .eq(InspectTask::getStatus, STATUS_PENDING).eq(InspectTask::getDelFlag, DEL_FLAG_NORMAL);
+        if (validateAssignee) {
+            updateWrapper.eq(InspectTask::getAssignUserId, currentUserId());
+        }
         boolean updated = this.update(updateWrapper);
         if (!updated) {
             throw new ServiceException("该巡检任务已完成或已被删除,无法重复完成巡检");
@@ -255,6 +299,13 @@ public class InspectTaskServiceImpl extends ServiceImpl<InspectTaskMapper, Inspe
         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.WorkOrderPushVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
+import com.zksy.ops.domain.vo.MobileAssignableUserVO;
 import com.zksy.ops.enums.WorkOrderDelayApplyStatusEnum;
 import com.zksy.common.core.domain.entity.SysDictData;
 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.support.OpsAssignScopeSupport;
 import com.zksy.system.service.ISysUserService;
+import com.zksy.system.service.ISysMenuService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -62,7 +64,6 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
@@ -105,11 +106,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
      */
     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}")
     private String minioEndpoint;
@@ -142,6 +141,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Autowired
     private ISysUserService sysUserService;
 
+    @Autowired
+    private ISysMenuService sysMenuService;
+
     @Autowired
     private ISysDictDataService sysDictDataService;
 
@@ -170,6 +172,37 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         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 逐条转出参。
      *
@@ -201,7 +234,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         }
 
         // 不叠加状态筛选,否则点击某个状态后其它状态的计数会全部变成 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;
         if (!CollectionUtils.isEmpty(rows)) {
             for (Map<String, Object> row : rows) {
@@ -231,9 +266,26 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         WorkOrderDetailVO detail = new WorkOrderDetailVO();
         detail.setOrder(vo);
         detail.setLogs(listLogs(orderId));
+        detail.setAttachmentBaseUrl(attachmentBaseUrl());
         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
     public List<WorkOrderLogVO> listLogs(Long orderId) {
         if (orderId == null) {
@@ -261,6 +313,24 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         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
     public List<WorkOrderPushVO> listPendingOrOverdue(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.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
-        // 数据按部门隔离:默认归属创建人所在部门(框架 DataScopeAspect 依据该字段过滤)
-        if (workOrder.getDeptId() == null) {
-            workOrder.setDeptId(assignScopeSupport.currentDeptId());
-        }
         workOrder.setCreateTime(now);
         workOrder.setCreateBy(currentUserId());
         workOrder.setUpdateTime(now);
@@ -360,7 +426,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         workOrder.setFaultTime(alarm.getCreateTime() == null
                 ? now
                 : java.sql.Timestamp.valueOf(alarm.getCreateTime()));
-        workOrder.setDeptId(assignScopeSupport.currentDeptId());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setCreateTime(now);
@@ -408,8 +473,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         workOrder.setDeviceId(equipmentId);
         workOrder.setOrderLevel(normalizeWorkOrderLevel(mapHazardLevelToOrderLevel(hazardLevel)));
         workOrder.setOrderDesc(hazardDesc);
-        // 数据按部门隔离:默认归属创建人所在部门(框架 DataScopeAspect 依据该字段过滤)
-        workOrder.setDeptId(assignScopeSupport.currentDeptId());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
         workOrder.setPlanFinishTime(planFinishTime);
@@ -448,21 +511,22 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         if (!Objects.equals(WorkOrderStatusEnum.PENDING_DISPATCH.getCode(), order.getOrderStatus())) {
             throw new ServiceException("仅待派单工单允许派单");
         }
+        requireDispatchScope(order);
         assignScopeSupport.validateAssignUserInScope(handlerId);
         SysUser handler = sysUserService.selectUserById(handlerId);
         if (handler == null || !UserConstants.NORMAL.equals(String.valueOf(handler.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(handler.getDelFlag()))) {
             throw new ServiceException("责任人不存在或已停用");
         }
+        requireUserPermission(handlerId, PERM_PROCESS, "责任人");
         assignScopeSupport.validateAssignUserInScope(verifyUserId);
         SysUser verifier = sysUserService.selectUserById(verifyUserId);
         if (verifier == null || !UserConstants.NORMAL.equals(String.valueOf(verifier.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(verifier.getDelFlag()))) {
             throw new ServiceException("核验人不存在或已停用");
         }
+        requireUserPermission(verifyUserId, PERM_AUDIT, "核验人");
 
         Date now = new Date();
         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())
                 .eq(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_DISPATCH.getCode()));
@@ -477,6 +541,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     public boolean accept(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "核查接单");
         requireStatus(order, WorkOrderStatusEnum.PENDING_ACCEPT, "仅待接单工单允许核查接单");
         // 核查接单必须上传现场情况附件
@@ -496,13 +561,14 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     public boolean process(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "处置");
         Integer status = order.getOrderStatus();
         if (!WorkOrderStatusEnum.canProcess(status)) {
             throw new ServiceException("当前工单状态不允许处置");
         }
 
-        // 已接单/延期中 → 处理中;处理中保持原状态,支持多次处置
+        // 已接单/延期中处置后进入处理中;处理中不允许重复提交处置
         Integer targetStatus = (Objects.equals(WorkOrderStatusEnum.ACCEPTED.getCode(), status)
                 || Objects.equals(WorkOrderStatusEnum.DELAYING.getCode(), status))
                 ? WorkOrderStatusEnum.PROCESSING.getCode()
@@ -518,6 +584,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     public boolean submitVerify(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "提交验收");
         Integer status = order.getOrderStatus();
         if (!WorkOrderStatusEnum.canSubmitVerify(status)) {
@@ -544,6 +611,8 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("验收结果不能为空");
         }
         WorkOrder order = getOrderById(id);
+        requireDeviceCompanyScope(order);
+        requireVerifier(order, currentUserId(), "验收");
         requireStatus(order, WorkOrderStatusEnum.PENDING_VERIFY, "仅待验收工单允许验收");
 
         Date now = new Date();
@@ -576,14 +645,20 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     public boolean cancel(Long orderId, WorkOrderOperateDTO dto) {
         Long id = requireOrderId(orderId);
         WorkOrder order = getOrderById(id);
+        if (!isMobileManagementVisible(order, currentUserId())) {
+            throw new ServiceException("无权取消该工单");
+        }
         if (!WorkOrderStatusEnum.canCancel(order.getOrderStatus())) {
-            throw new ServiceException("当前工单状态不允许取消");
+            throw new ServiceException("仅待派单、待接单工单允许取消");
         }
         if (StringUtils.isEmpty(dto.getContent())) {
             throw new ServiceException("请填写取消原因");
         }
 
         boolean updated = transition(id, order.getOrderStatus(), WorkOrderStatusEnum.CANCELLED.getCode(), null);
+        if (!updated) {
+            throw new ServiceException("工单状态已变更,请刷新后重试");
+        }
         writeLog(id, WorkOrderLogOperTypeEnum.CANCEL, buildDesc("原因", dto.getContent()), dto.getAttachUrl());
         return updated;
     }
@@ -593,6 +668,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     public boolean applyDelay(Long orderId, WorkOrderDelayApplyDTO dto) {
         Long id = requireOrderId(orderId);
         WorkOrder order = getOrderById(id);
+        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "申请延期");
         requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许申请延期");
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.NONE.getCode(), order.getDelayApplyStatus())) {
@@ -621,6 +697,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("延期审核结果不能为空");
         }
         WorkOrder order = getOrderById(id);
+        requireDeviceCompanyScope(order);
+        requireVerifier(order, currentUserId(), "延期审核");
+        requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许延期审核");
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.PENDING_AUDIT.getCode(), order.getDelayApplyStatus())) {
             throw new ServiceException("该工单没有待审核的延期申请");
         }
@@ -656,17 +735,16 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return true;
     }
 
-    /**
-     * 构建工单分页查询条件(含数据权限片段与设备类别收窄)
-     */
+    /** 构建 PC 端工单分页查询条件(含参与人数据权限与设备类别收窄)。 */
     private LambdaQueryWrapper<WorkOrder> buildQueryWrapper(WorkOrderPageDTO condition) {
         LambdaQueryWrapper<WorkOrder> wrapper = buildFilterWrapper(condition, true);
+        applyPcScope(wrapper);
         wrapper.orderByAsc(WorkOrder::getOrderLevel).orderByAsc(WorkOrder::getOrderStatus).orderByDesc(WorkOrder::getFaultTime).orderByDesc(WorkOrder::getCreateTime);
         return wrapper;
     }
 
     /**
-     * 构建工单筛选条件(数据权限片段 + 业务条件 + 设备类别收窄),列表查询与状态统计共用。
+     * 构建工单业务筛选条件,PC 与移动端查询共用。
      *
      * <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) {
         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())
                 .eq(condition.getDelayApplyStatus() != null, WorkOrder::getDelayApplyStatus, condition.getDelayApplyStatus())
                 .eq(StringUtils.isNotEmpty(condition.getDeviceId()), WorkOrder::getDeviceId, condition.getDeviceId())
@@ -720,6 +793,117 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         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 的差异
      */
@@ -734,38 +918,13 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         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
      * → equipment_base.equipment_type_id → equipment_type.id → equipment_type.parent_id。
      * 采用 EXISTS 子查询而非 JOIN,避免设备表一对多时放大分页结果集,也不影响已有的
-     * 数据权限片段(dataScope)与 MyBatis-Plus 分页计数。</p>
+     * 参与人数据权限与 MyBatis-Plus 分页计数。</p>
      *
      * <p>parentTypeId 通过 MyBatis-Plus 的 apply 占位符 {0} 注入,由框架做 SQL 注入检查,不拼接原始入参。</p>
      *
@@ -1022,8 +1181,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         item.setOrderStatus(status);
         item.setOrderStatusDesc(WorkOrderStatusEnum.getDescByCode(status));
         item.setOrderLevel(order.getOrderLevel());
-        item.setDeptId(order.getDeptId());
+        item.setCreateBy(order.getCreateBy());
         item.setReceiveUser(order.getReceiveUser());
+        item.setVerifyUser(order.getVerifyUser());
         item.setDeviceId(order.getDeviceId());
         item.setDeviceCode(resolveDeviceCode(order.getDeviceId()));
         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);
     }
 
+    /**
+     * 查询当前用户可指派范围内的全部人员。
+     *
+     * <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 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> 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();
 
     /**
-     * 查询指定部门及其全部下级部门的有效联系人。
+     * 查询全部有效联系人,排除当前登录用户和超级管理员。
      *
-     * @param deptId 当前用户部门ID
      * @param excludeUserId 需要排除的当前用户ID
      * @return 通讯录联系人
      */
-    public List<ContactUserVo> selectContactsByDeptId(@Param("deptId") Long deptId,
-            @Param("excludeUserId") Long excludeUserId);
+    public List<ContactUserVo> selectContacts(@Param("excludeUserId") Long excludeUserId);
 
     /**
      * 按用户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> 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();
 
     /**
-     * 查询当前部门及其全部下级部门的有效联系人。
+     * 查询全部有效联系人,排除当前登录用户和超级管理员。
      *
-     * @param deptId 当前用户部门ID
      * @param excludeUserId 需要排除的当前用户ID
      * @return 通讯录联系人
      */
-    public List<ContactUserVo> selectContactsByDeptId(Long deptId, Long excludeUserId);
+    public List<ContactUserVo> selectContacts(Long excludeUserId);
 
     /**
      * 按用户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.mapper.SysLogininforMapper;
 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);
     }
 
+    @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
-    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"?>
 <!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">
+    <!-- 已转工单按 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">
         FROM app_user.alarm_data a
         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"?>
 <!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">
+    <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 保证一条报警一行;不按设备去重,否则会丢失待审核事件。 -->
     <sql id="scope">
         <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="orderLevel" column="order_level" jdbcType="TINYINT"/>
             <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="orderStatus" column="order_status" jdbcType="TINYINT"/>
             <result property="delayApplyStatus" column="delay_apply_status" jdbcType="TINYINT"/>
@@ -32,7 +31,7 @@
     <sql id="Base_Column_List">
         order_id,order_no,device_id,
         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,
         real_finish_time,repair_result,maintenance_before_img,maintenance_after_img,verify_user,
         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
 	</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 from app_user.sys_logininfor where info_id in
  		<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
     </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
     </select>
 
-    <select id="selectContactsByDeptId" resultMap="ContactUserResult">
+    <select id="selectContacts" resultMap="ContactUserResult">
         select u.user_id,
                u.nick_name,
                u.phonenumber,
@@ -120,15 +120,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                up.post_id,
                p.post_name
         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_post p on p.post_id = up.post_id and p.status = '0'
         where u.del_flag = '0'
           and u.status = '0'
           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
     </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 {
     private final MybatisConfiguration configuration = new MybatisConfiguration();
     @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) {

+ 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 {
     private final EquipmentTypeMapper types = mock(EquipmentTypeMapper.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();
     @BeforeEach void login() {
         SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно