Parcourir la source

移动端-工单管理

zengy il y a 1 semaine
Parent
commit
94bb7e4d8b
23 fichiers modifiés avec 502 ajouts et 114 suppressions
  1. 2 2
      pipe-network-service/sql/water_supply_alarm_test_data.sql
  2. 9 9
      pipe-network-service/sql/water_supply_monitoring_alarm_rebuild_test_data.sql
  3. 36 8
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/ops/WorkOrderController.java
  4. 2 3
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/system/SysUserController.java
  5. 6 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusWebSocketHandler.java
  6. 6 3
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/WorkOrderWebSocketPushTask.java
  7. 35 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/websocket/DeviceStatusCockpitWebSocketHandlerTest.java
  8. 0 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/WorkOrder.java
  9. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/MobileAssignableUserVO.java
  10. 5 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderPushVO.java
  11. 0 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/domain/vo/WorkOrderVO.java
  12. 3 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderDetailVO.java
  13. 0 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/dto/WorkOrderPageDTO.java
  14. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/WorkOrderService.java
  15. 215 52
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/service/impl/WorkOrderServiceImpl.java
  16. 42 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/ops/support/OpsAssignScopeSupport.java
  17. 2 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/mapper/SysUserMapper.java
  18. 2 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/ISysUserService.java
  19. 3 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysUserServiceImpl.java
  20. 1 2
      pipe-network-service/zksy-system/src/main/resources/mapper/ops/WorkOrderMapper.xml
  21. 3 5
      pipe-network-service/zksy-system/src/main/resources/mapper/system/SysUserMapper.xml
  22. 55 1
      pipe-network-service/zksy-system/src/test/java/com/zksy/ops/service/impl/WorkOrderServiceImplTest.java
  23. 33 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/system/mapper/SysUserContactsMapperContractTest.java

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

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

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

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

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

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.vo.WorkOrderLogVO;
 import com.zksy.ops.domain.vo.WorkOrderVO;
