Bläddra i källkod

完善移动端工单管理,预警管理

zengy 4 dagar sedan
förälder
incheckning
900bccf7eb
31 ändrade filer med 1401 tillägg och 114 borttagningar
  1. 7 0
      pipe-network-service/CONTEXT.md
  2. 181 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEarlyWarningController.java
  3. 2 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileWorkOrderController.java
  4. 7 6
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/WorkOrderController.java
  5. 10 8
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningParticipantController.java
  6. 57 2
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/mobile/MobileControllerMappingTest.java
  7. 59 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/warning/AssignmentCandidateMapperTest.java
  8. 89 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/warning/WarningParticipantServiceTest.java
  9. 76 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/warning/WarningScopeMapperTest.java
  10. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/vo/MobileWarningParticipantVO.java
  11. 20 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningProcessRequest.java
  12. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningPublishRequest.java
  13. 21 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningQuery.java
  14. 11 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningRemarkRequest.java
  15. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningVerifyRequest.java
  16. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/WarningAccessScope.java
  17. 27 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/EarlyWarningMapper.java
  18. 2 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/WarningParticipantMapper.java
  19. 12 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/EarlyWarningService.java
  20. 42 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningAccessService.java
  21. 66 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningParticipantService.java
  22. 85 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningProcessAttachmentService.java
  23. 145 28
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java
  24. 5 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderVO.java
  25. 6 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java
  26. 79 32
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java
  27. 4 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysUserMapper.java
  28. 18 0
      pipe-network-service/zksy-system/src/main/resources/mapper/system/SysUserMapper.xml
  29. 150 8
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/EarlyWarningMapper.xml
  30. 15 2
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/WarningParticipantMapper.xml
  31. 141 3
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java

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

@@ -255,10 +255,17 @@ _Avoid_:同一报警记录、同设备全部工单
 在统一工单页中为待派单工单指定唯一接单责任人的活动;报警审核只生成待派单工单,设备异常管理和报警管理不直接派发。
 _Avoid_:报警处理、报警审核
 
+**工单处置相关人员**:
+产品界面统一将接单责任人称为“处置人”,保留接口 receiveUser/receiveUserName;核验人仍使用 verifyUser,列表及详情补充 verifyUserName。两端共用 Service 按处置人与核验人 ID 批量查询、优先回填昵称,避免客户端额外查用户列表。详情将两人集中展示在设备信息之后的“处置相关人员”区域(2026-10-16)。
+
 **工单处置**:
 被派单人员对工单执行接收、处理、提交验收和结案等后续活动;工单进度不直接决定设备报警状态,也不阻止设备满足恢复条件时自动清警。
 _Avoid_:报警审核(指工单后续操作时)、报警记录已处理
 
+**工单与预警候选人员**:
+PC 与移动端按业务 ID 和角色共用候选规则及提交校验。设备类型直接父级 1/2/3 对应部门自身或祖先 101/102/103;工单无设备或两类业务设备父级 4 时不限制候选部门,仅要求用户启用、未删除且有对应角色权限。工单责任人/核验人分别要求 `ops:workOrder:process`/`ops:workOrder:audit`,预警处置人/核验人分别要求 `warning:warning:handle`/`warning:warning:audit`;关联损坏不可当作无设备放宽。工单派单后按指定人员与状态办理,不再次限制人员公司;共享预警的指定处置人/核验人可跨公司查看和办理本人预警,此例外不扩大其他草稿或非共享预警范围(2026-10-16)。
+_Avoid_:用用户管理 DataScope 或派单人部门替代候选范围
+
 **工单验收**:
 对工单处置结果是否满足要求进行确认的活动;验收通过可以使工单结案,但不直接清除设备报警状态。
 _Avoid_:报警审核、自动清警

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

