Browse Source

优化报警提醒,报警清单,报警研判,当前报警GIS一张图

zengy 3 weeks ago
parent
commit
687491107b
100 changed files with 1930 additions and 1099 deletions
  1. 0 29
      alarm_data_table.sql
  2. 1 1
      audio-service/src/main/java/com/zksy/audio/service/NoiseLeakAlarmService.java
  3. 75 0
      docs/alarm-operations-2026-09-24.md
  4. 8 5
      docs/alarm-review-2026-09-23.md
  5. 4 5
      environment-service/src/main/java/com/zksy/environment/utils/AlarmUtil.java
  6. 2 2
      firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java
  7. 1 1
      flammable-gas-service/src/main/java/com/zksy/gas/utils/AlarmUtil.java
  8. 1 1
      flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java
  9. 2 2
      manhole-service/src/main/java/com/zksy/manhole/utils/AlarmUtil.java
  10. 12 4
      pipe-network-service/CONTEXT.md
  11. 38 0
      pipe-network-service/sql/migration_alarm_case_work_order_id.sql
  12. 24 0
      pipe-network-service/sql/migration_alarm_lifecycle_log.sql
  13. 37 0
      pipe-network-service/sql/migration_alarm_operations.sql
  14. 14 42
      pipe-network-service/sql/migration_alarm_review.sql
  15. 16 0
      pipe-network-service/sql/migration_alarm_review_log.sql
  16. 8 20
      pipe-network-service/sql/tests/alarm_review.test.mjs
  17. 21 17
      pipe-network-service/sql/water_supply_alarm_test_data.sql
  18. 31 27
      pipe-network-service/sql/water_supply_monitoring_alarm_rebuild_test_data.sql
  19. 1 40
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyAlarmController.java
  20. 53 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/alarm/AlarmController.java
  21. 0 8
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/alarm/AlarmTestController.java
  22. 0 24
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/drainage/DrainageWarningController.java
  23. 9 9
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/gasbasic/GasAlarmController.java
  24. 9 9
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/gasbasic/PressureAlarmController.java
  25. 0 60
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyAlarmLedgerControllerTest.java
  26. 48 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/alarm/AlarmOperationsControllerTest.java
  27. 35 3
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/alarm/AlarmReviewControllerTest.java
  28. 0 81
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/AlarmDashboardOutDTO.java
  29. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/AlarmMapPointOutDTO.java
  30. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/WaterAlarmOutDTO.java
  31. 0 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/IWaterSupplyAlarmService.java
  32. 10 160
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyAlarmServiceImpl.java
  33. 0 79
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterAlarmLedgerExportDTO.java
  34. 7 11
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/domain/AlarmData.java
  35. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/domain/AlarmLog.java
  36. 19 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmQuery.java
  37. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmReviewRequest.java
  38. 15 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmSampleVO.java
  39. 23 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmVO.java
  40. 8 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/enums/AlarmLogOperTypeEnum.java
  41. 30 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/enums/AlarmMetricSource.java
  42. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/enums/AlarmSeriesPeriod.java
  43. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmDataMapper.java
  44. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmLogMapper.java
  45. 22 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java
  46. 140 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmOperationsService.java
  47. 16 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewScopeService.java
  48. 18 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java
  49. 4 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/impl/AlarmClearServiceImpl.java
  50. 78 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/impl/AlarmDataServiceImpl.java
  51. 45 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/support/AlarmLifecycleSupport.java
  52. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/CaseInfo.java
  53. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/HazardHiddenAccount.java
  54. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/AbnormalDeviceVO.java
  55. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/drill/service/impl/DrillSimulationServiceImpl.java
  56. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/EquipmentBaseMapper.java
  57. 2 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/HazardHiddenAccountMapper.java
  58. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/DrillSimulationServiceImpl.java
  59. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/ManholeLayerServiceImpl.java
  60. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/RiskAssessmentServiceImpl.java
  61. 4 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java
  62. 4 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyQueryService.java
  63. 3 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java
  64. 0 20
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/DrainageWarningService.java
  65. 0 47
      pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/impl/DrainageWarningServiceImpl.java
  66. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/WorkOrderLog.java
  67. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderLogVO.java
  68. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/mapper/WorkOrderMapper.java
  69. 3 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java
  70. 59 45
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java
  71. 2 2
      pipe-network-service/zksy-system/src/main/resources/mapper/CaseInfoMapper.xml
  72. 7 7
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmDataMapper.xml
  73. 89 0
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml
  74. 3 3
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmReviewMapper.xml
  75. 2 2
      pipe-network-service/zksy-system/src/main/resources/mapper/ops/WorkOrderLogMapper.xml
  76. 15 0
      pipe-network-service/zksy-system/src/main/resources/mapper/ops/WorkOrderMapper.xml
  77. 2 2
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/AlarmWarningRelMapper.xml
  78. 0 228
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmDashboardServiceTest.java
  79. 2 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmLedgerServiceTest.java
  80. 2 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmRealtimeContractTest.java
  81. 30 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/mapper/AlarmLogHistoryMapperTest.java
  82. 60 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/mapper/AlarmOperationsMapperTest.java
  83. 4 4
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmClearServiceTest.java
  84. 114 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmLifecycleServiceTest.java
  85. 57 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmNumberGenerationTest.java
  86. 109 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmOperationsServiceTest.java
  87. 20 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewScopeServiceTest.java
  88. 81 6
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewServiceTest.java
  89. 0 1
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImplTest.java
  90. 73 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/mapper/WorkOrderAlarmConflictMapperTest.java
  91. 142 7
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java
  92. 1 1
      radar-service/src/main/java/com/zksy/radar/utils/MessageHandler.java
  93. 1 1
      telemetry-service/src/main/java/com/zksy/telemetry/utils/MessageHandler.java
  94. 1 1
      water-level-service/src/main/java/com/zksy/water/utils/MessageHandler.java
  95. 8 8
      zk-api-service/src/main/java/com/zksy/api/domain/AlarmData.java
  96. 1 1
      zk-api-service/src/main/java/com/zksy/api/domain/CaseInfo.java
  97. 1 1
      zk-api-service/src/main/java/com/zksy/api/domain/WorkOrderLog.java
  98. 23 0
      zk-api-service/src/main/java/com/zksy/api/enums/AlarmLogOperTypeEnum.java
  99. 19 4
      zk-api-service/src/main/java/com/zksy/api/mapper/AlarmDataMapper.java
  100. 27 0
      zk-api-service/src/main/java/com/zksy/api/mapper/AlarmLogMapper.java

+ 0 - 29
alarm_data_table.sql

