feat: 支持审批步骤绑定部门与多对象

- 新增审批对象关联表和存量数据迁移

- 支持用户、角色、部门多选及办理权限匹配

- 完善部门状态与批量管理交互
This commit is contained in:
2026-07-30 15:13:11 +08:00
parent 864cea6135
commit 57bd7b5d06
29 changed files with 3694 additions and 365 deletions

View File

@@ -1,27 +1,38 @@
package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.approval.entity.ApprovalFlowScope;
import tech.easyflow.approval.entity.ApprovalFlowStepAssignee;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalScopeType;
import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 部门表 控制层。
@@ -33,18 +44,47 @@ import java.util.List;
@RequestMapping("/api/v1/sysDept")
public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> {
@Resource
private SysAccountService sysAccountService;
private final SysAccountService sysAccountService;
private final ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
private final ApprovalFlowScopeMapper approvalFlowScopeMapper;
public SysDeptController(SysDeptService service) {
/**
* 创建部门管理控制器。
*
* @param service 部门服务
* @param sysAccountService 用户服务
* @param approvalFlowStepAssigneeMapper 审批步骤对象 Mapper
* @param approvalFlowScopeMapper 审批范围 Mapper
*/
public SysDeptController(SysDeptService service,
SysAccountService sysAccountService,
ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper,
ApprovalFlowScopeMapper approvalFlowScopeMapper) {
super(service);
this.sysAccountService = sysAccountService;
this.approvalFlowStepAssigneeMapper = approvalFlowStepAssigneeMapper;
this.approvalFlowScopeMapper = approvalFlowScopeMapper;
}
/**
* 获取部门列表默认排序规则。
*
* @return 默认排序表达式
*/
@Override
protected String getDefaultOrderBy() {
return "sort_no asc";
}
/**
* 查询部门列表并组装为树形结构。
*
* @param entity 查询条件
* @param asTree 是否返回树形结构
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 部门树
*/
@Override
@GetMapping("list")
public Result<List<SysDept>> list(SysDept entity, Boolean asTree, String sortKey, String sortType) {
@@ -54,9 +94,64 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
return Result.ok(Tree.tryToTree(sysMenus, "id", "parentId"));
}
/**
* 批量修改部门状态。
*
* @param ids 部门主键集合
* @param status 目标状态
* @return 实际更新数量
*/
@PostMapping("changeStatusBatch")
@SaCheckPermission("/api/v1/sysDept/save")
@Transactional(rollbackFor = Exception.class)
public Result<Integer> changeStatusBatch(
@JsonBody(value = "ids", required = true) List<BigInteger> ids,
@JsonBody(value = "status", required = true) Integer status) {
Set<BigInteger> uniqueIds = normalizeIds(ids);
if (uniqueIds.isEmpty()) {
return Result.fail("请选择需要操作的部门", null);
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(status)
&& !EnumDataStatus.UNAVAILABLE.getCode().equals(status)) {
return Result.fail("部门状态不合法", null);
}
List<SysDept> records = service.listByIds(uniqueIds);
if (records.size() != uniqueIds.size()) {
return Result.fail("部分部门不存在或已删除,请刷新后重试", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status) && containsRootDept(records)) {
return Result.fail("根部门不能禁用", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status)
&& isUsedByApprovalFlow(uniqueIds)) {
return Result.fail("所选部门已被审批流程使用,不能禁用", null);
}
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysDept update = new SysDept();
update.setStatus(status);
update.setModified(new Date());
update.setModifiedBy(loginUser.getId());
QueryWrapper updateWrapper = QueryWrapper.create();
updateWrapper.in(SysDept::getId, uniqueIds);
int updated = service.getMapper().updateByQuery(update, updateWrapper);
if (updated <= 0) {
return Result.fail("部门状态修改失败", null);
}
return Result.ok(updated);
}
/**
* {@inheritDoc}
*/
@Override
protected Result onSaveOrUpdateBefore(SysDept entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave && entity.getStatus() == null) {
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
}
BigInteger parentId = entity.getParentId();
if (parentId.equals(BigInteger.ZERO)) {
entity.setAncestors(parentId.toString());
@@ -65,7 +160,7 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
entity.setAncestors(parent.getAncestors() + "," + parentId);
}
if (isSave) {
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} else {
entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId());
@@ -73,20 +168,87 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
return null;
}
/**
* {@inheritDoc}
*/
@Override
protected Result onRemoveBefore(Collection<Serializable> ids) {
List<SysDept> records = service.listByIds(ids);
for (SysDept dept : records) {
if (Constants.ROOT_DEPT.equals(dept.getDeptCode())) {
return Result.fail(1, "无法删除根部门");
}
if (records.size() != ids.size()) {
return Result.fail(1, "部分部门不存在或已删除,请刷新后重试");
}
QueryWrapper w = QueryWrapper.create();
w.in(SysAccount::getDeptId, ids);
long count = sysAccountService.count(w);
if (containsRootDept(records)) {
return Result.fail(1, "无法删除根部门");
}
QueryWrapper childQuery = QueryWrapper.create();
childQuery.in(SysDept::getParentId, ids);
childQuery.notIn(SysDept::getId, ids);
if (service.count(childQuery) > 0) {
return Result.fail(1, "所选部门包含未选中的下级部门,不能删除");
}
if (isUsedByApprovalFlow(ids)) {
return Result.fail(1, "所选部门已被审批流程使用,请先调整审批配置");
}
QueryWrapper accountQuery = QueryWrapper.create();
accountQuery.in(SysAccount::getDeptId, ids);
long count = sysAccountService.count(accountQuery);
if (count > 0) {
return Result.fail(1, "部门下有员工,不能删除");
return Result.fail(1, "所选部门下有员工,不能删除");
}
return super.onRemoveBefore(ids);
}
}
/**
* 去重并过滤无效部门主键。
*
* @param ids 原始部门主键集合
* @return 有效且去重后的主键集合
*/
private Set<BigInteger> normalizeIds(Collection<BigInteger> ids) {
Set<BigInteger> uniqueIds = new LinkedHashSet<>();
if (ids == null) {
return uniqueIds;
}
for (BigInteger id : ids) {
if (id != null) {
uniqueIds.add(id);
}
}
return uniqueIds;
}
/**
* 判断部门集合中是否包含根部门。
*
* @param records 部门集合
* @return 包含根部门时返回 {@code true}
*/
private boolean containsRootDept(Collection<SysDept> records) {
return records.stream()
.anyMatch(dept -> Constants.ROOT_DEPT.equals(dept.getDeptCode()));
}
/**
* 判断部门是否仍被审批步骤或审批范围引用。
*
* @param ids 部门主键集合
* @return 存在审批流程引用时返回 {@code true}
*/
private boolean isUsedByApprovalFlow(Collection<? extends Serializable> ids) {
QueryWrapper assigneeQuery = QueryWrapper.create();
assigneeQuery.eq(
ApprovalFlowStepAssignee::getAssigneeType,
ApprovalAssigneeType.DEPT.getCode());
assigneeQuery.in(ApprovalFlowStepAssignee::getTargetId, ids);
if (approvalFlowStepAssigneeMapper.selectCountByQuery(assigneeQuery) > 0) {
return true;
}
QueryWrapper scopeQuery = QueryWrapper.create();
scopeQuery.eq(ApprovalFlowScope::getScopeType, ApprovalScopeType.DEPT.getCode());
scopeQuery.in(ApprovalFlowScope::getScopeValue, ids);
return approvalFlowScopeMapper.selectCountByQuery(scopeQuery) > 0;
}
}