@@ -0,0 +1,181 @@
+package com.zksy.web.controller.mobile;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.zksy.base.warning.domain.WarningAttachment;
+import com.zksy.base.warning.dto.*;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.base.warning.service.WarningProcessAttachmentService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.exception.ServiceException;
+import io.minio.GetObjectArgs;
+import io.minio.MinioClient;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/mobile/warning")
+@RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
+public class MobileEarlyWarningController {
+    private final EarlyWarningService warnings;
+    private final WarningProcessAttachmentService processAttachments;
+    private final MinioClient minioClient;
+
+    @Value("${minio.bucket}")
+    private String minioBucket;
+
+    @GetMapping
+    public AjaxResult list(@Valid MobileWarningQuery query) {
+        IPage<Map<String, Object>> page = warnings.getMobileWarningPage(query, false);
+        return AjaxResult.success(page);
+    }
+
+    @GetMapping("/pending")
+    public AjaxResult pending(@Valid MobileWarningQuery query) {
+        return AjaxResult.success(warnings.getMobileWarningPage(query, true));
+    }
+
+    @GetMapping("/participants")
+    @PreAuthorize("@ss.hasPermi('warning:warning:publish')")
+    public AjaxResult participants(@RequestParam("warningId") String warningId, @RequestParam("role") String role) {
+        return AjaxResult.success(warnings.getMobileWarningParticipants(warningId, role));
+    }
+
+    @GetMapping("/{warningId}")
+    public AjaxResult detail(@PathVariable("warningId") String warningId) {
+        return AjaxResult.success(warnings.getMobileWarningDetail(warningId));
+    }
+
+    @PostMapping("/{warningId}/publish")
+    @PreAuthorize("@ss.hasPermi('warning:warning:publish')")
+    public AjaxResult publish(@PathVariable("warningId") String warningId,
+                              @Valid @RequestBody MobileWarningPublishRequest request) {
+        warnings.requireMobileWarningVisible(warningId);
+        return AjaxResult.success(warnings.publishWarning(warningId, request.getHandler(), request.getReviewer()));
+    }
+
+    @PostMapping("/{warningId}/receive")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult receive(@PathVariable("warningId") String warningId,
+                              @Valid @RequestBody MobileWarningRemarkRequest request) {
+        warnings.requireMobileWarningVisible(warningId);
+        return AjaxResult.success(warnings.confirmWarning(warningId, null, request.getRemark()));
+    }
+
+    @PostMapping("/{warningId}/start")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult start(@PathVariable("warningId") String warningId,
+                            @Valid @RequestBody MobileWarningRemarkRequest request) {
+        warnings.requireMobileWarningVisible(warningId);
+        return AjaxResult.success(warnings.startWarningProcess(warningId, request.getRemark()));
+    }
+
+    @PostMapping("/{warningId}/process/attachments")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult uploadProcessAttachment(@PathVariable("warningId") String warningId,
+                                              @RequestPart("file") MultipartFile file) {
+        Map<String, Object> detail = warnings.getMobileWarningDetail(warningId);
+        if (!actions(detail).contains("HANDLE")) throw new ServiceException("当前预警不能上传处置图片");
+        return AjaxResult.success(processAttachments.upload(warningId, file));
+    }
+
+    @PostMapping("/{warningId}/process")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult process(@PathVariable("warningId") String warningId,
+                              @Valid @RequestBody MobileWarningProcessRequest request) {
+        warnings.requireMobileWarningVisible(warningId);
+        return AjaxResult.success(warnings.submitMobileWarningProcess(warningId, request.getContent(),
+                request.getAttachmentTokens(), request.getReasonIds()));
+    }
+
+    @PostMapping("/{warningId}/verify")
+    @PreAuthorize("@ss.hasPermi('warning:warning:audit')")
+    public AjaxResult verify(@PathVariable("warningId") String warningId,
+                             @Valid @RequestBody MobileWarningVerifyRequest request) {
+        warnings.requireMobileWarningVisible(warningId);
+        boolean result = "CLEAR".equals(request.getResult())
+                ? warnings.clearWarning(warningId, request.getOpinion())
+                : warnings.returnWarning(warningId, request.getOpinion());
+        return AjaxResult.success(result);
+    }
+
+    @GetMapping("/attachments/{attachmentId}")
+    public void attachment(@PathVariable("attachmentId") String attachmentId, HttpServletResponse response) {
+        WarningAttachment attachment = warnings.getAttachment(attachmentId);
+        if (attachment == null) {
+            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+            return;
+        }
+        warnings.requireMobileWarningVisible(attachment.getWarningId());
+        String objectName = normalizeObjectName(attachment.getAttachmentUrl());
+        if (objectName == null) {
+            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+        }
+        String fileName = displayName(attachment.getAttachmentName());
+        try (InputStream input = minioClient.getObject(GetObjectArgs.builder()
+                .bucket(minioBucket).object(objectName).build())) {
+            response.setContentType(contentType(attachment.getAttachmentType(), fileName));
+            response.setHeader("X-Content-Type-Options", "nosniff");
+            response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" +
+                    URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20"));
+            if (attachment.getAttachmentSize() != null) response.setContentLengthLong(attachment.getAttachmentSize());
+            byte[] buffer = new byte[8192];
+            int length;
+            while ((length = input.read(buffer)) != -1) response.getOutputStream().write(buffer, 0, length);
+            response.getOutputStream().flush();
+        } catch (Exception e) {
+            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> basicInfo(Map<String, Object> detail) {
+        return (Map<String, Object>) detail.getOrDefault("basicInfo", Collections.emptyMap());
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> actions(Map<String, Object> detail) {
+        Object value = basicInfo(detail).get("availableActions");
+        return value instanceof List ? (List<String>) value : Collections.emptyList();
+    }
+
+    private String normalizeObjectName(String value) {
+        if (value == null || value.trim().isEmpty()) return null;
+        String objectName = value.trim();
+        try {
+            if (objectName.startsWith("http://") || objectName.startsWith("https://")) {
+                objectName = new java.net.URI(objectName).getPath();
+            }
+        } catch (Exception e) {
+            return null;
+        }
+        objectName = objectName.replaceFirst("^/", "");
+        if (objectName.startsWith(minioBucket + "/")) objectName = objectName.substring(minioBucket.length() + 1);
+        return objectName.isEmpty() || objectName.contains("..") ? null : objectName;
+    }
+
+    private String displayName(String value) {
+        if (value == null || value.trim().isEmpty()) return "预警附件";
+        return value.replaceFirst("^\\[(预警报告|处理反馈|督办附件)\\]\\s*", "");
+    }
+
+    private String contentType(String savedType, String fileName) {
+        String lower = fileName.toLowerCase(Locale.ROOT);
+        if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
+        if (lower.endsWith(".png")) return "image/png";
+        if (lower.endsWith(".gif")) return "image/gif";
+        if (lower.endsWith(".pdf")) return "application/pdf";
+        return savedType == null || savedType.trim().isEmpty() ? "application/octet-stream" : savedType;
+    }
+}

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

@@ -43,8 +43,8 @@ public class MobileWorkOrderController {
     @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);
+    public AjaxResult mobileAssignableUsers(@RequestParam("orderId") Long orderId, @RequestParam("type") String type) {
+        List<MobileAssignableUserVO> users = workOrderService.mobileAssignableUsers(orderId, type);
         return AjaxResult.success(users);
     }
 }

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

@@ -79,19 +79,20 @@ public class WorkOrderController {
         return AjaxResult.success(logs);
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:dispatch')")
     @GetMapping("/assignable/deptTree")
     @ApiOperation("获取可指派部门树")
-    public AjaxResult assignableDeptTree() {
-        List<TreeSelect> tree = workOrderService.assignableDeptTree();
+    public AjaxResult assignableDeptTree(@RequestParam("orderId") Long orderId, @RequestParam("type") String type) {
+        List<TreeSelect> tree = workOrderService.assignableDeptTree(orderId, type);
         return AjaxResult.success(tree);
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:dispatch')")
     @GetMapping("/assignable/users")
     @ApiOperation("查询可指派用户")
-    public AjaxResult assignableUsers(@RequestParam(value = "deptId", required = false) Long deptId) {
-        List<SysUser> users = workOrderService.assignableUsers(deptId);
+    public AjaxResult assignableUsers(@RequestParam("orderId") Long orderId, @RequestParam("type") String type,
+                                     @RequestParam(value = "deptId", required = false) Long deptId) {
+        List<SysUser> users = workOrderService.assignableUsers(orderId, type, deptId);
         return AjaxResult.success(users);
     }
 

+ 10 - 8
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningParticipantController.java

@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController;
 /** 预警处置人、核查人选择,不要求用户管理权限。 */
 @RestController
 @RequestMapping("/warning/participants")
-@PreAuthorize("@ss.hasAnyPermi('warning:warning:add,warning:warning:edit,warning:warning:publish')")
+@PreAuthorize("@ss.hasPermi('warning:warning:publish')")
 public class WarningParticipantController {
     private final WarningParticipantService participants;
 
@@ -20,15 +20,17 @@ public class WarningParticipantController {
     }
 
     @GetMapping("/deptTree")
-    public AjaxResult departments() {
-        return AjaxResult.success(participants.departments());
+    public AjaxResult departments(@RequestParam("warningId") String warningId, @RequestParam("role") String role) {
+        return AjaxResult.success(participants.departments(warningId, role));
     }
 
     @GetMapping("/users")
-    public AjaxResult users(@RequestParam(defaultValue = "1") int pageNum,
-                            @RequestParam(defaultValue = "10") int pageSize,
-                            @RequestParam(required = false) Long deptId,
-                            @RequestParam(required = false) String keyword) {
-        return AjaxResult.success(participants.users(pageNum, pageSize, deptId, keyword));
+    public AjaxResult users(@RequestParam("warningId") String warningId,
+                            @RequestParam("role") String role,
+                            @RequestParam(value = "pageNum", defaultValue = "1") int pageNum,
+                            @RequestParam(value = "pageSize", defaultValue = "10") int pageSize,
+                            @RequestParam(value = "deptId", required = false) Long deptId,
+                            @RequestParam(value = "keyword", required = false) String keyword) {
+        return AjaxResult.success(participants.users(warningId, role, pageNum, pageSize, deptId, keyword));
     }
 }

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

@@ -1,6 +1,9 @@
 package com.zksy.web.controller.mobile;
 
 import com.zksy.base.alarm.service.*;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.base.warning.service.WarningProcessAttachmentService;
+import io.minio.MinioClient;
 import com.zksy.base.service.EquipmentBaseService;
 import com.zksy.ops.dto.InspectTaskCompleteDTO;
 import com.zksy.ops.dto.InspectTaskPageDTO;
@@ -35,12 +38,14 @@ class MobileControllerMappingTest {
     private final EquipmentBaseService equipment = mock(EquipmentBaseService.class);
     private final ISysNoticeService notices = mock(ISysNoticeService.class);
     private final ISysLogininforService loginLogs = mock(ISysLogininforService.class);
+    private final EarlyWarningService warnings = mock(EarlyWarningService.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)
+            new MobileOfficeController(equipment, mock(AlarmOperationsService.class), inspections),
+            new MobileEarlyWarningController(warnings, mock(WarningProcessAttachmentService.class), mock(MinioClient.class))
     };
     private MockMvc mvc;
     private RequestMappingHandlerMapping mapping;
@@ -77,6 +82,11 @@ class MobileControllerMappingTest {
             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);
+        for (String path : new String[] {"", "/pending", "/participants", "/w1", "/attachments/a1"})
+            route("GET", "/api/mobile/warning" + path, MobileEarlyWarningController.class);
+        for (String path : new String[] {"/w1/publish", "/w1/receive", "/w1/start", "/w1/process",
+                "/w1/process/attachments", "/w1/verify"})
+            route("POST", "/api/mobile/warning" + path, MobileEarlyWarningController.class);
     }
 
     @Test void pcAndSharedOperationUrlsKeepTheirOwners() throws Exception {
@@ -93,11 +103,40 @@ class MobileControllerMappingTest {
         route("POST", "/api/alarm/a1/reviews", AlarmController.class);
     }
 
+    @Test void workOrderCandidatesRequireOrderAndRoleAndBindThemExplicitly() throws Exception {
+        String path = "/api/mobile/ops/workOrder/assignable/users";
+        mvc.perform(get(path).param("type", "handler")).andExpect(status().isBadRequest());
+        mvc.perform(get(path).param("orderId", "42")).andExpect(status().isBadRequest());
+        mvc.perform(get(path).param("orderId", "42").param("type", "reviewer"))
+                .andExpect(status().isOk());
+        verify(orders).mobileAssignableUsers(42L, "reviewer");
+        for (String method : List.of("assignableDeptTree", "assignableUsers")) {
+            Class<?>[] types = method.equals("assignableDeptTree")
+                    ? new Class<?>[]{Long.class, String.class} : new Class<?>[]{Long.class, String.class, Long.class};
+            assertEquals("@ss.hasPermi('ops:workOrder:dispatch')", WorkOrderController.class
+                    .getMethod(method, types).getAnnotation(PreAuthorize.class).value());
+        }
+    }
+
     @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());
+                .getMethod("mobileAssignableUsers", Long.class, String.class).getAnnotation(PreAuthorize.class).value());
+        assertEquals("@ss.hasPermi('warning:warning:publish')", MobileEarlyWarningController.class
+                .getMethod("publish", String.class, com.zksy.base.warning.dto.MobileWarningPublishRequest.class)
+                .getAnnotation(PreAuthorize.class).value());
+        for (String method : new String[] {"receive", "start"}) {
+            assertEquals("@ss.hasPermi('warning:warning:handle')", MobileEarlyWarningController.class
+                    .getMethod(method, String.class, com.zksy.base.warning.dto.MobileWarningRemarkRequest.class)
+                    .getAnnotation(PreAuthorize.class).value());
+        }
+        assertEquals("@ss.hasPermi('warning:warning:handle')", MobileEarlyWarningController.class
+                .getMethod("process", String.class, com.zksy.base.warning.dto.MobileWarningProcessRequest.class)
+                .getAnnotation(PreAuthorize.class).value());
+        assertEquals("@ss.hasPermi('warning:warning:audit')", MobileEarlyWarningController.class
+                .getMethod("verify", String.class, com.zksy.base.warning.dto.MobileWarningVerifyRequest.class)
+                .getAnnotation(PreAuthorize.class).value());
     }
 
     @Test void equipmentParametersAndPathBindingRemainCompatible() throws Exception {
@@ -127,6 +166,22 @@ class MobileControllerMappingTest {
         verify(inspections).completeMobileTask(any(InspectTaskCompleteDTO.class));
     }
 
+    @Test void warningQueriesReachTheSharedService() throws Exception {
+        mvc.perform(get("/api/mobile/warning").param("pageNum", "2").param("pageSize", "20")
+                        .param("keyword", "井盖").param("status", "PROCESSING"))
+                .andExpect(status().isOk());
+        verify(warnings).getMobileWarningPage(argThat(query -> query.getPageNum() == 2
+                && query.getPageSize() == 20 && "井盖".equals(query.getKeyword())
+                && "PROCESSING".equals(query.getStatus())), eq(false));
+        mvc.perform(get("/api/mobile/warning/pending")).andExpect(status().isOk());
+        verify(warnings).getMobileWarningPage(any(com.zksy.base.warning.dto.MobileWarningQuery.class), eq(true));
+        mvc.perform(get("/api/mobile/warning/w1")).andExpect(status().isOk());
+        verify(warnings).getMobileWarningDetail("w1");
+        mvc.perform(get("/api/mobile/warning/participants").param("warningId", "w1").param("role", "handler"))
+                .andExpect(status().isOk());
+        verify(warnings).getMobileWarningParticipants("w1", "handler");
+    }
+
     @Test void closedNoticeIsStillHiddenAndLoginLogUsesCurrentUserService() throws Exception {
         SysNotice notice = new SysNotice(); notice.setNoticeId(42L); notice.setStatus("1");
         when(notices.selectNoticeById(42L)).thenReturn(notice);

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

@@ -0,0 +1,59 @@
+package com.zksy.web.controller.warning;
+
+import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.common.core.domain.entity.SysDept;
+import com.zksy.common.core.domain.entity.SysRole;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.io.Resources;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class AssignmentCandidateMapperTest {
+    private BoundSql statement(String resource, String name, Map<String, Object> params) throws Exception {
+        Configuration config = new Configuration();
+        for (Class<?> type : List.of(SysUser.class, SysDept.class, SysRole.class))
+            config.getTypeAliasRegistry().registerAlias(type.getSimpleName(), type);
+        try (InputStream input = Resources.getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, config, resource, config.getSqlFragments()).parse();
+        }
+        return config.getMappedStatement(name).getBoundSql(params);
+    }
+
+    @Test void workOrderOnlyAddsCompanyRestrictionForCompanyDevices() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("companyDeptId", null);
+        params.put("deptId", null);
+        for (Long company : Arrays.asList(null, 101L, 102L, 103L)) {
+            params.put("companyDeptId", company);
+            BoundSql bound = statement("mapper/system/SysUserMapper.xml",
+                    "com.zksy.system.mapper.SysUserMapper.selectAssignmentCandidates", params);
+            String sql = bound.getSql().replaceAll("\\s+", " ");
+            assertTrue(sql.contains("LEFT JOIN app_user.sys_dept"));
+            assertTrue(sql.contains("u.status = '0' AND u.del_flag = '0'"));
+            assertEquals(company != null, sql.contains("d.status = '0'"));
+            assertEquals(company != null, bound.getParameterMappings().stream()
+                    .anyMatch(p -> p.getProperty().equals("companyDeptId")));
+            assertFalse(sql.contains("dataScope"));
+            assertFalse(sql.contains("u.dept_id IS NOT NULL"));
+        }
+    }
+
+    @Test void warningSharedTypeBypassesDepartmentButOtherTypesKeepCompanyMatch() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("warningId", "w1"); params.put("deptId", null); params.put("keyword", null);
+        String sql = statement("mapper/warning/WarningParticipantMapper.xml",
+                "com.zksy.base.warning.mapper.WarningParticipantMapper.selectCandidates", params)
+                .getSql().replaceAll("\\s+", " ");
+        assertTrue(sql.contains("LEFT JOIN app_user.sys_dept"));
+        assertTrue(sql.contains("et.parent_id = 4 OR (d.status = '0' AND d.del_flag = '0' AND ("));
+        for (int parent = 1; parent <= 3; parent++)
+            assertTrue(sql.contains("et.parent_id = " + parent + " AND (d.dept_id = " + (100 + parent)));
+        assertFalse(sql.substring(0, sql.indexOf("AND EXISTS")).contains("d.status"));
+        assertTrue(sql.contains("eb.equipment_code = w.device_code"));
+    }
+}

+ 89 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/warning/WarningParticipantServiceTest.java

@@ -0,0 +1,89 @@
+package com.zksy.web.controller.warning;
+
+import com.zksy.base.warning.domain.WarningParticipant;
+import com.zksy.base.warning.mapper.WarningParticipantMapper;
+import com.zksy.base.warning.service.WarningAccessService;
+import com.zksy.base.warning.service.WarningParticipantService;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.system.mapper.SysDeptMapper;
+import com.zksy.system.service.ISysDeptService;
+import com.zksy.system.service.ISysMenuService;
+import org.junit.jupiter.api.Test;
+
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.LongStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class WarningParticipantServiceTest {
+    private final WarningParticipantMapper mapper = mock(WarningParticipantMapper.class);
+    private final WarningAccessService access = mock(WarningAccessService.class);
+    private final ISysMenuService menus = mock(ISysMenuService.class);
+    private final WarningParticipantService service = new WarningParticipantService(mock(SysDeptMapper.class),
+            mock(ISysDeptService.class), mapper, access, menus);
+
+    private WarningParticipant person(long id) {
+        WarningParticipant user = new WarningParticipant();
+        user.setUserId(id);
+        user.setUserName("user" + id);
+        user.setDeptId(102L);
+        return user;
+    }
+
+    @Test void rolesUseSeparatePermissionsAndSupportWildcard() {
+        when(mapper.selectCandidates("w1", null, null)).thenReturn(Arrays.asList(person(2), person(3), person(4), person(5)));
+        when(menus.selectMenuPermsByUserId(2L)).thenReturn(Set.of("warning:warning:handle"));
+        when(menus.selectMenuPermsByUserId(3L)).thenReturn(Set.of("warning:warning:audit"));
+        when(menus.selectMenuPermsByUserId(4L)).thenReturn(Set.of("warning:warning:*"));
+        when(menus.selectMenuPermsByUserId(5L)).thenReturn(Collections.emptySet());
+        assertEquals(Arrays.asList("user2", "user4"), names(service.candidates("w1", "handler", null, null)));
+        assertEquals(Arrays.asList("user3", "user4"), names(service.candidates("w1", "reviewer", null, null)));
+        verify(access, times(2)).requirePublisher("w1");
+    }
+
+    @Test void filterPermissionsBeforePaginationSoEligibleUsersAreNotLost() {
+        List<WarningParticipant> users = LongStream.rangeClosed(2, 131).mapToObj(this::person).collect(Collectors.toList());
+        when(mapper.selectCandidates("w1", null, null)).thenReturn(users);
+        for (WarningParticipant user : users) when(menus.selectMenuPermsByUserId(user.getUserId()))
+                .thenReturn(user.getUserId() % 2 == 0 ? Set.of("warning:warning:audit") : Set.of("warning:warning:handle"));
+        var page = service.users("w1", "reviewer", 2, 20, null, null);
+        assertEquals(65, page.getTotal());
+        assertEquals(20, page.getRecords().size());
+        assertEquals("user42", page.getRecords().get(0).getUserName());
+    }
+
+    @Test void publishRechecksPermissionsAfterUserWasSelected() {
+        when(mapper.selectCandidates("w1", null, "user3")).thenReturn(List.of(person(3)));
+        when(menus.selectMenuPermsByUserId(3L)).thenReturn(Set.of("warning:warning:audit"), Collections.emptySet());
+        assertDoesNotThrow(() -> service.requireCandidate("w1", "reviewer", "user3"));
+        assertThrows(ServiceException.class, () -> service.requireCandidate("w1", "reviewer", "user3"));
+    }
+
+    @Test void usersOutsideDeviceCompanyCannotBeAssignedEvenWhenTheyHavePermission() {
+        when(mapper.selectCandidates("w1", null, "user3")).thenReturn(Collections.emptyList());
+        assertThrows(ServiceException.class, () -> service.requireCandidate("w1", "reviewer", "user3"));
+        verifyNoInteractions(menus);
+    }
+
+    @Test void sharedDeviceCandidateNeedsPermissionButDoesNotNeedDepartment() {
+        WarningParticipant candidate = person(3);
+        candidate.setDeptId(null);
+        when(mapper.selectCandidates("shared", null, "user3")).thenReturn(List.of(candidate));
+        when(menus.selectMenuPermsByUserId(3L)).thenReturn(Set.of("warning:warning:audit"));
+        assertDoesNotThrow(() -> service.requireCandidate("shared", "reviewer", "user3"));
+        assertThrows(ServiceException.class, () -> service.requireCandidate("shared", "handler", "user3"));
+    }
+
+    @Test void blockedWarningAndInvalidRolesNeverQueryUsers() {
+        doThrow(new ServiceException("无发布权限")).when(access).requirePublisher("blocked");
+        assertThrows(ServiceException.class, () -> service.candidates("blocked", "handler", null, null));
+        assertThrows(ServiceException.class, () -> service.candidates("w1", "publisher", null, null));
+        verifyNoInteractions(mapper);
+    }
+
+    private List<String> names(List<WarningParticipant> users) {
+        return users.stream().map(WarningParticipant::getUserName).collect(Collectors.toList());
+    }
+}

+ 76 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/warning/WarningScopeMapperTest.java

@@ -0,0 +1,76 @@
+package com.zksy.web.controller.warning;
+
+import com.zksy.base.warning.dto.WarningAccessScope;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.io.Resources;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Validate the actual MyBatis statements and bound parameters without a live database. */
+class WarningScopeMapperTest {
+    private static final String NS = "com.zksy.base.warning.mapper.EarlyWarningMapper.";
+
+    private BoundSql statement(String name, Set<Integer> parentTypes, Map<String, Object> extra) throws Exception {
+        Configuration configuration = new Configuration();
+        String resource = "mapper/warning/EarlyWarningMapper.xml";
+        try (InputStream input = Resources.getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        Map<String, Object> params = new HashMap<>(extra);
+        params.put("scope", new WarningAccessScope("current", true, true, true, parentTypes));
+        params.putIfAbsent("q", Collections.emptyMap());
+        params.putIfAbsent("releaseOrder", false);
+        params.putIfAbsent("pendingOnly", false);
+        return configuration.getMappedStatement(NS + name).getBoundSql(params);
+    }
+
+    private String sql(BoundSql bound) { return bound.getSql().replaceAll("\\s+", " "); }
+
+    @Test void companyScopeAppliesToManagementDetailPendingAndArchive() throws Exception {
+        for (String query : List.of("selectWarningList", "searchWarnings", "selectMobileWarnings", "selectMyTasks",
+                "countVisibleWarning", "selectProcessingWarnings", "selectCompletedWarnings", "selectVisibleArchives")) {
+            BoundSql bound = statement(query, Set.of(2, 4), Collections.emptyMap());
+            assertTrue(sql(bound).contains("eb.equipment_code = w.device_code"), query);
+            assertTrue(sql(bound).contains("et.parent_id IN"), query);
+            assertTrue(bound.getParameterMappings().stream().anyMatch(p -> p.getProperty().equals("scope.username")), query);
+            List<Object> types = bound.getParameterMappings().stream()
+                    .filter(p -> p.getProperty().startsWith("__frch_parentTypeId"))
+                    .map(p -> bound.getAdditionalParameter(p.getProperty())).collect(Collectors.toList());
+            assertEquals(Set.of(2, 4), new HashSet<>(types), query);
+        }
+    }
+
+    @Test void missingCompanyOnlyAllowsExplicitSharedDeviceAssignees() throws Exception {
+        for (String query : List.of("selectMobileWarnings", "countVisibleWarning", "countCompanyDevice")) {
+            String sql = sql(statement(query, Collections.emptySet(), Collections.emptyMap()));
+            if (query.equals("countCompanyDevice")) assertTrue(sql.contains("AND 1 = 0"));
+            else {
+                assertTrue(sql.contains("1 = 0 OR (et.parent_id = 4 AND (w.handler = ? OR w.reviewer = ?))"));
+                assertTrue(sql.contains("eb.equipment_code = w.device_code"));
+            }
+            assertFalse(sql.contains("IN ( )"));
+        }
+    }
+
+    @Test void pcAndMobileTodoUseSameAssigneePermissionStateRulesWithoutRequiringTodoRows() throws Exception {
+        String pc = sql(statement("selectMyTasks", Set.of(2, 4), Map.of("q", Map.of("todoType", "TODO"))));
+        String mobile = sql(statement("selectMobileWarnings", Set.of(2, 4), Map.of("pendingOnly", true)));
+        for (String query : List.of(pc, mobile)) {
+            assertTrue(query.contains("w.handler = ? AND ? = TRUE AND w.status IN ('PENDING', 'RECEIVED', 'PROCESSING')"));
+            assertTrue(query.contains("w.reviewer = ? AND ? = TRUE AND w.status = 'HANDLED'"));
+            assertTrue(query.contains("OR (w.status = 'DRAFT' AND ? = TRUE)"));
+        }
+        assertTrue(pc.contains("LEFT JOIN"));
+        assertTrue(pc.contains("'TODO' AS todo_type"));
+        assertFalse(pc.contains("AND t.todo_id IS NOT NULL"));
+        String done = sql(statement("selectMyTasks", Set.of(2, 4), Map.of("q", Map.of("todoType", "DONE"))));
+        assertTrue(done.contains("AND t.todo_id IS NOT NULL"));
+    }
+}

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/vo/MobileWarningParticipantVO.java

@@ -0,0 +1,18 @@
+package com.zksy.base.warning.domain.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@AllArgsConstructor
+public class MobileWarningParticipantVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private Long userId;
+    private String userName;
+    private String nickName;
+    private Long deptId;
+    private String deptName;
+}

+ 20 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningProcessRequest.java

@@ -0,0 +1,20 @@
+package com.zksy.base.warning.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+@Data
+public class MobileWarningProcessRequest {
+    @NotBlank(message = "请填写处置内容")
+    @Size(max = 2000, message = "处置内容最多2000字")
+    private String content;
+
+    @Size(max = 9, message = "最多上传9个附件")
+    private List<String> attachmentTokens;
+
+    @Size(max = 20, message = "一次最多选择20个处置原因")
+    private List<String> reasonIds;
+}

+ 14 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningPublishRequest.java

@@ -0,0 +1,14 @@
+package com.zksy.base.warning.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class MobileWarningPublishRequest {
+    @NotBlank(message = "请选择处置人")
+    private String handler;
+
+    @NotBlank(message = "请选择核验人")
+    private String reviewer;
+}

+ 21 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningQuery.java

@@ -0,0 +1,21 @@
+package com.zksy.base.warning.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+
+@Data
+public class MobileWarningQuery {
+    @Min(value = 1, message = "页码不能小于1")
+    private int pageNum = 1;
+
+    @Min(value = 1, message = "每页条数不能小于1")
+    @Max(value = 100, message = "每页最多100条")
+    private int pageSize = 10;
+
+    private String keyword;
+    private String status;
+    private String warningType;
+    private String warningLevel;
+}

+ 11 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningRemarkRequest.java

@@ -0,0 +1,11 @@
+package com.zksy.base.warning.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.Size;
+
+@Data
+public class MobileWarningRemarkRequest {
+    @Size(max = 1000, message = "备注最多1000字")
+    private String remark;
+}

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/MobileWarningVerifyRequest.java

@@ -0,0 +1,18 @@
+package com.zksy.base.warning.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Pattern;
+import javax.validation.constraints.Size;
+
+@Data
+public class MobileWarningVerifyRequest {
+    @NotBlank(message = "请选择核验结果")
+    @Pattern(regexp = "CLEAR|RETURN", message = "核验结果只能为CLEAR或RETURN")
+    private String result;
+
+    @NotBlank(message = "请填写核验意见")
+    @Size(max = 1000, message = "核验意见最多1000字")
+    private String opinion;
+}

+ 14 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/dto/WarningAccessScope.java

@@ -0,0 +1,14 @@
+package com.zksy.base.warning.dto;
+
+import lombok.Value;
+import java.util.Set;
+
+/** 两端共用的预警可见范围,只由登录上下文构造。 */
+@Value
+public class WarningAccessScope {
+    String username;
+    boolean canPublish;
+    boolean canHandle;
+    boolean canAudit;
+    Set<Integer> parentTypeIds;
+}

+ 27 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/EarlyWarningMapper.java

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.base.warning.domain.EarlyWarning;
+import com.zksy.base.warning.domain.WarningArchive;
+import com.zksy.base.warning.dto.WarningAccessScope;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -18,20 +20,40 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
     @Select("SELECT * FROM early_warning WHERE warning_id=#{warningId} FOR UPDATE")
     EarlyWarning selectForUpdate(@Param("warningId") String warningId);
 
-    IPage<Map<String, Object>> selectWarningList(Page<?> page, @Param("warningNo") String warningNo, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status);
+    IPage<Map<String, Object>> selectWarningList(Page<?> page, @Param("warningNo") String warningNo, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status, @Param("scope") WarningAccessScope scope);
 
-    IPage<Map<String, Object>> searchWarnings(Page<?> page, @Param("warningNo") String warningNo, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status, @Param("publisher") String publisher, @Param("ownershipUnit") String ownershipUnit, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, @Param("excludePublisher") String excludePublisher, @Param("deviceCode") String deviceCode, @Param("deviceName") String deviceName, @Param("releaseOrder") boolean releaseOrder);
+    IPage<Map<String, Object>> searchWarnings(Page<?> page, @Param("warningNo") String warningNo, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status, @Param("publisher") String publisher, @Param("ownershipUnit") String ownershipUnit, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, @Param("excludePublisher") String excludePublisher, @Param("deviceCode") String deviceCode, @Param("deviceName") String deviceName, @Param("releaseOrder") boolean releaseOrder, @Param("scope") WarningAccessScope scope);
 
     IPage<Map<String, Object>> selectTodoList(Page<?> page, @Param("userId") String userId, @Param("todoType") String todoType, @Param("warningNo") String warningNo);
 
-    IPage<Map<String, Object>> selectMyTasks(Page<?> page, @Param("username") String username, @Param("userId") String userId, @Param("q") Map<String, String> filters);
+    IPage<Map<String, Object>> selectMyTasks(Page<?> page, @Param("username") String username, @Param("userId") String userId, @Param("q") Map<String, String> filters, @Param("scope") WarningAccessScope scope);
 
-    IPage<Map<String, Object>> selectProcessingWarnings(Page<?> page, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel);
+    IPage<Map<String, Object>> selectProcessingWarnings(Page<?> page, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("scope") WarningAccessScope scope);
 
-    IPage<Map<String, Object>> selectCompletedWarnings(Page<?> page, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);
+    IPage<Map<String, Object>> selectCompletedWarnings(Page<?> page, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, @Param("scope") WarningAccessScope scope);
 
     Map<String, Object> selectWarningDetail(@Param("warningId") String warningId);
 
+    IPage<Map<String, Object>> selectMobileWarnings(
+            Page<?> page,
+            @Param("scope") WarningAccessScope scope,
+            @Param("keyword") String keyword,
+            @Param("status") String status,
+            @Param("warningType") String warningType,
+            @Param("warningLevel") String warningLevel,
+            @Param("pendingOnly") boolean pendingOnly);
+
+    int countVisibleWarning(
+            @Param("warningId") String warningId,
+            @Param("scope") WarningAccessScope scope);
+
+    int countCompanyDevice(@Param("deviceCode") String deviceCode, @Param("scope") WarningAccessScope scope);
+
+    IPage<WarningArchive> selectVisibleArchives(Page<WarningArchive> page,
+            @Param("warningNo") String warningNo, @Param("warningType") String warningType,
+            @Param("warningLevel") String warningLevel, @Param("startTime") LocalDateTime startTime,
+            @Param("endTime") LocalDateTime endTime, @Param("scope") WarningAccessScope scope);
+
     List<Map<String, Object>> selectStatistics();
 
     /**

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

@@ -1,13 +1,12 @@
 package com.zksy.base.warning.mapper;
 
-import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.base.warning.domain.WarningParticipant;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
+import java.util.List;
 
 @Mapper
 public interface WarningParticipantMapper {
-    IPage<WarningParticipant> selectCandidates(Page<WarningParticipant> page,
+    List<WarningParticipant> selectCandidates(@Param("warningId") String warningId,
             @Param("deptId") Long deptId, @Param("keyword") String keyword);
 }

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/EarlyWarningService.java

@@ -8,6 +8,8 @@ import com.zksy.base.warning.domain.WarningArchive;
 import com.zksy.base.warning.domain.WarningDisposal;
 import com.zksy.base.warning.domain.WarningSupervision;
 import com.zksy.base.warning.domain.WarningAttachment;
+import com.zksy.base.warning.domain.vo.MobileWarningParticipantVO;
+import com.zksy.base.warning.dto.MobileWarningQuery;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.time.LocalDateTime;
@@ -28,6 +30,14 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     Map<String, Object> getWarningFullDetail(String warningId);
 
+    IPage<Map<String, Object>> getMobileWarningPage(MobileWarningQuery query, boolean pendingOnly);
+
+    Map<String, Object> getMobileWarningDetail(String warningId);
+
+    List<MobileWarningParticipantVO> getMobileWarningParticipants(String warningId, String role);
+
+    void requireMobileWarningVisible(String warningId);
+
     boolean saveWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files);
 
     boolean updateWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files);
@@ -58,6 +68,8 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files, List<String> reasonIds);
 
+    boolean submitMobileWarningProcess(String warningId, String processContent, List<String> attachmentTokens, List<String> reasonIds);
+
     boolean clearWarning(String warningId, String clearRemark);
 
     boolean upgradeWarning(String warningId, String newLevel, String disposalContent);

+ 42 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningAccessService.java

@@ -0,0 +1,42 @@
+package com.zksy.base.warning.service;
+
+import com.zksy.base.warning.dto.WarningAccessScope;
+import com.zksy.base.warning.mapper.EarlyWarningMapper;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.common.utils.SecurityUtils;
+import com.zksy.ops.support.OpsAssignScopeSupport;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class WarningAccessService {
+    private final EarlyWarningMapper warnings;
+    private final OpsAssignScopeSupport companyScope;
+
+    public WarningAccessScope current() {
+        return new WarningAccessScope(SecurityUtils.getUsername(),
+                SecurityUtils.hasPermi("warning:warning:publish"),
+                SecurityUtils.hasPermi("warning:warning:handle"),
+                SecurityUtils.hasPermi("warning:warning:audit"),
+                companyScope.currentCompanyEquipmentParentTypeIds());
+    }
+
+    public void requireVisible(String warningId) {
+        if (warningId == null || warningId.trim().isEmpty()) throw new ServiceException("预警ID不能为空");
+        if (warnings.countVisibleWarning(warningId, current()) == 0) {
+            throw new ServiceException("预警不存在或不在当前用户可见范围内");
+        }
+    }
+
+    public void requirePublisher(String warningId) {
+        if (!SecurityUtils.hasPermi("warning:warning:publish")) throw new ServiceException("没有预警发布权限");
+        requireVisible(warningId);
+    }
+
+    public void requireDeviceVisible(String deviceCode) {
+        if (warnings.countCompanyDevice(deviceCode, current()) == 0) {
+            throw new ServiceException("关联设备不在当前用户所属公司范围内,请重新选择");
+        }
+    }
+}

+ 66 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningParticipantService.java

@@ -9,6 +9,12 @@ import com.zksy.common.core.domain.entity.SysDept;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.system.mapper.SysDeptMapper;
 import com.zksy.system.service.ISysDeptService;
+import com.zksy.system.service.ISysMenuService;
+import com.zksy.common.utils.SecurityUtils;
+import java.util.Set;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.stream.Collectors;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
@@ -19,28 +25,81 @@ public class WarningParticipantService {
     private final SysDeptMapper deptMapper;
     private final ISysDeptService deptService;
     private final WarningParticipantMapper participantMapper;
+    private final WarningAccessService access;
+    private final ISysMenuService menus;
 
     public WarningParticipantService(SysDeptMapper deptMapper, ISysDeptService deptService,
-                                     WarningParticipantMapper participantMapper) {
+                                     WarningParticipantMapper participantMapper,
+                                     WarningAccessService access, ISysMenuService menus) {
         this.deptMapper = deptMapper;
         this.deptService = deptService;
         this.participantMapper = participantMapper;
+        this.access = access;
+        this.menus = menus;
     }
 
-    public List<TreeSelect> departments() {
+    public List<TreeSelect> departments(String warningId, String role) {
+        List<WarningParticipant> candidates = candidates(warningId, role, null, null);
+        Set<Long> ids = candidates.stream().map(WarningParticipant::getDeptId).collect(Collectors.toSet());
         SysDept query = new SysDept();
         query.setStatus("0");
-        // 直接使用现有 Mapper 查询全部启用部门;只向客户端输出 id/label 树。
-        return deptService.buildDeptTreeSelect(deptMapper.selectDeptList(query));
+        List<SysDept> departments = deptMapper.selectDeptList(query);
+        // 保留候选部门的父节点,使部门树完整;不暴露无关分支。
+        Set<Long> treeIds = new HashSet<>(ids);
+        departments.stream().filter(d -> ids.contains(d.getDeptId())).forEach(d -> {
+            if (d.getAncestors() != null) for (String id : d.getAncestors().split(",")) {
+                try { treeIds.add(Long.valueOf(id.trim())); } catch (NumberFormatException ignored) { }
+            }
+        });
+        return deptService.buildDeptTreeSelect(departments.stream()
+                .filter(d -> treeIds.contains(d.getDeptId())).collect(Collectors.toList()));
     }
 
-    public IPage<WarningParticipant> users(int pageNum, int pageSize, Long deptId, String keyword) {
+    public IPage<WarningParticipant> users(String warningId, String role, int pageNum, int pageSize, Long deptId, String keyword) {
+        List<WarningParticipant> users = candidates(warningId, role, deptId, keyword);
+        Page<WarningParticipant> page = new Page<>(Math.max(1, pageNum), Math.max(1, Math.min(100, pageSize)));
+        page.setTotal(users.size());
+        int from = (int) Math.min(users.size(), (page.getCurrent() - 1L) * page.getSize());
+        page.setRecords(users.subList(from, (int) Math.min(users.size(), from + page.getSize())));
+        return page;
+    }
+
+    public List<WarningParticipant> candidates(String warningId, String role, Long deptId, String keyword) {
+        access.requirePublisher(warningId);
+        String permission = permission(role);
         if (deptId != null && deptId <= 0) throw new ServiceException("请选择有效部门");
         if (keyword != null) {
             keyword = keyword.trim();
             if (keyword.length() > 100) throw new ServiceException("搜索关键字不能超过100个字符");
         }
-        Page<WarningParticipant> page = new Page<>(Math.max(1, pageNum), Math.max(1, Math.min(100, pageSize)));
-        return participantMapper.selectCandidates(page, deptId, keyword);
+        // 按实际权限(含通配权限)过滤后分页,避免页内过滤漏人;不走用户管理 @DataScope。
+        return participantMapper.selectCandidates(warningId, deptId, keyword).stream()
+                .filter(user -> hasPermission(user.getUserId(), permission)).collect(Collectors.toList());
+    }
+
+    public void requireCandidate(String warningId, String role, String username) {
+        if (username == null || username.trim().isEmpty()) throw new ServiceException("请选择" + label(role));
+        if (candidates(warningId, role, null, username.trim()).stream()
+                .noneMatch(user -> Objects.equals(user.getUserName(), username.trim()))) {
+            throw new ServiceException("所选" + label(role) + "已停用、缺少操作权限或不属于设备对应公司,请重新选择");
+        }
+    }
+
+    private boolean hasPermission(Long id, String permission) {
+        if (id == null) return false;
+        if (SecurityUtils.isAdmin(id)) return true;
+        Set<String> permissions = menus.selectMenuPermsByUserId(id);
+        return permissions != null && SecurityUtils.hasPermi(permissions, permission);
+    }
+
+    private String permission(String role) {
+        if ("handler".equals(role)) return "warning:warning:handle";
+        if ("reviewer".equals(role)) return "warning:warning:audit";
+        throw new ServiceException("候选人类型只能为handler或reviewer");
+    }
+
+    private String label(String role) {
+        permission(role);
+        return "handler".equals(role) ? "处置人" : "核验人";
     }
 }

+ 85 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningProcessAttachmentService.java

@@ -0,0 +1,85 @@
+package com.zksy.base.warning.service;
+
+import com.zksy.common.core.redis.RedisCache;
+import com.zksy.common.exception.ServiceException;
+import com.zksy.common.utils.SecurityUtils;
+import com.zksy.service.MinioFileStorageService;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.Serializable;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/** Temporary, user-bound upload tokens for mobile warning disposal images. */
+@Service
+public class WarningProcessAttachmentService {
+    private static final long MAX_BYTES = 20L * 1024L * 1024L;
+    private final RedisCache redis;
+    private final MinioFileStorageService storage;
+
+    public WarningProcessAttachmentService(RedisCache redis,
+                                           @Qualifier("minioFileStorageService") MinioFileStorageService storage) {
+        this.redis = redis;
+        this.storage = storage;
+    }
+
+    public Map<String, String> upload(String warningId, MultipartFile file) {
+        if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) {
+            throw new ServiceException("图片不能为空且单个不得超过20MB");
+        }
+        String name = Objects.toString(file.getOriginalFilename(), "");
+        String lowerName = name.toLowerCase(Locale.ROOT);
+        if (!(lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || lowerName.endsWith(".png"))) {
+            throw new ServiceException("仅支持JPG、JPEG、PNG格式的图片");
+        }
+        final String path;
+        try {
+            path = storage.uploadFile(file, "warning");
+        } catch (Exception e) {
+            throw new ServiceException("图片上传失败,请重试");
+        }
+        String token = UUID.randomUUID().toString();
+        UploadInfo info = new UploadInfo(path, name, file.getContentType(), file.getSize());
+        redis.setCacheObject(key(warningId, token), info, 2, TimeUnit.HOURS);
+        Map<String, String> result = new LinkedHashMap<>();
+        result.put("token", token);
+        result.put("name", name);
+        return result;
+    }
+
+    public List<UploadInfo> resolve(String warningId, List<String> tokens) {
+        if (tokens == null || tokens.isEmpty()) return Collections.emptyList();
+        if (tokens.size() > 9 || new HashSet<>(tokens).size() != tokens.size()) {
+            throw new ServiceException("最多上传9个不重复的图片");
+        }
+        List<UploadInfo> result = new ArrayList<>();
+        for (String token : tokens) {
+            if (token == null || !token.matches("[a-fA-F0-9-]{36}")) throw new ServiceException("附件凭据无效");
+            UploadInfo info = redis.getCacheObject(key(warningId, token));
+            if (info == null) throw new ServiceException("附件已过期或不属于本次处置,请重新上传");
+            result.add(info);
+        }
+        return result;
+    }
+
+    public void removeTokens(String warningId, List<String> tokens) {
+        if (tokens == null) return;
+        for (String token : tokens) redis.deleteObject(key(warningId, token));
+    }
+
+    private String key(String warningId, String token) {
+        return "warning:process:attachment:" + SecurityUtils.getUserId() + ":" + warningId + ":" + token;
+    }
+
+    @lombok.Data
+    @lombok.AllArgsConstructor
+    public static class UploadInfo implements Serializable {
+        private static final long serialVersionUID = 1L;
+        private String path;
+        private String name;
+        private String contentType;
+        private Long size;
+    }
+}

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

@@ -7,9 +7,14 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.mapper.EquipmentBaseMapper;
 import com.zksy.base.warning.domain.*;
+import com.zksy.base.warning.domain.vo.MobileWarningParticipantVO;
+import com.zksy.base.warning.dto.MobileWarningQuery;
 import com.zksy.base.warning.mapper.*;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.base.warning.service.WarningSupervisionNotifier;
+import com.zksy.base.warning.service.WarningProcessAttachmentService;
+import com.zksy.base.warning.service.WarningAccessService;
+import com.zksy.base.warning.service.WarningParticipantService;
 import com.zksy.common.core.domain.entity.SysDictData;
 import com.zksy.common.core.domain.entity.SysUser;
 import com.zksy.system.mapper.SysUserMapper;
@@ -63,23 +68,31 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     private MinioFileStorageService minioFileStorageService;
     @Autowired
     private SysUserMapper sysUserMapper;
+    @Autowired private WarningAccessService warningAccess;
+    @Autowired private WarningParticipantService participants;
     @Autowired
     private ObjectProvider<SmsTemplate> smsTemplateProvider;
     @Autowired
     private SmsProperties smsProperties;
     @Autowired
     private WarningSupervisionNotifier supervisionNotifier;
+    @Autowired
+    private WarningProcessAttachmentService processAttachmentService;
 
     @Override
     public IPage<Map<String, Object>> getWarningList(Page<?> page, String warningNo, String warningType, String warningLevel, String status) {
         reference.validateStatus(status);
-        return earlyWarningMapper.selectWarningList(page, warningNo, warningType, warningLevel, status);
+        IPage<Map<String, Object>> result = earlyWarningMapper.selectWarningList(page, warningNo, warningType, warningLevel, status, warningAccess.current());
+        result.getRecords().forEach(this::addWorkflowActions);
+        return result;
     }
 
     @Override
     public IPage<Map<String, Object>> searchWarnings(Page<?> page, String warningNo, String warningType, String warningLevel, String status, String publisher, String ownershipUnit, LocalDateTime startTime, LocalDateTime endTime, String excludePublisher, String deviceCode, String deviceName, boolean releaseOrder) {
         reference.validateStatus(status);
-        return earlyWarningMapper.searchWarnings(page, warningNo, warningType, warningLevel, status, publisher, ownershipUnit, startTime, endTime, excludePublisher, deviceCode, deviceName, releaseOrder);
+        IPage<Map<String, Object>> result = earlyWarningMapper.searchWarnings(page, warningNo, warningType, warningLevel, status, publisher, ownershipUnit, startTime, endTime, excludePublisher, deviceCode, deviceName, releaseOrder, warningAccess.current());
+        result.getRecords().forEach(this::addWorkflowActions);
+        return result;
     }
 
     @Override
@@ -98,13 +111,14 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             throw new ServiceException("待办类型只能为 TODO、DONE 或 NOTICE");
         }
         IPage<Map<String, Object>> result = earlyWarningMapper.selectMyTasks(page, currentUser(),
-                String.valueOf(SecurityUtils.getUserId()), filters);
+                String.valueOf(SecurityUtils.getUserId()), filters, warningAccess.current());
         result.getRecords().forEach(this::addWorkflowActions);
         return result;
     }
 
     @Override
     public Map<String, Object> getWarningFullDetail(String warningId) {
+        warningAccess.requireVisible(warningId);
         Map<String, Object> result = new HashMap<>();
         Map<String, Object> basicInfo = earlyWarningMapper.selectWarningDetail(warningId);
         if (basicInfo == null) {
@@ -124,6 +138,60 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         return result;
     }
 
+    @Override
+    public IPage<Map<String, Object>> getMobileWarningPage(MobileWarningQuery query, boolean pendingOnly) {
+        MobileWarningQuery safeQuery = query == null ? new MobileWarningQuery() : query;
+        if (!isBlank(safeQuery.getStatus()) && EarlyWarningStatusEnum.of(safeQuery.getStatus()) == null) {
+            throw new ServiceException("预警状态不正确");
+        }
+        Page<Map<String, Object>> page = new Page<>(safeQuery.getPageNum(), safeQuery.getPageSize());
+        IPage<Map<String, Object>> result = earlyWarningMapper.selectMobileWarnings(page, warningAccess.current(),
+                trimToNull(safeQuery.getKeyword()), trimToNull(safeQuery.getStatus()),
+                trimToNull(safeQuery.getWarningType()), trimToNull(safeQuery.getWarningLevel()), pendingOnly);
+        result.getRecords().forEach(this::addMobileWorkflowActions);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getMobileWarningDetail(String warningId) {
+        requireMobileWarningVisible(warningId);
+        Map<String, Object> detail = getWarningFullDetail(warningId);
+        if (detail == null) throw new ServiceException("预警不存在");
+        Object basic = detail.get("basicInfo");
+        if (basic instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> basicInfo = (Map<String, Object>) basic;
+            addMobileWorkflowActions(basicInfo);
+        }
+        return detail;
+    }
+
+    @Override
+    public void requireMobileWarningVisible(String warningId) {
+        warningAccess.requireVisible(warningId);
+    }
+
+    @Override
+    public List<MobileWarningParticipantVO> getMobileWarningParticipants(String warningId, String role) {
+        return participants.candidates(warningId, role, null, null).stream()
+                .map(user -> new MobileWarningParticipantVO(user.getUserId(), user.getUserName(),
+                        isBlank(user.getNickName()) ? user.getUserName() : user.getNickName(), user.getDeptId(),
+                        user.getDeptName()))
+                .collect(java.util.stream.Collectors.toList());
+    }
+
+    private void addMobileWorkflowActions(Map<String, Object> row) {
+        addWorkflowActions(row);
+        @SuppressWarnings("unchecked")
+        List<String> actions = new ArrayList<>((List<String>) row.get("availableActions"));
+        actions.retainAll(Arrays.asList("PUBLISH", "RECEIVE", "START", "HANDLE", "CLEAR", "RETURN"));
+        row.put("availableActions", actions);
+    }
+
+    private String trimToNull(String value) {
+        return isBlank(value) ? null : value.trim();
+    }
+
     private Map<String, Object> getGisLocation(Map<String, Object> warningInfo) {
         Map<String, Object> gis = new HashMap<>();
 
@@ -274,6 +342,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean updateWarning(EarlyWarning warning, List<String> alarmIds, List<MultipartFile> files) {
+        warningAccess.requireVisible(warning.getWarningId());
         EarlyWarning existing = earlyWarningMapper.selectForUpdate(warning.getWarningId());
         WarningWorkflow.requireState(existing, "修改草稿", WarningWorkflow.DRAFT);
         reference.validateType(warning.getWarningType());
@@ -314,6 +383,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             throw new ServiceException("关联设备不存在,请重新选择");
         }
         reference.validateEquipmentType(warning.getWarningType(), equipment.getEquipmentTypeId());
+        warningAccess.requireDeviceVisible(equipment.getEquipmentCode());
         warning.setDeviceCode(equipment.getEquipmentCode());
         warning.setDeviceName(equipment.getEquipmentName());
         // 位置已从预警业务移除,兼容旧客户端时也不再写入。
@@ -323,6 +393,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean deleteWarning(String warningId) {
+        warningAccess.requireVisible(warningId);
         WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "删除", WarningWorkflow.DRAFT);
         // 先查询附件列表,删除物理文件
         List<WarningAttachment> attachments = warningAttachmentMapper.selectList(
@@ -390,16 +461,17 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean publishWarning(String warningId, String requestedHandler, String requestedReviewer) {
+        warningAccess.requirePublisher(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
         WarningWorkflow.requireState(warning, "发布", WarningWorkflow.DRAFT);
 
         String assignee = isBlank(requestedHandler) ? warning.getHandler() : requestedHandler.trim();
         // 发布不能再自动指派当前登录用户,处置人必须由发布人明确选择或提前保存。
-        requireActiveUser(assignee, "处置人");
+        participants.requireCandidate(warningId, "handler", assignee);
         String reviewer = isBlank(requestedReviewer)
                 ? (isBlank(warning.getReviewer()) ? currentUser() : warning.getReviewer().trim())
                 : requestedReviewer.trim();
-        requireActiveUser(reviewer, "核查人");
+        participants.requireCandidate(warningId, "reviewer", reviewer);
         warning.setReviewer(reviewer);
 
         itemBinding.bind(warning);
@@ -523,6 +595,44 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         return true;
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean submitMobileWarningProcess(String warningId, String processContent, List<String> attachmentTokens,
+                                              List<String> reasonIds) {
+        List<WarningProcessAttachmentService.UploadInfo> uploads =
+                processAttachmentService.resolve(warningId, attachmentTokens);
+        boolean success = submitWarningProcess(warningId, processContent, Collections.emptyList(), reasonIds);
+        if (!success) return false;
+
+        WarningDisposal disposal = warningDisposalMapper.selectOne(new LambdaQueryWrapper<WarningDisposal>()
+                .eq(WarningDisposal::getWarningId, warningId)
+                .eq(WarningDisposal::getDisposalType, "HANDLE")
+                .orderByDesc(WarningDisposal::getCreateTime)
+                .last("LIMIT 1"));
+        for (WarningProcessAttachmentService.UploadInfo upload : uploads) {
+            WarningAttachment attachment = new WarningAttachment();
+            attachment.setWarningId(warningId);
+            attachment.setDisposalId(disposal == null ? null : disposal.getDisposalId());
+            attachment.setAttachmentStage("PROCESS");
+            attachment.setAttachmentName("[处理反馈] " + (isBlank(upload.getName()) ? "现场图片" : upload.getName()));
+            attachment.setAttachmentUrl(upload.getPath());
+            attachment.setAttachmentType(truncateAttachmentType(upload.getContentType()));
+            attachment.setAttachmentSize(upload.getSize());
+            attachment.setCreateBy(currentUser());
+            attachment.setCreateTime(LocalDateTime.now());
+            warningAttachmentMapper.insert(attachment);
+        }
+        if (!uploads.isEmpty() && TransactionSynchronizationManager.isSynchronizationActive()) {
+            List<String> tokens = new ArrayList<>(attachmentTokens);
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override public void afterCommit() {
+                    processAttachmentService.removeTokens(warningId, tokens);
+                }
+            });
+        }
+        return true;
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean clearWarning(String warningId, String clearRemark) {
@@ -559,6 +669,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     }
 
     private void requireHandler(EarlyWarning warning) {
+        warningAccess.requireVisible(warning.getWarningId());
         if (!SecurityUtils.hasPermi("warning:warning:handle")) {
             throw new ServiceException("没有预警处置权限");
         }
@@ -572,6 +683,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     }
 
     private void requireReviewer(EarlyWarning warning) {
+        warningAccess.requireVisible(warning.getWarningId());
         if (!SecurityUtils.hasPermi("warning:warning:audit")) {
             throw new ServiceException("没有预警核验权限");
         }
@@ -603,6 +715,12 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             actions.removeAll(Arrays.asList("RECEIVE", "START", "HANDLE"));
         }
         if (!SecurityUtils.hasPermi("warning:warning:audit")) actions.removeAll(Arrays.asList("CLEAR", "RETURN"));
+        if (!SecurityUtils.hasPermi("warning:warning:publish")) actions.remove("PUBLISH");
+        if (!SecurityUtils.hasPermi("warning:warning:edit")) actions.remove("EDIT");
+        if (!SecurityUtils.hasPermi("warning:warning:remove")) actions.remove("DELETE");
+        if (!SecurityUtils.hasPermi("warning:warning:handle")) actions.remove("UPGRADE");
+        if (!SecurityUtils.hasPermi("warning:warning:supervision")) actions.remove("SUPERVISION");
+        if (!SecurityUtils.hasPermi("warning:warning:instruction")) actions.remove("INSTRUCTION");
         if ("DONE".equals(row.get("todo_type"))) actions.clear();
         row.put("availableActions", actions);
         row.put("statusLabel", WarningWorkflow.label(state));
@@ -612,6 +730,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean uploadReport(String warningId, List<MultipartFile> files) {
+        warningAccess.requireVisible(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
         WarningWorkflow.requireState(warning, "上传报告", WarningWorkflow.DRAFT, WarningWorkflow.PENDING,
                 WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
@@ -624,6 +743,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean upgradeWarning(String warningId, String newLevel, String disposalContent) {
+        warningAccess.requireVisible(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
         WarningWorkflow.requireState(warning, "升级", WarningWorkflow.PENDING, WarningWorkflow.RECEIVED,
                 WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
@@ -676,6 +796,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public WarningSupervision supervisionWarning(String warningId, String supervisionUser, String supervisionContent, String notificationType, String notificationContent, MultipartFile attachment) {
+        warningAccess.requireVisible(warningId);
         EarlyWarning warning = earlyWarningMapper.selectForUpdate(warningId);
         WarningWorkflow.requireState(warning, "督办",
                 WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
@@ -747,6 +868,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean addLeaderInstruction(String warningId, String leaderName, String instructionContent, String smsReceivers) {
+        warningAccess.requireVisible(warningId);
         WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "添加领导批示",
                 WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
         if (isBlank(leaderName) || isBlank(instructionContent)) throw new ServiceException("请填写批示领导和批示内容");
@@ -763,6 +885,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean linkAlarms(String warningId, List<String> alarmIds) {
+        warningAccess.requireVisible(warningId);
         WarningWorkflow.requireState(earlyWarningMapper.selectForUpdate(warningId), "关联报警",
                 WarningWorkflow.DRAFT, WarningWorkflow.PENDING, WarningWorkflow.RECEIVED, WarningWorkflow.PROCESSING, WarningWorkflow.HANDLED);
         LambdaQueryWrapper<AlarmWarningRel> wrapper = new LambdaQueryWrapper<>();
@@ -784,31 +907,35 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public List<Map<String, Object>> getLinkedAlarms(String warningId) {
+        warningAccess.requireVisible(warningId);
         return alarmWarningRelMapper.selectAlarmListByWarningId(warningId);
     }
 
     @Override
     public List<Map<String, Object>> getDisposalList(String warningId) {
+        warningAccess.requireVisible(warningId);
         return warningDisposalMapper.selectDisposalList(warningId);
     }
 
     @Override
     public List<Map<String, Object>> getDisposalHistory(String warningId) {
+        warningAccess.requireVisible(warningId);
         return warningDisposalMapper.selectDisposalList(warningId);
     }
 
     @Override
     public IPage<Map<String, Object>> getProcessingWarnings(Page<?> page, String warningType, String warningLevel) {
-        return earlyWarningMapper.selectProcessingWarnings(page, warningType, warningLevel);
+        return earlyWarningMapper.selectProcessingWarnings(page, warningType, warningLevel, warningAccess.current());
     }
 
     @Override
     public IPage<Map<String, Object>> getCompletedWarnings(Page<?> page, String warningType, String warningLevel, LocalDateTime startTime, LocalDateTime endTime) {
-        return earlyWarningMapper.selectCompletedWarnings(page, warningType, warningLevel, startTime, endTime);
+        return earlyWarningMapper.selectCompletedWarnings(page, warningType, warningLevel, startTime, endTime, warningAccess.current());
     }
 
     @Override
     public List<Map<String, Object>> getAttachments(String warningId) {
+        warningAccess.requireVisible(warningId);
         return getAttachmentList(warningId);
     }
 
@@ -934,7 +1061,9 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public WarningAttachment getAttachment(String attachmentId) {
-        return warningAttachmentMapper.selectById(attachmentId);
+        WarningAttachment attachment = warningAttachmentMapper.selectById(attachmentId);
+        if (attachment != null) warningAccess.requireVisible(attachment.getWarningId());
+        return attachment;
     }
 
     private void createTodo(String warningId, String userId, String taskName, String todoType, String actionType) {
@@ -953,6 +1082,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public List<WarningSupervision> getSupervisionList(String warningId) {
+        warningAccess.requireVisible(warningId);
         return warningSupervisionMapper.selectByWarningId(warningId);
     }
 
@@ -972,6 +1102,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public WarningArchive getArchiveByWarningId(String warningId) {
+        warningAccess.requireVisible(warningId);
         LambdaQueryWrapper<WarningArchive> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(WarningArchive::getWarningId, warningId);
         return warningArchiveMapper.selectOne(wrapper);
@@ -979,26 +1110,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public IPage<WarningArchive> getArchiveList(Page<WarningArchive> page, String warningNo, String warningType, String warningLevel, LocalDateTime startTime, LocalDateTime endTime) {
-        LambdaQueryWrapper<WarningArchive> wrapper = new LambdaQueryWrapper<>();
-
-        if (warningNo != null && !warningNo.isEmpty()) {
-            wrapper.like(WarningArchive::getWarningNo, warningNo);
-        }
-        if (warningType != null && !warningType.isEmpty()) {
-            wrapper.eq(WarningArchive::getWarningType, warningType);
-        }
-        if (warningLevel != null && !warningLevel.isEmpty()) {
-            wrapper.eq(WarningArchive::getWarningLevel, warningLevel);
-        }
-        if (startTime != null) {
-            wrapper.ge(WarningArchive::getCreateTime, startTime);
-        }
-        if (endTime != null) {
-            wrapper.le(WarningArchive::getCreateTime, endTime);
-        }
-
-        wrapper.orderByDesc(WarningArchive::getCreateTime);
-        return warningArchiveMapper.selectPage(page, wrapper);
+        return earlyWarningMapper.selectVisibleArchives(page, warningNo, warningType, warningLevel,
+                startTime, endTime, warningAccess.current());
     }
 
     private static final Map<String, String> DISPOSAL_TYPE_MAP = new HashMap<>();
@@ -1019,6 +1132,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public List<WarningDisposal> getDisposalProcess(String warningId) {
+        warningAccess.requireVisible(warningId);
         LambdaQueryWrapper<WarningDisposal> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(WarningDisposal::getWarningId, warningId);
         wrapper.orderByAsc(WarningDisposal::getCreateTime);
@@ -1027,6 +1141,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public Map<String, Object> getProcessDiagram(String warningId) {
+        warningAccess.requireVisible(warningId);
         Map<String, Object> result = new HashMap<>();
         List<Map<String, Object>> nodes = new ArrayList<>();
         List<Map<String, Object>> edges = new ArrayList<>();
@@ -1124,6 +1239,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         if (disposal == null) {
             return null;
         }
+        warningAccess.requireVisible(disposal.getWarningId());
 
         Map<String, Object> result = new HashMap<>();
         result.put("disposalId", disposal.getDisposalId());
@@ -1147,6 +1263,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
 
     @Override
     public Map<String, Object> getProcessStatistics(String warningId) {
+        warningAccess.requireVisible(warningId);
         Map<String, Object> result = new HashMap<>();
 
         List<WarningDisposal> disposals = getDisposalProcess(warningId);

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

@@ -49,7 +49,7 @@ public class WorkOrderVO implements Serializable {
     @ApiModelProperty("工单问题描述")
     private String orderDesc;
 
-    @ApiModelProperty("责任人用户ID")
+    @ApiModelProperty("处置人用户ID")
     private Long receiveUser;
 
     @ApiModelProperty("工单状态:0-待派单 1-待接单 2-已接单 3-处理中 4-待验收 5-已结案 6-延期中 7-已取消")
@@ -97,9 +97,12 @@ public class WorkOrderVO implements Serializable {
     @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date updateTime;
 
-    @ApiModelProperty("责任人姓名(列表/详情均回填)")
+    @ApiModelProperty("处置人姓名(列表/详情均回填)")
     private String receiveUserName;
 
+    @ApiModelProperty("核验人姓名(列表/详情均回填)")
+    private String verifyUserName;
+
     @ApiModelProperty("创建人ID(创建工单时写入当前用户)")
     private Long createBy;
 

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

@@ -181,19 +181,19 @@ public interface WorkOrderService extends IService<WorkOrder> {
     boolean deleteWorkOrder(Long orderId);
 
     /**
-     * 获取可指派部门树
+     * 按工单设备公司与角色权限获取可指派部门树。
      */
-    List<TreeSelect> assignableDeptTree();
+    List<TreeSelect> assignableDeptTree(Long orderId, String type);
 
     /**
-     * 查询可指派用户
+     * 按工单设备公司与角色权限查询可指派用户。
      *
-     * @param deptId 部门ID,为空表示不限部门
+     * @param deptId 可选的部门浏览筛选,不覆盖工单设备对应的公司范围
      */
-    List<SysUser> assignableUsers(Long deptId);
+    List<SysUser> assignableUsers(Long orderId, String type, Long deptId);
 
     /** 移动端派单候选人,type 仅支持 handler/reviewer。 */
-    List<MobileAssignableUserVO> mobileAssignableUsers(String type);
+    List<MobileAssignableUserVO> mobileAssignableUsers(Long orderId, String type);
 
     /**
      * 查询需要通过 WebSocket 提醒的工单(待派单/待接单 或 已超时)

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

@@ -31,6 +31,10 @@ import com.zksy.ops.mapper.WorkOrderMapper;
 import com.zksy.common.constant.UserConstants;
 import com.zksy.common.core.domain.TreeSelect;
 import com.zksy.common.core.domain.entity.SysUser;
+import com.zksy.common.core.domain.entity.SysDept;
+import com.zksy.system.mapper.SysUserMapper;
+import com.zksy.system.mapper.SysDeptMapper;
+import com.zksy.system.service.ISysDeptService;
 import com.zksy.common.core.domain.model.LoginUser;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.common.utils.DateUtils;
@@ -152,6 +156,13 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Autowired
     private OpsAssignScopeSupport assignScopeSupport;
 
+    @Autowired
+    private SysUserMapper assignmentUserMapper;
+    @Autowired
+    private SysDeptMapper assignmentDeptMapper;
+    @Autowired
+    private ISysDeptService assignmentDeptService;
+
     @Override
     public Page<WorkOrderVO> findByPage(WorkOrderPageDTO query) {
         WorkOrderPageDTO condition = query == null ? new WorkOrderPageDTO() : query;
@@ -304,28 +315,38 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     }
 
     @Override
-    public List<TreeSelect> assignableDeptTree() {
-        return assignScopeSupport.assignableDeptTree();
+    public List<TreeSelect> assignableDeptTree(Long orderId, String type) {
+        Set<Long> ids = assignableUsers(orderId, type, null).stream()
+                .map(SysUser::getDeptId).filter(Objects::nonNull).collect(Collectors.toSet());
+        SysDept query = new SysDept();
+        query.setStatus("0");
+        List<SysDept> departments = assignmentDeptMapper.selectDeptList(query);
+        Set<Long> treeIds = new HashSet<>(ids);
+        departments.stream().filter(d -> ids.contains(d.getDeptId())).forEach(d -> {
+            if (d.getAncestors() != null) for (String id : d.getAncestors().split(",")) {
+                try { treeIds.add(Long.valueOf(id.trim())); } catch (NumberFormatException ignored) { }
+            }
+        });
+        return assignmentDeptService.buildDeptTreeSelect(departments.stream()
+                .filter(d -> treeIds.contains(d.getDeptId())).collect(Collectors.toList()));
     }
 
     @Override
-    public List<SysUser> assignableUsers(Long deptId) {
-        return assignScopeSupport.assignableUsers(deptId);
+    public List<SysUser> assignableUsers(Long orderId, String type, Long deptId) {
+        if (!SecurityUtils.hasPermi(PERM_DISPATCH)) throw new ServiceException("没有工单派单权限");
+        String permission = assignmentPermission(type);
+        WorkOrder order = getOrderById(requireOrderId(orderId));
+        requireStatus(order, WorkOrderStatusEnum.PENDING_DISPATCH, "仅待派单工单允许选择人员");
+        requireDispatchScope(order);
+        if (deptId != null && deptId <= 0) throw new ServiceException("请选择有效部门");
+        return assignmentUserMapper.selectAssignmentCandidates(assignmentCompanyDeptId(order), deptId).stream()
+                .filter(this::isNormalUser)
+                .filter(user -> userHasPermission(user.getUserId(), permission)).collect(Collectors.toList());
     }
 
     @Override
-    public List<MobileAssignableUserVO> mobileAssignableUsers(String type) {
-        String permission;
-        if ("handler".equals(type)) {
-            permission = PERM_PROCESS;
-        } else if ("reviewer".equals(type)) {
-            permission = PERM_AUDIT;
-        } else {
-            throw new ServiceException("候选人类型不正确");
-        }
-        return assignScopeSupport.assignableUsersInScope().stream()
-                .filter(this::isNormalUser)
-                .filter(user -> userHasPermission(user.getUserId(), permission))
+    public List<MobileAssignableUserVO> mobileAssignableUsers(Long orderId, String type) {
+        return assignableUsers(orderId, type, null).stream()
                 .map(user -> new MobileAssignableUserVO(user.getUserId(), userLabel(user), user.getDeptId(),
                         user.getDept() == null ? null : user.getDept().getDeptName()))
                 .collect(Collectors.toList());
@@ -501,7 +522,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         Long id = requireOrderId(orderId);
         Long handlerId = dto.getHandlerId();
         if (handlerId == null) {
-            throw new ServiceException("责任人不能为空");
+            throw new ServiceException("处置人不能为空");
         }
         Long verifyUserId = dto.getVerifyUser();
         if (verifyUserId == null) {
@@ -512,18 +533,18 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("仅待派单工单允许派单");
         }
         requireDispatchScope(order);
-        assignScopeSupport.validateAssignUserInScope(handlerId);
         SysUser handler = sysUserService.selectUserById(handlerId);
         if (handler == null || !UserConstants.NORMAL.equals(String.valueOf(handler.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(handler.getDelFlag()))) {
-            throw new ServiceException("责任人不存在或已停用");
+            throw new ServiceException("处置人不存在或已停用");
         }
-        requireUserPermission(handlerId, PERM_PROCESS, "责任人");
-        assignScopeSupport.validateAssignUserInScope(verifyUserId);
+        requireUserPermission(handlerId, PERM_PROCESS, "处置人");
+        requireAssignmentCompany(order, handlerId, "处置人");
         SysUser verifier = sysUserService.selectUserById(verifyUserId);
         if (verifier == null || !UserConstants.NORMAL.equals(String.valueOf(verifier.getStatus())) || !UserConstants.NORMAL.equals(String.valueOf(verifier.getDelFlag()))) {
             throw new ServiceException("核验人不存在或已停用");
         }
         requireUserPermission(verifyUserId, PERM_AUDIT, "核验人");
+        requireAssignmentCompany(order, verifyUserId, "核验人");
 
         Date now = new Date();
         this.update(updateWrapper(id).set(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_ACCEPT.getCode()).set(WorkOrder::getReceiveUser, handlerId).set(WorkOrder::getVerifyUser, verifyUserId)
@@ -531,7 +552,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
                 .set(dto.getPlanFinishTime() != null, WorkOrder::getPlanFinishTime, dto.getPlanFinishTime())
                 .eq(WorkOrder::getOrderStatus, WorkOrderStatusEnum.PENDING_DISPATCH.getCode()));
 
-        String logContent = "派单给责任人:" + userLabel(handler) + ",核验人:" + userLabel(verifier)
+        String logContent = "派单给处置人:" + userLabel(handler) + ",核验人:" + userLabel(verifier)
                 + (StringUtils.isEmpty(dto.getRemarks()) ? "" : ",备注:" + dto.getRemarks());
         writeLog(id, WorkOrderLogOperTypeEnum.DISPATCH, logContent, null);
         return true;
@@ -541,7 +562,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     public boolean accept(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
-        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "核查接单");
         requireStatus(order, WorkOrderStatusEnum.PENDING_ACCEPT, "仅待接单工单允许核查接单");
         // 核查接单必须上传现场情况附件
@@ -561,7 +581,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     public boolean process(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
-        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "处置");
         Integer status = order.getOrderStatus();
         if (!WorkOrderStatusEnum.canProcess(status)) {
@@ -584,7 +603,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Transactional(rollbackFor = Exception.class)
     public boolean submitVerify(Long orderId, WorkOrderOperateDTO dto) {
         WorkOrder order = getOrderById(orderId);
-        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "提交验收");
         Integer status = order.getOrderStatus();
         if (!WorkOrderStatusEnum.canSubmitVerify(status)) {
@@ -611,7 +629,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("验收结果不能为空");
         }
         WorkOrder order = getOrderById(id);
-        requireDeviceCompanyScope(order);
         requireVerifier(order, currentUserId(), "验收");
         requireStatus(order, WorkOrderStatusEnum.PENDING_VERIFY, "仅待验收工单允许验收");
 
@@ -668,7 +685,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     public boolean applyDelay(Long orderId, WorkOrderDelayApplyDTO dto) {
         Long id = requireOrderId(orderId);
         WorkOrder order = getOrderById(id);
-        requireDeviceCompanyScope(order);
         requireResponsible(order, currentUserId(), "申请延期");
         requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许申请延期");
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.NONE.getCode(), order.getDelayApplyStatus())) {
@@ -697,7 +713,6 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             throw new ServiceException("延期审核结果不能为空");
         }
         WorkOrder order = getOrderById(id);
-        requireDeviceCompanyScope(order);
         requireVerifier(order, currentUserId(), "延期审核");
         requireStatus(order, WorkOrderStatusEnum.PROCESSING, "仅处理中工单允许延期审核");
         if (!Objects.equals(WorkOrderDelayApplyStatusEnum.PENDING_AUDIT.getCode(), order.getDelayApplyStatus())) {
@@ -880,6 +895,32 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         requireDeviceCompanyScope(order);
     }
 
+    private String assignmentPermission(String type) {
+        if ("handler".equals(type)) return PERM_PROCESS;
+        if ("reviewer".equals(type)) return PERM_AUDIT;
+        throw new ServiceException("候选人类型只能为handler或reviewer");
+    }
+
+    /** 无设备及共享类型不按候选人的部门限制;关联损坏不可当作无设备放宽。 */
+    private Long assignmentCompanyDeptId(WorkOrder order) {
+        if (StringUtils.isEmpty(order.getDeviceId())) return null;
+        EquipmentBase device = equipmentBaseMapper.selectById(order.getDeviceId());
+        EquipmentType type = device == null || device.getEquipmentTypeId() == null ? null
+                : equipmentTypeMapper.selectById(device.getEquipmentTypeId());
+        Integer parent = type == null ? null : type.getParentId();
+        if (Objects.equals(parent, 4)) return null;
+        if (parent != null && parent >= 1 && parent <= 3) return 100L + parent;
+        throw new ServiceException("工单关联设备或设备类型无效,无法选择人员");
+    }
+
+    private void requireAssignmentCompany(WorkOrder order, Long userId, String label) {
+        Long company = assignmentCompanyDeptId(order);
+        if (assignmentUserMapper.selectAssignmentCandidates(company, null).stream()
+                .noneMatch(user -> Objects.equals(user.getUserId(), userId))) {
+            throw new ServiceException("所选" + label + "已停用或不属于设备对应公司,请重新选择");
+        }
+    }
+
     private void requireVerifier(WorkOrder order, Long userId, String operation) {
         if (!Objects.equals(order.getVerifyUser(), userId)) {
             throw new ServiceException("仅指定核验人可以" + operation);
@@ -984,7 +1025,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             return;
         }
         if (order.getReceiveUser() == null || !Objects.equals(order.getReceiveUser(), operUserId)) {
-            throw new ServiceException("仅工单责任人可" + actionName);
+            throw new ServiceException("仅工单处置人可" + actionName);
         }
     }
 
@@ -1229,7 +1270,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     }
 
     /**
-     * 批量回填责任人/派单人姓名,避免逐条查库。
+     * 批量回填处置人/核验人姓名,避免逐条查库。
      *
      * <p>姓名属于展示字段,只存在于出参 {@link WorkOrderVO},实体不承载。</p>
      */
@@ -1242,6 +1283,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             if (record.getReceiveUser() != null) {
                 record.setReceiveUserName(userMap.getOrDefault(record.getReceiveUser(), String.valueOf(record.getReceiveUser())));
             }
+            if (record.getVerifyUser() != null) {
+                record.setVerifyUserName(userMap.getOrDefault(record.getVerifyUser(), String.valueOf(record.getVerifyUser())));
+            }
         }
     }
 
@@ -1351,7 +1395,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
      * 按本页实际涉及的用户ID批量查询并构建「用户ID → 姓名」映射。
      *
      * <p>此前是全量拉取用户表(selectAllEnabledUser)再建 Map,用户量大时每次分页查询都要扫描全表。
-     * 实际只用到当页记录的 receiveUser,故改为按 ID 精确查询。</p>
+     * 实际只用到当页记录的 receiveUser、verifyUser,故改为按 ID 精确查询。</p>
      *
      * @param records 当前页工单记录
      * @return 用户ID → 姓名的映射;查询失败时返回空 Map(降级为展示ID,不影响主流程)
@@ -1362,6 +1406,9 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             if (record.getReceiveUser() != null) {
                 userIds.add(record.getReceiveUser());
             }
+            if (record.getVerifyUser() != null) {
+                userIds.add(record.getVerifyUser());
+            }
         }
         if (userIds.isEmpty()) {
             return new HashMap<>(0);
@@ -1379,7 +1426,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             }
             return userMap;
         } catch (Exception e) {
-            log.warn("加载工单责任人用户映射失败", e);
+            log.warn("加载工单处置相关人员映射失败", e);
             return new HashMap<>(0);
         }
     }

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

@@ -12,6 +12,10 @@ import com.zksy.system.domain.vo.ContactUserVo;
  */
 public interface SysUserMapper
 {
+    /** 业务派单候选人员,不应用用户管理的数据范围;公司为空时不限制部门。 */
+    List<SysUser> selectAssignmentCandidates(@Param("companyDeptId") Long companyDeptId,
+                                             @Param("deptId") Long deptId);
+
     /**
      * 根据条件分页查询用户列表
      * 

+ 18 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/system/SysUserMapper.xml

@@ -4,6 +4,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.zksy.system.mapper.SysUserMapper">
 
+    <select id="selectAssignmentCandidates" resultMap="SysUserResult">
+        SELECT u.user_id, u.user_name, u.nick_name, u.dept_id, u.status, u.del_flag,
+               d.dept_name, d.ancestors, d.parent_id, d.status AS dept_status
+        FROM app_user.sys_user u
+        LEFT JOIN app_user.sys_dept d ON d.dept_id = u.dept_id
+        WHERE u.status = '0' AND u.del_flag = '0'
+        <if test="companyDeptId != null">
+          AND d.status = '0' AND d.del_flag = '0'
+          AND (d.dept_id = #{companyDeptId}
+               OR POSITION(CONCAT(',', #{companyDeptId}, ',') IN CONCAT(',', d.ancestors, ',')) &gt; 0)
+        </if>
+        <if test="deptId != null">
+          AND (d.dept_id = #{deptId}
+               OR POSITION(CONCAT(',', #{deptId}, ',') IN CONCAT(',', d.ancestors, ',')) &gt; 0)
+        </if>
+        ORDER BY d.order_num, d.dept_id, u.user_name, u.user_id
+    </select>
+
     <resultMap type="SysUser" id="SysUserResult">
         <id     property="userId"        column="user_id"         />
         <result property="deptId"        column="dept_id"         />

+ 150 - 8
pipe-network-service/zksy-system/src/main/resources/mapper/warning/EarlyWarningMapper.xml

@@ -774,6 +774,7 @@
             (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name
         FROM early_warning w
         <where>
+            <include refid="WarningVisibility"/>
             <if test="warningNo != null and warningNo != ''">AND w.warning_no LIKE CONCAT('%',#{warningNo},'%')</if>
             <if test="warningType != null and warningType != ''">
                 AND w.warning_type = #{warningType}
@@ -794,6 +795,7 @@
             (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name
         FROM early_warning w
         <where>
+            <include refid="WarningVisibility"/>
             <if test="warningNo != null and warningNo != ''">
                 AND w.warning_no LIKE CONCAT('%', #{warningNo}, '%')
             </if>
@@ -872,12 +874,17 @@
     <select id="selectMyTasks" resultType="java.util.Map">
         SELECT w.*, (SELECT wt.warning_name FROM app_user.warning_type wt WHERE wt.warning_code=w.warning_type ORDER BY wt.id LIMIT 1) AS warning_type_name,
             (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='alert_level' AND dict_value=w.warning_level AND status='0' ORDER BY dict_sort LIMIT 1) AS warning_level_name,
-            (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name, t.todo_id, t.todo_type, t.action_type, t.task_name, t.read_status,
+            (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name, t.todo_id,
+            <choose>
+                <when test="q.todoType == null or q.todoType == '' or q.todoType == 'TODO'">'TODO' AS todo_type,</when>
+                <otherwise>t.todo_type,</otherwise>
+            </choose>
+            t.action_type, t.task_name, t.read_status,
                t.create_time AS task_create_time, t.complete_time,
                (SELECT STRING_AGG(DISTINCT a.alarm_no, ',')
                 FROM alarm_warning_rel rel JOIN alarm_data a ON a.id = rel.alarm_id
                 WHERE rel.warning_id = w.warning_id) AS alarm_numbers
-        FROM (
+        FROM early_warning w LEFT JOIN (
             SELECT task.*, ROW_NUMBER() OVER (
                   PARTITION BY CASE WHEN task.todo_type = 'NOTICE' THEN task.todo_id ELSE task.warning_id END
                 ORDER BY task.complete_time DESC NULLS LAST, task.create_time DESC, task.todo_id DESC
@@ -887,8 +894,15 @@
                    OR (task.user_name IS NULL AND task.user_id = #{username}))
               <if test="q.todoType != null and q.todoType != ''">AND task.todo_type = #{q.todoType}</if>
               <if test="q.todoType == null or q.todoType == ''">AND task.todo_type IN ('TODO', 'DONE')</if>
-        ) t JOIN early_warning w ON w.warning_id = t.warning_id
-        WHERE t.task_rank = 1
+        ) t ON w.warning_id = t.warning_id AND t.task_rank = 1
+        WHERE 1 = 1
+        <include refid="WarningVisibility"/>
+        <choose>
+            <when test="q.todoType == null or q.todoType == '' or q.todoType == 'TODO'">
+                <include refid="WarningPending"/>
+            </when>
+            <otherwise>AND t.todo_id IS NOT NULL</otherwise>
+        </choose>
 
         <if test="q.warningNo != null and q.warningNo != ''">AND w.warning_no LIKE CONCAT('%',#{q.warningNo},'%')</if>
         <if test="q.warningSpecial != null and q.warningSpecial != ''">AND w.warning_special LIKE CONCAT('%',#{q.warningSpecial},'%')</if>
@@ -898,13 +912,13 @@
         <if test="q.handler != null and q.handler != ''">AND w.handler LIKE CONCAT('%',#{q.handler},'%')</if>
         <if test="q.publisher != null and q.publisher != ''">AND w.publisher LIKE CONCAT('%',#{q.publisher},'%')</if>
         <if test="q.ownershipUnit != null and q.ownershipUnit != ''">AND w.ownership_unit LIKE CONCAT('%',#{q.ownershipUnit},'%')</if>
-        <if test="q.startTime != null and q.startTime != ''">AND w.publish_time &gt;= CAST(#{q.startTime} AS TIMESTAMP)</if>
-        <if test="q.endTime != null and q.endTime != ''">AND w.publish_time &lt;= CAST(#{q.endTime} AS TIMESTAMP)</if>
+        <if test="q.startTime != null and q.startTime != ''">AND COALESCE(w.publish_time, w.create_time) &gt;= CAST(#{q.startTime} AS TIMESTAMP)</if>
+        <if test="q.endTime != null and q.endTime != ''">AND COALESCE(w.publish_time, w.create_time) &lt;= CAST(#{q.endTime} AS TIMESTAMP)</if>
         <if test="q.alarmCode != null and q.alarmCode != ''">
             AND EXISTS (SELECT 1 FROM alarm_warning_rel r JOIN alarm_data a ON a.id = r.alarm_id
                         WHERE r.warning_id = w.warning_id AND a.alarm_no LIKE CONCAT('%',#{q.alarmCode},'%'))
         </if>
-        ORDER BY t.create_time DESC, t.todo_id DESC
+        ORDER BY COALESCE(t.create_time, w.create_time) DESC, w.warning_id DESC
     </select>
 
     <select id="selectProcessingWarnings" resultType="java.util.Map">
@@ -913,6 +927,7 @@
             (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name
         FROM early_warning w
         <where>
+            <include refid="WarningVisibility"/>
             AND w.status IN ('PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED')
             <if test="warningType != null and warningType != ''">
                 AND w.warning_type = #{warningType}
@@ -930,6 +945,7 @@
             (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name
         FROM early_warning w
         <where>
+            <include refid="WarningVisibility"/>
             AND w.status = 'CLOSED'
             <if test="warningType != null and warningType != ''">
                 AND w.warning_type = #{warningType}
@@ -947,11 +963,137 @@
         ORDER BY w.update_time DESC
     </select>
 
+    <sql id="WarningVisibility">
+        AND (
+            w.create_by = #{scope.username}
+            OR w.publisher = #{scope.username}
+            OR w.handler = #{scope.username}
+            OR w.reviewer = #{scope.username}
+            OR (w.create_by = '系统自动' AND #{scope.canPublish} = TRUE)
+            OR (w.status = 'DRAFT' AND #{scope.canPublish} = TRUE)
+        )
+        AND EXISTS (
+            SELECT 1
+            FROM app_user.equipment_base eb
+            INNER JOIN app_user.equipment_type et ON et.id = eb.equipment_type_id
+            WHERE eb.equipment_code = w.device_code
+              AND (
+              <choose>
+              <when test="scope.parentTypeIds != null and !scope.parentTypeIds.isEmpty()">
+              et.parent_id IN
+              <foreach collection="scope.parentTypeIds" item="parentTypeId" open="(" separator="," close=")">
+                  #{parentTypeId}
+              </foreach>
+              </when>
+              <otherwise>1 = 0</otherwise>
+              </choose>
+              OR (et.parent_id = 4 AND (w.handler = #{scope.username} OR w.reviewer = #{scope.username}))
+              )
+        )
+    </sql>
+
+    <sql id="WarningPending">
+        AND (
+            (w.handler = #{scope.username} AND #{scope.canHandle} = TRUE
+                AND w.status IN ('PENDING', 'RECEIVED', 'PROCESSING'))
+            OR (w.reviewer = #{scope.username} AND #{scope.canAudit} = TRUE AND w.status = 'HANDLED')
+            OR (w.status = 'DRAFT' AND #{scope.canPublish} = TRUE)
+        )
+    </sql>
+
+    <select id="selectMobileWarnings" resultType="java.util.Map">
+        SELECT
+            w.warning_id AS "warningId",
+            w.warning_no AS "warningNo",
+            w.warning_type AS "warningType",
+            COALESCE((SELECT NULLIF(TRIM(wt.warning_name), '') FROM app_user.warning_type wt
+                WHERE wt.warning_code = w.warning_type ORDER BY wt.id LIMIT 1), w.warning_type) AS "warningTypeName",
+            w.warning_level AS "warningLevel",
+            COALESCE((SELECT NULLIF(TRIM(dl.dict_label), '') FROM app_user.sys_dict_data dl
+                WHERE dl.dict_type = 'alert_level' AND dl.dict_value = w.warning_level AND dl.status = '0'
+                ORDER BY dl.dict_sort LIMIT 1), w.warning_level) AS "warningLevelName",
+            w.warning_content AS "warningContent",
+            w.device_code AS "deviceCode",
+            w.device_name AS "deviceName",
+            w.location,
+            w.status,
+            w.create_by AS "createBy",
+            w.publisher,
+            w.handler,
+            COALESCE(NULLIF(TRIM(hu.nick_name), ''), w.handler) AS "handlerName",
+            w.reviewer,
+            COALESCE(NULLIF(TRIM(ru.nick_name), ''), w.reviewer) AS "reviewerName",
+            w.publish_time AS "publishTime",
+            w.create_time AS "createTime"
+        FROM app_user.early_warning w
+        LEFT JOIN app_user.sys_user hu ON hu.user_name = w.handler
+        LEFT JOIN app_user.sys_user ru ON ru.user_name = w.reviewer
+        WHERE 1 = 1
+        <include refid="WarningVisibility"/>
+        <if test="pendingOnly">
+            <include refid="WarningPending"/>
+        </if>
+        <if test="keyword != null and keyword != ''">
+            AND (w.warning_no LIKE CONCAT('%', #{keyword}, '%')
+                OR w.warning_content LIKE CONCAT('%', #{keyword}, '%')
+                OR w.device_code LIKE CONCAT('%', #{keyword}, '%')
+                OR w.device_name LIKE CONCAT('%', #{keyword}, '%'))
+        </if>
+        <if test="status != null and status != ''">AND w.status = #{status}</if>
+        <if test="warningType != null and warningType != ''">AND w.warning_type = #{warningType}</if>
+        <if test="warningLevel != null and warningLevel != ''">AND w.warning_level = #{warningLevel}</if>
+        ORDER BY COALESCE(w.publish_time, w.create_time) DESC, w.warning_id DESC
+    </select>
+
+    <select id="countVisibleWarning" resultType="int">
+        SELECT COUNT(1)
+        FROM app_user.early_warning w
+        WHERE w.warning_id = #{warningId}
+        <include refid="WarningVisibility"/>
+    </select>
+
+    <select id="countCompanyDevice" resultType="int">
+        SELECT COUNT(1) FROM app_user.equipment_base eb
+        INNER JOIN app_user.equipment_type et ON et.id = eb.equipment_type_id
+        WHERE eb.equipment_code = #{deviceCode}
+        <choose>
+            <when test="scope.parentTypeIds != null and !scope.parentTypeIds.isEmpty()">
+                AND et.parent_id IN
+                <foreach collection="scope.parentTypeIds" item="parentTypeId" open="(" separator="," close=")">
+                    #{parentTypeId}
+                </foreach>
+            </when>
+            <otherwise>AND 1 = 0</otherwise>
+        </choose>
+    </select>
+
+    <select id="selectVisibleArchives" resultType="com.zksy.base.warning.domain.WarningArchive">
+        SELECT a.* FROM app_user.warning_archive a
+        JOIN app_user.early_warning w ON w.warning_id = a.warning_id
+        <where>
+            <include refid="WarningVisibility"/>
+            <if test="warningNo != null and warningNo != ''">AND a.warning_no LIKE CONCAT('%', #{warningNo}, '%')</if>
+            <if test="warningType != null and warningType != ''">AND a.warning_type = #{warningType}</if>
+            <if test="warningLevel != null and warningLevel != ''">AND a.warning_level = #{warningLevel}</if>
+            <if test="startTime != null">AND a.create_time &gt;= #{startTime}</if>
+            <if test="endTime != null">AND a.create_time &lt;= #{endTime}</if>
+        </where>
+        ORDER BY a.create_time DESC
+    </select>
+
     <select id="selectWarningDetail" resultType="java.util.Map">
         SELECT w.*, (SELECT wt.warning_name FROM app_user.warning_type wt WHERE wt.warning_code=w.warning_type ORDER BY wt.id LIMIT 1) AS warning_type_name,
             (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='alert_level' AND dict_value=w.warning_level AND status='0' ORDER BY dict_sort LIMIT 1) AS warning_level_name,
-            (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name
+            (SELECT dict_label FROM app_user.sys_dict_data WHERE dict_type='pre_alarm_status' AND dict_value=w.status AND status='0' ORDER BY dict_sort LIMIT 1) AS status_name,
+            COALESCE(NULLIF(TRIM(hu.nick_name), ''), w.handler) AS handler_name,
+            COALESCE(NULLIF(TRIM(ru.nick_name), ''), w.reviewer) AS reviewer_name,
+            COALESCE(NULLIF(TRIM(pu.nick_name), ''), w.publisher) AS publisher_name,
+            COALESCE(NULLIF(TRIM(cu.nick_name), ''), w.create_by) AS creator_name
         FROM early_warning w
+        LEFT JOIN app_user.sys_user hu ON hu.user_name = w.handler
+        LEFT JOIN app_user.sys_user ru ON ru.user_name = w.reviewer
+        LEFT JOIN app_user.sys_user pu ON pu.user_name = w.publisher
+        LEFT JOIN app_user.sys_user cu ON cu.user_name = w.create_by
         WHERE w.warning_id = #{warningId}
     </select>
 

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

@@ -4,9 +4,22 @@
     <select id="selectCandidates" resultType="com.zksy.base.warning.domain.WarningParticipant">
         SELECT u.user_id, u.user_name, u.nick_name, u.dept_id, d.dept_name
         FROM app_user.sys_user u
-        JOIN app_user.sys_dept d ON d.dept_id = u.dept_id
+        LEFT JOIN app_user.sys_dept d ON d.dept_id = u.dept_id
         WHERE u.status = '0' AND u.del_flag = '0'
-          AND d.status = '0' AND d.del_flag = '0'
+          AND EXISTS (
+            SELECT 1 FROM app_user.early_warning w
+            JOIN app_user.equipment_base eb ON eb.equipment_code = w.device_code
+            JOIN app_user.equipment_type et ON et.id = eb.equipment_type_id
+            WHERE w.warning_id = #{warningId}
+              AND (
+                et.parent_id = 4
+                OR (d.status = '0' AND d.del_flag = '0' AND (
+                  (et.parent_id = 1 AND (d.dept_id = 101 OR POSITION(',101,' IN CONCAT(',', d.ancestors, ',')) &gt; 0))
+                  OR (et.parent_id = 2 AND (d.dept_id = 102 OR POSITION(',102,' IN CONCAT(',', d.ancestors, ',')) &gt; 0))
+                  OR (et.parent_id = 3 AND (d.dept_id = 103 OR POSITION(',103,' IN CONCAT(',', d.ancestors, ',')) &gt; 0))
+                ))
+              )
+          )
         <if test="deptId != null">
           AND (d.dept_id = #{deptId}
                OR POSITION(CONCAT(',', #{deptId}, ',') IN CONCAT(',', d.ancestors, ',')) &gt; 0)

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

@@ -28,6 +28,12 @@ import com.zksy.ops.dto.WorkOrderDispatchDTO;
 import com.zksy.ops.dto.WorkOrderVerifyDTO;
 import com.zksy.ops.support.OpsAssignScopeSupport;
 import com.zksy.system.service.ISysUserService;
+import com.zksy.system.mapper.SysUserMapper;
+import com.zksy.system.service.ISysMenuService;
+import com.zksy.common.core.domain.model.LoginUser;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.junit.jupiter.api.AfterEach;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
@@ -89,6 +95,8 @@ class WorkOrderServiceImplTest {
     private CaseInfoMapper caseInfoMapper;
     private HazardHiddenAccountMapper hazardMapper;
     private OpsAssignScopeSupport assignScopeSupport;
+    private SysUserMapper assignmentUserMapper;
+    private ISysMenuService menuService;
 
     /**
      * 纯 Mockito 单测没有 Spring 容器,MyBatis-Plus 不会为实体初始化 TableInfo,
@@ -143,10 +151,119 @@ class WorkOrderServiceImplTest {
         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);
+        assignmentUserMapper = mock(SysUserMapper.class);
+        when(assignmentUserMapper.selectAssignmentCandidates(isNull(), isNull()))
+                .thenReturn(Arrays.asList(activeReceiver(), activeVerifier()));
+        ReflectionTestUtils.setField(service, "assignmentUserMapper", assignmentUserMapper);
+        menuService = mock(ISysMenuService.class);
         when(menuService.selectMenuPermsByUserId(HANDLER_ID)).thenReturn(Collections.singleton("ops:workOrder:process"));
         when(menuService.selectMenuPermsByUserId(VERIFIER_ID)).thenReturn(Collections.singleton("ops:workOrder:audit"));
         ReflectionTestUtils.setField(service, "sysMenuService", menuService);
+        loginForDispatch();
+    }
+
+    @AfterEach void clearLogin() { SecurityContextHolder.clearContext(); }
+
+    private void loginForDispatch() {
+        LoginUser login = new LoginUser(CURRENT_USER_ID, 101L, new SysUser(),
+                Collections.singleton("ops:workOrder:dispatch"));
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(login, null, Collections.emptyList()));
+    }
+
+    private WorkOrder orderWithDevice(int parent) {
+        WorkOrder order = pendingOrder();
+        order.setDeviceId("shared-device");
+        EquipmentBase device = new EquipmentBase();
+        device.setEquipmentTypeId(45);
+        EquipmentType type = new EquipmentType();
+        type.setParentId(parent);
+        when(equipmentBaseMapper.selectById("shared-device")).thenReturn(device);
+        when(equipmentTypeMapper.selectById(45)).thenReturn(type);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(order);
+        return order;
+    }
+
+    @ParameterizedTest @ValueSource(ints = {1, 2, 3})
+    void candidatesUseDeviceCompanyRatherThanDispatcherSiblingDepartments(int parent) {
+        loginForDispatch();
+        orderWithDevice(parent);
+        when(assignScopeSupport.currentCompanyEquipmentParentTypeIds()).thenReturn(Collections.singleton(parent));
+        when(assignmentUserMapper.selectAssignmentCandidates(100L + parent, null))
+                .thenReturn(Arrays.asList(activeReceiver(), activeVerifier()));
+        assertEquals(HANDLER_ID, service.assignableUsers(ORDER_ID, "handler", null).get(0).getUserId());
+        assertEquals(1, service.assignableUsers(ORDER_ID, "handler", null).size());
+        assertEquals(VERIFIER_ID, service.mobileAssignableUsers(ORDER_ID, "reviewer").get(0).getUserId());
+        verify(assignScopeSupport, never()).assignableUsersInScope();
+    }
+
+    @ParameterizedTest @ValueSource(ints = {0, 4})
+    void noDeviceAndSharedDeviceAllowUsersWithoutCompanyDepartment(int parent) {
+        loginForDispatch();
+        if (parent == 0) when(orderMapper.selectById(ORDER_ID)).thenReturn(pendingOrder());
+        else orderWithDevice(parent);
+        SysUser user = activeReceiver();
+        user.setDeptId(null);
+        when(assignmentUserMapper.selectAssignmentCandidates(null, null)).thenReturn(Collections.singletonList(user));
+        assertEquals(HANDLER_ID, service.assignableUsers(ORDER_ID, "handler", null).get(0).getUserId());
+        assertTrue(service.assignableUsers(ORDER_ID, "reviewer", null).isEmpty());
+        verify(assignmentUserMapper, org.mockito.Mockito.times(2)).selectAssignmentCandidates(null, null);
+    }
+
+    @Test void brokenDeviceAndUnknownTypeCannotBecomeUnrestrictedCandidates() {
+        loginForDispatch();
+        orderWithDevice(9);
+        when(assignScopeSupport.currentCompanyEquipmentParentTypeIds()).thenReturn(Collections.singleton(9));
+        assertThrows(ServiceException.class, () -> service.assignableUsers(ORDER_ID, "handler", null));
+        when(equipmentBaseMapper.selectById("shared-device")).thenReturn(null);
+        assertThrows(ServiceException.class, () -> service.assignableUsers(ORDER_ID, "handler", null));
+        verify(assignmentUserMapper, never()).selectAssignmentCandidates(any(), any());
+    }
+
+    @Test void dispatchRechecksCompanyMembershipAndRevokedPermission() {
+        orderWithDevice(1);
+        when(sysUserService.selectUserById(HANDLER_ID)).thenReturn(activeReceiver());
+        when(assignmentUserMapper.selectAssignmentCandidates(101L, null)).thenReturn(Collections.emptyList());
+        assertThrows(ServiceException.class, () -> service.dispatch(ORDER_ID, dispatchDTO()));
+        when(assignmentUserMapper.selectAssignmentCandidates(101L, null)).thenReturn(Collections.singletonList(activeReceiver()));
+        when(menuService.selectMenuPermsByUserId(HANDLER_ID)).thenReturn(Collections.emptySet());
+        assertThrows(ServiceException.class, () -> service.dispatch(ORDER_ID, dispatchDTO()));
+        verify(orderMapper, never()).update(isNull(), any());
+    }
+
+    @Test void sharedDeviceDispatchAcceptsQualifiedUsersOutsideCompanyDepartments() {
+        orderWithDevice(4);
+        SysUser handler = activeReceiver();
+        handler.setDeptId(null);
+        when(sysUserService.selectUserById(HANDLER_ID)).thenReturn(handler);
+        when(sysUserService.selectUserById(VERIFIER_ID)).thenReturn(activeVerifier());
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        assertTrue(service.dispatch(ORDER_ID, dispatchDTO()));
+        verify(assignScopeSupport, never()).validateAssignUserInScope(any());
+    }
+
+    @Test void assignedSharedDeviceHandlerCanContinueOutsideCompanyButOtherUsersCannot() {
+        WorkOrder order = orderWithDevice(4);
+        order.setOrderStatus(WorkOrderStatusEnum.ACCEPTED.getCode());
+        order.setReceiveUser(CURRENT_USER_ID);
+        when(assignScopeSupport.currentCompanyEquipmentParentTypeIds()).thenReturn(Collections.emptySet());
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        assertTrue(service.process(ORDER_ID, new WorkOrderOperateDTO()));
+        order.setReceiveUser(HANDLER_ID);
+        assertThrows(ServiceException.class, () -> service.process(ORDER_ID, new WorkOrderOperateDTO()));
+    }
+
+    @Test void assignedSharedDeviceReviewerCanVerifyOutsideCompanyButOtherUsersCannot() {
+        WorkOrder order = orderWithDevice(4);
+        order.setOrderStatus(WorkOrderStatusEnum.PENDING_VERIFY.getCode());
+        order.setVerifyUser(CURRENT_USER_ID);
+        when(assignScopeSupport.currentCompanyEquipmentParentTypeIds()).thenReturn(Collections.emptySet());
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+        WorkOrderVerifyDTO dto = new WorkOrderVerifyDTO();
+        dto.setPassed(false);
+        assertTrue(service.verify(ORDER_ID, dto));
+        order.setVerifyUser(VERIFIER_ID);
+        assertThrows(ServiceException.class, () -> service.verify(ORDER_ID, dto));
     }
 
     @Test
@@ -432,7 +549,7 @@ class WorkOrderServiceImplTest {
     void dispatchRejectsMissingHandlerId() {
         ServiceException error = assertThrows(ServiceException.class,
                 () -> service.dispatch(ORDER_ID, new WorkOrderDispatchDTO()));
-        assertEquals("责任人不能为空", error.getMessage());
+        assertEquals("处置人不能为空", error.getMessage());
         verify(orderMapper, never()).selectById(any());
     }
 
@@ -482,7 +599,7 @@ class WorkOrderServiceImplTest {
 
         ServiceException error = assertThrows(ServiceException.class,
                 () -> service.dispatch(ORDER_ID, dispatchDTO()));
-        assertEquals("责任人不存在或已停用", error.getMessage());
+        assertEquals("处置人不存在或已停用", error.getMessage());
         verify(orderMapper, never()).update(isNull(), any());
     }
 
@@ -552,6 +669,27 @@ class WorkOrderServiceImplTest {
         assertEquals("城南1号井", detail.getOrder().getDeviceLocation());
     }
 
+    @Test
+    void detailFillsHandlerAndReviewerNicknamesInOneBatch() {
+        WorkOrder order = new WorkOrder();
+        order.setOrderId(ORDER_ID);
+        order.setReceiveUser(HANDLER_ID);
+        order.setVerifyUser(VERIFIER_ID);
+        when(orderMapper.selectById(ORDER_ID)).thenReturn(order);
+        SysUser handler = activeReceiver();
+        handler.setNickName("处置员");
+        SysUser reviewer = activeVerifier();
+        reviewer.setNickName("核验员");
+        when(sysUserService.selectEnabledUsersByIds(any())).thenReturn(Arrays.asList(handler, reviewer));
+
+        WorkOrderDetailVO detail = service.getDetail(ORDER_ID);
+
+        assertEquals("处置员", detail.getOrder().getReceiveUserName());
+        assertEquals("核验员", detail.getOrder().getVerifyUserName());
+        verify(sysUserService).selectEnabledUsersByIds(org.mockito.ArgumentMatchers.argThat(ids -> ids.size() == 2
+                && ids.contains(HANDLER_ID) && ids.contains(VERIFIER_ID)));
+    }
+
     @Test
     void detailWithoutDeviceIdDoesNotScanEquipment() {
         WorkOrder order = new WorkOrder();