feat: 支持发起人撤回审批
- 在我发起和审批详情中提供申请人撤回操作并恢复资源状态 - 补齐审批说明列表展示、动作权限校验和防重复提交 - 为审批决策增加行锁并补充撤回权限回归测试
This commit is contained in:
@@ -34,6 +34,7 @@ import java.util.Date;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,8 +185,11 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
|||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void revoke(BigInteger instanceId, String comment, BigInteger operatorId) {
|
public void revoke(BigInteger instanceId, String comment, BigInteger operatorId) {
|
||||||
ApprovalInstance instance = requireActiveInstance(instanceId);
|
ApprovalInstance instance = requireActiveInstance(instanceId);
|
||||||
|
// 撤回属于发起人的自助操作,不能沿用审批任务处理人的授权口径。
|
||||||
|
if (!Objects.equals(instance.getApplicantId(), operatorId)) {
|
||||||
|
throw new BusinessException(403, 403, "仅审批申请人可以撤回该请求");
|
||||||
|
}
|
||||||
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
|
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
|
||||||
assertTaskOperable(currentTask, operatorId);
|
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
finishTask(currentTask, ApprovalTaskStatus.REVOKED.getCode(), comment, operatorId, now);
|
finishTask(currentTask, ApprovalTaskStatus.REVOKED.getCode(), comment, operatorId, now);
|
||||||
instance.setStatus(ApprovalInstanceStatus.REVOKED.getCode());
|
instance.setStatus(ApprovalInstanceStatus.REVOKED.getCode());
|
||||||
@@ -344,7 +348,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
|||||||
if (instanceId == null) {
|
if (instanceId == null) {
|
||||||
throw new BusinessException("审批实例ID不能为空");
|
throw new BusinessException("审批实例ID不能为空");
|
||||||
}
|
}
|
||||||
ApprovalInstance instance = approvalInstanceMapper.selectOneById(instanceId);
|
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(
|
||||||
|
QueryWrapper.create().eq(ApprovalInstance::getId, instanceId).forUpdate()
|
||||||
|
);
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
throw new BusinessException("审批实例不存在");
|
throw new BusinessException("审批实例不存在");
|
||||||
}
|
}
|
||||||
@@ -358,7 +364,8 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
|||||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
.eq(ApprovalTask::getInstanceId, instanceId)
|
.eq(ApprovalTask::getInstanceId, instanceId)
|
||||||
.eq(ApprovalTask::getStepNo, stepNo)
|
.eq(ApprovalTask::getStepNo, stepNo)
|
||||||
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode());
|
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode())
|
||||||
|
.forUpdate();
|
||||||
ApprovalTask task = approvalTaskMapper.selectOneByQuery(queryWrapper);
|
ApprovalTask task = approvalTaskMapper.selectOneByQuery(queryWrapper);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
throw new BusinessException("当前审批任务不存在");
|
throw new BusinessException("当前审批任务不存在");
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import java.util.HashSet;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -202,13 +203,14 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
|||||||
|
|
||||||
LoginAccount account = requireLoginAccount();
|
LoginAccount account = requireLoginAccount();
|
||||||
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
|
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
|
||||||
boolean canOperate = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished()
|
boolean active = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished();
|
||||||
|
boolean canReview = active
|
||||||
&& tasks.stream().anyMatch(item -> item.getStepNo().equals(instance.getCurrentStepNo())
|
&& tasks.stream().anyMatch(item -> item.getStepNo().equals(instance.getCurrentStepNo())
|
||||||
&& ApprovalTaskStatus.PENDING.getCode().equals(item.getStatus())
|
&& ApprovalTaskStatus.PENDING.getCode().equals(item.getStatus())
|
||||||
&& approvalAssigneeService.canHandleTask(item, account.getId(), roleIds));
|
&& approvalAssigneeService.canHandleTask(item, account.getId(), roleIds));
|
||||||
detail.setCanApprove(canOperate);
|
detail.setCanApprove(canReview);
|
||||||
detail.setCanReject(canOperate);
|
detail.setCanReject(canReview);
|
||||||
detail.setCanRevoke(canOperate);
|
detail.setCanRevoke(active && Objects.equals(account.getId(), instance.getApplicantId()));
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,12 +317,13 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
|||||||
item.setApplicantId(record.getApplicantId());
|
item.setApplicantId(record.getApplicantId());
|
||||||
item.setSubmittedAt(record.getSubmittedAt());
|
item.setSubmittedAt(record.getSubmittedAt());
|
||||||
item.setFinishedAt(record.getFinishedAt());
|
item.setFinishedAt(record.getFinishedAt());
|
||||||
boolean canOperate = pendingMode
|
boolean active = !ApprovalInstanceStatus.from(record.getStatus()).isFinished();
|
||||||
|
boolean canReview = pendingMode
|
||||||
&& pendingTaskInstanceIds.contains(record.getId())
|
&& pendingTaskInstanceIds.contains(record.getId())
|
||||||
&& !ApprovalInstanceStatus.from(record.getStatus()).isFinished();
|
&& active;
|
||||||
item.setCanApprove(canOperate);
|
item.setCanApprove(canReview);
|
||||||
item.setCanReject(canOperate);
|
item.setCanReject(canReview);
|
||||||
item.setCanRevoke(canOperate);
|
item.setCanRevoke(active && Objects.equals(account.getId(), record.getApplicantId()));
|
||||||
result.add(item);
|
result.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package tech.easyflow.approval.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
import org.mockito.junit.MockitoJUnitRunner;
|
||||||
|
import tech.easyflow.approval.entity.ApprovalInstance;
|
||||||
|
import tech.easyflow.approval.entity.ApprovalTask;
|
||||||
|
import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo;
|
||||||
|
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
|
||||||
|
import tech.easyflow.approval.enums.ApprovalTaskStatus;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalFlowStepMapper;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalLogMapper;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
|
||||||
|
import tech.easyflow.approval.service.ApprovalActionFacade;
|
||||||
|
import tech.easyflow.approval.service.ApprovalAssigneeService;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批撤回入口查询测试。
|
||||||
|
*/
|
||||||
|
@RunWith(MockitoJUnitRunner.class)
|
||||||
|
public class ApprovalRevokeQueryTest {
|
||||||
|
|
||||||
|
private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101);
|
||||||
|
private static final BigInteger TENANT_ID = BigInteger.valueOf(42);
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalInstanceMapper approvalInstanceMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalTaskMapper approvalTaskMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalLogMapper approvalLogMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalFlowStepMapper approvalFlowStepMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalAssigneeService approvalAssigneeService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalActionFacade approvalActionFacade;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CategoryPermissionService categoryPermissionService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SysAccountService sysAccountService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ApprovalQueryServiceImpl service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证活动审批的发起人只能看到撤回入口。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void detailShouldExposeRevokeOnlyToApplicant() {
|
||||||
|
LoginAccount account = account(7);
|
||||||
|
ApprovalInstance instance = instance(7);
|
||||||
|
stubDetail(instance, List.of());
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||||
|
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
|
||||||
|
assertTrue(detail.isCanRevoke());
|
||||||
|
assertFalse(detail.isCanApprove());
|
||||||
|
assertFalse(detail.isCanReject());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当前审批人只能看到通过和驳回入口,不能撤回他人申请。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void detailShouldNotExposeRevokeToCurrentReviewer() {
|
||||||
|
LoginAccount account = account(7);
|
||||||
|
ApprovalInstance instance = instance(8);
|
||||||
|
ApprovalTask task = new ApprovalTask();
|
||||||
|
task.setInstanceId(INSTANCE_ID);
|
||||||
|
task.setStepNo(1);
|
||||||
|
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
|
||||||
|
stubDetail(instance, List.of(task));
|
||||||
|
when(approvalAssigneeService.canHandleTask(task, account.getId(), Set.of())).thenReturn(true);
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||||
|
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
|
||||||
|
assertTrue(detail.isCanApprove());
|
||||||
|
assertTrue(detail.isCanReject());
|
||||||
|
assertFalse(detail.isCanRevoke());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置详情查询所需的兼容桩。
|
||||||
|
*
|
||||||
|
* @param instance 审批实例
|
||||||
|
* @param tasks 审批任务
|
||||||
|
*/
|
||||||
|
private void stubDetail(ApprovalInstance instance, List<ApprovalTask> tasks) {
|
||||||
|
lenient().when(approvalInstanceMapper.selectOneById(INSTANCE_ID)).thenReturn(instance);
|
||||||
|
lenient().when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||||
|
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(tasks);
|
||||||
|
when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
when(approvalAssigneeService.getAvailableRoleIds(any(BigInteger.class))).thenReturn(Set.of());
|
||||||
|
lenient().when(sysAccountService.listByIds(any())).thenReturn(List.of());
|
||||||
|
lenient().when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造活动审批实例。
|
||||||
|
*
|
||||||
|
* @param applicantId 申请人 ID
|
||||||
|
* @return 活动审批实例
|
||||||
|
*/
|
||||||
|
private ApprovalInstance instance(long applicantId) {
|
||||||
|
ApprovalInstance instance = new ApprovalInstance();
|
||||||
|
instance.setId(INSTANCE_ID);
|
||||||
|
instance.setTenantId(TENANT_ID);
|
||||||
|
instance.setFlowId(BigInteger.valueOf(301));
|
||||||
|
instance.setFlowVersion(1);
|
||||||
|
instance.setResourceType("KNOWLEDGE");
|
||||||
|
instance.setResourceId(BigInteger.valueOf(501));
|
||||||
|
instance.setActionType("PUBLISH");
|
||||||
|
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
|
||||||
|
instance.setCurrentStepNo(1);
|
||||||
|
instance.setApplicantId(BigInteger.valueOf(applicantId));
|
||||||
|
instance.setSnapshotJson(Map.of(
|
||||||
|
"steps", List.of(Map.of(
|
||||||
|
"stepNo", 1,
|
||||||
|
"stepName", "审核"
|
||||||
|
))
|
||||||
|
));
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造当前登录账号。
|
||||||
|
*
|
||||||
|
* @param accountId 账号 ID
|
||||||
|
* @return 登录账号
|
||||||
|
*/
|
||||||
|
private LoginAccount account(long accountId) {
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.valueOf(accountId));
|
||||||
|
account.setTenantId(TENANT_ID);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package tech.easyflow.approval.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.MockitoJUnitRunner;
|
||||||
|
import tech.easyflow.approval.entity.ApprovalInstance;
|
||||||
|
import tech.easyflow.approval.entity.ApprovalLog;
|
||||||
|
import tech.easyflow.approval.entity.ApprovalTask;
|
||||||
|
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
|
||||||
|
import tech.easyflow.approval.enums.ApprovalTaskStatus;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalLogMapper;
|
||||||
|
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
|
||||||
|
import tech.easyflow.approval.service.ApprovalActionFacade;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批发起人撤回行为测试。
|
||||||
|
*/
|
||||||
|
@RunWith(MockitoJUnitRunner.class)
|
||||||
|
public class ApprovalRevokeServiceTest {
|
||||||
|
|
||||||
|
private static final BigInteger APPLICANT_ID = BigInteger.valueOf(7);
|
||||||
|
private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101);
|
||||||
|
private static final BigInteger TENANT_ID = BigInteger.valueOf(42);
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalInstanceMapper approvalInstanceMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalTaskMapper approvalTaskMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalLogMapper approvalLogMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApprovalActionFacade approvalActionFacade;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SysAccountService sysAccountService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ApprovalInstanceServiceImpl service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证发起人撤回后结束当前任务、记录日志并恢复资源状态。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void revokeShouldCompleteCurrentTaskForApplicant() {
|
||||||
|
ApprovalInstance instance = activeInstance(APPLICANT_ID);
|
||||||
|
ApprovalTask task = pendingTask();
|
||||||
|
lenient().when(sysAccountService.getById(APPLICANT_ID)).thenReturn(tenantAccount(APPLICANT_ID));
|
||||||
|
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||||
|
when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(task);
|
||||||
|
|
||||||
|
service.revoke(INSTANCE_ID, "内容需要调整", APPLICANT_ID);
|
||||||
|
|
||||||
|
assertEquals(ApprovalInstanceStatus.REVOKED.getCode(), instance.getStatus());
|
||||||
|
assertNotNull(instance.getFinishedAt());
|
||||||
|
assertEquals(ApprovalTaskStatus.REVOKED.getCode(), task.getStatus());
|
||||||
|
assertEquals(APPLICANT_ID, task.getActedBy());
|
||||||
|
assertEquals("内容需要调整", task.getComment());
|
||||||
|
verify(approvalLogMapper).insert(any(ApprovalLog.class));
|
||||||
|
verify(approvalActionFacade).handleRevoked(instance, APPLICANT_ID, "内容需要调整");
|
||||||
|
|
||||||
|
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
verify(approvalInstanceMapper).selectOneByQuery(queryCaptor.capture());
|
||||||
|
assertTrue(queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当前审批人或其他同租户账号不能代替发起人撤回。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void revokeShouldRejectNonApplicant() {
|
||||||
|
BigInteger operatorId = BigInteger.valueOf(8);
|
||||||
|
lenient().when(sysAccountService.getById(operatorId)).thenReturn(tenantAccount(operatorId));
|
||||||
|
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class)))
|
||||||
|
.thenReturn(activeInstance(APPLICANT_ID));
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> service.revoke(INSTANCE_ID, "尝试撤回", operatorId)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(403, exception.getHttpStatus());
|
||||||
|
verify(approvalTaskMapper, never()).selectOneByQuery(any(QueryWrapper.class));
|
||||||
|
verify(approvalActionFacade, never())
|
||||||
|
.handleRevoked(any(ApprovalInstance.class), any(BigInteger.class), any(String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造活动审批实例。
|
||||||
|
*
|
||||||
|
* @param applicantId 申请人 ID
|
||||||
|
* @return 活动审批实例
|
||||||
|
*/
|
||||||
|
private ApprovalInstance activeInstance(BigInteger applicantId) {
|
||||||
|
ApprovalInstance instance = new ApprovalInstance();
|
||||||
|
instance.setId(INSTANCE_ID);
|
||||||
|
instance.setTenantId(TENANT_ID);
|
||||||
|
instance.setApplicantId(applicantId);
|
||||||
|
instance.setCurrentStepNo(1);
|
||||||
|
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造待处理任务。
|
||||||
|
*
|
||||||
|
* @return 待处理任务
|
||||||
|
*/
|
||||||
|
private ApprovalTask pendingTask() {
|
||||||
|
ApprovalTask task = new ApprovalTask();
|
||||||
|
task.setInstanceId(INSTANCE_ID);
|
||||||
|
task.setStepNo(1);
|
||||||
|
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造具有租户归属的账号。
|
||||||
|
*
|
||||||
|
* @param accountId 账号 ID
|
||||||
|
* @return 账号实体
|
||||||
|
*/
|
||||||
|
private SysAccount tenantAccount(BigInteger accountId) {
|
||||||
|
SysAccount account = new SysAccount();
|
||||||
|
account.setId(accountId);
|
||||||
|
account.setTenantId(TENANT_ID);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ElTag,
|
ElTag,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { hasPermission } from '#/api/common/hasPermission';
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { router } from '#/router';
|
import { router } from '#/router';
|
||||||
@@ -41,11 +42,24 @@ const actionLabelMap: Record<string, string> = {
|
|||||||
PUBLISH: $t('approval.action.publish'),
|
PUBLISH: $t('approval.action.publish'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canApproveAction = computed(
|
||||||
|
() =>
|
||||||
|
!!detail.value?.canApprove &&
|
||||||
|
hasPermission(['/api/v1/approvalInstance/approve']),
|
||||||
|
);
|
||||||
|
const canRejectAction = computed(
|
||||||
|
() =>
|
||||||
|
!!detail.value?.canReject &&
|
||||||
|
hasPermission(['/api/v1/approvalInstance/reject']),
|
||||||
|
);
|
||||||
|
const canRevokeAction = computed(
|
||||||
|
() =>
|
||||||
|
!!detail.value?.canRevoke &&
|
||||||
|
hasPermission(['/api/v1/approvalInstance/revoke']),
|
||||||
|
);
|
||||||
const canOperate = computed(() => {
|
const canOperate = computed(() => {
|
||||||
return (
|
return (
|
||||||
!!detail.value?.canApprove ||
|
canApproveAction.value || canRejectAction.value || canRevokeAction.value
|
||||||
!!detail.value?.canReject ||
|
|
||||||
!!detail.value?.canRevoke
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +272,7 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
|
|
||||||
<div v-if="canOperate" class="flex items-center gap-3">
|
<div v-if="canOperate" class="flex items-center gap-3">
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail?.canApprove"
|
v-if="canApproveAction"
|
||||||
type="success"
|
type="success"
|
||||||
:disabled="Boolean(approvalActionLoading)"
|
:disabled="Boolean(approvalActionLoading)"
|
||||||
:loading="approvalActionLoading === 'approve'"
|
:loading="approvalActionLoading === 'approve'"
|
||||||
@@ -267,7 +281,7 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
{{ $t('approval.action.approve') }}
|
{{ $t('approval.action.approve') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail?.canReject"
|
v-if="canRejectAction"
|
||||||
type="danger"
|
type="danger"
|
||||||
:disabled="Boolean(approvalActionLoading)"
|
:disabled="Boolean(approvalActionLoading)"
|
||||||
:loading="approvalActionLoading === 'reject'"
|
:loading="approvalActionLoading === 'reject'"
|
||||||
@@ -276,7 +290,7 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
{{ $t('approval.action.reject') }}
|
{{ $t('approval.action.reject') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail?.canRevoke"
|
v-if="canRevokeAction"
|
||||||
type="warning"
|
type="warning"
|
||||||
:disabled="Boolean(approvalActionLoading)"
|
:disabled="Boolean(approvalActionLoading)"
|
||||||
:loading="approvalActionLoading === 'revoke'"
|
:loading="approvalActionLoading === 'revoke'"
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
ElTag,
|
ElTag,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
|
||||||
import { hasPermission } from '#/api/common/hasPermission';
|
import { hasPermission } from '#/api/common/hasPermission';
|
||||||
|
import { api } from '#/api/request';
|
||||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
@@ -101,6 +101,7 @@ const pendingPageRef = ref();
|
|||||||
const processedPageRef = ref();
|
const processedPageRef = ref();
|
||||||
const initiatedPageRef = ref();
|
const initiatedPageRef = ref();
|
||||||
const pendingBadgeCount = ref(0);
|
const pendingBadgeCount = ref(0);
|
||||||
|
const approvalActionLoadingKey = ref('');
|
||||||
|
|
||||||
const flowQuery = ref({
|
const flowQuery = ref({
|
||||||
actionType: '',
|
actionType: '',
|
||||||
@@ -235,7 +236,8 @@ async function syncRouteTab() {
|
|||||||
const currentQueryTab = String(route.query.tab || '');
|
const currentQueryTab = String(route.query.tab || '');
|
||||||
if (
|
if (
|
||||||
fallbackTab &&
|
fallbackTab &&
|
||||||
(route.path !== '/sys/approval' || currentQueryTab !== String(fallbackTab.name))
|
(route.path !== '/sys/approval' ||
|
||||||
|
currentQueryTab !== String(fallbackTab.name))
|
||||||
) {
|
) {
|
||||||
await router.replace({
|
await router.replace({
|
||||||
path: '/sys/approval',
|
path: '/sys/approval',
|
||||||
@@ -395,31 +397,71 @@ async function submitApprovalAction(
|
|||||||
action: 'approve' | 'reject' | 'revoke',
|
action: 'approve' | 'reject' | 'revoke',
|
||||||
row: any,
|
row: any,
|
||||||
) {
|
) {
|
||||||
|
const loadingKey = `${action}:${String(row.id)}`;
|
||||||
|
if (approvalActionLoadingKey.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
approvalActionLoadingKey.value = loadingKey;
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
approve: $t('approval.action.approve'),
|
approve: $t('approval.action.approve'),
|
||||||
reject: $t('approval.action.reject'),
|
reject: $t('approval.action.reject'),
|
||||||
revoke: $t('approval.action.revoke'),
|
revoke: $t('approval.action.revoke'),
|
||||||
};
|
};
|
||||||
const { value } = await ElMessageBox.prompt(
|
try {
|
||||||
$t('approval.placeholder.actionComment'),
|
let value = '';
|
||||||
titleMap[action],
|
try {
|
||||||
{
|
const promptResult = await ElMessageBox.prompt(
|
||||||
inputValue: '',
|
$t('approval.placeholder.actionComment'),
|
||||||
inputType: 'textarea',
|
titleMap[action],
|
||||||
},
|
{
|
||||||
);
|
inputValue: '',
|
||||||
const res = await api.post(`/api/v1/approvalInstance/${action}`, {
|
inputType: 'textarea',
|
||||||
comment: value || '',
|
},
|
||||||
instanceId: row.id,
|
);
|
||||||
});
|
value = promptResult.value || '';
|
||||||
if (res.errorCode !== 0) {
|
} catch (error) {
|
||||||
return;
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const res = await api.post(`/api/v1/approvalInstance/${action}`, {
|
||||||
|
comment: value,
|
||||||
|
instanceId: row.id,
|
||||||
|
});
|
||||||
|
if (res.errorCode !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.success($t('approval.message.actionSuccess'));
|
||||||
|
pendingPageRef.value?.reload();
|
||||||
|
processedPageRef.value?.reload();
|
||||||
|
initiatedPageRef.value?.reload();
|
||||||
|
await refreshPendingBadgeCount();
|
||||||
|
} finally {
|
||||||
|
approvalActionLoadingKey.value = '';
|
||||||
}
|
}
|
||||||
ElMessage.success($t('approval.message.actionSuccess'));
|
}
|
||||||
pendingPageRef.value?.reload();
|
|
||||||
processedPageRef.value?.reload();
|
function isApprovalActionLoading(
|
||||||
initiatedPageRef.value?.reload();
|
action: 'approve' | 'reject' | 'revoke',
|
||||||
refreshPendingBadgeCount();
|
row: any,
|
||||||
|
) {
|
||||||
|
return approvalActionLoadingKey.value === `${action}:${String(row.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubmitApprovalAction(
|
||||||
|
action: 'approve' | 'reject' | 'revoke',
|
||||||
|
row: any,
|
||||||
|
) {
|
||||||
|
const actionFlagMap = {
|
||||||
|
approve: row.canApprove,
|
||||||
|
reject: row.canReject,
|
||||||
|
revoke: row.canRevoke,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
Boolean(actionFlagMap[action]) &&
|
||||||
|
hasPermission([`/api/v1/approvalInstance/${action}`])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInstanceDetail(row: any) {
|
function openInstanceDetail(row: any) {
|
||||||
@@ -456,6 +498,10 @@ function getActionLabel(value: string) {
|
|||||||
ACTION_OPTIONS.find((item) => item.value === value)?.label || value || '-'
|
ACTION_OPTIONS.find((item) => item.value === value)?.label || value || '-'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatApplicationReason(value?: null | string) {
|
||||||
|
return String(value || '').trim() || '-';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -731,6 +777,14 @@ function getActionLabel(value: string) {
|
|||||||
:label="$t('approval.fields.summary')"
|
:label="$t('approval.fields.summary')"
|
||||||
min-width="220"
|
min-width="220"
|
||||||
/>
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicationReason')"
|
||||||
|
min-width="220"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
:label="$t('approval.fields.resourceType')"
|
:label="$t('approval.fields.resourceType')"
|
||||||
width="120"
|
width="120"
|
||||||
@@ -782,17 +836,21 @@ function getActionLabel(value: string) {
|
|||||||
{{ $t('button.view') }}
|
{{ $t('button.view') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="row.canApprove"
|
v-if="canSubmitApprovalAction('approve', row)"
|
||||||
link
|
link
|
||||||
type="success"
|
type="success"
|
||||||
|
:disabled="Boolean(approvalActionLoadingKey)"
|
||||||
|
:loading="isApprovalActionLoading('approve', row)"
|
||||||
@click="submitApprovalAction('approve', row)"
|
@click="submitApprovalAction('approve', row)"
|
||||||
>
|
>
|
||||||
{{ $t('approval.action.approve') }}
|
{{ $t('approval.action.approve') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="row.canReject"
|
v-if="canSubmitApprovalAction('reject', row)"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
|
:disabled="Boolean(approvalActionLoadingKey)"
|
||||||
|
:loading="isApprovalActionLoading('reject', row)"
|
||||||
@click="submitApprovalAction('reject', row)"
|
@click="submitApprovalAction('reject', row)"
|
||||||
>
|
>
|
||||||
{{ $t('approval.action.reject') }}
|
{{ $t('approval.action.reject') }}
|
||||||
@@ -903,6 +961,14 @@ function getActionLabel(value: string) {
|
|||||||
:label="$t('approval.fields.summary')"
|
:label="$t('approval.fields.summary')"
|
||||||
min-width="220"
|
min-width="220"
|
||||||
/>
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicationReason')"
|
||||||
|
min-width="220"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
:label="$t('approval.fields.resourceType')"
|
:label="$t('approval.fields.resourceType')"
|
||||||
width="120"
|
width="120"
|
||||||
@@ -1052,6 +1118,14 @@ function getActionLabel(value: string) {
|
|||||||
:label="$t('approval.fields.summary')"
|
:label="$t('approval.fields.summary')"
|
||||||
min-width="220"
|
min-width="220"
|
||||||
/>
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicationReason')"
|
||||||
|
min-width="220"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
:label="$t('approval.fields.resourceType')"
|
:label="$t('approval.fields.resourceType')"
|
||||||
width="120"
|
width="120"
|
||||||
@@ -1098,8 +1172,11 @@ function getActionLabel(value: string) {
|
|||||||
{{ $t('button.view') }}
|
{{ $t('button.view') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="row.canRevoke"
|
v-if="canSubmitApprovalAction('revoke', row)"
|
||||||
link
|
link
|
||||||
|
type="warning"
|
||||||
|
:disabled="Boolean(approvalActionLoadingKey)"
|
||||||
|
:loading="isApprovalActionLoading('revoke', row)"
|
||||||
@click="submitApprovalAction('revoke', row)"
|
@click="submitApprovalAction('revoke', row)"
|
||||||
>
|
>
|
||||||
{{ $t('approval.action.revoke') }}
|
{{ $t('approval.action.revoke') }}
|
||||||
|
|||||||
Reference in New Issue
Block a user