-import com.zksy.common.annotation.DataScope;
+import com.zksy.ops.domain.vo.MobileAssignableUserVO;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.core.domain.TreeSelect;
@@ -53,7 +53,6 @@ public class WorkOrderController {
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @GetMapping
     @ApiOperation("分页查询工单列表")
-    @DataScope(deptAlias = "app_user.work_order", permission = "ops:workOrder:query")
     public AjaxResult page(WorkOrderPageDTO query) {
         Page<WorkOrderVO> page = workOrderService.findByPage(query);
         return AjaxResult.success(page);
@@ -62,7 +61,6 @@ public class WorkOrderController {
     @PreAuthorize("@ss.hasPermi('ops:workOrder:query')")
     @GetMapping("/statusCount")
     @ApiOperation("按状态统计工单数量(列表状态筛选条使用,忽略入参中的状态筛选)")
-    @DataScope(deptAlias = "app_user.work_order", permission = "ops:workOrder:query")
     public AjaxResult statusCount(WorkOrderPageDTO query) {
         return AjaxResult.success(workOrderService.countByStatus(query));
     }
@@ -115,7 +113,7 @@ public class WorkOrderController {
         return workOrderService.dispatch(orderId, dto) ? AjaxResult.success("派单成功") : AjaxResult.error("派单失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:accept')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/accept")
     @ApiOperation("核查接单(待接单 → 已接单)")
     @Log(title = "工单接单", businessType = BusinessType.UPDATE)
@@ -131,7 +129,7 @@ public class WorkOrderController {
         return workOrderService.process(orderId, dto) ? AjaxResult.success("处置成功") : AjaxResult.error("处置失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:submitVerify')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/submitVerify")
     @ApiOperation("提交验收(处理中/延期中 → 待验收)")
     @Log(title = "工单提交验收", businessType = BusinessType.UPDATE)
@@ -139,7 +137,7 @@ public class WorkOrderController {
         return workOrderService.submitVerify(orderId, dto) ? AjaxResult.success("提交验收成功") : AjaxResult.error("提交验收失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:verify')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:audit')")
     @PutMapping("/{orderId}/verify")
     @ApiOperation("工单验收(待验收 → 已结案 / 处理中)")
     @Log(title = "工单验收", businessType = BusinessType.UPDATE)
@@ -155,7 +153,7 @@ public class WorkOrderController {
         return workOrderService.cancel(orderId, dto) ? AjaxResult.success("取消成功") : AjaxResult.error("取消失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:delay')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:process')")
     @PutMapping("/{orderId}/delay/apply")
     @ApiOperation("申请延期(处理中且无延期申请 → 待延期审核)")
     @Log(title = "工单申请延期", businessType = BusinessType.UPDATE)
@@ -163,7 +161,7 @@ public class WorkOrderController {
         return workOrderService.applyDelay(orderId, dto) ? AjaxResult.success("延期申请已提交") : AjaxResult.error("延期申请失败");
     }
 
-    @PreAuthorize("@ss.hasPermi('ops:workOrder:delayAudit')")
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:audit')")
     @PutMapping("/{orderId}/delay/audit")
     @ApiOperation("延期审核(待延期审核 → 延期审核通过 / 延期审核驳回)")
     @Log(title = "工单延期审核", businessType = BusinessType.UPDATE)
@@ -179,6 +177,36 @@ public class WorkOrderController {
         return workOrderService.deleteWorkOrder(orderId) ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
     }
 
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/mobile")
+    @ApiOperation("移动端我的工单分页(无需 PC 查询权限)")
+    public AjaxResult mobilePage(WorkOrderPageDTO query) {
+        return AjaxResult.success(workOrderService.findMobilePage(query));
+    }
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/mobile/pending")
+    @ApiOperation("移动端待办工单分页(无需 PC 查询权限)")
+    public AjaxResult mobilePendingPage(WorkOrderPageDTO query) {
+        return AjaxResult.success(workOrderService.findMobilePendingPage(query));
+    }
+
+    @PreAuthorize("isAuthenticated()")
+    @GetMapping("/mobile/{orderId}")
+    @ApiOperation("移动端工单详情(含数据范围校验)")
+    public AjaxResult mobileDetail(@PathVariable("orderId") Long orderId) {
+        return AjaxResult.success(workOrderService.getMobileDetail(orderId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('ops:workOrder:dispatch')")
+    @GetMapping("/mobile/assignable/users")
+    @ApiOperation("移动端派单候选人,type=handler/reviewer")
+    public AjaxResult mobileAssignableUsers(@RequestParam("type") String type) {
+        List<MobileAssignableUserVO> users = workOrderService.mobileAssignableUsers(type);
+        return AjaxResult.success(users);
+    }
+
     /**
      * 入参 DTO 转工单实体:只映射创建阶段客户端可决定的字段,
      * 工单编号、状态、归属部门、创建时间等一律由 Service 生成。

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

@@ -264,11 +264,10 @@ public class SysUserController extends BaseController
     }
 
     /**
-     * 获取当前用户所在部门及全部下级部门的通讯录。
+     * 获取全部有效联系人,排除当前登录用户和超级管理员。
      */
     @GetMapping("/contacts")
     public AjaxResult contacts() {
-        Long deptId = getLoginUser().getUser().getDeptId();
-        return success(userService.selectContactsByDeptId(deptId, getUserId()));
+        return success(userService.selectContacts(getUserId()));
     }
 }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -155,6 +155,48 @@ public class OpsAssignScopeSupport {
         return userService.selectUserList(query);
     }
 
+    /**
+     * 查询当前用户可指派范围内的全部人员。
+     *
+     * <p>移动端派单不展示部门树,因此一次返回范围内人员,再由工单服务按操作权限过滤。</p>
+     */
+    public List<SysUser> assignableUsersInScope() {
+        Set<Long> scope = scopeDeptIds();
+        List<SysUser> users = userService.selectUserList(new SysUser());
+        if (users == null || users.isEmpty() || scope == null) {
+            return users == null ? Collections.emptyList() : users;
+        }
+        if (scope.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return users.stream()
+                .filter(user -> user.getDeptId() != null && scope.contains(user.getDeptId()))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 当前用户部门路径(部门自身 + ancestors 中的祖先部门)。
+     */
+    public Set<Long> currentDeptPathIds() {
+        Long deptId = currentDeptId();
+        if (deptId == null) {
+            return Collections.emptySet();
+        }
+        Set<Long> result = new HashSet<>();
+        result.add(deptId);
+        SysDept dept = deptService.selectDeptById(deptId);
+        if (dept == null || StringUtils.isEmpty(dept.getAncestors())) {
+            return result;
+        }
+        for (String part : dept.getAncestors().split(",")) {
+            String value = part.trim();
+            if (StringUtils.isNotEmpty(value) && StringUtils.isNumeric(value)) {
+                result.add(Long.valueOf(value));
+            }
+        }
+        return result;
+    }
+
     /**
      * 校验被指派人是否处于当前登录用户可指派的部门范围内,否则抛出异常
      */

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

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

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

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

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

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

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

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

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

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

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

@@ -1,6 +1,7 @@
 package com.zksy.ops.service.impl;
 
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentType;
@@ -15,6 +16,7 @@ import com.zksy.base.mapper.EquipmentTypeMapper;
 import com.zksy.ops.domain.WorkOrder;
 import com.zksy.ops.domain.WorkOrderLog;
 import com.zksy.ops.dto.WorkOrderDetailVO;
+import com.zksy.ops.dto.WorkOrderPageDTO;
 import com.zksy.ops.enums.WorkOrderStatusEnum;
 import com.zksy.ops.mapper.WorkOrderLogMapper;
 import com.zksy.ops.mapper.WorkOrderMapper;
@@ -67,6 +69,8 @@ class WorkOrderServiceImplTest {
 
     private static final Long VERIFIER_ID = 89L;
 
+    private static final Long CURRENT_USER_ID = 66L;
+
     private WorkOrderServiceImpl service;
 
     private WorkOrderMapper orderMapper;
@@ -83,6 +87,7 @@ class WorkOrderServiceImplTest {
     private AlarmDataService alarmDataService;
     private CaseInfoMapper caseInfoMapper;
     private HazardHiddenAccountMapper hazardMapper;
+    private OpsAssignScopeSupport assignScopeSupport;
 
     /**
      * 纯 Mockito 单测没有 Spring 容器,MyBatis-Plus 不会为实体初始化 TableInfo,
@@ -132,7 +137,41 @@ class WorkOrderServiceImplTest {
         ReflectionTestUtils.setField(service, "alarmDataService", alarmDataService);
         ReflectionTestUtils.setField(service, "hazardHiddenAccountMapper", hazardMapper);
         ReflectionTestUtils.setField(service, "caseInfoMapper", caseInfoMapper);
-        ReflectionTestUtils.setField(service, "assignScopeSupport", mock(OpsAssignScopeSupport.class));
+        assignScopeSupport = mock(OpsAssignScopeSupport.class);
+        when(assignScopeSupport.currentUserId()).thenReturn(CURRENT_USER_ID);
+        when(assignScopeSupport.currentDeptPathIds()).thenReturn(new java.util.HashSet<>(Collections.singletonList(101L)));
+        ReflectionTestUtils.setField(service, "assignScopeSupport", assignScopeSupport);
+        com.zksy.system.service.ISysMenuService menuService = mock(com.zksy.system.service.ISysMenuService.class);
+        when(menuService.selectMenuPermsByUserId(HANDLER_ID)).thenReturn(Collections.singleton("ops:workOrder:process"));
+        when(menuService.selectMenuPermsByUserId(VERIFIER_ID)).thenReturn(Collections.singleton("ops:workOrder:audit"));
+        ReflectionTestUtils.setField(service, "sysMenuService", menuService);
+    }
+
+    @Test
+    void pcQueryLimitsNormalUserToCreatorReceiverOrVerifier() {
+        LambdaQueryWrapper<WorkOrder> wrapper = ReflectionTestUtils.invokeMethod(
+                service, "buildQueryWrapper", new WorkOrderPageDTO());
+
+        assertNotNull(wrapper);
+        String sql = wrapper.getSqlSegment();
+        assertTrue(sql.contains("create_by"));
+        assertTrue(sql.contains("receive_user"));
+        assertTrue(sql.contains("verify_user"));
+        assertTrue(sql.contains(" OR "));
+    }
+
+    @Test
+    void pcQueryDoesNotLimitSuperAdministratorByParticipant() {
+        when(assignScopeSupport.isAdmin()).thenReturn(true);
+
+        LambdaQueryWrapper<WorkOrder> wrapper = ReflectionTestUtils.invokeMethod(
+                service, "buildQueryWrapper", new WorkOrderPageDTO());
+
+        assertNotNull(wrapper);
+        String sql = wrapper.getSqlSegment();
+        assertFalse(sql.contains("create_by"));
+        assertFalse(sql.contains("receive_user"));
+        assertFalse(sql.contains("verify_user"));
     }
 
     @Test
@@ -526,6 +565,7 @@ class WorkOrderServiceImplTest {
         WorkOrder order = new WorkOrder();
         order.setOrderId(ORDER_ID);
         order.setOrderStatus(WorkOrderStatusEnum.PENDING_DISPATCH.getCode());
+        order.setCreateBy(CURRENT_USER_ID);
         return order;
     }
 
@@ -566,7 +606,12 @@ class WorkOrderServiceImplTest {
         EquipmentBase equipment = new EquipmentBase();
         equipment.setEquipmentId("dev-1");
         equipment.setEquipmentCode("CN-GS-202609020001");
+        equipment.setEquipmentTypeId(1);
         when(equipmentBaseMapper.selectById("dev-1")).thenReturn(equipment);
+        EquipmentType equipmentType = new EquipmentType();
+        equipmentType.setId(1);
+        equipmentType.setParentId(1);
+        when(equipmentTypeMapper.selectById(1)).thenReturn(equipmentType);
         when(orderMapper.selectById(ORDER_ID)).thenReturn(order);
         when(orderMapper.update(isNull(), any())).thenReturn(1);
 
@@ -584,6 +629,14 @@ class WorkOrderServiceImplTest {
     void verifyRejectDoesNotTriggerAlarmClearReview() {
         WorkOrder order = pendingVerifyOrder();
         order.setDeviceId("dev-1");
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentId("dev-1");
+        equipment.setEquipmentTypeId(1);
+        when(equipmentBaseMapper.selectById("dev-1")).thenReturn(equipment);
+        EquipmentType equipmentType = new EquipmentType();
+        equipmentType.setId(1);
+        equipmentType.setParentId(1);
+        when(equipmentTypeMapper.selectById(1)).thenReturn(equipmentType);
         when(orderMapper.selectById(ORDER_ID)).thenReturn(order);
         when(orderMapper.update(isNull(), any())).thenReturn(1);
 
@@ -602,6 +655,7 @@ class WorkOrderServiceImplTest {
         order.setOrderId(ORDER_ID);
         order.setOrderNo("WO202609181000000001");
         order.setOrderStatus(WorkOrderStatusEnum.PENDING_VERIFY.getCode());
+        order.setVerifyUser(CURRENT_USER_ID);
         return order;
     }
 

+ 33 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/system/mapper/SysUserContactsMapperContractTest.java

@@ -0,0 +1,33 @@
+package com.zksy.system.mapper;
+
+import org.apache.ibatis.io.Resources;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SysUserContactsMapperContractTest {
+
+    @Test
+    void contactsIncludeAllEnabledUsersExceptCurrentUserAndAdministrator() throws Exception {
+        String xml;
+        try (InputStream input = Resources.getResourceAsStream("mapper/system/SysUserMapper.xml")) {
+            xml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        int start = xml.indexOf("<select id=\"selectContacts\"");
+        int end = xml.indexOf("</select>", start);
+        String statement = xml.substring(start, end);
+
+        assertTrue(statement.contains("u.del_flag = '0'"));
+        assertTrue(statement.contains("u.status = '0'"));
+        assertTrue(statement.contains("u.user_id &lt;&gt; #{excludeUserId}"));
+        assertTrue(statement.contains("u.user_id &lt;&gt; 1"));
+        assertFalse(statement.contains("limit #{pageSize}"));
+        assertFalse(statement.contains("#{keyword}"));
+        assertFalse(statement.contains("#{deptId}"));
+        assertFalse(statement.contains("d.ancestors"));
+    }
+}