@@ -1,29 +0,0 @@
--- 报警数据表
-CREATE TABLE IF NOT EXISTS `alarm_data` (
-  `id` varchar(64) NOT NULL COMMENT '主键',
-  `device_code` varchar(255) DEFAULT NULL COMMENT '设备编码',
-  `warning_type` varchar(100) DEFAULT NULL COMMENT '预警类型',
-  `warning_code` varchar(100) DEFAULT NULL COMMENT '预警编码',
-  `min_value` decimal(10,2) DEFAULT NULL COMMENT '预警最小值,小于等于该值视为报警',
-  `max_value` decimal(10,2) DEFAULT NULL COMMENT '预警最大值,大于等于该值视为报警',
-  `actual_value` decimal(10,2) DEFAULT NULL COMMENT '实际值',
-  `alarm_status` int(1) DEFAULT 0 COMMENT '报警状态 0-未处理 1-已处理',
-  `alarm_time` datetime DEFAULT NULL COMMENT '报警时间',
-  `remark` text COMMENT '备注',
-  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
-  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  KEY `idx_device_code` (`device_code`),
-  KEY `idx_alarm_status` (`alarm_status`),
-  KEY `idx_alarm_time` (`alarm_time`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报警数据表';
-
--- 修改现有表结构
--- ALTER TABLE `alarm_data` 
--- ADD COLUMN IF NOT EXISTS `min_value` decimal(10,2) DEFAULT NULL COMMENT '预警最小值,小于等于该值视为报警' AFTER `warning_code`,
--- ADD COLUMN IF NOT EXISTS `max_value` decimal(10,2) DEFAULT NULL COMMENT '预警最大值,大于等于该值视为报警' AFTER `min_value`;
-
--- 移除旧字段
--- ALTER TABLE `alarm_data` DROP COLUMN IF EXISTS `warning_value`;
--- ALTER TABLE `alarm_data` DROP COLUMN IF EXISTS `device_name`;
--- ALTER TABLE `alarm_data` DROP COLUMN IF EXISTS `device_type`;

+ 1 - 1
audio-service/src/main/java/com/zksy/audio/service/NoiseLeakAlarmService.java

@@ -194,7 +194,7 @@ public class NoiseLeakAlarmService {
         }
         alarm.setAlarmLevel(1);
         alarm.setAlarmStatus(0);
-        alarm.setAlarmTime(LocalDateTime.now());
+        alarm.setCreateTime(LocalDateTime.now());
         alarm.setCreateTime(LocalDateTime.now());
         alarm.setRemark(alarmCount == 1
                 ? String.format("漏失监测单次独立采样确认:rms≥阈值且在漏失频段[%.0f,%.0f]Hz", lowerFreq, upperFreq)

+ 75 - 0
docs/alarm-operations-2026-09-24.md

@@ -0,0 +1,75 @@
+# 报警提醒、清单、排水研判及 GIS 重构
+
+## 交付入口
+
+后端统一在 `AlarmController`,查询服务/DTO/枚举/Mapper 位于 `zksy-system/base/alarm`。前端共用 `src/views/subSystem/alarm`。
+
+| 既有菜单 | path | component | query |
+| --- | --- | --- | --- |
+| 2139 | alarmRemind | subSystem/alarm/remind | {"rootTypeId":2} |
+| 2140 | alarmList | subSystem/alarm/list | {"rootTypeId":2} |
+| 2141 | drainageAlarmAnalysis | subSystem/alarm/drainage/analysis | {"rootTypeId":2} |
+| 2143 | drainageAlarmGIS | subSystem/alarm/drainage/alarmGIS | {"rootTypeId":2} |
+| 2117 | alarmList | subSystem/alarm/list | {"rootTypeId":1} |
+
+GIS 按最终要求命名为 `alarmGIS.vue`,没有“周边环境”页签;保留设备信息、报警记录、监测曲线。菜单更新不创建新页面菜单,不调整父级、排序和角色关联。
+
+## 查询与交互
+
+- `GET /api/alarm`:框架分页,后端时间排序;支持报警单号、设备关键字、报警等级、监测状态、审核结果、日期。返回 `data.records/total`,清单不再只看 `alarm_status=0`。报警单号为清单和审核列表第一列。清单页面日期条件标题为“报警时间”,设备搜索提示“请输入设备编码/名称”,移除审核结果查询条件但保留表格的审核结果列;通用接口兼容已有审核结果参数。
+- `GET /api/alarm/statistics`、`/reminders`、`/options`:等级统计、按最近操作排序的事件提醒、枚举选项。提醒服务固定 `alarm_status=0`,不接受请求参数切换到已处理事件;页面移除“报警清单”操作,保留事件详情。每 30 秒更新,手动、悬停、聚焦、打开详情时暂停滚动,继续缓冲更新。
+- `GET /api/alarm/{alarmId}`、`/{alarmId}/timeline`:详情与报警操作历史,时间倒序、同一时间按日志 ID 倒序;人工操作显示用户昵称,前端按日期分组,附件图片缩略图可预览。工单操作不再复制到报警日志,确认日志直接显示“已转工单(实际工单号)”。
+- `GET /api/alarm/map`:排水 GIS,默认未恢复报警,按设备聚合;筛选后的报警超过 10000 条时明确提示收窄查询,不静默截断。缺坐标的设备仍保留在左侧列表。设备详情中的报警记录另按设备编码分页查询,包括历史已恢复记录。
+- `GET /api/alarm/{alarmId}/analysis?period=CURVE|DAY|DAILY|MONTHLY`:事件前后 60 分钟、事件当天、截至事件日 7 天日均、6 个月月均。与快照分开展示最新采样,不使用模拟数据。
+
+GIS 监测曲线选项/曲线操作与研判事件下拉只展示 `alarm_data.actual_value` 非空的事件,数值 0 正常保留。GIS 点位和报警记录仍包含无实际值的报警,不按此条件缩减地图。研判下拉请求 `GET /api/alarm?actualValueOnly=true`,数据库在分页之前加 `actual_value IS NOT NULL`,总数同步遵循此条件;默认 false 不影响普通清单。详情进入研判的按钮同样校验实际值,深链接不将无实际值事件补入候选,曲线接口对无实际值返回 unsupported/空采样且不查询采集表。
+
+所有新接口要求 `alarm:operations:list`,同时服务端按用户获授权的菜单校验 `rootTypeId` 和可选 `warningCode`,并查询设备类型根及全部后代。空设备类型范围返回空,不退化为无限制查询。审核授权仍单独校验 `subSystem/alarm/index`,有清单权限不等于有审核权限。GIS/研判只接受排水根类型。
+
+2117 同时保留 `waterSupply:alarm:list`(框架支持逗号分隔的权限),避免仍使用原接口的供水首页和供水报警一张图失去查询权限。
+
+监测曲线使用采集协议白名单:雷达流量/流速、水位、压力,以及水质悬浮物/COD/氨氮/电导率/pH。水质按 `device_id` 和 `node_id` 关联原采集数据。日/月均值直接聚合有效原始值,字符串无效值不强转、不补零,空日/月保留断点;原始曲线大于区间中位采样间隔 3 倍时断线。未配置固定采样周期,故不虚构覆盖率。状态类等没有数值数据源的报警显示无曲线。原始结果超过 20000 个时间点时提示改看日/月均值。
+
+## 数据库脚本及部署
+
+本轮脚本:`pipe-network-service/sql/migration_alarm_operations.sql`(KingbaseES PostgreSQL 兼容模式)。首次部署顺序:`migration_alarm_case_work_order_id.sql` → `migration_alarm_review.sql` → `migration_alarm_operations.sql`,先落地 `work_order_id`/`create_time` 字段,再创建日志与菜单。各脚本均不再创建生命周期触发器或自定义函数。
+
+已部署上述表结构的环境,本次必须重新部署管理端及 `zk-api-service`,日志已经改为业务代码主动写入,不依赖安装日志触发器。`pipe-network-service/sql/migration_alarm_lifecycle_log.sql` 作为增量补丁,更新单号注释/唯一索引、清除旧数据库赋号机制,并删除旧报警生命周期、工单日志镜像触发器及对应自定义函数,可重复执行,不更新菜单、不补录历史日志。部署时停止相关写入,执行清理脚本并同步更新管理端和采集端,再恢复服务。此后直接 SQL 修改业务表不会自动产生操作日志,应通过业务服务入口操作。
+
+- `alarm_data.alarm_no varchar(64)`,唯一索引;管理端 `AlarmDataServiceImpl.saveAlarmData`(含新增/演练)和采集端 `CollectionAlarmWriter` 在 INSERT 前由程序生成 `BJyyyyMMddHHmmss + 4位随机数`,日期时间取北京时间,随机数为 0000~9999,总长 20 位。覆盖请求带入的号码,复用、恢复和普通更新不改号。两端通过相同的数据库事务锁串行执行随机取号、查重和入库,碰撞重试最多 100 次,耗尽明确报错回滚,不生成重复号或改变格式。不回填历史记录。
+- 最新脚本删除旧 `trg_alarm_number` 触发器、`assign_alarm_number()` 函数、字段默认值及 `alarm_no_seq` 序列,不再由数据库赋号;既有号码保持原值。唯一索引继续作为最终约束。
+- 每次新增 `alarm_data` 写入一条类型 0 的 `alarm_log`,内容为“系统触发报警;报警单号:…;报警描述:…”。描述优先用非空 `remark`,否则依次用报警类型、报警编码、“监测报警”;保留有效触发值快照,数值为空时不生成实际值/阈值占位,数值 0 正常保留,未配置的上下限不生成空文案。
+- 采集 API 的 `CollectionAlarmWriter.markRecovered` 在独立事务中锁定同设备同指标的全部未处理记录,逐条更新状态并主动插入“监测恢复,报警自动解除 / 系统自动”。重复恢复不重复写日志;历史重复报警逐条记日志。不再设置数据库事务来源变量,不调用自定义数据库函数。管理端 `updateById` 统一覆盖通用修改、报警处理、燃气/压力解除入口,人工处理记录“报警已处理”,不误标为自动恢复。
+- 报警插入/恢复与日志在同一事务中,失败一起回滚。确认报警生成工单时,`work_order_log.oper_desc` 使用 `alarm_no` 而非 UUID。无号码的历史报警写明“历史记录(未生成单号)”,不迁移旧工单日志。
+- 不引入 `source_key`,不回填历史业务数据,不改变审核结果或设备状态。
+
+本次未连接或执行实际数据库,不能据此断言部署环境的触发器状态。旧 Java 代码缺失主动生命周期日志的问题已修复;日志表或权限异常会抛出错误并回滚。保留 Kingbase 原生事务锁及 log_id 的 nextval 序列自增,不引入存储过程。
+
+报警审核日志调整另提供 `pipe-network-service/sql/migration_alarm_review_log.sql`:清除旧日志触发器/自定义函数,把新写入日志类型限制为 0/1/2/3,以 `NOT VALID` 保留历史旧类型行,不删除或改写历史数据。已有环境先停止相关写入,按需要执行生命周期补丁,再执行本脚本;同步部署管理端、采集 API 和前端。首次部署也可在上述结构脚本后执行此补丁,支持重复执行。
+
+按最新要求,`zksy-system` 与 `zk-api-service` 各自保留一份 `AlarmLogOperTypeEnum`,值统一为 0 触发、1 确认、2 误报、3 处理,不增加共享模块或模块依赖。工单操作仅写 `work_order_log`;确认审核成功创建工单后,单条确认日志写入意见及实际工单号。`oper_user_name` 使用 `sys_user.nick_name`,自动动作使用系统名称。建议在仓库根目录通过 `mvn -pl zk-api-service,pipe-network-service/zksy-admin -am package -DskipTests` 联合构建;不再需要安装额外的报警契约模块。
+
+## 清理范围
+
+删除排水旧 `jcbj/bjtx.vue、bjyp.vue、bjqd.vue、dqbj.vue`,供水旧 `monitorAlarm/index.vue、wjcbj/bjgl.vue`。`WaterAlarmPage` 只保留仍在使用的供水 GIS,移除旧管理页分支和独占 JS/CSS;删除独占 API 包装函数。
+
+后端删除排水旧 `firstApprove/finalApprove` controller/service 方法、供水旧 dashboard/export controller、统计 service 和独占 DTO;同步删除/更新对应旧测试。供水首页、供水 GIS、其他模块仍调用的共享查询和排水预警发布/历史功能保留,不删除相应业务表。被删源码可通过 Git 历史恢复。
+
+## 验证
+
+2026-09-24 提醒/清单/GIS/研判筛选调整:管理端相关 Maven 测试 22 项、前端 Node 测试 26 项、PGlite 实际 Mapper SQL 测试 7 项通过,前端生产构建通过。覆盖提醒强制未处理、空值/零值、研判过滤先于分页及总数、跨子系统范围不泄漏。无需数据库结构脚本;部署前后端后生效,尚未进行真实 KingbaseES/登录浏览器验收。
+
+2026-09-24 后续枚举布局调整:按用户要求移除共享模块,恢复两工程各自的枚举并核对值完全一致。相关根目录联合 `clean test` 共 12 类、98 项通过(零失败/错误/跳过);代码与 Maven 配置无旧共享模块引用。以下 97 项为调整前验证记录。
+
+2026-09-24 报警审核日志调整:从仓库根目录对管理端和采集端执行 Maven `clean test`,选定 11 类共 97 项通过(零失败/错误/跳过),包含单条确认日志和真实工单号、昵称、两端枚举、生命周期事务回滚与历史查询;干净编译确保不依赖已删除的重复枚举 class。SQL 兼容回归和前端 Node 回归各 18 项通过,前端生产构建通过。
+
+最新规则回归覆盖确认审核单条日志含工单号、昵称取值、工单不写报警日志、两端枚举值一致、历史时间/ID 倒序。`AlarmLogHistoryMapperTest` 导出真实查询 SQL,PGlite 验证昵称关联、排序及历史旧类型保留;移除已失效的工单日志镜像测试。报警单号仍为 `BJyyyyMMddHHmmss + 4位随机数`,业务代码主动记日志,生命周期写入失败随业务变更一起回滚。前端历史组件及相关页面 18 项 Node 测试、生产构建通过,SQL 18 项通过。以下为此前阶段的验证记录,不表示仍采用旧的雪花单号、工单日志镜像或触发器日志方案。
+
+- Maven 管理端相关测试 72 项通过:工单 service 26、工单 Mapper 2、审核 service 17、范围 7、运营 service 7、运营 Mapper 1、审核 controller 8、运营 controller 3、供水保留台账 controller 1。
+- `mvn -pl zk-api-service -am -DskipTests compile -q` 通过。
+- 前端相关 Node 测试 22 项通过,生产构建通过(保留既有 Sass 弃用及包体积提示)。
+- PGlite 兼容性测试 6 项:验证移除旧数据库赋号机制、保留程序提供的号码与唯一索引、不回填历史、自动与人工处理日志、事务回滚、重复部署、5 个精准菜单与角色保留;直接执行 MyBatis 实际生成的清单/GIS/提醒/统计/详情/采样 SQL,验证范围和有效原始样本加权均值。
+- 程序赋号专项测试 7 项通过:管理端 3 项覆盖保存前赋号、覆盖请求号码、2000 条并发生成及插入/更新映射策略;采集端 4 项覆盖事务内赋号、复用不改号、入库失败回滚、插入/更新映射策略。工单 26 项和运营 Mapper 1 项通过;SQL 6 项重跑通过。更新 Mapper 测试以适配工作区已有 `work_order_id` 字段改名。
+- SQL 测试:先运行 `AlarmOperationsMapperTest` 生成 `zksy-system/target/alarm-operations-queries.json`,再通过 `PGLITE_MODULE` 指向 PGlite ESM 模块,执行 `node --test pipe-network-service/sql/tests/alarm_operations.test.mjs`。
+- 2026-09-24 日志补充回归:`CollectionAlarmLifecycleTest` 5 项通过,验证恢复事务来源标记、重复恢复、触发器写日志失败时传播异常并回滚;审核/运营 PGlite 测试合计 17 项通过,覆盖新增描述、空值/零值、多条报警逐条恢复、人工处理区分、真实日志插入失败回滚、补丁重跑及既有工单日志保留。同时移除采集未处理报警查询中已删除的 `alarm_time` 字段,使用 `create_time` 排序。
+- 两仓 `git diff --check` 通过。未执行真实 KingbaseES 集成、真实登录浏览器或地图底图联调;以上构建/测试不能替代实机验收。未提交、推送或部署。

+ 8 - 5
docs/alarm-review-2026-09-23.md

@@ -7,8 +7,9 @@
 - `audit_status`:0 未审核、1 确认报警、2 误报警。`alarm_level` 继续使用 1~4(一级最严重,红/橙/蓝/绿),不新增或更名为 `case_level`。
 - 确认报警原子更新审核状态、记录审核日志并创建工单;任一步失败整个审核事务回滚。不修改设备状态,也不将报警标为已处理。误报警仅记录结论及意见。
 - 审核转工单时,`work_order.order_desc` 根据原报警信息生成故障描述,审核意见单独记录到 `alarm_log.oper_desc`,不再覆盖故障描述。实际值为空的报警,页面及自动生成的工单描述均不显示实际值/阈值占位;数值 0 仍视为有效值。
-- 报警生成不再提前创建工单;保留井盖必要建案,确认建单时回填已有案件的 `order_id`。其他工单入口及既有同设备未办结工单冲突校验保留,发生冲突会返回明确错误并回滚审核。
-- 意见必填、最多 500 字;只允许 JPG/JPEG/PNG 图片,最多 5 张、单张 10 MB,前后端均拒绝 PDF。处置记录显示图片缩略图,点击可查看原图并切换同条记录的多张图片。图片复用管理端框架上传目录,票据在 Redis 保留两小时,绑定当前用户和报警。
+- 报警生成不再提前创建工单;保留井盖必要建案,确认建单时回填已有案件的 `order_id`。普通手动建单、隐患转工单不再限制同设备的工单数量;仅报警转工单按设备主键 + `alarm_data.warning_code` 查重,通过 `alarm_data.order_id` 关联工单,未办结状态使用 `WorkOrderStatusEnum.activeCodes()`(0/1/2/3/4/6),5 已结案和 7 已取消不占名额。不同报警编码可以分别建单,不按显示名称合并;报警已恢复也不释放尚未办结的工单名额。缺失报警编码时明确报错,不能绕过查重。冲突会回滚本次审核。
+- 报警建单在同一事务中先锁定设备档案行,再重新读取报警关联、查询同类未办结工单、创建并关联新工单,避免同设备多条报警并发审核重复建单。锁查询禁用缓存并刷新会话缓存,设备状态不作修改;同一报警重复调用继续返回原关联工单。本项为代码调整,不需要新增数据库结构或迁移历史数据(2026-09-24)。
+- 审核意见选填、最多 500 字,两种审核结果均允许不填;审核结果仍必选。正常关闭审核弹窗不再提示“放弃本次审核”,上传或提交中仍禁止关闭。只允许 JPG/JPEG/PNG 图片,最多 5 张、单张 10 MB,前后端均拒绝 PDF。处置记录显示图片缩略图,点击可查看原图并切换同条记录的多张图片。图片复用管理端框架上传目录,票据在 Redis 保留两小时,绑定当前用户和报警。
 
 ## 脚本
 
@@ -16,7 +17,9 @@
 
 - 只做结构和菜单调整,不复制旧 `alarm_audit`、不补历史日志、不推测历史审核结果;旧表数据不删除,但新代码不再读写。
 - `alarm_log.log_id int8 DEFAULT nextval('app_user.alarm_log_id_seq'::regclass)`;序列单独定义并 `OWNED BY`,Java 为 `Long` + `IdType.AUTO`。不新增 `source_key`。
-- 通过事务内触发器覆盖各服务的报警 INSERT,初始化日志;报警处理/工单关联、工单操作日志同步进入报警时间线。
+- 管理端与采集端在新增/处理事务中主动插入 `alarm_log`,不依赖日志触发器已部署。新增记录“系统触发报警;报警描述:…”,描述优先取非空备注,否则取报警类型/编码。采集自动恢复记录“监测恢复,报警自动解除”,仅状态真正变为 1 时记录,重复正常上报不重复记日志。部署脚本删除旧生命周期及工单日志镜像触发器/自定义函数,不再由数据库自动写日志。人工处理仍记报警日志;工单关联、工单操作不再单独写入 `alarm_log`,确认日志直接包含“已转工单(实际工单号)”及选填的审核意见。日志写入失败与业务变更一起回滚。最新单号规则为程序生成 `BJyyyyMMddHHmmss + 4位随机数`,见运营交付说明。
+- 人工操作写入 `sys_user.nick_name` 与用户 ID;历史查询按用户 ID 关联昵称,用户不存在时保留原日志名称。按 `oper_time DESC NULLS LAST, log_id DESC` 返回,审核页和详情页共用日期分组历史组件,样式参考工单 `dispose.vue`,图片保留缩略图和大图预览。
+- 日志枚举在 `zksy-system` 与 `zk-api-service` 各定义一份 `AlarmLogOperTypeEnum`,两端值保持一致:0 报警触发、1 确认报警、2 误报警、3 报警处理。不新增共享模块,移除关联工单/工单操作类型,历史旧类型日志保留并显示“历史操作”。已有环境执行增量脚本 `migration_alarm_review_log.sql`,不迁移或删除历史数据。
 - 精准更新已有菜单 2118/2142/2097/2024,`path='alarmAudit'`、`component='subSystem/alarm/index'`。设备类型根分别为 1/2/3/4,燃气额外 `warning_code='WARN-GAS-CONCENTRATION'`;保留原父级和角色关联。
 - 页面权限为 `alarm:review:list`;仅在缺失时创建 `alarm:review:audit` 按钮权限,给对应页面的现有角色补充新按钮授权,重复执行不恢复已经撤销的按钮授权。不会重复新增四个页面菜单。
 - `rootTypeId` 代表 `equipment_type.id` 的根,查询包含所有后代;服务端校验当前用户实际获授权的菜单范围,不能仅修改前端 query 跨系统访问。
@@ -33,7 +36,7 @@
 - `GET /reviews/{alarmId}`:详情。
 - `GET /{alarmId}/logs`:时间线。
 - `POST /{alarmId}/attachments`:Multipart 文件,返回附件票据。
-- `POST /{alarmId}/reviews`:auditStatus、opinion、attachmentTokens。
+- `POST /{alarmId}/reviews`:auditStatus 必填,opinion 选填(最多 500 字),attachmentTokens 选填。
 
 其他仍被报警总览、地图和独立页面使用的通用接口保留。供水旧审核/停用写接口、通用 `approve-to-work-order`、旧燃气浓度监测页面独占接口与五个旧 Vue 入口已移除,可通过 Git 恢复源码。
 
@@ -43,4 +46,4 @@
 - 前端生产构建通过,保留原有 Sass 弃用/包体积提示;相关 Node 回归 14 项通过。
 - 内存 PGlite SQL 回归 7 项通过:显式序列、精确菜单、初始化/关联/处理日志、回滚、重跑及权限撤销保留。测试文件 `pipe-network-service/sql/tests/alarm_review.test.mjs`。
 - 未连接或修改实际数据库;PGlite 不是 KingbaseES 实机验收,也未执行真实登录端到端测试。
-- `zk-api-service` 单独编译被既有缺失类 `com.zksy.api.service.CollectionAlarmWriter` 阻塞。HEAD 已引用该类,但 Git 树中无对应文件;本轮不臆造采集恢复实现。部署采集 API 前需要补回该类并重新编译、联调。
+- 2026-09-24 更新:`CollectionAlarmWriter` 已存在,`zk-api-service` 编译通过,原缺失类阻塞已解除。后续报警运营重构及新增脚本的执行顺序见 [报警运营交付说明](alarm-operations-2026-09-24.md)。

+ 4 - 5
environment-service/src/main/java/com/zksy/environment/utils/AlarmUtil.java

@@ -68,9 +68,9 @@ public class AlarmUtil {
         }
     }
 
-    public int saveAlarm(String deviceCode,
-                           String warningType, String warningCode, BigDecimal minValue, BigDecimal maxValue,
-                           BigDecimal actualValue, String remark) {
+    public void saveAlarm(String deviceCode,
+                          String warningType, String warningCode, BigDecimal minValue, BigDecimal maxValue,
+                          BigDecimal actualValue, String remark) {
         int alarmLevel = 4;
         try {
             AlarmData alarmData = new AlarmData();
@@ -87,7 +87,7 @@ public class AlarmUtil {
             alarmData.setDeviationRatio(deviationRatio);
 
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
+            alarmData.setCreateTime(LocalDateTime.now());
             alarmData.setRemark(remark);
             alarmData.setCreateTime(LocalDateTime.now());
 
@@ -102,7 +102,6 @@ public class AlarmUtil {
         } catch (Exception e) {
             log.error("报警处理失败: deviceCode={}, warningCode={}", deviceCode, warningCode, e);
         }
-        return alarmLevel;
     }
 
     private void sendAlarmSms(String deviceCode, String warningType, BigDecimal actualValue,

+ 2 - 2
firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java

@@ -289,7 +289,7 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			alarmData.setDeviationRatio(deviationRatio);
 
 			alarmData.setAlarmStatus(0);
-			alarmData.setAlarmTime(LocalDateTime.now());
+			alarmData.setCreateTime(LocalDateTime.now());
 			alarmData.setRemark(remark);
 			alarmData.setCreateTime(LocalDateTime.now());
 			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));
@@ -315,7 +315,7 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			alarmData.setActualValue(actualValue);
 			alarmData.setAlarmLevel(1);
 			alarmData.setAlarmStatus(0);
-			alarmData.setAlarmTime(LocalDateTime.now());
+			alarmData.setCreateTime(LocalDateTime.now());
 			alarmData.setRemark(remark);
 			alarmData.setCreateTime(LocalDateTime.now());
 			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));

+ 1 - 1
flammable-gas-service/src/main/java/com/zksy/gas/utils/AlarmUtil.java

@@ -49,7 +49,7 @@ public class AlarmUtil {
             alarmData.setMaxValue(maxValue);
             alarmData.setActualValue(actualValue);
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
+            alarmData.setCreateTime(LocalDateTime.now());
             alarmData.setRemark(remark);
             alarmData.setCreateTime(LocalDateTime.now());
 

+ 1 - 1
flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java

@@ -277,7 +277,7 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			alarmData.setDeviationRatio(deviationRatio);
 
 			alarmData.setAlarmStatus(0);
-			alarmData.setAlarmTime(LocalDateTime.now());
+			alarmData.setCreateTime(LocalDateTime.now());
 			alarmData.setRemark(remark);
 			alarmData.setCreateTime(LocalDateTime.now());
 			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));

+ 2 - 2
manhole-service/src/main/java/com/zksy/manhole/utils/AlarmUtil.java

@@ -84,7 +84,7 @@ public class AlarmUtil {
             alarmData.setDeviationRatio(deviationRatio);
 
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
+            alarmData.setCreateTime(LocalDateTime.now());
             alarmData.setRemark(remark);
 
             boolean saved = run(deviceCode, warningCode + ":报警入库", () ->
@@ -124,7 +124,7 @@ public class AlarmUtil {
             alarmData.setAlarmLevel(alarmLevel);
             alarmData.setDeviationRatio(BigDecimal.ZERO);
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
+            alarmData.setCreateTime(LocalDateTime.now());
             alarmData.setRemark(remark);
 
             boolean saved = run(deviceCode, warningCode + ":报警入库", () ->

+ 12 - 4
pipe-network-service/CONTEXT.md

@@ -35,7 +35,7 @@ _Avoid_:把供水子系统等同于单一的报警页面,或把监测设备
 
 2.2 **设备异常管理**:集中展示当前运行异常的供水监测设备,提供异常分类查询、设备状态跟踪和批量导出;本页面为只读状态看板,不承载工单创建或派发。
 
-2.3 **设备维修派单**:作为进入统一工单页的业务入口,面向已判定运行故障且不存在适用未结束同类工单的供水监测设备创建故障维修工单;具体派单与处置由统一工单页承载。
+2.3 **设备维修派单**:作为进入统一工单页的业务入口,面向已判定运行故障的供水监测设备创建故障维修工单;普通手动建单不限制同设备的工单数量,具体派单与处置由统一工单页承载。
 
 2.4 **设备维修记录**:管理设备历史维修记录,支持多维查询、修改、删除、批量导入和批量导出。
 
@@ -51,9 +51,9 @@ _Avoid_:把供水子系统等同于单一的报警页面,或把监测设备
 
 ### 4. 监测报警
 
-4.1 **报警管理**:依据各类监测设备的阈值规则和其他自动报警规则触发报警,展示报警等级、设备报警状态、报警展示状态及关联工单;本页面为报警台账总览,支持态势统计、趋势分析、分类占比、详情查看、批量导出和跳转报警审核,不承载审核、转工单、解除或派发操作。不同报警类型可形成不同工单;同一设备同一报警类型同时只能存在一个未结束的同类报警工单。
+4.1 **报警管理**:供水报警管理与排水报警清单统一到 `subSystem/alarm/list`,展示报警单号、设备信息、监测/审核/工单状态与责任人,按菜单 `rootTypeId` 校验授权并过滤设备类型树。清单只承载查询与详情,审核、派发和处置仍在相应页面。排水另有 `remind` 提醒、`drainage/analysis` 研判、`drainage/alarmGIS` GIS(无周边环境页签)。新清单替代旧管理页的态势/占比/导出分支;供水原有报警一张图继续保留。不同报警类型可形成不同工单;同一设备同一报警类型同时只能存在一个未结束的同类报警工单(2026-09-24)。
 
-4.2 **报警审核**:四个子系统统一使用 `AlarmController` 的审核接口和 `subSystem/alarm/index` 页面,列表仅展示 `alarm_status=0` 的事件。`audit_status` 独立记录未审核、确认报警、误报警;确认报警时创建待派单工单,误报警只记录结论,两种审核都不直接修改设备状态或报警处理状态。完整派单、接单、处理、验收由统一工单页承载。旧供水审核、直接派单和停用的解除接口已删除。报警生成、审核、处理及关联工单操作记录在 `alarm_log`,不回填历史数据。运行采集系统在收到有效恢复数据时,可以自动处理同设备、同报警编码的未处理告警;数值回到预警区即可处理,处理记录注明“系统自动”,不自动完成关联工单(2026-09-23用户确认,见仓库docs/collection-alarm-recovery-2026-09-23.md)。
+4.2 **报警审核**:四个子系统统一使用 `AlarmController` 的审核接口和 `subSystem/alarm/index` 页面,列表仅展示 `alarm_status=0` 的事件。`audit_status` 独立记录未审核、确认报警、误报警;确认报警时创建待派单工单,误报警只记录结论,两种审核都不直接修改设备状态或报警处理状态。完整派单、接单、处理、验收由统一工单页承载。旧供水审核、直接派单和停用的解除接口已删除。报警生成、审核及处理记录在 `alarm_log`,确认记录直接包含“已转工单(实际工单号)”及选填意见;关联工单、工单操作不再单独写入报警日志。人工操作使用 `sys_user.nick_name`,历史按时间和日志 ID 倒序展示,不回填或删除历史数据。运行采集系统在收到有效恢复数据时,可以自动处理同设备、同报警编码的未处理告警;数值回到预警区即可处理,处理记录注明“系统自动”,不自动完成关联工单(见仓库docs/collection-alarm-recovery-2026-09-23.md)。
 
 4.3 **阈值管理**:支持按传感器分类批量设置阈值、设置分时阈值并查询已配置点位的阈值详情。
 
@@ -154,6 +154,14 @@ _Avoid_:报警记录、未结工单
 设备因阈值越界或其他自动报警原因而产生的一次事件记录,用于保留报警事实和历史,不作为设备当前是否报警的主要依据。
 _Avoid_:设备报警状态、设备异常
 
+**报警运营筛选**:
+报警提醒固定只查询 `alarm_status=0`,清单可查询历史已处理事件。GIS 监测曲线选项和研判候选事件要求 `actual_value IS NOT NULL`,数值 0 有效;此限制不影响 GIS 点位和报警记录。研判使用 `actualValueOnly=true` 在后端分页前过滤,默认 false 保持一般查询不变,无实际值事件不读取采集曲线。
+_Avoid_:用有无实际值判断报警是否存在、把数值 0 当作空值
+
+**报警单号**:
+`alarm_data.alarm_no` 是报警事件的唯一业务号码,新建时由管理端或采集端程序生成,格式为 `BJyyyyMMddHHmmss` 加 4 位随机数(0000~9999),北京时间,总长 20 位。两端使用同一数据库事务锁保护取号、查重与入库,碰撞重试最多 100 次,唯一索引兜底;覆盖请求传入的号码,普通更新不改号,不回填历史。不使用单号生成触发器、默认值或序列。清单首列和报警转工单的创建日志使用该号,UUID 仅用于内部关联。新增、自动恢复和人工处理由业务代码在同一事务中主动写 `alarm_log`,不依赖任何自定义数据库函数或触发器;工单操作仅写 `work_order_log`,脚本删除旧日志镜像触发器及函数。`zksy-system` 与 `zk-api-service` 各保留一份 `AlarmLogOperTypeEnum`,不新增共享模块,两端值一致:0 报警触发、1 确认报警、2 误报警、3 报警处理,删除关联工单及工单操作类型。新增记录“系统触发报警”及报警描述(优先取非空备注,否则取类型/编码),恢复时记录“监测恢复,报警自动解除”;重复恢复不重复记录,日志写入失败则报警变更一同回滚,自动恢复不关闭工单。
+_Avoid_:报警主键、报警类型编码、工单号
+
 **报警展示状态**:
 由报警持久化状态、审核结果和关联工单推导出的面向用户的状态,用于区分待审核、已转工单、已解除和误报警;它不替代设备报警状态,误报警不代表监测数据已恢复。
 _Avoid_:报警记录持久化状态、设备报警状态
@@ -197,7 +205,7 @@ _Avoid_:用页面入口、工单标题或报警记录状态推断系统归属
 _Avoid_:维修任务、报警工单
 
 **同类报警工单**:
-同一设备针对同一报警类型建立的故障维修工单;同一设备同一报警类型同时只能存在一个未结束的同类报警工单。
+同一设备主键针对同一 `alarm_data.warning_code` 建立的故障维修工单,通过 `alarm_data.order_id` 关联;同设备同编码同时只能存在一个未办结工单(0/1/2/3/4/6),已结案和已取消不占名额。报警恢复不代表关联工单办结,不同编码可分别建单,普通手动建单及隐患转工单不受此限制。报警编码缺失时阻止报警转工单,不以报警名称替代(2026-09-24)。
 _Avoid_:同一报警记录、同设备全部工单
 
 **工单派发**:

+ 38 - 0
pipe-network-service/sql/migration_alarm_case_work_order_id.sql

@@ -0,0 +1,38 @@
+-- =====================================================================
+-- alarm / case / work_order_log 关联列 order_id → work_order_id 统一改名迁移
+-- 以及 alarm_data 删除 alarm_time 列(统一用 create_time)。
+--
+-- 部署顺序(务必先执行本脚本,再执行以下两个待执行迁移):
+--   1. migration_alarm_case_work_order_id.sql   (本脚本:4 表列改名 + alarm_data 删 alarm_time)
+--   2. migration_alarm_review.sql               (其触发器/索引已改为引用 work_order_id / create_time)
+--   3. migration_alarm_operations.sql           (其触发器已改为引用 work_order_id / create_time)
+--
+-- 原因:migration_alarm_review.sql 与 migration_alarm_operations.sql 两个待执行迁移里的
+--   触发器函数已经引用 NEW.work_order_id 与 coalesce(NEW.create_time, CURRENT_TIMESTAMP),
+--   并且 migration_alarm_review.sql 的 idx_alarm_review_query 已改为 (alarm_status, audit_status, create_time DESC)。
+--   因此必须在这两个迁移之前,先把列改名 + 删 alarm_time 落库,否则触发器/索引会因列不存在而报错。
+--
+-- 改动范围:
+--   - alarm_data / case_info / hazard_hidden_account / work_order_log 四张表的 order_id
+--     关联列统一 RENAME 为 work_order_id,关联关系不变(仍指向 work_order.order_id 主键)。
+--   - alarm_data 删除 alarm_time 列,统一使用 create_time。
+--   - work_order 表自身的 order_id 是工单主键,保持不变。
+--   - 不删除任何报警 / 案件 / 工单记录,仅调整列结构。
+--
+-- 方言:KingbaseES / PostgreSQL 兼容。RENAME COLUMN 在同一事务内原子完成,
+--   并自动更新依赖该列的索引、视图与触发器引用;触发器“函数体”已在上述两个迁移中同步改写。
+--   历史已执行的迁移(如 migration_work_order_status_refactor.sql 中
+--   idx_work_order_log_order_id ON work_order_log(order_id))不需改动,RENAME 会自动随列重命名。
+-- =====================================================================
+BEGIN;
+LOCK TABLE app_user.alarm_data, app_user.case_info, app_user.hazard_hidden_account,
+      app_user.work_order_log IN SHARE ROW EXCLUSIVE MODE;
+
+ALTER TABLE app_user.alarm_data            RENAME COLUMN order_id TO work_order_id;
+ALTER TABLE app_user.case_info             RENAME COLUMN order_id TO work_order_id;
+ALTER TABLE app_user.hazard_hidden_account RENAME COLUMN order_id TO work_order_id;
+ALTER TABLE app_user.work_order_log        RENAME COLUMN order_id TO work_order_id;
+
+ALTER TABLE app_user.alarm_data DROP COLUMN IF EXISTS alarm_time;
+
+COMMIT;

+ 24 - 0
pipe-network-service/sql/migration_alarm_lifecycle_log.sql

@@ -0,0 +1,24 @@
+-- 报警触发/自动解除日志补丁,KingbaseES PostgreSQL 兼容模式。
+-- 已部署 migration_alarm_case_work_order_id.sql、migration_alarm_review.sql、
+-- migration_alarm_operations.sql 的环境只需执行本补丁,不改菜单、不回填历史数据。
+-- 日志字典共用 AlarmLogOperTypeEnum:0=报警触发,1=确认报警,2=误报警,3=报警处理。
+-- 新增、审核、自动解除、人工处理日志由 Java 在业务事务内写入;工单操作仅写 work_order_log。
+-- 日志类型约束收敛另执行 migration_alarm_review_log.sql;确认日志直接包含实际工单号。
+-- 停止相关写入,执行本脚本并同步部署管理端和 zk-api-service 后恢复服务。
+BEGIN;
+LOCK TABLE app_user.alarm_data, app_user.work_order_log IN SHARE ROW EXCLUSIVE MODE;
+COMMENT ON COLUMN app_user.alarm_data.alarm_no IS '报警单号:程序生成 BJyyyyMMddHHmmss + 4位随机数,北京时间,唯一且不回填历史';
+CREATE UNIQUE INDEX IF NOT EXISTS uk_alarm_data_alarm_no ON app_user.alarm_data(alarm_no);
+-- 防止旧数据库赋号机制覆盖程序提供的新格式;不改写已有号码。
+DROP TRIGGER IF EXISTS trg_alarm_number ON app_user.alarm_data;
+DROP FUNCTION IF EXISTS app_user.assign_alarm_number();
+ALTER TABLE app_user.alarm_data ALTER COLUMN alarm_no DROP DEFAULT;
+DROP SEQUENCE IF EXISTS app_user.alarm_no_seq;
+
+-- 报警日志完全由业务代码写入;删除旧触发器/自定义函数,避免重复日志。
+DROP TRIGGER IF EXISTS trg_alarm_lifecycle_log ON app_user.alarm_data;
+DROP TRIGGER IF EXISTS trg_work_order_alarm_log ON app_user.work_order_log;
+DROP FUNCTION IF EXISTS app_user.record_alarm_lifecycle();
+DROP FUNCTION IF EXISTS app_user.record_alarm_work_order_log();
+
+COMMIT;

+ 37 - 0
pipe-network-service/sql/migration_alarm_operations.sql

@@ -0,0 +1,37 @@
+-- KingbaseES PostgreSQL 兼容模式。
+-- 部署顺序:必须先执行 migration_alarm_case_work_order_id.sql(4 表 order_id→work_order_id 改名
+--   + alarm_data 删 alarm_time),再执行 migration_alarm_review.sql,最后执行本脚本;
+--   列未先落地会因列不存在而报错。
+-- 不回填历史报警、不重写旧日志;已存在报警允许 alarm_no 为空。
+BEGIN;
+LOCK TABLE app_user.alarm_data, app_user.work_order_log IN SHARE ROW EXCLUSIVE MODE;
+ALTER TABLE app_user.alarm_data ADD COLUMN IF NOT EXISTS alarm_no varchar(64);
+COMMENT ON COLUMN app_user.alarm_data.alarm_no IS '报警单号:程序生成 BJyyyyMMddHHmmss + 4位随机数,北京时间,唯一且不回填历史';
+CREATE UNIQUE INDEX IF NOT EXISTS uk_alarm_data_alarm_no ON app_user.alarm_data(alarm_no);
+
+-- 兼容曾执行过旧版脚本的环境:停止数据库赋号,已有号码保持原值。
+DROP TRIGGER IF EXISTS trg_alarm_number ON app_user.alarm_data;
+DROP FUNCTION IF EXISTS app_user.assign_alarm_number();
+ALTER TABLE app_user.alarm_data ALTER COLUMN alarm_no DROP DEFAULT;
+DROP SEQUENCE IF EXISTS app_user.alarm_no_seq;
+
+-- 报警日志完全由业务代码写入;删除旧触发器/自定义函数,避免重复日志。
+DROP TRIGGER IF EXISTS trg_alarm_lifecycle_log ON app_user.alarm_data;
+DROP TRIGGER IF EXISTS trg_work_order_alarm_log ON app_user.work_order_log;
+DROP FUNCTION IF EXISTS app_user.record_alarm_lifecycle();
+DROP FUNCTION IF EXISTS app_user.record_alarm_work_order_log();
+
+-- 精确更新现有菜单,保留父级、排序和角色授权。
+UPDATE app_user.sys_menu SET
+    path = CASE menu_id WHEN 2139 THEN 'alarmRemind' WHEN 2140 THEN 'alarmList'
+        WHEN 2141 THEN 'drainageAlarmAnalysis' WHEN 2143 THEN 'drainageAlarmGIS' WHEN 2117 THEN 'alarmList' END,
+    component = CASE menu_id WHEN 2139 THEN 'subSystem/alarm/remind' WHEN 2140 THEN 'subSystem/alarm/list'
+        WHEN 2141 THEN 'subSystem/alarm/drainage/analysis' WHEN 2143 THEN 'subSystem/alarm/drainage/alarmGIS'
+        WHEN 2117 THEN 'subSystem/alarm/list' END,
+    query = CASE WHEN menu_id = 2117 THEN '{"rootTypeId":1}' ELSE '{"rootTypeId":2}' END,
+    -- 框架按逗号拆分权限;供水首页和供水一张图仍使用原查询接口。
+    perms = CASE WHEN menu_id = 2117 THEN 'alarm:operations:list,waterSupply:alarm:list'
+                 ELSE 'alarm:operations:list' END,
+    update_time = CURRENT_TIMESTAMP
+WHERE menu_id IN (2139,2140,2141,2143,2117);
+COMMIT;

+ 14 - 42
pipe-network-service/sql/migration_alarm_review.sql

@@ -1,8 +1,10 @@
--- 统一报警审核。KingbaseES(PostgreSQL 兼容模式)。先备份,停止报警/工单写入,
--- 完整执行事务,再同时部署管理端、API 服务和前端;不要拆开执行或忽略错误。
+-- 统一报警审核。KingbaseES(PostgreSQL 兼容模式)。
+-- 部署顺序:必须先执行 migration_alarm_case_work_order_id.sql(4 表 order_id→work_order_id 改名
+--   + alarm_data 删 alarm_time),再执行本脚本,日志索引使用 create_time。
+--   列未先落地会因列不存在而报错。
+-- 先备份,停止报警/工单写入,完整执行事务,再同时部署管理端、API 服务和前端;不要拆开执行或忽略错误。
 -- audit_status 与 alarm_status 独立;确认报警创建工单,但不改变设备或报警处理状态。
--- 日志操作字典与 AlarmLogOperTypeEnum 对齐:0触发/1确认/2误报/3处理/4关联工单/5工单操作。
--- 用数据库触发器统一覆盖各采集服务、API、演练及直接入库路径,不在多个服务重复初始化日志。
+-- 日志操作字典与共享 AlarmLogOperTypeEnum 对齐:0触发/1确认/2误报/3处理。
 -- 仅调整结构与菜单,不迁移或补录历史数据;旧 alarm_audit 表不再由新代码读写。
 BEGIN;
 LOCK TABLE app_user.alarm_data, app_user.work_order_log IN SHARE ROW EXCLUSIVE MODE;
@@ -25,7 +27,7 @@ CREATE SEQUENCE IF NOT EXISTS app_user.alarm_log_id_seq
 CREATE TABLE IF NOT EXISTS app_user.alarm_log (
     log_id int8 NOT NULL DEFAULT nextval('app_user.alarm_log_id_seq'::regclass) PRIMARY KEY,
     alarm_id varchar(64) NOT NULL,
-    oper_type integer NOT NULL CHECK (oper_type IN (0,1,2,3,4,5)),
+    oper_type integer NOT NULL CHECK (oper_type IN (0,1,2,3)),
     oper_desc text,
     attach_url text,
     oper_user_id int8,
@@ -34,52 +36,22 @@ CREATE TABLE IF NOT EXISTS app_user.alarm_log (
 );
 ALTER SEQUENCE app_user.alarm_log_id_seq OWNED BY app_user.alarm_log.log_id;
 COMMENT ON COLUMN app_user.alarm_log.log_id IS '日志主键,int8,使用 alarm_log_id_seq 序列自增长';
-COMMENT ON TABLE app_user.alarm_log IS '报警操作日志,保留报警触发、审核、处理及关联工单的全流程';
+COMMENT ON TABLE app_user.alarm_log IS '报警操作日志,记录报警触发、审核及处理;确认日志包含转工单号';
 COMMENT ON COLUMN app_user.alarm_log.alarm_id IS '关联 alarm_data.id';
-COMMENT ON COLUMN app_user.alarm_log.oper_type IS 'AlarmLogOperTypeEnum:0触发 1确认 2误报 3处理 4关联工单 5工单操作';
+COMMENT ON COLUMN app_user.alarm_log.oper_type IS 'AlarmLogOperTypeEnum:0触发 1确认 2误报 3处理;工单日志只写work_order_log';
 COMMENT ON COLUMN app_user.alarm_log.oper_desc IS '操作详情或审核意见';
 COMMENT ON COLUMN app_user.alarm_log.attach_url IS '附件地址,多个用逗号分隔';
 COMMENT ON COLUMN app_user.alarm_log.oper_user_id IS '操作人ID,系统操作可为空';
-COMMENT ON COLUMN app_user.alarm_log.oper_user_name IS '操作人名称';
+COMMENT ON COLUMN app_user.alarm_log.oper_user_name IS '操作人昵称(sys_user.nick_name);自动操作使用系统名称';
 COMMENT ON COLUMN app_user.alarm_log.oper_time IS '操作时间';
 CREATE INDEX IF NOT EXISTS idx_alarm_log_alarm_time ON app_user.alarm_log(alarm_id, oper_time, log_id);
-CREATE INDEX IF NOT EXISTS idx_alarm_review_query ON app_user.alarm_data(alarm_status, audit_status, alarm_time DESC);
+CREATE INDEX IF NOT EXISTS idx_alarm_review_query ON app_user.alarm_data(alarm_status, audit_status, create_time DESC);
 
-CREATE OR REPLACE FUNCTION app_user.record_alarm_lifecycle() RETURNS trigger AS $$
-BEGIN
-    IF TG_OP = 'INSERT' THEN
-        INSERT INTO app_user.alarm_log(alarm_id, oper_type, oper_desc, oper_user_name, oper_time)
-        VALUES (NEW.id, 0, concat(coalesce(NEW.warning_type, NEW.warning_code, '监测报警'), ';实际值:', NEW.actual_value,
-                ';触发时下限:', NEW.min_value, ';触发时上限:', NEW.max_value),
-                '监测系统', coalesce(NEW.alarm_time, NEW.create_time, CURRENT_TIMESTAMP));
-    ELSIF NEW.alarm_status IS DISTINCT FROM OLD.alarm_status AND NEW.alarm_status = 1 THEN
-        INSERT INTO app_user.alarm_log(alarm_id, oper_type, oper_desc, oper_user_name, oper_time)
-        VALUES (NEW.id, 3, '报警已处理', '系统', clock_timestamp());
-    END IF;
-    IF (TG_OP = 'INSERT' AND NEW.order_id IS NOT NULL)
-       OR (TG_OP = 'UPDATE' AND NEW.order_id IS DISTINCT FROM OLD.order_id AND NEW.order_id IS NOT NULL) THEN
-        INSERT INTO app_user.alarm_log(alarm_id, oper_type, oper_desc, oper_user_name, oper_time)
-        VALUES (NEW.id, 4, '已关联工单ID:' || NEW.order_id, '系统', clock_timestamp());
-    END IF;
-    RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
+-- 报警日志完全由业务代码写入;删除旧触发器/自定义函数,避免重复日志。
 DROP TRIGGER IF EXISTS trg_alarm_lifecycle_log ON app_user.alarm_data;
-CREATE TRIGGER trg_alarm_lifecycle_log AFTER INSERT OR UPDATE OF alarm_status, order_id ON app_user.alarm_data
-FOR EACH ROW EXECUTE PROCEDURE app_user.record_alarm_lifecycle();
-
-CREATE OR REPLACE FUNCTION app_user.record_alarm_work_order_log() RETURNS trigger AS $$
-BEGIN
-    INSERT INTO app_user.alarm_log(alarm_id, oper_type, oper_desc, attach_url, oper_user_id, oper_user_name, oper_time)
-    SELECT a.id, 5, NEW.oper_desc, NEW.attach_url, NEW.oper_user_id, NEW.oper_user_name,
-           coalesce(NEW.oper_time, CURRENT_TIMESTAMP)
-    FROM app_user.alarm_data a WHERE a.order_id = NEW.order_id;
-    RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
 DROP TRIGGER IF EXISTS trg_work_order_alarm_log ON app_user.work_order_log;
-CREATE TRIGGER trg_work_order_alarm_log AFTER INSERT ON app_user.work_order_log
-FOR EACH ROW EXECUTE PROCEDURE app_user.record_alarm_work_order_log();
+DROP FUNCTION IF EXISTS app_user.record_alarm_lifecycle();
+DROP FUNCTION IF EXISTS app_user.record_alarm_work_order_log();
 
 -- 四条都是已有页面菜单:精准更新 ID,统一 path,保留父级、排序、图标与已有角色关联。
 UPDATE app_user.sys_menu SET path = 'alarmAudit', component = 'subSystem/alarm/index', perms = 'alarm:review:list',

+ 16 - 0
pipe-network-service/sql/migration_alarm_review_log.sql

@@ -0,0 +1,16 @@
+-- 报警审核日志收敛,KingbaseES PostgreSQL兼容模式。
+-- 不删除或改写历史日志;NOT VALID保留既有旧类型记录,只约束后续写入。
+-- 停止相关写入,执行后同步部署管理端和zk-api-service。
+BEGIN;
+LOCK TABLE app_user.alarm_data, app_user.work_order_log, app_user.alarm_log IN SHARE ROW EXCLUSIVE MODE;
+DROP TRIGGER IF EXISTS trg_alarm_lifecycle_log ON app_user.alarm_data;
+DROP TRIGGER IF EXISTS trg_work_order_alarm_log ON app_user.work_order_log;
+DROP FUNCTION IF EXISTS app_user.record_alarm_lifecycle();
+DROP FUNCTION IF EXISTS app_user.record_alarm_work_order_log();
+ALTER TABLE app_user.alarm_log DROP CONSTRAINT IF EXISTS alarm_log_oper_type_check;
+ALTER TABLE app_user.alarm_log DROP CONSTRAINT IF EXISTS ck_alarm_log_oper_type_current;
+ALTER TABLE app_user.alarm_log ADD CONSTRAINT ck_alarm_log_oper_type_current CHECK (oper_type IN (0,1,2,3)) NOT VALID;
+COMMENT ON TABLE app_user.alarm_log IS '报警操作日志,记录报警触发、审核及处理;确认日志包含转工单号';
+COMMENT ON COLUMN app_user.alarm_log.oper_type IS 'AlarmLogOperTypeEnum:0触发 1确认 2误报 3处理;工单日志只写work_order_log';
+COMMENT ON COLUMN app_user.alarm_log.oper_user_name IS '操作人昵称(sys_user.nick_name);自动操作使用系统名称';
+COMMIT;

+ 8 - 20
pipe-network-service/sql/tests/alarm_review.test.mjs

@@ -11,9 +11,9 @@ const sql = await readFile(new URL('../migration_alarm_review.sql', import.meta.
 await db.exec(`
  CREATE SCHEMA app_user;
  CREATE TABLE app_user.alarm_data(id varchar(64) PRIMARY KEY, alarm_status integer DEFAULT 0, alarm_level integer,
- warning_code text, warning_type text, actual_value numeric, min_value numeric, max_value numeric,
- alarm_time timestamp, create_time timestamp, handle_remark text, handle_user text, handle_time timestamp, order_id int8);
- CREATE TABLE app_user.work_order_log(log_id int8, order_id int8, oper_desc text, attach_url text,
+ warning_code text, warning_type text, remark text, actual_value numeric, min_value numeric, max_value numeric,
+ create_time timestamp, work_order_id int8);
+ CREATE TABLE app_user.work_order_log(log_id int8, work_order_id int8, oper_desc text, attach_url text,
  oper_user_id int8, oper_user_name text, oper_time timestamp);
  CREATE TABLE app_user.sys_menu(menu_id int8 PRIMARY KEY, menu_name text, parent_id int8, order_num integer,
  path text, component text, query text, is_frame integer, is_cache integer, menu_type text, visible text,
@@ -46,22 +46,6 @@ test('four existing menu IDs get alarmAudit and exact independent scopes, unrela
   assert.equal((await rows('SELECT path FROM app_user.sys_menu WHERE menu_id=3000'))[0].path,'keep')
   assert.equal((await rows("SELECT * FROM app_user.sys_menu WHERE perms='alarm:review:audit'")).length,4)
 })
-test('new alarm initializes one snapshot log with generated ID', async () => {
-  await db.exec("INSERT INTO app_user.alarm_data(id,warning_type,actual_value,min_value,max_value) VALUES ('a1','压力',0,1,10)")
-  const logs = await rows("SELECT * FROM app_user.alarm_log WHERE alarm_id='a1'")
-  assert.equal(logs.length,1); assert.equal(logs[0].oper_type,0); assert.ok(logs[0].log_id > 0)
-  assert.match(logs[0].oper_desc,/实际值:0/)
-})
-test('order relation and work order operations are logged, unrelated updates do not duplicate logs', async () => {
-  await db.exec("UPDATE app_user.alarm_data SET order_id=88 WHERE id='a1'; UPDATE app_user.alarm_data SET order_id=88 WHERE id='a1'; INSERT INTO app_user.work_order_log(order_id,oper_desc) VALUES (88,'创建工单');")
-  const logs = await rows("SELECT oper_type FROM app_user.alarm_log WHERE alarm_id='a1' ORDER BY log_id")
-  assert.deepEqual(logs.map(x=>x.oper_type),[0,4,5])
-})
-test('handling log records recovery but does not modify audit result', async () => {
-  await db.exec("UPDATE app_user.alarm_data SET audit_status=1,alarm_status=1,handle_remark='监测恢复' WHERE id='a1'")
-  assert.equal((await rows("SELECT audit_status FROM app_user.alarm_data WHERE id='a1'"))[0].audit_status,1)
-  assert.equal((await rows("SELECT oper_desc FROM app_user.alarm_log WHERE alarm_id='a1' AND oper_type=3"))[0].oper_desc,'监测恢复')
-})
 test('repeated deployment preserves existing logs, IDs and intentional permission revocations', async () => {
   const oldLogs = await rows('SELECT log_id FROM app_user.alarm_log ORDER BY log_id')
   await db.exec("DELETE FROM app_user.sys_role_menu WHERE menu_id IN (SELECT menu_id FROM app_user.sys_menu WHERE parent_id=2118 AND perms='alarm:review:audit')")
@@ -70,10 +54,14 @@ test('repeated deployment preserves existing logs, IDs and intentional permissio
   assert.equal((await rows("SELECT * FROM app_user.sys_menu WHERE perms='alarm:review:audit'")).length,4)
   assert.equal((await rows("SELECT * FROM app_user.sys_role_menu WHERE menu_id IN (SELECT menu_id FROM app_user.sys_menu WHERE parent_id=2118 AND perms='alarm:review:audit')")).length,0)
   await db.exec("INSERT INTO app_user.alarm_data(id) VALUES ('a2')")
-  assert.ok((await rows("SELECT log_id FROM app_user.alarm_log WHERE alarm_id='a2'"))[0].log_id > oldLogs.at(-1).log_id)
+  assert.equal((await rows("SELECT * FROM app_user.alarm_log WHERE alarm_id='a2'")).length,0)
 })
 test('transaction rollback also rolls back initialized logs', async () => {
   await db.exec("BEGIN; INSERT INTO app_user.alarm_data(id) VALUES ('rolled-back'); ROLLBACK;")
   assert.equal((await rows("SELECT * FROM app_user.alarm_log WHERE alarm_id='rolled-back'")).length,0)
   await assert.rejects(db.exec("UPDATE app_user.alarm_data SET audit_status=3 WHERE id='a2'"))
 })
+test('schema contains no stored lifecycle functions or triggers',async()=>{
+  assert.equal((await rows("SELECT * FROM pg_trigger WHERE tgname IN ('trg_alarm_lifecycle_log','trg_work_order_alarm_log')")).length,0)
+  assert.deepEqual(await rows("SELECT to_regprocedure('app_user.record_alarm_lifecycle()') AS lifecycle,to_regprocedure('app_user.record_alarm_work_order_log()') AS mirror"),[{lifecycle:null,mirror:null}])
+})

+ 21 - 17
pipe-network-service/sql/water_supply_alarm_test_data.sql

@@ -4,15 +4,18 @@
 --   1. 测试数据使用 TEST- / test- 前缀,可安全重复执行(先删除再插入)。
 --   2. 若设备类别中已存在顶级类型"供水",则复用;否则自动创建测试顶级类型。
 --   3. 清理请执行本文件末尾的清理脚本,或单独执行 water_supply_alarm_test_cleanup.sql。
+--   4. 部署顺序:须先执行 migration_alarm_case_work_order_id.sql(alarm_data/case_info/
+--      hazard_hidden_account/work_order_log 的 order_id→work_order_id 改名 + alarm_data 删 alarm_time),
+--      再执行本脚本;否则 work_order_id 列不存在会报错。
 -- ============================================================
 
 -- ------------------------------------------------------------
 -- 0. 清理历史测试数据(保证可重复执行)
 -- ------------------------------------------------------------
 DELETE FROM app_user.alarm_audit WHERE alarm_id LIKE 'test-alarm-%';
-DELETE FROM app_user.work_order_log WHERE order_id IN
-    (SELECT order_id FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%'));
-DELETE FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%');
+DELETE FROM app_user.work_order_log WHERE work_order_id IN
+    (SELECT order_id FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT work_order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%'));
+DELETE FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT work_order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%');
 DELETE FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%';
 DELETE FROM app_user.warning_threshold WHERE id LIKE 'test-thr-%' OR device_code LIKE 'TEST-%';
 DELETE FROM app_user.equipment_base WHERE equipment_id LIKE 'TEST-EQ-%';
@@ -83,18 +86,19 @@ END $$;
 -- ------------------------------------------------------------
 -- 2. 报警数据(alarm_data)
 --    预期统计:实时报警3个设备 / 今日报警6条 / 平均响应时间由alarm_log处理日志统计 / 设备异常率75%
+--    注意:alarm_time 列已删除,统一使用 create_time。
 -- ------------------------------------------------------------
 INSERT INTO app_user.alarm_data
-    (id, device_code, warning_type, warning_code, actual_value, min_value, max_value, alarm_status, alarm_level, deviation_ratio, alarm_time, remark, create_time, update_time)
+    (id, device_code, warning_type, warning_code, actual_value, min_value, max_value, alarm_status, alarm_level, deviation_ratio, remark, create_time, update_time)
 VALUES
-    ('test-alarm-001', 'TEST-WL-001', '水位预警', 'WARN-WATER-LEVEL-TEST', 0.20, 0.50, 3.00, 0, 1, 40.00, now() - interval '180 minutes', '供水监测报警测试数据', now() - interval '180 minutes', now() - interval '180 minutes'),
-    ('test-alarm-002', 'TEST-WL-001', '水位预警', 'WARN-WATER-LEVEL-TEST', 0.30, 0.50, 3.00, 0, 2, 35.00, now() - interval '150 minutes', '供水监测报警测试数据', now() - interval '150 minutes', now() - interval '150 minutes'),
-    ('test-alarm-003', 'TEST-WL-002', '水位预警', 'WARN-WATER-LEVEL-TEST', 3.80, 0.50, 3.00, 1, 3, 25.00, now() - interval '120 minutes', '供水监测报警测试数据', now() - interval '120 minutes', now() - interval '85 minutes'),
-    ('test-alarm-004', 'TEST-PR-001', '压力预警', 'WARN-PRESSURE-TEST', 0.95, 0.40, 0.80, 0, 1, 30.00, now() - interval '100 minutes', '供水监测报警测试数据', now() - interval '100 minutes', now() - interval '100 minutes'),
-    ('test-alarm-005', 'TEST-PR-001', '压力预警', 'WARN-PRESSURE-TEST', 0.90, 0.40, 0.80, 1, 2, 28.00, now() - interval '90 minutes', '供水监测报警测试数据', now() - interval '90 minutes', now() - interval '60 minutes'),
-    ('test-alarm-006', 'TEST-FL-001', '流量预警', 'WARN-FLOW-TEST', 120.00, 10.00, 100.00, 1, 4, 20.00, now() - interval '1 day' - interval '60 minutes', '供水监测报警测试数据', now() - interval '1 day' - interval '60 minutes', now() - interval '1 day'),
-    ('test-alarm-007', 'TEST-FL-001', '流量预警', 'WARN-FLOW-TEST', 115.00, 10.00, 100.00, 0, 3, 15.00, now() - interval '1 day' - interval '30 minutes', '供水监测报警测试数据', now() - interval '1 day' - interval '30 minutes', now() - interval '1 day' - interval '30 minutes'),
-    ('test-alarm-008', 'TEST-WL-002', '水位预警', 'WARN-WATER-LEVEL-TEST', 3.50, 0.50, 3.00, 1, 2, 18.00, now() - interval '300 minutes', '供水监测报警测试数据', now() - interval '300 minutes', now() - interval '290 minutes');
+    ('test-alarm-001', 'TEST-WL-001', '水位预警', 'WARN-WATER-LEVEL-TEST', 0.20, 0.50, 3.00, 0, 1, 40.00, '供水监测报警测试数据', now() - interval '180 minutes', now() - interval '180 minutes'),
+    ('test-alarm-002', 'TEST-WL-001', '水位预警', 'WARN-WATER-LEVEL-TEST', 0.30, 0.50, 3.00, 0, 2, 35.00, '供水监测报警测试数据', now() - interval '150 minutes', now() - interval '150 minutes'),
+    ('test-alarm-003', 'TEST-WL-002', '水位预警', 'WARN-WATER-LEVEL-TEST', 3.80, 0.50, 3.00, 1, 3, 25.00, '供水监测报警测试数据', now() - interval '120 minutes', now() - interval '85 minutes'),
+    ('test-alarm-004', 'TEST-PR-001', '压力预警', 'WARN-PRESSURE-TEST', 0.95, 0.40, 0.80, 0, 1, 30.00, '供水监测报警测试数据', now() - interval '100 minutes', now() - interval '100 minutes'),
+    ('test-alarm-005', 'TEST-PR-001', '压力预警', 'WARN-PRESSURE-TEST', 0.90, 0.40, 0.80, 1, 2, 28.00, '供水监测报警测试数据', now() - interval '90 minutes', now() - interval '60 minutes'),
+    ('test-alarm-006', 'TEST-FL-001', '流量预警', 'WARN-FLOW-TEST', 120.00, 10.00, 100.00, 1, 4, 20.00, '供水监测报警测试数据', now() - interval '1 day' - interval '60 minutes', now() - interval '1 day'),
+    ('test-alarm-007', 'TEST-FL-001', '流量预警', 'WARN-FLOW-TEST', 115.00, 10.00, 100.00, 0, 3, 15.00, '供水监测报警测试数据', now() - interval '1 day' - interval '30 minutes', now() - interval '1 day' - interval '30 minutes'),
+    ('test-alarm-008', 'TEST-WL-002', '水位预警', 'WARN-WATER-LEVEL-TEST', 3.50, 0.50, 3.00, 1, 2, 18.00, '供水监测报警测试数据', now() - interval '300 minutes', now() - interval '290 minutes');
 
 -- ------------------------------------------------------------
 -- 3. 阈值数据(warning_threshold,含分时阈值)
@@ -129,11 +133,11 @@ VALUES
      NULL);
 
 UPDATE app_user.alarm_data
-   SET order_id = (SELECT order_id FROM app_user.work_order WHERE order_no = 'TEST-WO-001')
+   SET work_order_id = (SELECT order_id FROM app_user.work_order WHERE order_no = 'TEST-WO-001')
  WHERE id = 'test-alarm-003';
 
 INSERT INTO app_user.work_order_log
-    (order_id, oper_type, oper_desc, oper_user_id, oper_user_name, oper_time)
+    (work_order_id, oper_type, oper_desc, oper_user_id, oper_user_name, oper_time)
 SELECT order_id, 5, '供水监测报警测试-提交验收', 1, '测试用户', now()
   FROM app_user.work_order WHERE order_no = 'TEST-WO-001';
 
@@ -151,9 +155,9 @@ VALUES
 -- 清理脚本(如需清理测试数据,执行以下语句)
 -- ============================================================
 -- DELETE FROM app_user.alarm_audit WHERE alarm_id LIKE 'test-alarm-%';
--- DELETE FROM app_user.work_order_log WHERE order_id IN
---     (SELECT order_id FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%'));
--- DELETE FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%');
+-- DELETE FROM app_user.work_order_log WHERE work_order_id IN
+--     (SELECT order_id FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT work_order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%'));
+-- DELETE FROM app_user.work_order WHERE order_no = 'TEST-WO-001' OR order_id IN (SELECT work_order_id FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%');
 -- DELETE FROM app_user.alarm_data WHERE id LIKE 'test-alarm-%';
 -- DELETE FROM app_user.warning_threshold WHERE id LIKE 'test-thr-%' OR device_code LIKE 'TEST-%';
 -- DELETE FROM app_user.equipment_base WHERE equipment_id LIKE 'TEST-EQ-%';

+ 31 - 27
pipe-network-service/sql/water_supply_monitoring_alarm_rebuild_test_data.sql

@@ -22,6 +22,9 @@
 --      Z-TEST-*),以及 equipment_type_id 已指向不存在类型的脏数据设备;不带测试前缀的
 --      供水设备一律保留。删除清单可在执行前快照中追溯。
 --   4. 非供水设备按类型归位而非删除:燃气→3,排水→2,窨井→4。
+--   5. 部署顺序:须先执行 migration_alarm_case_work_order_id.sql(alarm_data/case_info/
+--      hazard_hidden_account/work_order_log 的 order_id→work_order_id 改名 + alarm_data 删 alarm_time),
+--      再执行本脚本;否则 work_order_id 列不存在会报错。
 --
 -- 执行后可对照的口径(本轮已在库中复算):
 --   设备:供水设备 15 台(流量 6 / 压力 5 / 漏失 4),全部挂在真实子类型 11/12/14
@@ -105,7 +108,7 @@ DELETE FROM app_user.alarm_audit
                      WHERE device_code IN (SELECT equipment_code FROM wsr_deleted_devices));
 
 DELETE FROM app_user.work_order_log
- WHERE order_id IN (SELECT order_id FROM app_user.work_order
+ WHERE work_order_id IN (SELECT order_id FROM app_user.work_order
                      WHERE device_id IN (SELECT equipment_id FROM wsr_deleted_devices));
 
 DELETE FROM app_user.alarm_data
@@ -134,14 +137,14 @@ DELETE FROM app_user.alarm_audit
     OR alarm_id IN ('test-alarm-004', 'test-alarm-005', 'test-alarm-006', 'test-alarm-007');
 
 DELETE FROM app_user.work_order_log
- WHERE order_id IN (SELECT order_id FROM app_user.work_order
+ WHERE work_order_id IN (SELECT order_id FROM app_user.work_order
                      WHERE order_no LIKE 'T45-WO-%' OR order_no LIKE 'T46-WO-%' OR order_no LIKE 'WRB-WO-%'
-                        OR order_id IN (SELECT order_id FROM app_user.alarm_data
+                        OR order_id IN (SELECT work_order_id FROM app_user.alarm_data
                                         WHERE id LIKE 't45-alarm-%' OR id LIKE 't46-alarm-%' OR id LIKE 'wrb-alarm-%'));
 
 DELETE FROM app_user.work_order
  WHERE order_no LIKE 'T45-WO-%' OR order_no LIKE 'T46-WO-%' OR order_no LIKE 'WRB-WO-%'
-    OR order_id IN (SELECT order_id FROM app_user.alarm_data
+    OR order_id IN (SELECT work_order_id FROM app_user.alarm_data
                    WHERE id LIKE 't45-alarm-%' OR id LIKE 't46-alarm-%' OR id LIKE 'wrb-alarm-%');
 
 DELETE FROM app_user.alarm_data
@@ -324,26 +327,27 @@ VALUES
 
 -- ------------------------------------------------------------
 -- 9. 报警流水(16 条:一级4/二级5/三级3/四级4;待审核8/已转工单6/已解除2)
+--    注意:alarm_time 列已删除,统一使用 create_time。
 -- ------------------------------------------------------------
 INSERT INTO app_user.alarm_data
-    (id, device_code, warning_type, warning_code, actual_value, min_value, max_value, alarm_status, alarm_level, deviation_ratio, alarm_time, remark, create_time, update_time)
+    (id, device_code, warning_type, warning_code, actual_value, min_value, max_value, alarm_status, alarm_level, deviation_ratio, remark, create_time, update_time)
 VALUES
-    ('wrb-alarm-01', 'CN-GS-202609160201', '流量预警', 'WRB-WARN-FLOW', 132.00, 10.00, 100.00, 0, 1, 32.00, now() - interval '35 minutes', '#56已转工单-WO-01 待派单', now(), now()),
-    ('wrb-alarm-02', 'CB-GS-202609160202', '流量预警', 'WRB-WARN-FLOW', 128.00, 8.00, 120.00, 0, 2, 6.67, now() - interval '3 hours', '#56已转工单-WO-02 待接单', now(), now()),
-    ('wrb-alarm-03', 'TC-GS-202609160203', '流量预警', 'WRB-WARN-FLOW', 145.00, 10.00, 100.00, 0, 3, 45.00, now() - interval '1 day 2 hours', '#56已转工单-WO-03 已接单', now(), now()),
-    ('wrb-alarm-04', 'CN-GS-202609160204', '流量预警', 'WRB-WARN-FLOW', 118.00, 10.00, 100.00, 0, 4, 18.00, now() - interval '2 days', '#56已转工单-WO-04 处理中', now(), now()),
-    ('wrb-alarm-05', 'CN-GS-202609160211', '压力预警', 'WRB-WARN-PRESSURE', 0.95, 0.40, 0.80, 0, 1, 18.75, now() - interval '3 days', '#56已转工单-WO-05 待验收', now(), now()),
-    ('wrb-alarm-06', 'CB-GS-202609160212', '压力预警', 'WRB-WARN-PRESSURE', 0.30, 0.35, 0.85, 0, 2, 14.29, now() - interval '4 days', '#56已转工单(WO-06 已结案);终态不占用唯一性名额,可再次转工单', now(), now()),
-    ('wrb-alarm-07', 'CN-GS-202609160221', '漏失预警', 'WRB-WARN-LEAK', 7.20, 0.00, 5.00, 0, 3, 44.00, now() - interval '5 days', '#56已转工单-WO-07 延期中', now(), now()),
-    ('wrb-alarm-08', 'CB-GS-202609160222', '漏失预警', 'WRB-WARN-LEAK', 6.10, 0.00, 6.00, 0, 4, 1.67, now() - interval '6 days', '#56待审核(WO-08 已取消,口径回落到报警事实);终态不占用唯一性名额', now(), now()),
-    ('wrb-alarm-09', 'TC-GS-202609160213', '压力预警', 'WRB-WARN-PRESSURE', 0.28, 0.40, 0.80, 0, 1, 30.00, now() - interval '50 minutes', '#56待审核-无工单,可直接转工单', now(), now()),
-    ('wrb-alarm-10', 'CN-GS-202609160214', '压力预警', 'WRB-WARN-PRESSURE', 0.22, 0.40, 0.80, 0, 3, 45.00, now() - interval '5 hours', '#56待审核-无工单,可直接转工单', now(), now()),
-    ('wrb-alarm-11', 'TC-GS-202609160223', '漏失预警', 'WRB-WARN-LEAK', 9.40, 0.00, 5.00, 0, 2, 88.00, now() - interval '1 day 6 hours', '#56待审核-无工单,可直接转工单', now(), now()),
-    ('wrb-alarm-12', 'CB-GS-202609160205', '流量预警', 'WRB-WARN-FLOW', 155.00, 8.00, 120.00, 0, 4, 29.17, now() - interval '2 days 4 hours', '#56待审核-无工单,可直接转工单', now(), now()),
-    ('wrb-alarm-13', 'TC-GS-202609160206', '流量预警', 'WRB-WARN-FLOW', 112.00, 10.00, 100.00, 0, 2, 12.00, now() - interval '3 days 3 hours', '#56待审核-设备无状态记录,报警状态走回退分支', now(), now()),
-    ('wrb-alarm-14', 'CB-GS-202609160215', '压力预警', 'WRB-WARN-PRESSURE', 0.20, 0.40, 0.80, 0, 4, 50.00, now() - interval '4 days 5 hours', '#56已解除-误报(审核结论=2,不产生工单)', now(), now()),
-    ('wrb-alarm-15', 'CN-GS-202609160224', '漏失预警', 'WRB-WARN-LEAK', 8.10, 0.00, 5.00, 1, 1, 62.00, now() - interval '6 days 2 hours', '#56已解除-报警记录已处理', now(), now()),
-    ('wrb-alarm-16', 'CN-GS-202609160201', '流量预警', 'WRB-WARN-FLOW', 124.00, 10.00, 100.00, 0, 2, 24.00, now() - interval '90 minutes', '#56待审核-同设备同类型已有 WO-01 未办结,转工单应被阻止', now(), now());
+    ('wrb-alarm-01', 'CN-GS-202609160201', '流量预警', 'WRB-WARN-FLOW', 132.00, 10.00, 100.00, 0, 1, 32.00, '#56已转工单-WO-01 待派单', now(), now()),
+    ('wrb-alarm-02', 'CB-GS-202609160202', '流量预警', 'WRB-WARN-FLOW', 128.00, 8.00, 120.00, 0, 2, 6.67, '#56已转工单-WO-02 待接单', now(), now()),
+    ('wrb-alarm-03', 'TC-GS-202609160203', '流量预警', 'WRB-WARN-FLOW', 145.00, 10.00, 100.00, 0, 3, 45.00, '#56已转工单-WO-03 已接单', now(), now()),
+    ('wrb-alarm-04', 'CN-GS-202609160204', '流量预警', 'WRB-WARN-FLOW', 118.00, 10.00, 100.00, 0, 4, 18.00, '#56已转工单-WO-04 处理中', now(), now()),
+    ('wrb-alarm-05', 'CN-GS-202609160211', '压力预警', 'WRB-WARN-PRESSURE', 0.95, 0.40, 0.80, 0, 1, 18.75, '#56已转工单-WO-05 待验收', now(), now()),
+    ('wrb-alarm-06', 'CB-GS-202609160212', '压力预警', 'WRB-WARN-PRESSURE', 0.30, 0.35, 0.85, 0, 2, 14.29, '#56已转工单(WO-06 已结案);终态不占用唯一性名额,可再次转工单', now(), now()),
+    ('wrb-alarm-07', 'CN-GS-202609160221', '漏失预警', 'WRB-WARN-LEAK', 7.20, 0.00, 5.00, 0, 3, 44.00, '#56已转工单-WO-07 延期中', now(), now()),
+    ('wrb-alarm-08', 'CB-GS-202609160222', '漏失预警', 'WRB-WARN-LEAK', 6.10, 0.00, 6.00, 0, 4, 1.67, '#56待审核(WO-08 已取消,口径回落到报警事实);终态不占用唯一性名额', now(), now()),
+    ('wrb-alarm-09', 'TC-GS-202609160213', '压力预警', 'WRB-WARN-PRESSURE', 0.28, 0.40, 0.80, 0, 1, 30.00, '#56待审核-无工单,可直接转工单', now(), now()),
+    ('wrb-alarm-10', 'CN-GS-202609160214', '压力预警', 'WRB-WARN-PRESSURE', 0.22, 0.40, 0.80, 0, 3, 45.00, '#56待审核-无工单,可直接转工单', now(), now()),
+    ('wrb-alarm-11', 'TC-GS-202609160223', '漏失预警', 'WRB-WARN-LEAK', 9.40, 0.00, 5.00, 0, 2, 88.00, '#56待审核-无工单,可直接转工单', now(), now()),
+    ('wrb-alarm-12', 'CB-GS-202609160205', '流量预警', 'WRB-WARN-FLOW', 155.00, 8.00, 120.00, 0, 4, 29.17, '#56待审核-无工单,可直接转工单', now(), now()),
+    ('wrb-alarm-13', 'TC-GS-202609160206', '流量预警', 'WRB-WARN-FLOW', 112.00, 10.00, 100.00, 0, 2, 12.00, '#56待审核-设备无状态记录,报警状态走回退分支', now(), now()),
+    ('wrb-alarm-14', 'CB-GS-202609160215', '压力预警', 'WRB-WARN-PRESSURE', 0.20, 0.40, 0.80, 0, 4, 50.00, '#56已解除-误报(审核结论=2,不产生工单)', now(), now()),
+    ('wrb-alarm-15', 'CN-GS-202609160224', '漏失预警', 'WRB-WARN-LEAK', 8.10, 0.00, 5.00, 1, 1, 62.00, '#56已解除-报警记录已处理', now(), now()),
+    ('wrb-alarm-16', 'CN-GS-202609160201', '流量预警', 'WRB-WARN-FLOW', 124.00, 10.00, 100.00, 0, 2, 24.00, '#56待审核-同设备同类型已有 WO-01 未办结,转工单应被阻止', now(), now());
 
 -- ------------------------------------------------------------
 -- 10. 工单(0..7 各一张;device_id 必须是供水设备 equipment_id,否则审核计数条取不到)
@@ -370,7 +374,7 @@ VALUES
      '#56测试工单-城北漏失已取消', 100, 1, now() - interval '6 days', NULL, 7, now() - interval '6 days', now(), now());
 
 UPDATE app_user.alarm_data a
-   SET order_id = w.order_id
+   SET work_order_id = w.order_id
   FROM app_user.work_order w
  WHERE (w.order_no = 'WRB-WO-01' AND a.id = 'wrb-alarm-01')
     OR (w.order_no = 'WRB-WO-02' AND a.id = 'wrb-alarm-02')
@@ -382,7 +386,7 @@ UPDATE app_user.alarm_data a
     OR (w.order_no = 'WRB-WO-08' AND a.id = 'wrb-alarm-08');
 
 INSERT INTO app_user.work_order_log
-    (order_id, oper_type, oper_desc, oper_user_id, oper_user_name, oper_time)
+    (work_order_id, oper_type, oper_desc, oper_user_id, oper_user_name, oper_time)
 SELECT order_id, 0, '#56测试-报警审核转工单创建', 1, '调度管理员', now()
   FROM app_user.work_order
  WHERE order_no LIKE 'WRB-WO-%';
@@ -409,7 +413,7 @@ SELECT '运行监测-流量' AS item, count(*)::varchar AS value
   FROM app_user.equipment_base e
  WHERE e.equipment_type_id = (SELECT id FROM app_user.equipment_type WHERE id = 11 AND parent_id = 1 LIMIT 1)
    AND (EXISTS (SELECT 1 FROM app_user.radar_data r WHERE r.device_code = e.equipment_code)
-        OR EXISTS (SELECT 1 FROM app_user.telemetry_data t WHERE t.device_code = e.equipment_code))
+        OR EXISTS (SELECT 1 FROM app_user.telemetry_data t WHERE t.device_code = e.equipment_code));
 UNION ALL
 SELECT '运行监测-压力', count(DISTINCT e.equipment_code)::varchar
   FROM app_user.equipment_base e
@@ -427,8 +431,8 @@ SELECT '报警-类型分布' AS item, string_agg(concat(warning_type, '=', cnt::
 SELECT '报警-展示状态' AS item, string_agg(concat(display_status, '=', cnt::varchar), ' / ' ORDER BY display_status) AS value
   FROM (
     SELECT CASE
-             WHEN a.order_id IS NOT NULL AND EXISTS (SELECT 1 FROM app_user.work_order w
-                           WHERE w.order_id = a.order_id AND w.order_status <> 7) THEN '已转工单'
+             WHEN a.work_order_id IS NOT NULL AND EXISTS (SELECT 1 FROM app_user.work_order w
+                           WHERE w.order_id = a.work_order_id AND w.order_status <> 7) THEN '已转工单'
              WHEN a.alarm_status = 1
                OR EXISTS (SELECT 1 FROM app_user.alarm_audit au
                            WHERE au.alarm_id = a.id AND au.audit_result = 2) THEN '已解除'
@@ -459,7 +463,7 @@ SELECT '供水设备缺测量值' AS item, count(*)::varchar AS value
 -- 清理脚本(如需移除本批测试数据,执行以下语句)
 -- ------------------------------------------------------------
 -- DELETE FROM app_user.alarm_audit     WHERE alarm_id LIKE 'wrb-alarm-%';
--- DELETE FROM app_user.work_order_log  WHERE order_id IN
+-- DELETE FROM app_user.work_order_log  WHERE work_order_id IN
 --     (SELECT order_id FROM app_user.work_order WHERE order_no LIKE 'WRB-WO-%');
 -- DELETE FROM app_user.work_order      WHERE order_no LIKE 'WRB-WO-%';
 -- DELETE FROM app_user.alarm_data      WHERE id LIKE 'wrb-alarm-%';

+ 1 - 40
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyAlarmController.java

@@ -1,18 +1,15 @@
 package com.zksy.web.controller.WaterSupply.MonitorAlarm;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmMapPointOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmStatsOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService;
 import com.zksy.base.alarm.service.AlarmClearResult;
 import com.zksy.base.alarm.service.AlarmClearService;
-import com.zksy.WaterSupply.export.WaterAlarmLedgerExportDTO;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.enums.BusinessType;
-import com.zksy.common.utils.poi.ExcelUtil;
 import com.zksy.common.utils.StringUtils;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
@@ -23,14 +20,12 @@ import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
-import javax.servlet.http.HttpServletResponse;
 import java.time.LocalDateTime;
 import java.util.List;
-import java.util.stream.Collectors;
 
 /**
  * 供水管网-监测报警接口
- * 包含:报警管理(统计/实时/历史/详情/标记处理/派发工单)、报警审核、全流程跟踪、监测报警一张图
+ * 保留供水首页统计、实时异常及供水监测报警一张图查询;清单和审核使用统一 AlarmController
  */
 @Slf4j
 @RestController
@@ -128,40 +123,6 @@ public class WaterSupplyAlarmController {
         return AjaxResult.success(page);
     }
 
-    @GetMapping("/ledger/dashboard")
-    @ApiOperation("报警管理态势统计(展示状态分布、报警趋势、报警与设备分类占比)")
-    @PreAuthorize("@ss.hasPermi('waterSupply:alarm:list')")
-    public AjaxResult getAlarmDashboard(
-            @ApiParam(value = "趋势回溯天数(含当天)", defaultValue = "7")
-            @RequestParam(defaultValue = "7") int days) {
-        AlarmDashboardOutDTO dashboard = waterSupplyAlarmService.getAlarmDashboard(days);
-        return AjaxResult.success(dashboard);
-    }
-
-    @PostMapping("/ledger/export")
-    @ApiOperation("导出报警管理只读台账")
-    @Log(title = "导出供水报警台账", businessType = BusinessType.EXPORT)
-    @PreAuthorize("@ss.hasPermi('waterSupply:alarm:list')")
-    public void exportAlarmLedger(HttpServletResponse response,
-            @ApiParam("设备编码") @RequestParam(required = false) String deviceCode,
-            @ApiParam("报警类型") @RequestParam(required = false) String warningType,
-            @ApiParam("报警等级 1-4") @RequestParam(required = false) Integer alarmLevel,
-            @ApiParam("报警展示状态 1-待审核 2-已转工单 3-已解除") @RequestParam(required = false) Integer displayStatus,
-            @ApiParam("设备报警状态 0-正常 1-报警") @RequestParam(required = false) Integer deviceAlarmStatus,
-            @ApiParam(value = "开始时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false)
-            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
-            @ApiParam(value = "结束时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false)
-            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
-            @ApiParam("关键字(设备编码/报警类型/预警编码)") @RequestParam(required = false) String keyword) {
-        List<WaterAlarmLedgerExportDTO> rows = waterSupplyAlarmService.getAlarmLedgerExport(
-                        deviceCode, warningType, alarmLevel, displayStatus, deviceAlarmStatus,
-                        startTime, endTime, keyword)
-                .stream()
-                .map(WaterAlarmLedgerExportDTO::from)
-                .collect(Collectors.toList());
-        new ExcelUtil<>(WaterAlarmLedgerExportDTO.class).exportExcel(response, rows, "供水报警台账");
-    }
-
     @GetMapping("/map/points")
     @ApiOperation("监测报警一张图-报警点位(按报警级别着色)")
     @PreAuthorize("@ss.hasPermi('waterSupply:alarm:list')")

+ 53 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/alarm/AlarmController.java

@@ -3,6 +3,9 @@ package com.zksy.web.controller.alarm;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.alarm.service.AlarmOperationsService;
+import com.zksy.base.alarm.dto.AlarmQuery;
+import com.zksy.base.alarm.enums.AlarmSeriesPeriod;
 import com.zksy.base.alarm.service.AlarmReviewService;
 import com.zksy.base.alarm.service.AlarmReviewAttachmentService;
 import com.zksy.base.alarm.dto.AlarmReviewQuery;
@@ -39,6 +42,56 @@ public class AlarmController {
     @Autowired
     private AlarmReviewAttachmentService attachmentService;
 
+    @Autowired
+    private AlarmOperationsService operations;
+
+    @GetMapping
+    @ApiOperation("报警事件清单,分页及子系统数据范围校验")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult list(@Valid AlarmQuery query) { return AjaxResult.success(operations.page(query)); }
+
+    @GetMapping("/statistics")
+    @ApiOperation("按等级统计报警事件")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult statistics(@Valid AlarmQuery query) { return AjaxResult.success(operations.statistics(query)); }
+
+    @GetMapping("/reminders")
+    @ApiOperation("按最近操作时间查询未处理报警提醒,固定 alarm_status=0")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult reminders(@Valid AlarmQuery query) { return AjaxResult.success(operations.reminders(query)); }
+
+    @GetMapping("/options")
+    @ApiOperation("报警查询字典选项")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult options(@Valid AlarmQuery query) { return AjaxResult.success(operations.options(query)); }
+
+    @GetMapping("/map")
+    @ApiOperation("排水报警GIS点位")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult map(@Valid AlarmQuery query) { return AjaxResult.success(operations.map(query)); }
+
+    @GetMapping("/{alarmId}")
+    @ApiOperation("范围内报警事件详情")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult detail(@PathVariable("alarmId") String alarmId, @Valid AlarmQuery query) {
+        return AjaxResult.success(operations.detail(alarmId, query));
+    }
+
+    @GetMapping("/{alarmId}/timeline")
+    @ApiOperation("报警及关联工单操作时间线")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult timeline(@PathVariable("alarmId") String alarmId, @Valid AlarmQuery query) {
+        return AjaxResult.success(operations.logs(alarmId, query));
+    }
+
+    @GetMapping("/{alarmId}/analysis")
+    @ApiOperation("排水报警真实监测曲线及日月均值")
+    @PreAuthorize("@ss.hasPermi('alarm:operations:list')")
+    public AjaxResult analysis(@PathVariable("alarmId") String alarmId, @Valid AlarmQuery query,
+            @RequestParam(value = "period", defaultValue = "CURVE") AlarmSeriesPeriod period) {
+        return AjaxResult.success(operations.analysis(alarmId, query, period));
+    }
+
     @GetMapping("/reviews")
     @ApiOperation("报警审核列表,仅未处理报警,按设备大类及报警编码隔离")
     @PreAuthorize("@ss.hasPermi('alarm:review:list')")

+ 0 - 8
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/alarm/AlarmTestController.java

@@ -47,7 +47,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(50.0));
             alarmData.setActualValue(BigDecimal.valueOf(temperature));
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟井盖温度报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -64,7 +63,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(threshold));
             alarmData.setActualValue(BigDecimal.valueOf(tiltAngle));
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟井盖倾斜报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -99,7 +97,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(50.0));
             alarmData.setActualValue(BigDecimal.valueOf(temperature));
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟可燃气体温度报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -116,7 +113,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(80.0));
             alarmData.setActualValue(BigDecimal.valueOf(humidity));
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟可燃气体湿度报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -133,7 +129,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(50.0));
             alarmData.setActualValue(gasConcentration);
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟可燃气体浓度报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -166,7 +161,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(50.0));
             alarmData.setActualValue(BigDecimal.valueOf(temperature));
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟环境温度报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -183,7 +177,6 @@ public class AlarmTestController {
             alarmData.setMaxValue(BigDecimal.valueOf(90.0));
             alarmData.setActualValue(BigDecimal.valueOf(humidity));
             alarmData.setAlarmStatus(0);
-            alarmData.setAlarmTime(LocalDateTime.now());
             alarmData.setRemark("模拟环境湿度报警");
             alarmData.setCreateTime(LocalDateTime.now());
             alarmDataService.saveAlarmData(alarmData);
@@ -219,7 +212,6 @@ public class AlarmTestController {
         }
         
         alarmData.setAlarmStatus(0);
-        alarmData.setAlarmTime(LocalDateTime.now());
         alarmData.setRemark((String) params.getOrDefault("remark", "自定义测试报警"));
         alarmData.setCreateTime(LocalDateTime.now());
         

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

@@ -136,31 +136,7 @@ public class DrainageWarningController {
         return AjaxResult.success("发布成功");
     }
 
-    @Anonymous
-    @PutMapping("/{id}/firstApprove")
-    @ApiOperation(value = "处理(指定人员处理)")
-    @Log(title = "预警处理", businessType = BusinessType.UPDATE)
-    @Transactional
-    public AjaxResult firstApprove(
-            @PathVariable String id,
-            @ApiParam("处理人ID") @RequestParam Long userId,
-            @ApiParam("处理人名称") @RequestParam(required = false) String userName) {
-        service.firstApprove(id, userId, userName);
-        return AjaxResult.success("处理成功");
-    }
 
-    @Anonymous
-    @PutMapping("/{id}/finalApprove")
-    @ApiOperation(value = "审批(管理员审批)")
-    @Log(title = "预警审批", businessType = BusinessType.UPDATE)
-    @Transactional
-    public AjaxResult finalApprove(
-            @PathVariable String id,
-            @ApiParam("审批人ID(管理员)") @RequestParam Long userId,
-            @ApiParam("审批人名称") @RequestParam(required = false) String userName) {
-        service.finalApprove(id, userId, userName);
-        return AjaxResult.success("审批成功");
-    }
 
     @Anonymous
     @GetMapping("/scopeUsers/{warningId}")

+ 9 - 9
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/gasbasic/GasAlarmController.java

@@ -74,7 +74,7 @@ public class GasAlarmController {
         m.put("threshold", a.getMaxValue());
         m.put("alarmLevel", a.getAlarmLevel());
         m.put("alarmLevelText", a.getAlarmLevel() != null && a.getAlarmLevel() == 1 ? "严重" : "预警");
-        m.put("alarmTime", a.getAlarmTime() != null ? a.getAlarmTime().format(FMT) : "");
+        m.put("createTime", a.getCreateTime() != null ? a.getCreateTime().format(FMT) : "");
         m.put("alarmStatus", a.getAlarmStatus());
         m.put("statusText", a.getAlarmStatus() != null && a.getAlarmStatus() == 0 ? "待处理" : "已处理");
         m.put("remark", a.getRemark());
@@ -90,14 +90,14 @@ public class GasAlarmController {
             return Collections.emptyMap();
         }
         List<AlarmData> alarms = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
-                .in(AlarmData::getId, alarmIds).isNotNull(AlarmData::getOrderId));
-        Set<Long> orderIds = alarms.stream().map(AlarmData::getOrderId).collect(Collectors.toSet());
+                .in(AlarmData::getId, alarmIds).isNotNull(AlarmData::getWorkOrderId));
+        Set<Long> orderIds = alarms.stream().map(AlarmData::getWorkOrderId).collect(Collectors.toSet());
         Map<Long, WorkOrder> orders = orderIds.isEmpty() ? Collections.emptyMap()
                 : workOrderMapper.selectBatchIds(orderIds).stream()
                 .collect(Collectors.toMap(WorkOrder::getOrderId, order -> order, (a, b) -> a));
         Map<String, WorkOrder> result = new HashMap<>();
         for (AlarmData alarm : alarms) {
-            WorkOrder order = orders.get(alarm.getOrderId());
+            WorkOrder order = orders.get(alarm.getWorkOrderId());
             if (order != null) result.put(alarm.getId(), order);
         }
         return result;
@@ -114,7 +114,7 @@ public class GasAlarmController {
         // 查所有报警,按设备聚合最新一条
         List<AlarmData> allAlarms = codes.isEmpty() ? Collections.emptyList()
                 : alarmDataService.list(new LambdaQueryWrapper<AlarmData>().in(AlarmData::getDeviceCode, codes)
-                        .orderByDesc(AlarmData::getAlarmTime));
+                        .orderByDesc(AlarmData::getCreateTime));
         Map<String, AlarmData> latestMap = allAlarms.stream()
                 .collect(Collectors.toMap(AlarmData::getDeviceCode, a -> a, (a, b) -> a));
         // 设备列表只展示每个设备最新报警的工单状态,避免把全部历史报警 ID 拼成大 IN 条件。
@@ -132,7 +132,7 @@ public class GasAlarmController {
             m.put("hasAlarm", latest != null && latest.getAlarmStatus() != null && latest.getAlarmStatus() == 0);
             m.put("latestConcentration", latest != null ? latest.getActualValue() : null);
             m.put("latestThreshold", latest != null ? latest.getMaxValue() : null);
-            m.put("latestAlarmTime", latest != null && latest.getAlarmTime() != null ? latest.getAlarmTime().format(FMT) : null);
+            m.put("latestCreateTime", latest != null && latest.getCreateTime() != null ? latest.getCreateTime().format(FMT) : null);
             m.put("latestAlarmLevel", latest != null ? latest.getAlarmLevel() : null);
             WorkOrder order = latest == null ? null : orderMap.get(latest.getId());
             m.put("latestAlarmId", latest == null ? null : latest.getId());
@@ -161,9 +161,9 @@ public class GasAlarmController {
             if (codes.isEmpty()) return AjaxResult.success(new Page<>(pageNum, pageSize, 0));
             qw.in(AlarmData::getDeviceCode, codes);
         }
-        if (startDate != null && !startDate.isEmpty()) qw.ge(AlarmData::getAlarmTime, startDate + " 00:00:00");
-        if (endDate != null && !endDate.isEmpty()) qw.le(AlarmData::getAlarmTime, endDate + " 23:59:59");
-        qw.orderByDesc(AlarmData::getAlarmTime);
+        if (startDate != null && !startDate.isEmpty()) qw.ge(AlarmData::getCreateTime, startDate + " 00:00:00");
+        if (endDate != null && !endDate.isEmpty()) qw.le(AlarmData::getCreateTime, endDate + " 23:59:59");
+        qw.orderByDesc(AlarmData::getCreateTime);
         Page<AlarmData> page = alarmDataService.page(new Page<>(pageNum, pageSize), qw);
         Map<String, EquipmentBase> eqMap = loadEquipMap();
         Map<String, WorkOrder> orderMap = loadWorkOrderMap(page.getRecords().stream()

+ 9 - 9
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/gasbasic/PressureAlarmController.java

@@ -70,7 +70,7 @@ public class PressureAlarmController {
         m.put("address", eq != null ? eq.getEquipmentLocation() : "");
         m.put("pressure", a.getActualValue()); m.put("threshold", a.getMaxValue());
         m.put("alarmLevelText", a.getAlarmLevel() != null && a.getAlarmLevel() == 1 ? "严重" : "预警");
-        m.put("alarmTime", a.getAlarmTime() != null ? a.getAlarmTime().format(FMT) : "");
+        m.put("createTime", a.getCreateTime() != null ? a.getCreateTime().format(FMT) : "");
         m.put("alarmStatus", a.getAlarmStatus());
         m.put("statusText", a.getAlarmStatus() != null && a.getAlarmStatus() == 0 ? "待处理" : "已处理");
         m.put("remark", a.getRemark());
@@ -86,14 +86,14 @@ public class PressureAlarmController {
             return Collections.emptyMap();
         }
         List<AlarmData> alarms = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
-                .in(AlarmData::getId, alarmIds).isNotNull(AlarmData::getOrderId));
-        Set<Long> orderIds = alarms.stream().map(AlarmData::getOrderId).collect(Collectors.toSet());
+                .in(AlarmData::getId, alarmIds).isNotNull(AlarmData::getWorkOrderId));
+        Set<Long> orderIds = alarms.stream().map(AlarmData::getWorkOrderId).collect(Collectors.toSet());
         Map<Long, WorkOrder> orders = orderIds.isEmpty() ? Collections.emptyMap()
                 : workOrderMapper.selectBatchIds(orderIds).stream()
                 .collect(Collectors.toMap(WorkOrder::getOrderId, order -> order, (a, b) -> a));
         Map<String, WorkOrder> result = new HashMap<>();
         for (AlarmData alarm : alarms) {
-            WorkOrder order = orders.get(alarm.getOrderId());
+            WorkOrder order = orders.get(alarm.getWorkOrderId());
             if (order != null) result.put(alarm.getId(), order);
         }
         return result;
@@ -107,7 +107,7 @@ public class PressureAlarmController {
                 .filter(e -> codes.contains(e.getEquipmentCode())).collect(Collectors.toList());
         List<AlarmData> all = codes.isEmpty() ? Collections.emptyList()
                 : alarmDataService.list(new LambdaQueryWrapper<AlarmData>().in(AlarmData::getDeviceCode, codes)
-                        .orderByDesc(AlarmData::getAlarmTime));
+                        .orderByDesc(AlarmData::getCreateTime));
         Map<String, AlarmData> latestMap = all.stream()
                 .collect(Collectors.toMap(AlarmData::getDeviceCode, a -> a, (a, b) -> a));
         // 设备列表只展示每个设备最新报警的工单状态,避免把全部历史报警 ID 拼成大 IN 条件。
@@ -122,7 +122,7 @@ public class PressureAlarmController {
             m.put("hasAlarm", latest != null && latest.getAlarmStatus() != null && latest.getAlarmStatus() == 0);
             m.put("latestPressure", latest != null ? latest.getActualValue() : null);
             m.put("latestThreshold", latest != null ? latest.getMaxValue() : null);
-            m.put("latestAlarmTime", latest != null && latest.getAlarmTime() != null ? latest.getAlarmTime().format(FMT) : null);
+            m.put("latestCreateTime", latest != null && latest.getCreateTime() != null ? latest.getCreateTime().format(FMT) : null);
             m.put("latestAlarmLevel", latest != null ? latest.getAlarmLevel() : null);
             m.put("latestAlarmId", latest == null ? null : latest.getId());
             WorkOrder order = latest == null ? null : orderMap.get(latest.getId());
@@ -150,9 +150,9 @@ public class PressureAlarmController {
             if (codes.isEmpty()) return AjaxResult.success(new Page<>(pageNum, pageSize, 0));
             qw.in(AlarmData::getDeviceCode, codes);
         }
-        if (startDate != null && !startDate.isEmpty()) qw.ge(AlarmData::getAlarmTime, startDate + " 00:00:00");
-        if (endDate != null && !endDate.isEmpty()) qw.le(AlarmData::getAlarmTime, endDate + " 23:59:59");
-        qw.orderByDesc(AlarmData::getAlarmTime);
+        if (startDate != null && !startDate.isEmpty()) qw.ge(AlarmData::getCreateTime, startDate + " 00:00:00");
+        if (endDate != null && !endDate.isEmpty()) qw.le(AlarmData::getCreateTime, endDate + " 23:59:59");
+        qw.orderByDesc(AlarmData::getCreateTime);
         Page<AlarmData> page = alarmDataService.page(new Page<>(pageNum, pageSize), qw);
         Map<String, EquipmentBase> eqMap = loadEquipMap();
         Map<String, WorkOrder> orderMap = loadWorkOrderMap(page.getRecords().stream()

+ 0 - 60
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyAlarmLedgerControllerTest.java

@@ -1,7 +1,6 @@
 package com.zksy.web.controller.WaterSupply.MonitorAlarm;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService;
 import org.junit.jupiter.api.BeforeEach;
@@ -82,65 +81,6 @@ class WaterSupplyAlarmLedgerControllerTest {
                 });
     }
 
-    @Test
-    void dashboardExposesDisplayStatusCountsAndRatios() throws Exception {
-        AlarmDashboardOutDTO dashboard = new AlarmDashboardOutDTO();
-        dashboard.setTotalCount(6);
-        dashboard.setPendingAuditCount(2);
-        dashboard.setTransferredCount(3);
-        dashboard.setReleasedCount(1);
-        dashboard.setAlarmingDeviceCount(4);
-        AlarmDashboardOutDTO.TrendItem trend = new AlarmDashboardOutDTO.TrendItem();
-        trend.setDate("2026-09-14");
-        trend.setLevel1Count(1);
-        trend.setLevel2Count(2);
-        trend.setLevel3Count(0);
-        trend.setLevel4Count(1);
-        dashboard.setTrend(Collections.singletonList(trend));
-        AlarmDashboardOutDTO.RatioItem ratio = new AlarmDashboardOutDTO.RatioItem();
-        ratio.setName("压力预警");
-        ratio.setCount(4);
-        dashboard.setWarningTypeRatio(Collections.singletonList(ratio));
-        when(alarmService.getAlarmDashboard(anyInt())).thenReturn(dashboard);
-
-        mockMvc.perform(get("/waterSupply/alarm/ledger/dashboard").param("days", "7"))
-                .andExpect(status().isOk())
-                .andExpect(result -> {
-                    String json = body(result);
-                    assertThat(json, "\"pendingAuditCount\":2");
-                    assertThat(json, "\"transferredCount\":3");
-                    assertThat(json, "\"releasedCount\":1");
-                    assertThat(json, "\"alarmingDeviceCount\":4");
-                    assertThat(json, "\"level1Count\":1");
-                    assertThat(json, "\"level2Count\":2");
-                    assertThat(json, "\"level4Count\":1");
-                    assertThat(json, "\"name\":\"压力预警\"");
-                });
-    }
-
-    @Test
-    void ledgerExportStreamsExcelWithFilteredRows() throws Exception {
-        WaterAlarmOutDTO row = new WaterAlarmOutDTO();
-        row.setId("alarm-1");
-        row.setDeviceCode("CN-GS-202609020001");
-        row.setWarningType("压力预警");
-        row.setAlarmLevelText("一级报警");
-        row.setDeviceAlarmStatusText("报警中");
-        row.setDisplayStatusText("已转工单");
-        row.setOrderNo("WO202606101703482640");
-        when(alarmService.getAlarmLedgerExport(any(), any(), any(), any(), any(), any(), any(), any()))
-                .thenReturn(List.of(row));
-
-        mockMvc.perform(post("/waterSupply/alarm/ledger/export"))
-                .andExpect(status().isOk())
-                .andExpect(result -> {
-                    String contentType = result.getResponse().getContentType();
-                    if (contentType == null || !contentType.contains("spreadsheet")) {
-                        throw new AssertionError("导出响应不是 Excel:" + contentType);
-                    }
-                });
-    }
-
     private static void assertThat(String json, String expected) {
         if (!json.contains(expected)) {
             throw new AssertionError("响应缺少期望内容:" + expected + "\n实际:" + json);

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

@@ -0,0 +1,48 @@
+package com.zksy.web.controller.alarm;
+
+import com.zksy.base.alarm.dto.AlarmQuery;
+import com.zksy.base.alarm.enums.AlarmSeriesPeriod;
+import com.zksy.base.alarm.service.AlarmOperationsService;
+import org.junit.jupiter.api.*;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+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.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class AlarmOperationsControllerTest {
+    private final AlarmOperationsService operations = mock(AlarmOperationsService.class);
+    private MockMvc mvc;
+    @BeforeEach void setup() {
+        AlarmController controller = new AlarmController(); ReflectionTestUtils.setField(controller,"operations",operations);
+        mvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+    @Test void listBindsScopePaginationAndOptionalRecoveredFilter() throws Exception {
+        mvc.perform(get("/api/alarm").param("rootTypeId","2").param("pageNum","3").param("pageSize","20").param("alarmStatus","1"))
+                .andExpect(status().isOk());
+        ArgumentCaptor<AlarmQuery> query = ArgumentCaptor.forClass(AlarmQuery.class); verify(operations).page(query.capture());
+        assertEquals(2,query.getValue().getRootTypeId()); assertEquals(3,query.getValue().getPageNum());
+        assertEquals(20,query.getValue().getPageSize()); assertEquals(1,query.getValue().getAlarmStatus());
+    }
+    @Test void missingScopeAndOversizedPageAreRejected() throws Exception {
+        mvc.perform(get("/api/alarm")).andExpect(status().isBadRequest());
+        mvc.perform(get("/api/alarm").param("rootTypeId","2").param("pageSize","1001")).andExpect(status().isBadRequest());
+        verifyNoInteractions(operations);
+    }
+    @Test void analysisChoicesBindActualValueFilterBeforePagination() throws Exception {
+        mvc.perform(get("/api/alarm").param("rootTypeId","2").param("actualValueOnly","true").param("pageSize","100"))
+                .andExpect(status().isOk());
+        ArgumentCaptor<AlarmQuery> query = ArgumentCaptor.forClass(AlarmQuery.class);
+        verify(operations).page(query.capture());
+        assertTrue(query.getValue().isActualValueOnly());
+        assertEquals(100,query.getValue().getPageSize());
+    }
+    @Test void analysisAcceptsOnlyWhitelistedPeriods() throws Exception {
+        mvc.perform(get("/api/alarm/a1/analysis").param("rootTypeId","2").param("period","DAY")).andExpect(status().isOk());
+        verify(operations).analysis(eq("a1"),any(AlarmQuery.class),eq(AlarmSeriesPeriod.DAY));
+        mvc.perform(get("/api/alarm/a1/analysis").param("rootTypeId","2").param("period","custom")).andExpect(status().isBadRequest());
+    }
+}

+ 35 - 3
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/alarm/AlarmReviewControllerTest.java

@@ -1,9 +1,13 @@
 package com.zksy.web.controller.alarm;
 
 import com.zksy.base.alarm.dto.AlarmReviewQuery;
+import com.zksy.base.alarm.dto.AlarmReviewRequest;
+import com.zksy.base.alarm.enums.AlarmAuditStatusEnum;
 import com.zksy.base.alarm.service.AlarmReviewService;
 import com.zksy.web.controller.WaterSupply.MonitorAlarm.WaterSupplyAlarmController;
 import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.test.web.servlet.MockMvc;
@@ -26,14 +30,42 @@ class AlarmReviewControllerTest {
         ArgumentCaptor<AlarmReviewQuery> query = ArgumentCaptor.forClass(AlarmReviewQuery.class);
         verify(reviews).page(query.capture()); assertEquals(999999000, query.getValue().getEndTime().getNano());
     }
-    @Test void missingRootAndEmptyOpinionAreRejected() throws Exception {
+    @Test void missingRootAndMissingAuditStatusAreRejected() throws Exception {
         mvc.perform(get("/api/alarm/reviews")).andExpect(status().isBadRequest());
         mvc.perform(post("/api/alarm/a1/reviews").param("rootTypeId","1").contentType("application/json")
-                .content("{\"auditStatus\":1,\"opinion\":\" \"}")).andExpect(status().isBadRequest());
+                .content("{\"opinion\":\" \"}")).andExpect(status().isBadRequest());
         verifyNoInteractions(reviews);
     }
+    @ParameterizedTest
+    @ValueSource(strings = {"", ",\"opinion\":null", ",\"opinion\":\"\"", ",\"opinion\":\"  \""})
+    void eitherDecisionAcceptsOmittedNullOrBlankOpinion(String opinionJson) throws Exception {
+        for (AlarmAuditStatusEnum decision : new AlarmAuditStatusEnum[] {
+                AlarmAuditStatusEnum.CONFIRMED, AlarmAuditStatusEnum.FALSE_ALARM}) {
+            mvc.perform(post("/api/alarm/a1/reviews").param("rootTypeId","1").contentType("application/json")
+                    .content("{\"auditStatus\":" + decision.getCode() + opinionJson + "}"))
+                    .andExpect(status().isOk());
+        }
+        ArgumentCaptor<AlarmReviewRequest> request = ArgumentCaptor.forClass(AlarmReviewRequest.class);
+        verify(reviews, times(2)).review(eq("a1"), any(AlarmReviewQuery.class), request.capture());
+        assertEquals(AlarmAuditStatusEnum.CONFIRMED.getCode(), request.getAllValues().get(0).getAuditStatus());
+        assertEquals(AlarmAuditStatusEnum.FALSE_ALARM.getCode(), request.getAllValues().get(1).getAuditStatus());
+        for (AlarmReviewRequest value : request.getAllValues()) {
+            assertTrue(value.getOpinion() == null || value.getOpinion().trim().isEmpty());
+        }
+    }
+    @Test void opinionLengthLimitIsStillEnforced() throws Exception {
+        mvc.perform(post("/api/alarm/a1/reviews").param("rootTypeId","1").contentType("application/json")
+                .content("{\"auditStatus\":1,\"opinion\":\"" + "核".repeat(501) + "\"}"))
+                .andExpect(status().isBadRequest());
+        verifyNoInteractions(reviews);
+        mvc.perform(post("/api/alarm/a1/reviews").param("rootTypeId","1").contentType("application/json")
+                .content("{\"auditStatus\":1,\"opinion\":\"" + "核".repeat(500) + "\"}"))
+                .andExpect(status().isOk());
+        verify(reviews).review(eq("a1"), any(AlarmReviewQuery.class), any(AlarmReviewRequest.class));
+    }
     @Test void removedWriteEndpointsAreNoLongerRegistered() throws Exception {
-        mvc.perform(post("/api/alarm/approve-to-work-order").contentType("application/json").content("{}")).andExpect(status().isNotFound());
+        // REST详情 /{alarmId} 只接受GET,旧POST入口应返回405且不能触发写操作。
+        mvc.perform(post("/api/alarm/approve-to-work-order").contentType("application/json").content("{}")).andExpect(status().isMethodNotAllowed());
         mvc.perform(put("/waterSupply/alarm/resolve/a1")).andExpect(status().isNotFound());
         mvc.perform(post("/waterSupply/alarm/dispatch")).andExpect(status().isNotFound());
         mvc.perform(post("/waterSupply/alarm/audit")).andExpect(status().isNotFound());

+ 0 - 81
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/AlarmDashboardOutDTO.java

@@ -1,81 +0,0 @@
-package com.zksy.WaterSupply.MonitorAlarm.dto.out;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * 供水报警管理态势统计-出参(展示状态分布 + 报警趋势 + 分类占比)
- */
-@Data
-@ApiModel(value = "供水报警态势统计-出参", description = "报警管理页顶部态势卡片、趋势图与分类占比")
-public class AlarmDashboardOutDTO implements Serializable {
-
-    private static final long serialVersionUID = 1L;
-
-    @ApiModelProperty(value = "供水报警总数(台账全量)", example = "6")
-    private long totalCount;
-
-    @ApiModelProperty(value = "待审核报警数", example = "2")
-    private long pendingAuditCount;
-
-    @ApiModelProperty(value = "已转工单报警数", example = "3")
-    private long transferredCount;
-
-    @ApiModelProperty(value = "已解除报警数", example = "1")
-    private long releasedCount;
-
-    @ApiModelProperty(value = "当前处于报警中的设备数(设备报警状态=1 去重)", example = "3")
-    private long alarmingDeviceCount;
-
-    @ApiModelProperty(value = "报警趋势(按天,含空日)")
-    private List<TrendItem> trend = new ArrayList<>();
-
-    @ApiModelProperty(value = "报警分类占比(按预警类型)")
-    private List<RatioItem> warningTypeRatio = new ArrayList<>();
-
-    @ApiModelProperty(value = "设备分类占比(按设备类型)")
-    private List<RatioItem> deviceTypeRatio = new ArrayList<>();
-
-    @ApiModelProperty(value = "报警等级占比(1-4 级)")
-    private List<RatioItem> alarmLevelRatio = new ArrayList<>();
-
-    @Data
-    @ApiModel(value = "报警趋势项")
-    public static class TrendItem implements Serializable {
-
-        private static final long serialVersionUID = 1L;
-
-        @ApiModelProperty(value = "日期", example = "2026-09-14")
-        private String date;
-
-        @ApiModelProperty(value = "当日一级报警数", example = "1")
-        private long level1Count;
-
-        @ApiModelProperty(value = "当日二级报警数", example = "2")
-        private long level2Count;
-
-        @ApiModelProperty(value = "当日三级报警数", example = "0")
-        private long level3Count;
-
-        @ApiModelProperty(value = "当日四级报警数", example = "1")
-        private long level4Count;
-    }
-
-    @Data
-    @ApiModel(value = "占比项")
-    public static class RatioItem implements Serializable {
-
-        private static final long serialVersionUID = 1L;
-
-        @ApiModelProperty(value = "分类名称", example = "压力预警")
-        private String name;
-
-        @ApiModelProperty(value = "数量", example = "4")
-        private long count;
-    }
-}

+ 2 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/AlarmMapPointOutDTO.java

@@ -48,7 +48,7 @@ public class AlarmMapPointOutDTO implements Serializable {
     @ApiModelProperty(value = "报警等级文本")
     private String alarmLevelText;
 
-    @ApiModelProperty(value = "报警时间")
+    @ApiModelProperty(value = "报警触发时间(取记录创建时间)")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
-    private LocalDateTime alarmTime;
+    private LocalDateTime createTime;
 }

+ 2 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/dto/out/WaterAlarmOutDTO.java

@@ -84,9 +84,9 @@ public class WaterAlarmOutDTO implements Serializable {
     @ApiModelProperty(value = "报警展示状态文本", example = "已转工单")
     private String displayStatusText;
 
-    @ApiModelProperty(value = "报警时间")
+    @ApiModelProperty(value = "报警触发时间(取记录创建时间)")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
-    private LocalDateTime alarmTime;
+    private LocalDateTime createTime;
 
     @ApiModelProperty(value = "备注")
     private String remark;

+ 0 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/IWaterSupplyAlarmService.java

@@ -1,7 +1,6 @@
 package com.zksy.WaterSupply.MonitorAlarm.service;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmMapPointOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmStatsOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
@@ -55,13 +54,6 @@ public interface IWaterSupplyAlarmService {
                                                 LocalDateTime startTime, LocalDateTime endTime,
                                                 String keyword);
 
-    /**
-     * 报警管理态势统计:展示状态分布、报警趋势、报警/设备分类占比
-     *
-     * @param days 趋势回溯天数(含当天),非正数时按 7 天处理
-     */
-    AlarmDashboardOutDTO getAlarmDashboard(int days);
-
     /**
      * 报警详情(报警数据 + 设备数据 + 关联工单)
      */

+ 10 - 160
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyAlarmServiceImpl.java

@@ -5,7 +5,6 @@ import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmMapPointOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmStatsOutDTO;
 import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
@@ -41,7 +40,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
@@ -143,7 +141,7 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
         LocalDateTime todayStart = LocalDate.now().atStartOfDay();
         long todayCount = alarmDataService.count(new LambdaQueryWrapper<AlarmData>()
                 .in(AlarmData::getDeviceCode, waterCodes)
-                .ge(AlarmData::getAlarmTime, todayStart));
+                .ge(AlarmData::getCreateTime, todayStart));
         stats.setTodayAlarmCount(todayCount);
 
         // 已移除告警表处理时间,响应时长以实际处理日志为准。
@@ -166,7 +164,7 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
         // 必须保留在实时列表中;已解除(展示状态 3)的报警在富化后排除。
         List<AlarmData> alarms = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
                 .in(AlarmData::getDeviceCode, waterCodes)
-                .orderByDesc(AlarmData::getAlarmTime));
+                .orderByDesc(AlarmData::getCreateTime));
         // 实时列表复用台账富化口径:首页需按“待审核 / 已转工单”分流,
         // 行内必须携带展示状态、设备报警状态与关联工单号(T05R / Issue #58)。
         return buildLedgerVoList(alarms).stream()
@@ -192,12 +190,12 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
                 .like(StrUtil.isNotBlank(warningType), AlarmData::getWarningType, warningType)
                 .eq(alarmLevel != null, AlarmData::getAlarmLevel, alarmLevel)
                 .eq(alarmStatus != null, AlarmData::getAlarmStatus, alarmStatus)
-                .ge(startTime != null, AlarmData::getAlarmTime, startTime)
-                .le(endTime != null, AlarmData::getAlarmTime, endTime)
+                .ge(startTime != null, AlarmData::getCreateTime, startTime)
+                .le(endTime != null, AlarmData::getCreateTime, endTime)
                 .and(StrUtil.isNotBlank(keyword), w -> w.like(AlarmData::getDeviceCode, keyword)
                         .or().like(AlarmData::getWarningType, keyword)
                         .or().like(AlarmData::getWarningCode, keyword))
-                .orderByDesc(AlarmData::getAlarmTime);
+                .orderByDesc(AlarmData::getCreateTime);
         Page<AlarmData> result = alarmDataService.page(page, wrapper);
         Page<WaterAlarmOutDTO> outPage = new Page<>(pageNum, pageSize, result.getTotal());
         outPage.setRecords(convertToVoList(result.getRecords()));
@@ -253,12 +251,12 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
                 .eq(StrUtil.isNotBlank(deviceCode), AlarmData::getDeviceCode, deviceCode)
                 .like(StrUtil.isNotBlank(warningType), AlarmData::getWarningType, warningType)
                 .eq(alarmLevel != null, AlarmData::getAlarmLevel, alarmLevel)
-                .ge(startTime != null, AlarmData::getAlarmTime, startTime)
-                .le(endTime != null, AlarmData::getAlarmTime, endTime)
+                .ge(startTime != null, AlarmData::getCreateTime, startTime)
+                .le(endTime != null, AlarmData::getCreateTime, endTime)
                 .and(StrUtil.isNotBlank(keyword), w -> w.like(AlarmData::getDeviceCode, keyword)
                         .or().like(AlarmData::getWarningType, keyword)
                         .or().like(AlarmData::getWarningCode, keyword))
-                .orderByDesc(AlarmData::getAlarmTime);
+                .orderByDesc(AlarmData::getCreateTime);
         List<AlarmData> alarms = alarmDataService.list(wrapper);
         List<WaterAlarmOutDTO> rows = buildLedgerVoList(alarms);
         return rows.stream()
@@ -366,154 +364,6 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
         return waterSupplyQueryService.statusesByDeviceIds(equipmentIds);
     }
 
-    // ------------------------------------------------------------------
-    // 报警管理态势统计
-    // ------------------------------------------------------------------
-
-    @Override
-    public AlarmDashboardOutDTO getAlarmDashboard(int days) {
-        int trendDays = days > 0 ? days : 7;
-        AlarmDashboardOutDTO dashboard = new AlarmDashboardOutDTO();
-        Set<String> waterCodes = resolveWaterDeviceCodes();
-        if (waterCodes.isEmpty()) {
-            dashboard.setTrend(emptyTrend(trendDays));
-            return dashboard;
-        }
-
-        // 台账全量口径与列表一致:调用同一推导逻辑,避免统计与列表两套口径漂移
-        List<WaterAlarmOutDTO> rows = buildLedgerVoList(alarmDataService.list(
-                new LambdaQueryWrapper<AlarmData>()
-                        .in(AlarmData::getDeviceCode, waterCodes)
-                        .orderByDesc(AlarmData::getAlarmTime)));
-
-        dashboard.setTotalCount(rows.size());
-        dashboard.setPendingAuditCount(countDisplayStatus(rows, DISPLAY_PENDING_AUDIT));
-        dashboard.setTransferredCount(countDisplayStatus(rows, DISPLAY_TRANSFERRED));
-        dashboard.setReleasedCount(countDisplayStatus(rows, DISPLAY_RELEASED));
-        dashboard.setAlarmingDeviceCount(rows.stream()
-                .filter(row -> Integer.valueOf(1).equals(row.getDeviceAlarmStatus()))
-                .map(WaterAlarmOutDTO::getDeviceCode)
-                .filter(StrUtil::isNotBlank)
-                .distinct()
-                .count());
-
-        dashboard.setTrend(buildTrend(rows, trendDays));
-        dashboard.setWarningTypeRatio(buildRatio(rows, WaterAlarmOutDTO::getWarningType, "未分类"));
-        dashboard.setAlarmLevelRatio(buildRatio(rows,
-                row -> formatAlarmLevel(row.getAlarmLevel()), "未知等级"));
-        dashboard.setDeviceTypeRatio(buildDeviceTypeRatio(rows, loadEquipmentCodeMap(rows.stream()
-                .map(WaterAlarmOutDTO::getDeviceCode)
-                .filter(StrUtil::isNotBlank)
-                .collect(Collectors.toSet()))));
-        return dashboard;
-    }
-
-    private long countDisplayStatus(List<WaterAlarmOutDTO> rows, int displayStatus) {
-        return rows.stream().filter(row -> Integer.valueOf(displayStatus).equals(row.getDisplayStatus())).count();
-    }
-
-    /**
-     * 报警趋势:按自然日分桶,区间内无报警的日期补 0,保证图表横轴连续。
-     *
-     * <p>逐日报警按四个报警等级分别计数,供前端直接画四条趋势序列。</p>
-     */
-    private List<AlarmDashboardOutDTO.TrendItem> buildTrend(List<WaterAlarmOutDTO> rows, int days) {
-        LocalDate today = LocalDate.now();
-        LocalDate from = today.minusDays(days - 1L);
-        Map<String, AlarmDashboardOutDTO.TrendItem> byDate = new LinkedHashMap<>();
-        for (int offset = 0; offset < days; offset++) {
-            AlarmDashboardOutDTO.TrendItem item = new AlarmDashboardOutDTO.TrendItem();
-            item.setDate(from.plusDays(offset).toString());
-            byDate.put(item.getDate(), item);
-        }
-        for (WaterAlarmOutDTO row : rows) {
-            if (row.getAlarmTime() == null) {
-                continue;
-            }
-            AlarmDashboardOutDTO.TrendItem item = byDate.get(row.getAlarmTime().toLocalDate().toString());
-            if (item == null) {
-                continue;
-            }
-            increaseTrendLevel(item, row.getAlarmLevel());
-        }
-        return new ArrayList<>(byDate.values());
-    }
-
-    /**
-     * 按报警等级累加当日趋势计数;等级缺失或越界时不计入任何序列。
-     */
-    private void increaseTrendLevel(AlarmDashboardOutDTO.TrendItem item, Integer alarmLevel) {
-        if (alarmLevel == null) {
-            return;
-        }
-        switch (alarmLevel) {
-            case 1:
-                item.setLevel1Count(item.getLevel1Count() + 1);
-                break;
-            case 2:
-                item.setLevel2Count(item.getLevel2Count() + 1);
-                break;
-            case 3:
-                item.setLevel3Count(item.getLevel3Count() + 1);
-                break;
-            case 4:
-                item.setLevel4Count(item.getLevel4Count() + 1);
-                break;
-            default:
-                break;
-        }
-    }
-
-    private List<AlarmDashboardOutDTO.TrendItem> emptyTrend(int days) {
-        List<AlarmDashboardOutDTO.TrendItem> empty = new ArrayList<>();
-        LocalDate from = LocalDate.now().minusDays(days - 1L);
-        for (int offset = 0; offset < days; offset++) {
-            AlarmDashboardOutDTO.TrendItem item = new AlarmDashboardOutDTO.TrendItem();
-            item.setDate(from.plusDays(offset).toString());
-            empty.add(item);
-        }
-        return empty;
-    }
-
-    private List<AlarmDashboardOutDTO.RatioItem> buildRatio(List<WaterAlarmOutDTO> rows,
-                                                            Function<WaterAlarmOutDTO, String> classify,
-                                                            String fallbackName) {
-        Map<String, Long> grouped = new LinkedHashMap<>();
-        for (WaterAlarmOutDTO row : rows) {
-            String name = classify.apply(row);
-            String key = StrUtil.isBlank(name) ? fallbackName : name;
-            grouped.merge(key, 1L, Long::sum);
-        }
-        return grouped.entrySet().stream()
-                .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
-                .map(entry -> {
-                    AlarmDashboardOutDTO.RatioItem item = new AlarmDashboardOutDTO.RatioItem();
-                    item.setName(entry.getKey());
-                    item.setCount(entry.getValue());
-                    return item;
-                })
-                .collect(Collectors.toList());
-    }
-
-    /**
-     * 设备分类占比取自报警所属设备在设备类别表中的真实类型名称,未知类型归入“未分类”。
-     */
-    private List<AlarmDashboardOutDTO.RatioItem> buildDeviceTypeRatio(List<WaterAlarmOutDTO> rows,
-                                                                      Map<String, EquipmentBase> eqMap) {
-        List<Integer> typeIds = eqMap.values().stream()
-                .map(EquipmentBase::getEquipmentTypeId)
-                .filter(Objects::nonNull)
-                .distinct()
-                .collect(Collectors.toList());
-        Map<Integer, String> typeNames = typeIds.isEmpty() ? Collections.emptyMap()
-                : equipmentTypeMapper.selectBatchIds(typeIds).stream()
-                .collect(Collectors.toMap(EquipmentType::getId, EquipmentType::getTypeName, (a, b) -> a));
-        return buildRatio(rows, row -> {
-            EquipmentBase eq = row.getDeviceCode() == null ? null : eqMap.get(row.getDeviceCode());
-            return eq == null ? null : typeNames.get(eq.getEquipmentTypeId());
-        }, "未分类");
-    }
-
     /**
      * 报警记录 -> 出参(填充设备名称/位置/经纬度及关联工单)
      */
@@ -574,7 +424,7 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
         List<AlarmData> pendingAlarms = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
                 .in(AlarmData::getDeviceCode, waterCodes)
                 .eq(AlarmData::getAlarmStatus, 0)
-                .orderByDesc(AlarmData::getAlarmTime));
+                .orderByDesc(AlarmData::getCreateTime));
         Map<String, EquipmentBase> eqMap = loadEquipmentCodeMap(pendingAlarms.stream()
                 .map(AlarmData::getDeviceCode).collect(Collectors.toSet()));
         // 同一设备只展示最新一条未处理报警
@@ -599,7 +449,7 @@ public class WaterSupplyAlarmServiceImpl implements IWaterSupplyAlarmService {
             point.setActualValue(alarm.getActualValue());
             point.setAlarmLevel(alarm.getAlarmLevel());
             point.setAlarmLevelText(formatAlarmLevel(alarm.getAlarmLevel()));
-            point.setAlarmTime(alarm.getAlarmTime());
+            point.setCreateTime(alarm.getCreateTime());
             points.add(point);
         }
         points.sort(Comparator.comparing(AlarmMapPointOutDTO::getAlarmLevel, Comparator.nullsLast(Comparator.naturalOrder())));

+ 0 - 79
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterAlarmLedgerExportDTO.java

@@ -1,79 +0,0 @@
-package com.zksy.WaterSupply.export;
-
-import com.zksy.WaterSupply.MonitorAlarm.dto.out.WaterAlarmOutDTO;
-import com.zksy.common.annotation.Excel;
-import lombok.Data;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-
-/**
- * 报警管理只读台账导出行(T02 / Issue #45)。
- *
- * <p>导出列与列表同口径:报警等级、设备报警状态、报警展示状态与关联工单编号,
- * 展示状态使用后端推导结果,不暴露底层报警记录状态字段。</p>
- */
-@Data
-public class WaterAlarmLedgerExportDTO {
-
-    @Excel(name = "报警编码")
-    private String warningCode;
-
-    @Excel(name = "设备编码")
-    private String deviceCode;
-
-    @Excel(name = "设备名称")
-    private String deviceName;
-
-    @Excel(name = "报警类型")
-    private String warningType;
-
-    @Excel(name = "报警等级")
-    private String alarmLevelText;
-
-    @Excel(name = "设备报警状态")
-    private String deviceAlarmStatusText;
-
-    @Excel(name = "报警展示状态")
-    private String displayStatusText;
-
-    @Excel(name = "关联工单号")
-    private String orderNo;
-
-    @Excel(name = "实际值")
-    private BigDecimal actualValue;
-
-    @Excel(name = "阈值下限")
-    private BigDecimal minValue;
-
-    @Excel(name = "阈值上限")
-    private BigDecimal maxValue;
-
-    @Excel(name = "偏差率")
-    private BigDecimal deviationRatio;
-
-    @Excel(name = "安装位置")
-    private String equipmentLocation;
-
-    @Excel(name = "报警时间", dateFormat = "yyyy-MM-dd HH:mm:ss")
-    private LocalDateTime alarmTime;
-
-    public static WaterAlarmLedgerExportDTO from(WaterAlarmOutDTO value) {
-        WaterAlarmLedgerExportDTO dto = new WaterAlarmLedgerExportDTO();
-        dto.warningCode = value.getWarningCode();
-        dto.deviceCode = value.getDeviceCode();
-        dto.deviceName = value.getDeviceName();
-        dto.warningType = value.getWarningType();
-        dto.alarmLevelText = value.getAlarmLevelText();
-        dto.deviceAlarmStatusText = value.getDeviceAlarmStatusText();
-        dto.displayStatusText = value.getDisplayStatusText();
-        dto.orderNo = value.getOrderNo();
-        dto.actualValue = value.getActualValue();
-        dto.minValue = value.getMinValue();
-        dto.maxValue = value.getMaxValue();
-        dto.deviationRatio = value.getDeviationRatio();
-        dto.equipmentLocation = value.getEquipmentLocation();
-        dto.alarmTime = value.getAlarmTime();
-        return dto;
-    }
-}

+ 7 - 11
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/domain/AlarmData.java

@@ -1,6 +1,7 @@
 package com.zksy.base.alarm.domain;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
@@ -22,13 +23,17 @@ public class AlarmData implements Serializable {
     @ApiModelProperty(value = "主键")
     private String id;
 
+    @TableField(value = "alarm_no", insertStrategy = FieldStrategy.NOT_NULL, updateStrategy = FieldStrategy.NEVER)
+    @ApiModelProperty(value = "报警单号")
+    private String alarmNo;
+
     @TableField(value = "device_code")
     @ApiModelProperty(value = "设备编码")
     private String deviceCode;
 
-    @TableField(value = "order_id")
+    @TableField(value = "work_order_id")
     @ApiModelProperty(value = "关联工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     @TableField(exist = false)
     @ApiModelProperty(value = "设备名称,由设备台账查询补充")
@@ -70,11 +75,6 @@ public class AlarmData implements Serializable {
     @ApiModelProperty(value = "报警状态 0-未处理 1-已处理")
     private Integer alarmStatus;
 
-    @TableField(value = "alarm_time")
-    @ApiModelProperty(value = "报警时间")
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
-    private LocalDateTime alarmTime;
-
     @TableField(value = "remark")
     @ApiModelProperty(value = "备注")
     private String remark;
@@ -101,10 +101,6 @@ public class AlarmData implements Serializable {
     @TableField(exist = false)
     private Boolean hasWorkOrder;
 
-    /** 关联工单ID(由查询接口补充) */
-    @TableField(exist = false)
-    private Long workOrderId;
-
     @ApiModelProperty("审核结果,见 AlarmAuditStatusEnum:0-未审核 1-确认报警 2-误报警")
     @TableField(value = "audit_status")
     private Integer auditStatus;

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/domain/AlarmLog.java

@@ -23,7 +23,7 @@ public class AlarmLog {
     private String attachUrl;
     @ApiModelProperty("操作人ID")
     private Long operUserId;
-    @ApiModelProperty("操作人姓名")
+    @ApiModelProperty("操作人昵称(sys_user.nick_name),自动操作为系统名称")
     private String operUserName;
     @ApiModelProperty("操作时间")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

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

@@ -0,0 +1,19 @@
+package com.zksy.base.alarm.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import io.swagger.annotations.ApiModelProperty;
+import javax.validation.constraints.Size;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AlarmQuery extends AlarmReviewQuery {
+    private Integer alarmStatus;
+    /** 研判候选事件仅保留有触发实际值的报警,零值仍属于有效值。 */
+    @ApiModelProperty("只查询 actual_value 非空的报警,默认 false,实际值 0 保留")
+    private boolean actualValueOnly;
+    @Size(max = 64)
+    private String alarmNo;
+    @Size(max = 100)
+    private String deviceCode;
+}

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/AlarmReviewRequest.java

@@ -7,7 +7,7 @@ import javax.validation.constraints.*;
 public class AlarmReviewRequest {
     @NotNull(message = "请选择审核结果")
     private Integer auditStatus;
-    @NotBlank(message = "请填写审核意见")
+    /** 审核意见选填,最多500字。 */
     @Size(max = 500, message = "审核意见最多500字")
     private String opinion;
     /** 服务端生成的附件票据,避免任意拼接外部附件 URL。 */

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

@@ -0,0 +1,15 @@
+package com.zksy.base.alarm.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+public class AlarmSampleVO {
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime time;
+    private BigDecimal value;
+    private long validCount;
+    private long sampleCount;
+}

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

@@ -0,0 +1,23 @@
+package com.zksy.base.alarm.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import java.time.LocalDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AlarmVO extends AlarmReviewVO {
+    private Integer equipmentTypeId;
+    private String equipmentImage;
+    private String orderNo;
+    private Integer orderStatus;
+    private String orderStatusName;
+    private String receiveUserName;
+    private String monitorStatusName;
+    private String latestOperation;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime latestOperationTime;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime planFinishTime;
+}

+ 8 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/enums/AlarmLogOperTypeEnum.java

@@ -1,18 +1,22 @@
 package com.zksy.base.alarm.enums;
 
-import lombok.AllArgsConstructor;
 import lombok.Getter;
+
 import java.util.Arrays;
 
-/** 报警日志操作类型。 */
+/** 报警日志类型;两端枚举值保持一致,工单关联及工单操作不再写入报警日志。 */
 @Getter
-@AllArgsConstructor
 public enum AlarmLogOperTypeEnum {
-    CREATED(0, "报警触发"), CONFIRMED(1, "确认报警"), FALSE_ALARM(2, "误报警"), HANDLED(3, "报警处理"), ORDER_LINKED(4, "关联工单"), WORK_ORDER_OPERATION(5, "工单操作");
+    CREATED(0, "报警触发"),
+    CONFIRMED(1, "确认报警"),
+    FALSE_ALARM(2, "误报警"),
+    HANDLED(3, "报警处理");
 
     private final int code;
     private final String label;
 
+    AlarmLogOperTypeEnum(int code, String label) { this.code = code; this.label = label; }
+
     public static AlarmLogOperTypeEnum fromCode(Integer code) {
         return Arrays.stream(values()).filter(value -> code != null && value.code == code).findFirst().orElse(null);
     }

+ 30 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/enums/AlarmMetricSource.java

@@ -0,0 +1,30 @@
+package com.zksy.base.alarm.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import java.util.Arrays;
+
+/** 与采集协议的报警编码保持一致;SQL 标识符只能取自此白名单。 */
+@Getter
+@AllArgsConstructor
+public enum AlarmMetricSource {
+    INSTANT_FLOW("WARN_INSTANT_FLOW", "radar_data", "meter1_instant_flow", "timestamp_since", "device_code", null),
+    FLOW_SPEED("WARN_FLOW_SPEED", "radar_data", "flow_speed", "timestamp_since", "device_code", null),
+    WATER_LEVEL("WARN-WATER-LEVEL", "water_monitor_data", "water_level", "timestamp_since", "device_code", null),
+    PRESSURE("WARN-PRESSURE", "telemetry_data", "pressure", "timestamp_since", "device_code", null),
+    SUSPENDED_SOLIDS("WARN_SUSPENDED_SOLIDS", "e_real_time_data", "float_value", "create_time", "device_id", 1),
+    COD("WARN_COD", "e_real_time_data", "hum", "create_time", "device_id", 2),
+    AMMONIA_NITROGEN("WARN_AMMONIA_NITROGEN", "e_real_time_data", "hum", "create_time", "device_id", 3),
+    CONDUCTIVITY("WARN_CONDUCTIVITY", "e_real_time_data", "hum", "create_time", "device_id", 4),
+    PH("WARN_PH", "e_real_time_data", "hum", "create_time", "device_id", 5);
+
+    private final String warningCode;
+    private final String table;
+    private final String column;
+    private final String timeColumn;
+    private final String deviceColumn;
+    private final Integer nodeId;
+    public static AlarmMetricSource fromCode(String code) {
+        return Arrays.stream(values()).filter(value -> value.warningCode.equals(code)).findFirst().orElse(null);
+    }
+}

+ 4 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/enums/AlarmSeriesPeriod.java

@@ -0,0 +1,4 @@
+package com.zksy.base.alarm.enums;
+
+/** SQL 聚合粒度白名单。 */
+public enum AlarmSeriesPeriod { CURVE, DAY, DAILY, MONTHLY }

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

@@ -6,11 +6,25 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.base.alarm.domain.AlarmData;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Select;
 
 import java.util.List;
 
 @Mapper
 public interface AlarmDataMapper extends BaseMapper<AlarmData> {
+    @Select("SELECT 1 FROM pg_advisory_xact_lock(hashtext('alarm-number-generation'))")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE, timeout = 5)
+    Integer lockAlarmNumberGeneration();
+
+    @Select("SELECT EXISTS(SELECT 1 FROM alarm_data WHERE alarm_no=#{alarmNo})")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    boolean existsAlarmNo(@Param("alarmNo") String alarmNo);
+
+    /** 人工处理也必须锁定原状态,重复处理不生成新日志。 */
+    @Select("SELECT id, alarm_status, work_order_id FROM alarm_data WHERE id=#{id} FOR UPDATE")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE, timeout = 5)
+    AlarmData selectForLifecycleUpdate(@Param("id") String id);
 
     /** 平均响应时间取已处理操作日志,不依赖告警表已移除的处理时间字段。 */
     java.math.BigDecimal selectAverageResponseMinutes(@Param("deviceCodes") java.util.Collection<String> deviceCodes,

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

@@ -3,7 +3,36 @@ package com.zksy.base.alarm.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.zksy.base.alarm.domain.AlarmLog;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Param;
+import java.time.LocalDateTime;
+import java.util.List;
 
 @Mapper
 public interface AlarmLogMapper extends BaseMapper<AlarmLog> {
+    /** 全部历史记录倒序,同一时间按主键倒序;旧记录的账号显示同步使用用户昵称。 */
+    @Select("SELECT l.log_id,l.alarm_id,l.oper_type,l.oper_desc,l.attach_url,l.oper_user_id,l.oper_time, "
+            + "coalesce(nullif(u.nick_name,''),l.oper_user_name) AS oper_user_name "
+            + "FROM app_user.alarm_log l LEFT JOIN app_user.sys_user u ON u.user_id=l.oper_user_id "
+            + "WHERE l.alarm_id=#{alarmId} ORDER BY l.oper_time DESC NULLS LAST,l.log_id DESC")
+    List<AlarmLog> selectHistory(@Param("alarmId") String alarmId);
+
+    /** 报警记录已持有事务行锁,按本次变更前的日志ID避免同一事件重复记录。 */
+    @Insert("INSERT INTO app_user.alarm_log(alarm_id,oper_type,oper_desc,oper_user_name,oper_time,oper_user_id) "
+            + "SELECT #{alarmId},#{operType},#{description},#{actor},#{time},#{operUserId,jdbcType=BIGINT} "
+            + "WHERE NOT EXISTS(SELECT 1 FROM app_user.alarm_log WHERE alarm_id=#{alarmId} AND oper_type=#{operType} AND log_id > #{afterLogId})")
+    int insertLifecycleIfAbsent(@Param("alarmId") String alarmId, @Param("operType") int operType,
+                                @Param("description") String description, @Param("actor") String actor,
+                                @Param("time") LocalDateTime time, @Param("afterLogId") long afterLogId,
+                                @Param("operUserId") Long operUserId);
+
+    @Select("SELECT EXISTS(SELECT 1 FROM app_user.alarm_log WHERE alarm_id=#{alarmId} AND oper_type=#{operType} AND log_id > #{afterLogId})")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    boolean existsLifecycle(@Param("alarmId") String alarmId, @Param("operType") int operType, @Param("afterLogId") long afterLogId);
+
+    @Select("SELECT coalesce(max(log_id),0) FROM app_user.alarm_log WHERE alarm_id=#{alarmId}")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    long latestLogId(@Param("alarmId") String alarmId);
 }

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

@@ -0,0 +1,22 @@
+package com.zksy.base.alarm.mapper;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.alarm.dto.*;
+import com.zksy.base.alarm.enums.AlarmMetricSource;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+public interface AlarmOperationsMapper {
+    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);
+    List<Map<String, Object>> countByLevel(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
+    List<AlarmVO> selectMap(@Param("query") AlarmQuery query, @Param("typeIds") List<Integer> typeIds);
+    AlarmSampleVO selectLatestSample(@Param("source") AlarmMetricSource source, @Param("deviceCode") String deviceCode);
+    List<AlarmSampleVO> selectSamples(@Param("source") AlarmMetricSource source, @Param("deviceCode") String deviceCode,
+            @Param("start") LocalDateTime start, @Param("end") LocalDateTime end, @Param("period") String period);
+}

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

@@ -0,0 +1,140 @@
+package com.zksy.base.alarm.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.alarm.domain.AlarmLog;
+import com.zksy.base.alarm.dto.*;
+import com.zksy.base.alarm.enums.*;
+import com.zksy.base.alarm.enums.AlarmLogOperTypeEnum;
+import com.zksy.base.alarm.mapper.*;
+import com.zksy.base.enums.EquipmentTypeEnum;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.ops.enums.WorkOrderStatusEnum;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class AlarmOperationsService {
+    private final AlarmOperationsMapper mapper;
+    private final AlarmReviewScopeService scope;
+    private final AlarmLogMapper logs;
+
+    public Page<AlarmVO> page(AlarmQuery query) {
+        validate(query);
+        Page<AlarmVO> page = mapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), query, scope.resolveOperations(query));
+        page.getRecords().forEach(this::labels);
+        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;
+    }
+    public Page<AlarmVO> reminders(AlarmQuery query) {
+        // 提醒只展示未处理报警,不能通过请求参数切换为历史已恢复事件。
+        query.setAlarmStatus(AlarmRecordStatusEnum.UNHANDLED.getCode());
+        validate(query);
+        Page<AlarmVO> page = mapper.selectReminders(new Page<>(query.getPageNum(), query.getPageSize()), query, scope.resolveOperations(query));
+        page.getRecords().forEach(this::labels);
+        return page;
+    }
+    public List<AlarmLog> logs(String id, AlarmQuery query) {
+        detail(id, query);
+        List<AlarmLog> result = logs.selectHistory(id);
+        result.forEach(log -> { AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
+            log.setOperTypeName(type == null ? "历史操作" : type.getLabel()); });
+        return result;
+    }
+    public List<Map<String, Object>> statistics(AlarmQuery query) {
+        validate(query);
+        return mapper.countByLevel(query, scope.resolveOperations(query));
+    }
+    public List<AlarmVO> map(AlarmQuery query) {
+        requireDrainage(query);
+        validate(query);
+        List<AlarmVO> result = mapper.selectMap(query, scope.resolveOperations(query));
+        if (result.size() > 10000) throw new ServiceException("报警数量超过地图展示上限,请缩小查询范围");
+        result.forEach(this::labels);
+        return result;
+    }
+    public Map<String, Object> analysis(String id, AlarmQuery query, AlarmSeriesPeriod period) {
+        requireDrainage(query);
+        AlarmVO alarm = detail(id, query);
+        AlarmMetricSource source = AlarmMetricSource.fromCode(alarm.getWarningCode());
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("alarm", alarm); result.put("period", period);
+        result.put("supported", source != null && alarm.getActualValue() != null);
+        if (alarm.getActualValue() == null) {
+            result.put("samples", Collections.emptyList());
+            result.put("message", "该报警无实际值,不展示监测曲线"); return result;
+        }
+        if (source == null) {
+            result.put("samples", Collections.emptyList());
+            result.put("message", "该报警指标暂无对应的数值采集曲线"); return result;
+        }
+        LocalDateTime anchor = alarm.getCreateTime() == null ? LocalDateTime.now() : alarm.getCreateTime();
+        LocalDateTime start = period == AlarmSeriesPeriod.CURVE ? anchor.minusHours(1)
+                : period == AlarmSeriesPeriod.DAY ? anchor.toLocalDate().atStartOfDay()
+                : period == AlarmSeriesPeriod.DAILY ? anchor.toLocalDate().minusDays(6).atStartOfDay()
+                : anchor.toLocalDate().withDayOfMonth(1).minusMonths(5).atStartOfDay();
+        LocalDateTime end = period == AlarmSeriesPeriod.CURVE ? anchor.plusHours(1)
+                : anchor.toLocalDate().plusDays(1).atStartOfDay();
+        if (end.isAfter(LocalDateTime.now())) end = LocalDateTime.now();
+        List<AlarmSampleVO> samples = mapper.selectSamples(source, alarm.getDeviceCode(), start, end, period.name());
+        if (samples.size() > 20000) throw new ServiceException("采样点数量过多,请选择日均值或月均值");
+        if (period == AlarmSeriesPeriod.DAILY || period == AlarmSeriesPeriod.MONTHLY) samples = fillEmptyBuckets(samples, start, end, period);
+        result.put("latestSample", mapper.selectLatestSample(source, alarm.getDeviceCode()));
+        result.put("samples", samples); result.put("startTime", start.toString()); result.put("endTime", end.toString());
+        result.put("message", "均值按有效原始采样计算;无效值不补零,阈值为报警触发时快照");
+        return result;
+    }
+    /** 缺失整日/月保留空值断点,不能将两侧均值连成有数据的连续曲线。 */
+    private List<AlarmSampleVO> fillEmptyBuckets(List<AlarmSampleVO> samples, LocalDateTime start,
+                                                LocalDateTime end, AlarmSeriesPeriod period) {
+        Map<LocalDateTime, AlarmSampleVO> byTime = samples.stream().collect(Collectors.toMap(AlarmSampleVO::getTime, v -> v));
+        List<AlarmSampleVO> result = new ArrayList<>();
+        for (LocalDateTime cursor = start; cursor.isBefore(end);
+             cursor = period == AlarmSeriesPeriod.DAILY ? cursor.plusDays(1) : cursor.plusMonths(1)) {
+            AlarmSampleVO sample = byTime.get(cursor);
+            if (sample == null) { sample = new AlarmSampleVO(); sample.setTime(cursor); }
+            result.add(sample);
+        }
+        return result;
+    }
+    public Map<String, Object> options(AlarmQuery query) {
+        scope.resolveOperations(query);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("alarmLevels", Arrays.stream(AlarmLevelEnum.values()).map(v -> option(v.getCode(), v.getLabel())).collect(Collectors.toList()));
+        result.put("auditStatuses", Arrays.stream(AlarmAuditStatusEnum.values()).map(v -> option(v.getCode(), v.getLabel())).collect(Collectors.toList()));
+        result.put("monitorStatuses", Arrays.stream(AlarmRecordStatusEnum.values()).map(v -> option(v.getCode(),
+                v == AlarmRecordStatusEnum.UNHANDLED ? "异常" : "已恢复")).collect(Collectors.toList()));
+        return result;
+    }
+    private Map<String, Object> option(int value, String label) {
+        Map<String,Object> result = new LinkedHashMap<>(); result.put("value",value); result.put("label",label); return result;
+    }
+    private void requireDrainage(AlarmQuery query) {
+        if (!EquipmentTypeEnum.ROOT_DRAINAGE_DEVICE.getId().equals(query.getRootTypeId()))
+            throw new ServiceException("该功能仅适用于排水子系统");
+    }
+    private void validate(AlarmQuery query) {
+        if (query.getPageNum() < 1 || query.getPageSize() < 1 || query.getPageSize() > 100) throw new ServiceException("分页参数无效");
+        if (query.getAlarmLevel() != null && AlarmLevelEnum.fromCode(query.getAlarmLevel()) == null) throw new ServiceException("报警等级无效");
+        if (query.getAuditStatus() != null && AlarmAuditStatusEnum.fromCode(query.getAuditStatus()) == null) throw new ServiceException("审核结果无效");
+        if (query.getAlarmStatus() != null && AlarmRecordStatusEnum.fromCode(query.getAlarmStatus()) == null) throw new ServiceException("监测状态无效");
+        if (query.getStartTime() != null && query.getEndTime() != null && query.getStartTime().isAfter(query.getEndTime())) throw new ServiceException("开始时间不能晚于结束时间");
+    }
+    private void labels(AlarmVO alarm) {
+        AlarmLevelEnum level = AlarmLevelEnum.fromCode(alarm.getAlarmLevel());
+        AlarmAuditStatusEnum audit = AlarmAuditStatusEnum.fromCode(alarm.getAuditStatus());
+        alarm.setAlarmLevelName(level == null ? "未知等级" : level.getLabel());
+        alarm.setAuditStatusName(audit == null ? "未审核" : audit.getLabel());
+        alarm.setMonitorStatusName(Objects.equals(alarm.getAlarmStatus(), AlarmRecordStatusEnum.UNHANDLED.getCode()) ? "异常"
+                : Objects.equals(alarm.getAlarmStatus(), AlarmRecordStatusEnum.HANDLED.getCode()) ? "已恢复" : "未知");
+        alarm.setOrderStatusName(alarm.getWorkOrderId() == null ? "未转工单" : WorkOrderStatusEnum.getDescByCode(alarm.getOrderStatus()));
+    }
+}

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

@@ -23,11 +23,21 @@ public class AlarmReviewScopeService {
     private final ISysMenuService menuService;
 
     public List<Integer> resolve(AlarmReviewQuery query) {
+        return resolve(query, Collections.singleton("subSystem/alarm/index"));
+    }
+
+    /** 读取与审核授权独立,列表菜单不能授予跨系统审核能力。 */
+    public List<Integer> resolveOperations(AlarmReviewQuery query) {
+        return resolve(query, new HashSet<>(Arrays.asList("subSystem/alarm/remind", "subSystem/alarm/list",
+                "subSystem/alarm/drainage/analysis", "subSystem/alarm/drainage/alarmGIS")));
+    }
+
+    private List<Integer> resolve(AlarmReviewQuery query, Set<String> components) {
         EquipmentTypeEnum root = EquipmentTypeEnum.getById(query.getRootTypeId());
         if (root == null || !root.isRoot()) throw new ServiceException("无效的设备大类");
         if (!SecurityUtils.isAdmin(SecurityUtils.getUserId())) {
             boolean allowed = menuService.selectMenuList(SecurityUtils.getUserId()).stream()
-                    .anyMatch(menu -> matches(menu, query));
+                    .anyMatch(menu -> matches(menu, query, components));
             if (!allowed) throw new AccessDeniedException("无权访问当前子系统的报警数据");
         }
         List<EquipmentType> types = typeMapper.selectList(null);
@@ -45,7 +55,11 @@ public class AlarmReviewScopeService {
     }
 
     boolean matches(SysMenu menu, AlarmReviewQuery query) {
-        if (!"subSystem/alarm/index".equals(menu.getComponent()) || !"0".equals(menu.getStatus())) return false;
+        return matches(menu, query, Collections.singleton("subSystem/alarm/index"));
+    }
+
+    private boolean matches(SysMenu menu, AlarmReviewQuery query, Set<String> components) {
+        if (!components.contains(menu.getComponent()) || !"0".equals(menu.getStatus())) return false;
         try {
             JSONObject scope = JSONUtil.parseObj(menu.getQuery());
             String code = scope.getStr("warning_code", scope.getStr("warningCode"));

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

@@ -1,11 +1,14 @@
 package com.zksy.base.alarm.service;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.base.alarm.domain.*;
 import com.zksy.base.alarm.dto.*;
 import com.zksy.base.alarm.enums.*;
+import com.zksy.base.alarm.enums.AlarmLogOperTypeEnum;
+import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.system.mapper.SysUserMapper;
+import com.zksy.ops.domain.WorkOrder;
 import com.zksy.base.alarm.mapper.*;
 import com.zksy.base.enums.EquipmentTypeEnum;
 import com.zksy.common.exception.ServiceException;
@@ -27,6 +30,7 @@ public class AlarmReviewService {
     private final AlarmReviewScopeService scopeService;
     private final AlarmReviewAttachmentService attachments;
     private final WorkOrderService workOrderService;
+    private final SysUserMapper userMapper;
 
     public Page<AlarmReviewVO> page(AlarmReviewQuery query) {
         validate(query);
@@ -51,8 +55,7 @@ public class AlarmReviewService {
 
     public List<AlarmLog> logs(String id, AlarmReviewQuery query) {
         detail(id, query);
-        List<AlarmLog> logs = logMapper.selectList(new LambdaQueryWrapper<AlarmLog>()
-                .eq(AlarmLog::getAlarmId, id).orderByAsc(AlarmLog::getOperTime, AlarmLog::getLogId));
+        List<AlarmLog> logs = logMapper.selectHistory(id);
         logs.forEach(log -> {
             AlarmLogOperTypeEnum type = AlarmLogOperTypeEnum.fromCode(log.getOperType());
             log.setOperTypeName(type == null ? "历史操作" : type.getLabel());
@@ -66,7 +69,7 @@ public class AlarmReviewService {
         AlarmAuditStatusEnum result = AlarmAuditStatusEnum.fromCode(request.getAuditStatus());
         if (result == null || result == AlarmAuditStatusEnum.UNREVIEWED) throw new ServiceException("审核结果无效");
         String opinion = request.getOpinion() == null ? "" : request.getOpinion().trim();
-        if (opinion.isEmpty() || opinion.length() > 500) throw new ServiceException("审核意见须为1至500字");
+        if (opinion.length() > 500) throw new ServiceException("审核意见最多500字");
         String attachUrl = attachments.resolve(id, request.getAttachmentTokens());
         LocalDateTime now = LocalDateTime.now();
         // 原子条件更新锁定待审核报警;并发审核/监测恢复时,只有一方成功。
@@ -83,15 +86,23 @@ public class AlarmReviewService {
         log.setOperDesc(opinion);
         log.setAttachUrl(attachUrl);
         log.setOperUserId(SecurityUtils.getUserId());
-        log.setOperUserName(SecurityUtils.getUsername());
+        SysUser operator = userMapper.selectUserById(log.getOperUserId());
+        if (operator == null) throw new ServiceException("审核用户不存在,请重新登录");
+        log.setOperUserName(operator.getNickName() == null || operator.getNickName().trim().isEmpty()
+                ? "未设置昵称" : operator.getNickName().trim());
         log.setOperTime(now);
-        if (logMapper.insert(log) != 1) throw new ServiceException("审核日志保存失败");
         if (result == AlarmAuditStatusEnum.CONFIRMED) {
             // 工单故障描述由原始报警信息生成;审核意见仅写审核日志,不能覆盖故障事实。
             Long orderId = workOrderService.createWorkOrderFromAlarm(id, null, null);
             if (orderId == null) throw new ServiceException("工单创建失败");
-            alarm.setOrderId(orderId);
+            WorkOrder order = workOrderService.getById(orderId);
+            if (order == null || order.getOrderNo() == null || order.getOrderNo().trim().isEmpty()) {
+                throw new ServiceException("未获取到工单号,审核已回滚");
+            }
+            log.setOperDesc((opinion.isEmpty() ? "" : opinion + "\n") + "已转工单(" + order.getOrderNo() + ")");
+            alarm.setWorkOrderId(orderId);
         }
+        if (logMapper.insert(log) != 1) throw new ServiceException("审核日志保存失败");
         // 审核不会修改 equipment_status;误报仅记录结论,保留原始报警。
         alarm.setAuditStatus(result.getCode());
         labels(alarm);

+ 4 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/impl/AlarmClearServiceImpl.java

@@ -100,7 +100,7 @@ public class AlarmClearServiceImpl implements AlarmClearService {
 
         List<AlarmData> alarms = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
                 .eq(AlarmData::getDeviceCode, deviceCode)
-                .orderByDesc(AlarmData::getAlarmTime));
+                .orderByDesc(AlarmData::getCreateTime));
         List<AlarmData> remaining = new ArrayList<>();
         List<AlarmData> resolved = new ArrayList<>();
         if (!alarms.isEmpty()) {
@@ -178,7 +178,7 @@ public class AlarmClearServiceImpl implements AlarmClearService {
 
     /** 报警ID -> 其全部关联工单:闭环判定需要同时看到未办结、已结案与已取消。 */
     private Map<String, List<WorkOrder>> loadOrdersByAlarmId(List<AlarmData> alarms) {
-        List<Long> orderIds = alarms.stream().map(AlarmData::getOrderId)
+        List<Long> orderIds = alarms.stream().map(AlarmData::getWorkOrderId)
                 .filter(Objects::nonNull).distinct().collect(Collectors.toList());
         List<WorkOrder> orders = orderIds.isEmpty()
                 ? Collections.emptyList() : workOrderMapper.selectBatchIds(orderIds);
@@ -186,10 +186,10 @@ public class AlarmClearServiceImpl implements AlarmClearService {
                 .collect(Collectors.toMap(WorkOrder::getOrderId, order -> order, (a, b) -> a));
         Map<String, List<WorkOrder>> result = new LinkedHashMap<>();
         for (AlarmData alarm : alarms) {
-            if (alarm.getOrderId() == null) {
+            if (alarm.getWorkOrderId() == null) {
                 continue;
             }
-            WorkOrder order = ordersById.get(alarm.getOrderId());
+            WorkOrder order = ordersById.get(alarm.getWorkOrderId());
             if (order != null) {
                 result.computeIfAbsent(alarm.getId(), key -> new ArrayList<>()).add(order);
             }

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

@@ -7,6 +7,13 @@ import com.zksy.base.alarm.domain.AlarmData;
 import com.zksy.base.alarm.enums.AlarmAuditStatusEnum;
 import com.zksy.base.alarm.enums.AlarmRecordStatusEnum;
 import com.zksy.base.alarm.mapper.AlarmDataMapper;
+import com.zksy.base.alarm.mapper.AlarmLogMapper;
+import com.zksy.base.alarm.enums.AlarmLogOperTypeEnum;
+import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.common.utils.SecurityUtils;
+import com.zksy.system.mapper.SysUserMapper;
+import com.zksy.base.alarm.support.AlarmLifecycleSupport;
 import com.zksy.base.alarm.service.AlarmDataService;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentType;
@@ -16,6 +23,7 @@ import com.zksy.ops.mapper.WorkOrderMapper;
 import com.zksy.ops.domain.WorkOrder;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.io.Serializable;
 import java.time.LocalDateTime;
@@ -41,6 +49,12 @@ public class AlarmDataServiceImpl extends ServiceImpl<AlarmDataMapper, AlarmData
     @Autowired
     private WorkOrderMapper workOrderMapper;
 
+    @Autowired
+    private AlarmLogMapper alarmLogMapper;
+
+    @Autowired
+    private SysUserMapper sysUserMapper;
+
     @Override
     public Page<AlarmData> findByPage(long pageNum, long pageSize, String deviceCode,
                                         String warningType, String warningCode,
@@ -141,15 +155,15 @@ public class AlarmDataServiceImpl extends ServiceImpl<AlarmDataMapper, AlarmData
         if (records == null || records.isEmpty()) {
             return;
         }
-        Set<Long> orderIds = records.stream().map(AlarmData::getOrderId)
+        Set<Long> orderIds = records.stream().map(AlarmData::getWorkOrderId)
                 .filter(Objects::nonNull).collect(Collectors.toSet());
         Map<Long, WorkOrder> orders = orderIds.isEmpty()
                 ? Collections.emptyMap()
                 : workOrderMapper.selectBatchIds(orderIds).stream().collect(Collectors.toMap(
                         WorkOrder::getOrderId, order -> order, (a, b) -> a));
         for (AlarmData record : records) {
-            WorkOrder order = record.getOrderId() == null
-                    ? null : orders.get(record.getOrderId());
+            WorkOrder order = record.getWorkOrderId() == null
+                    ? null : orders.get(record.getWorkOrderId());
             boolean hasOrder = order != null;
             record.setHasWorkOrder(hasOrder);
             record.setWorkOrderId(hasOrder ? order.getOrderId() : null);
@@ -239,17 +253,20 @@ public class AlarmDataServiceImpl extends ServiceImpl<AlarmDataMapper, AlarmData
                 .like(warningType != null, AlarmData::getWarningType, warningType)
                 .like(warningCode != null, AlarmData::getWarningCode, warningCode)
                 .eq(alarmStatus != null, AlarmData::getAlarmStatus, alarmStatus);
-        queryWrapper.orderByDesc(AlarmData::getAlarmTime);
+        queryWrapper.orderByDesc(AlarmData::getCreateTime);
         List<AlarmData> records = this.list(queryWrapper);
         fillDeviceInfo(records);
         return records;
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public boolean saveAlarmData(AlarmData alarmData) {
-        if (alarmData.getAlarmTime() == null) {
-            alarmData.setAlarmTime(LocalDateTime.now());
+        // 覆盖客户端号码,新增与演练入口统一由程序生成,更新入口不经过此方法。
+        if (!Integer.valueOf(1).equals(baseMapper.lockAlarmNumberGeneration())) {
+            throw new IllegalStateException("未取得报警单号生成事务锁");
         }
+        alarmData.setAlarmNo(AlarmLifecycleSupport.nextAlarmNo(baseMapper::existsAlarmNo));
         if (alarmData.getCreateTime() == null) {
             alarmData.setCreateTime(LocalDateTime.now());
         }
@@ -259,6 +276,60 @@ public class AlarmDataServiceImpl extends ServiceImpl<AlarmDataMapper, AlarmData
         if (alarmData.getAuditStatus() == null) {
             alarmData.setAuditStatus(AlarmAuditStatusEnum.UNREVIEWED.getCode());
         }
-        return this.save(alarmData);
+        if (baseMapper.insert(alarmData) != 1) throw new IllegalStateException("报警入库失败");
+        writeLifecycleLog(alarmData.getId(), AlarmLogOperTypeEnum.CREATED,
+                AlarmLifecycleSupport.createdDescription(alarmData.getAlarmNo(), alarmData.getRemark(),
+                        alarmData.getWarningType(), alarmData.getWarningCode(), alarmData.getActualValue(),
+                        alarmData.getMinValue(), alarmData.getMaxValue()), "监测系统", alarmData.getCreateTime(), 0L);
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean save(AlarmData alarmData) {
+        return saveAlarmData(alarmData);
+    }
+
+    /** 覆盖通用修改与各子系统的人工处理入口,只有状态真正改变才记处理日志。 */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean updateById(AlarmData alarmData) {
+        boolean handling = Objects.equals(alarmData.getAlarmStatus(), AlarmRecordStatusEnum.HANDLED.getCode());
+        if (!handling) {
+            return super.updateById(alarmData);
+        }
+        AlarmData current = baseMapper.selectForLifecycleUpdate(alarmData.getId());
+        if (current == null) return false;
+        boolean changed = handling && !Objects.equals(current.getAlarmStatus(), AlarmRecordStatusEnum.HANDLED.getCode());
+        long beforeLogId = changed ? alarmLogMapper.latestLogId(alarmData.getId()) : 0L;
+        if (!super.updateById(alarmData)) return false;
+        if (changed) {
+            Long userId = null;
+            String nickName = "系统";
+            if (SecurityUtils.getAuthentication() != null && SecurityUtils.getAuthentication().getPrincipal() instanceof LoginUser) {
+                userId = SecurityUtils.getUserId();
+                SysUser operator = sysUserMapper.selectUserById(userId);
+                if (operator == null) throw new IllegalStateException("操作用户不存在,请重新登录");
+                nickName = operator.getNickName() == null || operator.getNickName().trim().isEmpty()
+                        ? "未设置昵称" : operator.getNickName().trim();
+            }
+            writeLifecycleLog(alarmData.getId(), AlarmLogOperTypeEnum.HANDLED,
+                    "报警已处理", nickName, LocalDateTime.now(), beforeLogId, userId);
+        }
+        return true;
+    }
+
+    private void writeLifecycleLog(String alarmId, AlarmLogOperTypeEnum type, String description,
+                                   String actor, LocalDateTime time, long afterLogId) {
+        writeLifecycleLog(alarmId, type, description, actor, time, afterLogId, null);
+    }
+
+    private void writeLifecycleLog(String alarmId, AlarmLogOperTypeEnum type, String description,
+                                   String actor, LocalDateTime time, long afterLogId, Long operUserId) {
+        if (alarmId == null || alarmId.trim().isEmpty()) throw new IllegalStateException("报警日志缺少报警ID");
+        int count = alarmLogMapper.insertLifecycleIfAbsent(alarmId, type.getCode(), description, actor, time, afterLogId, operUserId);
+        if (count != 1 && !(count == 0 && alarmLogMapper.existsLifecycle(alarmId, type.getCode(), afterLogId))) {
+            throw new IllegalStateException("报警日志写入失败");
+        }
     }
 }

+ 45 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/support/AlarmLifecycleSupport.java

@@ -0,0 +1,45 @@
+package com.zksy.base.alarm.support;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Predicate;
+
+/** 管理端与采集端遵循同一报警单号、触发描述规则。 */
+public final class AlarmLifecycleSupport {
+    private static final DateTimeFormatter NUMBER_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+    private AlarmLifecycleSupport() {}
+
+    /** 调用方须先持有数据库事务内单号锁;随机碰撞时重新取号,不能只查重而不加锁。 */
+    public static String nextAlarmNo(Predicate<String> exists) {
+        for (int attempt = 0; attempt < 100; attempt++) {
+            String number = "BJ" + LocalDateTime.now(ZoneId.of("Asia/Shanghai")).format(NUMBER_TIME)
+                    + String.format(Locale.ROOT, "%04d", ThreadLocalRandom.current().nextInt(10000));
+            if (!exists.test(number)) return number;
+        }
+        throw new IllegalStateException("报警单号生成冲突,请稍后重试");
+    }
+
+    public static String createdDescription(String alarmNo, String remark, String warningType,
+                                            String warningCode, BigDecimal actual, BigDecimal min, BigDecimal max) {
+        String description = firstNonBlank(remark, warningType, warningCode, "监测报警");
+        StringBuilder text = new StringBuilder("系统触发报警;报警单号:")
+                .append(alarmNo).append(";报警描述:").append(description);
+        if (actual != null) {
+            text.append(";实际值:").append(actual.toPlainString());
+            if (min != null) text.append(";触发时下限:").append(min.toPlainString());
+            if (max != null) text.append(";触发时上限:").append(max.toPlainString());
+        }
+        return text.toString();
+    }
+
+    private static String firstNonBlank(String... values) {
+        for (String value : values) {
+            if (value != null && !value.trim().isEmpty()) return value.trim();
+        }
+        throw new IllegalStateException("报警描述不能为空");
+    }
+}

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/CaseInfo.java

@@ -39,7 +39,7 @@ public class CaseInfo implements Serializable {
 
     /** 关联工单ID */
     @ApiModelProperty(value = "关联工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     /**
      * 关联报警ID,手动立案可为空

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/HazardHiddenAccount.java

@@ -31,7 +31,7 @@ public class HazardHiddenAccount {
     private String hazardNo;
 
     @ApiModelProperty("关联工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     @ApiModelProperty("关联管网设施ID")
     private String networkId;

+ 2 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/AbnormalDeviceVO.java

@@ -62,8 +62,8 @@ public class AbnormalDeviceVO {
     @ApiModelProperty("异常原因(来自未处理报警或维修状态)")
     private String abnormalReason;
 
-    @ApiModelProperty("报警时间")
-    private LocalDateTime alarmTime;
+    @ApiModelProperty("报警触发时间(取记录创建时间)")
+    private LocalDateTime createTime;
 
     @ApiModelProperty("关联统一工单号")
     private String relatedWorkOrderNo;

+ 0 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/drill/service/impl/DrillSimulationServiceImpl.java

@@ -67,7 +67,6 @@ public class DrillSimulationServiceImpl implements DrillSimulationService {
                 alarm.setActualValue(new BigDecimal("150"));
                 alarm.setAlarmLevel(1);
                 alarm.setAlarmStatus(0);
-                alarm.setAlarmTime(now);
                 alarm.setDeviationRatio(new BigDecimal("50"));
                 alarm.setRemark("演练模拟告警-" + category);
                 alarm.setCreateTime(now);

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

@@ -3,9 +3,17 @@ package com.zksy.base.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.zksy.base.domain.EquipmentBase;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 @Mapper
 public interface EquipmentBaseMapper extends BaseMapper<EquipmentBase>{
 
+    /** 报警转工单时串行化同设备建单,锁由调用方事务持有至提交或回滚。 */
+    @Select("SELECT equipment_id FROM app_user.equipment_base WHERE equipment_id = #{equipmentId} FOR UPDATE")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    String lockById(@Param("equipmentId") String equipmentId);
+
 //    EquipmentBase getByIdTest(String id);
 }

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

@@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Update;
 
 @Mapper
 public interface HazardHiddenAccountMapper extends BaseMapper<HazardHiddenAccount> {
-    @Update("UPDATE app_user.hazard_hidden_account SET order_id = NULL WHERE order_id = #{orderId}")
-    int clearOrderRelation(@Param("orderId") Long orderId);
+    @Update("UPDATE app_user.hazard_hidden_account SET work_order_id = NULL WHERE work_order_id = #{workOrderId}")
+    int clearOrderRelation(@Param("workOrderId") Long workOrderId);
 }

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

@@ -67,7 +67,6 @@ public class DrillSimulationServiceImpl implements DrillSimulationService {
                 alarm.setActualValue(new BigDecimal("150"));
                 alarm.setAlarmLevel(1);
                 alarm.setAlarmStatus(0);
-                alarm.setAlarmTime(now);
                 alarm.setDeviationRatio(new BigDecimal("50"));
                 alarm.setRemark("演练模拟告警-" + category);
                 alarm.setCreateTime(now);

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

@@ -135,7 +135,7 @@ public class ManholeLayerServiceImpl implements ManholeLayerService {
                     .collect(Collectors.toList());
             List<AlarmData> alarmDataList = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
                     .in(AlarmData::getDeviceCode, equipmentCodes)
-                    .orderByDesc(AlarmData::getAlarmTime));
+                    .orderByDesc(AlarmData::getCreateTime));
             alarmDataMap = alarmDataList.stream()
                     .collect(Collectors.groupingBy(AlarmData::getDeviceCode));
         }

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

@@ -137,7 +137,7 @@ public class RiskAssessmentServiceImpl extends ServiceImpl<RiskAssessmentMapper,
         List<AlarmData> alarms = equipmentToNetwork.isEmpty() ? Collections.emptyList() :
                 alarmDataMapper.selectList(new LambdaQueryWrapper<AlarmData>()
                         .in(AlarmData::getDeviceCode, equipmentToNetwork.keySet())
-                        .ge(AlarmData::getAlarmTime, since));
+                        .ge(AlarmData::getCreateTime, since));
         Map<String, List<AlarmData>> alarmsByNetwork = new HashMap<>();
         for (AlarmData alarm : alarms) {
             String networkId = equipmentToNetwork.get(alarm.getDeviceCode());

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

@@ -384,7 +384,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                         .in(AlarmData::getDeviceCode, deviceCodes)
                         .eq(AlarmData::getAlarmStatus, 0)
                         .orderByAsc(AlarmData::getAlarmLevel)
-                        .orderByDesc(AlarmData::getAlarmTime)
+                        .orderByDesc(AlarmData::getCreateTime)
         );
         Map<String, AlarmData> result = new HashMap<>();
         for (AlarmData alarm : alarms) {
@@ -404,8 +404,8 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
             return true;
         }
         return Objects.equals(candidate.getAlarmLevel(), existing.getAlarmLevel())
-                && candidate.getAlarmTime() != null
-                && (existing.getAlarmTime() == null || candidate.getAlarmTime().isAfter(existing.getAlarmTime()));
+                && candidate.getCreateTime() != null
+                && (existing.getCreateTime() == null || candidate.getCreateTime().isAfter(existing.getCreateTime()));
     }
 
     private Map<String, WorkOrder> getLatestWorkOrderMap(List<EquipmentBase> devices) {
@@ -458,7 +458,7 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
         if (alarm != null) {
             vo.setAbnormalReason(StrUtil.isNotBlank(alarm.getWarningType())
                     ? alarm.getWarningType() : "设备报警");
-            vo.setAlarmTime(alarm.getAlarmTime());
+            vo.setCreateTime(alarm.getCreateTime());
         } else if (Objects.equals(vo.getCurrentStatus(), 3)) {
             vo.setAbnormalReason("设备维修中");
         } else {

+ 4 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyQueryService.java

@@ -109,10 +109,10 @@ public class WaterSupplyQueryService {
         }
         Map<String, WorkOrder> result = new LinkedHashMap<>();
         List<AlarmData> alarms = alarmDataMapper.selectList(new LambdaQueryWrapper<AlarmData>()
-                .select(AlarmData::getId, AlarmData::getOrderId)
+                .select(AlarmData::getId, AlarmData::getWorkOrderId)
                 .in(AlarmData::getId, ids)
-                .isNotNull(AlarmData::getOrderId));
-        Set<Long> orderIds = alarms.stream().map(AlarmData::getOrderId)
+                .isNotNull(AlarmData::getWorkOrderId));
+        Set<Long> orderIds = alarms.stream().map(AlarmData::getWorkOrderId)
                 .filter(java.util.Objects::nonNull).collect(Collectors.toSet());
         if (orderIds.isEmpty()) {
             return result;
@@ -120,7 +120,7 @@ public class WaterSupplyQueryService {
         Map<Long, WorkOrder> ordersById = workOrderMapper.selectBatchIds(orderIds).stream()
                 .collect(Collectors.toMap(WorkOrder::getOrderId, Function.identity(), (a, b) -> a));
         for (AlarmData alarm : alarms) {
-            WorkOrder order = ordersById.get(alarm.getOrderId());
+            WorkOrder order = ordersById.get(alarm.getWorkOrderId());
             if (order != null) {
                 result.putIfAbsent(alarm.getId(), order);
             }

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

@@ -70,9 +70,9 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
     public CockpitWorkOrderReadSnapshot loadByEvent(String eventSource, String eventCode) {
         if ("ALARM".equals(eventSource)) {
             AlarmData alarm = alarmDataService.getById(eventCode);
-            List<WorkOrder> orders = alarm == null || alarm.getOrderId() == null ? Collections.emptyList()
+            List<WorkOrder> orders = alarm == null || alarm.getWorkOrderId() == null ? Collections.emptyList()
                     : workOrderService.lambdaQuery()
-                    .eq(WorkOrder::getOrderId, alarm.getOrderId())
+                    .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
                     .orderByDesc(WorkOrder::getCreateTime)
                     .list();
             return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode,
@@ -143,7 +143,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
 
     private List<WorkOrder> findOrdersByAlarmIds(List<String> alarmIds) {
         List<Long> orderIds = alarmDataService.listByIds(alarmIds).stream()
-                .map(AlarmData::getOrderId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+                .map(AlarmData::getWorkOrderId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
         return orderIds.isEmpty() ? Collections.emptyList() : workOrderService.lambdaQuery()
                 .in(WorkOrder::getOrderId, orderIds).orderByDesc(WorkOrder::getCreateTime).list();
     }

+ 0 - 20
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/DrainageWarningService.java

@@ -105,26 +105,6 @@ public interface DrainageWarningService extends IService<DrainageWarning> {
                                                   String equipmentName, String startTime, String endTime,
                                                   String equipmentType);
 
-    /**
-     * 处理(指定人员处理)
-     * 更新 status=1, first_approver, first_approver_name, first_approve_time
-     *
-     * @param warningId 预警ID
-     * @param userId    处理人ID
-     * @param userName  处理人名称
-     */
-    void firstApprove(String warningId, Long userId, String userName);
-
-    /**
-     * 审批(管理员审批)
-     * 更新 status=2, final_approver, final_approver_name, final_approve_time
-     * 完成后进入历史预警
-     *
-     * @param warningId 预警ID
-     * @param userId    审批人ID(管理员)
-     * @param userName  审批人名称
-     */
-    void finalApprove(String warningId, Long userId, String userName);
 
     /**
      * 获取某预警的接收人员列表

+ 0 - 47
pipe-network-service/zksy-system/src/main/java/com/zksy/drainage/service/impl/DrainageWarningServiceImpl.java

@@ -248,54 +248,7 @@ public class DrainageWarningServiceImpl extends ServiceImpl<DrainageWarningMappe
         return result;
     }
 
-    @Override
-    @Transactional
-    public void firstApprove(String warningId, Long userId, String userName) {
-        DrainageWarning existing = this.getById(warningId);
-        if (existing == null) {
-            throw new ServiceException("预警信息不存在:" + warningId);
-        }
-        if (existing.getStatus() == null || !"PENDING".equals(existing.getStatus())) {
-            throw new ServiceException("预警状态非待办,无法处理");
-        }
-
-        DrainageWarning update = new DrainageWarning();
-        update.setId(warningId);
-        update.setStatus("PROCESSING");
-        update.setFirstApprover(userId);
-        update.setFirstApproverName(userName);
-        update.setFirstApproveTime(LocalDateTime.now());
-        this.updateById(update);
-
-        // 更新scope user的审批状态
-        WarningScopeUser scopeUpdate = new WarningScopeUser();
-        scopeUpdate.setApproved(1);
-        scopeUpdate.setApproveTime(LocalDateTime.now());
-        warningScopeUserMapper.update(scopeUpdate,
-                new LambdaQueryWrapper<WarningScopeUser>()
-                        .eq(WarningScopeUser::getWarningId, warningId)
-                        .eq(WarningScopeUser::getUserId, userId));
-    }
 
-    @Override
-    @Transactional
-    public void finalApprove(String warningId, Long userId, String userName) {
-        DrainageWarning existing = this.getById(warningId);
-        if (existing == null) {
-            throw new ServiceException("预警信息不存在:" + warningId);
-        }
-        if (existing.getStatus() == null || !"PROCESSING".equals(existing.getStatus())) {
-            throw new ServiceException("预警状态非处置中,无法审批");
-        }
-
-        DrainageWarning update = new DrainageWarning();
-        update.setId(warningId);
-        update.setStatus("RELEASED");
-        update.setFinalApprover(userId);
-        update.setFinalApproverName(userName);
-        update.setFinalApproveTime(LocalDateTime.now());
-        this.updateById(update);
-    }
 
     @Override
     public List<WarningScopeUser> findScopeUsers(String warningId) {

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/WorkOrderLog.java

@@ -26,7 +26,7 @@ public class WorkOrderLog implements Serializable {
     private Long logId;
 
     @ApiModelProperty(value = "工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     @ApiModelProperty(value = "操作类型:0-创建工单 1-派单 2-接单 3-处置 4-提交验收 5-验收通过 6-验收驳回 7-取消工单 8-申请延期 9-延期审核通过 10-延期审核驳回 (见WorkOrderLogOperTypeEnum)")
     private Integer operType;

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderLogVO.java

@@ -29,7 +29,7 @@ public class WorkOrderLogVO implements Serializable {
     private Long logId;
 
     @ApiModelProperty("工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     @ApiModelProperty("操作类型:0-创建工单 1-派单 2-接单 3-处置 4-提交验收 5-验收通过 6-验收驳回 7-取消工单 8-申请延期 9-延期审核通过 10-延期审核驳回")
     private Integer operType;

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/mapper/WorkOrderMapper.java

@@ -24,6 +24,11 @@ public interface WorkOrderMapper extends BaseMapper<WorkOrder> {
      */
     List<WorkOrder> selectPendingOrOverdue(@Param("now") Date now);
 
+    /** 按设备主键和报警编码查询未办结报警工单,不受当前用户部门范围限制。 */
+    boolean existsActiveAlarmOrder(@Param("deviceId") String deviceId,
+                                   @Param("warningCode") String warningCode,
+                                   @Param("activeStatuses") List<Integer> activeStatuses);
+
     /**
      * 按状态分组统计工单数量(状态筛选条使用)。
      *

+ 3 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java

@@ -80,7 +80,9 @@ public interface WorkOrderService extends IService<WorkOrder> {
      * 从报警记录创建工单(初始状态:待派单)。
      *
      * <p>同一报警记录重复调用时返回已有工单 ID,不重复建单;报警记录与工单的
-     * 关联在同一事务内完成;创建工单不代表报警已处理,不修改设备状态。</p>
+     * 关联在同一事务内完成;同设备 + 同 warning_code 只允许一个未办结报警工单,
+     * 通过设备行锁防止并发重复创建。普通手动/隐患建单不受此限制。
+     * 创建工单不代表报警已处理,不修改设备状态。</p>
      *
      * @param alarmId    报警记录 ID
      * @param orderLevel 工单优先级,传空时按报警等级映射

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

@@ -112,6 +112,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Autowired
     private WorkOrderLogMapper workOrderLogMapper;
 
+
     @Autowired
     private AlarmClearService alarmClearService;
 
@@ -225,7 +226,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         if (orderId == null) {
             return Collections.emptyList();
         }
-        List<WorkOrderLog> logs = workOrderLogMapper.selectList(new LambdaQueryWrapper<WorkOrderLog>().eq(WorkOrderLog::getOrderId, orderId).orderByDesc(WorkOrderLog::getOperTime));
+        List<WorkOrderLog> logs = workOrderLogMapper.selectList(new LambdaQueryWrapper<WorkOrderLog>().eq(WorkOrderLog::getWorkOrderId, orderId).orderByDesc(WorkOrderLog::getOperTime));
         // 展示字段(操作类型描述、附件列表)只回填到出参上,实体保持纯表映射
         List<WorkOrderLogVO> result = new ArrayList<>(logs.size());
         for (WorkOrderLog item : logs) {
@@ -270,8 +271,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         if (workOrder == null || StringUtils.isEmpty(workOrder.getOrderDesc())) {
             throw new ServiceException("故障描述不能为空");
         }
-        checkDeviceOrderConflict(workOrder.getDeviceId());
-
         Date now = new Date();
         workOrder.setOrderId(null);
         workOrder.setOrderNo(generateOrderNo());
@@ -307,13 +306,8 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         }
 
         // 报警转工单必须幂等:重复点击直接返回已有工单,不重复生成工单。
-        WorkOrder existing = alarm.getOrderId() == null ? null : getById(alarm.getOrderId());
-        if (existing != null) {
-            return existing.getOrderId();
-        }
-        if (alarm.getOrderId() != null) {
-            throw new ServiceException("报警关联的工单不存在,请先检查关联数据");
-        }
+        Long existingOrderId = linkedAlarmOrderId(alarm);
+        if (existingOrderId != null) return existingOrderId;
 
         EquipmentBase equipment = null;
         if (StringUtils.isNotEmpty(alarm.getDeviceCode())) {
@@ -325,7 +319,20 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         if (deviceId == null) {
             throw new ServiceException("报警设备未关联设备档案,无法创建工单");
         }
-        checkDeviceOrderConflict(deviceId);
+        // 锁设备而不是单条报警:同设备的不同报警记录也必须串行完成查重与建单。
+        // 不写设备状态,事务结束即释放;不同报警编码仍可分别创建工单。
+        if (equipmentBaseMapper.lockById(deviceId) == null) {
+            throw new ServiceException("报警设备档案已不存在,无法创建工单");
+        }
+        AlarmData currentAlarm = alarmDataService.getById(alarmId);
+        if (currentAlarm == null || !Objects.equals(currentAlarm.getDeviceCode(), alarm.getDeviceCode())) {
+            throw new ServiceException("报警信息已变更,请刷新后重试");
+        }
+        alarm = currentAlarm;
+        // 等锁期间同一报警可能已关联工单,须重新读取后再保证幂等。
+        existingOrderId = linkedAlarmOrderId(alarm);
+        if (existingOrderId != null) return existingOrderId;
+        checkAlarmOrderConflict(deviceId, alarm.getWarningCode());
 
         Date now = new Date();
         WorkOrder workOrder = new WorkOrder();
@@ -335,9 +342,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
                 ? mapAlarmLevelToOrderLevel(alarm.getAlarmLevel()).getCode()
                 : WorkOrderLevelEnum.getByCode(orderLevel).getCode());
         workOrder.setOrderDesc(StringUtils.isNotEmpty(orderDesc) ? orderDesc : buildAlarmOrderDesc(alarm));
-        workOrder.setFaultTime(alarm.getAlarmTime() == null
+        workOrder.setFaultTime(alarm.getCreateTime() == null
                 ? now
-                : java.sql.Timestamp.valueOf(alarm.getAlarmTime()));
+                : java.sql.Timestamp.valueOf(alarm.getCreateTime()));
         workOrder.setDeptId(assignScopeSupport.currentDeptId());
         workOrder.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
         workOrder.setDelayApplyStatus(WorkOrderDelayApplyStatusEnum.NONE.getCode());
@@ -347,18 +354,19 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
 
         // 关联工单不等于异常已经恢复,仅更新关联;不覆盖审核结论、报警处理状态或设备状态。
         AlarmData relation = new AlarmData();
-        relation.setOrderId(workOrder.getOrderId());
+        relation.setWorkOrderId(workOrder.getOrderId());
         relation.setUpdateTime(LocalDateTime.now());
         if (!alarmDataService.update(relation, new LambdaQueryWrapper<AlarmData>()
-                .eq(AlarmData::getId, alarmId).isNull(AlarmData::getOrderId))) {
+                .eq(AlarmData::getId, alarmId).isNull(AlarmData::getWorkOrderId))) {
             throw new ServiceException("报警已关联工单或已被删除,请刷新后重试");
         }
         // 保留报警生成时已有的井盖案件关联,案件按设备主键关联,不复制旧设备编码。
         caseInfoMapper.update(null, new LambdaUpdateWrapper<CaseInfo>()
-                .eq(CaseInfo::getAlarmId, alarmId).isNull(CaseInfo::getOrderId)
-                .set(CaseInfo::getOrderId, workOrder.getOrderId()));
+                .eq(CaseInfo::getAlarmId, alarmId).isNull(CaseInfo::getWorkOrderId)
+                .set(CaseInfo::getWorkOrderId, workOrder.getOrderId()));
         writeLog(workOrder.getOrderId(), WorkOrderLogOperTypeEnum.CREATE,
-                buildDesc("由报警[" + alarmId + "]创建工单", workOrder.getOrderDesc()), null);
+                buildDesc("由报警单[" + (StringUtils.isBlank(alarm.getAlarmNo()) ? "历史记录(未生成单号)" : alarm.getAlarmNo())
+                        + "]创建工单", workOrder.getOrderDesc()), null);
         return workOrder.getOrderId();
     }
 
@@ -369,17 +377,15 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         if (hazard == null) {
             throw new ServiceException("隐患信息不存在");
         }
-        if (hazard.getOrderId() != null) {
-            if (getById(hazard.getOrderId()) != null) {
-                return hazard.getOrderId();
+        if (hazard.getWorkOrderId() != null) {
+            if (getById(hazard.getWorkOrderId()) != null) {
+                return hazard.getWorkOrderId();
             }
             throw new ServiceException("隐患关联的工单不存在,请先检查关联数据");
         }
         if (StringUtils.isEmpty(equipmentId)) {
             throw new ServiceException("隐患关联设备不能为空");
         }
-        checkDeviceOrderConflict(equipmentId);
-
         Date now = new Date();
         WorkOrder workOrder = new WorkOrder();
         workOrder.setOrderNo(generateOrderNo());
@@ -398,9 +404,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         workOrder.setCreateTime(now);
         workOrder.setUpdateTime(now);
         this.save(workOrder);
-        hazard.setOrderId(workOrder.getOrderId());
+        hazard.setWorkOrderId(workOrder.getOrderId());
         if (hazardHiddenAccountMapper.update(hazard, new LambdaQueryWrapper<HazardHiddenAccount>()
-                .eq(HazardHiddenAccount::getHazardId, hazardId).isNull(HazardHiddenAccount::getOrderId)) != 1) {
+                .eq(HazardHiddenAccount::getHazardId, hazardId).isNull(HazardHiddenAccount::getWorkOrderId)) != 1) {
             throw new ServiceException("隐患关联工单失败");
         }
 
@@ -603,12 +609,12 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         }
         // 先清理该工单的操作日志,避免 work_order_log 残留脏数据
         alarmDataService.update(new LambdaUpdateWrapper<AlarmData>()
-                .eq(AlarmData::getOrderId, id).set(AlarmData::getOrderId, null));
+                .eq(AlarmData::getWorkOrderId, id).set(AlarmData::getWorkOrderId, null));
         caseInfoMapper.update(null, new LambdaUpdateWrapper<CaseInfo>()
-                .eq(CaseInfo::getOrderId, id).set(CaseInfo::getOrderId, null));
+                .eq(CaseInfo::getWorkOrderId, id).set(CaseInfo::getWorkOrderId, null));
         // 包含逻辑删除的隐患,不能遗留指向已删除工单的引用。
         hazardHiddenAccountMapper.clearOrderRelation(id);
-        workOrderLogMapper.delete(new LambdaQueryWrapper<WorkOrderLog>().eq(WorkOrderLog::getOrderId, id));
+        workOrderLogMapper.delete(new LambdaQueryWrapper<WorkOrderLog>().eq(WorkOrderLog::getWorkOrderId, id));
         if (!this.removeById(id)) {
             throw new ServiceException("工单删除失败,关联清理已回滚");
         }
@@ -804,16 +810,22 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return orderId;
     }
 
-    /**
-     * 校验同一设备是否已存在未办结工单
-     */
-    private void checkDeviceOrderConflict(String deviceId) {
-        if (StringUtils.isEmpty(deviceId)) {
-            return;
+    private Long linkedAlarmOrderId(AlarmData alarm) {
+        if (alarm.getWorkOrderId() == null) return null;
+        WorkOrder existing = getById(alarm.getWorkOrderId());
+        if (existing == null) {
+            throw new ServiceException("报警关联的工单不存在,请先检查关联数据");
         }
-        boolean exists = this.exists(new LambdaQueryWrapper<WorkOrder>().eq(WorkOrder::getDeviceId, deviceId).in(WorkOrder::getOrderStatus, WorkOrderStatusEnum.activeCodes()));
-        if (exists) {
-            throw new ServiceException("该设备已有处理中的工单,需结案或取消后才能创建新工单");
+        return existing.getOrderId();
+    }
+
+    /** 仅报警转工单查重,调用前必须取得设备事务锁。 */
+    private void checkAlarmOrderConflict(String deviceId, String warningCode) {
+        if (StringUtils.isBlank(warningCode)) {
+            throw new ServiceException("报警编码缺失,无法判断同类报警工单,请先完善报警数据");
+        }
+        if (baseMapper.existsActiveAlarmOrder(deviceId, warningCode, WorkOrderStatusEnum.activeCodes())) {
+            throw new ServiceException("该设备同一报警类型已有未完成的工单,需结案或取消后才能再次转工单");
         }
     }
 
@@ -822,14 +834,16 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
      */
     private void writeLog(Long orderId, WorkOrderLogOperTypeEnum operType, String operDesc, String attachUrl) {
         WorkOrderLog logEntry = new WorkOrderLog();
-        logEntry.setOrderId(orderId);
+        logEntry.setWorkOrderId(orderId);
         logEntry.setOperType(operType.getCode());
         logEntry.setOperDesc(operDesc);
         logEntry.setAttachUrl(attachUrl);
         logEntry.setOperUserId(currentUserId());
         logEntry.setOperUserName(currentUserName());
         logEntry.setOperTime(new Date());
-        workOrderLogMapper.insert(logEntry);
+        if (workOrderLogMapper.insert(logEntry) != 1) {
+            throw new ServiceException("工单日志写入失败");
+        }
     }
 
     /**
@@ -950,7 +964,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         }
         try {
             List<HazardHiddenAccount> hazards = hazardHiddenAccountMapper.selectList(new LambdaQueryWrapper<HazardHiddenAccount>()
-                    .eq(HazardHiddenAccount::getOrderId, order.getOrderId()));
+                    .eq(HazardHiddenAccount::getWorkOrderId, order.getOrderId()));
             for (HazardHiddenAccount hazard : hazards) {
                 HazardHiddenAccount update = new HazardHiddenAccount();
                 update.setHazardId(hazard.getHazardId());
@@ -1042,13 +1056,13 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             return;
         }
         Map<Long, List<CaseInfo>> cases = caseInfoMapper.selectList(new LambdaQueryWrapper<CaseInfo>()
-                .select(CaseInfo::getOrderId, CaseInfo::getCaseNo).in(CaseInfo::getOrderId, orderIds))
-                .stream().collect(Collectors.groupingBy(CaseInfo::getOrderId));
+                .select(CaseInfo::getWorkOrderId, CaseInfo::getCaseNo).in(CaseInfo::getWorkOrderId, orderIds))
+                .stream().collect(Collectors.groupingBy(CaseInfo::getWorkOrderId));
         Map<Long, List<HazardHiddenAccount>> hazards = hazardHiddenAccountMapper.selectList(
                 new LambdaQueryWrapper<HazardHiddenAccount>().select(
-                        HazardHiddenAccount::getOrderId, HazardHiddenAccount::getHazardNo)
-                        .in(HazardHiddenAccount::getOrderId, orderIds))
-                .stream().collect(Collectors.groupingBy(HazardHiddenAccount::getOrderId));
+                        HazardHiddenAccount::getWorkOrderId, HazardHiddenAccount::getHazardNo)
+                        .in(HazardHiddenAccount::getWorkOrderId, orderIds))
+                .stream().collect(Collectors.groupingBy(HazardHiddenAccount::getWorkOrderId));
         for (WorkOrderVO record : records) {
             record.setCaseNo(cases.getOrDefault(record.getOrderId(), Collections.emptyList()).stream()
                     .map(CaseInfo::getCaseNo).filter(StringUtils::isNotEmpty).distinct().sorted()

+ 2 - 2
pipe-network-service/zksy-system/src/main/resources/mapper/CaseInfoMapper.xml

@@ -7,7 +7,7 @@
     <resultMap id="BaseResultMap" type="com.zksy.base.domain.CaseInfo">
             <id property="caseId" column="case_id" jdbcType="BIGINT"/>
             <result property="caseNo" column="case_no" jdbcType="VARCHAR"/>
-            <result property="orderId" column="order_id" jdbcType="BIGINT"/>
+            <result property="workOrderId" column="work_order_id" jdbcType="BIGINT"/>
             <result property="alarmId" column="alarm_id" jdbcType="VARCHAR"/>
             <result property="equipmentId" column="equipment_id" jdbcType="VARCHAR"/>
             <result property="caseLevel" column="case_level" jdbcType="TINYINT"/>
@@ -25,7 +25,7 @@
     </resultMap>
 
     <sql id="Base_Column_List">
-        case_id,case_no,order_id,alarm_id,
+        case_id,case_no,work_order_id,alarm_id,
         equipment_id,case_level,
         case_desc,case_source,case_status,
         file_user,verify_user,verify_time,

+ 7 - 7
pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmDataMapper.xml

@@ -3,12 +3,12 @@
 <mapper namespace="com.zksy.base.alarm.mapper.AlarmDataMapper">
 
     <select id="selectAverageResponseMinutes" resultType="java.math.BigDecimal">
-        SELECT AVG((EXTRACT(EPOCH FROM l.oper_time) - EXTRACT(EPOCH FROM a.alarm_time)) / 60.0)
+        SELECT AVG((EXTRACT(EPOCH FROM l.oper_time) - EXTRACT(EPOCH FROM a.create_time)) / 60.0)
         FROM app_user.alarm_data a
         JOIN (SELECT alarm_id, MIN(oper_time) AS oper_time FROM app_user.alarm_log
               WHERE oper_type = 3 GROUP BY alarm_id) l ON l.alarm_id = a.id
         WHERE a.alarm_status = 1 AND l.oper_time &gt;= #{startTime}
-          AND l.oper_time &gt;= a.alarm_time
+          AND l.oper_time &gt;= a.create_time
           <choose>
               <when test="deviceCodes != null and deviceCodes.size() > 0">
                   AND a.device_code IN
@@ -41,25 +41,25 @@
                 AND a.alarm_status = #{alarmStatus}
             </if>
             <if test="startTime != null">
-                AND a.alarm_time &gt;= #{startTime}
+                AND a.create_time &gt;= #{startTime}
             </if>
             <if test="endTime != null">
-                AND a.alarm_time &lt;= #{endTime}
+                AND a.create_time &lt;= #{endTime}
             </if>
             AND NOT EXISTS (
                 SELECT 1
                 FROM alarm_data newer
                 WHERE newer.device_code = a.device_code
                   AND (
-                      COALESCE(newer.alarm_time, newer.create_time) &gt; COALESCE(a.alarm_time, a.create_time)
+                      COALESCE(newer.create_time, newer.create_time) &gt; COALESCE(a.create_time, a.create_time)
                       OR (
-                          COALESCE(newer.alarm_time, newer.create_time) = COALESCE(a.alarm_time, a.create_time)
+                          COALESCE(newer.create_time, newer.create_time) = COALESCE(a.create_time, a.create_time)
                           AND newer.id &gt; a.id
                       )
                   )
             )
         </where>
-        ORDER BY a.alarm_time DESC, a.create_time DESC, a.id DESC
+        ORDER BY a.create_time DESC, a.create_time DESC, a.id DESC
     </select>
 
 </mapper>

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

@@ -0,0 +1,89 @@
+<?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">
+    <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
+            ORDER BY b.equipment_id LIMIT 1) e ON true
+        LEFT JOIN app_user.work_order w ON w.order_id = a.work_order_id
+        LEFT JOIN app_user.sys_user u ON u.user_id = w.receive_user
+    </sql>
+    <sql id="columns">
+        a.*, e.equipment_id, e.equipment_name AS current_device_name, e.equipment_location,
+        e.equipment_type_id, e.equipment_image, e.longitude, e.latitude,
+        w.order_no, w.order_status, w.plan_finish_time, COALESCE(u.nick_name,u.user_name) AS receive_user_name,
+        (SELECT wt.warning_unit FROM app_user.equipment_warning_type wt
+            WHERE wt.equipment_type_id = e.equipment_type_id AND wt.warning_code = a.warning_code LIMIT 1) AS warning_unit
+    </sql>
+    <resultMap id="alarm" type="com.zksy.base.alarm.dto.AlarmVO" autoMapping="true">
+        <id property="id" column="id"/>
+        <result property="deviceName" column="current_device_name"/>
+    </resultMap>
+    <sql id="filters">
+        <include refid="com.zksy.base.alarm.mapper.AlarmReviewMapper.scope"/>
+        <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 e.equipment_location ILIKE CONCAT('%',#{query.keyword},'%'))
+        </if>
+        <if test="query.alarmNo != null and query.alarmNo != ''">AND a.alarm_no ILIKE CONCAT('%',#{query.alarmNo},'%')</if>
+        <if test="query.deviceCode != null and query.deviceCode != ''">AND a.device_code = #{query.deviceCode}</if>
+        <if test="query.alarmStatus != null">AND a.alarm_status = #{query.alarmStatus}</if>
+        <if test="query.actualValueOnly">AND a.actual_value IS NOT NULL</if>
+        <if test="query.auditStatus != null">AND a.audit_status = #{query.auditStatus}</if>
+        <if test="query.alarmLevel != null">AND a.alarm_level = #{query.alarmLevel}</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>
+    </sql>
+    <select id="selectPage" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="joins"/> <where><include refid="filters"/></where>
+        ORDER BY a.create_time <choose><when test="query.ascending">ASC</when><otherwise>DESC</otherwise></choose> NULLS LAST, a.id DESC
+    </select>
+    <select id="selectDetail" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="joins"/> WHERE a.id = #{id}
+        <include refid="com.zksy.base.alarm.mapper.AlarmReviewMapper.scope"/>
+    </select>
+    <select id="selectReminders" resultMap="alarm">
+        SELECT <include refid="columns"/>, l.oper_desc AS latest_operation,
+            COALESCE(l.oper_time, a.update_time, a.create_time) AS latest_operation_time
+        <include refid="joins"/>
+        LEFT JOIN LATERAL (SELECT oper_time, oper_desc FROM app_user.alarm_log
+            WHERE alarm_id = a.id ORDER BY oper_time DESC, log_id DESC LIMIT 1) l ON true
+        <where><include refid="filters"/></where>
+        ORDER BY latest_operation_time DESC NULLS LAST, a.id DESC
+    </select>
+    <select id="countByLevel" resultType="java.util.Map">
+        SELECT a.alarm_level AS level, COUNT(*) AS total <include refid="joins"/>
+        <where><include refid="filters"/></where> GROUP BY a.alarm_level
+    </select>
+    <select id="selectMap" resultMap="alarm">
+        SELECT <include refid="columns"/> <include refid="joins"/>
+        <where><include refid="filters"/></where> ORDER BY a.create_time DESC NULLS LAST, a.id DESC LIMIT 10001
+    </select>
+    <!-- source 来自服务端枚举,绝不能由请求直接指定表名/字段名。无效字符串不强转,不补零。 -->
+    <sql id="numericValue">
+        CASE WHEN trim(CAST(${source.column} AS text)) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$'
+            THEN trim(CAST(${source.column} AS text))::numeric ELSE NULL END
+    </sql>
+    <sql id="sampleDevice">
+        <choose><when test="source.nodeId != null">CAST(${source.deviceColumn} AS text) = #{deviceCode} AND node_id = #{source.nodeId}</when>
+            <otherwise>${source.deviceColumn} = #{deviceCode}</otherwise></choose>
+    </sql>
+    <select id="selectLatestSample" resultType="com.zksy.base.alarm.dto.AlarmSampleVO">
+        SELECT ${source.timeColumn} AS time, <include refid="numericValue"/> AS value
+        FROM app_user.${source.table} WHERE <include refid="sampleDevice"/>
+            AND ${source.timeColumn} &lt;= CURRENT_TIMESTAMP
+        ORDER BY ${source.timeColumn} DESC, id DESC LIMIT 1
+    </select>
+    <select id="selectSamples" resultType="com.zksy.base.alarm.dto.AlarmSampleVO">
+        WITH samples AS (
+            SELECT ${source.timeColumn} AS time, <include refid="numericValue"/> AS value
+            FROM app_user.${source.table}
+            WHERE <include refid="sampleDevice"/> AND ${source.timeColumn} &gt;= #{start} AND ${source.timeColumn} &lt; #{end}
+        )
+        SELECT <choose><when test="period == 'DAILY'">date_trunc('day',time)</when>
+            <when test="period == 'MONTHLY'">date_trunc('month',time)</when><otherwise>time</otherwise></choose> AS time,
+            AVG(value) AS value, COUNT(value) AS valid_count, COUNT(*) AS sample_count
+        FROM samples GROUP BY 1 ORDER BY 1 LIMIT 20001
+    </select>
+</mapper>

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

@@ -22,8 +22,8 @@
                     AND e.equipment_name ILIKE CONCAT('%', #{query.keyword}, '%')))
         </if>
         <if test="query.alarmLevel != null">AND a.alarm_level = #{query.alarmLevel}</if>
-        <if test="query.startTime != null">AND a.alarm_time &gt;= #{query.startTime}</if>
-        <if test="query.endTime != null">AND a.alarm_time &lt;= #{query.endTime}</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>
     </sql>
     <sql id="columns">
         a.*,
@@ -45,7 +45,7 @@
             <include refid="filters"/>
             <if test="query.auditStatus != null">AND a.audit_status = #{query.auditStatus}</if>
         </where>
-        ORDER BY a.alarm_time <choose><when test="query.ascending">ASC</when><otherwise>DESC</otherwise></choose> NULLS LAST, a.id DESC
+        ORDER BY a.create_time <choose><when test="query.ascending">ASC</when><otherwise>DESC</otherwise></choose> NULLS LAST, a.id DESC
     </select>
     <select id="selectDetail" resultMap="review">
         SELECT <include refid="columns"/> FROM app_user.alarm_data a WHERE a.id = #{id} <include refid="scope"/>

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

@@ -6,7 +6,7 @@
 
     <resultMap id="BaseResultMap" type="com.zksy.ops.domain.WorkOrderLog">
             <id property="logId" column="log_id" jdbcType="BIGINT"/>
-            <result property="orderId" column="order_id" jdbcType="BIGINT"/>
+            <result property="workOrderId" column="work_order_id" jdbcType="BIGINT"/>
             <result property="operType" column="oper_type" jdbcType="TINYINT"/>
             <result property="operDesc" column="oper_desc" jdbcType="VARCHAR"/>
             <result property="operUserId" column="oper_user_id" jdbcType="BIGINT"/>
@@ -15,7 +15,7 @@
     </resultMap>
 
     <sql id="Base_Column_List">
-        log_id,order_id,oper_type,
+        log_id,work_order_id,oper_type,
         oper_desc,oper_user_id,oper_user_name,
         oper_time
     </sql>

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

@@ -56,6 +56,21 @@
                  order_id DESC
     </select>
 
+    <!-- 仅报警来源工单占用同类名额;即使报警已恢复,仍以关联工单是否办结为准。 -->
+    <select id="existsActiveAlarmOrder" resultType="boolean" useCache="false" flushCache="true">
+        SELECT EXISTS (
+            SELECT 1
+            FROM app_user.work_order w
+            JOIN app_user.alarm_data a ON a.work_order_id = w.order_id
+            WHERE w.device_id = #{deviceId}
+              AND a.warning_code = #{warningCode}
+              AND w.order_status IN
+              <foreach collection="activeStatuses" item="status" open="(" separator="," close=")">
+                  #{status}
+              </foreach>
+        )
+    </select>
+
     <!-- 状态筛选条统计:复用列表筛选条件(不含状态筛选)按状态分组计数。
          条件由 wrapper 承载且不含 orderBy,避免 MySQL ONLY_FULL_GROUP_BY 下报错。 -->
     <select id="countByStatus" resultType="java.util.HashMap">

+ 2 - 2
pipe-network-service/zksy-system/src/main/resources/mapper/warning/AlarmWarningRelMapper.xml

@@ -15,11 +15,11 @@
             a.warning_code,
             a.actual_value,
             a.alarm_status,
-            a.alarm_time
+            a.create_time
         FROM alarm_warning_rel r
         LEFT JOIN alarm_data a ON r.alarm_id = a.id
         WHERE r.warning_id = #{warningId}
-        ORDER BY a.alarm_time DESC
+        ORDER BY a.create_time DESC
     </select>
 
 </mapper>

+ 0 - 228
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmDashboardServiceTest.java

@@ -1,228 +0,0 @@
-package com.zksy.WaterSupply.MonitorAlarm;
-
-import com.baomidou.mybatisplus.core.MybatisConfiguration;
-import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
-import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
-import com.zksy.WaterSupply.MonitorAlarm.dto.out.AlarmDashboardOutDTO;
-import com.zksy.WaterSupply.MonitorAlarm.service.impl.WaterSupplyAlarmServiceImpl;
-import com.zksy.base.service.impl.WaterSupplyQueryService;
-import com.zksy.base.alarm.domain.AlarmData;
-import com.zksy.base.alarm.mapper.AlarmDataMapper;
-import com.zksy.base.alarm.service.AlarmDataService;
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentStatus;
-import com.zksy.base.mapper.EquipmentBaseMapper;
-import com.zksy.base.mapper.EquipmentStatusMapper;
-import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.mapper.EquipmentTypeMapper;
-import com.zksy.base.service.EquipmentBaseService;
-import com.zksy.ops.domain.WorkOrder;
-import com.zksy.ops.mapper.WorkOrderMapper;
-import org.apache.ibatis.builder.MapperBuilderAssistant;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import java.math.BigDecimal;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-/**
- * 报警管理态势统计口径测试(T02 / Issue #45)。
- *
- * <p>被测公共 seam:{@link com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyAlarmService#getAlarmDashboard(int)}。
- * 期望值全部来自手算的独立样本,不重算实现逻辑。</p>
- */
-class WaterSupplyAlarmDashboardServiceTest {
-
-    @BeforeAll
-    static void initLambdaCache() {
-        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
-        TableInfoHelper.initTableInfo(assistant, AlarmData.class);
-        TableInfoHelper.initTableInfo(assistant, EquipmentBase.class);
-        TableInfoHelper.initTableInfo(assistant, EquipmentStatus.class);
-        TableInfoHelper.initTableInfo(assistant, WorkOrder.class);
-        TableInfoHelper.initTableInfo(assistant, EquipmentType.class);
-    }
-
-    private WaterSupplyAlarmServiceImpl service;
-    private AlarmDataService alarmDataService;
-    private AlarmDataMapper alarmDataMapper;
-    private EquipmentBaseService equipmentBaseService;
-    private EquipmentBaseMapper equipmentBaseMapper;
-    private EquipmentStatusMapper equipmentStatusMapper;
-    private EquipmentTypeMapper equipmentTypeMapper;
-    private WorkOrderMapper workOrderMapper;
-
-    @BeforeEach
-    void setUp() {
-        service = new WaterSupplyAlarmServiceImpl();
-        alarmDataService = mock(AlarmDataService.class);
-        alarmDataMapper = mock(AlarmDataMapper.class);
-        equipmentBaseService = mock(EquipmentBaseService.class);
-        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
-        equipmentStatusMapper = mock(EquipmentStatusMapper.class);
-        equipmentTypeMapper = mock(EquipmentTypeMapper.class);
-        workOrderMapper = mock(WorkOrderMapper.class);
-        ReflectionTestUtils.setField(service, "alarmDataService", alarmDataService);
-        ReflectionTestUtils.setField(service, "waterSupplyQueryService",
-                new WaterSupplyQueryService(equipmentBaseService, equipmentBaseMapper,
-                        equipmentStatusMapper, equipmentTypeMapper, workOrderMapper,
-                        alarmDataMapper));
-        ReflectionTestUtils.setField(service, "equipmentBaseMapper", equipmentBaseMapper);
-        ReflectionTestUtils.setField(service, "equipmentTypeMapper", equipmentTypeMapper);
-        ReflectionTestUtils.setField(service, "workOrderMapper", workOrderMapper);
-
-        EquipmentBase pressure = device("EQ-P-1", "CN-GS-202609020001", "1");
-        EquipmentBase flow = device("EQ-F-1", "CN-GS-202609020002", "2");
-        when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Arrays.asList(pressure, flow));
-        when(equipmentBaseMapper.selectList(any())).thenReturn(Arrays.asList(pressure, flow));
-        when(equipmentTypeMapper.selectBatchIds(any())).thenReturn(Arrays.asList(
-                type(1, "压力监测"), type(2, "流量监测")));
-    }
-
-    /** 设备类型ID 现为整型;非数字按 null 处理。 */
-    private static Integer parseTypeId(String value) {
-        if (value == null) {
-            return null;
-        }
-        try {
-            return Integer.valueOf(value);
-        } catch (NumberFormatException e) {
-            return null;
-        }
-    }
-
-    private static EquipmentBase device(String id, String code, String typeId) {
-        EquipmentBase device = new EquipmentBase();
-        device.setEquipmentId(id);
-        device.setEquipmentCode(code);
-        device.setEquipmentName("监测点");
-        device.setEquipmentTypeId(parseTypeId(typeId));
-        device.setLongitude(new BigDecimal("113.28"));
-        device.setLatitude(new BigDecimal("23.12"));
-        return device;
-    }
-
-    private static EquipmentType type(Integer id, String name) {
-        EquipmentType type = new EquipmentType();
-        type.setId(id);
-        type.setTypeName(name);
-        return type;
-    }
-
-    private static AlarmData alarm(String id, String deviceCode, String warningType, Integer alarmStatus,
-                                    Integer alarmLevel, LocalDateTime alarmTime) {
-        AlarmData alarm = new AlarmData();
-        alarm.setId(id);
-        alarm.setDeviceCode(deviceCode);
-        alarm.setWarningType(warningType);
-        alarm.setAlarmStatus(alarmStatus);
-        alarm.setAlarmLevel(alarmLevel);
-        alarm.setAlarmTime(alarmTime);
-        return alarm;
-    }
-
-    @Test
-    void dashboardCountsDisplayStatusTrendAndRatios() {
-        LocalDateTime today = LocalDate.now().atTime(9, 0);
-        List<AlarmData> alarms = new ArrayList<>(Arrays.asList(
-                // 今日:压力预警 1 级未审核(待审核)+ 同设备已转工单
-                alarm("a1", "CN-GS-202609020001", "压力预警", 0, 1, today),
-                alarm("a2", "CN-GS-202609020001", "压力预警", 1, 2, today.plusHours(1)),
-                // 昨日:流量预警 3 级,误报解除(已解除)
-                alarm("a3", "CN-GS-202609020002", "流量预警", 1, 3, today.minusDays(1))));
-        when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(alarms);
-
-        alarms.get(1).setOrderId(202L);
-        when(alarmDataMapper.selectList(any())).thenReturn(Collections.singletonList(alarms.get(1)));
-        WorkOrder order = new WorkOrder();
-        order.setOrderId(202L);
-        order.setOrderNo("WO-T02-002");
-        order.setOrderStatus(3);
-        when(workOrderMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(order));
-
-        alarms.get(2).setAuditStatus(com.zksy.base.alarm.enums.AlarmAuditStatusEnum.FALSE_ALARM.getCode());
-
-        when(equipmentStatusMapper.selectList(any())).thenReturn(Collections.singletonList(
-                status("EQ-P-1", 1, 1, 1)));
-
-        AlarmDashboardOutDTO dashboard = service.getAlarmDashboard(7);
-
-        // 手算期望:总量 3;待审核 a1;已转工单 a2;已解除 a3
-        assertEquals(3, dashboard.getTotalCount());
-        assertEquals(1, dashboard.getPendingAuditCount());
-        assertEquals(1, dashboard.getTransferredCount());
-        assertEquals(1, dashboard.getReleasedCount());
-        assertEquals(1, dashboard.getAlarmingDeviceCount());
-
-        // 趋势:按天覆盖 7 天(含无报警日),且只按四个报警等级分别给序列,
-        // 不再输出“报警数 / 高等级报警”两个汇总口径(#45 验收反馈)
-        assertEquals(7, dashboard.getTrend().size());
-        String todayLabel = LocalDate.now().toString();
-        String yesterdayLabel = LocalDate.now().minusDays(1).toString();
-        assertEquals(1, levelCountOn(dashboard, todayLabel, 1));
-        assertEquals(1, levelCountOn(dashboard, todayLabel, 2));
-        assertEquals(0, levelCountOn(dashboard, todayLabel, 3));
-        assertEquals(0, levelCountOn(dashboard, todayLabel, 4));
-        assertEquals(1, levelCountOn(dashboard, yesterdayLabel, 3));
-
-        // 报警分类占比:压力预警 2 条,流量预警 1 条
-        assertEquals(2, ratioOf(dashboard.getWarningTypeRatio(), "压力预警"));
-        assertEquals(1, ratioOf(dashboard.getWarningTypeRatio(), "流量预警"));
-        // 设备分类占比来自报警设备实际类型:压力监测 2 条,流量监测 1 条
-        assertEquals(2, ratioOf(dashboard.getDeviceTypeRatio(), "压力监测"));
-        assertEquals(1, ratioOf(dashboard.getDeviceTypeRatio(), "流量监测"));
-        // 报警等级占比:1 级 1 条、2 级 1 条、3 级 1 条
-        assertEquals(1, ratioOf(dashboard.getAlarmLevelRatio(), "一级报警"));
-        assertEquals(1, ratioOf(dashboard.getAlarmLevelRatio(), "二级报警"));
-        assertEquals(1, ratioOf(dashboard.getAlarmLevelRatio(), "三级报警"));
-        assertTrue(dashboard.getTrend().stream().allMatch(item -> item.getDate() != null),
-                "趋势项应带日期");
-    }
-
-    private static EquipmentStatus status(String equipmentId, Integer alarmStatus, Integer currentStatus,
-                                          Integer onlineStatus) {
-        EquipmentStatus status = new EquipmentStatus();
-        status.setEquipmentId(equipmentId);
-        status.setAlarmStatus(alarmStatus);
-        status.setCurrentStatus(currentStatus);
-        status.setOnlineStatus(onlineStatus);
-        return status;
-    }
-
-    /** 读取指定日期、指定报警等级的趋势计数 */
-    private static long levelCountOn(AlarmDashboardOutDTO dashboard, String date, int level) {
-        return dashboard.getTrend().stream()
-                .filter(item -> date.equals(item.getDate()))
-                .mapToLong(item -> {
-                    switch (level) {
-                        case 1:
-                            return item.getLevel1Count();
-                        case 2:
-                            return item.getLevel2Count();
-                        case 3:
-                            return item.getLevel3Count();
-                        default:
-                            return item.getLevel4Count();
-                    }
-                })
-                .sum();
-    }
-
-    private static long ratioOf(List<AlarmDashboardOutDTO.RatioItem> items, String name) {
-        return items.stream().filter(item -> name.equals(item.getName()))
-                .mapToLong(AlarmDashboardOutDTO.RatioItem::getCount).sum();
-    }
-}

+ 2 - 3
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmLedgerServiceTest.java

@@ -111,7 +111,6 @@ class WaterSupplyAlarmLedgerServiceTest {
         alarm.setWarningCode("WARN-" + warningType);
         alarm.setAlarmStatus(alarmStatus);
         alarm.setAlarmLevel(alarmLevel);
-        alarm.setAlarmTime(LocalDateTime.of(2026, 9, 14, 10, 0));
         return alarm;
     }
 
@@ -127,7 +126,7 @@ class WaterSupplyAlarmLedgerServiceTest {
     @Test
     void alarmDetailLoadsItsDeviceWithoutAnUnboundedEquipmentRead() {
         AlarmData linkedAlarm = alarm("alarm-1", "CN-GS-202609020001", "压力预警", 1, 1);
-        linkedAlarm.setOrderId(501L);
+        linkedAlarm.setWorkOrderId(501L);
         when(alarmDataMapper.selectList(any())).thenReturn(Collections.singletonList(linkedAlarm));
         when(alarmDataService.getById("alarm-1")).thenReturn(linkedAlarm);
         // 数据访问边界拒绝无条件设备扫描,详情应只读取本条报警涉及的设备。
@@ -168,7 +167,7 @@ class WaterSupplyAlarmLedgerServiceTest {
     void alarmWithLiveWorkOrderShowsTransferred() {
         stubLedger(alarm("alarm-1", "CN-GS-202609020001", "压力预警", 1, 1));
         AlarmData linkedAlarm = alarm("alarm-1", "CN-GS-202609020001", "压力过低", 0, 1);
-        linkedAlarm.setOrderId(201L);
+        linkedAlarm.setWorkOrderId(201L);
         when(alarmDataMapper.selectList(any())).thenReturn(Collections.singletonList(linkedAlarm));
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Collections.singletonList(linkedAlarm));
         WorkOrder order = new WorkOrder();

+ 2 - 3
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyAlarmRealtimeContractTest.java

@@ -106,7 +106,6 @@ class WaterSupplyAlarmRealtimeContractTest {
         alarm.setWarningCode("WARN-" + warningType);
         alarm.setAlarmStatus(alarmStatus);
         alarm.setAlarmLevel(alarmLevel);
-        alarm.setAlarmTime(LocalDateTime.of(2026, 9, 14, 10, 0));
         return alarm;
     }
 
@@ -123,7 +122,7 @@ class WaterSupplyAlarmRealtimeContractTest {
     @Test
     void realtimeAlarmWithLiveWorkOrderIsEnrichedAsTransferred() {
         AlarmData linkedAlarm = alarm("alarm-1", "CN-GS-202609020001", "压力过低", 0, 1);
-        linkedAlarm.setOrderId(101L);
+        linkedAlarm.setWorkOrderId(101L);
         when(alarmDataMapper.selectList(any())).thenReturn(Collections.singletonList(linkedAlarm));
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Collections.singletonList(linkedAlarm));
         WorkOrder order = new WorkOrder();
@@ -172,7 +171,7 @@ class WaterSupplyAlarmRealtimeContractTest {
             }
             return Collections.singletonList(transferred);
         });
-        transferred.setOrderId(103L);
+        transferred.setWorkOrderId(103L);
         when(alarmDataMapper.selectList(any())).thenReturn(Collections.singletonList(transferred));
         WorkOrder order = new WorkOrder();
         order.setOrderId(103L);

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

@@ -0,0 +1,30 @@
+package com.zksy.base.alarm.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zksy.base.alarm.enums.AlarmLogOperTypeEnum;
+import org.junit.jupiter.api.Test;
+import java.nio.file.*;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class AlarmLogHistoryMapperTest {
+    @Test void exportsNicknameAndDescendingHistoryQuery() throws Exception {
+        MybatisConfiguration config=new MybatisConfiguration(); config.addMapper(AlarmLogMapper.class);
+        var bound=config.getMappedStatement(AlarmLogMapper.class.getName()+".selectHistory").getBoundSql(new HashMap<>());
+        assertTrue(bound.getSql().contains("u.nick_name"));
+        assertTrue(bound.getSql().contains("ORDER BY l.oper_time DESC NULLS LAST,l.log_id DESC"));
+        Files.createDirectories(Paths.get("target"));
+        new ObjectMapper().writeValue(Paths.get("target/alarm-log-history-query.json").toFile(),Map.of("sql",bound.getSql()));
+    }
+    @Test void enumContainsOnlyTheFourAlarmOperations() {
+        assertArrayEquals(new int[]{0,1,2,3},Arrays.stream(AlarmLogOperTypeEnum.values()).mapToInt(AlarmLogOperTypeEnum::getCode).toArray());
+        assertArrayEquals(new String[]{"报警触发", "确认报警", "误报警", "报警处理"},
+                Arrays.stream(AlarmLogOperTypeEnum.values()).map(AlarmLogOperTypeEnum::getLabel).toArray(String[]::new));
+        for (AlarmLogOperTypeEnum type : AlarmLogOperTypeEnum.values()) {
+            assertSame(type, AlarmLogOperTypeEnum.fromCode(type.getCode()));
+        }
+        assertNull(AlarmLogOperTypeEnum.fromCode(null));
+        assertNull(AlarmLogOperTypeEnum.fromCode(4)); assertNull(AlarmLogOperTypeEnum.fromCode(5));
+    }
+}

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

@@ -0,0 +1,60 @@
+package com.zksy.base.alarm.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zksy.base.alarm.dto.AlarmQuery;
+import com.zksy.base.alarm.enums.AlarmMetricSource;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.junit.jupiter.api.Test;
+import java.io.InputStream;
+import java.nio.file.*;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class AlarmOperationsMapperTest {
+    @Test void queriesEnforceEquipmentTreeScopeAndAggregateRawSamples() throws Exception {
+        MybatisConfiguration config = new MybatisConfiguration();
+        for (String name : Arrays.asList("AlarmReviewMapper", "AlarmOperationsMapper")) {
+            try (InputStream xml = getClass().getResourceAsStream("/mapper/base/" + name + ".xml")) {
+                assertNotNull(xml); new XMLMapperBuilder(xml,config,name,config.getSqlFragments()).parse();
+            }
+        }
+        AlarmQuery query = new AlarmQuery(); query.setRootTypeId(2);
+        Map<String,Object> params = new HashMap<>(); params.put("query",query); params.put("typeIds",Arrays.asList(2,21));
+        Map<String,String> rendered = new LinkedHashMap<>();
+        for (String name : Arrays.asList("selectPage","selectDetail","selectReminders","countByLevel","selectMap")) {
+            query.setAlarmStatus("selectReminders".equals(name) ? 0 : null);
+            String sql = sql(config,name,params);
+            assertTrue(sql.contains("e.equipment_type_id IN"));
+            assertEquals("selectReminders".equals(name),sql.contains("a.alarm_status ="));
+            assertFalse(sql.contains("a.actual_value IS NOT NULL"));
+            assertTrue(sql.contains("w.order_id = a.work_order_id")); rendered.put(name,sql);
+        }
+        query.setActualValueOnly(true);
+        String numeric = sql(config,"selectPage",params);
+        assertTrue(numeric.contains("a.actual_value IS NOT NULL"));
+        assertFalse(numeric.contains("a.alarm_status ="));
+        rendered.put("selectNumericPage",numeric);
+        query.setActualValueOnly(false);
+        query.setWarningCode("WARN_INSTANT_FLOW"); query.setAlarmStatus(0);
+        assertTrue(sql(config,"selectPage",params).contains("a.warning_code = ?"));
+        assertTrue(sql(config,"selectPage",params).contains("a.alarm_status = ?"));
+        params.put("typeIds",Collections.emptyList()); assertTrue(sql(config,"selectPage",params).contains("1 = 0"));
+        params.put("source",AlarmMetricSource.INSTANT_FLOW); params.put("period","DAILY");
+        String daily = sql(config,"selectSamples",params);
+        assertTrue(daily.contains("AVG(value)")); assertTrue(daily.contains("COUNT(value) AS valid_count"));
+        assertTrue(daily.contains("ELSE NULL")); assertTrue(daily.contains("date_trunc('day',time)"));
+        rendered.put("flowDaily",daily);
+        params.put("source",AlarmMetricSource.COD); params.put("period","MONTHLY");
+        String monthly = sql(config,"selectSamples",params);
+        assertTrue(monthly.contains("CAST(device_id AS text) = ? AND node_id = ?"));
+        assertTrue(monthly.contains("CAST(hum AS text)")); rendered.put("codMonthly",monthly);
+        rendered.put("codLatest",sql(config,"selectLatestSample",params));
+        // SQL兼容性测试直接执行MyBatis真实生成的查询,避免手写副本掩盖字段或语法错误。
+        Path artifact = Paths.get("target/alarm-operations-queries.json");
+        Files.createDirectories(artifact.getParent()); new ObjectMapper().writeValue(artifact.toFile(),rendered);
+    }
+    private String sql(MybatisConfiguration config,String name,Map<String,Object> params) {
+        return config.getMappedStatement(AlarmOperationsMapper.class.getName()+"."+name).getBoundSql(params).getSql().replaceAll("\\s+"," ").trim();
+    }
+}

+ 4 - 4
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmClearServiceTest.java

@@ -140,7 +140,7 @@ class AlarmClearServiceTest {
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Collections.singletonList(
                 alarm("alarm-1", "压力过低", 1)));
         AlarmData linked = alarm("alarm-1", "压力过低", 1);
-        linked.setOrderId(101L);
+        linked.setWorkOrderId(101L);
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Collections.singletonList(linked));
         WorkOrder live = new WorkOrder();
         live.setOrderId(101L);
@@ -162,8 +162,8 @@ class AlarmClearServiceTest {
         // 两条报警各有一张 5-已结案工单:维修完成是闭环证据
         AlarmData alarmA = alarm("alarm-1", "压力过低", 1);
         AlarmData alarmB = alarm("alarm-2", "流量异常", 1);
-        alarmA.setOrderId(201L);
-        alarmB.setOrderId(202L);
+        alarmA.setWorkOrderId(201L);
+        alarmB.setWorkOrderId(202L);
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Arrays.asList(alarmA, alarmB));
         WorkOrder closedA = new WorkOrder();
         closedA.setOrderId(201L);
@@ -190,7 +190,7 @@ class AlarmClearServiceTest {
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Collections.singletonList(
                 alarm("alarm-1", "压力过低", 1)));
         AlarmData linked = alarm("alarm-1", "压力过低", 1);
-        linked.setOrderId(301L);
+        linked.setWorkOrderId(301L);
         when(alarmDataService.list(any(AbstractWrapper.class))).thenReturn(Collections.singletonList(linked));
         WorkOrder cancelled = new WorkOrder();
         cancelled.setOrderId(301L);

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

@@ -0,0 +1,114 @@
+package com.zksy.base.alarm.service;
+
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.mapper.AlarmDataMapper;
+import com.zksy.base.alarm.mapper.AlarmLogMapper;
+import com.zksy.base.alarm.service.impl.AlarmDataServiceImpl;
+import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.system.mapper.SysUserMapper;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import java.util.Collections;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.aop.framework.ProxyFactory;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+import org.springframework.transaction.support.SimpleTransactionStatus;
+import java.math.BigDecimal;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class AlarmLifecycleServiceTest {
+    private final AlarmDataMapper mapper=mock(AlarmDataMapper.class);
+    private final AlarmLogMapper logs=mock(AlarmLogMapper.class);
+    private final PlatformTransactionManager manager=mock(PlatformTransactionManager.class);
+    private final SysUserMapper users=mock(SysUserMapper.class);
+    private AlarmDataService service;
+    @BeforeEach void setup() {
+        AlarmDataServiceImpl target=new AlarmDataServiceImpl();
+        ReflectionTestUtils.setField(target,"baseMapper",mapper);
+        ReflectionTestUtils.setField(target,"alarmLogMapper",logs);
+        ReflectionTestUtils.setField(target,"sysUserMapper",users);
+        when(manager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
+        ProxyFactory factory=new ProxyFactory(target);
+        factory.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));
+        service=(AlarmDataService)factory.getProxy();
+        when(mapper.lockAlarmNumberGeneration()).thenReturn(1);
+        when(mapper.insert(any(AlarmData.class))).thenAnswer(call -> {
+            ((AlarmData)call.getArgument(0)).setId("a1"); return 1;
+        });
+        when(mapper.updateById(any(AlarmData.class))).thenReturn(1);
+        when(logs.insertLifecycleIfAbsent(anyString(),anyInt(),anyString(),anyString(),any(),anyLong(),nullable(Long.class))).thenReturn(1);
+    }
+    @Test void insertWritesLogBeforeCommittingWithoutTrigger() {
+        AlarmData alarm=new AlarmData(); alarm.setRemark("压力过低"); alarm.setActualValue(BigDecimal.ZERO);
+        assertTrue(service.saveAlarmData(alarm));
+        var ordered=inOrder(mapper,logs,manager);
+        ordered.verify(mapper).insert(alarm);
+        ordered.verify(logs).insertLifecycleIfAbsent(eq("a1"),eq(0),eq("系统触发报警;报警单号:"+alarm.getAlarmNo()+";报警描述:压力过低;实际值:0"),eq("监测系统"),any(),anyLong(),nullable(Long.class));
+        ordered.verify(manager).commit(any());
+    }
+    @Test void inheritedSaveAlsoInitializesLog() {
+        assertTrue(service.save(new AlarmData()));
+        verify(logs).insertLifecycleIfAbsent(eq("a1"),eq(0),anyString(),eq("监测系统"),any(),anyLong(),nullable(Long.class));
+    }
+    @Test void logFailureRollsBackInsert() {
+        when(logs.insertLifecycleIfAbsent(anyString(),anyInt(),anyString(),anyString(),any(),anyLong(),nullable(Long.class))).thenReturn(0);
+        assertThrows(IllegalStateException.class,()->service.saveAlarmData(new AlarmData()));
+        verify(manager).rollback(any()); verify(manager,never()).commit(any());
+    }
+    @Test void manualHandlingAlsoWritesLog() {
+        when(mapper.selectForLifecycleUpdate("a1")).thenReturn(alarm(0));
+        assertTrue(service.updateById(alarm(1)));
+        verify(logs).insertLifecycleIfAbsent(eq("a1"),eq(3),eq("报警已处理"),eq("系统"),any(),anyLong(),nullable(Long.class));
+        verify(manager).commit(any());
+    }
+    @Test void duplicateHandlingDoesNotRepeatLog() {
+        when(mapper.selectForLifecycleUpdate("a1")).thenReturn(alarm(1));
+        assertTrue(service.updateById(alarm(1))); verifyNoInteractions(logs);
+    }
+    @Test void missingAlarmDoesNotCreateOrphanLog() {
+        assertFalse(service.updateById(alarm(1))); verifyNoInteractions(logs);
+    }
+    @Test void handlingLogFailureRollsBackStatusChange() {
+        when(mapper.selectForLifecycleUpdate("a1")).thenReturn(alarm(0));
+        when(logs.insertLifecycleIfAbsent(anyString(),eq(3),anyString(),anyString(),any(),anyLong(),nullable(Long.class))).thenThrow(new IllegalStateException("log failed"));
+        assertThrows(IllegalStateException.class,()->service.updateById(alarm(1)));
+        verify(manager).rollback(any()); verify(manager,never()).commit(any());
+    }
+    @Test void existingEventLogIsNotDuplicated() {
+        when(logs.insertLifecycleIfAbsent(anyString(),eq(0),anyString(),anyString(),any(),anyLong(),nullable(Long.class))).thenReturn(0);
+        when(logs.existsLifecycle("a1",0,0L)).thenReturn(true);
+        assertTrue(service.saveAlarmData(new AlarmData())); verify(manager).commit(any());
+    }
+    private AlarmData alarm(int status) {
+        AlarmData alarm=new AlarmData(); alarm.setId("a1"); alarm.setAlarmStatus(status); return alarm;
+    }
+    @Test void insertWithOrderDoesNotLogLinkage() {
+        AlarmData alarm=new AlarmData(); alarm.setWorkOrderId(77L);
+        assertTrue(service.saveAlarmData(alarm));
+        verify(logs).insertLifecycleIfAbsent(eq("a1"),eq(0),anyString(),eq("监测系统"),any(),eq(0L),nullable(Long.class));
+        verifyNoMoreInteractions(logs);
+    }
+    @Test void ordinaryOrderUpdateDoesNotProduceAlarmLog() {
+        when(mapper.selectForLifecycleUpdate("a1")).thenReturn(alarm(0));
+        AlarmData update=new AlarmData(); update.setId("a1"); update.setWorkOrderId(77L);
+        assertTrue(service.updateById(update));
+        verifyNoInteractions(logs);
+    }
+    @Test void humanHandlingUsesNicknameFromUserTable() {
+        SysUser user=new SysUser(); user.setUserName("account"); user.setNickName("操作人昵称");
+        when(users.selectUserById(9L)).thenReturn(user);
+        LoginUser principal=new LoginUser(9L,1L,user,Collections.emptySet());
+        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(principal,null));
+        try {
+            when(mapper.selectForLifecycleUpdate("a1")).thenReturn(alarm(0));
+            assertTrue(service.updateById(alarm(1)));
+            verify(logs).insertLifecycleIfAbsent(eq("a1"),eq(3),anyString(),eq("操作人昵称"),any(),anyLong(),eq(9L));
+        } finally { SecurityContextHolder.clearContext(); }
+    }
+}

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

@@ -0,0 +1,57 @@
+package com.zksy.base.alarm.service;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.mapper.AlarmDataMapper;
+import com.zksy.base.alarm.mapper.AlarmLogMapper;
+import com.zksy.base.alarm.service.impl.AlarmDataServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class AlarmNumberGenerationTest {
+    private final AlarmDataServiceImpl service = new AlarmDataServiceImpl();
+    private final AlarmDataMapper mapper = mock(AlarmDataMapper.class);
+    private final AlarmLogMapper logs = mock(AlarmLogMapper.class);
+    @BeforeEach void setup() {
+        ReflectionTestUtils.setField(service,"baseMapper",mapper);
+        ReflectionTestUtils.setField(service,"alarmLogMapper",logs);
+        when(mapper.lockAlarmNumberGeneration()).thenReturn(1);
+        when(mapper.insert(any(AlarmData.class))).thenAnswer(call -> {
+            ((AlarmData)call.getArgument(0)).setId("new-alarm"); return 1;
+        });
+        when(logs.insertLifecycleIfAbsent(anyString(),anyInt(),anyString(),anyString(),any(),anyLong(),nullable(Long.class))).thenReturn(1);
+    }
+    @Test void newAlarmUsesBeijingSecondAndFourRandomDigits() {
+        AlarmData alarm = new AlarmData(); alarm.setAlarmNo("caller-number");
+        LocalDateTime before=LocalDateTime.now(ZoneId.of("Asia/Shanghai")).withNano(0);
+        assertTrue(service.saveAlarmData(alarm));
+        assertTrue(alarm.getAlarmNo().matches("BJ[0-9]{18}"));
+        LocalDateTime timestamp=LocalDateTime.parse(alarm.getAlarmNo().substring(2,16),DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
+        assertFalse(timestamp.isBefore(before)); assertFalse(timestamp.isAfter(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))));
+        assertNotNull(alarm.getCreateTime()); assertEquals(0,alarm.getAlarmStatus());
+    }
+    @Test void collisionRetriesUnderSharedTransactionLockBeforeInsert() {
+        when(mapper.existsAlarmNo(anyString())).thenReturn(true,false);
+        assertTrue(service.saveAlarmData(new AlarmData()));
+        var ordered=inOrder(mapper);
+        ordered.verify(mapper).lockAlarmNumberGeneration();
+        ordered.verify(mapper,times(2)).existsAlarmNo(anyString());
+        ordered.verify(mapper).insert(any(AlarmData.class));
+    }
+    @Test void exhaustedCollisionsDoNotInsert() {
+        when(mapper.existsAlarmNo(anyString())).thenReturn(true);
+        assertThrows(IllegalStateException.class,()->service.saveAlarmData(new AlarmData()));
+        verify(mapper,never()).insert(any(AlarmData.class)); verifyNoInteractions(logs);
+    }
+    @Test void mappingIncludesNumberInInsertButExcludesItFromUpdates() throws Exception {
+        TableField field = AlarmData.class.getDeclaredField("alarmNo").getAnnotation(TableField.class);
+        assertEquals(FieldStrategy.NOT_NULL,field.insertStrategy()); assertEquals(FieldStrategy.NEVER,field.updateStrategy());
+    }
+}

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

@@ -0,0 +1,109 @@
+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.enums.*;
+import com.zksy.base.alarm.mapper.*;
+import com.zksy.common.exception.ServiceException;
+import org.junit.jupiter.api.*;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class AlarmOperationsServiceTest {
+    private final AlarmOperationsMapper mapper = mock(AlarmOperationsMapper.class);
+    private final AlarmReviewScopeService scope = mock(AlarmReviewScopeService.class);
+    private final AlarmLogMapper logs = mock(AlarmLogMapper.class);
+    private final AlarmOperationsService service = new AlarmOperationsService(mapper, scope, logs);
+    private final AlarmQuery query = new AlarmQuery();
+    private final AlarmVO alarm = new AlarmVO();
+    @BeforeEach void setup() {
+        query.setRootTypeId(2);
+        alarm.setId("a1"); alarm.setDeviceCode("D1"); alarm.setWarningCode("WARN_INSTANT_FLOW");
+        alarm.setCreateTime(LocalDateTime.of(2026,1,10,12,0));
+        alarm.setActualValue(BigDecimal.ZERO);
+        alarm.setAlarmStatus(1); alarm.setAuditStatus(1); alarm.setAlarmLevel(2); alarm.setWorkOrderId(9L); alarm.setOrderStatus(3);
+        when(scope.resolveOperations(query)).thenReturn(Arrays.asList(2,21));
+        when(mapper.selectDetail(eq("a1"),eq(query),anyList())).thenReturn(alarm);
+    }
+    @Test void ledgerUsesServerPaginationAndKeepsRecoveredEvents() {
+        query.setPageNum(3); query.setPageSize(20);
+        Page<AlarmVO> page = new Page<>(3,20,41); page.setRecords(Collections.singletonList(alarm));
+        when(mapper.selectPage(any(),eq(query),anyList())).thenAnswer(call -> {
+            Page<?> request = call.getArgument(0); assertEquals(3,request.getCurrent()); assertEquals(20,request.getSize()); return page;
+        });
+        assertEquals(41,service.page(query).getTotal());
+        assertNull(query.getAlarmStatus()); assertEquals("已恢复",alarm.getMonitorStatusName());
+        assertEquals("确认报警",alarm.getAuditStatusName()); assertEquals("处理中",alarm.getOrderStatusName());
+        verify(scope).resolveOperations(query);
+    }
+    @Test void outOfScopeDetailAndTimelineCannotExposeLogs() {
+        when(mapper.selectDetail(anyString(),eq(query),anyList())).thenReturn(null);
+        assertThrows(ServiceException.class, () -> service.logs("foreign",query)); verifyNoInteractions(logs);
+    }
+    @Test void remindersForceUnhandledStatusBeforeQueryAndKeepServerPagination() {
+        query.setPageNum(2); query.setPageSize(20);
+        when(mapper.selectReminders(any(),eq(query),anyList())).thenAnswer(call -> {
+            Page<AlarmVO> page = call.getArgument(0);
+            AlarmQuery filter = call.getArgument(1);
+            assertEquals(AlarmRecordStatusEnum.UNHANDLED.getCode(), filter.getAlarmStatus());
+            assertEquals(2, page.getCurrent()); assertEquals(20, page.getSize());
+            return page;
+        });
+        service.reminders(query);
+        query.setAlarmStatus(AlarmRecordStatusEnum.HANDLED.getCode());
+        service.reminders(query);
+        verify(mapper,times(2)).selectReminders(any(),eq(query),anyList());
+    }
+    @Test void analysisWithoutActualValueNeverQueriesSamples() {
+        alarm.setActualValue(null);
+        Map<String,Object> result = service.analysis("a1",query,AlarmSeriesPeriod.CURVE);
+        assertEquals(false,result.get("supported"));
+        assertEquals(Collections.emptyList(),result.get("samples"));
+        assertEquals("该报警无实际值,不展示监测曲线",result.get("message"));
+        verify(mapper,never()).selectSamples(any(),anyString(),any(),any(),anyString());
+        verify(mapper,never()).selectLatestSample(any(),anyString());
+    }
+    @Test void timelineUsesTheSharedNewestFirstQuery() {
+        when(logs.selectHistory("a1")).thenReturn(Collections.emptyList());
+        assertEquals(Collections.emptyList(),service.logs("a1",query));
+        verify(logs).selectHistory("a1");
+    }
+    @Test void invalidFiltersAndNonDrainageAnalysisAreRejected() {
+        query.setAlarmStatus(5); assertThrows(ServiceException.class, () -> service.page(query));
+        query.setRootTypeId(1); assertThrows(ServiceException.class, () -> service.map(query));
+        assertThrows(ServiceException.class, () -> service.analysis("a1",query,AlarmSeriesPeriod.CURVE));
+        verifyNoInteractions(mapper);
+    }
+    @Test void unsupportedStateAlarmReturnsNoFabricatedCurve() {
+        alarm.setWarningCode("WARN-OFFLINE");
+        Map<String,Object> result = service.analysis("a1",query,AlarmSeriesPeriod.CURVE);
+        assertEquals(false,result.get("supported")); assertEquals(Collections.emptyList(),result.get("samples"));
+        verify(mapper,never()).selectSamples(any(),anyString(),any(),any(),anyString());
+    }
+    @Test void dailyBucketsKeepMissingDaysNullAndDoNotAverageExistingMeansAgain() {
+        AlarmSampleVO sample = new AlarmSampleVO(); sample.setTime(LocalDateTime.of(2026,1,9,0,0));
+        sample.setValue(new BigDecimal("2.75")); sample.setValidCount(4); sample.setSampleCount(5);
+        when(mapper.selectSamples(any(),anyString(),any(),any(),eq("DAILY"))).thenReturn(Collections.singletonList(sample));
+        Map<String,Object> result = service.analysis("a1",query,AlarmSeriesPeriod.DAILY);
+        @SuppressWarnings("unchecked") List<AlarmSampleVO> points = (List<AlarmSampleVO>)result.get("samples");
+        assertEquals(7,points.size()); assertNull(points.get(0).getValue()); assertEquals(0,points.get(0).getSampleCount());
+        assertSame(sample,points.get(5)); assertNull(points.get(6).getValue());
+        verify(mapper).selectSamples(AlarmMetricSource.INSTANT_FLOW,"D1",LocalDateTime.of(2026,1,4,0,0),LocalDateTime.of(2026,1,11,0,0),"DAILY");
+    }
+    @Test void eventDayAndLatestSampleAreIndependentFromSnapshot() {
+        when(mapper.selectSamples(any(),anyString(),any(),any(),eq("DAY"))).thenReturn(Collections.emptyList());
+        AlarmSampleVO latest = new AlarmSampleVO(); latest.setValue(BigDecimal.TEN);
+        when(mapper.selectLatestSample(any(),eq("D1"))).thenReturn(latest);
+        Map<String,Object> result = service.analysis("a1",query,AlarmSeriesPeriod.DAY);
+        assertSame(latest,result.get("latestSample")); assertEquals(BigDecimal.ZERO,alarm.getActualValue());
+        assertEquals(true,result.get("supported"));
+        verify(mapper).selectSamples(AlarmMetricSource.INSTANT_FLOW,"D1",LocalDateTime.of(2026,1,10,0,0),LocalDateTime.of(2026,1,11,0,0),"DAY");
+    }
+    @Test void mapNeverSilentlyTruncatesLargeResult() {
+        when(mapper.selectMap(eq(query),anyList())).thenReturn(Collections.nCopies(10001,alarm));
+        assertThrows(ServiceException.class, () -> service.map(query));
+    }
+}

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

@@ -38,4 +38,24 @@ class AlarmReviewScopeServiceTest {
         query.setWarningCode("WARN-GAS-PRESSURE"); assertThrows(AccessDeniedException.class, () -> service.resolve(query));
     }
     @Test void missingRootDoesNotFallBackToUnrestrictedList() { when(types.selectList(null)).thenReturn(Collections.singletonList(type(22,3))); assertTrue(service.resolve(query).isEmpty()); }
+    @Test void operationsMenuAllowsOnlyItsRootAndDoesNotGrantAudit() {
+        SysMenu menu = new SysMenu(); menu.setStatus("0"); menu.setComponent("subSystem/alarm/list");
+        menu.setQuery("{\"rootTypeId\":1}"); when(menus.selectMenuList(9L)).thenReturn(Collections.singletonList(menu));
+        query.setRootTypeId(1); query.setWarningCode(null);
+        assertEquals(Arrays.asList(1,12), service.resolveOperations(query));
+        assertThrows(AccessDeniedException.class, () -> service.resolve(query));
+        query.setRootTypeId(3);
+        assertThrows(AccessDeniedException.class, () -> service.resolveOperations(query));
+    }
+    @Test void gisComponentUsesUpdatedNameAndDisabledMenuIsDenied() {
+        SysMenu menu = new SysMenu(); menu.setStatus("0"); menu.setComponent("subSystem/alarm/drainage/alarmGIS");
+        menu.setQuery("{\"rootTypeId\":2}"); when(menus.selectMenuList(9L)).thenReturn(Collections.singletonList(menu));
+        when(types.selectList(null)).thenReturn(Arrays.asList(type(2,0),type(21,2),type(22,21),type(12,1)));
+        query.setRootTypeId(2); query.setWarningCode(null);
+        assertEquals(Arrays.asList(2,21,22), service.resolveOperations(query));
+        menu.setStatus("1"); assertThrows(AccessDeniedException.class, () -> service.resolveOperations(query));
+    }
+    @Test void reviewMenuDoesNotGrantOperationsAccess() {
+        assertThrows(AccessDeniedException.class, () -> service.resolveOperations(query));
+    }
 }

+ 81 - 6
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewServiceTest.java

@@ -6,6 +6,9 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.zksy.base.alarm.domain.*;
 import com.zksy.base.alarm.dto.*;
 import com.zksy.base.alarm.enums.*;
+import com.zksy.base.alarm.enums.AlarmLogOperTypeEnum;
+import com.zksy.system.mapper.SysUserMapper;
+import com.zksy.ops.domain.WorkOrder;
 import com.zksy.base.alarm.mapper.*;
 import com.zksy.common.core.domain.entity.SysUser;
 import com.zksy.common.core.domain.model.LoginUser;
@@ -13,6 +16,9 @@ import com.zksy.common.exception.ServiceException;
 import com.zksy.ops.service.WorkOrderService;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -29,7 +35,8 @@ class AlarmReviewServiceTest {
     private final AlarmReviewScopeService scope = mock(AlarmReviewScopeService.class);
     private final AlarmReviewAttachmentService attachments = mock(AlarmReviewAttachmentService.class);
     private final WorkOrderService orders = mock(WorkOrderService.class);
-    private final AlarmReviewService service = new AlarmReviewService(queries, alarms, logs, scope, attachments, orders);
+    private final SysUserMapper users = mock(SysUserMapper.class);
+    private final AlarmReviewService service = new AlarmReviewService(queries, alarms, logs, scope, attachments, orders, users);
     private final AlarmReviewQuery query = new AlarmReviewQuery();
     private AlarmReviewVO alarm;
 
@@ -39,7 +46,10 @@ class AlarmReviewServiceTest {
         TableInfoHelper.initTableInfo(assistant, AlarmLog.class);
     }
     @BeforeEach void setup() {
-        SysUser user = new SysUser(); user.setUserName("审核员");
+        SysUser user = new SysUser(); user.setUserName("audit-account"); user.setNickName("审核员昵称");
+        when(users.selectUserById(9L)).thenReturn(user);
+        WorkOrder order = new WorkOrder(); order.setOrderId(88L); order.setOrderNo("WO202609241234560001");
+        when(orders.getById(88L)).thenReturn(order);
         LoginUser principal = new LoginUser(9L, 10L, user, Collections.emptySet());
         SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(principal, null));
         query.setRootTypeId(1);
@@ -61,12 +71,17 @@ class AlarmReviewServiceTest {
     @Test void confirmationCreatesOrderWithoutHandlingAlarmOrChangingDeviceState() {
         when(orders.createWorkOrderFromAlarm("a1", null, null)).thenReturn(88L);
         AlarmReviewVO result = service.review("a1", query, request(AlarmAuditStatusEnum.CONFIRMED));
-        assertEquals(88L, result.getOrderId()); assertEquals(0, result.getAlarmStatus());
+        assertEquals(88L, result.getWorkOrderId()); assertEquals(0, result.getAlarmStatus());
         assertEquals("一级报警", result.getAlarmLevelName()); assertEquals("确认报警", result.getAuditStatusName());
         ArgumentCaptor<AlarmLog> capture = ArgumentCaptor.forClass(AlarmLog.class);
         verify(logs).insert(capture.capture());
         assertEquals(9L, capture.getValue().getOperUserId());
-        assertEquals("现场核实", capture.getValue().getOperDesc());
+        assertEquals("现场核实\n已转工单(WO202609241234560001)", capture.getValue().getOperDesc());
+        assertEquals("审核员昵称", capture.getValue().getOperUserName());
+        var sequence = inOrder(orders, logs);
+        sequence.verify(orders).createWorkOrderFromAlarm("a1", null, null);
+        sequence.verify(orders).getById(88L);
+        sequence.verify(logs).insert(any(AlarmLog.class));
         // 不把审核意见作为故障描述传入,委托工单服务根据原报警生成 order_desc。
         verify(orders).createWorkOrderFromAlarm("a1", null, null);
         ArgumentCaptor<LambdaUpdateWrapper<AlarmData>> update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
@@ -92,10 +107,59 @@ class AlarmReviewServiceTest {
         assertThrows(ServiceException.class, () -> service.review("a1", query, request(AlarmAuditStatusEnum.CONFIRMED)));
         verifyNoInteractions(logs, orders);
     }
-    @Test void blankOpinionAndUnreviewedResultAreRejectedBeforeWrites() {
-        AlarmReviewRequest request = request(AlarmAuditStatusEnum.CONFIRMED); request.setOpinion("  ");
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"  ", "\t"})
+    void confirmationAllowsEmptyOpinionAndStillCreatesOrderAndLog(String opinion) {
+        AlarmReviewRequest request = request(AlarmAuditStatusEnum.CONFIRMED);
+        request.setOpinion(opinion);
+        when(orders.createWorkOrderFromAlarm("a1", null, null)).thenReturn(88L);
+        AlarmReviewVO result = service.review("a1", query, request);
+        assertEquals(88L, result.getWorkOrderId());
+        assertEquals(AlarmAuditStatusEnum.CONFIRMED.getCode(), result.getAuditStatus());
+        assertEquals(AlarmRecordStatusEnum.UNHANDLED.getCode(), result.getAlarmStatus());
+        verify(orders).createWorkOrderFromAlarm("a1", null, null);
+        ArgumentCaptor<AlarmLog> log = ArgumentCaptor.forClass(AlarmLog.class);
+        verify(logs).insert(log.capture());
+        assertEquals("已转工单(WO202609241234560001)", log.getValue().getOperDesc());
+        assertEquals(AlarmLogOperTypeEnum.CONFIRMED.getCode(), log.getValue().getOperType());
+    }
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"  ", "\t"})
+    void falseAlarmAllowsEmptyOpinionAndStillRecordsLogWithoutOrder(String opinion) {
+        AlarmReviewRequest request = request(AlarmAuditStatusEnum.FALSE_ALARM);
+        request.setOpinion(opinion);
+        AlarmReviewVO result = service.review("a1", query, request);
+        assertEquals(AlarmAuditStatusEnum.FALSE_ALARM.getCode(), result.getAuditStatus());
+        assertEquals(AlarmRecordStatusEnum.UNHANDLED.getCode(), result.getAlarmStatus());
+        verifyNoInteractions(orders);
+        ArgumentCaptor<AlarmLog> log = ArgumentCaptor.forClass(AlarmLog.class);
+        verify(logs).insert(log.capture());
+        assertEquals("", log.getValue().getOperDesc());
+        assertEquals(AlarmLogOperTypeEnum.FALSE_ALARM.getCode(), log.getValue().getOperType());
+    }
+    @Test void opinionAtMaximumLengthIsAccepted() {
+        AlarmReviewRequest request = request(AlarmAuditStatusEnum.FALSE_ALARM);
+        request.setOpinion("核".repeat(500));
+        service.review("a1", query, request);
+        ArgumentCaptor<AlarmLog> log = ArgumentCaptor.forClass(AlarmLog.class);
+        verify(logs).insert(log.capture());
+        assertEquals(request.getOpinion(), log.getValue().getOperDesc());
+    }
+    @Test void oversizedOpinionIsRejectedBeforeWrites() {
+        AlarmReviewRequest request = request(AlarmAuditStatusEnum.CONFIRMED);
+        request.setOpinion("核".repeat(501));
         assertThrows(ServiceException.class, () -> service.review("a1", query, request));
+        verifyNoInteractions(alarms, logs, orders);
+    }
+    @Test void invalidOrUnreviewedResultIsRejectedBeforeWrites() {
         assertThrows(ServiceException.class, () -> service.review("a1", query, request(AlarmAuditStatusEnum.UNREVIEWED)));
+        AlarmReviewRequest request = request(AlarmAuditStatusEnum.CONFIRMED);
+        request.setAuditStatus(null);
+        assertThrows(ServiceException.class, () -> service.review("a1", query, request));
+        request.setAuditStatus(-1);
+        assertThrows(ServiceException.class, () -> service.review("a1", query, request));
         verifyNoInteractions(alarms, logs, orders);
     }
     @Test void workOrderFailurePropagatesAndReviewHasRollbackBoundary() throws Exception {
@@ -115,4 +179,15 @@ class AlarmReviewServiceTest {
         assertThrows(ServiceException.class, () -> service.review("a1", query, request(AlarmAuditStatusEnum.CONFIRMED)));
         verifyNoInteractions(alarms, logs, orders);
     }
+    @Test void missingOrderNumberFailsWithoutWritingAConfirmationLog() {
+        when(orders.createWorkOrderFromAlarm("a1", null, null)).thenReturn(88L);
+        when(orders.getById(88L)).thenReturn(new WorkOrder());
+        assertThrows(ServiceException.class,()->service.review("a1",query,request(AlarmAuditStatusEnum.CONFIRMED)));
+        verifyNoInteractions(logs);
+    }
+    @Test void logsUseSharedDescendingHistoryQuery() {
+        when(logs.selectHistory("a1")).thenReturn(Collections.emptyList());
+        assertEquals(Collections.emptyList(),service.logs("a1",query));
+        verify(logs).selectHistory("a1");
+    }
 }

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

@@ -365,7 +365,6 @@ class WaterSupplyDeviceServiceImplTest {
         alarm.setWarningType(warningType);
         alarm.setAlarmLevel(level);
         alarm.setAlarmStatus(0);
-        alarm.setAlarmTime(LocalDateTime.of(2026, 9, 2, 9, 0));
         return alarm;
     }
 

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

@@ -0,0 +1,73 @@
+package com.zksy.ops.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.ops.enums.WorkOrderStatusEnum;
+import com.zksy.ops.service.impl.WorkOrderServiceImpl;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class WorkOrderAlarmConflictMapperTest {
+    @Test
+    void conflictQueryUsesReverseAlarmRelationAndAuthoritativeActiveStatuses() throws Exception {
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        try (InputStream xml = getClass().getResourceAsStream("/mapper/ops/WorkOrderMapper.xml")) {
+            assertNotNull(xml);
+            new XMLMapperBuilder(xml, configuration, "WorkOrderMapper.xml", configuration.getSqlFragments()).parse();
+        }
+        Map<String, Object> params = new HashMap<>();
+        params.put("deviceId", "eq-1");
+        params.put("warningCode", "WARN-PRESSURE");
+        params.put("activeStatuses", WorkOrderStatusEnum.activeCodes());
+        MappedStatement statement = configuration.getMappedStatement(WorkOrderMapper.class.getName() + ".existsActiveAlarmOrder");
+        BoundSql bound = statement.getBoundSql(params);
+        String sql = bound.getSql().replaceAll("\\s+", " ");
+        assertTrue(sql.contains("JOIN app_user.alarm_data a ON a.order_id = w.order_id"));
+        assertTrue(sql.contains("w.device_id = ?"));
+        assertTrue(sql.contains("a.warning_code = ?"));
+        assertTrue(sql.contains("w.order_status IN"));
+        assertFalse(sql.contains("warning_type"));
+        assertFalse(sql.contains("alarm_status"));
+        assertFalse(sql.contains("audit_status"));
+        assertFalse(sql.contains("dept_id"));
+        assertEquals("deviceId", bound.getParameterMappings().get(0).getProperty());
+        assertEquals("warningCode", bound.getParameterMappings().get(1).getProperty());
+        assertEquals(2 + WorkOrderStatusEnum.activeCodes().size(), bound.getParameterMappings().size());
+        for (int i = 0; i < WorkOrderStatusEnum.activeCodes().size(); i++) {
+            assertEquals(WorkOrderStatusEnum.activeCodes().get(i),
+                    bound.getAdditionalParameter(bound.getParameterMappings().get(i + 2).getProperty()));
+        }
+        assertTrue(WorkOrderStatusEnum.activeCodes().contains(WorkOrderStatusEnum.DELAYING.getCode()));
+        assertFalse(WorkOrderStatusEnum.activeCodes().contains(WorkOrderStatusEnum.CLOSED.getCode()));
+        assertFalse(WorkOrderStatusEnum.activeCodes().contains(WorkOrderStatusEnum.CANCELLED.getCode()));
+        assertFalse(statement.isUseCache());
+        assertTrue(statement.isFlushCacheRequired());
+    }
+
+    @Test
+    void deviceLockAlwaysHitsDatabaseAndIsHeldByCreationTransaction() throws Exception {
+        Method lock = EquipmentBaseMapper.class.getMethod("lockById", String.class);
+        String sql = String.join(" ", lock.getAnnotation(Select.class).value());
+        assertTrue(sql.contains("equipment_id = #{equipmentId} FOR UPDATE"));
+        Options options = lock.getAnnotation(Options.class);
+        assertFalse(options.useCache());
+        assertEquals(Options.FlushCachePolicy.TRUE, options.flushCache());
+        Transactional tx = WorkOrderServiceImpl.class.getMethod("createWorkOrderFromAlarm", String.class, Integer.class, String.class)
+                .getAnnotation(Transactional.class);
+        assertNotNull(tx);
+        assertTrue(Arrays.asList(tx.rollbackFor()).contains(Exception.class));
+    }
+}

+ 142 - 7
pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java

@@ -29,17 +29,25 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.Collections;
 import java.util.Arrays;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -96,6 +104,7 @@ class WorkOrderServiceImplTest {
         service = new WorkOrderServiceImpl();
         orderMapper = mock(WorkOrderMapper.class);
         logMapper = mock(WorkOrderLogMapper.class);
+        when(logMapper.insert(any(WorkOrderLog.class))).thenReturn(1);
         sysUserService = mock(ISysUserService.class);
         equipmentBaseMapper = mock(EquipmentBaseMapper.class);
         equipmentTypeMapper = mock(EquipmentTypeMapper.class);
@@ -119,10 +128,13 @@ class WorkOrderServiceImplTest {
     void alarmConfirmationLinksNewOrderWithoutHandlingAlarmOrClearingDevice() {
         AlarmData alarm = new AlarmData();
         alarm.setId("a-new"); alarm.setDeviceCode("DEVICE-NEW"); alarm.setAlarmLevel(1);
+        alarm.setAlarmNo("BJ2026092400000001");
+        alarm.setWarningCode("WARN-PRESSURE");
         alarm.setAlarmStatus(0); alarm.setAuditStatus(1);
         when(alarmDataService.getById("a-new")).thenReturn(alarm);
         EquipmentBase device = new EquipmentBase(); device.setEquipmentId("eq-new");
         when(equipmentBaseMapper.selectOne(any())).thenReturn(device);
+        when(equipmentBaseMapper.lockById("eq-new")).thenReturn("eq-new");
         when(orderMapper.insert(any(WorkOrder.class))).thenAnswer(call -> {
             WorkOrder order = call.getArgument(0); order.setOrderId(99L); return 1;
         });
@@ -130,10 +142,14 @@ class WorkOrderServiceImplTest {
         assertEquals(99L, service.createWorkOrderFromAlarm("a-new", null, "现场确认"));
         org.mockito.ArgumentCaptor<AlarmData> relation = org.mockito.ArgumentCaptor.forClass(AlarmData.class);
         verify(alarmDataService).update(relation.capture(), any());
-        assertEquals(99L, relation.getValue().getOrderId());
+        assertEquals(99L, relation.getValue().getWorkOrderId());
         assertNull(relation.getValue().getAlarmStatus());
-        assertNull(relation.getValue().getUpdateTime());
+        assertNotNull(relation.getValue().getUpdateTime());
         assertNull(relation.getValue().getAuditStatus());
+        org.mockito.ArgumentCaptor<WorkOrderLog> creationLog = org.mockito.ArgumentCaptor.forClass(WorkOrderLog.class);
+        verify(logMapper).insert(creationLog.capture());
+        assertTrue(creationLog.getValue().getOperDesc().contains("BJ2026092400000001"));
+        assertFalse(creationLog.getValue().getOperDesc().contains("a-new"));
         org.mockito.Mockito.verifyNoInteractions(alarmClearService);
         verify(caseInfoMapper).update(isNull(), any());
     }
@@ -160,9 +176,11 @@ class WorkOrderServiceImplTest {
 
     private WorkOrder createAlarmOrderWithGeneratedDescription(AlarmData alarm) {
         alarm.setId("a-description"); alarm.setDeviceCode("DEVICE-DESC"); alarm.setAlarmLevel(1);
+        alarm.setWarningCode("WARN-DESCRIPTION");
         when(alarmDataService.getById("a-description")).thenReturn(alarm);
         EquipmentBase device = new EquipmentBase(); device.setEquipmentId("eq-description");
         when(equipmentBaseMapper.selectOne(any())).thenReturn(device);
+        when(equipmentBaseMapper.lockById("eq-description")).thenReturn("eq-description");
         when(orderMapper.insert(any(WorkOrder.class))).thenAnswer(call -> {
             WorkOrder order = call.getArgument(0); order.setOrderId(100L); return 1;
         });
@@ -172,6 +190,123 @@ class WorkOrderServiceImplTest {
         verify(orderMapper).insert(saved.capture());
         return saved.getValue();
     }
+    @Test void workOrderServiceHasNoAlarmLogWriteDependency() {
+        assertFalse(Arrays.stream(WorkOrderServiceImpl.class.getDeclaredFields())
+                .anyMatch(field -> field.getType().getSimpleName().equals("AlarmLogMapper")));
+    }
+
+    @Test
+    void manualCreationDoesNotQueryDeviceOrAlarmConflicts() {
+        WorkOrder order = new WorkOrder();
+        order.setDeviceId("eq-conflict"); order.setOrderDesc("手动报修");
+        stubOrderInsert(101L);
+        assertEquals(101L, service.createWorkOrder(order));
+        assertEquals(WorkOrderStatusEnum.PENDING_DISPATCH.getCode(), order.getOrderStatus());
+        assertOnlyOrderNumberUniquenessQueried();
+        verify(orderMapper, never()).existsActiveAlarmOrder(anyString(), anyString(), anyList());
+        org.mockito.Mockito.verifyNoInteractions(equipmentBaseMapper, alarmDataService);
+    }
+
+    @Test
+    void hazardCreationDoesNotQueryDeviceOrAlarmConflicts() {
+        HazardHiddenAccount hazard = new HazardHiddenAccount(); hazard.setHazardId("h-new");
+        when(hazardMapper.selectById("h-new")).thenReturn(hazard);
+        when(hazardMapper.update(any(HazardHiddenAccount.class), any())).thenReturn(1);
+        stubOrderInsert(102L);
+        assertEquals(102L, service.createWorkOrderFromHazard("h-new", "H-NEW", "隐患报修", "eq-conflict", "1", null, null));
+        assertEquals(102L, hazard.getWorkOrderId());
+        assertOnlyOrderNumberUniquenessQueried();
+        verify(orderMapper, never()).existsActiveAlarmOrder(anyString(), anyString(), anyList());
+        org.mockito.Mockito.verifyNoInteractions(equipmentBaseMapper, alarmDataService);
+    }
+
+    @Test
+    void sameDeviceAndWarningCodeConflictIsRejectedBeforeWrites() {
+        stubAlarmForConflict("WARN-PRESSURE");
+        when(orderMapper.existsActiveAlarmOrder("eq-conflict", "WARN-PRESSURE", WorkOrderStatusEnum.activeCodes())).thenReturn(true);
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.createWorkOrderFromAlarm("a-conflict", null, null));
+        assertEquals("该设备同一报警类型已有未完成的工单,需结案或取消后才能再次转工单", error.getMessage());
+        verify(orderMapper, never()).insert(any(WorkOrder.class));
+        verify(alarmDataService, never()).update(any(AlarmData.class), any());
+        org.mockito.Mockito.verifyNoInteractions(logMapper, caseInfoMapper, alarmClearService);
+        org.mockito.InOrder sequence = inOrder(equipmentBaseMapper, orderMapper);
+        sequence.verify(equipmentBaseMapper).lockById("eq-conflict");
+        sequence.verify(orderMapper).existsActiveAlarmOrder("eq-conflict", "WARN-PRESSURE", WorkOrderStatusEnum.activeCodes());
+    }
+
+    @Test
+    void noSameTypeConflictAllowsCreationAfterLockAndReRead() {
+        stubAlarmForConflict("WARN-LEVEL");
+        stubOrderInsert(103L);
+        when(alarmDataService.update(any(AlarmData.class), any())).thenReturn(true);
+        assertEquals(103L, service.createWorkOrderFromAlarm("a-conflict", null, null));
+        org.mockito.InOrder sequence = inOrder(equipmentBaseMapper, alarmDataService, orderMapper);
+        sequence.verify(alarmDataService).getById("a-conflict");
+        sequence.verify(equipmentBaseMapper).lockById("eq-conflict");
+        sequence.verify(alarmDataService).getById("a-conflict");
+        sequence.verify(orderMapper).existsActiveAlarmOrder("eq-conflict", "WARN-LEVEL", WorkOrderStatusEnum.activeCodes());
+        sequence.verify(orderMapper).insert(any(WorkOrder.class));
+        assertOnlyOrderNumberUniquenessQueried();
+    }
+
+    @Test
+    void alarmLinkedWhileWaitingForDeviceLockReusesExistingOrder() {
+        AlarmData original = stubAlarmForConflict("WARN-PRESSURE");
+        AlarmData linked = new AlarmData(); linked.setId(original.getId());
+        linked.setDeviceCode(original.getDeviceCode()); linked.setWorkOrderId(ORDER_ID);
+        when(alarmDataService.getById("a-conflict")).thenReturn(original, linked);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(pendingOrder());
+        assertEquals(ORDER_ID, service.createWorkOrderFromAlarm("a-conflict", null, null));
+        verify(equipmentBaseMapper).lockById("eq-conflict");
+        verify(orderMapper, never()).existsActiveAlarmOrder(anyString(), anyString(), anyList());
+        verify(orderMapper, never()).insert(any(WorkOrder.class));
+    }
+
+    @Test
+    void deletedEquipmentCannotBypassConflictLock() {
+        stubAlarmForConflict("WARN-PRESSURE");
+        when(equipmentBaseMapper.lockById("eq-conflict")).thenReturn(null);
+        assertThrows(ServiceException.class, () -> service.createWorkOrderFromAlarm("a-conflict", null, null));
+        verify(orderMapper, never()).existsActiveAlarmOrder(anyString(), anyString(), anyList());
+        verify(orderMapper, never()).insert(any(WorkOrder.class));
+    }
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"  "})
+    void missingWarningCodeFailsClosedInsteadOfFallingBackToAlarmName(String warningCode) {
+        AlarmData alarm = stubAlarmForConflict(warningCode);
+        alarm.setWarningType("显示名称不能代替报警编码");
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.createWorkOrderFromAlarm("a-conflict", null, null));
+        assertTrue(error.getMessage().contains("报警编码缺失"));
+        verify(orderMapper, never()).insert(any(WorkOrder.class));
+    }
+
+    private AlarmData stubAlarmForConflict(String warningCode) {
+        AlarmData alarm = new AlarmData();
+        alarm.setId("a-conflict"); alarm.setDeviceCode("DEVICE-CONFLICT"); alarm.setWarningCode(warningCode);
+        when(alarmDataService.getById("a-conflict")).thenReturn(alarm);
+        EquipmentBase equipment = new EquipmentBase(); equipment.setEquipmentId("eq-conflict");
+        when(equipmentBaseMapper.selectOne(any())).thenReturn(equipment);
+        when(equipmentBaseMapper.lockById("eq-conflict")).thenReturn("eq-conflict");
+        return alarm;
+    }
+
+    private void stubOrderInsert(Long orderId) {
+        when(orderMapper.insert(any(WorkOrder.class))).thenAnswer(call -> {
+            WorkOrder saved = call.getArgument(0); saved.setOrderId(orderId); return 1;
+        });
+    }
+
+    private void assertOnlyOrderNumberUniquenessQueried() {
+        org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<WorkOrder>> wrapper =
+                org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(orderMapper).exists(wrapper.capture());
+        assertTrue(wrapper.getValue().getSqlSegment().contains("order_no"));
+        org.junit.jupiter.api.Assertions.assertFalse(wrapper.getValue().getSqlSegment().contains("device_id"));
+    }
 
     @Test
     void dispatchRejectsMissingHandlerId() {
@@ -347,7 +482,7 @@ class WorkOrderServiceImplTest {
         AlarmData source = new AlarmData();
         source.setId("a1");
         source.setAlarmStatus(1);
-        source.setOrderId(ORDER_ID);
+        source.setWorkOrderId(ORDER_ID);
         when(alarmDataService.getById("a1")).thenReturn(source);
         WorkOrder existing = pendingOrder();
         when(orderMapper.selectById(ORDER_ID)).thenReturn(existing);
@@ -369,7 +504,7 @@ class WorkOrderServiceImplTest {
     void hazardCreationReusesSourceOrderId() {
         HazardHiddenAccount hazard = new HazardHiddenAccount();
         hazard.setHazardId("h1");
-        hazard.setOrderId(ORDER_ID);
+        hazard.setWorkOrderId(ORDER_ID);
         when(hazardMapper.selectById("h1")).thenReturn(hazard);
         when(orderMapper.selectById(ORDER_ID)).thenReturn(pendingOrder());
         assertEquals(ORDER_ID, service.createWorkOrderFromHazard("h1", "H1", "desc", "dev1", "1", null, null));
@@ -380,13 +515,13 @@ class WorkOrderServiceImplTest {
     void detailReadsCaseAndHazardNumbersUsingReverseOrderRelation() {
         when(orderMapper.selectById(ORDER_ID)).thenReturn(pendingOrder());
         CaseInfo source = new CaseInfo();
-        source.setOrderId(ORDER_ID);
+        source.setWorkOrderId(ORDER_ID);
         source.setCaseNo("CS1");
         HazardHiddenAccount h1 = new HazardHiddenAccount();
-        h1.setOrderId(ORDER_ID);
+        h1.setWorkOrderId(ORDER_ID);
         h1.setHazardNo("HZ1");
         HazardHiddenAccount h2 = new HazardHiddenAccount();
-        h2.setOrderId(ORDER_ID);
+        h2.setWorkOrderId(ORDER_ID);
         h2.setHazardNo("HZ2");
         when(caseInfoMapper.selectList(any())).thenReturn(Collections.singletonList(source));
         when(hazardMapper.selectList(any())).thenReturn(Arrays.asList(h2, h1));

+ 1 - 1
radar-service/src/main/java/com/zksy/radar/utils/MessageHandler.java

@@ -269,7 +269,7 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			alarmData.setDeviationRatio(deviationRatio);
 
 			alarmData.setAlarmStatus(0);
-			alarmData.setAlarmTime(LocalDateTime.now());
+			alarmData.setCreateTime(LocalDateTime.now());
 			alarmData.setRemark(remark);
 			alarmData.setCreateTime(LocalDateTime.now());
 			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));

+ 1 - 1
telemetry-service/src/main/java/com/zksy/telemetry/utils/MessageHandler.java

@@ -177,7 +177,7 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
                 alarm.setAlarmLevel(level);
                 alarm.setDeviationRatio(AlarmLevelUtil.calculateDeviationRatio(actual, min, max));
                 alarm.setAlarmStatus(0);
-                alarm.setAlarmTime(java.time.LocalDateTime.now());
+                alarm.setCreateTime(java.time.LocalDateTime.now());
                 alarm.setRemark(null);
                 alarm.setCreateTime(java.time.LocalDateTime.now());
                 requireSaved(alarmDataService.saveAlarmData(alarm));

+ 1 - 1
water-level-service/src/main/java/com/zksy/water/utils/MessageHandler.java

@@ -268,7 +268,7 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			alarmData.setDeviationRatio(deviationRatio);
 
 			alarmData.setAlarmStatus(0);
-			alarmData.setAlarmTime(LocalDateTime.now());
+			alarmData.setCreateTime(LocalDateTime.now());
 			alarmData.setRemark(remark);
 			alarmData.setCreateTime(LocalDateTime.now());
 			boolean saved = run(deviceCode, warningCode + ":报警入库", () -> requireSaved(alarmDataService.saveAlarmData(alarmData)));

+ 8 - 8
zk-api-service/src/main/java/com/zksy/api/domain/AlarmData.java

@@ -1,6 +1,7 @@
 package com.zksy.api.domain;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
@@ -22,9 +23,13 @@ public class AlarmData implements Serializable {
     @ApiModelProperty(value = "主键")
     private String id;
 
-    @TableField(value = "order_id")
-    @ApiModelProperty(value = "关联工单ID")
-    private Long orderId;
+    @TableField(value = "alarm_no", insertStrategy = FieldStrategy.NOT_NULL, updateStrategy = FieldStrategy.NEVER)
+    @ApiModelProperty(value = "报警单号")
+    private String alarmNo;
+
+    @TableField(value = "work_order_id")
+    @ApiModelProperty(value = "工单ID")
+    private Long workOrderId;
 
     @TableField(value = "device_code")
     @ApiModelProperty(value = "设备编码")
@@ -66,11 +71,6 @@ public class AlarmData implements Serializable {
     @ApiModelProperty(value = "审核结果:0-未审核 1-确认报警 2-误报警;由管理端统一审核")
     private Integer auditStatus;
 
-    @TableField(value = "alarm_time")
-    @ApiModelProperty(value = "报警时间")
-    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
-    private LocalDateTime alarmTime;
-
     @TableField(value = "remark")
     @ApiModelProperty(value = "备注")
     private String remark;

+ 1 - 1
zk-api-service/src/main/java/com/zksy/api/domain/CaseInfo.java

@@ -38,7 +38,7 @@ public class CaseInfo implements Serializable {
     private String caseNo;
 
     @ApiModelProperty(value = "关联工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     /**
      * 关联报警ID,手动立案可为空

+ 1 - 1
zk-api-service/src/main/java/com/zksy/api/domain/WorkOrderLog.java

@@ -35,7 +35,7 @@ public class WorkOrderLog implements Serializable {
      * 关联工单ID
      */
     @ApiModelProperty(value = "工单ID")
-    private Long orderId;
+    private Long workOrderId;
 
     /**
      * 操作类型:1-创建工单 2-派单 3-接单 4-开始维修 5-提交验收 6-验收通过 7-驳回工单 8-申请延期

+ 23 - 0
zk-api-service/src/main/java/com/zksy/api/enums/AlarmLogOperTypeEnum.java

@@ -0,0 +1,23 @@
+package com.zksy.api.enums;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/** 报警日志类型;两端枚举值保持一致,工单关联及工单操作不再写入报警日志。 */
+@Getter
+public enum AlarmLogOperTypeEnum {
+    CREATED(0, "报警触发"),
+    CONFIRMED(1, "确认报警"),
+    FALSE_ALARM(2, "误报警"),
+    HANDLED(3, "报警处理");
+
+    private final int code;
+    private final String label;
+
+    AlarmLogOperTypeEnum(int code, String label) { this.code = code; this.label = label; }
+
+    public static AlarmLogOperTypeEnum fromCode(Integer code) {
+        return Arrays.stream(values()).filter(value -> code != null && value.code == code).findFirst().orElse(null);
+    }
+}

+ 19 - 4
zk-api-service/src/main/java/com/zksy/api/mapper/AlarmDataMapper.java

@@ -6,8 +6,18 @@ import org.apache.ibatis.annotations.Options;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 import org.apache.ibatis.annotations.Update;
+import java.util.List;
 
 public interface AlarmDataMapper extends BaseMapper<AlarmData> {
+    /** 两个写入服务共用事务锁,随机单号的查重与入库不可被并发插入穿透。 */
+    @Select("SELECT 1 FROM pg_advisory_xact_lock(hashtext('alarm-number-generation'))")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE, timeout = 5)
+    Integer lockAlarmNumberGeneration();
+
+    @Select("SELECT EXISTS(SELECT 1 FROM alarm_data WHERE alarm_no=#{alarmNo})")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    boolean existsAlarmNo(@Param("alarmNo") String alarmNo);
+
     /** Kingbase事务锁:同一设备/报警编码的跨线程、跨实例写入串行,提交/回滚自动释放。 */
     @Select("SELECT 1 FROM pg_advisory_xact_lock(hashtext('collection-alarm:' || #{deviceCode}), hashtext(#{warningCode}))")
     @Options(timeout = 5)
@@ -15,7 +25,7 @@ public interface AlarmDataMapper extends BaseMapper<AlarmData> {
 
     /** 锁定最新未处理记录,防止读取等级后人工处理;独立语句保证看到前一写入事务的提交。 */
     @Select("SELECT id, alarm_level FROM alarm_data WHERE device_code=#{deviceCode} AND warning_code=#{warningCode} AND alarm_status=0 "
-            + "ORDER BY alarm_time DESC NULLS LAST, create_time DESC NULLS LAST, id DESC LIMIT 1 FOR UPDATE")
+            + "ORDER BY create_time DESC NULLS LAST, id DESC LIMIT 1 FOR UPDATE")
     @Options(timeout = 5)
     AlarmData selectUnhandledAlarmForUpdate(@Param("deviceCode") String deviceCode, @Param("warningCode") String warningCode);
 
@@ -27,9 +37,14 @@ public interface AlarmDataMapper extends BaseMapper<AlarmData> {
     @Options(timeout = 5)
     int updateUnhandledAlarmLevel(@Param("id") String id, @Param("sample") AlarmData sample);
 
-    /** 按指标恢复全部未处理记录(包括历史重复);保留告警发生时的等级、数值和工单关联。 */
+    /** 锁定全部待恢复记录,逐条更新并记日志,包含历史重复记录。 */
+    @Select("SELECT id FROM alarm_data WHERE device_code=#{deviceCode} AND warning_code=#{warningCode} "
+            + "AND alarm_status=0 ORDER BY id FOR UPDATE")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE, timeout = 5)
+    List<String> selectRecoverableAlarmIdsForUpdate(@Param("deviceCode") String deviceCode, @Param("warningCode") String warningCode);
+
     @Update("UPDATE alarm_data SET alarm_status=1, update_time=CURRENT_TIMESTAMP "
-            + "WHERE device_code=#{deviceCode} AND warning_code=#{warningCode} AND alarm_status=0")
+            + "WHERE id=#{alarmId} AND alarm_status=0")
     @Options(timeout = 5)
-    int markUnhandledAlarmsRecovered(@Param("deviceCode") String deviceCode, @Param("warningCode") String warningCode);
+    int markAlarmRecovered(@Param("alarmId") String alarmId);
 }

+ 27 - 0
zk-api-service/src/main/java/com/zksy/api/mapper/AlarmLogMapper.java

@@ -0,0 +1,27 @@
+package com.zksy.api.mapper;
+
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.time.LocalDateTime;
+
+@Mapper
+public interface AlarmLogMapper {
+    /** 在业务事务中直接写入本次变更日志,避免同次变更重复记录。 */
+    @Insert("INSERT INTO app_user.alarm_log(alarm_id,oper_type,oper_desc,oper_user_name,oper_time) "
+            + "SELECT #{alarmId},#{operType},#{description},#{actor},#{time} "
+            + "WHERE NOT EXISTS(SELECT 1 FROM app_user.alarm_log WHERE alarm_id=#{alarmId} AND oper_type=#{operType} AND log_id > #{afterLogId})")
+    int insertLifecycleIfAbsent(@Param("alarmId") String alarmId, @Param("operType") int operType,
+                                @Param("description") String description, @Param("actor") String actor,
+                                @Param("time") LocalDateTime time, @Param("afterLogId") long afterLogId);
+
+    @Select("SELECT EXISTS(SELECT 1 FROM app_user.alarm_log WHERE alarm_id=#{alarmId} AND oper_type=#{operType} AND log_id > #{afterLogId})")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    boolean existsLifecycle(@Param("alarmId") String alarmId, @Param("operType") int operType, @Param("afterLogId") long afterLogId);
+
+    @Select("SELECT coalesce(max(log_id),0) FROM app_user.alarm_log WHERE alarm_id=#{alarmId}")
+    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
+    long latestLogId(@Param("alarmId") String alarmId);
+}

Some files were not shown because too many files changed in this diff