Sfoglia il codice sorgente

移动端-工单管理,巡检管理相关接口开发

zengy 6 giorni fa
parent
commit
43fc0c1931
53 ha cambiato i file con 1314 aggiunte e 125 eliminazioni
  1. 10 0
      pipe-network-service/CONTEXT.md
  2. 25 0
      pipe-network-service/docs/adr/0011-mobile-controllers-shared-services.md
  3. 4 3
      pipe-network-service/docs/domain/work-order-lifecycle.md
  4. 1 1
      pipe-network-service/sql/early_warning_system.sql
  5. 2 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/EquipmentBaseController.java
  6. 59 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileAlarmController.java
  7. 27 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileContactsController.java
  8. 37 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEquipmentController.java
  9. 45 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileInspectTaskController.java
  10. 31 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileLogininforController.java
  11. 44 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileNoticeController.java
  12. 50 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileWorkOrderController.java
  13. 1 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/monitor/SysLogininforController.java
  14. 1 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/InspectTaskController.java
  15. 1 30
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/WorkOrderController.java
  16. 0 22
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysNoticeController.java
  17. 0 7
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysUserController.java
  18. 2 1
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitInfrastructureEmergencySupportAdapterTest.java
  19. 71 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileAlarmControllerTest.java
  20. 142 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileControllerMappingTest.java
  21. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/dto/MobileAlarmQuery.java
  22. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmOperationsMapper.java
  23. 2 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/mapper/AlarmReviewMapper.java
  24. 40 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmOperationsService.java
  25. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewScopeService.java
  26. 21 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java
  27. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/EquipmentBaseService.java
  28. 87 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java
  29. 49 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/EarlyWarningStatusEnum.java
  30. 13 24
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningWorkflow.java
  31. 20 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java
  32. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/InspectTask.java
  33. 2 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/enums/WorkOrderStatusEnum.java
  34. 9 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/InspectTaskService.java
  35. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java
  36. 54 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/InspectTaskServiceImpl.java
  37. 5 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java
  38. 23 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/support/OpsAssignScopeSupport.java
  39. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysLogininforMapper.java
  40. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/ISysLogininforService.java
  41. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysLogininforServiceImpl.java
  42. 43 0
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml
  43. 5 0
      pipe-network-service/zksy-system/src/main/resources/mapper/base/AlarmReviewMapper.xml
  44. 8 1
      pipe-network-service/zksy-system/src/main/resources/mapper/system/SysLogininforMapper.xml
  45. 49 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/mapper/MobileAlarmMapperTest.java
  46. 111 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmMobileServiceTest.java
  47. 5 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewMapperTest.java
  48. 2 1
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewScopeServiceTest.java
  49. 20 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewServiceTest.java
  50. 53 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java
  51. 45 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/support/CompanyEquipmentScopeTest.java
  52. 2 5
      zk-api-service/src/main/java/com/zksy/api/service/impl/EarlyWarningAutoServiceImpl.java
  53. 11 2
      zk-api-service/src/main/java/com/zksy/api/support/AlarmLifecycleSupport.java

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

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

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

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

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

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

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