View File

@@ -0,0 +1,247 @@
package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.testng.annotations.Test;
import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.mapper.SysDeptMapper;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
/**
* {@link SysDeptController} 部门状态保存测试。
*/
public class SysDeptControllerTest {
/**
* 验证批量状态修改使用单次更新并写入审计字段。
*/
@Test
public void changeStatusBatchShouldUpdateAllDepartmentsOnce() {
SysDeptService service = mock(SysDeptService.class);
SysDeptMapper mapper = mock(SysDeptMapper.class);
SysDeptController controller = createController(service);
List<BigInteger> ids = List.of(BigInteger.ONE, BigInteger.TWO);
when(service.listByIds(anyCollection()))
.thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1"), buildDept(BigInteger.TWO, "dept_2")));
when(service.getMapper()).thenReturn(mapper);
when(mapper.updateByQuery(any(SysDept.class), any(QueryWrapper.class))).thenReturn(2);
LoginAccount loginAccount = mock(LoginAccount.class);
when(loginAccount.getId()).thenReturn(BigInteger.TEN);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
Result<Integer> result = controller.changeStatusBatch(ids, EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(result.getErrorCode(), 0);
assertEquals(result.getData(), Integer.valueOf(2));
}
ArgumentCaptor<SysDept> updateCaptor = ArgumentCaptor.forClass(SysDept.class);
verify(mapper).updateByQuery(updateCaptor.capture(), any(QueryWrapper.class));
assertEquals(updateCaptor.getValue().getStatus(), EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(updateCaptor.getValue().getModifiedBy(), BigInteger.TEN);
}
/**
* 验证根部门不能通过批量接口禁用。
*/
@Test
public void changeStatusBatchShouldRejectRootDepartmentDisable() {
SysDeptService service = mock(SysDeptService.class);
SysDeptMapper mapper = mock(SysDeptMapper.class);
SysDeptController controller = createController(service);
SysDept root = buildDept(BigInteger.ONE, Constants.ROOT_DEPT);
when(service.listByIds(anyCollection())).thenReturn(List.of(root));
when(service.getMapper()).thenReturn(mapper);
Result<Integer> result = controller.changeStatusBatch(
List.of(BigInteger.ONE),
EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "根部门不能禁用");
verify(mapper, never()).updateByQuery(any(SysDept.class), any(QueryWrapper.class));
}
/**
* 验证批量状态修改拒绝非法状态。
*/
@Test
public void changeStatusBatchShouldRejectInvalidStatus() {
SysDeptService service = mock(SysDeptService.class);
SysDeptController controller = createController(service);
Result<Integer> result = controller.changeStatusBatch(List.of(BigInteger.ONE), 2);
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "部门状态不合法");
verify(service, never()).listByIds(anyCollection());
}
/**
* 验证存在未选中下级部门时禁止批量删除。
*/
@Test
public void removeBatchShouldRejectUnselectedChildDepartment() {
SysDeptService service = mock(SysDeptService.class);
SysDeptController controller = createController(service);
Collection<Serializable> ids = List.of(BigInteger.ONE);
when(service.listByIds(ids)).thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
when(service.count(any(QueryWrapper.class))).thenReturn(1L);
Result<?> result = controller.onRemoveBefore(ids);
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "所选部门包含未选中的下级部门,不能删除");
}
/**
* 验证审批步骤引用的部门不能禁用。
*/
@Test
public void changeStatusBatchShouldRejectApprovalStepReference() {
SysDeptService service = mock(SysDeptService.class);
ApprovalFlowStepAssigneeMapper assigneeMapper = mock(ApprovalFlowStepAssigneeMapper.class);
SysDeptController controller = new SysDeptController(
service,
mock(SysAccountService.class),
assigneeMapper,
mock(ApprovalFlowScopeMapper.class));
when(service.listByIds(anyCollection()))
.thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
when(assigneeMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
Result<Integer> result = controller.changeStatusBatch(
List.of(BigInteger.ONE),
EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "所选部门已被审批流程使用,不能禁用");
}
/**
* 验证审批范围引用的部门不能删除。
*/
@Test
public void removeBatchShouldRejectApprovalScopeReference() {
SysDeptService service = mock(SysDeptService.class);
ApprovalFlowScopeMapper scopeMapper = mock(ApprovalFlowScopeMapper.class);
SysDeptController controller = new SysDeptController(
service,
mock(SysAccountService.class),
mock(ApprovalFlowStepAssigneeMapper.class),
scopeMapper);
Collection<Serializable> ids = List.of(BigInteger.ONE);
when(service.listByIds(ids)).thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
when(service.count(any(QueryWrapper.class))).thenReturn(0L);
when(scopeMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
Result<?> result = controller.onRemoveBefore(ids);
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "所选部门已被审批流程使用,请先调整审批配置");
}
/**
* 验证新增部门未传状态时默认启用。
*/
@Test
public void saveShouldDefaultMissingStatusToAvailable() {
SysDept entity = buildDept(null);
invokeSaveBefore(entity);
assertEquals(entity.getStatus(), EnumDataStatus.AVAILABLE.getCode());
}
/**
* 验证新增部门显式传入未启用状态时保持原值。
*/
@Test
public void saveShouldPreserveExplicitUnavailableStatus() {
SysDept entity = buildDept(EnumDataStatus.UNAVAILABLE.getCode());
invokeSaveBefore(entity);
assertEquals(entity.getStatus(), EnumDataStatus.UNAVAILABLE.getCode());
}
/**
* 构造待保存的部门。
*
* @param status 部门状态
* @return 部门实体
*/
private SysDept buildDept(Integer status) {
SysDept entity = new SysDept();
entity.setParentId(BigInteger.ZERO);
entity.setStatus(status);
return entity;
}
/**
* 构造指定主键和编码的部门。
*
* @param id 部门主键
* @param deptCode 部门编码
* @return 部门实体
*/
private SysDept buildDept(BigInteger id, String deptCode) {
SysDept entity = new SysDept();
entity.setId(id);
entity.setDeptCode(deptCode);
return entity;
}
/**
* 调用新增前置处理。
*
* @param entity 部门实体
*/
private void invokeSaveBefore(SysDept entity) {
LoginAccount loginAccount = mock(LoginAccount.class);
SysDeptController controller = createController(mock(SysDeptService.class));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.onSaveOrUpdateBefore(entity, true);
}
}
/**
* 创建带有隔离依赖的部门控制器。
*
* @param service 部门服务
* @return 部门控制器
*/
private SysDeptController createController(SysDeptService service) {
return new SysDeptController(
service,
mock(SysAccountService.class),
mock(ApprovalFlowStepAssigneeMapper.class),
mock(ApprovalFlowScopeMapper.class));
}
}