diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalFlowStepBase.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalFlowStepBase.java index 21dc6dfb..e1a85abd 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalFlowStepBase.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalFlowStepBase.java @@ -39,6 +39,12 @@ public class ApprovalFlowStepBase implements Serializable { @Column(comment = "审批对象名称") private String assigneeTargetName; + /** + * 是否限定发起人直属部门。 + */ + @Column(comment = "是否限定发起人直属部门") + private Integer restrictToApplicantDept; + @Column(comment = "创建时间") private Date created; @@ -115,6 +121,14 @@ public class ApprovalFlowStepBase implements Serializable { this.assigneeTargetName = assigneeTargetName; } + public Integer getRestrictToApplicantDept() { + return restrictToApplicantDept; + } + + public void setRestrictToApplicantDept(Integer restrictToApplicantDept) { + this.restrictToApplicantDept = restrictToApplicantDept; + } + public Date getCreated() { return created; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java index 01272cbe..825fb78d 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java @@ -59,6 +59,18 @@ public class ApprovalInstanceBase implements Serializable { @Column(comment = "申请人ID") private BigInteger applicantId; + /** + * 提交时冻结的申请人直属部门 ID。 + */ + @Column(comment = "申请人直属部门ID") + private BigInteger applicantDeptId; + + /** + * 提交时冻结的申请人直属部门名称。 + */ + @Column(comment = "申请人直属部门名称") + private String applicantDeptName; + @Column(comment = "提交时间") private Date submittedAt; @@ -181,6 +193,22 @@ public class ApprovalInstanceBase implements Serializable { this.applicantId = applicantId; } + public BigInteger getApplicantDeptId() { + return applicantDeptId; + } + + public void setApplicantDeptId(BigInteger applicantDeptId) { + this.applicantDeptId = applicantDeptId; + } + + public String getApplicantDeptName() { + return applicantDeptName; + } + + public void setApplicantDeptName(String applicantDeptName) { + this.applicantDeptName = applicantDeptName; + } + public Date getSubmittedAt() { return submittedAt; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalTaskBase.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalTaskBase.java index 39f80ace..55bcd1b9 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalTaskBase.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalTaskBase.java @@ -42,6 +42,12 @@ public class ApprovalTaskBase implements Serializable { @Column(comment = "审批对象名称") private String assigneeTargetName; + /** + * 任务要求的直属部门 ID,为空表示不限制部门。 + */ + @Column(comment = "限定直属部门ID") + private BigInteger requiredDeptId; + @Column(comment = "处理人ID") private BigInteger actedBy; @@ -135,6 +141,14 @@ public class ApprovalTaskBase implements Serializable { this.assigneeTargetName = assigneeTargetName; } + public BigInteger getRequiredDeptId() { + return requiredDeptId; + } + + public void setRequiredDeptId(BigInteger requiredDeptId) { + this.requiredDeptId = requiredDeptId; + } + public BigInteger getActedBy() { return actedBy; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalFlowStepVo.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalFlowStepVo.java index 800d0fbb..0611c5f3 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalFlowStepVo.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalFlowStepVo.java @@ -24,6 +24,11 @@ public class ApprovalFlowStepVo { private List assigneeTargets; + /** + * 是否限定发起人直属部门。 + */ + private Integer restrictToApplicantDept; + public BigInteger getId() { return id; } @@ -97,4 +102,22 @@ public class ApprovalFlowStepVo { public void setAssigneeTargets(List assigneeTargets) { this.assigneeTargets = assigneeTargets; } + + /** + * 获取发起人部门限制标记。 + * + * @return 1 表示限制,0 表示不限制 + */ + public Integer getRestrictToApplicantDept() { + return restrictToApplicantDept; + } + + /** + * 设置发起人部门限制标记。 + * + * @param restrictToApplicantDept 限制标记 + */ + public void setRestrictToApplicantDept(Integer restrictToApplicantDept) { + this.restrictToApplicantDept = restrictToApplicantDept; + } } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalAssigneeService.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalAssigneeService.java index 43fa7bd9..6151d2f0 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalAssigneeService.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/ApprovalAssigneeService.java @@ -26,6 +26,14 @@ public interface ApprovalAssigneeService { */ ApprovalFlowStepVo normalizeStepAssignee(ApprovalFlowStepVo step); + /** + * 校验受发起人部门限制的步骤至少存在一个合法审批账号。 + * + * @param steps 步骤配置 + * @param requiredDeptId 指定直属部门 ID;为空时校验任一有效部门 + */ + void validateRestrictedStepCandidates(List steps, BigInteger requiredDeptId); + /** * 批量加载流程步骤的审批对象,关联记录缺失时兼容旧单值字段。 * diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImpl.java index fd8a7d00..eb2e355e 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImpl.java @@ -21,8 +21,10 @@ import tech.easyflow.approval.service.ApprovalAssigneeService; import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysDept; import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysDeptService; import tech.easyflow.system.service.SysRoleService; @@ -52,6 +54,9 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { @Resource private SysAccountService sysAccountService; + @Resource + private SysAccountRoleService sysAccountRoleService; + @Resource private SysDeptService sysDeptService; @@ -72,7 +77,12 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { if (step == null) { throw new BusinessException("审批步骤不能为空"); } + boolean restricted = Integer.valueOf(1).equals(step.getRestrictToApplicantDept()); + step.setRestrictToApplicantDept(restricted ? 1 : 0); ApprovalAssigneeType assigneeType = ApprovalAssigneeType.from(step.getAssigneeType()); + if (restricted && ApprovalAssigneeType.DEPT == assigneeType) { + throw new BusinessException("限定发起人部门后,审批对象类型不能选择部门"); + } List requestedTargets = step.getAssigneeTargets(); if (CollectionUtil.isEmpty(requestedTargets) && step.getAssigneeTargetId() != null) { requestedTargets = List.of(legacyTarget( @@ -105,6 +115,102 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { return step; } + /** + * {@inheritDoc} + */ + @Override + public void validateRestrictedStepCandidates(List steps, BigInteger requiredDeptId) { + List restrictedSteps = safeList(steps).stream() + .filter(Objects::nonNull) + .filter(step -> Integer.valueOf(1).equals(step.getRestrictToApplicantDept())) + .collect(Collectors.toList()); + if (restrictedSteps.isEmpty()) { + return; + } + if (requiredDeptId != null) { + requireAvailableDepartment(requiredDeptId, "发起人部门不存在或未启用"); + } + + Set directAccountIds = new LinkedHashSet<>(); + Set roleIds = new LinkedHashSet<>(); + for (ApprovalFlowStepVo step : restrictedSteps) { + ApprovalAssigneeType type = ApprovalAssigneeType.from(step.getAssigneeType()); + if (ApprovalAssigneeType.DEPT == type) { + throw new BusinessException("限定发起人部门后,审批对象类型不能选择部门"); + } + Set targetIds = stepTargetIds(step); + if (ApprovalAssigneeType.USER == type) { + directAccountIds.addAll(targetIds); + } else { + roleIds.addAll(targetIds); + } + } + + Set availableRoleIds = roleIds.isEmpty() ? Set.of() : safeList( + sysRoleService.list(QueryWrapper.create() + .in(SysRole::getId, roleIds) + .eq(SysRole::getStatus, EnumDataStatus.AVAILABLE.getCode()))) + .stream() + .map(SysRole::getId) + .collect(Collectors.toSet()); + List roleRelations = availableRoleIds.isEmpty() ? List.of() : safeList( + sysAccountRoleService.list(QueryWrapper.create() + .in(SysAccountRole::getRoleId, availableRoleIds))); + Set candidateAccountIds = new LinkedHashSet<>(directAccountIds); + roleRelations.stream() + .map(SysAccountRole::getAccountId) + .filter(Objects::nonNull) + .forEach(candidateAccountIds::add); + + QueryWrapper accountQuery = QueryWrapper.create() + .in(SysAccount::getId, candidateAccountIds) + .eq(SysAccount::getStatus, EnumDataStatus.AVAILABLE.getCode()); + if (requiredDeptId != null) { + accountQuery.eq(SysAccount::getDeptId, requiredDeptId); + } + List accounts = candidateAccountIds.isEmpty() + ? List.of() + : safeList(sysAccountService.list(accountQuery)); + Set accountDeptIds = accounts.stream() + .map(SysAccount::getDeptId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set availableDeptIds; + if (requiredDeptId != null) { + availableDeptIds = Set.of(requiredDeptId); + } else if (accountDeptIds.isEmpty()) { + availableDeptIds = Set.of(); + } else { + availableDeptIds = safeList(sysDeptService.list(QueryWrapper.create() + .in(SysDept::getId, accountDeptIds) + .eq(SysDept::getStatus, EnumDataStatus.AVAILABLE.getCode()))) + .stream() + .map(SysDept::getId) + .collect(Collectors.toSet()); + } + Set eligibleAccountIds = accounts.stream() + .filter(account -> account.getDeptId() != null + && availableDeptIds.contains(account.getDeptId())) + .map(SysAccount::getId) + .collect(Collectors.toSet()); + Map> roleAccountMap = roleRelations.stream() + .filter(relation -> eligibleAccountIds.contains(relation.getAccountId())) + .collect(Collectors.groupingBy( + SysAccountRole::getRoleId, + Collectors.mapping(SysAccountRole::getAccountId, Collectors.toSet()))); + + for (ApprovalFlowStepVo step : restrictedSteps) { + ApprovalAssigneeType type = ApprovalAssigneeType.from(step.getAssigneeType()); + boolean hasCandidate = stepTargetIds(step).stream().anyMatch(targetId -> + ApprovalAssigneeType.USER == type + ? eligibleAccountIds.contains(targetId) + : CollectionUtil.isNotEmpty(roleAccountMap.get(targetId))); + if (!hasCandidate) { + throw new BusinessException(stepCandidateError(step, requiredDeptId)); + } + } + } + /** * {@inheritDoc} */ @@ -291,6 +397,14 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { return false; } ApprovalAssigneeType assigneeType = ApprovalAssigneeType.from(task.getAssigneeType()); + SysAccount operator = null; + if (task.getRequiredDeptId() != null) { + operator = sysAccountService.getById(operatorId); + if (ApprovalAssigneeType.DEPT == assigneeType + || !Objects.equals(task.getRequiredDeptId(), resolveAvailableDirectDeptId(operator))) { + return false; + } + } List targets = loadTaskAssigneeTargets(List.of(task)) .getOrDefault(task.getId(), List.of()); if (ApprovalAssigneeType.USER == assigneeType) { @@ -299,7 +413,9 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { if (ApprovalAssigneeType.ROLE == assigneeType) { return roleIds != null && targets.stream().anyMatch(target -> roleIds.contains(target.getTargetId())); } - SysAccount operator = sysAccountService.getById(operatorId); + if (operator == null) { + operator = sysAccountService.getById(operatorId); + } return operator != null && matchesDepartment(targets, operator.getDeptId()); } @@ -321,6 +437,7 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { } SysAccount operator = sysAccountService.getById(operatorId); + BigInteger availableDirectDeptId = resolveAvailableDirectDeptId(operator); Set departmentIds = resolveDepartmentIds(operator == null ? null : operator.getDeptId()); if (CollectionUtil.isNotEmpty(departmentIds)) { matchedTaskIds.addAll(loadMatchingRelationTaskIds( @@ -328,6 +445,7 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { } Set result = loadPendingTasks(matchedTaskIds, instanceIds).stream() + .filter(task -> matchesRequiredDepartment(task, availableDirectDeptId)) .map(ApprovalTask::getInstanceId) .collect(Collectors.toCollection(LinkedHashSet::new)); @@ -348,11 +466,42 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { .collect(Collectors.toSet()); legacyCandidates.stream() .filter(task -> !relatedTaskIds.contains(task.getId())) + .filter(task -> matchesRequiredDepartment(task, availableDirectDeptId)) .map(ApprovalTask::getInstanceId) .forEach(result::add); return result; } + /** + * 判断任务的冻结部门限制是否命中当前账号直属部门。 + * + * @param task 审批任务 + * @param availableDirectDeptId 当前账号的有效直属部门 ID + * @return 是否命中 + */ + private boolean matchesRequiredDepartment(ApprovalTask task, BigInteger availableDirectDeptId) { + return task.getRequiredDeptId() == null + || Objects.equals(task.getRequiredDeptId(), availableDirectDeptId); + } + + /** + * 获取账号当前有效的直属部门 ID。 + * + * @param account 账号 + * @return 有效直属部门 ID,无有效部门时返回空 + */ + private BigInteger resolveAvailableDirectDeptId(SysAccount account) { + if (account == null + || !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus()) + || account.getDeptId() == null) { + return null; + } + SysDept dept = sysDeptService.getById(account.getDeptId()); + return dept != null && EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus()) + ? dept.getId() + : null; + } + /** * 查询能命中当前主体的任务关联 ID。 * @@ -488,6 +637,59 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService { return currentDeptId == null ? Set.of() : sysDeptService.getSelfAndAncestorDeptIds(currentDeptId); } + /** + * 校验部门存在且处于启用状态。 + * + * @param deptId 部门 ID + * @param message 校验失败消息 + * @return 有效部门 + */ + private SysDept requireAvailableDepartment(BigInteger deptId, String message) { + SysDept dept = sysDeptService.getById(deptId); + if (dept == null || !EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus())) { + throw new BusinessException(message); + } + return dept; + } + + /** + * 提取步骤审批对象 ID。 + * + * @param step 步骤 + * @return 审批对象 ID 集合 + */ + private Set stepTargetIds(ApprovalFlowStepVo step) { + List targets = step.getAssigneeTargets(); + if (CollectionUtil.isEmpty(targets) && step.getAssigneeTargetId() != null) { + targets = List.of(legacyTarget( + step.getAssigneeTargetId(), + step.getAssigneeTargetCode(), + step.getAssigneeTargetName())); + } + return safeList(targets).stream() + .filter(Objects::nonNull) + .map(ApprovalAssigneeTargetVo::getTargetId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * 组装受限步骤无候选人的业务提示。 + * + * @param step 步骤 + * @param requiredDeptId 指定部门 ID + * @return 业务提示 + */ + private String stepCandidateError(ApprovalFlowStepVo step, BigInteger requiredDeptId) { + String stepLabel = step.getStepNo() == null ? "审批步骤" : "第" + step.getStepNo() + "步"; + if (StringUtils.hasText(step.getStepName())) { + stepLabel += "“" + step.getStepName().trim() + "”"; + } + return requiredDeptId == null + ? stepLabel + "没有可用审批账号,请检查角色成员及部门归属" + : stepLabel + "在发起人部门内没有可用审批账号"; + } + /** * 解析并校验单个审批对象。 * diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImpl.java index 6e239728..7f3c8f13 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImpl.java @@ -295,12 +295,15 @@ public class ApprovalFlowServiceImpl extends ServiceImpl steps = new ArrayList<>(flow.getSteps()); steps.sort(Comparator.comparing(ApprovalFlowStepVo::getStepNo)); + SysDept applicantDept = resolveApplicantDepartment(steps, applicant); + if (applicantDept != null) { + // 在创建任何实例或任务前确认每个受限步骤在发起人直属部门内都有候选账号。 + approvalAssigneeService.validateRestrictedStepCandidates(steps, applicantDept.getId()); + } ApprovalFlowStepVo firstStep = steps.get(0); Date now = new Date(); @@ -112,10 +123,14 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { instance.setActionType(flow.getActionType()); instance.setStatus(ApprovalInstanceStatus.PENDING.getCode()); instance.setCurrentStepNo(firstStep.getStepNo()); - instance.setSnapshotJson(buildInstanceSnapshot(request, flow, steps)); + instance.setSnapshotJson(buildInstanceSnapshot(request, flow, steps, applicantDept)); instance.setSummary(request.getSummary()); instance.setApplicationReason(request.getApplicationReason()); instance.setApplicantId(request.getApplicantId()); + if (applicantDept != null) { + instance.setApplicantDeptId(applicantDept.getId()); + instance.setApplicantDeptName(applicantDept.getDeptName()); + } instance.setSubmittedAt(now); instance.setCreated(now); instance.setCreatedBy(request.getApplicantId()); @@ -123,7 +138,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { instance.setModifiedBy(request.getApplicantId()); approvalInstanceMapper.insert(instance); - createTask(instance.getId(), firstStep, request.getApplicantId(), now); + createTask(instance.getId(), firstStep, instance.getApplicantDeptId(), request.getApplicantId(), now); Map submittedPayload = new LinkedHashMap<>(); submittedPayload.put("flowId", flow.getId()); submittedPayload.put("flowVersion", flow.getVersion()); @@ -150,16 +165,24 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { List steps = resolveFrozenSteps(instance); Date now = new Date(); - finishTask(currentTask, ApprovalTaskStatus.APPROVED.getCode(), comment, operatorId, now); int currentIndex = findCurrentStepIndex(steps, instance.getCurrentStepNo()); + ApprovalFlowStepVo nextStep = currentIndex == steps.size() - 1 ? null : steps.get(currentIndex + 1); + if (nextStep != null && Integer.valueOf(1).equals(nextStep.getRestrictToApplicantDept())) { + if (instance.getApplicantDeptId() == null) { + throw new BusinessException("审批实例缺少冻结的发起人部门"); + } + // 先校验下一步候选人,校验失败时当前待办保持未处理。 + approvalAssigneeService.validateRestrictedStepCandidates( + List.of(nextStep), instance.getApplicantDeptId()); + } + finishTask(currentTask, ApprovalTaskStatus.APPROVED.getCode(), comment, operatorId, now); if (currentIndex == steps.size() - 1) { instance.setStatus(ApprovalInstanceStatus.APPROVED.getCode()); instance.setFinishedAt(now); } else { - ApprovalFlowStepVo nextStep = steps.get(currentIndex + 1); instance.setStatus(ApprovalInstanceStatus.PROCESSING.getCode()); instance.setCurrentStepNo(nextStep.getStepNo()); - createTask(instance.getId(), nextStep, operatorId, now); + createTask(instance.getId(), nextStep, instance.getApplicantDeptId(), operatorId, now); appendLog(instance.getId(), ApprovalEventType.STEP_CREATED.getCode(), operatorId, buildStepCreatedPayload(nextStep), now); } @@ -252,7 +275,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { } private Map buildInstanceSnapshot(ApprovalSubmitRequest request, ApprovalFlowDetailVo flow, - List steps) { + List steps, SysDept applicantDept) { Map snapshot = new LinkedHashMap<>(); if (request.getSnapshotJson() != null) { snapshot.putAll(request.getSnapshotJson()); @@ -261,6 +284,10 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { snapshot.put("deptId", request.getDeptId()); snapshot.put("flowId", flow.getId()); snapshot.put("flowVersion", flow.getVersion()); + if (applicantDept != null) { + snapshot.put("applicantDeptId", applicantDept.getId()); + snapshot.put("applicantDeptName", applicantDept.getDeptName()); + } if (request.getApplicationReason() != null) { snapshot.put("applicationReason", request.getApplicationReason()); } @@ -272,6 +299,8 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { map.put("assigneeTargetId", item.getAssigneeTargetId()); map.put("assigneeTargetCode", item.getAssigneeTargetCode()); map.put("assigneeTargetName", item.getAssigneeTargetName()); + map.put("restrictToApplicantDept", + Integer.valueOf(1).equals(item.getRestrictToApplicantDept()) ? 1 : 0); map.put("assigneeTargets", resolveStepTargets(item).stream().map(target -> { Map targetMap = new LinkedHashMap<>(); targetMap.put("targetId", target.getTargetId()); @@ -324,6 +353,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { if (assigneeTargetName != null) { stepVo.setAssigneeTargetName(String.valueOf(assigneeTargetName)); } + stepVo.setRestrictToApplicantDept(parseEnabledFlag(stepMap.get("restrictToApplicantDept"))); stepVo.setAssigneeTargets(parseAssigneeTargets(stepMap.get("assigneeTargets"))); if (stepVo.getAssigneeTargets().isEmpty() && stepVo.getAssigneeTargetId() != null) { stepVo.setAssigneeTargets(List.of(legacyTarget(stepVo))); @@ -338,7 +368,8 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { return result; } - private void createTask(BigInteger instanceId, ApprovalFlowStepVo step, BigInteger operatorId, Date now) { + private void createTask(BigInteger instanceId, ApprovalFlowStepVo step, BigInteger applicantDeptId, + BigInteger operatorId, Date now) { List targets = resolveStepTargets(step); if (CollectionUtil.isEmpty(targets)) { throw new BusinessException("审批步骤缺少审批对象"); @@ -356,6 +387,12 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { task.setAssigneeTargetId(primaryTarget.getTargetId()); task.setAssigneeTargetCode(primaryTarget.getTargetCode()); task.setAssigneeTargetName(primaryTarget.getTargetName()); + if (Integer.valueOf(1).equals(step.getRestrictToApplicantDept())) { + if (applicantDeptId == null) { + throw new BusinessException("审批任务缺少冻结的发起人部门"); + } + task.setRequiredDeptId(applicantDeptId); + } task.setCreated(now); task.setCreatedBy(operatorId); task.setModified(now); @@ -536,9 +573,34 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { payload.put("assigneeTargetCode", step.getAssigneeTargetCode()); payload.put("assigneeTargetName", step.getAssigneeTargetName()); payload.put("assigneeTargets", resolveStepTargets(step)); + payload.put("restrictToApplicantDept", + Integer.valueOf(1).equals(step.getRestrictToApplicantDept()) ? 1 : 0); return payload; } + /** + * 解析受限步骤提交时需要冻结的发起人直属部门。 + * + * @param steps 流程步骤 + * @param applicant 发起人账号 + * @return 有受限步骤时返回有效部门,否则返回空 + */ + private SysDept resolveApplicantDepartment(List steps, SysAccount applicant) { + boolean restricted = steps.stream() + .anyMatch(step -> Integer.valueOf(1).equals(step.getRestrictToApplicantDept())); + if (!restricted) { + return null; + } + if (applicant.getDeptId() == null) { + throw new BusinessException("发起人未归属部门,无法提交当前审批"); + } + SysDept dept = sysDeptService.getById(applicant.getDeptId()); + if (dept == null || !EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus())) { + throw new BusinessException("发起人部门不存在或未启用"); + } + return dept; + } + /** * 从冻结快照中解析审批对象列表。 * @@ -589,6 +651,21 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService { return null; } + /** + * 解析快照中的开关标记,旧快照缺失时默认关闭。 + * + * @param value 快照值 + * @return 1 表示开启,0 表示关闭 + */ + private int parseEnabledFlag(Object value) { + return value instanceof Number number && number.intValue() == 1 + || Boolean.TRUE.equals(value) + || "1".equals(value) + || "true".equalsIgnoreCase(String.valueOf(value)) + ? 1 + : 0; + } + /** * 将旧单值步骤转换为单元素审批对象。 * diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalApplicantDeptRestrictionMigrationContractTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalApplicantDeptRestrictionMigrationContractTest.java new file mode 100644 index 00000000..8e7bf40a --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalApplicantDeptRestrictionMigrationContractTest.java @@ -0,0 +1,51 @@ +package tech.easyflow.approval.service.impl; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +/** + * V40 审批发起人部门限制迁移契约测试。 + */ +public class ApprovalApplicantDeptRestrictionMigrationContractTest { + + /** + * 验证迁移以关闭开关兼容历史流程,并提供实例与任务冻结字段。 + * + * @throws Exception 迁移文件不可读时抛出 + */ + @Test + public void migrationShouldAddBackwardCompatibleRestrictionFields() throws Exception { + String sql = migrationSql(); + + assertTrue(sql.contains("`restrict_to_applicant_dept` TINYINT NOT NULL DEFAULT 0")); + assertTrue(sql.contains("`applicant_dept_id` BIGINT UNSIGNED NULL")); + assertTrue(sql.contains("`applicant_dept_name` VARCHAR(128) NULL")); + assertTrue(sql.contains("`required_dept_id` BIGINT UNSIGNED NULL")); + assertTrue(sql.contains("`idx_approval_task_required_dept_status`")); + } + + /** + * 读取工作区中的 V40 MySQL 迁移。 + * + * @return 迁移 SQL + * @throws Exception 迁移文件不存在或不可读时抛出 + */ + private String migrationSql() throws Exception { + Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory", + Path.of(System.getProperty("user.dir")).toAbsolutePath().toString())); + while (root != null) { + Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/" + + "db/migration/mysql/V40__mysql_approval_applicant_dept_restriction.sql"); + if (Files.isRegularFile(migration)) { + return Files.readString(migration, StandardCharsets.UTF_8); + } + root = root.getParent(); + } + throw new IllegalStateException("找不到 V40 审批发起人部门限制迁移"); + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImplTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImplTest.java index d3b8a729..8f14f6e0 100644 --- a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImplTest.java +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalAssigneeServiceImplTest.java @@ -16,9 +16,12 @@ import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper; import tech.easyflow.approval.mapper.ApprovalTaskAssigneeMapper; import tech.easyflow.approval.mapper.ApprovalTaskMapper; import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysDept; import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysDeptService; import tech.easyflow.system.service.SysRoleService; @@ -29,6 +32,7 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -45,6 +49,9 @@ public class ApprovalAssigneeServiceImplTest { @Mock private SysAccountService sysAccountService; + @Mock + private SysAccountRoleService sysAccountRoleService; + @Mock private SysDeptService sysDeptService; @@ -199,6 +206,135 @@ public class ApprovalAssigneeServiceImplTest { assertTrue(service.canHandleTask(task, operatorId, Set.of(BigInteger.valueOf(32)))); } + /** + * 验证开启发起人部门限制后不能配置部门审批对象。 + */ + @Test + public void normalizeStepAssigneeShouldRejectDepartmentWhenRestricted() { + ApprovalFlowStepVo step = new ApprovalFlowStepVo(); + step.setAssigneeType(ApprovalAssigneeType.DEPT.getCode()); + step.setRestrictToApplicantDept(1); + + assertThrows(BusinessException.class, () -> service.normalizeStepAssignee(step)); + } + + /** + * 验证受限用户没有有效部门时,流程配置校验失败。 + */ + @Test + public void validateRestrictedStepCandidatesShouldRejectUserWithoutDepartment() { + SysAccount account = account(11, null); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(account)); + + ApprovalFlowStepVo step = restrictedStep( + ApprovalAssigneeType.USER, List.of(target(account.getId(), 0))); + + assertThrows(BusinessException.class, + () -> service.validateRestrictedStepCandidates(List.of(step), null)); + } + + /** + * 验证角色中存在发起人直属部门的有效账号时校验通过。 + */ + @Test + public void validateRestrictedStepCandidatesShouldMatchRoleMemberInRequiredDepartment() { + BigInteger roleId = BigInteger.valueOf(31); + BigInteger accountId = BigInteger.valueOf(11); + BigInteger deptId = BigInteger.valueOf(21); + SysRole role = role(roleId.longValue(), "reviewer", "审核员"); + when(sysRoleService.list(any(QueryWrapper.class))).thenReturn(List.of(role)); + SysAccountRole relation = new SysAccountRole(); + relation.setRoleId(roleId); + relation.setAccountId(accountId); + when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(List.of(relation)); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(account(11, deptId))); + when(sysDeptService.getById(deptId)).thenReturn(dept(deptId)); + + ApprovalFlowStepVo step = restrictedStep( + ApprovalAssigneeType.ROLE, List.of(target(roleId, 0))); + + service.validateRestrictedStepCandidates(List.of(step), deptId); + } + + /** + * 验证角色成员与发起人直属部门没有交集时校验失败。 + */ + @Test + public void validateRestrictedStepCandidatesShouldRejectEmptyRoleDepartmentIntersection() { + BigInteger roleId = BigInteger.valueOf(31); + BigInteger deptId = BigInteger.valueOf(21); + SysRole role = role(roleId.longValue(), "reviewer", "审核员"); + when(sysRoleService.list(any(QueryWrapper.class))).thenReturn(List.of(role)); + SysAccountRole relation = new SysAccountRole(); + relation.setRoleId(roleId); + relation.setAccountId(BigInteger.valueOf(11)); + when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(List.of(relation)); + when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of()); + when(sysDeptService.getById(deptId)).thenReturn(dept(deptId)); + + ApprovalFlowStepVo step = restrictedStep( + ApprovalAssigneeType.ROLE, List.of(target(roleId, 0))); + + assertThrows(BusinessException.class, + () -> service.validateRestrictedStepCandidates(List.of(step), deptId)); + } + + /** + * 验证任务同时命中角色和冻结直属部门时才允许处理。 + */ + @Test + public void canHandleTaskShouldRequireFrozenDirectDepartment() { + BigInteger operatorId = BigInteger.valueOf(7); + BigInteger deptId = BigInteger.valueOf(21); + BigInteger roleId = BigInteger.valueOf(31); + ApprovalTask task = new ApprovalTask(); + task.setId(BigInteger.valueOf(101)); + task.setAssigneeType(ApprovalAssigneeType.ROLE.getCode()); + task.setRequiredDeptId(deptId); + when(approvalTaskAssigneeMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn(List.of(taskAssignee(task.getId(), roleId.longValue()))); + when(sysAccountService.getById(operatorId)).thenReturn(account(operatorId.longValue(), deptId)); + when(sysDeptService.getById(deptId)).thenReturn(dept(deptId)); + + assertTrue(service.canHandleTask(task, operatorId, Set.of(roleId))); + + task.setRequiredDeptId(BigInteger.valueOf(22)); + assertFalse(service.canHandleTask(task, operatorId, Set.of(roleId))); + } + + /** + * 验证待审批列表会排除不属于当前账号直属部门的受限任务。 + */ + @Test + public void listPendingInstanceIdsShouldFilterFrozenDepartment() { + BigInteger operatorId = BigInteger.valueOf(7); + BigInteger deptId = BigInteger.valueOf(21); + BigInteger roleId = BigInteger.valueOf(31); + BigInteger matchedTaskId = BigInteger.valueOf(101); + BigInteger otherTaskId = BigInteger.valueOf(102); + BigInteger matchedInstanceId = BigInteger.valueOf(201); + + when(sysAccountService.getById(operatorId)).thenReturn(account(operatorId.longValue(), deptId)); + when(sysDeptService.getById(deptId)).thenReturn(dept(deptId)); + when(sysDeptService.getSelfAndAncestorDeptIds(deptId)).thenReturn(Set.of()); + when(approvalTaskAssigneeMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn( + List.of(), + List.of( + taskAssignee(matchedTaskId, roleId.longValue()), + taskAssignee(otherTaskId, roleId.longValue()))); + + ApprovalTask matchedTask = pendingTask(matchedTaskId, matchedInstanceId, deptId); + ApprovalTask otherTask = pendingTask( + otherTaskId, BigInteger.valueOf(202), BigInteger.valueOf(22)); + when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn(List.of(matchedTask, otherTask), List.of(), List.of()); + + assertEquals( + Set.of(matchedInstanceId), + service.listPendingInstanceIds(operatorId, Set.of(roleId), null)); + } + /** * 构造有效角色。 * @@ -216,6 +352,70 @@ public class ApprovalAssigneeServiceImplTest { return role; } + /** + * 构造有效账号。 + * + * @param id 账号 ID + * @param deptId 部门 ID + * @return 账号 + */ + private SysAccount account(long id, BigInteger deptId) { + SysAccount account = new SysAccount(); + account.setId(BigInteger.valueOf(id)); + account.setDeptId(deptId); + account.setStatus(EnumDataStatus.AVAILABLE.getCode()); + return account; + } + + /** + * 构造有效部门。 + * + * @param deptId 部门 ID + * @return 部门 + */ + private SysDept dept(BigInteger deptId) { + SysDept dept = new SysDept(); + dept.setId(deptId); + dept.setStatus(EnumDataStatus.AVAILABLE.getCode()); + return dept; + } + + /** + * 构造受发起人部门限制的步骤。 + * + * @param type 审批对象类型 + * @param targets 审批对象 + * @return 步骤 + */ + private ApprovalFlowStepVo restrictedStep(ApprovalAssigneeType type, + List targets) { + ApprovalFlowStepVo step = new ApprovalFlowStepVo(); + step.setStepNo(1); + step.setStepName("审核"); + step.setAssigneeType(type.getCode()); + step.setAssigneeTargets(targets); + step.setRestrictToApplicantDept(1); + return step; + } + + /** + * 构造待审批任务。 + * + * @param taskId 任务 ID + * @param instanceId 实例 ID + * @param requiredDeptId 限定部门 ID + * @return 待审批任务 + */ + private ApprovalTask pendingTask(BigInteger taskId, BigInteger instanceId, BigInteger requiredDeptId) { + ApprovalTask task = new ApprovalTask(); + task.setId(taskId); + task.setInstanceId(instanceId); + task.setStatus(ApprovalTaskStatus.PENDING.getCode()); + task.setAssigneeType(ApprovalAssigneeType.ROLE.getCode()); + task.setRequiredDeptId(requiredDeptId); + return task; + } + /** * 构造审批对象。 * diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImplTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImplTest.java new file mode 100644 index 00000000..8ead1c91 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalFlowServiceImplTest.java @@ -0,0 +1,92 @@ +package tech.easyflow.approval.service.impl; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import tech.easyflow.approval.entity.ApprovalFlow; +import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo; +import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo; +import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo; +import tech.easyflow.approval.enums.ApprovalAssigneeType; +import tech.easyflow.approval.mapper.ApprovalFlowMapper; +import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper; +import tech.easyflow.approval.mapper.ApprovalFlowStepMapper; +import tech.easyflow.approval.mapper.ApprovalInstanceMapper; +import tech.easyflow.approval.service.ApprovalAssigneeService; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; + +/** + * {@link ApprovalFlowServiceImpl} 流程配置校验测试。 + */ +@RunWith(MockitoJUnitRunner.class) +public class ApprovalFlowServiceImplTest { + + @Mock + private ApprovalFlowMapper approvalFlowMapper; + + @Mock + private ApprovalFlowScopeMapper approvalFlowScopeMapper; + + @Mock + private ApprovalFlowStepMapper approvalFlowStepMapper; + + @Mock + private ApprovalInstanceMapper approvalInstanceMapper; + + @Mock + private ApprovalAssigneeService approvalAssigneeService; + + @InjectMocks + private ApprovalFlowServiceImpl service; + + /** + * 验证受限步骤在写入流程前完成合法候选账号校验。 + */ + @Test + public void saveFlowShouldValidateRestrictedCandidatesBeforeInsert() { + ApprovalFlowStepVo step = new ApprovalFlowStepVo(); + step.setStepName("审核"); + step.setAssigneeType(ApprovalAssigneeType.USER.getCode()); + step.setRestrictToApplicantDept(1); + ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo(); + target.setTargetId(BigInteger.valueOf(11)); + step.setAssigneeTargets(List.of(target)); + + ApprovalFlowDetailVo request = new ApprovalFlowDetailVo(); + request.setName("发布审批"); + request.setResourceType("WORKFLOW"); + request.setActionType("PUBLISH"); + request.setPriority(100); + request.setStatus("ENABLED"); + request.setSteps(List.of(step)); + doAnswer(invocation -> invocation.getArgument(0)) + .when(approvalAssigneeService) + .normalizeStepAssignee(any(ApprovalFlowStepVo.class)); + doAnswer(invocation -> { + ((ApprovalFlow) invocation.getArgument(0)).setId(BigInteger.valueOf(301)); + return 1; + }).when(approvalFlowMapper).insert(any(ApprovalFlow.class)); + + assertEquals(BigInteger.valueOf(301), + service.saveFlow(request, BigInteger.valueOf(7))); + + InOrder order = inOrder(approvalAssigneeService, approvalFlowMapper); + order.verify(approvalAssigneeService) + .validateRestrictedStepCandidates(any(), isNull()); + order.verify(approvalFlowMapper).insert(any(ApprovalFlow.class)); + verify(approvalAssigneeService).saveStepAssigneeTargets( + any(), any(), any(), any(), any()); + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceMultiAssigneeTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceMultiAssigneeTest.java index fac65bce..9f62300a 100644 --- a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceMultiAssigneeTest.java +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalInstanceMultiAssigneeTest.java @@ -23,10 +23,14 @@ import tech.easyflow.approval.mapper.ApprovalLogMapper; import tech.easyflow.approval.mapper.ApprovalTaskMapper; import tech.easyflow.approval.service.ApprovalAssigneeService; import tech.easyflow.approval.service.ApprovalMatchService; +import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; 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 java.math.BigInteger; import java.util.Date; @@ -35,11 +39,14 @@ import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -70,6 +77,9 @@ public class ApprovalInstanceMultiAssigneeTest { @Mock private SysAccountService sysAccountService; + @Mock + private SysDeptService sysDeptService; + @InjectMocks private ApprovalInstanceServiceImpl service; @@ -81,6 +91,7 @@ public class ApprovalInstanceMultiAssigneeTest { public void submitApprovalShouldFreezeAllAssigneeTargets() { BigInteger applicantId = BigInteger.valueOf(7); BigInteger tenantId = BigInteger.valueOf(42); + BigInteger applicantDeptId = BigInteger.valueOf(43); BigInteger instanceId = BigInteger.valueOf(101); BigInteger taskId = BigInteger.valueOf(201); ApprovalSubmitRequest request = new ApprovalSubmitRequest(); @@ -92,6 +103,7 @@ public class ApprovalInstanceMultiAssigneeTest { step.setStepNo(1); step.setStepName("审核"); step.setAssigneeType(ApprovalAssigneeType.USER.getCode()); + step.setRestrictToApplicantDept(1); step.setAssigneeTargets(List.of( target(11, "u11", "用户甲"), target(12, "u12", "用户乙"))); @@ -109,7 +121,13 @@ public class ApprovalInstanceMultiAssigneeTest { SysAccount applicant = new SysAccount(); applicant.setId(applicantId); applicant.setTenantId(tenantId); + applicant.setDeptId(applicantDeptId); when(sysAccountService.getById(applicantId)).thenReturn(applicant); + SysDept applicantDept = new SysDept(); + applicantDept.setId(applicantDeptId); + applicantDept.setDeptName("技术部"); + applicantDept.setStatus(EnumDataStatus.AVAILABLE.getCode()); + when(sysDeptService.getById(applicantDeptId)).thenReturn(applicantDept); doAnswer(invocation -> { ((ApprovalInstance) invocation.getArgument(0)).setId(instanceId); return 1; @@ -132,6 +150,16 @@ public class ApprovalInstanceMultiAssigneeTest { (List>) frozenSteps.get(0).get("assigneeTargets"); assertEquals(2, frozenTargets.size()); assertEquals(BigInteger.valueOf(12), frozenTargets.get(1).get("targetId")); + assertEquals(1, frozenSteps.get(0).get("restrictToApplicantDept")); + assertEquals(applicantDeptId, instanceCaptor.getValue().getApplicantDeptId()); + assertEquals("技术部", instanceCaptor.getValue().getApplicantDeptName()); + assertEquals(applicantDeptId, + instanceCaptor.getValue().getSnapshotJson().get("applicantDeptId")); + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(ApprovalTask.class); + verify(approvalTaskMapper).insert(taskCaptor.capture()); + assertEquals(applicantDeptId, taskCaptor.getValue().getRequiredDeptId()); + verify(approvalAssigneeService).validateRestrictedStepCandidates( + eq(List.of(step)), eq(applicantDeptId)); verify(approvalAssigneeService).saveTaskAssigneeTargets( eq(taskId), eq(ApprovalAssigneeType.USER.getCode()), @@ -141,6 +169,48 @@ public class ApprovalInstanceMultiAssigneeTest { verify(approvalLogMapper, org.mockito.Mockito.times(2)).insert(any(ApprovalLog.class)); } + /** + * 验证发起人没有直属部门时,会在创建实例前拒绝受限流程提审。 + */ + @Test + public void submitApprovalShouldRejectRestrictedFlowWhenApplicantHasNoDepartment() { + BigInteger applicantId = BigInteger.valueOf(7); + BigInteger tenantId = BigInteger.valueOf(42); + ApprovalSubmitRequest request = new ApprovalSubmitRequest(); + request.setApplicantId(applicantId); + request.setResourceId(BigInteger.valueOf(501)); + + ApprovalFlowStepVo step = new ApprovalFlowStepVo(); + step.setStepNo(1); + step.setStepName("审核"); + step.setAssigneeType(ApprovalAssigneeType.USER.getCode()); + step.setAssigneeTargets(List.of(target(11, "u11", "用户甲"))); + step.setRestrictToApplicantDept(1); + ApprovalFlowDetailVo flow = new ApprovalFlowDetailVo(); + flow.setId(BigInteger.valueOf(301)); + flow.setVersion(3); + flow.setResourceType("WORKFLOW"); + flow.setActionType("PUBLISH"); + flow.setSteps(List.of(step)); + when(approvalMatchService.matchFlow(request)).thenReturn(flow); + + LoginAccount loginAccount = new LoginAccount(); + loginAccount.setId(applicantId); + loginAccount.setTenantId(tenantId); + SysAccount applicant = new SysAccount(); + applicant.setId(applicantId); + applicant.setTenantId(tenantId); + when(sysAccountService.getById(applicantId)).thenReturn(applicant); + + try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { + saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount); + assertThrows(BusinessException.class, () -> service.submitApproval(request)); + } + + verify(approvalInstanceMapper, never()).insert(any(ApprovalInstance.class)); + verify(approvalTaskMapper, never()).insert(any(ApprovalTask.class)); + } + /** * 验证进入下一步骤时继续使用实例快照中冻结的全部审批对象。 */ @@ -204,6 +274,57 @@ public class ApprovalInstanceMultiAssigneeTest { assertEquals(Integer.valueOf(2), instance.getCurrentStepNo()); } + /** + * 验证下一受限步骤没有合法候选人时,当前任务保持待审批。 + */ + @Test + public void approveShouldKeepCurrentTaskPendingWhenNextRestrictedStepHasNoCandidate() { + BigInteger operatorId = BigInteger.valueOf(7); + BigInteger tenantId = BigInteger.valueOf(42); + BigInteger instanceId = BigInteger.valueOf(101); + BigInteger applicantDeptId = BigInteger.valueOf(21); + ApprovalInstance instance = new ApprovalInstance(); + instance.setId(instanceId); + instance.setTenantId(tenantId); + instance.setFlowId(BigInteger.valueOf(301)); + instance.setStatus(ApprovalInstanceStatus.PENDING.getCode()); + instance.setCurrentStepNo(1); + instance.setApplicantDeptId(applicantDeptId); + instance.setSnapshotJson(Map.of( + "steps", List.of( + frozenStep(1, ApprovalAssigneeType.USER.getCode(), + List.of(frozenTarget(11, 0))), + frozenRestrictedStep(2, ApprovalAssigneeType.ROLE.getCode(), + List.of(frozenTarget(31, 0)))))); + + ApprovalTask currentTask = new ApprovalTask(); + currentTask.setId(BigInteger.valueOf(201)); + currentTask.setInstanceId(instanceId); + currentTask.setStepNo(1); + currentTask.setStatus(ApprovalTaskStatus.PENDING.getCode()); + currentTask.setAssigneeType(ApprovalAssigneeType.USER.getCode()); + SysAccount operator = new SysAccount(); + operator.setId(operatorId); + operator.setTenantId(tenantId); + when(sysAccountService.getById(operatorId)).thenReturn(operator); + when(approvalInstanceMapper.selectOneByQuery(any())).thenReturn(instance); + when(approvalTaskMapper.selectOneByQuery(any())).thenReturn(currentTask); + when(approvalAssigneeService.getAvailableRoleIds(operatorId)).thenReturn(Set.of()); + when(approvalAssigneeService.canHandleTask(currentTask, operatorId, Set.of())).thenReturn(true); + when(approvalFlowStepMapper.selectListByQuery(any())).thenReturn(List.of()); + doThrow(new BusinessException("下一步没有候选人")) + .when(approvalAssigneeService) + .validateRestrictedStepCandidates(any(), eq(applicantDeptId)); + + assertThrows(BusinessException.class, + () -> service.approve(instanceId, "同意", operatorId)); + + assertEquals(ApprovalTaskStatus.PENDING.getCode(), currentTask.getStatus()); + verify(approvalTaskMapper, never()).update(any(ApprovalTask.class)); + verify(approvalTaskMapper, never()).insert(any(ApprovalTask.class)); + verify(approvalInstanceMapper, never()).update(any(ApprovalInstance.class)); + } + /** * 构造冻结步骤。 * @@ -221,6 +342,29 @@ public class ApprovalInstanceMultiAssigneeTest { "assigneeTargetId", primary.get("targetId"), "assigneeTargetCode", "target-" + primary.get("targetId"), "assigneeTargetName", "对象" + primary.get("targetId"), + "restrictToApplicantDept", 0, + "assigneeTargets", targets); + } + + /** + * 构造受发起人部门限制的冻结步骤。 + * + * @param stepNo 步骤序号 + * @param assigneeType 审批对象类型 + * @param targets 审批对象 + * @return 冻结步骤 + */ + private Map frozenRestrictedStep(int stepNo, String assigneeType, + List> targets) { + Map primary = targets.get(0); + return Map.of( + "stepNo", stepNo, + "stepName", "第" + stepNo + "步", + "assigneeType", assigneeType, + "assigneeTargetId", primary.get("targetId"), + "assigneeTargetCode", "target-" + primary.get("targetId"), + "assigneeTargetName", "对象" + primary.get("targetId"), + "restrictToApplicantDept", 1, "assigneeTargets", targets); } diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V40__mysql_approval_applicant_dept_restriction.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V40__mysql_approval_applicant_dept_restriction.sql new file mode 100644 index 00000000..3e0536e9 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V40__mysql_approval_applicant_dept_restriction.sql @@ -0,0 +1,18 @@ +SET NAMES utf8mb4; + +ALTER TABLE `tb_approval_flow_step` + ADD COLUMN `restrict_to_applicant_dept` TINYINT NOT NULL DEFAULT 0 + COMMENT '是否限定发起人直属部门' AFTER `assignee_target_name`; + +ALTER TABLE `tb_approval_instance` + ADD COLUMN `applicant_dept_id` BIGINT UNSIGNED NULL + COMMENT '提交时申请人直属部门ID' AFTER `applicant_id`, + ADD COLUMN `applicant_dept_name` VARCHAR(128) NULL + COMMENT '提交时申请人直属部门名称' AFTER `applicant_dept_id`; + +ALTER TABLE `tb_approval_task` + ADD COLUMN `required_dept_id` BIGINT UNSIGNED NULL + COMMENT '限定直属部门ID' AFTER `assignee_target_name`; + +CREATE INDEX `idx_approval_task_required_dept_status` + ON `tb_approval_task` (`required_dept_id`, `status`); diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json b/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json index 105754ad..1463c73d 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/approval.json @@ -82,6 +82,8 @@ "finishedAt": "Finished At", "assigneeType": "Assignee Type", "assigneeTarget": "Assignee", + "restrictToApplicantDept": "Applicant department only", + "applicantDept": "Applicant Department", "selectedDeptCount": "Selected departments: {value}", "actedBy": "Acted By", "actedAt": "Acted At", @@ -118,6 +120,7 @@ "needStep": "At least one step is required", "needStepAssignee": "Each approval step requires an assignee", "confirmAssigneeTypeChange": "Changing the assignee type clears the current selections. Continue?", + "confirmApplicantDeptRestriction": "This will switch to role approval and clear selected departments. Continue?", "saveSuccess": "Flow saved", "statusUpdated": "Flow status updated", "deleteSuccess": "Flow deleted", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json index 8499748b..8e7ff8b2 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/approval.json @@ -82,6 +82,8 @@ "finishedAt": "完成时间", "assigneeType": "对象类型", "assigneeTarget": "审批对象", + "restrictToApplicantDept": "限发起人部门", + "applicantDept": "发起人部门", "selectedDeptCount": "已选 {value} 个部门", "actedBy": "处理人", "actedAt": "处理时间", @@ -118,6 +120,7 @@ "needStep": "至少需要一个审批步骤", "needStepAssignee": "每个审批步骤都需要配置审批对象", "confirmAssigneeTypeChange": "切换审批方式会清空当前已选对象,确认继续吗?", + "confirmApplicantDeptRestriction": "开启后将改为角色审批并清空已选部门,确认继续吗?", "saveSuccess": "审批流程已保存", "statusUpdated": "流程状态已更新", "deleteSuccess": "流程已删除", diff --git a/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue b/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue index 6ecbc815..27215376 100644 --- a/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue +++ b/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue @@ -374,6 +374,12 @@ function formatEventInfo(row: Record) { ) }} + + {{ detail.snapshotJson.applicantDeptName }} + {{ detail.submittedAt || '-' }} diff --git a/easyflow-ui-admin/app/src/views/system/approval/ApprovalFlowModal.vue b/easyflow-ui-admin/app/src/views/system/approval/ApprovalFlowModal.vue index 39947d93..08c5ca5a 100644 --- a/easyflow-ui-admin/app/src/views/system/approval/ApprovalFlowModal.vue +++ b/easyflow-ui-admin/app/src/views/system/approval/ApprovalFlowModal.vue @@ -1,6 +1,8 @@