@@ -16,7 +16,7 @@ CREATE TABLE `early_warning` (
   `publish_time` datetime DEFAULT NULL COMMENT '发布时间',
   `handler` varchar(128) DEFAULT NULL COMMENT '处置人',
   `reviewer` varchar(128) DEFAULT NULL COMMENT '核查人(默认发布人)',
-  `status` varchar(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态(DRAFT-草稿 PENDING-待办 RECEIVED-已接收 PROCESSING-处置中 HANDLED-已处置 CLOSED-已解除)',
+  `status` varchar(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态(DRAFT-草稿 PENDING-待接收 RECEIVED-已接收 PROCESSING-处置中 HANDLED-已处置 CLOSED-已解除)',
   `warning_content` text COMMENT '预警内容描述',
   `process_instance_id` varchar(128) DEFAULT NULL COMMENT '工作流实例ID',
   `create_by` varchar(128) DEFAULT NULL COMMENT '创建人',

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
-import com.zksy.ops.domain.vo.MobileAssignableUserVO;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.core.domain.TreeSelect;
@@ -147,7 +146,7 @@ public class WorkOrderController {
 
     @PreAuthorize("@ss.hasPermi('ops:workOrder:cancel')")
     @PutMapping("/{orderId}/cancel")
-    @ApiOperation("取消工单(待派单/待接单/已接单/处理中/待验收 → 已取消)")
+    @ApiOperation("取消工单(仅待派单/待接单 → 已取消)")
     @Log(title = "取消工单", businessType = BusinessType.UPDATE)
     public AjaxResult cancel(@PathVariable("orderId") Long orderId, @Valid @RequestBody WorkOrderOperateDTO dto) {
         return workOrderService.cancel(orderId, dto) ? AjaxResult.success("取消成功") : AjaxResult.error("取消失败");
@@ -178,34 +177,6 @@ public class WorkOrderController {
     }
 
 
-    @PreAuthorize("isAuthenticated()")
-    @GetMapping("/mobile")
-    @ApiOperation("移动端我的工单分页(无需 PC 查询权限)")
-    public AjaxResult mobilePage(WorkOrderPageDTO query) {
-        return AjaxResult.success(workOrderService.findMobilePage(query));
-    }
-
-    @PreAuthorize("isAuthenticated()")
-    @GetMapping("/mobile/pending")
-    @ApiOperation("移动端待办工单分页(无需 PC 查询权限)")
-    public AjaxResult mobilePendingPage(WorkOrderPageDTO query) {
-        return AjaxResult.success(workOrderService.findMobilePendingPage(query));
-    }
-
-    @PreAuthorize("isAuthenticated()")
-    @GetMapping("/mobile/{orderId}")
-    @ApiOperation("移动端工单详情(含数据范围校验)")
-    public AjaxResult mobileDetail(@PathVariable("orderId") Long orderId) {
-        return AjaxResult.success(workOrderService.getMobileDetail(orderId));
-    }
-
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:dispatch')")
-    @GetMapping("/mobile/assignable/users")
-    @ApiOperation("移动端派单候选人,type=handler/reviewer")
-    public AjaxResult mobileAssignableUsers(@RequestParam("type") String type) {
-        List<MobileAssignableUserVO> users = workOrderService.mobileAssignableUsers(type);
-        return AjaxResult.success(users);
-    }
 
     /**
      * 入参 DTO 转工单实体:只映射创建阶段客户端可决定的字段,

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

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

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

@@ -263,11 +263,4 @@ public class SysUserController extends BaseController
         return success(userService.selectAllEnabledUser());
     }
 
-    /**
-     * 获取全部有效联系人,排除当前登录用户和超级管理员。
-     */
-    @GetMapping("/contacts")
-    public AjaxResult contacts() {
-        return success(userService.selectContacts(getUserId()));
-    }
 }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -149,7 +149,7 @@ public interface WorkOrderService extends IService<WorkOrder> {
     boolean verify(Long orderId, WorkOrderVerifyDTO dto);
 
     /**
-     * 取消工单:待派单/待接单/已接单/处理中/待验收 → 已取消
+     * 取消工单:仅待派单/待接单 → 已取消
      *
      * @param orderId 工单ID
      * @param dto     操作入参(取消原因)

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

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

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

@@ -649,13 +649,16 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("无权取消该工单");
         }
         if (!WorkOrderStatusEnum.canCancel(order.getOrderStatus())) {
-            throw new ServiceException("当前工单状态不允许取消");
+            throw new ServiceException("仅待派单、待接单工单允许取消");
         }
         if (StringUtils.isEmpty(dto.getContent())) {
             throw new ServiceException("请填写取消原因");
         }
 
         boolean updated = transition(id, order.getOrderStatus(), WorkOrderStatusEnum.CANCELLED.getCode(), null);
+        if (!updated) {
+            throw new ServiceException("工单状态已变更,请刷新后重试");
+        }
         writeLog(id, WorkOrderLogOperTypeEnum.CANCEL, buildDesc("原因", dto.getContent()), dto.getAttachUrl());
         return updated;
     }
@@ -838,13 +841,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     }
 
     private Set<Integer> currentCompanyParentTypeIds() {
-        Set<Long> deptPath = assignScopeSupport.currentDeptPathIds();
-        Set<Integer> result = new HashSet<>();
-        if (deptPath.contains(101L)) result.add(1);
-        if (deptPath.contains(102L)) result.add(2);
-        if (deptPath.contains(103L)) result.add(3);
-        if (!result.isEmpty()) result.add(4);
-        return result;
+        return assignScopeSupport.currentCompanyEquipmentParentTypeIds();
     }
 
     private boolean isMobileManagementVisible(WorkOrder order, Long userId) {

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

@@ -197,6 +197,29 @@ public class OpsAssignScopeSupport {
         return result;
     }
 
+    /**
+     * 当前用户所属公司的设备父类型范围(含共享窨井类型)。
+     *
+     * <p>部门路径包含 101/102/103 时分别代表供水/排水/燃气公司;窨井类型(4)
+     * 是三家公司共享的设备范围。
+     * 空集合表示当前部门不属于上述任一公司。</p>
+     */
+    public Set<Integer> currentCompanyEquipmentParentTypeIds() {
+        Set<Integer> result = currentCompanyEquipmentRootTypeIds();
+        if (!result.isEmpty()) result.add(4);
+        return result;
+    }
+
+    /** 当前用户所属公司的根设备类型范围,不包含共享窨井类型。 */
+    public Set<Integer> currentCompanyEquipmentRootTypeIds() {
+        Set<Long> deptPath = currentDeptPathIds();
+        Set<Integer> result = new HashSet<>();
+        if (deptPath.contains(101L)) result.add(1);
+        if (deptPath.contains(102L)) result.add(2);
+        if (deptPath.contains(103L)) result.add(3);
+        return result;
+    }
+
     /**
      * 校验被指派人是否处于当前登录用户可指派的部门范围内,否则抛出异常
      */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -77,6 +77,26 @@ class AlarmReviewServiceTest {
         return request;
     }
 
+    @Test void mobileOutOfScopeAuditNeverUpdatesAlarmOrCreatesOrder() {
+        assertThrows(ServiceException.class, () -> service.reviewInScope("foreign", query,
+                Collections.singletonList(12), request(AlarmAuditStatusEnum.CONFIRMED)));
+        verifyNoInteractions(alarms, attachments, orders, logs);
+    }
+
+    @Test void mobileConfirmationSharesOrderAndAuditLogTransaction() {
+        when(queries.selectMobileDetail(eq("a1"), eq(query), anyList())).thenReturn(alarm);
+        when(attachments.resolve(eq("a1"), any())).thenReturn(null);
+        when(orders.createWorkOrderFromAlarm("a1", null, null)).thenReturn(88L);
+        AlarmReviewVO reviewed = service.reviewInScope("a1", query, Collections.singletonList(12),
+                request(AlarmAuditStatusEnum.CONFIRMED));
+        assertEquals(1, reviewed.getAuditStatus());
+        verify(orders).createWorkOrderFromAlarm("a1", null, null);
+        ArgumentCaptor<AlarmLog> log = ArgumentCaptor.forClass(AlarmLog.class);
+        verify(logs).insert(log.capture());
+        assertTrue(log.getValue().getOperDesc().contains("WO202609241234560001"));
+        verify(scope, never()).resolve(any());
+    }
+
     @Test void confirmationCreatesOrderWithoutHandlingAlarmOrChangingDeviceState() {
         when(orders.createWorkOrderFromAlarm("a1", null, null)).thenReturn(88L);
         AlarmReviewVO result = service.review("a1", query, request(AlarmAuditStatusEnum.CONFIRMED));

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

@@ -17,6 +17,7 @@ import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrderLog;
 import com.zksy.ops.dto.WorkOrderDetailVO;
 import com.zksy.ops.dto.WorkOrderPageDTO;
+import com.zksy.ops.dto.WorkOrderOperateDTO;
 import com.zksy.ops.enums.WorkOrderStatusEnum;
 import com.zksy.ops.mapper.WorkOrderLogMapper;
 import com.zksy.ops.mapper.WorkOrderMapper;
@@ -140,6 +141,7 @@ class WorkOrderServiceImplTest {
         assignScopeSupport = mock(OpsAssignScopeSupport.class);
         when(assignScopeSupport.currentUserId()).thenReturn(CURRENT_USER_ID);
         when(assignScopeSupport.currentDeptPathIds()).thenReturn(new java.util.HashSet<>(Collections.singletonList(101L)));
+        when(assignScopeSupport.currentCompanyEquipmentParentTypeIds()).thenReturn(new java.util.HashSet<>(Arrays.asList(1, 4)));
         ReflectionTestUtils.setField(service, "assignScopeSupport", assignScopeSupport);
         com.zksy.system.service.ISysMenuService menuService = mock(com.zksy.system.service.ISysMenuService.class);
         when(menuService.selectMenuPermsByUserId(HANDLER_ID)).thenReturn(Collections.singleton("ops:workOrder:process"));
@@ -569,6 +571,57 @@ class WorkOrderServiceImplTest {
         return order;
     }
 
+    @ParameterizedTest
+    @ValueSource(ints = {0, 1})
+    void cancelAllowsOnlyOrdersAwaitingDispatchOrAcceptance(int status) {
+        WorkOrder order = pendingOrder();
+        order.setOrderStatus(status);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(order);
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        WorkOrderOperateDTO dto = new WorkOrderOperateDTO();
+        dto.setContent("取消原因");
+
+        assertTrue(service.cancel(ORDER_ID, dto));
+
+        org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<WorkOrder>> update =
+                org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper.class);
+        verify(orderMapper).update(isNull(), update.capture());
+        assertTrue(update.getValue().getSqlSegment().contains("order_status ="));
+        assertTrue(update.getValue().getParamNameValuePairs().containsValue(status));
+        org.mockito.ArgumentCaptor<WorkOrderLog> log = org.mockito.ArgumentCaptor.forClass(WorkOrderLog.class);
+        verify(logMapper).insert(log.capture());
+        assertEquals(7, log.getValue().getOperType());
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {2, 3, 4, 5, 6, 7, -1, 8})
+    void cancelRejectsAcceptedAndLaterOrInvalidStates(int status) {
+        WorkOrder order = pendingOrder();
+        order.setOrderStatus(status);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(order);
+        WorkOrderOperateDTO dto = new WorkOrderOperateDTO();
+        dto.setContent("取消原因");
+
+        ServiceException error = assertThrows(ServiceException.class, () -> service.cancel(ORDER_ID, dto));
+
+        assertEquals("仅待派单、待接单工单允许取消", error.getMessage());
+        verify(orderMapper, never()).update(isNull(), any());
+        verify(logMapper, never()).insert(any(WorkOrderLog.class));
+    }
+
+    @Test
+    void cancelDoesNotWriteSuccessLogWhenStatusChangesConcurrently() {
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(pendingOrder());
+        when(orderMapper.update(isNull(), any())).thenReturn(0);
+        WorkOrderOperateDTO dto = new WorkOrderOperateDTO();
+        dto.setContent("取消原因");
+
+        ServiceException error = assertThrows(ServiceException.class, () -> service.cancel(ORDER_ID, dto));
+
+        assertEquals("工单状态已变更,请刷新后重试", error.getMessage());
+        verify(logMapper, never()).insert(any(WorkOrderLog.class));
+    }
+
     private SysUser activeReceiver() {
         SysUser user = new SysUser();
         user.setUserId(HANDLER_ID);

+ 45 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/ops/support/CompanyEquipmentScopeTest.java

@@ -0,0 +1,45 @@
+package com.zksy.ops.support;
+
+import com.zksy.common.core.domain.entity.SysDept;
+import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.system.service.ISysDeptService;
+import org.junit.jupiter.api.*;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class CompanyEquipmentScopeTest {
+    private final ISysDeptService depts = mock(ISysDeptService.class);
+    private final OpsAssignScopeSupport scope = new OpsAssignScopeSupport();
+    @BeforeEach void setup() { ReflectionTestUtils.setField(scope, "deptService", depts); }
+    @AfterEach void cleanup() { SecurityContextHolder.clearContext(); }
+    private void login(long userId, long deptId, String ancestors) {
+        SysUser user = new SysUser(); user.setUserId(userId); user.setDeptId(deptId);
+        LoginUser login = new LoginUser(userId, deptId, user, Collections.emptySet());
+        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(login, null, Collections.emptyList()));
+        SysDept dept = new SysDept(); dept.setDeptId(deptId); dept.setAncestors(ancestors);
+        when(depts.selectDeptById(deptId)).thenReturn(dept);
+    }
+    @Test void companyDepartmentItselfMapsToExactRootWithoutSharedType() {
+        for (int company = 101; company <= 103; company++) {
+            login(42, company, "0,100");
+            assertEquals(Collections.singleton(company - 100), scope.currentCompanyEquipmentRootTypeIds());
+        }
+    }
+    @Test void descendantDepartmentUsesExactAncestorIds() {
+        login(42, 201, "0,100, 102 ,invalid");
+        assertEquals(Collections.singleton(2), scope.currentCompanyEquipmentRootTypeIds());
+        login(42, 201, "0,100,1101,1102,1103");
+        assertTrue(scope.currentCompanyEquipmentRootTypeIds().isEmpty());
+    }
+    @Test void administratorStillUsesOwnCompanyAndHasNoGlobalBypass() {
+        login(1, 201, "0,100,103");
+        assertEquals(Collections.singleton(3), scope.currentCompanyEquipmentRootTypeIds());
+        login(1, 100, "0");
+        assertTrue(scope.currentCompanyEquipmentRootTypeIds().isEmpty());
+    }
+}

+ 2 - 5
zk-api-service/src/main/java/com/zksy/api/service/impl/EarlyWarningAutoServiceImpl.java

@@ -9,6 +9,7 @@ import com.zksy.api.mapper.EarlyWarningApiMapper;
 import com.zksy.api.mapper.EquipmentBaseApiMapper;
 import com.zksy.api.mapper.WarningTypeApiMapper;
 import com.zksy.api.service.EarlyWarningAutoService;
+import com.zksy.api.support.AlarmLifecycleSupport;
 import com.zksy.api.support.EarlyWarningContentSupport;
 import com.zksy.api.utils.EarlyWarningLevelUtil;
 import com.zksy.api.utils.EarlyWarningLevelUtil.EarlyWarningLevelResult;
@@ -18,11 +19,9 @@ import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
-import java.util.Random;
 
 /**
  * 采集数据接近阈值时自动写入 early_warning
@@ -266,8 +265,6 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
     }
 
     private static String generateWarningNo() {
-        String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
-        String random = String.format("%04d", new Random().nextInt(10000));
-        return "YJ" + ts + random;
+        return AlarmLifecycleSupport.nextWarningNo();
     }
 }

+ 11 - 2
zk-api-service/src/main/java/com/zksy/api/support/AlarmLifecycleSupport.java

@@ -16,13 +16,22 @@ public final class 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));
+            String number = buildNo("BJ");
             if (!exists.test(number)) return number;
         }
         throw new IllegalStateException("报警单号生成冲突,请稍后重试");
     }
 
+    /** 预警单号:规则与报警单号一致(yyyyMMddHHmmss + 4 位随机),仅前缀为 YJ。 */
+    public static String nextWarningNo() {
+        return buildNo("YJ");
+    }
+
+    private static String buildNo(String prefix) {
+        return prefix + LocalDateTime.now(ZoneId.of("Asia/Shanghai")).format(NUMBER_TIME)
+                + String.format(Locale.ROOT, "%04d", ThreadLocalRandom.current().nextInt(10000));
+    }
+
     public static String alarmContent(String warningName, BigDecimal actual, BigDecimal min, BigDecimal max, String unit) {
         String description = firstNonBlank(warningName, "监测报警");
         if (actual != null && min != null && actual.compareTo(min) <= 0) {