feat: 增加审批步骤发起人部门限制

- 增加配置期与提审期合法审批账号校验

- 冻结发起人部门并统一待办与办理权限判断

- 增加兼容迁移、前端联动和自动化测试
This commit is contained in:
2026-07-31 09:38:53 +08:00
parent 0b764b79de
commit 048aa9bc1e
19 changed files with 1089 additions and 18 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -24,6 +24,11 @@ public class ApprovalFlowStepVo {
private List<ApprovalAssigneeTargetVo> assigneeTargets;
/**
* 是否限定发起人直属部门。
*/
private Integer restrictToApplicantDept;
public BigInteger getId() {
return id;
}
@@ -97,4 +102,22 @@ public class ApprovalFlowStepVo {
public void setAssigneeTargets(List<ApprovalAssigneeTargetVo> assigneeTargets) {
this.assigneeTargets = assigneeTargets;
}
/**
* 获取发起人部门限制标记。
*
* @return 1 表示限制0 表示不限制
*/
public Integer getRestrictToApplicantDept() {
return restrictToApplicantDept;
}
/**
* 设置发起人部门限制标记。
*
* @param restrictToApplicantDept 限制标记
*/
public void setRestrictToApplicantDept(Integer restrictToApplicantDept) {
this.restrictToApplicantDept = restrictToApplicantDept;
}
}

View File

@@ -26,6 +26,14 @@ public interface ApprovalAssigneeService {
*/
ApprovalFlowStepVo normalizeStepAssignee(ApprovalFlowStepVo step);
/**
* 校验受发起人部门限制的步骤至少存在一个合法审批账号。
*
* @param steps 步骤配置
* @param requiredDeptId 指定直属部门 ID为空时校验任一有效部门
*/
void validateRestrictedStepCandidates(List<ApprovalFlowStepVo> steps, BigInteger requiredDeptId);
/**
* 批量加载流程步骤的审批对象,关联记录缺失时兼容旧单值字段。
*

View File

@@ -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<ApprovalAssigneeTargetVo> 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<ApprovalFlowStepVo> steps, BigInteger requiredDeptId) {
List<ApprovalFlowStepVo> 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<BigInteger> directAccountIds = new LinkedHashSet<>();
Set<BigInteger> roleIds = new LinkedHashSet<>();
for (ApprovalFlowStepVo step : restrictedSteps) {
ApprovalAssigneeType type = ApprovalAssigneeType.from(step.getAssigneeType());
if (ApprovalAssigneeType.DEPT == type) {
throw new BusinessException("限定发起人部门后,审批对象类型不能选择部门");
}
Set<BigInteger> targetIds = stepTargetIds(step);
if (ApprovalAssigneeType.USER == type) {
directAccountIds.addAll(targetIds);
} else {
roleIds.addAll(targetIds);
}
}
Set<BigInteger> 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<SysAccountRole> roleRelations = availableRoleIds.isEmpty() ? List.of() : safeList(
sysAccountRoleService.list(QueryWrapper.create()
.in(SysAccountRole::getRoleId, availableRoleIds)));
Set<BigInteger> 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<SysAccount> accounts = candidateAccountIds.isEmpty()
? List.of()
: safeList(sysAccountService.list(accountQuery));
Set<BigInteger> accountDeptIds = accounts.stream()
.map(SysAccount::getDeptId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<BigInteger> 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<BigInteger> eligibleAccountIds = accounts.stream()
.filter(account -> account.getDeptId() != null
&& availableDeptIds.contains(account.getDeptId()))
.map(SysAccount::getId)
.collect(Collectors.toSet());
Map<BigInteger, Set<BigInteger>> 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<ApprovalAssigneeTargetVo> 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<BigInteger> departmentIds = resolveDepartmentIds(operator == null ? null : operator.getDeptId());
if (CollectionUtil.isNotEmpty(departmentIds)) {
matchedTaskIds.addAll(loadMatchingRelationTaskIds(
@@ -328,6 +445,7 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
}
Set<BigInteger> 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<BigInteger> stepTargetIds(ApprovalFlowStepVo step) {
List<ApprovalAssigneeTargetVo> 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 + "在发起人部门内没有可用审批账号";
}
/**
* 解析并校验单个审批对象。
*

View File

@@ -295,12 +295,15 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
normalized.setAssigneeType(item.getAssigneeType());
normalized.setAssigneeTargetId(item.getAssigneeTargetId());
normalized.setAssigneeTargets(item.getAssigneeTargets());
normalized.setRestrictToApplicantDept(
Integer.valueOf(1).equals(item.getRestrictToApplicantDept()) ? 1 : 0);
approvalAssigneeService.normalizeStepAssignee(normalized);
result.add(normalized);
}
if (result.isEmpty()) {
throw new BusinessException("审批步骤不能为空");
}
approvalAssigneeService.validateRestrictedStepCandidates(result, null);
return result;
}
@@ -337,6 +340,7 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
step.setAssigneeTargetId(stepVo.getAssigneeTargetId());
step.setAssigneeTargetCode(stepVo.getAssigneeTargetCode());
step.setAssigneeTargetName(stepVo.getAssigneeTargetName());
step.setRestrictToApplicantDept(stepVo.getRestrictToApplicantDept());
step.setCreated(now);
step.setCreatedBy(operatorId);
step.setModified(now);
@@ -394,6 +398,8 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
stepVo.setAssigneeTargetCode(item.getAssigneeTargetCode());
stepVo.setAssigneeTargetName(item.getAssigneeTargetName());
stepVo.setAssigneeTargets(targetMap.getOrDefault(item.getId(), List.of()));
stepVo.setRestrictToApplicantDept(
Integer.valueOf(1).equals(item.getRestrictToApplicantDept()) ? 1 : 0);
return stepVo;
})
.collect(Collectors.toList());
@@ -412,6 +418,7 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
for (ApprovalFlowStepVo step : steps) {
approvalAssigneeService.normalizeStepAssignee(step);
}
approvalAssigneeService.validateRestrictedStepCandidates(steps, null);
}
private long countPendingInstances(BigInteger flowId) {

View File

@@ -28,8 +28,11 @@ import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalMatchService;
import tech.easyflow.common.constant.enums.EnumDataStatus;
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 javax.annotation.Resource;
import java.math.BigInteger;
@@ -69,6 +72,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Resource
private SysAccountService sysAccountService;
@Resource
private SysDeptService sysDeptService;
@Lazy
@Resource
private ApprovalActionFacade approvalActionFacade;
@@ -100,6 +106,11 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
List<ApprovalFlowStepVo> 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<String, Object> submittedPayload = new LinkedHashMap<>();
submittedPayload.put("flowId", flow.getId());
submittedPayload.put("flowVersion", flow.getVersion());
@@ -150,16 +165,24 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
List<ApprovalFlowStepVo> 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<String, Object> buildInstanceSnapshot(ApprovalSubmitRequest request, ApprovalFlowDetailVo flow,
List<ApprovalFlowStepVo> steps) {
List<ApprovalFlowStepVo> steps, SysDept applicantDept) {
Map<String, Object> 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<String, Object> 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<ApprovalAssigneeTargetVo> 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<ApprovalFlowStepVo> 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;
}
/**
* 将旧单值步骤转换为单元素审批对象。
*

View File

@@ -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 审批发起人部门限制迁移");
}
}

View File

@@ -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<ApprovalAssigneeTargetVo> 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;
}
/**
* 构造审批对象。
*

View File

@@ -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());
}
}

View File

@@ -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<Map<String, Object>>) 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<ApprovalTask> 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<SaTokenUtil> 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<String, Object> frozenRestrictedStep(int stepNo, String assigneeType,
List<Map<String, Object>> targets) {
Map<String, Object> 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);
}

View File

@@ -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`);

View File

@@ -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",

View File

@@ -82,6 +82,8 @@
"finishedAt": "完成时间",
"assigneeType": "对象类型",
"assigneeTarget": "审批对象",
"restrictToApplicantDept": "限发起人部门",
"applicantDept": "发起人部门",
"selectedDeptCount": "已选 {value} 个部门",
"actedBy": "处理人",
"actedAt": "处理时间",
@@ -118,6 +120,7 @@
"needStep": "至少需要一个审批步骤",
"needStepAssignee": "每个审批步骤都需要配置审批对象",
"confirmAssigneeTypeChange": "切换审批方式会清空当前已选对象,确认继续吗?",
"confirmApplicantDeptRestriction": "开启后将改为角色审批并清空已选部门,确认继续吗?",
"saveSuccess": "审批流程已保存",
"statusUpdated": "流程状态已更新",
"deleteSuccess": "流程已删除",

View File

@@ -374,6 +374,12 @@ function formatEventInfo(row: Record<string, any>) {
)
}}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.snapshotJson?.applicantDeptName"
:label="$t('approval.fields.applicantDept')"
>
{{ detail.snapshotJson.applicantDeptName }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.submittedAt')">
{{ detail.submittedAt || '-' }}
</ElDescriptionsItem>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { ApprovalAssigneeType as AssigneeType } from './approval-flow-restriction';
import { computed, nextTick, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -23,6 +25,14 @@ import {
import { api } from '#/api/request';
import { $t } from '#/locales';
import {
applyApplicantDeptRestriction,
isApplicantDeptAssigneeTypeDisabled,
needsApplicantDeptRestrictionConfirmation,
normalizeApplicantDeptRestriction,
serializeApplicantDeptRestriction,
} from './approval-flow-restriction';
const emit = defineEmits<{
reload: [];
}>();
@@ -33,7 +43,6 @@ defineExpose({
type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
type AssigneeType = 'DEPT' | 'ROLE' | 'USER';
type ScopeType = 'CATEGORY' | 'DEPT';
type FlowStatus = 'DISABLED' | 'ENABLED';
@@ -55,6 +64,7 @@ interface StepItem {
assigneeType: AssigneeType;
includeChildrenByTarget: Record<string, boolean>;
id?: number | string;
restrictToApplicantDept: boolean;
stepName: string;
}
@@ -191,6 +201,7 @@ function buildDefaultStep(): StepItem {
assigneeTargets: [],
assigneeType: 'ROLE',
includeChildrenByTarget: {},
restrictToApplicantDept: false,
stepName: '',
};
}
@@ -241,6 +252,9 @@ async function openDialog(row: any = {}) {
assigneeType: item.assigneeType || 'ROLE',
id: item.id,
includeChildrenByTarget: buildIncludeChildrenByTarget(targets),
restrictToApplicantDept: normalizeApplicantDeptRestriction(
item.restrictToApplicantDept,
),
stepName: item.stepName,
};
}),
@@ -413,7 +427,13 @@ async function handleAssigneeTypeChange(
step: StepItem,
assigneeType: AssigneeType,
) {
if (step.assigneeType === assigneeType) {
if (
step.assigneeType === assigneeType ||
isApplicantDeptAssigneeTypeDisabled(
step.restrictToApplicantDept,
assigneeType,
)
) {
return;
}
if (step.assigneeTargetIds.length > 0) {
@@ -443,6 +463,32 @@ async function handleAssigneeTypeChange(
}
}
async function handleApplicantDeptRestrictionChange(
step: StepItem,
enabled: boolean | number | string,
) {
const restricted = normalizeApplicantDeptRestriction(enabled);
if (needsApplicantDeptRestrictionConfirmation(step, restricted)) {
try {
await ElMessageBox.confirm(
$t('approval.message.confirmApplicantDeptRestriction'),
$t('approval.fields.restrictToApplicantDept'),
{
cancelButtonText: $t('button.cancel'),
confirmButtonText: $t('button.confirm'),
type: 'warning',
},
);
} catch (error) {
if (error === 'cancel' || error === 'close') {
return;
}
throw error;
}
}
applyApplicantDeptRestriction(step, restricted);
}
function handleAssigneeTargetChange(step: StepItem) {
const selectedIds = new Set(step.assigneeTargetIds.map(String));
step.assigneeTargets = step.assigneeTargets.filter((target) =>
@@ -515,6 +561,9 @@ async function save() {
})),
assigneeType: step.assigneeType,
id: step.id,
restrictToApplicantDept: serializeApplicantDeptRestriction(
step.restrictToApplicantDept,
),
stepName: step.stepName?.trim(),
stepNo: index + 1,
}))
@@ -733,14 +782,25 @@ function closeDialog() {
<span class="approval-flow-modal__step-index">
{{ $t('approval.fields.stepNo', { value: index + 1 }) }}
</span>
<ElButton
text
type="danger"
:disabled="formModel.steps.length <= 1"
@click="removeStep(index)"
>
{{ $t('button.delete') }}
</ElButton>
<div class="approval-flow-modal__step-actions">
<label class="approval-flow-modal__restriction-switch">
<span>{{ $t('approval.fields.restrictToApplicantDept') }}</span>
<ElSwitch
:model-value="step.restrictToApplicantDept"
@change="
(value) => handleApplicantDeptRestrictionChange(step, value)
"
/>
</label>
<ElButton
text
type="danger"
:disabled="formModel.steps.length <= 1"
@click="removeStep(index)"
>
{{ $t('button.delete') }}
</ElButton>
</div>
</div>
<div class="approval-flow-modal__step-grid">
<div class="approval-flow-modal__step-field">
@@ -765,7 +825,17 @@ function closeDialog() {
class="approval-flow-modal__assignee-type-item"
:class="{
'is-active': step.assigneeType === item.value,
'is-disabled': isApplicantDeptAssigneeTypeDisabled(
step.restrictToApplicantDept,
item.value,
),
}"
:disabled="
isApplicantDeptAssigneeTypeDisabled(
step.restrictToApplicantDept,
item.value,
)
"
:aria-pressed="step.assigneeType === item.value"
@click="handleAssigneeTypeChange(step, item.value)"
>
@@ -995,6 +1065,21 @@ function closeDialog() {
border-radius: var(--radius-pill);
}
.approval-flow-modal__step-actions {
display: inline-flex;
gap: var(--space-3);
align-items: center;
}
.approval-flow-modal__restriction-switch {
display: inline-flex;
gap: var(--space-2);
align-items: center;
font-size: 13px;
color: hsl(var(--text-muted));
white-space: nowrap;
}
.approval-flow-modal__step-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -1051,6 +1136,15 @@ function closeDialog() {
color: hsl(var(--foreground));
}
.approval-flow-modal__assignee-type-item:disabled {
color: hsl(var(--text-muted) / 48%);
cursor: not-allowed;
}
.approval-flow-modal__assignee-type-item:disabled:hover {
color: hsl(var(--text-muted) / 48%);
}
.approval-flow-modal__assignee-type-item:focus-visible {
outline: 2px solid hsl(var(--primary) / 28%);
outline-offset: 1px;

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
applyApplicantDeptRestriction,
isApplicantDeptAssigneeTypeDisabled,
needsApplicantDeptRestrictionConfirmation,
normalizeApplicantDeptRestriction,
serializeApplicantDeptRestriction,
} from './approval-flow-restriction';
describe('approval flow applicant department restriction', () => {
it('normalizes edit values and serializes save values', () => {
expect(normalizeApplicantDeptRestriction(1)).toBe(true);
expect(normalizeApplicantDeptRestriction(undefined)).toBe(false);
expect(serializeApplicantDeptRestriction(true)).toBe(1);
expect(serializeApplicantDeptRestriction(false)).toBe(0);
});
it('switches a department step to role and clears department targets', () => {
const step = {
assigneeTargetIds: [21],
assigneeTargets: [{ targetId: 21 }],
assigneeType: 'DEPT' as const,
includeChildrenByTarget: { '21': true },
restrictToApplicantDept: false,
};
expect(needsApplicantDeptRestrictionConfirmation(step, true)).toBe(true);
applyApplicantDeptRestriction(step, true);
expect(step).toEqual({
assigneeTargetIds: [],
assigneeTargets: [],
assigneeType: 'ROLE',
includeChildrenByTarget: {},
restrictToApplicantDept: true,
});
expect(isApplicantDeptAssigneeTypeDisabled(true, 'DEPT')).toBe(true);
expect(isApplicantDeptAssigneeTypeDisabled(true, 'ROLE')).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
export type ApprovalAssigneeType = 'DEPT' | 'ROLE' | 'USER';
export interface ApplicantDeptRestrictionStepState<TTarget = unknown> {
assigneeTargetIds: Array<number | string>;
assigneeTargets: TTarget[];
assigneeType: ApprovalAssigneeType;
includeChildrenByTarget: Record<string, boolean>;
restrictToApplicantDept: boolean;
}
export function normalizeApplicantDeptRestriction(value: unknown) {
return value === true || value === 1 || value === '1' || value === 'true';
}
export function needsApplicantDeptRestrictionConfirmation(
step: ApplicantDeptRestrictionStepState,
enabled: boolean,
) {
return (
enabled && step.assigneeType === 'DEPT' && step.assigneeTargetIds.length > 0
);
}
export function applyApplicantDeptRestriction<TTarget>(
step: ApplicantDeptRestrictionStepState<TTarget>,
enabled: boolean,
) {
if (enabled && step.assigneeType === 'DEPT') {
step.assigneeType = 'ROLE';
step.assigneeTargetIds = [];
step.assigneeTargets = [];
step.includeChildrenByTarget = {};
}
step.restrictToApplicantDept = enabled;
}
export function isApplicantDeptAssigneeTypeDisabled(
restricted: boolean,
assigneeType: ApprovalAssigneeType,
) {
return restricted && assigneeType === 'DEPT';
}
export function serializeApplicantDeptRestriction(restricted: boolean) {
return restricted ? 1 : 0;
}